From 6fdf30b6865944e886c5d21ae646e382ec4e2a26 Mon Sep 17 00:00:00 2001 From: Florence Haudin Date: Mon, 3 Aug 2026 10:36:27 +0200 Subject: [PATCH 1/2] Add radiacode-extension to support a Radiacode 110 detector as bluetooth device. --- .../jupyterlab_web_bluetooth_manager.spec.ts | 28 ++++++++-- src/index.ts | 4 +- src/radiacode-extension/detector.ts | 8 +++ src/radiacode-extension/index.ts | 53 +++++++++++++++++++ 4 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 src/radiacode-extension/detector.ts create mode 100644 src/radiacode-extension/index.ts diff --git a/src/__tests__/jupyterlab_web_bluetooth_manager.spec.ts b/src/__tests__/jupyterlab_web_bluetooth_manager.spec.ts index 9d8e2cc..ce9589d 100644 --- a/src/__tests__/jupyterlab_web_bluetooth_manager.spec.ts +++ b/src/__tests__/jupyterlab_web_bluetooth_manager.spec.ts @@ -1,9 +1,31 @@ -/** - * Example of [Jest](https://jestjs.io/docs/getting-started) unit tests - */ +import { DropDownRegistry } from '../bluetooth-extension'; +import { BluetoothManager } from '../bluetooth/BluetoothManager'; describe('bluetooh-manager', () => { it('should be tested', () => { expect(1 + 1).toEqual(2); }); + + it('adds newly registered device types to the dialog dropdown', () => { + const bluetoothManager = new BluetoothManager(); + const dropdown = new DropDownRegistry(bluetoothManager.deviceTypeRegistry); + + expect(dropdown.node.querySelectorAll('option')).toHaveLength(0); + + bluetoothManager.deviceTypeRegistry.add({ + deviceType: 'Radiacode® 110', + options: { + acceptAllDevices: false, + filters: [{ services: ['e63215e5-7003-49d8-96b0-b024798fb901'] }], + optionalServices: ['e63215e5-7003-49d8-96b0-b024798fb901'] + }, + factory: async () => undefined as never + }); + + const options = Array.from(dropdown.node.querySelectorAll('option')).map( + option => option.textContent + ); + + expect(options).toContain('Radiacode® 110'); + }); }); diff --git a/src/index.ts b/src/index.ts index 1cf27a4..d4730b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ import BluetoothExtensionPlugins from './bluetooth-extension'; import MoveHubExtensionPlugins from './movehub-extension'; +import RadiacodeDectectorExtensionPlugins from './radiacode-extension'; -const plugins = BluetoothExtensionPlugins.concat(MoveHubExtensionPlugins); +const plugins = BluetoothExtensionPlugins.concat(MoveHubExtensionPlugins, RadiacodeDectectorExtensionPlugins); export default plugins; + \ No newline at end of file diff --git a/src/radiacode-extension/detector.ts b/src/radiacode-extension/detector.ts new file mode 100644 index 0000000..886bd59 --- /dev/null +++ b/src/radiacode-extension/detector.ts @@ -0,0 +1,8 @@ +import { BluetoothManager } from "../bluetooth/BluetoothManager"; + + +export class RadiacodeDetector extends BluetoothManager.Device { + constructor(native: BluetoothDevice) { + super(native); + } +} \ No newline at end of file diff --git a/src/radiacode-extension/index.ts b/src/radiacode-extension/index.ts new file mode 100644 index 0000000..bcd1876 --- /dev/null +++ b/src/radiacode-extension/index.ts @@ -0,0 +1,53 @@ +import { + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; + +import { + IDeviceTypeRegistryItem, + IBluetoothManager, + BluetoothManager +} from '../bluetooth/BluetoothManager'; +import { RadiacodeDetector } from './detector'; +export const radiacodeServiceUUID = 'e63215e5-7003-49d8-96b0-b024798fb901' +export const radiacodeReadCharacteristicUUID = 'e63215e6-7003-49d8-96b0-b024798fb901'; +export const radiacodeWriteCharacteristicUUID = 'e63215e6-7003-49d8-96b0-b024798fb901'; +export const radiacodeRegistryItem: IDeviceTypeRegistryItem = { + deviceType: 'Radiacode® 110', + options: { + acceptAllDevices: false, + filters: [{ services: [radiacodeServiceUUID] }], + optionalServices: [radiacodeServiceUUID] + }, + factory: async (native: BluetoothDevice) => { + const device = new RadiacodeDetector(native); + return device; + } +}; + +const RadiacodeDetectorRegisterPlugin: JupyterFrontEndPlugin = { + id: 'bluetooth-manager:radiacode-detector-register-plugin', + description: 'Registers the radiacode detector device and provides a factory.', + requires: [IBluetoothManager], + autoStart: true, + activate: ( + app: JupyterFrontEnd, + bluetoothManager: BluetoothManager + ): void => { + console.log('JupyterLab radiacode-detector-register plugin is activated!'); + bluetoothManager.deviceTypeRegistry.added.connect( + async (sender, radiacodeRegistryItem) => { + console.warn( + `New item from category ${radiacodeRegistryItem.deviceType} is added to the deviceType registry.` + ); + } + ); + bluetoothManager.deviceTypeRegistry.add(radiacodeRegistryItem); + } +}; + + +const RadiacodeDetectorExtensionPlugins: JupyterFrontEndPlugin[] = [ + RadiacodeDetectorRegisterPlugin, +]; +export default RadiacodeDetectorExtensionPlugins; From 7e9b8e688ce012d0721417a39864f1b8db0b45ec Mon Sep 17 00:00:00 2001 From: Florence Haudin Date: Wed, 5 Aug 2026 10:19:54 +0200 Subject: [PATCH 2/2] Add logics to try to receive and send information from the device. Vendor radiacode.js from https://github.com/bsharper/radiacode.js and try to use it to decode the information received from the device. --- src/index.ts | 6 +- src/radiacode-extension/detector.ts | 88 +- src/radiacode-extension/index.ts | 15 +- src/radiacode-extension/protocol/parsing.ts | 343 +++ .../protocol/protocolManager.ts | 294 ++ src/radiacode-extension/radiacode.js | 2493 +++++++++++++++++ src/radiacode-extension/type.ts | 8 + 7 files changed, 3235 insertions(+), 12 deletions(-) create mode 100644 src/radiacode-extension/protocol/parsing.ts create mode 100644 src/radiacode-extension/protocol/protocolManager.ts create mode 100644 src/radiacode-extension/radiacode.js create mode 100644 src/radiacode-extension/type.ts diff --git a/src/index.ts b/src/index.ts index d4730b6..1baa691 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,8 @@ import BluetoothExtensionPlugins from './bluetooth-extension'; import MoveHubExtensionPlugins from './movehub-extension'; import RadiacodeDectectorExtensionPlugins from './radiacode-extension'; -const plugins = BluetoothExtensionPlugins.concat(MoveHubExtensionPlugins, RadiacodeDectectorExtensionPlugins); +const plugins = BluetoothExtensionPlugins.concat( + MoveHubExtensionPlugins, + RadiacodeDectectorExtensionPlugins +); export default plugins; - \ No newline at end of file diff --git a/src/radiacode-extension/detector.ts b/src/radiacode-extension/detector.ts index 886bd59..742052f 100644 --- a/src/radiacode-extension/detector.ts +++ b/src/radiacode-extension/detector.ts @@ -1,8 +1,88 @@ -import { BluetoothManager } from "../bluetooth/BluetoothManager"; +import { BluetoothManager } from '../bluetooth/BluetoothManager'; +import { DeviceInfo } from './type'; +import { buildShortIdentifier } from '../bluetooth-extension'; +import { + radiacodeNotifyCharacteristicUUID, + radiacodeServiceUUID, + radiacodeWriteCharacteristicUUID +} from '.'; +//import { Buffer } from '../movehub-extension/moveHub/helpers/buffer'; +import { ProtocolManager } from './protocol/protocolManager'; +import { COMMAND } from './protocol/parsing'; +export const defaultDeviceInfo: DeviceInfo = { + err: '', + connected: false, + batteryLevel: undefined, + identifier: '', + primaryMACAddress: '' +}; export class RadiacodeDetector extends BluetoothManager.Device { - constructor(native: BluetoothDevice) { - super(native); + public deviceInfo: DeviceInfo; + public notifyCharacteristic: BluetoothRemoteGATTCharacteristic | undefined; + public writeCharacteristic: BluetoothRemoteGATTCharacteristic | undefined; + public protocolManager: ProtocolManager; + + constructor(native: BluetoothDevice) { + super(native); + this.deviceInfo = defaultDeviceInfo; + } + + async initDevice(): Promise { + this.connected.connect(async (sender, connected: boolean) => { + if (connected) { + this.deviceInfo.connected = connected; + this.isConnected = connected; + } + console.warn('The connection state is', this.isConnected); + this.deviceInfo.identifier = buildShortIdentifier(this.native); + }); + this.disconnected.connect(async (sender, disconnected: boolean) => { + if (disconnected) { + this.deviceInfo.connected = false; + this.isConnected = false; + } + console.warn('The connection state is', this.isConnected); + }); + + this.writeCharacteristic = await this.getCharacteristic( + radiacodeServiceUUID, + radiacodeWriteCharacteristicUUID + ); + + this.notifyCharacteristic = await this.getCharacteristic( + radiacodeServiceUUID, + radiacodeNotifyCharacteristicUUID + ); + this.protocolManager = new ProtocolManager( + this.notifyCharacteristic, + this.writeCharacteristic + ); + + await this.protocolManager.init(); + + /* this.protocolManager.readData(new Uint8Array([0x11, 0x05, 0x00, 0x00]), COMMAND.RD_VIRT_SFR, "brightness").then((brightness) => { + console.log(`Read ${brightness} command sent successfully.`); + }).catch(error => { + console.error('Error sending read brightness command:', error); + });*/ + + /*this.protocolManager.readData(new Uint8Array([0x24, 0x08, 0x00, 0x00]), COMMAND.RD_VIRT_SFR, "temperature").then((temperature) => { + console.log(`Read ${temperature} command sent successfully.`); + }).catch(error => { + console.error('Error sending read temperature command:', error); + });*/ + + try { + const brightness = await this.protocolManager.readData( + new Uint8Array([0x11, 0x05, 0x00, 0x00]), + COMMAND.RD_VIRT_SFR, + 'brightness' + ); + console.log(`Read ${brightness} command returned successfully.`); + } catch (error) { + console.error('Error sending read brightness command:', error); } -} \ No newline at end of file + } +} diff --git a/src/radiacode-extension/index.ts b/src/radiacode-extension/index.ts index bcd1876..6d68cbb 100644 --- a/src/radiacode-extension/index.ts +++ b/src/radiacode-extension/index.ts @@ -9,9 +9,11 @@ import { BluetoothManager } from '../bluetooth/BluetoothManager'; import { RadiacodeDetector } from './detector'; -export const radiacodeServiceUUID = 'e63215e5-7003-49d8-96b0-b024798fb901' -export const radiacodeReadCharacteristicUUID = 'e63215e6-7003-49d8-96b0-b024798fb901'; -export const radiacodeWriteCharacteristicUUID = 'e63215e6-7003-49d8-96b0-b024798fb901'; +export const radiacodeServiceUUID = 'e63215e5-7003-49d8-96b0-b024798fb901'; +export const radiacodeNotifyCharacteristicUUID = + 'e63215e7-7003-49d8-96b0-b024798fb901'; +export const radiacodeWriteCharacteristicUUID = + 'e63215e6-7003-49d8-96b0-b024798fb901'; export const radiacodeRegistryItem: IDeviceTypeRegistryItem = { deviceType: 'Radiacode® 110', options: { @@ -21,13 +23,15 @@ export const radiacodeRegistryItem: IDeviceTypeRegistryItem = { }, factory: async (native: BluetoothDevice) => { const device = new RadiacodeDetector(native); + await device.initDevice(); return device; } }; const RadiacodeDetectorRegisterPlugin: JupyterFrontEndPlugin = { id: 'bluetooth-manager:radiacode-detector-register-plugin', - description: 'Registers the radiacode detector device and provides a factory.', + description: + 'Registers the radiacode detector device and provides a factory.', requires: [IBluetoothManager], autoStart: true, activate: ( @@ -46,8 +50,7 @@ const RadiacodeDetectorRegisterPlugin: JupyterFrontEndPlugin = { } }; - const RadiacodeDetectorExtensionPlugins: JupyterFrontEndPlugin[] = [ - RadiacodeDetectorRegisterPlugin, + RadiacodeDetectorRegisterPlugin ]; export default RadiacodeDetectorExtensionPlugins; diff --git a/src/radiacode-extension/protocol/parsing.ts b/src/radiacode-extension/protocol/parsing.ts new file mode 100644 index 0000000..eabb3e7 --- /dev/null +++ b/src/radiacode-extension/protocol/parsing.ts @@ -0,0 +1,343 @@ +/** + * Extracted parsing utilities from radiacode.js from https://github.com/bsharper/radiacode.js + * Handles binary data parsing for RadiaCode device protocol + */ + +/** + * BytesBuffer - A utility class for handling binary data similar to the Python version + */ +export class BytesBuffer { + data: Uint8Array; + position: number = 0; + + constructor(data: Uint8Array | ArrayBuffer) { + if (data instanceof ArrayBuffer) { + this.data = new Uint8Array(data); + } else { + this.data = data; + } + this.position = 0; + } + + read(length: number): Uint8Array { + if (this.position + length > this.data.length) { + throw new Error('Insufficient data in buffer'); + } + const result = this.data.slice(this.position, this.position + length); + this.position += length; + return result; + } + + readUint8(): number { + const result = this.data[this.position]; + this.position += 1; + return result; + } + + readInt8(): number { + const result = this.data[this.position]; + this.position += 1; + return result > 127 ? result - 256 : result; + } + + readUint16LE(): number { + const result = + (this.data[this.position + 1] << 8) | this.data[this.position]; + this.position += 2; + return result; + } + + readInt16LE(): number { + const result = + (this.data[this.position + 1] << 8) | this.data[this.position]; + this.position += 2; + return result > 32767 ? result - 65536 : result; + } + + readUint32LE(): number { + const result = + (this.data[this.position + 3] << 24) | + (this.data[this.position + 2] << 16) | + (this.data[this.position + 1] << 8) | + this.data[this.position]; + this.position += 4; + return result >>> 0; // Convert to unsigned + } + + readInt32LE(): number { + const result = + (this.data[this.position + 3] << 24) | + (this.data[this.position + 2] << 16) | + (this.data[this.position + 1] << 8) | + this.data[this.position]; + this.position += 4; + return result; + } + + readFloatLE(): number { + const buffer = new ArrayBuffer(4); + const view = new DataView(buffer); + for (let i = 0; i < 4; i++) { + view.setUint8(i, this.data[this.position + i]); + } + this.position += 4; + return view.getFloat32(0, true); // true = little endian + } + + readString(): string { + const length = this.readUint8(); + const bytes = this.read(length); + const decoder = new TextDecoder('ascii'); + return decoder.decode(bytes); + } + + remaining(): number { + return this.data.length - this.position; + } + + size(): number { + return this.data.length - this.position; + } + + getBytes(): Uint8Array { + return this.data; + } +} + +/** + * Command types (from Python implementation) + */ +export enum COMMAND { + GET_STATUS = 0x0005, + SET_EXCHANGE = 0x0007, + GET_VERSION = 0x000a, + GET_SERIAL = 0x000b, + FW_IMAGE_GET_INFO = 0x0012, + FW_SIGNATURE = 0x0101, + RD_HW_CONFIG = 0x0807, + RD_VIRT_SFR = 0x0824, + WR_VIRT_SFR = 0x0825, + RD_VIRT_STRING = 0x0826, + WR_VIRT_STRING = 0x0827, + RD_VIRT_SFR_BATCH = 0x082a, + WR_VIRT_SFR_BATCH = 0x082b, + RD_FLASH = 0x081c, + SET_TIME = 0x0a04 +} + +/** + * Virtual String command IDs + */ +export enum VS { + CONFIGURATION = 2, + SERIAL_NUMBER = 8, + TEXT_MESSAGE = 0xf, + DATA_BUF = 0x100, + SFR_FILE = 0x101, + SPECTRUM = 0x200, + SPEC_ACCUM = 0x201, + ENERGY_CALIB = 0x202 +} + +/** + * Virtual Special Function Register IDs (VSFR) + */ +export enum VSFR { + DEVICE_CTRL = 0x0500, + DEVICE_LANG = 0x0502, + DEVICE_ON = 0x0503, + DEVICE_TIME = 0x0504, + + DISP_CTRL = 0x0510, + DISP_BRT = 0x0511, + DISP_CONTR = 0x0512, + DISP_OFF_TIME = 0x0513, + DISP_ON = 0x0514, + DISP_DIR = 0x0515, + DISP_BACKLT_ON = 0x0516, + + SOUND_CTRL = 0x0520, + SOUND_VOL = 0x0521, + SOUND_ON = 0x0522, + SOUND_BUTTON = 0x0523, + + VIBRO_CTRL = 0x0530, + VIBRO_ON = 0x0531, + + LEDS_CTRL = 0x0540, + LED0_BRT = 0x0541, + LED1_BRT = 0x0542, + LED2_BRT = 0x0543, + LED3_BRT = 0x0544, + LEDS_ON = 0x0545, + + ALARM_MODE = 0x05e0, + PLAY_SIGNAL = 0x05e1, + + MS_CTRL = 0x0600, + MS_MODE = 0x0601, + MS_SUB_MODE = 0x0602, + MS_RUN = 0x0603, + + BLE_TX_PWR = 0x0700, + + DR_LEV1_uR_h = 0x8000, + DR_LEV2_uR_h = 0x8001, + DS_LEV1_100uR = 0x8002, + DS_LEV2_100uR = 0x8003, + DS_UNITS = 0x8004, + CPS_FILTER = 0x8005, + RAW_FILTER = 0x8006, + DOSE_RESET = 0x8007, + CR_LEV1_cp10s = 0x8008, + CR_LEV2_cp10s = 0x8009, + + USE_nSv_h = 0x800c, + + CHN_TO_keV_A0 = 0x8010, + CHN_TO_keV_A1 = 0x8011, + CHN_TO_keV_A2 = 0x8012, + CR_UNITS = 0x8013, + DS_LEV1_uR = 0x8014, + DS_LEV2_uR = 0x8015, + + CPS = 0x8020, + DR_uR_h = 0x8021, + DS_uR = 0x8022, + + TEMP_degC = 0x8024, + ACC_X = 0x8025, + ACC_Y = 0x8026, + ACC_Z = 0x8027, + OPT = 0x8028, + + RAW_TEMP_degC = 0x8033, + TEMP_UP_degC = 0x8034, + TEMP_DN_degC = 0x8035, + + VBIAS_mV = 0xc000, + COMP_LEV = 0xc001, + CALIB_MODE = 0xc002, + DPOT_RDAC = 0xc004, + DPOT_RDAC_EEPROM = 0xc005, + DPOT_TOLER = 0xc006, + + SYS_MCU_ID0 = 0xffff0000, + SYS_MCU_ID1 = 0xffff0001, + SYS_MCU_ID2 = 0xffff0002, + + SYS_DEVICE_ID = 0xffff0005, + SYS_SIGNATURE = 0xffff0006, + SYS_RX_SIZE = 0xffff0007, + SYS_TX_SIZE = 0xffff0008, + SYS_BOOT_VERSION = 0xffff0009, + SYS_TARGET_VERSION = 0xffff000a, + SYS_STATUS = 0xffff000b, + SYS_MCU_VREF = 0xffff000c, + SYS_MCU_TEMP = 0xffff000d, + SYS_FW_VER_BT = 0xffff010 +} + +/** + * Control flags used for sound/vibration controllers + */ +export enum CTRL { + BUTTONS = 1 << 0, + CLICKS = 1 << 1, + DOSE_RATE_ALARM_1 = 1 << 2, + DOSE_RATE_ALARM_2 = 1 << 3, + DOSE_RATE_OUT_OF_SCALE = 1 << 4, + DOSE_ALARM_1 = 1 << 5, + DOSE_ALARM_2 = 1 << 6, + DOSE_OUT_OF_SCALE = 1 << 7 +} + +/** + * Display direction enum + */ +export enum DisplayDirection { + AUTO = 0, + RIGHT = 1, + LEFT = 2 +} + +/** + * Utility function to create a DataView from array buffer at offset + */ +export function createDataView(data: Uint8Array, offset: number = 0): DataView { + return new DataView( + data.buffer, + data.byteOffset + offset, + data.byteLength - offset + ); +} + +/** + * Helper to build a packet header + * @param command Command ID + * @param sequence Sequence number (0x80-0x9F) + * @returns Uint8Array with command header + */ +export function buildCommandHeader( + command: number, + sequence: number +): Uint8Array { + const header = new Uint8Array(4); + const view = new DataView(header.buffer); + view.setUint16(0, command, true); // Command (little-endian) + view.setUint8(2, 0); // Reserved + view.setUint8(3, sequence); // Sequence number + return header; +} + +/** + * Helper to build a complete packet with payload length header + * @param command Command ID + * @param sequence Sequence number + * @param args Command arguments (optional) + * @returns Complete packet as Uint8Array + */ +export function buildPacket( + command: number, + sequence: number, + args?: Uint8Array +): Uint8Array { + const argsBytes = args || new Uint8Array(0); + const payloadLength = 4 + argsBytes.length; // header + args + + const packet = new Uint8Array(4 + payloadLength); + const view = new DataView(packet.buffer); + + // Payload length (little-endian) + view.setUint32(0, payloadLength, true); + + // Command header + const header = buildCommandHeader(command, sequence); + packet.set(header, 4); + + // Command data + packet.set(argsBytes, 8); + + return packet; +} + +/** + * Parse response packet and extract payload + * @param response Uint8Array response data + * @returns BytesBuffer positioned at start of payload (after header) + */ +export function parseResponsePacket(response: Uint8Array): BytesBuffer { + const buffer = new BytesBuffer(response); + + // Read and validate header + const commandEcho = buffer.readUint16LE(); + const reserved = buffer.readUint8(); + const sequence = buffer.readUint8(); + + console.log( + `Received response: commandEcho=0x${commandEcho.toString(16)}, reserved=${reserved}, sequence=0x${sequence.toString(16)}` + ); + + // Remaining data is the response payload + return buffer; +} diff --git a/src/radiacode-extension/protocol/protocolManager.ts b/src/radiacode-extension/protocol/protocolManager.ts new file mode 100644 index 0000000..1fee93c --- /dev/null +++ b/src/radiacode-extension/protocol/protocolManager.ts @@ -0,0 +1,294 @@ +/* This class uses protocol information from at https://github.com/cdump/radiacode/blob/master/src/radiacode*/ + +//import { COMMAND, VSFR } from './protocol'; +import { BytesBuffer, parseResponsePacket } from './parsing'; + +export class ProtocolManager { + notifyCharacteristic: BluetoothRemoteGATTCharacteristic | undefined; + writeCharacteristic: BluetoothRemoteGATTCharacteristic | undefined; + sequence: any = 0x80; // Initialize sequence to 0x80 + + constructor( + notifyCharacteristic: BluetoothRemoteGATTCharacteristic | undefined, + writeCharacteristic: BluetoothRemoteGATTCharacteristic | undefined + ) { + this.notifyCharacteristic = notifyCharacteristic; + this.writeCharacteristic = writeCharacteristic; + } + + // public init that callers can await + public async init(): Promise { + await this.addListeners(); + } + + private async addListeners(): Promise { + if (!this.notifyCharacteristic) { + throw new Error('Notification characteristic is not available.'); + } + + if (!this.writeCharacteristic) { + throw new Error('Write characteristic is not available.'); + } + + await this.notifyCharacteristic.startNotifications(); + this.notifyCharacteristic.addEventListener( + 'characteristicvaluechanged', + (event: any) => this.handleNotification(event as Event) + ); + } + + /** + * Handle incoming notifications from the device + * (Currently logs the data; can be extended for buffering if needed) + */ + private handleNotification(ev: Event): void { + const characteristic = ev.target as BluetoothRemoteGATTCharacteristic; + if (!characteristic || !characteristic.value) return; + + const data = new Uint8Array( + characteristic.value.buffer, + characteristic.value.byteOffset, + characteristic.value.byteLength + ); + + console.log('notification received:', data); + } + + /** + * Write a packet to the device in chunks (max 18 bytes per chunk) + * @param packet Uint8Array packet to write + */ + async writePacket(packet: Uint8Array): Promise { + if (!this.writeCharacteristic) { + throw new Error('Write characteristic is not available.'); + } + + const maxPacketSize = 18; + for (let offset = 0; offset < packet.length; offset += maxPacketSize) { + const chunk = packet.slice(offset, offset + maxPacketSize); + console.log( + 'TX:', + [...chunk].map(b => b.toString(16).padStart(2, '0')).join(' ') + ); + await this.writeCharacteristic.writeValue(chunk); + console.log('chunk written:', chunk); + } + } + + /** + * Build a complete packet with length header, command, and data + * @param command Command ID + * @param data Command data/arguments + * @returns Complete packet as Uint8Array + */ + buildPacket( + command: number, + data: Uint8Array = new Uint8Array() + ): Uint8Array { + const payloadLength = 4 + data.length; // 4 bytes for header + data + + const packet = new Uint8Array(4 + payloadLength); + const view = new DataView(packet.buffer); + + // Payload length (little-endian) + view.setUint32(0, payloadLength, true); + + // Command (little-endian) + view.setUint16(4, command, true); + + // Reserved byte + view.setUint8(6, 0); + + // Sequence number + view.setUint8(7, this.sequence); + + // Command data + packet.set(data, 8); + + // Increment sequence for next request (wraps at 32 values: 0x80-0x9F) + this.sequence = 0x80 + ((this.sequence - 0x80 + 1) % 32); + + return packet; + } + + /** + * Read data from a device register + * Sends a read command and waits for the response, then parses it + * @param register Register data to read + * @param command Command type (e.g., COMMAND.RD_VIRT_SFR) + * @param dataName Name of the data being read (for logging) + * @returns Parsed value from the response + */ + async readData( + register: Uint8Array, + command: number, + dataName: string + ): Promise { + if (!this.notifyCharacteristic) { + throw new Error('Notification characteristic is not available.'); + } + if (!this.writeCharacteristic) { + throw new Error('Write characteristic is not available.'); + } + + const packet = this.buildPacket(command, register); + + // Promise that resolves when we receive a complete response + const responsePromise = new Promise((resolve, reject) => { + const handler = (event: Event) => { + const characteristic = + event.target as BluetoothRemoteGATTCharacteristic; + if (!characteristic || !characteristic.value) { + return; + } + + const response = new Uint8Array( + characteristic.value.buffer, + characteristic.value.byteOffset, + characteristic.value.byteLength + ); + + // Parse the response packet and extract payload + try { + const buffer = parseResponsePacket(response); + + // Cleanup + characteristic.removeEventListener( + 'characteristicvaluechanged', + handler + ); + clearTimeout(timeoutId); + resolve(buffer); + } catch (error) { + characteristic.removeEventListener( + 'characteristicvaluechanged', + handler + ); + clearTimeout(timeoutId); + reject(error); + } + }; + + // Register the handler for this read + this.notifyCharacteristic!.addEventListener( + 'characteristicvaluechanged', + handler + ); + + // Timeout after 5 seconds + const timeoutId = setTimeout(() => { + try { + this.notifyCharacteristic!.removeEventListener( + 'characteristicvaluechanged', + handler + ); + } catch (e) {} + reject(new Error('Timeout waiting for device response')); + }, 5000); + }); + + // Send the request packet + await this.writePacket(packet); + + // Wait for the response + const responseBuffer = await responsePromise; + + // Parse response - extract the value (last 4 bytes as uint32 LE) + // Adjust parsing based on your actual protocol response format + const view = new DataView( + responseBuffer.data.buffer, + responseBuffer.data.byteOffset, + responseBuffer.data.byteLength + ); + console.log('view:', view); + + // If the response has data after the 4-byte header, read the value + if (responseBuffer.size() >= 4) { + const value = responseBuffer.readUint32LE(); + console.log(`Radiacode ${dataName}:`, value); + return value; + } else { + throw new Error(`Unexpected response format for ${dataName}`); + } + } + + /** + * Alternative: Read data and return the complete BytesBuffer + * This gives more control over response parsing + * @param register Register data + * @param command Command type + * @param dataName Name for logging + * @returns BytesBuffer with full response data + */ + async readDataRaw( + register: Uint8Array, + command: number, + dataName: string + ): Promise { + if (!this.notifyCharacteristic) { + throw new Error('Notification characteristic is not available.'); + } + if (!this.writeCharacteristic) { + throw new Error('Write characteristic is not available.'); + } + + const packet = this.buildPacket(command, register); + + const responsePromise = new Promise((resolve, reject) => { + const handler = (event: Event) => { + const characteristic = + event.target as BluetoothRemoteGATTCharacteristic; + if (!characteristic || !characteristic.value) { + return; + } + + const response = new Uint8Array( + characteristic.value.buffer, + characteristic.value.byteOffset, + characteristic.value.byteLength + ); + + try { + const buffer = parseResponsePacket(response); + + characteristic.removeEventListener( + 'characteristicvaluechanged', + handler + ); + clearTimeout(timeoutId); + resolve(buffer); + } catch (error) { + characteristic.removeEventListener( + 'characteristicvaluechanged', + handler + ); + clearTimeout(timeoutId); + reject(error); + } + }; + + this.notifyCharacteristic!.addEventListener( + 'characteristicvaluechanged', + handler + ); + + const timeoutId = setTimeout(() => { + try { + this.notifyCharacteristic!.removeEventListener( + 'characteristicvaluechanged', + handler + ); + } catch (e) {} + reject(new Error('Timeout waiting for device response')); + }, 5000); + }); + + await this.writePacket(packet); + const responseBuffer = await responsePromise; + + console.log( + `Radiacode ${dataName}: received ${responseBuffer.size()} bytes` + ); + return responseBuffer; + } +} diff --git a/src/radiacode-extension/radiacode.js b/src/radiacode-extension/radiacode.js new file mode 100644 index 0000000..5a9cbd5 --- /dev/null +++ b/src/radiacode-extension/radiacode.js @@ -0,0 +1,2493 @@ +/*# Distributed under the terms of the MIT License. + +# This file comes from https://github.com/bsharper/radiacode.js +# +# It is licensed under the following license: +# + +MIT License + +Copyright (c) 2025 bsharper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +/** + * RadiaCode Web Library - Consolidated Implementation + * + * A complete JavaScript implementation for communicating with RadiaCode radiation detection devices. + * This consolidated version includes all transport layers and communication protocols in a single file. + * + * Features: + * - Support for both Bluetooth and USB transports + * - Real-time radiation measurements similar to Python implementation + * - Spectrum acquisition and analysis + * - Device configuration management + * - Energy calibration support + * + * Compatible with the Python RadiaCode library API for familiar usage patterns. + */ + +// ============================================================================ +// COMMON DEFINITIONS AND UTILITIES +// ============================================================================ + +// Library version +const RADIACODE_JS_VERSION = '1.1.0'; + +// Command types (from Python implementation) +const COMMAND = { + GET_STATUS: 0x0005, + SET_EXCHANGE: 0x0007, + GET_VERSION: 0x000a, + GET_SERIAL: 0x000b, + FW_IMAGE_GET_INFO: 0x0012, + FW_SIGNATURE: 0x0101, + RD_HW_CONFIG: 0x0807, + RD_VIRT_SFR: 0x0824, + WR_VIRT_SFR: 0x0825, + RD_VIRT_STRING: 0x0826, + WR_VIRT_STRING: 0x0827, + RD_VIRT_SFR_BATCH: 0x082a, + WR_VIRT_SFR_BATCH: 0x082b, + RD_FLASH: 0x081c, + SET_TIME: 0x0a04 +}; + +// Virtual String command IDs (from types.py) +const VS = { + CONFIGURATION: 2, + SERIAL_NUMBER: 8, + TEXT_MESSAGE: 0xf, + DATA_BUF: 0x100, + SFR_FILE: 0x101, + SPECTRUM: 0x200, + SPEC_ACCUM: 0x201, + ENERGY_CALIB: 0x202 +}; + +// Virtual Special Function Register IDs (VSFR) +const VSFR = { + DEVICE_CTRL: 0x0500, + DEVICE_LANG: 0x0502, + DEVICE_ON: 0x0503, + DEVICE_TIME: 0x0504, + + DISP_CTRL: 0x0510, + DISP_BRT: 0x0511, + DISP_CONTR: 0x0512, + DISP_OFF_TIME: 0x0513, + DISP_ON: 0x0514, + DISP_DIR: 0x0515, + DISP_BACKLT_ON: 0x0516, + + SOUND_CTRL: 0x0520, + SOUND_VOL: 0x0521, + SOUND_ON: 0x0522, + SOUND_BUTTON: 0x0523, + + VIBRO_CTRL: 0x0530, + VIBRO_ON: 0x0531, + + LEDS_CTRL: 0x0540, + LED0_BRT: 0x0541, + LED1_BRT: 0x0542, + LED2_BRT: 0x0543, + LED3_BRT: 0x0544, + LEDS_ON: 0x0545, + + ALARM_MODE: 0x05e0, + PLAY_SIGNAL: 0x05e1, + + MS_CTRL: 0x0600, + MS_MODE: 0x0601, + MS_SUB_MODE: 0x0602, + MS_RUN: 0x0603, + + BLE_TX_PWR: 0x0700, + + DR_LEV1_uR_h: 0x8000, + DR_LEV2_uR_h: 0x8001, + DS_LEV1_100uR: 0x8002, + DS_LEV2_100uR: 0x8003, + DS_UNITS: 0x8004, + CPS_FILTER: 0x8005, + RAW_FILTER: 0x8006, + DOSE_RESET: 0x8007, + CR_LEV1_cp10s: 0x8008, + CR_LEV2_cp10s: 0x8009, + + USE_nSv_h: 0x800c, + + CHN_TO_keV_A0: 0x8010, + CHN_TO_keV_A1: 0x8011, + CHN_TO_keV_A2: 0x8012, + CR_UNITS: 0x8013, + DS_LEV1_uR: 0x8014, + DS_LEV2_uR: 0x8015, + + CPS: 0x8020, + DR_uR_h: 0x8021, + DS_uR: 0x8022, + + TEMP_degC: 0x8024, + ACC_X: 0x8025, + ACC_Y: 0x8026, + ACC_Z: 0x8027, + OPT: 0x8028, + + RAW_TEMP_degC: 0x8033, + TEMP_UP_degC: 0x8034, + TEMP_DN_degC: 0x8035, + + VBIAS_mV: 0xc000, + COMP_LEV: 0xc001, + CALIB_MODE: 0xc002, + DPOT_RDAC: 0xc004, + DPOT_RDAC_EEPROM: 0xc005, + DPOT_TOLER: 0xc006, + + SYS_MCU_ID0: 0xffff0000, + SYS_MCU_ID1: 0xffff0001, + SYS_MCU_ID2: 0xffff0002, + + SYS_DEVICE_ID: 0xffff0005, + SYS_SIGNATURE: 0xffff0006, + SYS_RX_SIZE: 0xffff0007, + SYS_TX_SIZE: 0xffff0008, + SYS_BOOT_VERSION: 0xffff0009, + SYS_TARGET_VERSION: 0xffff000a, + SYS_STATUS: 0xffff000b, + SYS_MCU_VREF: 0xffff000c, + SYS_MCU_TEMP: 0xffff000d, + SYS_FW_VER_BT: 0xffff010 +}; + +// Control flags used for sound/vibration controllers (mirrors Python CTRL enum) +const CTRL = { + BUTTONS: 1 << 0, + CLICKS: 1 << 1, + DOSE_RATE_ALARM_1: 1 << 2, + DOSE_RATE_ALARM_2: 1 << 3, + DOSE_RATE_OUT_OF_SCALE: 1 << 4, + DOSE_ALARM_1: 1 << 5, + DOSE_ALARM_2: 1 << 6, + DOSE_OUT_OF_SCALE: 1 << 7 +}; + +// Display direction enum (mirrors Python DisplayDirection) +const DisplayDirection = { + AUTO: 0, + RIGHT: 1, + LEFT: 2 +}; + +// VSFR data format specifications (format string for data type) +const VSFR_FORMATS = { + [VSFR.DEVICE_CTRL]: 'I', // uint32 + [VSFR.DEVICE_LANG]: 'I', // uint32 + [VSFR.DEVICE_ON]: 'I', // uint32 + [VSFR.DEVICE_TIME]: 'I', // uint32 + + [VSFR.DISP_CTRL]: 'I', // uint32 + [VSFR.DISP_BRT]: 'I', // uint32 + [VSFR.DISP_CONTR]: 'I', // uint32 + [VSFR.DISP_OFF_TIME]: 'I', // uint32 + [VSFR.DISP_ON]: 'I', // uint32 + [VSFR.DISP_DIR]: 'I', // uint32 + [VSFR.DISP_BACKLT_ON]: 'I', // uint32 + + [VSFR.SOUND_CTRL]: 'I', // uint32 + [VSFR.SOUND_VOL]: 'I', // uint32 + [VSFR.SOUND_ON]: 'I', // uint32 + [VSFR.SOUND_BUTTON]: 'I', // uint32 + + [VSFR.VIBRO_CTRL]: 'I', // uint32 + [VSFR.VIBRO_ON]: 'I', // uint32 + + [VSFR.LEDS_CTRL]: 'I', // uint32 + [VSFR.LED0_BRT]: 'I', // uint32 + [VSFR.LED1_BRT]: 'I', // uint32 + [VSFR.LED2_BRT]: 'I', // uint32 + [VSFR.LED3_BRT]: 'I', // uint32 + [VSFR.LEDS_ON]: 'I', // uint32 + + [VSFR.ALARM_MODE]: 'I', // uint32 + [VSFR.PLAY_SIGNAL]: 'I', // uint32 + + [VSFR.MS_CTRL]: 'I', // uint32 + [VSFR.MS_MODE]: 'I', // uint32 + [VSFR.MS_SUB_MODE]: 'I', // uint32 + [VSFR.MS_RUN]: 'I', // uint32 + + [VSFR.BLE_TX_PWR]: 'I', // uint32 + + [VSFR.DR_LEV1_uR_h]: 'I', // uint32 + [VSFR.DR_LEV2_uR_h]: 'I', // uint32 + [VSFR.DS_LEV1_100uR]: 'I', // uint32 + [VSFR.DS_LEV2_100uR]: 'I', // uint32 + [VSFR.DS_UNITS]: 'I', // uint32 (boolean flag) + [VSFR.CPS_FILTER]: 'I', // uint32 + [VSFR.RAW_FILTER]: 'I', // uint32 + [VSFR.DOSE_RESET]: 'I', // uint32 + [VSFR.CR_LEV1_cp10s]: 'I', // uint32 + [VSFR.CR_LEV2_cp10s]: 'I', // uint32 + + [VSFR.USE_nSv_h]: 'I', // uint32 + + [VSFR.CHN_TO_keV_A0]: 'I', // uint32 + [VSFR.CHN_TO_keV_A1]: 'I', // uint32 + [VSFR.CHN_TO_keV_A2]: 'I', // uint32 + [VSFR.CR_UNITS]: 'I', // uint32 (boolean flag) + [VSFR.DS_LEV1_uR]: 'I', // uint32 + [VSFR.DS_LEV2_uR]: 'I', // uint32 + + [VSFR.CPS]: 'I', // uint32 + [VSFR.DR_uR_h]: 'I', // uint32 + [VSFR.DS_uR]: 'I', // uint32 + + [VSFR.TEMP_degC]: 'I', // uint32 + [VSFR.ACC_X]: 'I', // uint32 + [VSFR.ACC_Y]: 'I', // uint32 + [VSFR.ACC_Z]: 'I', // uint32 + [VSFR.OPT]: 'I', // uint32 + + [VSFR.RAW_TEMP_degC]: 'I', // uint32 + [VSFR.TEMP_UP_degC]: 'I', // uint32 + [VSFR.TEMP_DN_degC]: 'I', // uint32 + + [VSFR.VBIAS_mV]: 'I', // uint32 + [VSFR.COMP_LEV]: 'I', // uint32 + [VSFR.CALIB_MODE]: 'I', // uint32 + [VSFR.DPOT_RDAC]: 'I', // uint32 + [VSFR.DPOT_RDAC_EEPROM]: 'I', // uint32 + [VSFR.DPOT_TOLER]: 'I', // uint32 + + [VSFR.SYS_MCU_ID0]: 'I', // uint32 + [VSFR.SYS_MCU_ID1]: 'I', // uint32 + [VSFR.SYS_MCU_ID2]: 'I', // uint32 + + [VSFR.SYS_DEVICE_ID]: 'I', // uint32 + [VSFR.SYS_SIGNATURE]: 'I', // uint32 + [VSFR.SYS_RX_SIZE]: 'I', // uint32 + [VSFR.SYS_TX_SIZE]: 'I', // uint32 + [VSFR.SYS_BOOT_VERSION]: 'I', // uint32 + [VSFR.SYS_TARGET_VERSION]: 'I', // uint32 + [VSFR.SYS_STATUS]: 'I', // uint32 + [VSFR.SYS_MCU_VREF]: 'I', // uint32 + [VSFR.SYS_MCU_TEMP]: 'I', // uint32 + [VSFR.SYS_FW_VER_BT]: 'I' // uint32 +}; + +function sleep(ms) { + return new Promise(r => setTimeout(r, ms)); +} + +// ---------------------------------------------------------------------------- +// Debug logging (env-driven) +// - Uses the `debug` package when available (Node/CommonJS) +// - Falls back to a simple namespaced console logger in browsers controlled by +// localStorage.debug (same convention as `debug`), e.g.: +// localStorage.debug = 'radiacode:*' // enable all radiacode logs +// localStorage.debug = 'radiacode:usb' // only USB transport +// ---------------------------------------------------------------------------- +const createLogger = (() => { + let factory = null; + // Prefer Node/CommonJS debug if available + try { + if (typeof module !== 'undefined' && module.exports) { + // eslint-disable-next-line global-require + const dbg = require('debug'); + factory = ns => dbg(ns); + } + } catch (_) { + /* ignore */ + } + + if (!factory) { + // Browser fallback: simple namespace matcher using localStorage.debug + const getPattern = () => { + try { + if (typeof localStorage !== 'undefined') { + return localStorage.debug || localStorage.DEBUG || ''; + } + } catch (_) { + /* ignore */ + } + return ''; + }; + + const escapeRe = s => s.replace(/[|\\{}()[\]^$+?.]/g, '\\$&'); + const toRegex = glob => + new RegExp('^' + escapeRe(glob).replace(/\*/g, '.*?') + '$'); + const compile = str => { + const tokens = String(str || '') + .split(/[\s,]+/) + .filter(Boolean); + const enables = []; + const disables = []; + for (const t of tokens) { + if (t.startsWith('-')) disables.push(toRegex(t.slice(1))); + else enables.push(toRegex(t)); + } + return ns => { + if (disables.some(re => re.test(ns))) return false; + if (enables.length === 0) return false; + return enables.some(re => re.test(ns)); + }; + }; + + let matcher = compile(getPattern()); + try { + if ( + typeof window !== 'undefined' && + typeof window.addEventListener === 'function' + ) { + window.addEventListener('storage', e => { + if (!e.key) return; + const k = e.key.toLowerCase(); + if (k === 'debug') matcher = compile(getPattern()); + }); + } + } catch (_) { + /* ignore */ + } + + factory = ns => { + const fn = (...args) => { + if (!matcher(ns)) return; + console.log(`${ns}:`, ...args); + }; + // Expose enabled flag similar to `debug` + Object.defineProperty(fn, 'enabled', { + get: () => matcher(ns) + }); + return fn; + }; + } + return factory; +})(); + +// Error classes +class DeviceNotFound extends Error { + constructor(message) { + super(message); + this.name = 'DeviceNotFound'; + } +} + +class ConnectionClosed extends Error { + constructor(message) { + super(message); + this.name = 'ConnectionClosed'; + } +} + +class TimeoutError extends Error { + constructor(message) { + super(message); + this.name = 'TimeoutError'; + } +} + +class MultipleUSBReadFailure extends Error { + constructor(message) { + super(message || 'Multiple USB Read Failures'); + this.name = 'MultipleUSBReadFailure'; + } +} + +/** + * BytesBuffer - A utility class for handling binary data similar to the Python version + */ +class BytesBuffer { + constructor(data) { + this.data = new Uint8Array(data); + this.position = 0; + } + + read(length) { + if (this.position + length > this.data.length) { + throw new Error('Insufficient data in buffer'); + } + const result = this.data.slice(this.position, this.position + length); + this.position += length; + return result; + } + + readUint8() { + const result = this.data[this.position]; + this.position += 1; + return result; + } + + readUint16LE() { + const result = + (this.data[this.position + 1] << 8) | this.data[this.position]; + this.position += 2; + return result; + } + + readUint32LE() { + const result = + (this.data[this.position + 3] << 24) | + (this.data[this.position + 2] << 16) | + (this.data[this.position + 1] << 8) | + this.data[this.position]; + this.position += 4; + return result >>> 0; // Convert to unsigned + } + + readInt32LE() { + const result = + (this.data[this.position + 3] << 24) | + (this.data[this.position + 2] << 16) | + (this.data[this.position + 1] << 8) | + this.data[this.position]; + this.position += 4; + return result; + } + + readFloatLE() { + const buffer = new ArrayBuffer(4); + const view = new DataView(buffer); + for (let i = 0; i < 4; i++) { + view.setUint8(i, this.data[this.position + i]); + } + this.position += 4; + return view.getFloat32(0, true); // true = little endian + } + + // Read a length-prefixed string, like Python's unpack_string + readString() { + const length = this.readUint8(); + const bytes = this.read(length); + const decoder = new TextDecoder('ascii'); + return decoder.decode(bytes); + } + + remaining() { + return this.data.length - this.position; + } + + size() { + return this.data.length - this.position; + } + + getBytes() { + return this.data; + } +} + +// ============================================================================ +// DATA TYPES AND STRUCTURES +// ============================================================================ + +/** + * Real-time radiation measurement data from the device (matching Python RealTimeData) + */ +class RealTimeData { + constructor( + dt, + count_rate, + count_rate_err, + dose_rate, + dose_rate_err, + flags, + real_time_flags + ) { + this.dt = dt; // Timestamp of the measurement + this.count_rate = count_rate; // Number of counts per second + this.count_rate_err = count_rate_err; // Count rate error percentage + this.dose_rate = dose_rate; // Radiation dose rate measurement + this.dose_rate_err = dose_rate_err; // Dose rate measurement error percentage + this.flags = flags; // Status flags for the measurement + this.real_time_flags = real_time_flags; // Real-time status flags + } +} + +/** + * Raw radiation measurement data without error calculations + */ +class RawData { + constructor(dt, count_rate, dose_rate) { + this.dt = dt; // Timestamp of the measurement + this.count_rate = count_rate; // Number of counts per second + this.dose_rate = dose_rate; // Radiation dose rate measurement + } +} + +/** + * Database record for dose rate measurements + */ +class DoseRateDB { + constructor(dt, count, count_rate, dose_rate, dose_rate_err, flags) { + this.dt = dt; // Timestamp of the measurement + this.count = count; // Total number of counts in the measurement period + this.count_rate = count_rate; // Number of counts per second + this.dose_rate = dose_rate; // Radiation dose rate measurement + this.dose_rate_err = dose_rate_err; // Dose rate measurement error percentage + this.flags = flags; // Status flags for the measurement + } +} + +/** + * Periodic device status and accumulated dose data + */ +class RareData { + constructor(dt, duration, dose, temperature, charge_level, flags) { + this.dt = dt; // Timestamp of the status reading + this.duration = duration; // Duration of dose accumulation in seconds + this.dose = dose; // Accumulated radiation dose + this.temperature = temperature; // Device temperature reading + this.charge_level = charge_level; // Battery charge level + this.flags = flags; // Status flags + } +} + +/** + * Radiation energy spectrum measurement data (matching Python Spectrum) + */ +class Spectrum { + constructor(duration, a0, a1, a2, counts) { + this.duration = duration; // Measurement duration in seconds + this.a0 = a0; // Energy calibration coefficient (offset) + this.a1 = a1; // Energy calibration coefficient (linear) + this.a2 = a2; // Energy calibration coefficient (quadratic) + this.counts = counts; // List of counts per energy channel + } + + /** + * Convert channel number to energy using calibration coefficients + * @param {number} channel - Channel number + * @returns {number} Energy in keV + */ + channelToEnergy(channel) { + return this.a0 + this.a1 * channel + this.a2 * channel * channel; + } + + /** + * Get total counts in the spectrum + * @returns {number} Total counts + */ + getTotalCounts() { + return this.counts.reduce((sum, count) => sum + count, 0); + } + + /** + * Get energy range for all channels + * @returns {Array} Array of energies corresponding to each channel + */ + getEnergies() { + return this.counts.map((_, index) => this.channelToEnergy(index + 0.5)); + } +} + +/** + * Device alarm limits configuration (matching Python AlarmLimits) + */ +class AlarmLimits { + constructor( + l1_count_rate, + l2_count_rate, + l1_dose_rate, + l2_dose_rate, + l1_dose, + l2_dose, + dose_unit, + count_unit + ) { + this.l1_count_rate = l1_count_rate; // Level 1 count rate alarm threshold + this.l2_count_rate = l2_count_rate; // Level 2 count rate alarm threshold + this.l1_dose_rate = l1_dose_rate; // Level 1 dose rate alarm threshold + this.l2_dose_rate = l2_dose_rate; // Level 2 dose rate alarm threshold + this.l1_dose = l1_dose; // Level 1 accumulated dose alarm threshold + this.l2_dose = l2_dose; // Level 2 accumulated dose alarm threshold + this.dose_unit = dose_unit; // Dose unit ('Sv' or 'R') + this.count_unit = count_unit; // Count rate unit ('cpm' or 'cps') + } +} + +// ============================================================================ +// TRANSPORT LAYER +// ============================================================================ + +/** + * Abstract base class for RadiaCode transports + */ +class RadiaCodeTransport { + constructor() { + this.isConnected = false; + this.isClosing = false; + } + + static isSupported() { + throw new Error('isSupported() must be implemented by transport'); + } + + async connect() { + throw new Error('connect() must be implemented by transport'); + } + + async send(data) { + throw new Error('send() must be implemented by transport'); + } + + async receive(timeout = 10000) { + throw new Error('receive() must be implemented by transport'); + } + + async disconnect() { + throw new Error('disconnect() must be implemented by transport'); + } + + connected() { + return this.isConnected; + } + + cleanup() { + this.isConnected = false; + this.isClosing = false; + } +} + +// ============================================================================ +// BLUETOOTH TRANSPORT +// ============================================================================ + +// RadiaCode Bluetooth Service and Characteristic UUIDs +const RADIACODE_SERVICE_UUID = 'e63215e5-7003-49d8-96b0-b024798fb901'; +const WRITE_CHARACTERISTIC_UUID = 'e63215e6-7003-49d8-96b0-b024798fb901'; +const NOTIFY_CHARACTERISTIC_UUID = 'e63215e7-7003-49d8-96b0-b024798fb901'; + +/** + * Bluetooth transport implementation for RadiaCode devices + */ +class RadiaCodeBluetoothTransport extends RadiaCodeTransport { + constructor() { + super(); + this.device = null; + this.server = null; + this.service = null; + this.writeCharacteristic = null; + this.notifyCharacteristic = null; + + this.responseBuffer = new Uint8Array(0); + this.responseSize = 0; + this.pendingResponse = null; + this.responsePromiseResolve = null; + this.responsePromiseReject = null; + + this.maxPacketSize = 18; + + // Request queue system to prevent "GATT operation already in progress" errors + this.requestQueue = []; + this.isProcessingQueue = false; + } + + /** + * Process the request queue sequentially to avoid GATT operation conflicts + */ + async processQueue() { + if (this.isProcessingQueue) return; + + this.isProcessingQueue = true; + + while (this.requestQueue.length > 0) { + const request = this.requestQueue.shift(); + + try { + if (request.type === 'send') { + await this._sendInternal(request.data); + request.resolve(); + } else if (request.type === 'receive') { + const result = await this._receiveInternal(request.timeout); + request.resolve(result); + } + } catch (error) { + request.reject(error); + } + } + + this.isProcessingQueue = false; + } + + /** + * Queue a request to be processed sequentially + */ + queueRequest(type, data = null, timeout = 10000) { + return new Promise((resolve, reject) => { + this.requestQueue.push({ + type, + data, + timeout, + resolve, + reject + }); + + // Start processing if not already processing + this.processQueue().catch(error => { + console.error('Queue processing error:', error); + }); + }); + } + + static isSupported() { + const nav = + typeof navigator !== 'undefined' + ? navigator + : typeof globalThis !== 'undefined' + ? globalThis.navigator + : undefined; + return !!(nav && 'bluetooth' in nav); + } + + async connect() { + if (!RadiaCodeBluetoothTransport.isSupported()) { + throw new DeviceNotFound( + 'Web Bluetooth is not supported in this browser' + ); + } + + try { + this.device = await navigator.bluetooth.requestDevice({ + filters: [ + { + services: [RADIACODE_SERVICE_UUID] + } + ], + optionalServices: [RADIACODE_SERVICE_UUID] + }); + + this.device.addEventListener( + 'gattserverdisconnected', + this.onDisconnected.bind(this) + ); + this.server = await this.device.gatt.connect(); + this.service = await this.server.getPrimaryService( + RADIACODE_SERVICE_UUID + ); + this.writeCharacteristic = await this.service.getCharacteristic( + WRITE_CHARACTERISTIC_UUID + ); + this.notifyCharacteristic = await this.service.getCharacteristic( + NOTIFY_CHARACTERISTIC_UUID + ); + + await this.notifyCharacteristic.startNotifications(); + this.notifyCharacteristic.addEventListener( + 'characteristicvaluechanged', + this.handleNotification.bind(this) + ); + + this.isConnected = true; + return true; + } catch (error) { + console.error('Bluetooth connection failed:', error); + throw new DeviceNotFound( + `Failed to connect to RadiaCode device: ${error.message}` + ); + } + } + + onDisconnected() { + console.log('Bluetooth device disconnected'); + this.isConnected = false; + this.cleanup(); + + if (this.responsePromiseReject) { + this.responsePromiseReject(new ConnectionClosed('Device disconnected')); + } + } + + handleNotification(event) { + //console.log(event); + const value = new Uint8Array(event.target.value.buffer); + //console.log(value); + if (this.responseSize === 0) { + if (value.length < 4) { + console.error('Invalid response packet: too short'); + return; + } + const dataView = new DataView( + value.buffer, + value.byteOffset, + value.byteLength + ); + const payloadSize = dataView.getUint32(0, true); + this.responseSize = 4 + payloadSize; + this.responseBuffer = new Uint8Array(value.slice(4)); + } else { + const newBuffer = new Uint8Array( + this.responseBuffer.length + value.length + ); + newBuffer.set(this.responseBuffer); + newBuffer.set(value, this.responseBuffer.length); + this.responseBuffer = newBuffer; + } + + this.responseSize -= value.length; + + if (this.responseSize < 0) { + //console.error('Response size mismatch'); + if (this.responsePromiseReject) + this.responsePromiseReject(new Error('Response size mismatch')); + this.responseBuffer = new Uint8Array(0); + this.responseSize = 0; + return; + } + + if (this.responseSize === 0) { + this.pendingResponse = new BytesBuffer(this.responseBuffer); + this.responseBuffer = new Uint8Array(0); + + if (this.responsePromiseResolve) { + this.responsePromiseResolve(this.pendingResponse); + this.responsePromiseResolve = null; + this.responsePromiseReject = null; + } + } + } + + async send(data) { + return this.queueRequest('send', data); + } + + async receive(timeout = 10000) { + return this.queueRequest('receive', null, timeout); + } + + /** + * Internal send method that performs the actual GATT write operation + */ + async _sendInternal(data) { + if (!this.isConnected) throw new ConnectionClosed('Device not connected'); + if (this.isClosing) throw new ConnectionClosed('Connection is closing'); + + const requestBytes = new Uint8Array(data); + for (let pos = 0; pos < requestBytes.length; pos += this.maxPacketSize) { + const chunk = requestBytes.slice( + pos, + Math.min(pos + this.maxPacketSize, requestBytes.length) + ); + await this.writeCharacteristic.writeValue(chunk); + } + } + + /** + * Internal receive method that performs the actual response waiting + */ + async _receiveInternal(timeout = 10000) { + if (!this.isConnected) throw new ConnectionClosed('Device not connected'); + if (this.isClosing) throw new ConnectionClosed('Connection is closing'); + if (this.responsePromiseResolve) + throw new Error('Concurrent receive operations are not supported.'); + + return new Promise((resolve, reject) => { + this.responsePromiseResolve = resolve; + this.responsePromiseReject = reject; + + setTimeout(() => { + if (this.responsePromiseReject === reject) { + this.responsePromiseResolve = null; + this.responsePromiseReject = null; + reject(new TimeoutError('Response timeout')); + } + }, timeout); + }); + } + + async disconnect() { + this.isClosing = true; + try { + if (this.device && this.device.gatt.connected) { + if (this.notifyCharacteristic) { + await this.notifyCharacteristic.stopNotifications(); + } + this.server.disconnect(); + } + } catch (error) { + console.warn('Error during Bluetooth disconnect:', error); + } + this.cleanup(); + } + + cleanup() { + super.cleanup(); + this.device = null; + this.server = null; + this.service = null; + this.writeCharacteristic = null; + this.notifyCharacteristic = null; + this.responseBuffer = new Uint8Array(0); + this.responseSize = 0; + this.pendingResponse = null; + this.responsePromiseResolve = null; + this.responsePromiseReject = null; + + // Clear the request queue and reject any pending requests + while (this.requestQueue.length > 0) { + const request = this.requestQueue.shift(); + request.reject(new ConnectionClosed('Device disconnected')); + } + this.isProcessingQueue = false; + } + + connected() { + return this.isConnected && this.device && this.device.gatt.connected; + } +} + +// ============================================================================ +// USB TRANSPORT +// ============================================================================ + +// RadiaCode USB device identifiers (from Python implementation) +const RADIACODE_USB_VENDOR_ID = 0x0483; +const RADIACODE_USB_PRODUCT_ID = 0xf123; + +/** + * USB transport implementation for RadiaCode devices + */ +class RadiaCodeUSBTransport extends RadiaCodeTransport { + constructor(serialNumber = null, timeoutMs = 3000) { + super(); + this.device = null; + this.interface = null; + this.serialNumber = serialNumber; + this.timeoutMs = timeoutMs; + // Deprecated flag retained for backward-compat only; use DEBUG env/localStorage + this.usbDebug = false; + this.usbLog = createLogger('radiacode:usb'); + // Fixed endpoint numbers matching Python implementation + this.endpointOut = 1; // Write endpoint (0x1 in Python) + this.endpointIn = 1; // Read endpoint (0x81 in Python, but WebUSB uses just the number) + + // Request queue system to prevent USB operation conflicts + this.requestQueue = []; + this.isProcessingQueue = false; + } + + static isSupported() { + const nav = + typeof navigator !== 'undefined' + ? navigator + : typeof globalThis !== 'undefined' + ? globalThis.navigator + : undefined; + return !!(nav && 'usb' in nav); + } + + /** + * Process the request queue sequentially to avoid USB operation conflicts + */ + async processQueue() { + if (this.isProcessingQueue) return; + + this.isProcessingQueue = true; + + while (this.requestQueue.length > 0) { + const request = this.requestQueue.shift(); + + try { + if (request.type === 'send') { + await this._sendInternal(request.data); + request.resolve(); + } else if (request.type === 'receive') { + const result = await this._receiveInternal(request.timeout); + request.resolve(result); + } + } catch (error) { + request.reject(error); + } + } + + this.isProcessingQueue = false; + } + + /** + * Queue a request to be processed sequentially + */ + queueRequest(type, data = null, timeout = 10000) { + return new Promise((resolve, reject) => { + this.requestQueue.push({ + type, + data, + timeout, + resolve, + reject + }); + + // Start processing if not already processing + this.processQueue().catch(error => { + console.error('USB queue processing error:', error); + }); + }); + } + + async connect() { + if (!RadiaCodeUSBTransport.isSupported()) { + throw new DeviceNotFound('Web USB is not supported in this browser'); + } + + try { + const filters = [ + { + vendorId: RADIACODE_USB_VENDOR_ID, + productId: RADIACODE_USB_PRODUCT_ID + } + ]; + + if (this.serialNumber) { + filters[0].serialNumber = this.serialNumber; + } + + this.device = await navigator.usb.requestDevice({ filters }); + + await this.device.open(); + + if (this.device.configuration === null) { + await this.device.selectConfiguration(1); + } + + this.interface = this.device.configuration.interfaces[0]; + await this.device.claimInterface(this.interface.interfaceNumber); + + // Log interface configuration for debugging + const alternate = this.interface.alternates[0]; + this.usbLog('Interface configuration:', { + interfaceNumber: this.interface.interfaceNumber, + alternateCount: this.interface.alternates.length, + endpoints: alternate.endpoints.map(ep => ({ + endpointNumber: ep.endpointNumber, + direction: ep.direction, + type: ep.type, + packetSize: ep.packetSize + })) + }); + + this.endpointOut = 1; + this.endpointIn = 1; + this.usbLog( + `🔌 Using fixed endpoints: OUT=${this.endpointOut}, IN=${this.endpointIn}` + ); + + // HACK: not sure why this isn't needed, but it makes things work if I comment it out + + //await this.clearPendingData(); + + //await new Promise(resolve => setTimeout(resolve, 50)); + + this.isConnected = true; + this.usbLog('RadiaCode USB device connected successfully'); + return true; + } catch (error) { + console.error('USB connection failed:', error); + throw new DeviceNotFound( + `Failed to connect to RadiaCode USB device: ${error.message}` + ); + } + } + + async clearPendingData() { + this.usbLog(`Clearing pending data from USB device...`); + let clearedBytes = 0; + let attempts = 0; + + try { + // Match Python implementation exactly: keep reading until timeout + while (true) { + attempts++; + try { + this.usbLog(`Clear attempt ${attempts}: reading pending data...`); + + // Use 256 bytes like Python implementation, with 100ms timeout + const result = await this.device.transferIn(this.endpointIn, 256); + + if (result.status !== 'ok') { + this.usbLog( + `Clear attempt ${attempts}: USB transfer status not OK (${result.status}), stopping` + ); + break; + } + + if (result.data && result.data.byteLength > 0) { + clearedBytes += result.data.byteLength; + this.usbLog( + `Clear attempt ${attempts}: cleared ${result.data.byteLength} bytes (total: ${clearedBytes})` + ); + // Continue loop - there might be more data + } else { + this.usbLog( + `Clear attempt ${attempts}: no data received, buffer is empty` + ); + break; + } + } catch (error) { + // This is expected when no more data - equivalent to USBTimeoutError in Python + this.usbLog( + `Clear attempt ${attempts}: ${error.message} - no more data available` + ); + break; + } + } + } catch (error) { + console.log(`Clear operation failed: ${error.message}`); + } + + if (clearedBytes > 0) { + this.usbLog( + `✅ Cleared ${clearedBytes} bytes of pending data in ${attempts} attempts` + ); + } else { + this.usbLog(`✅ No pending data found (checked in ${attempts} attempts)`); + } + } + + async send(data) { + return this.queueRequest('send', data); + } + + async receive(timeout = 10000) { + return this.queueRequest('receive', null, timeout); + } + + /** + * Internal send method that performs the actual USB transfer + */ + async _sendInternal(data) { + if (!this.isConnected) throw new ConnectionClosed('Device not connected'); + if (this.isClosing) throw new ConnectionClosed('Connection is closing'); + + try { + this.usbLog( + `Sending ${data.byteLength} bytes to endpoint ${this.endpointOut}:`, + Array.from(new Uint8Array(data)) + .map(b => '0x' + b.toString(16).padStart(2, '0')) + .join(' ') + ); + const result = await this.device.transferOut(this.endpointOut, data); + + if (result.status !== 'ok') { + throw new Error(`USB transfer failed: ${result.status}`); + } + this.usbLog(`Successfully sent ${result.bytesWritten} bytes`); + + // Add a small delay after sending to ensure device processes the command + await new Promise(resolve => setTimeout(resolve, 10)); + } catch (error) { + console.error('USB send error:', error); + throw new Error(`Failed to send USB data: ${error.message}`); + } + } + + /** + * Internal receive method that performs the actual USB transfer + */ + async _receiveInternal(timeout = 10000) { + if (!this.isConnected) throw new ConnectionClosed('Device not connected'); + if (this.isClosing) throw new ConnectionClosed('Connection is closing'); + + try { + // Simplified approach matching Python implementation more closely + let trials = 0; + const maxTrials = 3; + let initialData; + + // Create a timeout promise for the entire operation + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => reject(new TimeoutError(`USB read timeout after ${timeout}ms`)), + timeout + ) + ); + + // First, try to read initial data with retries like Python implementation + while (trials < maxTrials) { + try { + this.usbLog( + `Attempting to read from endpoint ${this.endpointIn}, trial ${trials + 1}` + ); + + // Use 256 bytes like Python implementation (this is buffer size, not packet size) + const transferPromise = this.device.transferIn(this.endpointIn, 256); + const result = await Promise.race([transferPromise, timeoutPromise]); + + if (result.status !== 'ok') { + throw new Error(`USB transfer failed: ${result.status}`); + } + + initialData = new Uint8Array(result.data.buffer); + this.usbLog( + `Received ${initialData.length} bytes on trial ${trials + 1}` + ); + + if (initialData.length > 0) { + break; + } else { + trials++; + // Add a small delay before retrying + await new Promise(resolve => setTimeout(resolve, 10)); + } + } catch (error) { + if (error instanceof TimeoutError) { + throw error; // Don't retry on timeout + } + console.error(`Trial ${trials + 1} failed:`, error); + trials++; + if (trials >= maxTrials) { + throw new MultipleUSBReadFailure( + `${trials} USB Read Failures in sequence` + ); + } + // Add a small delay before retrying + await new Promise(resolve => setTimeout(resolve, 10)); + } + } + + if (trials >= maxTrials) { + throw new MultipleUSBReadFailure( + `${trials} USB Read Failures in sequence` + ); + } + + if (initialData.length < 4) { + throw new Error('USB response too short - missing length header'); + } + + const dataView = new DataView(initialData.buffer, 0, 4); + const responseLength = dataView.getUint32(0, true); + this.usbLog(`Expected response length: ${responseLength}`); + + let responseData = initialData.slice(4); + + while (responseData.length < responseLength) { + const remainingBytes = responseLength - responseData.length; + this.usbLog(`Reading additional ${remainingBytes} bytes...`); + + const readSize = Math.min(remainingBytes, 256); + const transferPromise = this.device.transferIn( + this.endpointIn, + readSize + ); + const result = await Promise.race([transferPromise, timeoutPromise]); + + if (result.status !== 'ok') { + throw new Error(`USB transfer failed: ${result.status}`); + } + + const additionalData = new Uint8Array(result.data.buffer); + const combined = new Uint8Array( + responseData.length + additionalData.length + ); + combined.set(responseData); + combined.set(additionalData, responseData.length); + responseData = combined; + } + + this.usbLog( + `Successfully received complete response: ${responseData.length} bytes` + ); + return new BytesBuffer(responseData); + } catch (error) { + console.error('USB receive error:', error); + throw error; // Re-throw the original error instead of wrapping it + } + } + + async disconnect() { + this.isClosing = true; + try { + if (this.device) { + if (this.interface) { + await this.device.releaseInterface(this.interface.interfaceNumber); + } + await this.device.close(); + } + } catch (error) { + console.warn('Error during USB disconnect:', error); + } + this.cleanup(); + } + + cleanup() { + super.cleanup(); + this.device = null; + this.interface = null; + + // Clear the request queue and reject any pending requests + while (this.requestQueue.length > 0) { + const request = this.requestQueue.shift(); + request.reject(new ConnectionClosed('Device disconnected')); + } + this.isProcessingQueue = false; + } + + connected() { + return this.isConnected && this.device && this.device.opened; + } +} + +// ============================================================================ +// DATA BUFFER DECODERS +// ============================================================================ + +/** + * Decode data buffer contents similar to Python decode_VS_DATA_BUF + */ +function decodeDataBuffer(buffer, baseTime) { + const br = new BytesBuffer(buffer); + const ret = []; + let nextSeq = null; + while (br.size() >= 7) { + const seq = br.readUint8(); + const eid = br.readUint8(); + const gid = br.readUint8(); + const tsOffset = br.readInt32LE(); + //console.log(`eid=${eid}, gid=${gid}, seq=${seq}, tsOffset=${tsOffset}`); + + const dt = new Date(baseTime.getTime() + tsOffset * 10); + + if (nextSeq !== null && nextSeq !== seq) { + //console.warn(`Sequence jump while processing eid=${eid} gid=${gid}, expect:${nextSeq}, got:${seq}`); + //continue; + //break; + } + + nextSeq = (seq + 1) % 256; + + if (eid === 0 && gid === 0) { + // GRP_RealTimeData + const count_rate = br.readFloatLE(); + const dose_rate = br.readFloatLE(); + const count_rate_err = br.readUint16LE(); + const dose_rate_err = br.readUint16LE(); + const flags = br.readUint16LE(); + const rt_flags = br.readUint8(); + + ret.push( + new RealTimeData( + dt, + count_rate, + count_rate_err / 10, + dose_rate * 10000, // HACK: this makes the dose rate match the display on the device, need to investigate + dose_rate_err / 10, + flags, + rt_flags + ) + ); + } else if (eid === 0 && gid === 1) { + // GRP_RawData + const count_rate = br.readFloatLE(); + const dose_rate = br.readFloatLE(); + + ret.push(new RawData(dt, count_rate, dose_rate)); + } else if (eid === 0 && gid === 2) { + // GRP_DoseRateDB + const count = br.readUint32LE(); + const count_rate = br.readFloatLE(); + const dose_rate = br.readFloatLE(); + const dose_rate_err = br.readUint16LE(); + const flags = br.readUint16LE(); + + ret.push( + new DoseRateDB( + dt, + count, + count_rate, + dose_rate, + dose_rate_err / 10, + flags + ) + ); + } else if (eid === 0 && gid === 3) { + // GRP_RareData + const duration = br.readUint32LE(); + const dose = br.readFloatLE(); + const temperature = br.readUint16LE(); + const charge_level = br.readUint16LE(); + const flags = br.readUint16LE(); + let rd = new RareData( + dt, + duration, + dose, + (temperature - 2000) / 100, + charge_level / 100, + flags + ); + if (typeof window !== 'undefined') { + window.latestRareData = rd; // Store latest rare data globally + } + console.log( + `RareData: dt=${dt}, duration=${duration}, dose=${dose}, temperature=${temperature}, charge_level=${charge_level}, flags=${flags}` + ); + + ret.push(rd); + } else { + // Skip unknown data types + //console.log(`Unknown data type: eid=${eid}, gid=${gid}`); + continue; + } + } + + return ret; +} + +/** + * Decode spectrum data similar to Python decode_RC_VS_SPECTRUM + */ +function decodeSpectrum(buffer, formatVersion = 1) { + const br = new BytesBuffer(buffer); + + const ts = br.readUint32LE(); + const a0 = br.readFloatLE(); + const a1 = br.readFloatLE(); + const a2 = br.readFloatLE(); + + let counts; + if (formatVersion === 0) { + counts = []; + while (br.size() > 0) { + counts.push(br.readUint32LE()); + } + } else { + // Format version 1 - compressed format + counts = []; + let last = 0; + + while (br.size() > 0) { + const u16 = br.readUint16LE(); + const cnt = (u16 >> 4) & 0x0fff; + const vlen = u16 & 0x0f; + + for (let i = 0; i < cnt; i++) { + let v; + if (vlen === 0) { + v = 0; + } else if (vlen === 1) { + v = br.readUint8(); + } else if (vlen === 2) { + v = last + br.readInt8(); + } else if (vlen === 3) { + v = last + br.readInt16LE(); + } else if (vlen === 4) { + const a = br.readUint8(); + const b = br.readUint8(); + const c = br.readInt8(); + v = last + ((c << 16) | (b << 8) | a); + } else if (vlen === 5) { + v = last + br.readInt32LE(); + } else { + throw new Error(`Unsupported vlen=${vlen} in spectrum decoder`); + } + + last = v; + counts.push(v); + } + } + } + + return new Spectrum(ts, a0, a1, a2, counts); +} + +// Add missing readInt8 and readInt16LE methods to BytesBuffer +BytesBuffer.prototype.readInt8 = function () { + const result = this.data[this.position]; + this.position += 1; + return result > 127 ? result - 256 : result; +}; + +BytesBuffer.prototype.readInt16LE = function () { + const result = (this.data[this.position + 1] << 8) | this.data[this.position]; + this.position += 2; + return result > 32767 ? result - 65536 : result; +}; + +// ============================================================================ +// DEVICE COMMUNICATION PROTOCOL +// ============================================================================ + +/** + * RadiaCode device communication protocol implementation + */ +class RadiaCodeDevice { + constructor(transport) { + this.transport = transport; + this.sequenceNumber = 0; + // Deprecated flag retained for backward-compat only; use DEBUG env/localStorage + this.debug = false; + this.baseTime = new Date(); + this.spectrumFormatVersion = 1; + this.log = createLogger('radiacode:device'); + this.commandLookup = {}; + this.deviceTextMessage = ''; + + // Command execution queue to handle multiple simultaneous API calls + this.commandQueue = []; + this.isExecutingCommand = false; + + for (const [key, value] of Object.entries(COMMAND)) { + this.commandLookup[value] = key; + } + } + + /** + * Process command queue sequentially to avoid conflicts + */ + async processCommandQueue() { + if (this.isExecutingCommand) return; + + this.isExecutingCommand = true; + + while (this.commandQueue.length > 0) { + const request = this.commandQueue.shift(); + + try { + const result = await this._executeInternal( + request.command, + request.args, + request.timeout + ); + request.resolve(result); + } catch (error) { + request.reject(error); + } + } + + this.isExecutingCommand = false; + } + + /** + * Queue a command execution request + */ + queueCommand(command, args = null, timeout = 10000) { + return new Promise((resolve, reject) => { + this.commandQueue.push({ + command, + args, + timeout, + resolve, + reject + }); + + // Start processing if not already processing + this.processCommandQueue().catch(error => { + console.error('Command queue processing error:', error); + }); + }); + } + + /** + * Execute a command on the device (queued version) + */ + async execute(command, args = null, timeout = 10000) { + return this.queueCommand(command, args, timeout); + } + + /** + * Internal execute method that performs the actual command execution + */ + async _executeInternal(command, args = null, timeout = 10000) { + { + const cmdName = this.commandLookup[command] || command; + this.log( + `Executing command: ${cmdName}, args: ${args ? args.length : 0} bytes` + ); + } + + if (!this.transport.connected()) + throw new ConnectionClosed('Device not connected'); + + const reqSeqNo = 0x80 + this.sequenceNumber; + this.sequenceNumber = (this.sequenceNumber + 1) % 32; + + const header = new ArrayBuffer(4); + const headerView = new DataView(header); + headerView.setUint16(0, command, true); + headerView.setUint8(2, 0); + headerView.setUint8(3, reqSeqNo); + const requestHeaderBytes = new Uint8Array(header); + + const argsBytes = args || new Uint8Array(0); + const requestPayload = new Uint8Array(4 + argsBytes.length); + requestPayload.set(requestHeaderBytes, 0); + requestPayload.set(argsBytes, 4); + + const fullRequest = new ArrayBuffer(4 + requestPayload.length); + const fullRequestView = new DataView(fullRequest); + fullRequestView.setUint32(0, requestPayload.length, true); + new Uint8Array(fullRequest, 4).set(requestPayload); + + this.log( + `Sending request: command=${command}, seqNo=${reqSeqNo}, argsLength=${argsBytes.length}` + ); + + await this.transport.send(new Uint8Array(fullRequest)); + + const response = await this.transport.receive(timeout); + const responseHeader = response.read(4); + + let headersMatch = true; + for (let i = 0; i < 4; i++) { + if (requestHeaderBytes[i] !== responseHeader[i]) { + headersMatch = false; + break; + } + } + + this.log( + `Received response: command=${responseHeader[0]}, seqNo=${responseHeader[3]}, length=${response.size()}` + ); + + if (!headersMatch) { + const reqHex = Array.from(requestHeaderBytes) + .map(b => `0x${b.toString(16).padStart(2, '0')}`) + .join(' '); + const resHex = Array.from(responseHeader) + .map(b => `0x${b.toString(16).padStart(2, '0')}`) + .join(' '); + throw new Error( + `Header mismatch. Sent: [${reqHex}], Received: [${resHex}]` + ); + } + + { + const cmdName = this.commandLookup[command] || command; + this.log( + `Command ${cmdName} (${command}) executed successfully, response length: ${response.size()}` + ); + // Dump response object in verbose mode; debug packages typically include toString + this.log(response); + } + + return response; + } + + /** + * Connect to the device and initialize it + */ + async connect() { + const result = await this.transport.connect(); + if (result) { + await this.initialize(); + } + return result; + } + + /** + * Initialize the device after connection + */ + async initialize() { + this.log('Initializing device...'); + const exchangeData = new Uint8Array([0x01, 0xff, 0x12, 0xff]); + await this.execute(COMMAND.SET_EXCHANGE, exchangeData); + await this.setLocalTime(new Date()); + + // Reset DEVICE_TIME to 0 (matches Python device_time(0)) so timestamps align + try { + const payload = new ArrayBuffer(8); + const view = new DataView(payload); + view.setUint32(0, VSFR.DEVICE_TIME, true); + view.setUint32(4, 0, true); + const resp = await this.execute( + COMMAND.WR_VIRT_SFR, + new Uint8Array(payload) + ); + const retcode = resp.readUint32LE(); + if (retcode !== 1) { + throw new Error(`DEVICE_TIME write failed with retcode ${retcode}`); + } + // consume any unexpected trailing bytes (firmware quirk) + if (resp.size() !== 0) { + console.warn( + `DEVICE_TIME write returned ${resp.size()} extra byte(s), discarding` + ); + while (resp.size() > 0) resp.read(1); + } + } catch (e) { + console.warn('DEVICE_TIME reset failed:', e?.message || e); + } + + this.baseTime = new Date(Date.now() + 128000); // Add 128 seconds like Python + try { + this.deviceTextMessage = await this.readVirtualString(VS.TEXT_MESSAGE); + } catch (e) { + // likely means no text message set + this.deviceTextMessage = ''; + } + this.log(`Device text message: ${this.deviceTextMessage}`); + this.log('Device initialized successfully'); + } + + /** + * Set the device's local time + */ + async setLocalTime(date) { + // Use the same format as Python: day, month, year-2000, 0, second, minute, hour, 0 + const timeData = new ArrayBuffer(8); + const view = new DataView(timeData); + + view.setUint8(0, date.getDate()); // day + view.setUint8(1, date.getMonth() + 1); // month (0-based in JS, 1-based for device) + view.setUint8(2, date.getFullYear() - 2000); // year - 2000 + view.setUint8(3, 0); // padding + view.setUint8(4, date.getSeconds()); // second + view.setUint8(5, date.getMinutes()); // minute + view.setUint8(6, date.getHours()); // hour + view.setUint8(7, 0); // padding + + await this.execute(COMMAND.SET_TIME, new Uint8Array(timeData)); + } + + /** + * Get device firmware version + */ + async getFirmwareVersion() { + const response = await this.execute(COMMAND.GET_VERSION); + + const boot_minor = response.readUint16LE(); + const boot_major = response.readUint16LE(); + const boot_date = response.readString(); + + const target_minor = response.readUint16LE(); + const target_major = response.readUint16LE(); + const target_date = response.readString().trim(); + + return { + boot: { major: boot_major, minor: boot_minor, date: boot_date }, + target: { major: target_major, minor: target_minor, date: target_date } + }; + } + + /** + * Get the user-facing serial number string + */ + async getSerialNumber() { + return await this.readVirtualString(VS.SERIAL_NUMBER); + } + + /** + * Get the low-level hardware serial number + */ + async getHardwareSerialNumber() { + const response = await this.execute(COMMAND.GET_SERIAL); + const serialLen = response.readUint32LE(); + + if (serialLen % 4 !== 0) { + throw new Error( + `Invalid serial length: ${serialLen}, must be divisible by 4` + ); + } + + const serialGroups = []; + for (let i = 0; i < serialLen / 4; i++) { + serialGroups.push(response.readUint32LE()); + } + + return serialGroups + .map(v => v.toString(16).toUpperCase().padStart(8, '0')) + .join('-'); + } + + /** + * Generic function to read a virtual string from the device + */ + async readVirtualString(commandId) { + const args = new ArrayBuffer(4); + new DataView(args).setUint32(0, commandId, true); + + const response = await this.execute( + COMMAND.RD_VIRT_STRING, + new Uint8Array(args) + ); + + const retcode = response.readUint32LE(); + const flen = response.readUint32LE(); + + if (retcode !== 1) { + throw new Error( + `readVirtualString for command ${commandId} failed with retcode ${retcode}` + ); + } + + // Firmware workaround: sometimes there is a trailing 0x00 after payload + let trailingNull = false; + if (response.size() === flen + 1) { + const peekIndex = response.position + flen; + if ( + peekIndex < response.data.length && + response.data[peekIndex] === 0x00 + ) { + trailingNull = true; + } + } + + const stringData = response.read(flen); + if (trailingNull && response.size() === 1) { + response.read(1); // consume the extra null + } + const decoder = new TextDecoder('ascii'); + this.log(`Read virtual string (command ${commandId}):`, stringData); + return decoder.decode(stringData); + } + + /** + * Read virtual string data as raw binary (for DATA_BUF, SPECTRUM, etc.) + */ + async readVirtualBinary(commandId) { + const args = new ArrayBuffer(4); + new DataView(args).setUint32(0, commandId, true); + + const response = await this.execute( + COMMAND.RD_VIRT_STRING, + new Uint8Array(args) + ); + + const retcode = response.readUint32LE(); + const flen = response.readUint32LE(); + + if (retcode !== 1) { + throw new Error( + `readVirtualBinary for command ${commandId} failed with retcode ${retcode}` + ); + } + + // Firmware workaround: sometimes there is a trailing 0x00 after payload + let trailingNull = false; + if (response.size() === flen + 1) { + const peekIndex = response.position + flen; + if ( + peekIndex < response.data.length && + response.data[peekIndex] === 0x00 + ) { + trailingNull = true; + } + } + + const dataBytes = response.read(flen); + if (trailingNull && response.size() === 1) { + response.read(1); // consume the extra null + } + return dataBytes; + } + + /** + * Get device status + */ + async getStatus() { + const response = await this.execute(COMMAND.GET_STATUS); + return { + raw: response.getBytes() + }; + } + + /** + * Get buffered measurement data from the device + * @returns {Array} Array of RealTimeData, DoseRateDB, RareData, etc. + */ + async data_buf() { + const data = await this.readVirtualBinary(VS.DATA_BUF); + //console.log(data); + return decodeDataBuffer(data, this.baseTime); + } + + /** + * Get current spectrum data from the device (matching Python spectrum()) + * @returns {Spectrum} Spectrum object with duration, calibration, and counts + */ + async spectrum() { + const data = await this.readVirtualBinary(VS.SPECTRUM); + return decodeSpectrum(data, this.spectrumFormatVersion); + } + + /** + * Get accumulated spectrum data from the device + * @returns {Spectrum} Accumulated spectrum object + */ + async spectrum_accum() { + const data = await this.readVirtualBinary(VS.SPEC_ACCUM); + return decodeSpectrum(data, this.spectrumFormatVersion); + } + + /** + * Get energy calibration coefficients + * @returns {Array} Array of [a0, a1, a2] calibration coefficients + */ + async energy_calib() { + const data = await this.readVirtualBinary(VS.ENERGY_CALIB); + const br = new BytesBuffer(data); + return [br.readFloatLE(), br.readFloatLE(), br.readFloatLE()]; + } + + /** + * Read multiple VSFRs in a single batch operation + * @param {Array} vsfrIds - Array of VSFR IDs to read + * @returns {Array} Array of decoded values + */ + async batchReadVsfrs(vsfrIds) { + const nvsfr = vsfrIds.length; + if (nvsfr === 0) { + throw new Error('No VSFRs specified'); + } + + // Create the batch read VSFR command payload: + // First uint32: number of VSFRs to read + // Followed by each VSFR ID as uint32 + const payloadSize = (1 + nvsfr) * 4; + const payload = new ArrayBuffer(payloadSize); + const view = new DataView(payload); + + view.setUint32(0, nvsfr, true); // little-endian + for (let i = 0; i < nvsfr; i++) { + view.setUint32((i + 1) * 4, vsfrIds[i], true); + } + + const response = await this.execute( + COMMAND.RD_VIRT_SFR_BATCH, + new Uint8Array(payload) + ); + + // First uint32 is a bitmask indicating which VSFRs were successfully read + const validFlags = response.readUint32LE(); + const expectedFlags = (1 << nvsfr) - 1; + + if (validFlags !== expectedFlags) { + const validBits = validFlags.toString(2).padStart(nvsfr, '0'); + const expectedBits = expectedFlags.toString(2).padStart(nvsfr, '0'); + throw new Error( + `Unexpected validity flags, bad vsfr_id? ${validBits} != ${expectedBits}` + ); + } + + // Read the remaining data as uint32 values + const ret = []; + for (let i = 0; i < nvsfr; i++) { + const rawValue = response.readUint32LE(); + + // Decode based on VSFR format - for now all are uint32 ('I' format) + const format = VSFR_FORMATS[vsfrIds[i]]; + if (format === 'I') { + ret.push(rawValue); + } else { + // Handle other formats if needed in the future + ret.push(rawValue); + } + } + + if (response.size() !== 0) { + throw new Error('Unexpected remaining data in batch VSFR response'); + } + + return ret; + } + + /** + * Retrieve the alarm limits configuration from the device + * @returns {AlarmLimits} Device alarm limits configuration + */ + async getAlarmLimits() { + const regs = [ + VSFR.CR_LEV1_cp10s, + VSFR.CR_LEV2_cp10s, + VSFR.DR_LEV1_uR_h, + VSFR.DR_LEV2_uR_h, + VSFR.DS_LEV1_uR, + VSFR.DS_LEV2_uR, + VSFR.DS_UNITS, + VSFR.CR_UNITS + ]; + + const resp = await this.batchReadVsfrs(regs); + + const doseMultiplier = resp[6] ? 100 : 1; + const countMultiplier = resp[7] ? 60 : 1; + + return new AlarmLimits( + (resp[0] / 10) * countMultiplier, // l1_count_rate + (resp[1] / 10) * countMultiplier, // l2_count_rate + resp[2] / doseMultiplier, // l1_dose_rate + resp[3] / doseMultiplier, // l2_dose_rate + resp[4] / 1e6 / doseMultiplier, // l1_dose + resp[5] / 1e6 / doseMultiplier, // l2_dose + resp[6] ? 'Sv' : 'R', // dose_unit + resp[7] ? 'cpm' : 'cps' // count_unit + ); + } + + /** + * Reset the current spectrum data to zero + */ + async spectrum_reset() { + const args = new ArrayBuffer(8); + const view = new DataView(args); + view.setUint32(0, VS.SPECTRUM, true); + view.setUint32(4, 0, true); + + const response = await this.execute( + COMMAND.WR_VIRT_STRING, + new Uint8Array(args) + ); + const retcode = response.readUint32LE(); + + if (retcode !== 1) { + throw new Error(`spectrum_reset failed with retcode ${retcode}`); + } + + if (response.size() !== 0) { + throw new Error('Unexpected response data in spectrum_reset'); + } + } + + // --------------------------------------------------------------------- + // Generic VSFR write helpers + // --------------------------------------------------------------------- + async writeVSFR(vsfrId, value, includeValue = true) { + // Build payload: [] + const size = includeValue ? 8 : 4; + const buf = new ArrayBuffer(size); + const view = new DataView(buf); + view.setUint32(0, vsfrId >>> 0, true); + if (includeValue) view.setUint32(4, value >>> 0, true); + const resp = await this.execute(COMMAND.WR_VIRT_SFR, new Uint8Array(buf)); + const retcode = resp.readUint32LE(); + if (retcode !== 1) + throw new Error( + `writeVSFR(${vsfrId.toString(16)}) failed retcode=${retcode}` + ); + if (resp.size() !== 0) { + // consume any trailing bytes (firmware quirk) + while (resp.size() > 0) resp.read(1); + } + return true; + } + + async writeVSFRBatch(vsfrIds, values) { + if (vsfrIds.length !== values.length) + throw new Error('VSFR id/value length mismatch'); + const n = vsfrIds.length; + if (n === 0) throw new Error('No VSFRs specified'); + const payload = new ArrayBuffer(4 + n * 4 + n * 4); + const view = new DataView(payload); + view.setUint32(0, n, true); + for (let i = 0; i < n; i++) + view.setUint32(4 + i * 4, vsfrIds[i] >>> 0, true); + for (let i = 0; i < n; i++) + view.setUint32(4 + n * 4 + i * 4, values[i] >>> 0, true); + const resp = await this.execute( + COMMAND.WR_VIRT_SFR_BATCH, + new Uint8Array(payload) + ); + const flags = resp.readUint32LE(); + const expected = (1 << n) - 1; + return flags === expected; + } + + // --------------------------------------------------------------------- + // Setters mirroring Python API (radiacode.py) + // --------------------------------------------------------------------- + + async dose_reset() { + // No value (like Python write_request without data) + return this.writeVSFR(VSFR.DOSE_RESET, 0, false); + } + + async set_energy_calib(coef) { + if (!Array.isArray(coef) || coef.length !== 3) + throw new Error('coef must be array length 3'); + const payload = new ArrayBuffer(12); + const view = new DataView(payload); + for (let i = 0; i < 3; i++) view.setFloat32(i * 4, coef[i], true); + const args = new ArrayBuffer(8 + 12); + const aview = new DataView(args); + aview.setUint32(0, VS.ENERGY_CALIB, true); + aview.setUint32(4, 12, true); + new Uint8Array(args, 8).set(new Uint8Array(payload)); + const resp = await this.execute( + COMMAND.WR_VIRT_STRING, + new Uint8Array(args) + ); + const retcode = resp.readUint32LE(); + if (retcode !== 1) + throw new Error(`set_energy_calib failed retcode=${retcode}`); + return true; + } + + async set_language(lang = 'ru') { + if (!['ru', 'en'].includes(lang)) + throw new Error('unsupported lang value - use "ru" or "en"'); + return this.writeVSFR(VSFR.DEVICE_LANG, lang === 'en' ? 1 : 0); + } + + async set_device_on(on) { + return this.writeVSFR(VSFR.DEVICE_ON, on ? 1 : 0); + } + async set_sound_on(on) { + return this.writeVSFR(VSFR.SOUND_ON, on ? 1 : 0); + } + async set_vibro_on(on) { + return this.writeVSFR(VSFR.VIBRO_ON, on ? 1 : 0); + } + + async set_sound_ctrl(ctrls) { + if (!Array.isArray(ctrls)) throw new Error('ctrls must be an array'); + let flags = 0; + for (const c of ctrls) flags |= c; + return this.writeVSFR(VSFR.SOUND_CTRL, flags); + } + + async set_vibro_ctrl(ctrls) { + if (!Array.isArray(ctrls)) throw new Error('ctrls must be an array'); + let flags = 0; + for (const c of ctrls) { + if (c === CTRL.CLICKS) + throw new Error('CTRL.CLICKS not supported for vibro'); + flags |= c; + } + return this.writeVSFR(VSFR.VIBRO_CTRL, flags); + } + + async set_display_off_time(seconds) { + if (![5, 10, 15, 30].includes(seconds)) + throw new Error('seconds must be one of 5,10,15,30'); + const v = seconds === 30 ? 3 : seconds / 5 - 1; // as per Python implementation + return this.writeVSFR(VSFR.DISP_OFF_TIME, v); + } + + async set_display_brightness(brightness) { + if (!(brightness >= 0 && brightness <= 9)) + throw new Error('brightness must be 0..9'); + return this.writeVSFR(VSFR.DISP_BRT, brightness); + } + + async set_display_direction(direction) { + // Accept either enum constant or raw number + if (typeof direction !== 'number' || direction < 0 || direction > 2) + throw new Error('direction must be 0 (AUTO), 1 (RIGHT), or 2 (LEFT)'); + return this.writeVSFR(VSFR.DISP_DIR, direction); + } + + async set_alarm_limits({ + l1_count_rate = null, + l2_count_rate = null, + l1_dose_rate = null, + l2_dose_rate = null, + l1_dose = null, + l2_dose = null, + dose_unit_sv = null, + count_unit_cpm = null + } = {}) { + const which = []; + const values = []; + const doseMultiplier = dose_unit_sv === true ? 100 : 1; + const countMultiplier = + typeof count_unit_cpm === 'boolean' ? (count_unit_cpm ? 1 / 6 : 10) : 1; + + const add = (cond, id, val) => { + if (cond) { + which.push(id); + values.push(val >>> 0); + } + }; + + if (l1_count_rate != null) { + if (l1_count_rate < 0) throw new Error('bad l1_count_rate'); + add( + true, + VSFR.CR_LEV1_cp10s, + Math.round(l1_count_rate * countMultiplier) + ); + } + if (l2_count_rate != null) { + if (l2_count_rate < 0) throw new Error('bad l2_count_rate'); + add( + true, + VSFR.CR_LEV2_cp10s, + Math.round(l2_count_rate * countMultiplier) + ); + } + if (l1_dose_rate != null) { + if (l1_dose_rate < 0) throw new Error('bad l1_dose_rate'); + add(true, VSFR.DR_LEV1_uR_h, Math.round(l1_dose_rate * doseMultiplier)); + } + if (l2_dose_rate != null) { + if (l2_dose_rate < 0) throw new Error('bad l2_dose_rate'); + add(true, VSFR.DR_LEV2_uR_h, Math.round(l2_dose_rate * doseMultiplier)); + } + if (l1_dose != null) { + if (l1_dose < 0) throw new Error('bad l1_dose'); + // Python divides by 1e6 when reading; when setting we expect l1_dose as micro-unit? keep parity with Python set logic (uses same multiplier) + add(true, VSFR.DS_LEV1_uR, Math.round(l1_dose * doseMultiplier)); + } + if (l2_dose != null) { + if (l2_dose < 0) throw new Error('bad l2_dose'); + add(true, VSFR.DS_LEV2_uR, Math.round(l2_dose * doseMultiplier)); + } + if (typeof dose_unit_sv === 'boolean') + add(true, VSFR.DS_UNITS, dose_unit_sv ? 1 : 0); + if (typeof count_unit_cpm === 'boolean') + add(true, VSFR.CR_UNITS, count_unit_cpm ? 1 : 0); + + if (which.length === 0) throw new Error('No limits specified'); + return this.writeVSFRBatch(which, values); + } + /** + * Disconnect from the device + */ + async disconnect() { + await this.transport.disconnect(); + } + + /** + * Check if connected to device + */ + connected() { + return this.transport.connected(); + } +} + +// ============================================================================ +// FACTORY AND CONVENIENCE CLASSES +// ============================================================================ + +/** + * Factory function to create RadiaCode devices with different transports + */ +class RadiaCodeFactory { + static createBluetoothDevice() { + const transport = new RadiaCodeBluetoothTransport(); + return new RadiaCodeDevice(transport); + } + + static createUSBDevice(serialNumber = null, timeoutMs = 3000) { + const transport = new RadiaCodeUSBTransport(serialNumber, timeoutMs); + return new RadiaCodeDevice(transport); + } + + static createDevice(transport) { + return new RadiaCodeDevice(transport); + } + + static getAvailableTransports() { + return { + bluetooth: RadiaCodeBluetoothTransport.isSupported(), + usb: RadiaCodeUSBTransport.isSupported() + }; + } +} + +/** + * Main RadiaCode class that matches the Python API for familiar usage + * + * Usage similar to Python: + * const device = new RadiaCode(); // Uses USB by default, or specify transport + * await device.connect(); + * + * const data = await device.data_buf(); + * for (const record of data) { + * if (record instanceof RealTimeData) { + * console.log(`Dose rate: ${record.dose_rate}`); + * } + * } + * + * const spectrum = await device.spectrum(); + * console.log(`Live time: ${spectrum.duration}s`); + * console.log(`Total counts: ${spectrum.getTotalCounts()}`); + */ + +class RadiaCode extends RadiaCodeDevice { + constructor(transport = null, bluetoothMac = null, serialNumber = null) { + // Create transport based on parameters or default to USB + if (transport) { + super(transport); + } else if (bluetoothMac !== null) { + // Bluetooth transport requested + super(new RadiaCodeBluetoothTransport()); + } else { + // Default to USB, or fallback to Bluetooth if USB not available + if (RadiaCodeUSBTransport.isSupported()) { + super(new RadiaCodeUSBTransport(serialNumber)); + } else if (RadiaCodeBluetoothTransport.isSupported()) { + super(new RadiaCodeBluetoothTransport()); + } else { + throw new Error('No supported transport available'); + } + } + } + + /** + * Get firmware version (simplified format matching Python) + */ + async fw_version() { + const version = await this.getFirmwareVersion(); + return [ + [version.boot.major, version.boot.minor, version.boot.date], + [version.target.major, version.target.minor, version.target.date] + ]; + } + + /** + * Get serial number + */ + async serial_number() { + return await this.getSerialNumber(); + } + + /** + * Get hardware serial number + */ + async hw_serial_number() { + return await this.getHardwareSerialNumber(); + } +} + +// ============================================================================ +// EXPORTS AND GLOBAL DECLARATIONS +// ============================================================================ + +// Node.js environment shims for Web Bluetooth and WebUSB +(function initNodeNavigatorShims() { + try { + const isNode = + typeof process !== 'undefined' && + !!(process.versions && process.versions.node); + if (!isNode) return; + + // Ensure a navigator object exists on globalThis + if (typeof globalThis.navigator === 'undefined') { + globalThis.navigator = {}; + } + const nav = globalThis.navigator; + + // Try to attach Web Bluetooth from 'webbluetooth' if available + if (!('bluetooth' in nav)) { + try { + if (typeof require === 'function') { + const wb = require('webbluetooth'); + const Bluetooth = + wb && (wb.Bluetooth || (wb.default && wb.default.Bluetooth)); + if (Bluetooth) { + nav.bluetooth = new Bluetooth({ + deviceFound: false, + ignoreCache: true + }); + } + } + } catch (_) { + /* ignore if module not installed */ + } + } + + // Try to attach WebUSB from 'usb' if available + if (!('usb' in nav)) { + try { + if (typeof require === 'function') { + const usb = require('usb'); + const webusb = + usb && (usb.webusb || (usb.default && usb.default.webusb)); + if (webusb) { + nav.usb = webusb; + } + } + } catch (_) { + /* ignore if module not installed */ + } + } + } catch (_) { + // Ignore shim failures to keep browser-first behavior + } +})(); + +// If running under Node and we created globalThis.navigator, make a local alias so references to `navigator` work +// eslint-disable-next-line no-var +if ( + typeof navigator === 'undefined' && + typeof globalThis !== 'undefined' && + globalThis.navigator +) { + // eslint-disable-next-line no-var + var navigator = globalThis.navigator; +} + +// Make classes available globally in browser environment +if (typeof window !== 'undefined') { + // Browser environment + window.RadiaCode = RadiaCode; + window.RadiaCodeDevice = RadiaCodeDevice; + window.RadiaCodeFactory = RadiaCodeFactory; + window.RadiaCodeBluetoothTransport = RadiaCodeBluetoothTransport; + window.RadiaCodeUSBTransport = RadiaCodeUSBTransport; + window.RealTimeData = RealTimeData; + window.RawData = RawData; + window.DoseRateDB = DoseRateDB; + window.RareData = RareData; + window.Spectrum = Spectrum; + window.AlarmLimits = AlarmLimits; + window.COMMAND = COMMAND; + window.VS = VS; + window.VSFR = VSFR; + window.CTRL = CTRL; + window.DisplayDirection = DisplayDirection; + window.DeviceNotFound = DeviceNotFound; + window.ConnectionClosed = ConnectionClosed; + window.TimeoutError = TimeoutError; + window.MultipleUSBReadFailure = MultipleUSBReadFailure; + // Expose library version + window.RadiaCodeJS_VERSION = RADIACODE_JS_VERSION; +} + +// Node.js export (if needed) +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + RadiaCode, + RadiaCodeDevice, + RadiaCodeFactory, + RadiaCodeBluetoothTransport, + RadiaCodeUSBTransport, + RealTimeData, + RawData, + DoseRateDB, + RareData, + Spectrum, + AlarmLimits, + COMMAND, + VS, + VSFR, + CTRL, + DisplayDirection, + DeviceNotFound, + ConnectionClosed, + TimeoutError, + MultipleUSBReadFailure, + VERSION: RADIACODE_JS_VERSION + }; +} + +// Attach version to all public classes as a static property +// (avoid class field syntax for broader compatibility) +RadiaCode.VERSION = RADIACODE_JS_VERSION; +RadiaCodeDevice.VERSION = RADIACODE_JS_VERSION; +RadiaCodeFactory.VERSION = RADIACODE_JS_VERSION; +RadiaCodeBluetoothTransport.VERSION = RADIACODE_JS_VERSION; +RadiaCodeUSBTransport.VERSION = RADIACODE_JS_VERSION; +RealTimeData.VERSION = RADIACODE_JS_VERSION; +RawData.VERSION = RADIACODE_JS_VERSION; +DoseRateDB.VERSION = RADIACODE_JS_VERSION; +RareData.VERSION = RADIACODE_JS_VERSION; +Spectrum.VERSION = RADIACODE_JS_VERSION; +AlarmLimits.VERSION = RADIACODE_JS_VERSION; diff --git a/src/radiacode-extension/type.ts b/src/radiacode-extension/type.ts new file mode 100644 index 0000000..d3727aa --- /dev/null +++ b/src/radiacode-extension/type.ts @@ -0,0 +1,8 @@ +/** Information from Radiacode 110 device */ +export type DeviceInfo = { + connected: boolean; + err?: any; + batteryLevel: number | undefined; + identifier: string; + primaryMACAddress: string | undefined; +};