From 2626178a27f3de82f1799bfc7b78c13145184850 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:22:53 -0300 Subject: [PATCH 1/2] refactor: make simulator modules web-compatible (no Node.js APIs) Replace all Node.js-specific APIs (Buffer, EventEmitter, fs, SerialPort) with web-standard equivalents (Uint8Array, manual listeners, queueMicrotask) in the 4 simulator/modbus files so they can be shared with openplc-web verbatim. Node.js-specific logic (file I/O, real serial port creation, bootloader delay) moved to the IPC handler. Co-Authored-By: Claude Opus 4.6 --- docs/simulator-web-compatibility-refactor.md | 283 ++++++++++++++++++ src/main/modules/ipc/main.ts | 55 +++- src/main/modules/modbus/modbus-client.ts | 14 +- src/main/modules/modbus/modbus-rtu-client.ts | 224 +++++++------- src/main/modules/modbus/modbus-types.ts | 13 + .../modules/simulator/simulator-module.ts | 10 +- .../modules/simulator/virtual-serial-port.ts | 55 +++- 7 files changed, 508 insertions(+), 146 deletions(-) create mode 100644 docs/simulator-web-compatibility-refactor.md create mode 100644 src/main/modules/modbus/modbus-types.ts diff --git a/docs/simulator-web-compatibility-refactor.md b/docs/simulator-web-compatibility-refactor.md new file mode 100644 index 000000000..28d0f470b --- /dev/null +++ b/docs/simulator-web-compatibility-refactor.md @@ -0,0 +1,283 @@ +# Refactor Editor Simulator for Web Compatibility + +Refactor the `openplc-editor`'s simulator files to use only universal JavaScript APIs, enabling zero-modification copy to `openplc-web`. + +## Summary + +| Category | Count | Details | +|----------|-------|---------| +| Become web-compatible | 4 files | Simulator core, virtual serial port, Modbus RTU client, enums | +| Updated (stay Node.js) | 2 files | IPC handler absorbs file I/O and real serial port creation | +| Node.js APIs removed | 6 | Buffer, EventEmitter, fs, process.nextTick, SerialPort, NodeJS.Timeout | + +## Motivation + +Today, porting the simulator from editor to web requires ~30 individual adaptations across 3 files. Every future bug fix or feature must be applied twice. After this refactoring, the simulator files are **identical** in both projects. + +| Today (Node.js-specific) | After (universal JS) | +|--------------------------|----------------------| +| `Buffer.alloc()`, `.writeUInt16BE()`, etc. | `new Uint8Array()`, helper functions | +| `extends EventEmitter` | Manual callback arrays | +| `import { readFile } from 'fs/promises'` | Caller passes hex content as string | +| `process.nextTick(cb)` | `queueMicrotask(cb)` | +| `import { SerialPort } from 'serialport'` | `SerialPortLike` interface (duck typing) | +| `NodeJS.Timeout` type | `ReturnType` | + +> All replacements work in both Node.js >= 11 and modern browsers. No polyfills needed. + +## Implementation Order + +1. `modbus-types.ts` — Extract enums +2. `modbus-client.ts` — Import + re-export enums +3. `virtual-serial-port.ts` — Drop EventEmitter, Buffer, process.nextTick +4. `modbus-rtu-client.ts` — Drop Buffer, SerialPort; add SerialPortLike interface +5. `simulator-module.ts` — Drop fs; accept hex string +6. `ipc/main.ts` — Absorb file reading, SerialPort creation, bootloader delay + +## Detailed Changes + +### 1. `modbus-types.ts` — New file + +**Path:** `src/main/modules/modbus/modbus-types.ts` + +Extract `ModbusFunctionCode` and `ModbusDebugResponse` enums from `modbus-client.ts`. The RTU client currently imports these from `modbus-client.ts`, which also contains `ModbusTcpClient` (uses Node.js `net.Socket`). Extracting the enums breaks that dependency chain. + +```typescript +export enum ModbusFunctionCode { + DEBUG_INFO = 0x41, + DEBUG_SET = 0x42, + DEBUG_GET = 0x43, + DEBUG_GET_LIST = 0x44, + DEBUG_GET_MD5 = 0x45, +} + +export enum ModbusDebugResponse { + SUCCESS = 0x7e, + ERROR_OUT_OF_BOUNDS = 0x81, + ERROR_OUT_OF_MEMORY = 0x82, +} +``` + +**Impact:** Pure type extraction. Zero runtime change. `modbus-client.ts` re-exports for backward compat. + +### 2. `simulator-module.ts` — 2 changes + +**Path:** `src/main/modules/simulator/simulator-module.ts` + +| What | Before | After | +|------|--------|-------| +| Import | `import { readFile } from 'fs/promises'` | *removed* | +| Method | `async loadAndRun(hexPath: string): Promise` | `loadAndRun(hexContent: string): void` | +| File reading | `const hexData = await readFile(hexPath, 'utf-8')` | Uses `hexContent` parameter directly | + +The IPC handler reads the file and passes content to `loadAndRun(hexContent)`. + +**Impact:** Minimal. The module becomes a pure computation unit. All emulation logic unchanged. `setTimeout` and `performance.now()` already work in both environments. + +### 3. `virtual-serial-port.ts` — Rewrite internals, same interface + +**Path:** `src/main/modules/simulator/virtual-serial-port.ts` + +| What | Before | After | +|------|--------|-------| +| Base class | `extends EventEmitter` (from 'events') | Manual arrays: `dataListeners[]`, `openListeners[]`, `errorListeners[]` | +| Event emission | `this.emit('data', Buffer.from([byte]))` | `this.dataListeners.forEach(cb => cb(new Uint8Array([byte])))` | +| Async scheduling | `process.nextTick(() => this.emit('open'))` | `queueMicrotask(() => { ... })` | +| `write()` param | `Uint8Array \| Buffer` | `Uint8Array` | +| New methods | — | `on()`, `once()`, `removeListener()`, `removeAllListeners()` | + +**Impact:** Behavioral parity. Same bytes, same callbacks, same order. File grows from 47 to ~85 lines. + +### 4. `modbus-rtu-client.ts` — Most extensive changes + +**Path:** `src/main/modules/modbus/modbus-rtu-client.ts` + +#### Removed (Node.js-specific) + +| What | Current code | Where it goes | +|------|-------------|---------------| +| SerialPort import | `import { SerialPort } from 'serialport'` | Moved to `ipc/main.ts` | +| `port`, `baudRate` fields | `private port: string; private baudRate: number` | Removed — caller creates the port | +| `ARDUINO_BOOTLOADER_DELAY_MS` | `const = 2500` | Moved to `ipc/main.ts` | +| Real SerialPort branch | `new SerialPort({...})` in `connect()` | Moved to `ipc/main.ts` | + +#### Added: `SerialPortLike` interface + +Both `VirtualSerialPort` (simulator) and real `SerialPort` (npm) satisfy this via duck typing: + +```typescript +export interface SerialPortLike { + isOpen: boolean + open(): void + close(): void + write(data: Uint8Array, callback?: (err?: Error | null) => void): void + flush(callback?: (err?: Error | null) => void): void + on(event: string, listener: (...args: unknown[]) => void): void + once(event: string, listener: (...args: unknown[]) => void): void + removeListener(event: string, listener: (...args: unknown[]) => void): void + removeAllListeners(event?: string): void +} +``` + +#### Buffer → Uint8Array migration + +| Buffer API (removed) | Uint8Array helper (added) | +|----------------------|--------------------------| +| `Buffer.alloc(n)` | `allocBytes(n)` → `new Uint8Array(n)` | +| `Buffer.concat([a, b])` | `concatBytes(a, b)` → `Uint8Array.set()` | +| `buf.writeUInt8(val, off)` | `writeUint8(buf, off, val)` | +| `buf.writeUInt16BE(val, off)` | `writeUint16BE(buf, off, val)` | +| `buf.readUInt8(off)` | `readUint8(buf, off)` | +| `buf.readUInt16BE(off)` | `readUint16BE(buf, off)` | +| `buf.readUInt32BE(off)` | `readUint32BE(buf, off)` | +| `data.copy(target, offset)` | `target.set(data, offset)` | +| `response.toString('utf-8')` | `new TextDecoder().decode(bytes)` | +| `NodeJS.Timeout` | `ReturnType` | + +#### Simplified constructor + +```typescript +// serialPort is now required and strongly typed +interface ModbusRtuClientOptions { + slaveId: number + timeout: number + serialPort: SerialPortLike +} +``` + +#### Simplified `connect()` + +Only the injected port path remains: + +```typescript +async connect(): Promise { + this.serialPort = this.injectedSerialPort + return new Promise((resolve, reject) => { + this.serialPort!.on('open', () => resolve()) + this.serialPort!.on('error', (err) => reject(...)) + this.serialPort!.open() + }) +} +``` + +#### Return type changes + +| Method | Before | After | +|--------|--------|-------| +| `getVariablesList()` | `data?: Buffer` | `data?: Uint8Array` | +| `setVariable()` | `valueBuffer?: Buffer` | `valueBuffer?: Uint8Array` | + +> **Downstream impact:** Code calling `getVariablesList()` that expects `Buffer` must switch to `Uint8Array`. In the editor, this is only the IPC handler. Since `Buffer` extends `Uint8Array` in Node.js, most read operations work identically. + +**Protocol unchanged:** CRC tables, frame assembly, response parsing, mutex serialization, retry logic, timeouts — all identical. + +### 5. `modbus-client.ts` — Minor update + +**Path:** `src/main/modules/modbus/modbus-client.ts` + +Remove enum definitions, import and re-export from `modbus-types.ts`: + +```typescript +// Before: enums defined inline +// After: +export { ModbusFunctionCode, ModbusDebugResponse } from './modbus-types' +``` + +**Impact:** Zero. All existing imports continue to work. + +### 6. `ipc/main.ts` — Absorbs Node.js-specific logic + +**Path:** `src/main/modules/ipc/main.ts` + +#### a) `handleSimulatorLoadFirmware` + +```typescript +// Before: +await this.simulatorModule.loadAndRun(hexPath) + +// After: +const hexContent = await readFile(hexPath, 'utf-8') +this.simulatorModule.loadAndRun(hexContent) +``` + +#### b) 3 simulator ModbusRtuClient creation sites + +Remove `port` and `baudRate` from options (fields no longer exist): + +```typescript +const virtualPort = new VirtualSerialPort(this.simulatorModule) +client = new ModbusRtuClient({ + // removed: port: 'simulator', + // removed: baudRate: 115200, + slaveId: 1, + timeout: 5000, + serialPort: virtualPort, +}) +``` + +#### c) 3 real RTU ModbusRtuClient creation sites + +Move SerialPort creation and bootloader delay from client to handler: + +```typescript +const ARDUINO_BOOTLOADER_DELAY_MS = 2500 + +const realPort = new SerialPort({ + path: connectionParams.port, + baudRate: connectionParams.baudRate, + autoOpen: false, + dataBits: 8, stopBits: 1, parity: 'none', +}) +client = new ModbusRtuClient({ + slaveId: connectionParams.slaveId, + timeout: 5000, + serialPort: realPort, +}) +await client.connect() +await new Promise(resolve => setTimeout(resolve, ARDUINO_BOOTLOADER_DELAY_MS)) +``` + +> **6 call sites** must be updated: 3 simulator (remove `port`/`baudRate`) + 3 RTU (externalize SerialPort). + +## Impact Analysis + +### Benefits + +- 4 simulator files become copy-paste portable between editor and web +- Bug fixes and features only need to be written once +- Stronger typing: `SerialPortLike` replaces `any` +- Better separation of concerns: I/O moved to callers +- No new dependencies; `Uint8Array` is a built-in +- `serialport` npm package no longer imported in shared code + +### Trade-offs + +- 6 call sites in `main.ts` must be updated (mechanical changes) +- Virtual serial port grows from 47 to ~85 lines (EventEmitter reimplementation) +- `Uint8Array` helpers add ~30 lines to `modbus-rtu-client.ts` +- Future code using `getVariablesList` gets `Uint8Array` instead of `Buffer` +- Real RTU bootloader delay logic moves to 3 places in `main.ts` + +## What's NOT Affected + +| Component | Status | +|-----------|--------| +| ModbusTcpClient (`modbus-client.ts`) | Unchanged — still uses Node.js `net.Socket` | +| WebSocketDebugClient | Unchanged — still uses `Buffer` | +| Renderer / React components | Unchanged — call via `window.bridge.*` IPC | +| Compiler module | Unchanged — still produces HEX files | +| avr8js dependency | Unchanged — pinned at 0.20.0, already browser-compatible | +| Preload / bridge API | Unchanged — `simulatorLoadFirmware(hexPath)` still accepts a path | + +## Outcome + +After this refactoring, these files are **byte-identical** between `openplc-editor` and `openplc-web`: + +- `simulator-module.ts` +- `virtual-serial-port.ts` +- `modbus-rtu-client.ts` +- `modbus-types.ts` + +The web-only files (`simulator-service.ts`, `SimulatorManager` component) remain separate — they replace the Electron IPC bridge and are specific to the web architecture. + +**Future maintenance:** Any bug fix or feature added to these 4 files in either project can be copied directly to the other with no translation step. diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index ae73dde85..378eb7328 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -11,6 +11,9 @@ import type { IncomingMessage } from 'http' import https from 'https' import { join, resolve, sep } from 'path' import { platform } from 'process' +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore - serialport types are not available at build time but will be at runtime +import { SerialPort } from 'serialport' import { ProjectState } from '../../../renderer/store/slices' import { PLCPou, PLCProject } from '../../../types/PLC/open-plc' @@ -22,6 +25,8 @@ import { SimulatorModule } from '../simulator/simulator-module' import { VirtualSerialPort } from '../simulator/virtual-serial-port' import { WebSocketDebugClient } from '../websocket/websocket-debug-client' +const ARDUINO_BOOTLOADER_DELAY_MS = 2500 + type IDataToWrite = { projectPath: string content: { @@ -894,8 +899,6 @@ class MainProcessBridge implements MainIpcModule { if (connectionType === 'simulator') { const virtualPort = new VirtualSerialPort(this.simulatorModule) client = new ModbusRtuClient({ - port: 'simulator', - baudRate: 115200, slaveId: 1, timeout: 5000, serialPort: virtualPort, @@ -950,15 +953,25 @@ class MainProcessBridge implements MainIpcModule { if (!connectionParams.port || !connectionParams.baudRate || connectionParams.slaveId === undefined) { return { success: false, error: 'Port, baud rate, and slave ID are required for RTU connection' } } - client = new ModbusRtuClient({ - port: connectionParams.port, + const realPort = new SerialPort({ + path: connectionParams.port, baudRate: connectionParams.baudRate, + autoOpen: false, + dataBits: 8, + stopBits: 1, + parity: 'none', + }) + client = new ModbusRtuClient({ slaveId: connectionParams.slaveId, timeout: 5000, + serialPort: realPort, }) } await client.connect() + if (connectionType === 'rtu') { + await new Promise((resolve) => setTimeout(resolve, ARDUINO_BOOTLOADER_DELAY_MS)) + } const targetMd5 = await client.getMd5Hash() const match = targetMd5.toLowerCase() === expectedMd5.toLowerCase() @@ -1097,8 +1110,6 @@ class MainProcessBridge implements MainIpcModule { if (this.debuggerConnectionType === 'simulator') { const virtualPort = new VirtualSerialPort(this.simulatorModule) this.debuggerModbusClient = new ModbusRtuClient({ - port: 'simulator', - baudRate: 115200, slaveId: 1, timeout: 5000, serialPort: virtualPort, @@ -1118,11 +1129,18 @@ class MainProcessBridge implements MainIpcModule { this.debuggerReconnecting = false return { success: false, error: 'No RTU connection parameters stored', needsReconnect: true } } - this.debuggerModbusClient = new ModbusRtuClient({ - port: this.debuggerRtuPort, + const realPort = new SerialPort({ + path: this.debuggerRtuPort, baudRate: this.debuggerRtuBaudRate, + autoOpen: false, + dataBits: 8, + stopBits: 1, + parity: 'none', + }) + this.debuggerModbusClient = new ModbusRtuClient({ slaveId: this.debuggerRtuSlaveId, timeout: 5000, + serialPort: realPort, }) } else { this.debuggerReconnecting = false @@ -1130,6 +1148,9 @@ class MainProcessBridge implements MainIpcModule { } await this.debuggerModbusClient.connect() + if (this.debuggerConnectionType === 'rtu') { + await new Promise((resolve) => setTimeout(resolve, ARDUINO_BOOTLOADER_DELAY_MS)) + } this.debuggerReconnecting = false } catch (error) { this.debuggerModbusClient = null @@ -1180,8 +1201,6 @@ class MainProcessBridge implements MainIpcModule { const virtualPort = new VirtualSerialPort(this.simulatorModule) this.debuggerModbusClient = new ModbusRtuClient({ - port: 'simulator', - baudRate: 115200, slaveId: 1, timeout: 5000, serialPort: virtualPort, @@ -1258,13 +1277,21 @@ class MainProcessBridge implements MainIpcModule { this.debuggerModbusClient = null } - this.debuggerModbusClient = new ModbusRtuClient({ - port: connectionParams.port, + const realPort = new SerialPort({ + path: connectionParams.port, baudRate: connectionParams.baudRate, + autoOpen: false, + dataBits: 8, + stopBits: 1, + parity: 'none', + }) + this.debuggerModbusClient = new ModbusRtuClient({ slaveId: connectionParams.slaveId, timeout: 5000, + serialPort: realPort, }) await this.debuggerModbusClient.connect() + await new Promise((resolve) => setTimeout(resolve, ARDUINO_BOOTLOADER_DELAY_MS)) this.debuggerRtuPort = connectionParams.port this.debuggerRtuBaudRate = connectionParams.baudRate this.debuggerRtuSlaveId = connectionParams.slaveId @@ -1380,7 +1407,9 @@ class MainProcessBridge implements MainIpcModule { hexPath: string, ): Promise<{ success: boolean; error?: string }> => { try { - await this.simulatorModule.loadAndRun(hexPath) + const fs = await import('fs/promises') + const hexContent = await fs.readFile(hexPath, 'utf-8') + this.simulatorModule.loadAndRun(hexContent) return { success: true } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) } diff --git a/src/main/modules/modbus/modbus-client.ts b/src/main/modules/modbus/modbus-client.ts index a742d411b..9a277b613 100644 --- a/src/main/modules/modbus/modbus-client.ts +++ b/src/main/modules/modbus/modbus-client.ts @@ -1,18 +1,8 @@ import { Socket } from 'net' -export enum ModbusFunctionCode { - DEBUG_INFO = 0x41, - DEBUG_SET = 0x42, - DEBUG_GET = 0x43, - DEBUG_GET_LIST = 0x44, - DEBUG_GET_MD5 = 0x45, -} +import { ModbusDebugResponse, ModbusFunctionCode } from './modbus-types' -export enum ModbusDebugResponse { - SUCCESS = 0x7e, - ERROR_OUT_OF_BOUNDS = 0x81, - ERROR_OUT_OF_MEMORY = 0x82, -} +export { ModbusDebugResponse, ModbusFunctionCode } from './modbus-types' interface ModbusTcpClientOptions { host: string diff --git a/src/main/modules/modbus/modbus-rtu-client.ts b/src/main/modules/modbus/modbus-rtu-client.ts index 539b9690e..b91e62072 100644 --- a/src/main/modules/modbus/modbus-rtu-client.ts +++ b/src/main/modules/modbus/modbus-rtu-client.ts @@ -1,33 +1,76 @@ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore - serialport types are not available at build time but will be at runtime -import { SerialPort } from 'serialport' - -import { ModbusDebugResponse, ModbusFunctionCode } from './modbus-client' +import { ModbusDebugResponse, ModbusFunctionCode } from './modbus-types' + +export interface SerialPortLike { + isOpen: boolean + open(): void + close(): void + write(data: Uint8Array, callback?: (err?: Error | null) => void): void + flush(callback?: (err?: Error | null) => void): void + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on(event: string, listener: (...args: any[]) => void): void + // eslint-disable-next-line @typescript-eslint/no-explicit-any + once(event: string, listener: (...args: any[]) => void): void + // eslint-disable-next-line @typescript-eslint/no-explicit-any + removeListener(event: string, listener: (...args: any[]) => void): void + removeAllListeners(event?: string): void +} interface ModbusRtuClientOptions { - port: string - baudRate: number slaveId: number timeout: number - // eslint-disable-next-line @typescript-eslint/no-explicit-any - serialPort?: any // Pre-built serial port (e.g. VirtualSerialPort for simulator) + serialPort: SerialPortLike } -const ARDUINO_BOOTLOADER_DELAY_MS = 2500 const MD5_REQUEST_MAX_RETRIES = 3 const MD5_REQUEST_RETRY_DELAY_MS = 500 const FRAME_COMPLETE_TIMEOUT_MS = 10 +// --------------------------------------------------------------------------- +// Uint8Array helpers (replacing Node.js Buffer) +// --------------------------------------------------------------------------- + +function allocBytes(size: number): Uint8Array { + return new Uint8Array(size) +} + +function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { + const result = new Uint8Array(a.length + b.length) + result.set(a, 0) + result.set(b, a.length) + return result +} + +function readUint8(buf: Uint8Array, offset: number): number { + return buf[offset] +} + +function writeUint8(buf: Uint8Array, offset: number, value: number): void { + buf[offset] = value +} + +function readUint16BE(buf: Uint8Array, offset: number): number { + return (buf[offset] << 8) | buf[offset + 1] +} + +function writeUint16BE(buf: Uint8Array, offset: number, value: number): void { + buf[offset] = (value >>> 8) & 0xff + buf[offset + 1] = value & 0xff +} + +function readUint32BE(buf: Uint8Array, offset: number): number { + return ((buf[offset] << 24) | (buf[offset + 1] << 16) | (buf[offset + 2] << 8) | buf[offset + 3]) >>> 0 +} + +// --------------------------------------------------------------------------- +// Modbus RTU Client (web-compatible) +// --------------------------------------------------------------------------- + export class ModbusRtuClient { - private port: string - private baudRate: number private slaveId: number private timeout: number - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private serialPort: any = null - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private injectedSerialPort: any = null + private serialPort: SerialPortLike | null = null + private injectedSerialPort: SerialPortLike private static readonly CRC_HI_TABLE = [ 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, 0x41, 0x01, 0xc0, 0x80, 0x41, 0x00, 0xc1, 0x81, 0x40, 0x01, 0xc0, 0x80, @@ -64,14 +107,12 @@ export class ModbusRtuClient { ] constructor(options: ModbusRtuClientOptions) { - this.port = options.port - this.baudRate = options.baudRate this.slaveId = options.slaveId this.timeout = options.timeout - this.injectedSerialPort = options.serialPort ?? null + this.injectedSerialPort = options.serialPort } - private calculateCrc(buffer: Buffer): number { + private calculateCrc(buffer: Uint8Array): number { let crcHi = 0xff let crcLo = 0xff @@ -84,53 +125,26 @@ export class ModbusRtuClient { return (crcHi << 8) | crcLo } - private assembleRequest(functionCode: number, data: Buffer): Buffer { - const frameWithoutCrc = Buffer.alloc(2 + data.length) - frameWithoutCrc.writeUInt8(this.slaveId, 0) - frameWithoutCrc.writeUInt8(functionCode, 1) - data.copy(frameWithoutCrc as unknown as Uint8Array, 2) + private assembleRequest(functionCode: number, data: Uint8Array): Uint8Array { + const frameWithoutCrc = allocBytes(2 + data.length) + writeUint8(frameWithoutCrc, 0, this.slaveId) + writeUint8(frameWithoutCrc, 1, functionCode) + frameWithoutCrc.set(data, 2) const crc = this.calculateCrc(frameWithoutCrc) - const request = Buffer.alloc(frameWithoutCrc.length + 2) - frameWithoutCrc.copy(request as unknown as Uint8Array, 0) - request.writeUInt16BE(crc, frameWithoutCrc.length) + const request = allocBytes(frameWithoutCrc.length + 2) + request.set(frameWithoutCrc, 0) + writeUint16BE(request, frameWithoutCrc.length, crc) return request } async connect(): Promise { - // If a pre-built serial port was provided (e.g. VirtualSerialPort), use it directly - if (this.injectedSerialPort) { - this.serialPort = this.injectedSerialPort - return new Promise((resolve, reject) => { - this.serialPort.on('open', () => resolve()) - this.serialPort.on('error', (err: Error) => reject(err)) - this.serialPort.open() - }) - } - + this.serialPort = this.injectedSerialPort return new Promise((resolve, reject) => { - try { - this.serialPort = new SerialPort({ - path: this.port, - baudRate: this.baudRate, - dataBits: 8, - stopBits: 1, - parity: 'none', - }) - - this.serialPort.on('open', () => { - setTimeout(() => { - resolve() - }, ARDUINO_BOOTLOADER_DELAY_MS) - }) - - this.serialPort.on('error', (error: unknown) => { - reject(error instanceof Error ? error : new Error(String(error))) - }) - } catch (error) { - reject(error instanceof Error ? error : new Error(String(error))) - } + this.serialPort!.on('open', () => resolve()) + this.serialPort!.on('error', (err: unknown) => reject(err instanceof Error ? err : new Error(String(err)))) + this.serialPort!.open() }) } @@ -148,7 +162,7 @@ export class ModbusRtuClient { return } - this.serialPort.flush((err: Error | null) => { + this.serialPort.flush((err?: Error | null) => { if (err) { console.warn('Warning: Failed to flush serial port:', err.message) } @@ -159,8 +173,8 @@ export class ModbusRtuClient { private sendRequestMutex: Promise = Promise.resolve() - private async sendRequest(request: Buffer): Promise { - return new Promise((resolve, reject) => { + private async sendRequest(request: Uint8Array): Promise { + return new Promise((resolve, reject) => { this.sendRequestMutex = this.sendRequestMutex.then( () => this.sendRequestImpl(request).then(resolve, reject), () => this.sendRequestImpl(request).then(resolve, reject), @@ -168,7 +182,7 @@ export class ModbusRtuClient { }) } - private async sendRequestImpl(request: Buffer): Promise { + private async sendRequestImpl(request: Uint8Array): Promise { if (!this.serialPort || !this.serialPort.isOpen) { throw new Error('Serial port is not open') } @@ -176,10 +190,9 @@ export class ModbusRtuClient { await this.flushInputBuffer() return new Promise((resolve, reject) => { - let responseBuffer = Buffer.alloc(0) - let frameCompleteTimeout: NodeJS.Timeout | null = null + let responseBuffer = allocBytes(0) + let frameCompleteTimeout: ReturnType | null = null - // Forward-declared so the timeout handler can reference them for cleanup const cleanup = () => { this.serialPort?.removeListener('data', onData) this.serialPort?.removeListener('error', onError) @@ -193,8 +206,8 @@ export class ModbusRtuClient { reject(new Error('Request timeout')) }, this.timeout) - const onData = (data: Buffer) => { - responseBuffer = Buffer.concat([responseBuffer, data] as unknown as Uint8Array[]) + const onData = (data: Uint8Array) => { + responseBuffer = concatBytes(responseBuffer, data) if (frameCompleteTimeout) { clearTimeout(frameCompleteTimeout) @@ -209,7 +222,7 @@ export class ModbusRtuClient { return } - const receivedCrc = responseBuffer.readUInt16BE(responseBuffer.length - 2) + const receivedCrc = readUint16BE(responseBuffer, responseBuffer.length - 2) const calculatedCrc = this.calculateCrc(responseBuffer.slice(0, responseBuffer.length - 2)) if (receivedCrc !== calculatedCrc) { @@ -217,33 +230,27 @@ export class ModbusRtuClient { } const responseWithoutCrc = responseBuffer.slice(0, responseBuffer.length - 2) - const paddedResponse = Buffer.alloc(6 + responseWithoutCrc.length) - paddedResponse.fill(0, 0, 6) - responseWithoutCrc.copy(paddedResponse as unknown as Uint8Array, 6) + const paddedResponse = allocBytes(6 + responseWithoutCrc.length) + // First 6 bytes are zeros (padding for TCP header compatibility) + paddedResponse.set(responseWithoutCrc, 6) resolve(paddedResponse) }, FRAME_COMPLETE_TIMEOUT_MS) } - const onError = (error: Error) => { + const onError = (error: unknown) => { clearTimeout(timeoutHandle) cleanup() - reject(error) + reject(error instanceof Error ? error : new Error(String(error))) } this.serialPort!.on('data', onData) this.serialPort!.once('error', onError) - this.serialPort!.write(request as unknown as Uint8Array, (error: unknown) => { + this.serialPort!.write(request, (error?: Error | null) => { if (error) { clearTimeout(timeoutHandle) cleanup() - const errorMessage = - typeof error === 'string' - ? error - : typeof error === 'object' && error !== null - ? JSON.stringify(error) - : 'Unknown error' - reject(error instanceof Error ? error : new Error(errorMessage)) + reject(error) } }) }) @@ -253,10 +260,10 @@ export class ModbusRtuClient { const functionCode = ModbusFunctionCode.DEBUG_GET_MD5 const endiannessCheck = 0xdead - const data = Buffer.alloc(4) - data.writeUInt16BE(endiannessCheck, 0) - data.writeUInt8(0, 2) - data.writeUInt8(0, 3) + const data = allocBytes(4) + writeUint16BE(data, 0, endiannessCheck) + writeUint8(data, 2, 0) + writeUint8(data, 3, 0) const request = this.assembleRequest(functionCode, data) @@ -273,8 +280,8 @@ export class ModbusRtuClient { throw new Error('Invalid response: too short') } - const functionCodeResponse = response.readUInt8(7) - const statusCode = response.readUInt8(8) + const functionCodeResponse = readUint8(response, 7) + const statusCode = readUint8(response, 8) if (functionCodeResponse !== (ModbusFunctionCode.DEBUG_GET_MD5 as number)) { throw new Error('Function code mismatch') @@ -284,7 +291,8 @@ export class ModbusRtuClient { throw new Error(`Target returned error code: 0x${statusCode.toString(16)}`) } - const md5String = response.slice(9).toString('utf-8').trim() + const md5Bytes = response.slice(9) + const md5String = new TextDecoder().decode(md5Bytes).trim() return md5String } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)) @@ -301,18 +309,18 @@ export class ModbusRtuClient { success: boolean tick?: number lastIndex?: number - data?: Buffer + data?: Uint8Array error?: string }> { try { const functionCode = ModbusFunctionCode.DEBUG_GET_LIST const numIndexes = variableIndexes.length - const data = Buffer.alloc(2 + 2 * numIndexes) - data.writeUInt16BE(numIndexes, 0) + const data = allocBytes(2 + 2 * numIndexes) + writeUint16BE(data, 0, numIndexes) for (let i = 0; i < numIndexes; i++) { - data.writeUInt16BE(variableIndexes[i], 2 + i * 2) + writeUint16BE(data, 2 + i * 2, variableIndexes[i]) } const request = this.assembleRequest(functionCode, data) @@ -322,8 +330,8 @@ export class ModbusRtuClient { return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } } - const functionCodeResponse = response.readUInt8(7) - const statusCode = response.readUInt8(8) + const functionCodeResponse = readUint8(response, 7) + const statusCode = readUint8(response, 8) if (functionCodeResponse !== (ModbusFunctionCode.DEBUG_GET_LIST as number)) { return { success: false, error: 'Function code mismatch' } @@ -348,9 +356,9 @@ export class ModbusRtuClient { } } - const lastIndex = response.readUInt16BE(9) - const tick = response.readUInt32BE(11) - const responseSize = response.readUInt16BE(15) + const lastIndex = readUint16BE(response, 9) + const tick = readUint32BE(response, 11) + const responseSize = readUint16BE(response, 15) if (response.length < 17 + responseSize) { return { @@ -375,7 +383,7 @@ export class ModbusRtuClient { async setVariable( variableIndex: number, force: boolean, - valueBuffer?: Buffer, + valueBuffer?: Uint8Array, ): Promise<{ success: boolean error?: string @@ -384,18 +392,16 @@ export class ModbusRtuClient { const functionCode = ModbusFunctionCode.DEBUG_SET const dataLength = force && valueBuffer ? valueBuffer.length : 1 - const data = Buffer.alloc(5 + dataLength) + const data = allocBytes(5 + dataLength) - data.writeUInt16BE(variableIndex, 0) - data.writeUInt8(force ? 1 : 0, 2) - data.writeUInt16BE(dataLength, 3) + writeUint16BE(data, 0, variableIndex) + writeUint8(data, 2, force ? 1 : 0) + writeUint16BE(data, 3, dataLength) if (force && valueBuffer) { - for (let i = 0; i < valueBuffer.length; i++) { - data.writeUInt8(valueBuffer[i], 5 + i) - } + data.set(valueBuffer, 5) } else { - data.writeUInt8(0, 5) + writeUint8(data, 5, 0) } const request = this.assembleRequest(functionCode, data) @@ -405,8 +411,8 @@ export class ModbusRtuClient { return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } } - const functionCodeResponse = response.readUInt8(7) - const statusCode = response.readUInt8(8) + const functionCodeResponse = readUint8(response, 7) + const statusCode = readUint8(response, 8) if (functionCodeResponse !== (ModbusFunctionCode.DEBUG_SET as number)) { return { success: false, error: 'Function code mismatch' } diff --git a/src/main/modules/modbus/modbus-types.ts b/src/main/modules/modbus/modbus-types.ts new file mode 100644 index 000000000..a2ac377f6 --- /dev/null +++ b/src/main/modules/modbus/modbus-types.ts @@ -0,0 +1,13 @@ +export enum ModbusFunctionCode { + DEBUG_INFO = 0x41, + DEBUG_SET = 0x42, + DEBUG_GET = 0x43, + DEBUG_GET_LIST = 0x44, + DEBUG_GET_MD5 = 0x45, +} + +export enum ModbusDebugResponse { + SUCCESS = 0x7e, + ERROR_OUT_OF_BOUNDS = 0x81, + ERROR_OUT_OF_MEMORY = 0x82, +} diff --git a/src/main/modules/simulator/simulator-module.ts b/src/main/modules/simulator/simulator-module.ts index cf8f5eb75..3b46a6b82 100644 --- a/src/main/modules/simulator/simulator-module.ts +++ b/src/main/modules/simulator/simulator-module.ts @@ -10,7 +10,6 @@ import { timer2Config, usart0Config, } from 'avr8js' -import { readFile } from 'fs/promises' // ATmega2560 specs const CPU_FREQ_HZ = 16_000_000 @@ -172,14 +171,15 @@ export class SimulatorModule { onUartByte: ((byte: number) => void) | null = null /** - * Loads an Intel HEX firmware file and starts the emulated ATmega2560. + * Loads Intel HEX firmware content and starts the emulated ATmega2560. * Stops any currently running emulation first. + * + * @param hexContent - Raw Intel HEX string (caller is responsible for reading the file) */ - async loadAndRun(hexPath: string): Promise { + loadAndRun(hexContent: string): void { this.stop() - const hexData = await readFile(hexPath, 'utf-8') - const progMem = parseIntelHex(hexData, FLASH_SIZE_BYTES) + const progMem = parseIntelHex(hexContent, FLASH_SIZE_BYTES) this.cpu = new CPU(progMem, SRAM_BYTES) diff --git a/src/main/modules/simulator/virtual-serial-port.ts b/src/main/modules/simulator/virtual-serial-port.ts index d620e1a94..872a08a9f 100644 --- a/src/main/modules/simulator/virtual-serial-port.ts +++ b/src/main/modules/simulator/virtual-serial-port.ts @@ -1,18 +1,21 @@ -import { EventEmitter } from 'events' - import { SimulatorModule } from './simulator-module' /** * A virtual serial port that mimics the `serialport` npm package's event-based API. * Routes bytes through SimulatorModule's UART bridge, allowing the existing * ModbusRtuClient to communicate with the avr8js emulator unchanged. + * + * Uses manual listener arrays instead of Node.js EventEmitter for browser compatibility. */ -export class VirtualSerialPort extends EventEmitter { +export class VirtualSerialPort { public isOpen = false private simulator: SimulatorModule + private dataListeners: ((data: Uint8Array) => void)[] = [] + private openListeners: (() => void)[] = [] + private errorListeners: ((err: Error) => void)[] = [] + constructor(simulator: SimulatorModule) { - super() this.simulator = simulator } @@ -20,13 +23,20 @@ export class VirtualSerialPort extends EventEmitter { this.isOpen = true // Wire UART RX: bytes from emulated device → ModbusRtuClient via 'data' events this.simulator.onUartByte = (byte: number) => { - this.emit('data', Buffer.from([byte])) + const buf = new Uint8Array([byte]) + for (const cb of this.dataListeners) { + cb(buf) + } } // Emit 'open' asynchronously (matches real SerialPort behavior) - process.nextTick(() => this.emit('open')) + queueMicrotask(() => { + for (const cb of this.openListeners) { + cb() + } + }) } - write(data: Uint8Array | Buffer, callback?: (err?: Error | null) => void): void { + write(data: Uint8Array, callback?: (err?: Error | null) => void): void { // Send each byte to the emulated UART TX (host → device) for (const byte of data) { this.simulator.feedByte(byte) @@ -44,4 +54,35 @@ export class VirtualSerialPort extends EventEmitter { this.simulator.onUartByte = null this.removeAllListeners() } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on(event: string, listener: (...args: any[]) => void): void { + if (event === 'data') this.dataListeners.push(listener) + else if (event === 'open') this.openListeners.push(listener) + else if (event === 'error') this.errorListeners.push(listener) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + once(event: string, listener: (...args: any[]) => void): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const wrapper = (...args: any[]) => { + this.removeListener(event, wrapper) + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + listener(...args) + } + this.on(event, wrapper) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + removeListener(event: string, listener: (...args: any[]) => void): void { + if (event === 'data') this.dataListeners = this.dataListeners.filter((cb) => cb !== listener) + else if (event === 'open') this.openListeners = this.openListeners.filter((cb) => cb !== listener) + else if (event === 'error') this.errorListeners = this.errorListeners.filter((cb) => cb !== listener) + } + + removeAllListeners(event?: string): void { + if (!event || event === 'data') this.dataListeners = [] + if (!event || event === 'open') this.openListeners = [] + if (!event || event === 'error') this.errorListeners = [] + } } From d9c9726ce94140a1d09d14e58fceb06a06bde246 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:45:39 -0300 Subject: [PATCH 2/2] refactor: extract shared Modbus PDU module, refactor all debug clients to use it Extract PDU building/parsing logic duplicated across ModbusTcpClient, WebSocketDebugClient, and ModbusRtuClient into a single shared module at src/shared/modbus/modbus-pdu.ts. Uses Uint8Array for browser compatibility. All three clients now delegate PDU construction and response parsing to the shared module, keeping only transport-specific framing (MBAP header, CRC, hex encoding). Removes modbus-types.ts (enums moved to shared module). Includes 36 unit tests with byte-for-byte compatibility checks. Co-Authored-By: Claude Opus 4.6 --- src/main/modules/modbus/modbus-client.ts | 201 +++-------- src/main/modules/modbus/modbus-rtu-client.ts | 190 +++-------- src/main/modules/modbus/modbus-types.ts | 13 - .../websocket/websocket-debug-client.ts | 282 +++------------ src/shared/modbus/modbus-pdu.spec.ts | 323 ++++++++++++++++++ src/shared/modbus/modbus-pdu.ts | 236 +++++++++++++ 6 files changed, 694 insertions(+), 551 deletions(-) delete mode 100644 src/main/modules/modbus/modbus-types.ts create mode 100644 src/shared/modbus/modbus-pdu.spec.ts create mode 100644 src/shared/modbus/modbus-pdu.ts diff --git a/src/main/modules/modbus/modbus-client.ts b/src/main/modules/modbus/modbus-client.ts index 9a277b613..1ac02408d 100644 --- a/src/main/modules/modbus/modbus-client.ts +++ b/src/main/modules/modbus/modbus-client.ts @@ -1,8 +1,14 @@ +import { + buildGetListPdu, + buildGetMd5Pdu, + buildSetVariablePdu, + parseGetListResponse, + parseGetMd5Response, + parseSetVariableResponse, +} from '@shared/modbus/modbus-pdu' import { Socket } from 'net' -import { ModbusDebugResponse, ModbusFunctionCode } from './modbus-types' - -export { ModbusDebugResponse, ModbusFunctionCode } from './modbus-types' +export { ModbusDebugResponse, ModbusFunctionCode } from '@shared/modbus/modbus-pdu' interface ModbusTcpClientOptions { host: string @@ -99,51 +105,37 @@ export class ModbusTcpClient { }) } - async getMd5Hash(): Promise { - if (!this.socket) { - throw new Error('Not connected to target') - } - + private wrapMbap(pdu: Uint8Array): { request: Buffer; transactionId: number } { const transactionId = this.incrementTransactionId() - const protocolId = 0x0000 - const unitId = 0x00 - const functionCode = ModbusFunctionCode.DEBUG_GET_MD5 - const endiannessCheck = 0xdead - - const request = Buffer.alloc(12) - request.writeUInt16BE(transactionId, 0) - request.writeUInt16BE(protocolId, 2) - request.writeUInt16BE(6, 4) - request.writeUInt8(unitId, 6) - request.writeUInt8(functionCode, 7) - request.writeUInt16BE(endiannessCheck, 8) - request.writeUInt8(0, 10) - request.writeUInt8(0, 11) - - const data = await this.sendTcpRequest(request) + const request = Buffer.alloc(7 + pdu.length) + request.writeUInt16BE(transactionId, 0) // Transaction ID + request.writeUInt16BE(0x0000, 2) // Protocol ID + request.writeUInt16BE(1 + pdu.length, 4) // Length (unit ID + PDU) + request.writeUInt8(0x00, 6) // Unit ID + request.set(pdu, 7) // PDU + return { request, transactionId } + } + private stripMbap(data: Buffer, expectedTransactionId: number): Uint8Array { if (data.length < 9) { - throw new Error('Invalid response: too short') + throw new Error(`Invalid response: too short (${data.length} bytes, need at least 9)`) } - const responseTransactionId = data.readUInt16BE(0) - const responseFunctionCode = data.readUInt8(7) - const statusCode = data.readUInt8(8) - - if (responseTransactionId !== transactionId) { + if (responseTransactionId !== expectedTransactionId) { throw new Error('Transaction ID mismatch') } + return new Uint8Array(data.buffer, data.byteOffset + 7, data.length - 7) + } - if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_GET_MD5 as number)) { - throw new Error('Function code mismatch') - } - - if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { - throw new Error(`Target returned error code: 0x${statusCode.toString(16)}`) + async getMd5Hash(): Promise { + if (!this.socket) { + throw new Error('Not connected to target') } - const md5String = data.slice(9).toString('utf-8').trim() - return md5String + const { request, transactionId } = this.wrapMbap(buildGetMd5Pdu()) + const data = await this.sendTcpRequest(request) + const pdu = this.stripMbap(data, transactionId) + return parseGetMd5Response(pdu).md5 } async getVariablesList(variableIndexes: number[]): Promise<{ @@ -157,82 +149,22 @@ export class ModbusTcpClient { return { success: false, error: 'Not connected to target' } } - const transactionId = this.incrementTransactionId() - const protocolId = 0x0000 - const unitId = 0x00 - const functionCode = ModbusFunctionCode.DEBUG_GET_LIST - const numIndexes = variableIndexes.length - - const pduLength = 4 + 2 * numIndexes - const request = Buffer.alloc(6 + pduLength) - - request.writeUInt16BE(transactionId, 0) - request.writeUInt16BE(protocolId, 2) - request.writeUInt16BE(pduLength, 4) - request.writeUInt8(unitId, 6) - request.writeUInt8(functionCode, 7) - request.writeUInt16BE(numIndexes, 8) - - for (let i = 0; i < numIndexes; i++) { - request.writeUInt16BE(variableIndexes[i], 10 + i * 2) - } + const { request, transactionId } = this.wrapMbap(buildGetListPdu(variableIndexes)) try { const data = await this.sendTcpRequest(request) + const pdu = this.stripMbap(data, transactionId) + const result = parseGetListResponse(pdu) - if (data.length < 9) { - return { success: false, error: `Invalid response: too short (${data.length} bytes, need at least 9)` } - } - - const responseTransactionId = data.readUInt16BE(0) - const responseFunctionCode = data.readUInt8(7) - const statusCode = data.readUInt8(8) - - if (responseTransactionId !== transactionId) { - return { success: false, error: 'Transaction ID mismatch' } - } - - if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_GET_LIST as number)) { - return { success: false, error: 'Function code mismatch' } + if ('error' in result) { + return { success: false, error: result.error } } - if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { - return { success: false, error: 'ERROR_OUT_OF_BOUNDS' } - } - - if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { - return { success: false, error: 'ERROR_OUT_OF_MEMORY' } - } - - if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { - return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } - } - - if (data.length < 17) { - return { - success: false, - error: `Incomplete success response (${data.length} bytes, expected at least 17)`, - } - } - - const lastIndex = data.readUInt16BE(9) - const tick = data.readUInt32BE(11) - const responseSize = data.readUInt16BE(15) - - if (data.length < 17 + responseSize) { - return { - success: false, - error: `Incomplete variable data (expected ${responseSize} bytes, got ${data.length - 17})`, - } - } - - const variableData = data.slice(17, 17 + responseSize) - return { success: true, - tick, - lastIndex, - data: variableData, + tick: result.tick, + lastIndex: result.lastIndex, + data: Buffer.from(result.data), } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) } @@ -251,61 +183,16 @@ export class ModbusTcpClient { return { success: false, error: 'Not connected to target' } } - const transactionId = this.incrementTransactionId() - const protocolId = 0x0000 - const unitId = 0x00 - const functionCode = ModbusFunctionCode.DEBUG_SET - - const dataLength = force && valueBuffer ? valueBuffer.length : 1 - const pduLength = 7 + dataLength - const request = Buffer.alloc(6 + pduLength) - - request.writeUInt16BE(transactionId, 0) - request.writeUInt16BE(protocolId, 2) - request.writeUInt16BE(pduLength, 4) - request.writeUInt8(unitId, 6) - request.writeUInt8(functionCode, 7) - request.writeUInt16BE(variableIndex, 8) - request.writeUInt8(force ? 1 : 0, 10) - request.writeUInt16BE(dataLength, 11) - - if (force && valueBuffer) { - for (let i = 0; i < valueBuffer.length; i++) { - request.writeUInt8(valueBuffer[i], 13 + i) - } - } else { - request.writeUInt8(0, 13) - } + const value = valueBuffer ? new Uint8Array(valueBuffer) : undefined + const { request, transactionId } = this.wrapMbap(buildSetVariablePdu(variableIndex, force, value)) try { const data = await this.sendTcpRequest(request) + const pdu = this.stripMbap(data, transactionId) + const result = parseSetVariableResponse(pdu) - if (data.length < 9) { - return { success: false, error: `Invalid response: too short (${data.length} bytes, need at least 9)` } - } - - const responseTransactionId = data.readUInt16BE(0) - const responseFunctionCode = data.readUInt8(7) - const statusCode = data.readUInt8(8) - - if (responseTransactionId !== transactionId) { - return { success: false, error: 'Transaction ID mismatch' } - } - - if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_SET as number)) { - return { success: false, error: 'Function code mismatch' } - } - - if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { - return { success: false, error: 'ERROR_OUT_OF_BOUNDS' } - } - - if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { - return { success: false, error: 'ERROR_OUT_OF_MEMORY' } - } - - if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { - return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } + if ('error' in result) { + return { success: false, error: result.error } } return { success: true } diff --git a/src/main/modules/modbus/modbus-rtu-client.ts b/src/main/modules/modbus/modbus-rtu-client.ts index b91e62072..ead217f1d 100644 --- a/src/main/modules/modbus/modbus-rtu-client.ts +++ b/src/main/modules/modbus/modbus-rtu-client.ts @@ -1,4 +1,15 @@ -import { ModbusDebugResponse, ModbusFunctionCode } from './modbus-types' +import { + allocBytes, + buildGetListPdu, + buildGetMd5Pdu, + buildSetVariablePdu, + parseGetListResponse, + parseGetMd5Response, + parseSetVariableResponse, + readUint16BE, + writeUint8, + writeUint16BE, +} from '@shared/modbus/modbus-pdu' export interface SerialPortLike { isOpen: boolean @@ -26,14 +37,6 @@ const MD5_REQUEST_RETRY_DELAY_MS = 500 const FRAME_COMPLETE_TIMEOUT_MS = 10 -// --------------------------------------------------------------------------- -// Uint8Array helpers (replacing Node.js Buffer) -// --------------------------------------------------------------------------- - -function allocBytes(size: number): Uint8Array { - return new Uint8Array(size) -} - function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { const result = new Uint8Array(a.length + b.length) result.set(a, 0) @@ -41,27 +44,6 @@ function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { return result } -function readUint8(buf: Uint8Array, offset: number): number { - return buf[offset] -} - -function writeUint8(buf: Uint8Array, offset: number, value: number): void { - buf[offset] = value -} - -function readUint16BE(buf: Uint8Array, offset: number): number { - return (buf[offset] << 8) | buf[offset + 1] -} - -function writeUint16BE(buf: Uint8Array, offset: number, value: number): void { - buf[offset] = (value >>> 8) & 0xff - buf[offset + 1] = value & 0xff -} - -function readUint32BE(buf: Uint8Array, offset: number): number { - return ((buf[offset] << 24) | (buf[offset + 1] << 16) | (buf[offset + 2] << 8) | buf[offset + 3]) >>> 0 -} - // --------------------------------------------------------------------------- // Modbus RTU Client (web-compatible) // --------------------------------------------------------------------------- @@ -256,16 +238,18 @@ export class ModbusRtuClient { }) } - async getMd5Hash(): Promise { - const functionCode = ModbusFunctionCode.DEBUG_GET_MD5 - const endiannessCheck = 0xdead - - const data = allocBytes(4) - writeUint16BE(data, 0, endiannessCheck) - writeUint8(data, 2, 0) - writeUint8(data, 3, 0) + private extractPdu(response: Uint8Array): Uint8Array { + // RTU response is padded: [6 zero bytes][slave ID][PDU...] + // PDU starts at offset 7 + if (response.length < 9) { + throw new Error(`Invalid response: too short (${response.length} bytes, need at least 9)`) + } + return response.subarray(7) + } - const request = this.assembleRequest(functionCode, data) + async getMd5Hash(): Promise { + const pdu = buildGetMd5Pdu() + const request = this.assembleRequest(pdu[0], pdu.subarray(1)) let lastError: Error | null = null for (let attempt = 0; attempt <= MD5_REQUEST_MAX_RETRIES; attempt++) { @@ -275,25 +259,8 @@ export class ModbusRtuClient { } const response = await this.sendRequest(request) - - if (response.length < 9) { - throw new Error('Invalid response: too short') - } - - const functionCodeResponse = readUint8(response, 7) - const statusCode = readUint8(response, 8) - - if (functionCodeResponse !== (ModbusFunctionCode.DEBUG_GET_MD5 as number)) { - throw new Error('Function code mismatch') - } - - if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { - throw new Error(`Target returned error code: 0x${statusCode.toString(16)}`) - } - - const md5Bytes = response.slice(9) - const md5String = new TextDecoder().decode(md5Bytes).trim() - return md5String + const responsePdu = this.extractPdu(response) + return parseGetMd5Response(responsePdu).md5 } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)) if (attempt < MD5_REQUEST_MAX_RETRIES) { @@ -313,67 +280,21 @@ export class ModbusRtuClient { error?: string }> { try { - const functionCode = ModbusFunctionCode.DEBUG_GET_LIST - const numIndexes = variableIndexes.length - - const data = allocBytes(2 + 2 * numIndexes) - writeUint16BE(data, 0, numIndexes) - - for (let i = 0; i < numIndexes; i++) { - writeUint16BE(data, 2 + i * 2, variableIndexes[i]) - } - - const request = this.assembleRequest(functionCode, data) + const pdu = buildGetListPdu(variableIndexes) + const request = this.assembleRequest(pdu[0], pdu.subarray(1)) const response = await this.sendRequest(request) + const responsePdu = this.extractPdu(response) + const result = parseGetListResponse(responsePdu) - if (response.length < 9) { - return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + if ('error' in result) { + return { success: false, error: result.error } } - const functionCodeResponse = readUint8(response, 7) - const statusCode = readUint8(response, 8) - - if (functionCodeResponse !== (ModbusFunctionCode.DEBUG_GET_LIST as number)) { - return { success: false, error: 'Function code mismatch' } - } - - if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { - return { success: false, error: 'ERROR_OUT_OF_BOUNDS' } - } - - if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { - return { success: false, error: 'ERROR_OUT_OF_MEMORY' } - } - - if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { - return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } - } - - if (response.length < 17) { - return { - success: false, - error: `Incomplete success response (${response.length} bytes, expected at least 17)`, - } - } - - const lastIndex = readUint16BE(response, 9) - const tick = readUint32BE(response, 11) - const responseSize = readUint16BE(response, 15) - - if (response.length < 17 + responseSize) { - return { - success: false, - error: `Incomplete variable data (expected ${responseSize} bytes, got ${response.length - 17})`, - } - } - - const variableData = response.slice(17, 17 + responseSize) - return { success: true, - tick, - lastIndex, - data: variableData, + tick: result.tick, + lastIndex: result.lastIndex, + data: result.data, } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) } @@ -389,45 +310,14 @@ export class ModbusRtuClient { error?: string }> { try { - const functionCode = ModbusFunctionCode.DEBUG_SET - - const dataLength = force && valueBuffer ? valueBuffer.length : 1 - const data = allocBytes(5 + dataLength) - - writeUint16BE(data, 0, variableIndex) - writeUint8(data, 2, force ? 1 : 0) - writeUint16BE(data, 3, dataLength) - - if (force && valueBuffer) { - data.set(valueBuffer, 5) - } else { - writeUint8(data, 5, 0) - } - - const request = this.assembleRequest(functionCode, data) + const pdu = buildSetVariablePdu(variableIndex, force, valueBuffer) + const request = this.assembleRequest(pdu[0], pdu.subarray(1)) const response = await this.sendRequest(request) + const responsePdu = this.extractPdu(response) + const result = parseSetVariableResponse(responsePdu) - if (response.length < 9) { - return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } - } - - const functionCodeResponse = readUint8(response, 7) - const statusCode = readUint8(response, 8) - - if (functionCodeResponse !== (ModbusFunctionCode.DEBUG_SET as number)) { - return { success: false, error: 'Function code mismatch' } - } - - if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { - return { success: false, error: 'ERROR_OUT_OF_BOUNDS' } - } - - if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { - return { success: false, error: 'ERROR_OUT_OF_MEMORY' } - } - - if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { - return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } + if ('error' in result) { + return { success: false, error: result.error } } return { success: true } diff --git a/src/main/modules/modbus/modbus-types.ts b/src/main/modules/modbus/modbus-types.ts deleted file mode 100644 index a2ac377f6..000000000 --- a/src/main/modules/modbus/modbus-types.ts +++ /dev/null @@ -1,13 +0,0 @@ -export enum ModbusFunctionCode { - DEBUG_INFO = 0x41, - DEBUG_SET = 0x42, - DEBUG_GET = 0x43, - DEBUG_GET_LIST = 0x44, - DEBUG_GET_MD5 = 0x45, -} - -export enum ModbusDebugResponse { - SUCCESS = 0x7e, - ERROR_OUT_OF_BOUNDS = 0x81, - ERROR_OUT_OF_MEMORY = 0x82, -} diff --git a/src/main/modules/websocket/websocket-debug-client.ts b/src/main/modules/websocket/websocket-debug-client.ts index b2543ccad..120466b5e 100644 --- a/src/main/modules/websocket/websocket-debug-client.ts +++ b/src/main/modules/websocket/websocket-debug-client.ts @@ -1,7 +1,15 @@ +import { + buildGetListPdu, + buildGetMd5Pdu, + buildSetVariablePdu, + bytesToHexString, + hexStringToBytes, + parseGetListResponse, + parseGetMd5Response, + parseSetVariableResponse, +} from '@shared/modbus/modbus-pdu' import { io, Socket } from 'socket.io-client' -import { ModbusDebugResponse, ModbusFunctionCode } from '../modbus/modbus-client' - interface WebSocketDebugClientOptions { host: string port: number @@ -70,33 +78,7 @@ export class WebSocketDebugClient { } } - private bufferToHexString(buffer: Buffer): string { - return Array.from(buffer) - .map((byte) => byte.toString(16).toUpperCase().padStart(2, '0')) - .join(' ') - } - - private hexStringToBuffer(hexString: string): Buffer { - const bytes = hexString.split(' ').map((byte) => parseInt(byte, 16)) - return Buffer.from(bytes) - } - - async getMd5Hash(): Promise { - if (!this.socket) { - throw new Error('Not connected to target') - } - - const functionCode = ModbusFunctionCode.DEBUG_GET_MD5 - const endiannessCheck = 0xdead - - const request = Buffer.alloc(5) - request.writeUInt8(functionCode, 0) - request.writeUInt16BE(endiannessCheck, 1) - request.writeUInt8(0, 3) - request.writeUInt8(0, 4) - - const commandHex = this.bufferToHexString(request) - + private sendCommand(commandHex: string): Promise { return new Promise((resolve, reject) => { const timeoutHandle = setTimeout(() => { reject(new Error('Request timeout')) @@ -116,32 +98,7 @@ export class WebSocketDebugClient { return } - try { - const responseBuffer = this.hexStringToBuffer(response.data) - - if (responseBuffer.length < 2) { - reject(new Error('Invalid response: too short')) - return - } - - const responseFunctionCode = responseBuffer.readUInt8(0) - const statusCode = responseBuffer.readUInt8(1) - - if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_GET_MD5 as number)) { - reject(new Error('Function code mismatch')) - return - } - - if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { - reject(new Error(`Target returned error code: 0x${statusCode.toString(16)}`)) - return - } - - const md5String = responseBuffer.slice(2).toString('utf-8').trim() - resolve(md5String) - } catch (error) { - reject(error instanceof Error ? error : new Error(String(error))) - } + resolve(response.data) } this.socket!.on('debug_response', responseHandler) @@ -149,6 +106,17 @@ export class WebSocketDebugClient { }) } + async getMd5Hash(): Promise { + if (!this.socket) { + throw new Error('Not connected to target') + } + + const commandHex = bytesToHexString(buildGetMd5Pdu()) + const responseHex = await this.sendCommand(commandHex) + const responsePdu = hexStringToBytes(responseHex) + return parseGetMd5Response(responsePdu).md5 + } + async getVariablesList(variableIndexes: number[]): Promise<{ success: boolean tick?: number @@ -160,108 +128,26 @@ export class WebSocketDebugClient { return { success: false, error: 'Not connected to target' } } - const functionCode = ModbusFunctionCode.DEBUG_GET_LIST - const numIndexes = variableIndexes.length - - const request = Buffer.alloc(3 + 2 * numIndexes) - request.writeUInt8(functionCode, 0) - request.writeUInt16BE(numIndexes, 1) - - for (let i = 0; i < numIndexes; i++) { - request.writeUInt16BE(variableIndexes[i], 3 + i * 2) - } - - const commandHex = this.bufferToHexString(request) - - return new Promise((resolve) => { - const timeoutHandle = setTimeout(() => { - resolve({ success: false, error: 'Request timeout' }) - }, 5000) - - const responseHandler = (response: { success: boolean; data?: string; error?: string }) => { - clearTimeout(timeoutHandle) - this.socket?.off('debug_response', responseHandler) - - if (!response.success) { - resolve({ success: false, error: response.error || 'Unknown error' }) - return - } - - if (!response.data) { - resolve({ success: false, error: 'No data in response' }) - return - } - - try { - const responseBuffer = this.hexStringToBuffer(response.data) - - if (responseBuffer.length < 2) { - resolve({ - success: false, - error: `Invalid response: too short (${responseBuffer.length} bytes, need at least 2)`, - }) - return - } + const commandHex = bytesToHexString(buildGetListPdu(variableIndexes)) - const responseFunctionCode = responseBuffer.readUInt8(0) - const statusCode = responseBuffer.readUInt8(1) + try { + const responseHex = await this.sendCommand(commandHex) + const responsePdu = hexStringToBytes(responseHex) + const result = parseGetListResponse(responsePdu) - if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_GET_LIST as number)) { - resolve({ success: false, error: 'Function code mismatch' }) - return - } - - if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { - resolve({ success: false, error: 'ERROR_OUT_OF_BOUNDS' }) - return - } - - if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { - resolve({ success: false, error: 'ERROR_OUT_OF_MEMORY' }) - return - } - - if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { - resolve({ success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` }) - return - } - - if (responseBuffer.length < 10) { - resolve({ - success: false, - error: `Incomplete success response (${responseBuffer.length} bytes, expected at least 10)`, - }) - return - } - - const lastIndex = responseBuffer.readUInt16BE(2) - const tick = responseBuffer.readUInt32BE(4) - const responseSize = responseBuffer.readUInt16BE(8) - - if (responseBuffer.length < 10 + responseSize) { - resolve({ - success: false, - error: `Incomplete variable data (expected ${responseSize} bytes, got ${responseBuffer.length - 10})`, - }) - return - } - - const variableData = responseBuffer.slice(10, 10 + responseSize) - - resolve({ - success: true, - tick, - lastIndex, - data: variableData, - }) - } catch (error) { - resolve({ success: false, error: String(error) }) - } + if ('error' in result) { + return { success: false, error: result.error } } - this.socket!.on('debug_response', responseHandler) - this.socket!.emit('debug_command', { command: commandHex }) - }) + return { + success: true, + tick: result.tick, + lastIndex: result.lastIndex, + data: Buffer.from(result.data), + } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } } async setVariable( @@ -276,87 +162,21 @@ export class WebSocketDebugClient { return { success: false, error: 'Not connected to target' } } - const functionCode = ModbusFunctionCode.DEBUG_SET - - const dataLength = force && valueBuffer ? valueBuffer.length : 1 - const request = Buffer.alloc(6 + dataLength) - - request.writeUInt8(functionCode, 0) - request.writeUInt16BE(variableIndex, 1) - request.writeUInt8(force ? 1 : 0, 3) - request.writeUInt16BE(dataLength, 4) - - if (force && valueBuffer) { - for (let i = 0; i < valueBuffer.length; i++) { - request.writeUInt8(valueBuffer[i], 6 + i) - } - } else { - request.writeUInt8(0, 6) - } - - const commandHex = this.bufferToHexString(request) - - return new Promise((resolve) => { - const timeoutHandle = setTimeout(() => { - resolve({ success: false, error: 'Request timeout' }) - }, 5000) - - const responseHandler = (response: { success: boolean; data?: string; error?: string }) => { - clearTimeout(timeoutHandle) - this.socket?.off('debug_response', responseHandler) - - if (!response.success) { - resolve({ success: false, error: response.error || 'Unknown error' }) - return - } - - if (!response.data) { - resolve({ success: false, error: 'No data in response' }) - return - } + const value = valueBuffer ? new Uint8Array(valueBuffer) : undefined + const commandHex = bytesToHexString(buildSetVariablePdu(variableIndex, force, value)) - try { - const responseBuffer = this.hexStringToBuffer(response.data) + try { + const responseHex = await this.sendCommand(commandHex) + const responsePdu = hexStringToBytes(responseHex) + const result = parseSetVariableResponse(responsePdu) - if (responseBuffer.length < 2) { - resolve({ - success: false, - error: `Invalid response: too short (${responseBuffer.length} bytes, need at least 2)`, - }) - return - } - - const responseFunctionCode = responseBuffer.readUInt8(0) - const statusCode = responseBuffer.readUInt8(1) - - if (responseFunctionCode !== (ModbusFunctionCode.DEBUG_SET as number)) { - resolve({ success: false, error: 'Function code mismatch' }) - return - } - - if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { - resolve({ success: false, error: 'ERROR_OUT_OF_BOUNDS' }) - return - } - - if (statusCode === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { - resolve({ success: false, error: 'ERROR_OUT_OF_MEMORY' }) - return - } - - if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { - resolve({ success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` }) - return - } - - resolve({ success: true }) - } catch (error) { - resolve({ success: false, error: String(error) }) - } + if ('error' in result) { + return { success: false, error: result.error } } - this.socket!.on('debug_response', responseHandler) - this.socket!.emit('debug_command', { command: commandHex }) - }) + return { success: true } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } } } diff --git a/src/shared/modbus/modbus-pdu.spec.ts b/src/shared/modbus/modbus-pdu.spec.ts new file mode 100644 index 000000000..e36101dcf --- /dev/null +++ b/src/shared/modbus/modbus-pdu.spec.ts @@ -0,0 +1,323 @@ +import { + allocBytes, + buildGetListPdu, + buildGetMd5Pdu, + buildSetVariablePdu, + bytesToHexString, + hexStringToBytes, + ModbusDebugResponse, + ModbusFunctionCode, + parseGetListResponse, + parseGetMd5Response, + parseSetVariableResponse, + readUint8, + readUint16BE, + readUint32BE, + writeUint8, + writeUint16BE, +} from './modbus-pdu' + +// --------------------------------------------------------------------------- +// Byte helpers +// --------------------------------------------------------------------------- + +describe('byte helpers', () => { + test('allocBytes returns zeroed Uint8Array of given size', () => { + const buf = allocBytes(4) + expect(buf).toBeInstanceOf(Uint8Array) + expect(buf.length).toBe(4) + expect(Array.from(buf)).toEqual([0, 0, 0, 0]) + }) + + test('writeUint8 / readUint8 round-trip', () => { + const buf = allocBytes(1) + writeUint8(buf, 0, 0xab) + expect(readUint8(buf, 0)).toBe(0xab) + }) + + test('writeUint16BE / readUint16BE round-trip', () => { + const buf = allocBytes(2) + writeUint16BE(buf, 0, 0xdead) + expect(readUint16BE(buf, 0)).toBe(0xdead) + expect(buf[0]).toBe(0xde) + expect(buf[1]).toBe(0xad) + }) + + test('readUint32BE reads big-endian 32-bit unsigned', () => { + const buf = new Uint8Array([0x00, 0x00, 0x00, 0x0a]) + expect(readUint32BE(buf, 0)).toBe(10) + + const buf2 = new Uint8Array([0x80, 0x00, 0x00, 0x01]) + expect(readUint32BE(buf2, 0)).toBe(0x80000001) + }) +}) + +// --------------------------------------------------------------------------- +// Hex encoding +// --------------------------------------------------------------------------- + +describe('hex encoding', () => { + test('bytesToHexString produces space-separated uppercase hex', () => { + expect(bytesToHexString(new Uint8Array([0x44, 0x00, 0x03]))).toBe('44 00 03') + expect(bytesToHexString(new Uint8Array([0xde, 0xad]))).toBe('DE AD') + }) + + test('hexStringToBytes parses space-separated hex', () => { + const bytes = hexStringToBytes('44 00 03') + expect(Array.from(bytes)).toEqual([0x44, 0x00, 0x03]) + }) + + test('round-trip: bytesToHexString -> hexStringToBytes', () => { + const original = new Uint8Array([0x45, 0xde, 0xad, 0x00, 0x00]) + const hex = bytesToHexString(original) + const restored = hexStringToBytes(hex) + expect(Array.from(restored)).toEqual(Array.from(original)) + }) +}) + +// --------------------------------------------------------------------------- +// PDU builders +// --------------------------------------------------------------------------- + +describe('buildGetMd5Pdu', () => { + test('produces correct PDU bytes', () => { + const pdu = buildGetMd5Pdu() + expect(Array.from(pdu)).toEqual([0x45, 0xde, 0xad, 0x00, 0x00]) + }) + + test('hex-encoded matches expected string', () => { + expect(bytesToHexString(buildGetMd5Pdu())).toBe('45 DE AD 00 00') + }) +}) + +describe('buildGetListPdu', () => { + test('produces correct PDU for [3, 7, 12]', () => { + const pdu = buildGetListPdu([3, 7, 12]) + expect(Array.from(pdu)).toEqual([0x44, 0x00, 0x03, 0x00, 0x03, 0x00, 0x07, 0x00, 0x0c]) + }) + + test('hex-encoded matches expected string', () => { + expect(bytesToHexString(buildGetListPdu([3, 7, 12]))).toBe('44 00 03 00 03 00 07 00 0C') + }) + + test('empty indexes produces just header', () => { + const pdu = buildGetListPdu([]) + expect(Array.from(pdu)).toEqual([0x44, 0x00, 0x00]) + }) + + test('single index', () => { + const pdu = buildGetListPdu([256]) + expect(Array.from(pdu)).toEqual([0x44, 0x00, 0x01, 0x01, 0x00]) + }) +}) + +describe('buildSetVariablePdu', () => { + test('force=true with value', () => { + const value = new Uint8Array([0x01]) + const pdu = buildSetVariablePdu(5, true, value) + expect(Array.from(pdu)).toEqual([0x42, 0x00, 0x05, 0x01, 0x00, 0x01, 0x01]) + }) + + test('force=false produces release PDU', () => { + const pdu = buildSetVariablePdu(5, false) + expect(Array.from(pdu)).toEqual([0x42, 0x00, 0x05, 0x00, 0x00, 0x01, 0x00]) + }) + + test('force=true with multi-byte value', () => { + const value = new Uint8Array([0xff, 0x7f, 0x00, 0x00]) + const pdu = buildSetVariablePdu(10, true, value) + expect(Array.from(pdu)).toEqual([0x42, 0x00, 0x0a, 0x01, 0x00, 0x04, 0xff, 0x7f, 0x00, 0x00]) + }) + + test('force=true with no value falls back to release-like', () => { + const pdu = buildSetVariablePdu(5, true) + // force=true but no value → dataLength=1, byte=0x00 + expect(Array.from(pdu)).toEqual([0x42, 0x00, 0x05, 0x01, 0x00, 0x01, 0x00]) + }) +}) + +// --------------------------------------------------------------------------- +// PDU response parsers +// --------------------------------------------------------------------------- + +describe('parseGetMd5Response', () => { + test('parses successful MD5 response', () => { + // FC=0x45, status=SUCCESS, then MD5 string bytes + const md5 = 'abc123def456' + const md5Bytes = new TextEncoder().encode(md5) + const pdu = allocBytes(2 + md5Bytes.length) + writeUint8(pdu, 0, ModbusFunctionCode.DEBUG_GET_MD5) + writeUint8(pdu, 1, ModbusDebugResponse.SUCCESS) + pdu.set(md5Bytes, 2) + + const result = parseGetMd5Response(pdu) + expect(result.md5).toBe('abc123def456') + }) + + test('throws on function code mismatch', () => { + const pdu = new Uint8Array([0x44, 0x7e]) + expect(() => parseGetMd5Response(pdu)).toThrow('Function code mismatch') + }) + + test('throws on error status', () => { + const pdu = new Uint8Array([0x45, 0x81]) + expect(() => parseGetMd5Response(pdu)).toThrow('error code: 0x81') + }) + + test('throws on too-short response', () => { + const pdu = new Uint8Array([0x45]) + expect(() => parseGetMd5Response(pdu)).toThrow('too short') + }) +}) + +describe('parseGetListResponse', () => { + test('parses successful response', () => { + // FC=0x44, status=SUCCESS, lastIndex=3 (BE16), tick=10 (BE32), responseSize=2 (BE16), data=[0xFF, 0x01] + const pdu = new Uint8Array([0x44, 0x7e, 0x00, 0x03, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x02, 0xff, 0x01]) + const result = parseGetListResponse(pdu) + expect('error' in result).toBe(false) + if (!('error' in result)) { + expect(result.lastIndex).toBe(3) + expect(result.tick).toBe(10) + expect(Array.from(result.data)).toEqual([0xff, 0x01]) + } + }) + + test('returns error on ERROR_OUT_OF_MEMORY', () => { + const pdu = new Uint8Array([0x44, 0x82]) + const result = parseGetListResponse(pdu) + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toBe('ERROR_OUT_OF_MEMORY') + expect(result.code).toBe(0x82) + } + }) + + test('returns error on ERROR_OUT_OF_BOUNDS', () => { + const pdu = new Uint8Array([0x44, 0x81]) + const result = parseGetListResponse(pdu) + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toBe('ERROR_OUT_OF_BOUNDS') + expect(result.code).toBe(0x81) + } + }) + + test('returns error on function code mismatch', () => { + const pdu = new Uint8Array([0x45, 0x7e]) + const result = parseGetListResponse(pdu) + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toBe('Function code mismatch') + } + }) + + test('returns error on incomplete success response', () => { + // SUCCESS status but not enough bytes for header fields + const pdu = new Uint8Array([0x44, 0x7e, 0x00, 0x03]) + const result = parseGetListResponse(pdu) + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain('Incomplete success response') + } + }) + + test('returns error on incomplete variable data', () => { + // responseSize says 4 bytes but only 2 follow + const pdu = new Uint8Array([0x44, 0x7e, 0x00, 0x03, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x04, 0xff, 0x01]) + const result = parseGetListResponse(pdu) + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toContain('Incomplete variable data') + } + }) + + test('returns error on too-short response', () => { + const pdu = new Uint8Array([0x44]) + const result = parseGetListResponse(pdu) + expect('error' in result).toBe(true) + }) + + test('parses response with zero data', () => { + // responseSize=0 + const pdu = new Uint8Array([0x44, 0x7e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00]) + const result = parseGetListResponse(pdu) + expect('error' in result).toBe(false) + if (!('error' in result)) { + expect(result.lastIndex).toBe(0) + expect(result.tick).toBe(5) + expect(result.data.length).toBe(0) + } + }) +}) + +describe('parseSetVariableResponse', () => { + test('parses successful response', () => { + const pdu = new Uint8Array([0x42, 0x7e]) + const result = parseSetVariableResponse(pdu) + expect('error' in result).toBe(false) + if (!('error' in result)) { + expect(result.success).toBe(true) + } + }) + + test('returns error on ERROR_OUT_OF_BOUNDS', () => { + const pdu = new Uint8Array([0x42, 0x81]) + const result = parseSetVariableResponse(pdu) + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.code).toBe(0x81) + } + }) + + test('returns error on function code mismatch', () => { + const pdu = new Uint8Array([0x44, 0x7e]) + const result = parseSetVariableResponse(pdu) + expect('error' in result).toBe(true) + }) + + test('returns error on too-short response', () => { + const pdu = new Uint8Array([0x42]) + const result = parseSetVariableResponse(pdu) + expect('error' in result).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// Cross-validation: PDU bytes match editor's WebSocketDebugClient output +// --------------------------------------------------------------------------- + +describe('byte-for-byte compatibility with editor', () => { + test('getMd5 request matches WebSocketDebugClient', () => { + // Editor: Buffer.alloc(5), writeUInt8(0x45, 0), writeUInt16BE(0xDEAD, 1), writeUInt8(0, 3), writeUInt8(0, 4) + const pdu = buildGetMd5Pdu() + expect(pdu[0]).toBe(0x45) // function code + expect((pdu[1] << 8) | pdu[2]).toBe(0xdead) // endianness check + expect(pdu[3]).toBe(0x00) + expect(pdu[4]).toBe(0x00) + }) + + test('getVariablesList request matches WebSocketDebugClient', () => { + // Editor: writeUInt8(0x44, 0), writeUInt16BE(numIndexes, 1), writeUInt16BE(each, 3+i*2) + const indexes = [0, 1, 255, 1000] + const pdu = buildGetListPdu(indexes) + expect(pdu[0]).toBe(0x44) + expect(readUint16BE(pdu, 1)).toBe(4) // numIndexes + expect(readUint16BE(pdu, 3)).toBe(0) + expect(readUint16BE(pdu, 5)).toBe(1) + expect(readUint16BE(pdu, 7)).toBe(255) + expect(readUint16BE(pdu, 9)).toBe(1000) + }) + + test('setVariable request matches WebSocketDebugClient', () => { + // Editor: writeUInt8(0x42, 0), writeUInt16BE(idx, 1), writeUInt8(force, 3), writeUInt16BE(dataLen, 4), data at 6 + const value = new Uint8Array([0xab, 0xcd]) + const pdu = buildSetVariablePdu(42, true, value) + expect(pdu[0]).toBe(0x42) // function code + expect(readUint16BE(pdu, 1)).toBe(42) // variable index + expect(pdu[3]).toBe(1) // force flag + expect(readUint16BE(pdu, 4)).toBe(2) // data length + expect(pdu[6]).toBe(0xab) // value byte 0 + expect(pdu[7]).toBe(0xcd) // value byte 1 + }) +}) diff --git a/src/shared/modbus/modbus-pdu.ts b/src/shared/modbus/modbus-pdu.ts new file mode 100644 index 000000000..b463b6ee8 --- /dev/null +++ b/src/shared/modbus/modbus-pdu.ts @@ -0,0 +1,236 @@ +// Shared Modbus PDU module — browser and Node.js compatible. +// No dependencies. Uses Uint8Array (Buffer extends Uint8Array in Node.js). +// +// This module contains: +// - Modbus debug function codes and response status codes +// - PDU builders for debug requests (transport-agnostic) +// - PDU parsers for debug responses (transport-agnostic) +// - Hex string encoding/decoding for WebSocket and WebRTC transports +// - Uint8Array byte helpers + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export enum ModbusFunctionCode { + DEBUG_INFO = 0x41, + DEBUG_SET = 0x42, + DEBUG_GET = 0x43, + DEBUG_GET_LIST = 0x44, + DEBUG_GET_MD5 = 0x45, +} + +export enum ModbusDebugResponse { + SUCCESS = 0x7e, + ERROR_OUT_OF_BOUNDS = 0x81, + ERROR_OUT_OF_MEMORY = 0x82, +} + +// --------------------------------------------------------------------------- +// Uint8Array byte helpers +// --------------------------------------------------------------------------- + +export function allocBytes(size: number): Uint8Array { + return new Uint8Array(size) +} + +export function readUint8(buf: Uint8Array, offset: number): number { + return buf[offset] +} + +export function writeUint8(buf: Uint8Array, offset: number, value: number): void { + buf[offset] = value +} + +export function readUint16BE(buf: Uint8Array, offset: number): number { + return (buf[offset] << 8) | buf[offset + 1] +} + +export function writeUint16BE(buf: Uint8Array, offset: number, value: number): void { + buf[offset] = (value >>> 8) & 0xff + buf[offset + 1] = value & 0xff +} + +export function readUint32BE(buf: Uint8Array, offset: number): number { + return ((buf[offset] << 24) | (buf[offset + 1] << 16) | (buf[offset + 2] << 8) | buf[offset + 3]) >>> 0 +} + +// --------------------------------------------------------------------------- +// Hex string encoding/decoding (for WebSocket and WebRTC transports) +// --------------------------------------------------------------------------- + +export function bytesToHexString(bytes: Uint8Array): string { + return Array.from(bytes) + .map((byte) => byte.toString(16).toUpperCase().padStart(2, '0')) + .join(' ') +} + +export function hexStringToBytes(hex: string): Uint8Array { + const parts = hex.split(' ') + const bytes = new Uint8Array(parts.length) + for (let i = 0; i < parts.length; i++) { + bytes[i] = parseInt(parts[i], 16) + } + return bytes +} + +// --------------------------------------------------------------------------- +// PDU builders — return raw PDU bytes (function code + payload). +// Consumers add transport framing: +// TCP: prepend MBAP header +// RTU: prepend slave ID, append CRC +// WebSocket/WebRTC: hex-encode with bytesToHexString() +// --------------------------------------------------------------------------- + +export function buildGetMd5Pdu(): Uint8Array { + const pdu = allocBytes(5) + writeUint8(pdu, 0, ModbusFunctionCode.DEBUG_GET_MD5) + writeUint16BE(pdu, 1, 0xdead) // endianness check marker + writeUint8(pdu, 3, 0) + writeUint8(pdu, 4, 0) + return pdu +} + +export function buildGetListPdu(indexes: number[]): Uint8Array { + const numIndexes = indexes.length + const pdu = allocBytes(3 + 2 * numIndexes) + writeUint8(pdu, 0, ModbusFunctionCode.DEBUG_GET_LIST) + writeUint16BE(pdu, 1, numIndexes) + for (let i = 0; i < numIndexes; i++) { + writeUint16BE(pdu, 3 + i * 2, indexes[i]) + } + return pdu +} + +export function buildSetVariablePdu(index: number, force: boolean, value?: Uint8Array): Uint8Array { + const dataLength = force && value ? value.length : 1 + const pdu = allocBytes(6 + dataLength) + writeUint8(pdu, 0, ModbusFunctionCode.DEBUG_SET) + writeUint16BE(pdu, 1, index) + writeUint8(pdu, 3, force ? 1 : 0) + writeUint16BE(pdu, 4, dataLength) + if (force && value) { + pdu.set(value, 6) + } else { + writeUint8(pdu, 6, 0) + } + return pdu +} + +// --------------------------------------------------------------------------- +// PDU response parsers — take raw PDU bytes starting at the function code. +// Consumers strip transport framing before calling: +// TCP: skip MBAP header (7 bytes) → PDU at offset 7 +// RTU: skip padding (6 bytes) + slave ID (1 byte) → PDU at offset 7 +// WebSocket/WebRTC: hexStringToBytes() → PDU at offset 0 +// --------------------------------------------------------------------------- + +export interface GetMd5Result { + md5: string +} + +export interface GetListResult { + lastIndex: number + tick: number + data: Uint8Array +} + +export interface SetVariableResult { + success: true +} + +export interface PduError { + error: string + code: number +} + +export function parseGetMd5Response(pdu: Uint8Array): GetMd5Result { + if (pdu.length < 2) { + throw new Error('Invalid response: too short') + } + + const fc = readUint8(pdu, 0) + const status = readUint8(pdu, 1) + + if (fc !== (ModbusFunctionCode.DEBUG_GET_MD5 as number)) { + throw new Error('Function code mismatch') + } + + if (status !== (ModbusDebugResponse.SUCCESS as number)) { + throw new Error(`Target returned error code: 0x${status.toString(16)}`) + } + + const md5Bytes = pdu.slice(2) + const md5String = new TextDecoder().decode(md5Bytes).trim() + return { md5: md5String } +} + +export function parseGetListResponse(pdu: Uint8Array): GetListResult | PduError { + if (pdu.length < 2) { + return { error: `Invalid response: too short (${pdu.length} bytes, need at least 2)`, code: 0 } + } + + const fc = readUint8(pdu, 0) + const status = readUint8(pdu, 1) + + if (fc !== (ModbusFunctionCode.DEBUG_GET_LIST as number)) { + return { error: 'Function code mismatch', code: 0 } + } + + if (status === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { + return { error: 'ERROR_OUT_OF_BOUNDS', code: status } + } + + if (status === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { + return { error: 'ERROR_OUT_OF_MEMORY', code: status } + } + + if (status !== (ModbusDebugResponse.SUCCESS as number)) { + return { error: `Unknown error code: 0x${status.toString(16)}`, code: status } + } + + if (pdu.length < 10) { + return { error: `Incomplete success response (${pdu.length} bytes, expected at least 10)`, code: 0 } + } + + const lastIndex = readUint16BE(pdu, 2) + const tick = readUint32BE(pdu, 4) + const responseSize = readUint16BE(pdu, 8) + + if (pdu.length < 10 + responseSize) { + return { + error: `Incomplete variable data (expected ${responseSize} bytes, got ${pdu.length - 10})`, + code: 0, + } + } + + const data = pdu.slice(10, 10 + responseSize) + return { lastIndex, tick, data } +} + +export function parseSetVariableResponse(pdu: Uint8Array): SetVariableResult | PduError { + if (pdu.length < 2) { + return { error: `Invalid response: too short (${pdu.length} bytes, need at least 2)`, code: 0 } + } + + const fc = readUint8(pdu, 0) + const status = readUint8(pdu, 1) + + if (fc !== (ModbusFunctionCode.DEBUG_SET as number)) { + return { error: 'Function code mismatch', code: 0 } + } + + if (status === (ModbusDebugResponse.ERROR_OUT_OF_BOUNDS as number)) { + return { error: 'ERROR_OUT_OF_BOUNDS', code: status } + } + + if (status === (ModbusDebugResponse.ERROR_OUT_OF_MEMORY as number)) { + return { error: 'ERROR_OUT_OF_MEMORY', code: status } + } + + if (status !== (ModbusDebugResponse.SUCCESS as number)) { + return { error: `Unknown error code: 0x${status.toString(16)}`, code: status } + } + + return { success: true } +}