From c3dac157ddd967c536f833fe19295ff55918a2b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Fri, 3 Jul 2026 10:53:45 +0200 Subject: [PATCH 01/19] feat(debugger): always-on serial debugger decoupled from full Modbus Expose the debug function codes over serial without enabling full Modbus RTU/TCP. A new DEBUGGER_ENABLED gate brings up the serial port and the debug FCs without allocating any operation buffers (coils/holding/input regs), saving SRAM on small AVR boards. When Modbus is enabled behaviour is unchanged. Firmware: - MB_SERIAL_ACTIVE gate (MBSERIAL || DEBUGGER_ENABLED) guards serial RX/framing - debug-only setup path (Serial/115200/slave 1 defaults, overridable via DEBUG_IFACE/DEBUG_BAUD/DEBUG_SLAVE); no init_mbregs()/mapEmptyBuffers() - process_mbpacket() gates operation FCs under MODBUS_ENABLED -> operation requests return ILLEGAL_FUNCTION in debug-only builds - new FCs 0x46 status, 0x47 version, 0x48 board-id (ArduinoUniqueID, with a compilable fallback); RTU framing + CRC-bypass wired for all three - OPENPLC_RUNTIME_VERSION in openplc_version.h Editor/shared: - ArduinoUniqueID added to GLOBAL_LIBRARIES - mirrored FC enums (editor + simulator) - modbus-pdu build/parse helpers for the 3 new FCs (100% covered) - ModbusRtuClient getStatus/getVersion/getBoardId (100% covered) - generate-defines emits the //Debugger block for baremetal targets when full Modbus is off - simulator debug E2E cases for 0x46/0x47/0x48 (gated by CHRIS_DEMO_HEX) Co-Authored-By: Claude Opus 4.8 (1M context) --- resources/sources/Baremetal/Baremetal.ino | 25 +- resources/sources/Baremetal/ModbusSlave.cpp | 115 +++++++- resources/sources/Baremetal/ModbusSlave.h | 34 ++- resources/sources/Baremetal/openplc_version.h | 19 ++ .../editor/compiler/compiler-module.ts | 4 + src/backend/editor/modbus/modbus-client.ts | 3 + .../__tests__/generate-defines.test.ts | 41 +++ .../shared/compile/steps/generate-defines.ts | 18 ++ .../shared/debug/__tests__/modbus-pdu.test.ts | 185 +++++++++++++ src/backend/shared/debug/modbus-pdu.ts | 124 ++++++++- src/backend/shared/debug/types.ts | 32 +++ .../simulator/__tests__/debug-e2e.test.ts | 30 +++ .../__tests__/modbus-rtu-client.test.ts | 248 ++++++++++++++++++ .../shared/simulator/modbus-rtu-client.ts | 106 ++++++++ src/backend/shared/simulator/types.ts | 3 + 15 files changed, 977 insertions(+), 10 deletions(-) create mode 100644 resources/sources/Baremetal/openplc_version.h diff --git a/resources/sources/Baremetal/Baremetal.ino b/resources/sources/Baremetal/Baremetal.ino index 126e6478e..adcbeaf32 100644 --- a/resources/sources/Baremetal/Baremetal.ino +++ b/resources/sources/Baremetal/Baremetal.ino @@ -30,7 +30,7 @@ #include "defines.h" #include "arduino_runtime_glue.h" -#ifdef MODBUS_ENABLED +#if defined(MODBUS_ENABLED) || defined(DEBUGGER_ENABLED) #include "ModbusSlave.h" #endif @@ -178,6 +178,15 @@ void setup() init_mbregs(MAX_ANALOG_OUTPUT + MAX_MEMORY_WORD, MAX_MEMORY_DWORD, MAX_MEMORY_LWORD, MAX_DIGITAL_OUTPUT, MAX_ANALOG_INPUT, MAX_DIGITAL_INPUT); mapEmptyBuffers(); + #elif defined(DEBUGGER_ENABLED) + // Always-on debugger without full Modbus: bring up the serial port and + // the Modbus RTU framing/slave id ONLY. The debugger reads/writes IEC + // variables directly through the strucpp debug table (openplc_debug_*), + // so it needs NO operation buffers — init_mbregs()/mapEmptyBuffers() are + // deliberately not called here, saving SRAM on small boards. + DEBUG_IFACE.begin(DEBUG_BAUD); + mbconfig_serial_iface(&DEBUG_IFACE, DEBUG_BAUD, -1); + modbus.slaveid = DEBUG_SLAVE; #endif setupCycleDelay(base_tick_ns); @@ -369,8 +378,12 @@ void scheduler() sketch_loop(); #endif - #ifdef MODBUS_ENABLED + #if defined(MODBUS_ENABLED) modbusTask(); + #elif defined(DEBUGGER_ENABLED) + // Debug-only: poll the serial transport for debugger requests. No buffer + // sync (modbusTask's mirror loops) because there are no operation buffers. + mbtask(); #endif if (!first_cycle) @@ -392,12 +405,18 @@ void loop() last_run += scan_cycle; } - #ifdef MODBUS_ENABLED + #if defined(MODBUS_ENABLED) // Only run Modbus task again if we have at least 10ms gap until the next cycle if ((micros() - last_run) >= 10000) { modbusTask(); } + #elif defined(DEBUGGER_ENABLED) + // Debug-only: give the debugger extra serial-poll time between cycles too. + if ((micros() - last_run) >= 10000) + { + mbtask(); + } #endif #ifdef SIMULATOR_MODE diff --git a/resources/sources/Baremetal/ModbusSlave.cpp b/resources/sources/Baremetal/ModbusSlave.cpp index 80d113e9d..8350f6b1d 100644 --- a/resources/sources/Baremetal/ModbusSlave.cpp +++ b/resources/sources/Baremetal/ModbusSlave.cpp @@ -12,6 +12,15 @@ Copyright (C) 2022 OpenPLC - Thiago Alves // of the precompiled OpenPLCUserLib archive built with -std=gnu++17. #include "arduino_runtime_glue.h" +// ArduinoUniqueID (ricaun) backs the DEBUG_GET_BOARD_ID (0x48) function code. +// It supports AVR/megaAVR/SAM/SAMD/STM32/ESP/RP2040/Teensy. On a core without +// support (or when a board intentionally opts out via OPENPLC_NO_UNIQUE_ID), +// the board-id handler returns id_len = 0 instead of failing to compile. +#ifndef OPENPLC_NO_UNIQUE_ID + #include + #define OPENPLC_HAS_UNIQUE_ID +#endif + //Global Modbus vars struct MBinfo modbus; uint8_t mb_frame[MAX_MB_FRAME]; @@ -242,7 +251,7 @@ void mbtask() #ifdef MBTCP handle_tcp(); #endif - #ifdef MBSERIAL + #ifdef MB_SERIAL_ACTIVE handle_serial(); #endif } @@ -397,7 +406,7 @@ void handle_tcp() } #endif -#ifdef MBSERIAL +#ifdef MB_SERIAL_ACTIVE // Inter-frame idle, in milliseconds, used ONLY to abandon a frame whose // remainder never arrives. Modbus RTU was defined for RS485, where bytes of a // frame are ~one character time apart (T1.5/T3.5, tens of microseconds at @@ -452,6 +461,10 @@ static int32_t mb_rtu_frame_len(const uint8_t *f, uint16_t n) return 10 + (int32_t)(((uint16_t)f[6] << 8) | f[7]); case MB_FC_DEBUG_GET_MD5: return 8; // [id][fc][endian:2][00:2][crc:2] + case MB_FC_DEBUG_GET_STATUS: + case MB_FC_DEBUG_GET_VERSION: + case MB_FC_DEBUG_GET_BOARD_ID: + return 4; // [id][fc][crc:2] default: return -1; // not one of our function codes } @@ -521,7 +534,7 @@ void handle_serial() // Standard FCs are validated by CRC (the arbiter that makes resync // trustworthy); a mismatch means corruption or misalignment, so we // slide one byte and retry instead of discarding the whole buffer. - if (mb_frame[1] != MB_FC_DEBUG_INFO && mb_frame[1] != MB_FC_DEBUG_SET && mb_frame[1] != MB_FC_DEBUG_GET && mb_frame[1] != MB_FC_DEBUG_GET_LIST && mb_frame[1] != MB_FC_DEBUG_GET_MD5) + if (mb_frame[1] != MB_FC_DEBUG_INFO && mb_frame[1] != MB_FC_DEBUG_SET && mb_frame[1] != MB_FC_DEBUG_GET && mb_frame[1] != MB_FC_DEBUG_GET_LIST && mb_frame[1] != MB_FC_DEBUG_GET_MD5 && mb_frame[1] != MB_FC_DEBUG_GET_STATUS && mb_frame[1] != MB_FC_DEBUG_GET_VERSION && mb_frame[1] != MB_FC_DEBUG_GET_BOARD_ID) { mb_frame_len = (uint16_t)expected; packet_crc = ((mb_frame[expected - 2] << 8) | mb_frame[expected - 1]); @@ -594,13 +607,21 @@ void handle_serial() void process_mbpacket() { uint8_t fcode = mb_frame[1]; - // Standard Modbus fields — preserved for the non-debug FCs. +#ifdef MODBUS_ENABLED + // Standard Modbus fields — only used by the operation FCs, which are + // compiled out in debug-only builds (so guard to avoid unused-var warnings). uint16_t field1 = (uint16_t)mb_frame[2] << 8 | (uint16_t)mb_frame[3]; uint16_t field2 = (uint16_t)mb_frame[4] << 8 | (uint16_t)mb_frame[5]; +#endif void *endianness_check = &mb_frame[2]; switch (fcode) { +#ifdef MODBUS_ENABLED + // Standard Modbus operation FCs read/write the coil/register buffers, + // which only exist when full Modbus is enabled. In debug-only builds + // these cases are compiled out, so operation requests fall through to + // the default and get an ILLEGAL_FUNCTION exception. case MB_FC_WRITE_REG: //field1 = reg, field2 = value writeSingleRegister(field1, field2); @@ -640,6 +661,7 @@ void process_mbpacket() //field1 = startreg, field2 = numoutputs writeMultipleCoils(field1, field2, mb_frame[6]); break; +#endif // MODBUS_ENABLED case MB_FC_DEBUG_INFO: debugInfo(); @@ -679,6 +701,18 @@ void process_mbpacket() debugGetMd5(endianness_check); break; + case MB_FC_DEBUG_GET_STATUS: + debugGetStatus(); + break; + + case MB_FC_DEBUG_GET_VERSION: + debugGetVersion(); + break; + + case MB_FC_DEBUG_GET_BOARD_ID: + debugGetBoardId(); + break; + default: exceptionResponse(fcode, MB_EX_ILLEGAL_FUNCTION); } @@ -1330,7 +1364,7 @@ void debugGetTraceList(uint16_t numIndexes, uint8_t *indexArray) uint16_t responseSize = 0; uint16_t lastReqIdx = 0; - #ifdef MBSERIAL + #ifdef MB_SERIAL_ACTIVE #define VARIDX_SIZE 20 #else #define VARIDX_SIZE 60 @@ -1439,6 +1473,77 @@ void debugGetMd5(void * /*endianness*/) mb_frame_len = md5_len + 5; } +// PDU request: [FC] +// PDU response: [FC, STATUS, running:u8, tick:u32 BE, uptime_ms:u32 BE] +// +// Lightweight liveness/diagnostic probe that does not require a full debug +// session. `running` is always 1 on baremetal (the PLC scan is unconditional); +// `tick` is the scan counter (same value the read FCs report), so a client can +// tell whether the PLC is actually cycling by watching it advance. `uptime_ms` +// is millis() since boot. +void debugGetStatus() +{ + uint32_t uptime = (uint32_t)millis(); + + mb_frame[1] = MB_FC_DEBUG_GET_STATUS; + mb_frame[2] = MB_DEBUG_SUCCESS; + mb_frame[3] = 1; // PLC scan is always running on baremetal + mb_frame[4] = (uint8_t)((scan_counter >> 24) & 0xFF); + mb_frame[5] = (uint8_t)((scan_counter >> 16) & 0xFF); + mb_frame[6] = (uint8_t)((scan_counter >> 8) & 0xFF); + mb_frame[7] = (uint8_t)(scan_counter & 0xFF); + mb_frame[8] = (uint8_t)((uptime >> 24) & 0xFF); + mb_frame[9] = (uint8_t)((uptime >> 16) & 0xFF); + mb_frame[10] = (uint8_t)((uptime >> 8) & 0xFF); + mb_frame[11] = (uint8_t)(uptime & 0xFF); + mb_frame_len = 12; +} + +// PDU request: [FC] +// PDU response: [FC, STATUS, version_ascii...] (no NUL terminator) +// +// Reports OPENPLC_RUNTIME_VERSION (defined in openplc_version.h). The editor +// reads the ASCII bytes up to the end of the frame. +void debugGetVersion() +{ + mb_frame[1] = MB_FC_DEBUG_GET_VERSION; + mb_frame[2] = MB_DEBUG_SUCCESS; + + const char ver[] = OPENPLC_RUNTIME_VERSION; + uint16_t i = 0; + for (i = 0; ver[i] != '\0'; i++) + { + if ((uint16_t)(3 + i) >= MAX_MB_FRAME) break; // never overrun the frame + mb_frame[3 + i] = (uint8_t)ver[i]; + } + mb_frame_len = 3 + i; +} + +// PDU request: [FC] +// PDU response: [FC, STATUS, id_len:u8, id_bytes...] +// +// Returns the unique hardware ID via ArduinoUniqueID. id_len is UniqueIDsize +// (architecture-dependent: AVR 9-10, ESP8266 4, ESP32 6, SAM/SAMD 16, STM32 +// 12, Teensy 8). On a core without support, id_len = 0 and no bytes follow. +void debugGetBoardId() +{ + mb_frame[1] = MB_FC_DEBUG_GET_BOARD_ID; + mb_frame[2] = MB_DEBUG_SUCCESS; + +#ifdef OPENPLC_HAS_UNIQUE_ID + uint8_t idLen = (uint8_t)UniqueIDsize; + // Clamp so [FC][STATUS][id_len][id_bytes...] always fits the frame. + if ((uint16_t)(4 + idLen) > MAX_MB_FRAME) idLen = (uint8_t)(MAX_MB_FRAME - 4); + mb_frame[3] = idLen; + for (uint8_t i = 0; i < idLen; i++) + mb_frame[4 + i] = UniqueID[i]; + mb_frame_len = 4 + idLen; +#else + mb_frame[3] = 0; // no unique-id support on this core + mb_frame_len = 4; +#endif +} + uint16_t calcCrc() { uint8_t CRCHi = 0xFF, CRCLo = 0x0FF, Index; diff --git a/resources/sources/Baremetal/ModbusSlave.h b/resources/sources/Baremetal/ModbusSlave.h index 47236126d..5591eb387 100644 --- a/resources/sources/Baremetal/ModbusSlave.h +++ b/resources/sources/Baremetal/ModbusSlave.h @@ -8,6 +8,31 @@ Copyright (C) 2022 OpenPLC - Thiago Alves #include #include "defines.h" +#include "openplc_version.h" + +// Serial transport is active when full Modbus RTU (MBSERIAL) is enabled OR the +// always-on debugger (DEBUGGER_ENABLED) needs the serial port without the rest +// of Modbus. This gate guards the serial RX/framing code so the debugger works +// over serial even when no Modbus operation buffers (coils/holding/etc.) are +// allocated. +#if defined(MBSERIAL) || defined(DEBUGGER_ENABLED) + #define MB_SERIAL_ACTIVE +#endif + +// Default serial config for the always-on debugger when full Modbus RTU +// (MBSERIAL_*) is NOT configured. Override any of these in defines.h to change +// the port/baud/slave the debugger listens on. +#if defined(DEBUGGER_ENABLED) && !defined(MBSERIAL) + #ifndef DEBUG_IFACE + #define DEBUG_IFACE Serial + #endif + #ifndef DEBUG_BAUD + #define DEBUG_BAUD 115200 + #endif + #ifndef DEBUG_SLAVE + #define DEBUG_SLAVE 1 + #endif +#endif #ifndef bitRead #define bitRead(value, bit) (((value) >> (bit)) & 0x01) @@ -111,6 +136,9 @@ enum { MB_FC_DEBUG_GET = 0x43, // Debug get trace (read variables) MB_FC_DEBUG_GET_LIST = 0x44, // Debug get trace list (read list of variables) MB_FC_DEBUG_GET_MD5 = 0x45, // Debug get current program MD5 + MB_FC_DEBUG_GET_STATUS = 0x46, // Debug get PLC status (running, scan tick, uptime) + MB_FC_DEBUG_GET_VERSION = 0x47, // Debug get runtime firmware version + MB_FC_DEBUG_GET_BOARD_ID = 0x48, // Debug get unique hardware board ID }; //Exception Codes @@ -161,7 +189,7 @@ void mbtask(); #ifdef MBTCP void handle_tcp(); #endif -#ifdef MBSERIAL +#ifdef MB_SERIAL_ACTIVE void handle_serial(); #endif void process_mbpacket(); @@ -185,6 +213,10 @@ void debugSetTrace(uint8_t arr, uint16_t elem, uint8_t flag, void debugGetTrace(uint8_t arr, uint16_t startidx, uint16_t endidx); void debugGetTraceList(uint16_t numIndexes, uint8_t *indexArray); void debugGetMd5(void *endianness); +// Always-on debugger extras — served even without full Modbus (DEBUGGER_ENABLED). +void debugGetStatus(void); +void debugGetVersion(void); +void debugGetBoardId(void); /* Table of CRC values for high-order byte */ diff --git a/resources/sources/Baremetal/openplc_version.h b/resources/sources/Baremetal/openplc_version.h new file mode 100644 index 000000000..8023a9080 --- /dev/null +++ b/resources/sources/Baremetal/openplc_version.h @@ -0,0 +1,19 @@ +/* +openplc_version.h - OpenPLC runtime/firmware version +Copyright (C) 2022 OpenPLC - Thiago Alves + +Single source of truth for the firmware version reported by the always-on +debugger (Modbus FC 0x47, DEBUG_GET_VERSION). This is a property of the +firmware source tree, NOT the editor application version, so it is defined +here rather than injected by the editor at compile time. Bump manually when +the firmware runtime evolves. +*/ + +#ifndef OPENPLC_VERSION_H +#define OPENPLC_VERSION_H + +#ifndef OPENPLC_RUNTIME_VERSION + #define OPENPLC_RUNTIME_VERSION "4.2.7" +#endif + +#endif // OPENPLC_VERSION_H diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 6a3ae3cdb..74f9bf34b 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -195,6 +195,10 @@ class CompilerModule { 'ArduinoJson', 'Arduino_MachineControl', 'ArduinoMqttClient', + // Backs the always-on debugger's DEBUG_GET_BOARD_ID (FC 0x48). ModbusSlave.cpp + // includes unconditionally (not behind a USE_*_BLOCK gate), + // so the lib must be installed for every Arduino build. + 'ArduinoUniqueID', 'AVR_PWM', 'CAN', 'CONTROLLINO', diff --git a/src/backend/editor/modbus/modbus-client.ts b/src/backend/editor/modbus/modbus-client.ts index 5074b80bf..fba1f2508 100644 --- a/src/backend/editor/modbus/modbus-client.ts +++ b/src/backend/editor/modbus/modbus-client.ts @@ -9,6 +9,9 @@ export enum ModbusFunctionCode { DEBUG_GET = 0x43, DEBUG_GET_LIST = 0x44, DEBUG_GET_MD5 = 0x45, + DEBUG_GET_STATUS = 0x46, + DEBUG_GET_VERSION = 0x47, + DEBUG_GET_BOARD_ID = 0x48, } export enum ModbusDebugResponse { diff --git a/src/backend/shared/compile/__tests__/generate-defines.test.ts b/src/backend/shared/compile/__tests__/generate-defines.test.ts index 0ef0902b3..05a9b75ce 100644 --- a/src/backend/shared/compile/__tests__/generate-defines.test.ts +++ b/src/backend/shared/compile/__tests__/generate-defines.test.ts @@ -153,6 +153,43 @@ describe('generateDefinesContent — simulator comms block', () => { }) }) +describe('generateDefinesContent — Debugger block (always-on debug)', () => { + it('emits DEBUGGER_ENABLED for a baremetal arduino-cli target with no Modbus', () => { + const out = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'arduino-cli' }) + expect(out).toContain('//Debugger\n#define DEBUGGER_ENABLED\n') + }) + + it('emits DEBUGGER_ENABLED when the Modbus screen is present but disabled', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + vppModbusState: { modbus_rtu: { enabled: false }, modbus_tcp: { enabled: false } }, + }) + expect(out).toContain('#define DEBUGGER_ENABLED') + }) + + it('does NOT emit DEBUGGER_ENABLED when full Modbus is enabled (debugger rides Modbus)', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + vppModbusState: { + modbus_rtu: { enabled: true, rtu_interface: 'Serial1', rtu_baud_rate: '115200', rtu_slave_id: 1 }, + }, + }) + expect(out).not.toContain('DEBUGGER_ENABLED') + }) + + it('does NOT emit DEBUGGER_ENABLED for the simulator (it uses the full Modbus path)', () => { + const out = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'simulator' }) + expect(out).not.toContain('DEBUGGER_ENABLED') + }) + + it('does NOT emit DEBUGGER_ENABLED for openplc-compiler runtimes', () => { + const out = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'openplc-compiler' }) + expect(out).not.toContain('DEBUGGER_ENABLED') + }) +}) + describe('generateDefinesContent — IO Config (pin masks)', () => { it('emits empty pin masks when devicePinMapping is empty', () => { const out = generateDefinesContent(EMPTY_INPUTS) @@ -358,6 +395,10 @@ describe('generateDefinesContent — full output snapshot', () => { '//Program MD5', '#define PROGRAM_MD5 "ffffffffffffffffffffffffffffffff"', '', + '//Debugger', + '#define DEBUGGER_ENABLED', + '', + '', '//IO Config', '#define PINMASK_DIN ', '#define PINMASK_AIN 4', diff --git a/src/backend/shared/compile/steps/generate-defines.ts b/src/backend/shared/compile/steps/generate-defines.ts index ecddca33e..a5e0b55c2 100644 --- a/src/backend/shared/compile/steps/generate-defines.ts +++ b/src/backend/shared/compile/steps/generate-defines.ts @@ -145,6 +145,7 @@ export function generateDefinesContent(input: GenerateDefinesInput): string { // Runtime-v4 / runtime-v3 targets route Modbus config through // `conf/modbus_slave.json` in the upload bundle and emit no // macros here. + let modbusEnabled = false if (boardRuntime === 'simulator') { DEFINES_CONTENT += '//Comms Configuration\n' DEFINES_CONTENT += '#define SIMULATOR_MODE\n' @@ -154,14 +155,31 @@ export function generateDefinesContent(input: GenerateDefinesInput): string { DEFINES_CONTENT += '#define MBSERIAL\n' DEFINES_CONTENT += '#define MODBUS_ENABLED\n' DEFINES_CONTENT += `\n\n` + modbusEnabled = true } else if (boardRuntime !== 'openplc-compiler' && vppModbusState) { const modbusBlock = generateModbusDefines(vppModbusState) if (modbusBlock.length > 0) { DEFINES_CONTENT += modbusBlock DEFINES_CONTENT += '\n\n' + modbusEnabled = true } } + // 4b. Debugger — always-on debug over serial for baremetal Arduino targets. + // Emitted only when full Modbus is NOT active: with Modbus off, this is + // the gate that brings up the serial port and the debug function codes + // (0x41-0x48) WITHOUT allocating any operation buffers (coils/holding/ + // etc.), saving SRAM on small boards. When Modbus IS active the debugger + // already rides Modbus's own transport, so emitting DEBUGGER_ENABLED then + // would be redundant (and, in a TCP-only Modbus build, would leave the + // serial port uninitialised). Simulator gets MODBUS_ENABLED above; + // openplc-compiler runtimes don't use this firmware at all. + if (boardRuntime !== 'simulator' && boardRuntime !== 'openplc-compiler' && !modbusEnabled) { + DEFINES_CONTENT += '//Debugger\n' + DEFINES_CONTENT += '#define DEBUGGER_ENABLED\n' + DEFINES_CONTENT += `\n\n` + } + // 5. IO Config — derived from devicePinMapping. Pin order is // the iteration order of the input array; callers are // expected to have sorted by address. diff --git a/src/backend/shared/debug/__tests__/modbus-pdu.test.ts b/src/backend/shared/debug/__tests__/modbus-pdu.test.ts index 1bb6c2864..385afa244 100644 --- a/src/backend/shared/debug/__tests__/modbus-pdu.test.ts +++ b/src/backend/shared/debug/__tests__/modbus-pdu.test.ts @@ -12,11 +12,17 @@ if (typeof globalThis.TextDecoder === 'undefined') { import { ModbusDebugResponse, ModbusFunctionCode } from '../../simulator/types' import { + buildGetBoardIdRequest, buildGetListRequest, buildGetMd5Request, + buildGetStatusRequest, + buildGetVersionRequest, buildSetVariableRequest, + parseGetBoardIdResponse, parseGetListResponse, parseGetMd5Response, + parseGetStatusResponse, + parseGetVersionResponse, parseSetVariableResponse, responseFunctionCode, } from '../modbus-pdu' @@ -190,6 +196,185 @@ describe('buildSetVariableRequest / parseSetVariableResponse', () => { }) }) +describe('buildGetStatusRequest / parseGetStatusResponse', () => { + it('builds a bare 1-byte FC PDU', () => { + const buf = buildGetStatusRequest() + expect(buf).toHaveLength(1) + expect(buf[0]).toBe(ModbusFunctionCode.DEBUG_GET_STATUS) + }) + + it('parses running / tick / uptime on success', () => { + const buf = new Uint8Array([ + ModbusFunctionCode.DEBUG_GET_STATUS, + ModbusDebugResponse.SUCCESS, + 0x01, // running = true + 0x00, + 0x00, + 0x00, + 0x2a, // tick = 42 + 0x00, + 0x00, + 0x01, + 0x00, // uptime = 256 + ]) + const result = parseGetStatusResponse(buf) + expect(result).toEqual({ success: true, running: true, tick: 42, uptimeMs: 256 }) + }) + + it('reports running=false when the flag byte is zero', () => { + const buf = new Uint8Array([ + ModbusFunctionCode.DEBUG_GET_STATUS, + ModbusDebugResponse.SUCCESS, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ]) + expect(parseGetStatusResponse(buf).running).toBe(false) + }) + + it('flags too-short buffer', () => { + const result = parseGetStatusResponse(new Uint8Array([ModbusFunctionCode.DEBUG_GET_STATUS])) + expect(result.success).toBe(false) + expect(result.error).toMatch(/too short/) + }) + + it('flags function code mismatch', () => { + const result = parseGetStatusResponse(new Uint8Array([0x00, ModbusDebugResponse.SUCCESS])) + expect(result.success).toBe(false) + expect(result.error).toMatch(/mismatch/) + }) + + it('surfaces error status', () => { + const result = parseGetStatusResponse( + new Uint8Array([ModbusFunctionCode.DEBUG_GET_STATUS, ModbusDebugResponse.ERROR_OUT_OF_BOUNDS]), + ) + expect(result.success).toBe(false) + expect(result.error).toBe('ERROR_OUT_OF_BOUNDS') + }) + + it('flags an incomplete success payload', () => { + const result = parseGetStatusResponse( + new Uint8Array([ModbusFunctionCode.DEBUG_GET_STATUS, ModbusDebugResponse.SUCCESS, 0x01]), + ) + expect(result.success).toBe(false) + expect(result.error).toMatch(/Incomplete/) + }) +}) + +describe('buildGetVersionRequest / parseGetVersionResponse', () => { + it('builds a bare 1-byte FC PDU', () => { + const buf = buildGetVersionRequest() + expect(buf).toHaveLength(1) + expect(buf[0]).toBe(ModbusFunctionCode.DEBUG_GET_VERSION) + }) + + it('parses the ASCII version string on success', () => { + const ver = new TextEnc().encode('4.2.7') + const buf = new Uint8Array(2 + ver.length) + buf[0] = ModbusFunctionCode.DEBUG_GET_VERSION + buf[1] = ModbusDebugResponse.SUCCESS + buf.set(ver, 2) + expect(parseGetVersionResponse(buf)).toEqual({ success: true, version: '4.2.7' }) + }) + + it('strips a trailing NUL terminator', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_GET_VERSION, ModbusDebugResponse.SUCCESS, 0x31, 0x2e, 0x30, 0x00]) + expect(parseGetVersionResponse(buf).version).toBe('1.0') + }) + + it('flags too-short buffer', () => { + const result = parseGetVersionResponse(new Uint8Array([ModbusFunctionCode.DEBUG_GET_VERSION])) + expect(result.success).toBe(false) + expect(result.error).toMatch(/too short/) + }) + + it('flags function code mismatch', () => { + const result = parseGetVersionResponse(new Uint8Array([0x00, ModbusDebugResponse.SUCCESS])) + expect(result.success).toBe(false) + expect(result.error).toMatch(/mismatch/) + }) + + it('surfaces error status', () => { + const result = parseGetVersionResponse( + new Uint8Array([ModbusFunctionCode.DEBUG_GET_VERSION, ModbusDebugResponse.ERROR_OUT_OF_MEMORY]), + ) + expect(result.success).toBe(false) + expect(result.error).toBe('ERROR_OUT_OF_MEMORY') + }) +}) + +describe('buildGetBoardIdRequest / parseGetBoardIdResponse', () => { + it('builds a bare 1-byte FC PDU', () => { + const buf = buildGetBoardIdRequest() + expect(buf).toHaveLength(1) + expect(buf[0]).toBe(ModbusFunctionCode.DEBUG_GET_BOARD_ID) + }) + + it('parses id bytes and hex on success', () => { + const buf = new Uint8Array([ + ModbusFunctionCode.DEBUG_GET_BOARD_ID, + ModbusDebugResponse.SUCCESS, + 0x03, // id_len = 3 + 0x0a, + 0xbc, + 0x01, + ]) + const result = parseGetBoardIdResponse(buf) + expect(result.success).toBe(true) + expect(Array.from(result.boardId!)).toEqual([0x0a, 0xbc, 0x01]) + expect(result.boardIdHex).toBe('0abc01') + }) + + it('handles id_len = 0 (unsupported core) as success with empty id', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_GET_BOARD_ID, ModbusDebugResponse.SUCCESS, 0x00]) + const result = parseGetBoardIdResponse(buf) + expect(result.success).toBe(true) + expect(result.boardIdHex).toBe('') + expect(Array.from(result.boardId!)).toEqual([]) + }) + + it('flags too-short buffer', () => { + const result = parseGetBoardIdResponse(new Uint8Array([ModbusFunctionCode.DEBUG_GET_BOARD_ID])) + expect(result.success).toBe(false) + expect(result.error).toMatch(/too short/) + }) + + it('flags function code mismatch', () => { + const result = parseGetBoardIdResponse(new Uint8Array([0x00, ModbusDebugResponse.SUCCESS])) + expect(result.success).toBe(false) + expect(result.error).toMatch(/mismatch/) + }) + + it('surfaces error status', () => { + const result = parseGetBoardIdResponse( + new Uint8Array([ModbusFunctionCode.DEBUG_GET_BOARD_ID, ModbusDebugResponse.ERROR_OUT_OF_BOUNDS]), + ) + expect(result.success).toBe(false) + expect(result.error).toBe('ERROR_OUT_OF_BOUNDS') + }) + + it('flags a missing id_len byte', () => { + const result = parseGetBoardIdResponse( + new Uint8Array([ModbusFunctionCode.DEBUG_GET_BOARD_ID, ModbusDebugResponse.SUCCESS]), + ) + expect(result.success).toBe(false) + expect(result.error).toMatch(/at least 3/) + }) + + it('flags truncated id bytes', () => { + const buf = new Uint8Array([ModbusFunctionCode.DEBUG_GET_BOARD_ID, ModbusDebugResponse.SUCCESS, 0x04, 0x0a, 0x0b]) + const result = parseGetBoardIdResponse(buf) + expect(result.success).toBe(false) + expect(result.error).toMatch(/Incomplete board-id data/) + }) +}) + describe('responseFunctionCode', () => { it('returns the first byte', () => { expect(responseFunctionCode(new Uint8Array([0x45, 0x00]))).toBe(0x45) diff --git a/src/backend/shared/debug/modbus-pdu.ts b/src/backend/shared/debug/modbus-pdu.ts index 1c7d60e69..5fcd2856b 100644 --- a/src/backend/shared/debug/modbus-pdu.ts +++ b/src/backend/shared/debug/modbus-pdu.ts @@ -42,7 +42,14 @@ import { detectTargetEndian, type TargetEndian } from '../../../frontend/utils/endian' import { ModbusDebugResponse, ModbusFunctionCode } from '../simulator/types' -import type { DebugSetResult, DebugTransportResult, Md5ProbeResult } from './types' +import type { + DebugBoardIdResult, + DebugSetResult, + DebugStatusResult, + DebugTransportResult, + DebugVersionResult, + Md5ProbeResult, +} from './types' // --------------------------------------------------------------------------- // Uint8Array helpers — host-endian-agnostic, no typed-array views on wire data. @@ -135,6 +142,27 @@ export function buildSetVariableRequest(index: number, force: boolean, valueBuff return buf } +// Always-on debugger extras. Each is a bare [FC] PDU — no payload — mirroring +// the firmware's `mb_rtu_frame_len` entry of 4 (id + FC + 2 CRC bytes). + +export function buildGetStatusRequest(): Uint8Array { + const buf = alloc(1) + writeU8(buf, 0, ModbusFunctionCode.DEBUG_GET_STATUS) + return buf +} + +export function buildGetVersionRequest(): Uint8Array { + const buf = alloc(1) + writeU8(buf, 0, ModbusFunctionCode.DEBUG_GET_VERSION) + return buf +} + +export function buildGetBoardIdRequest(): Uint8Array { + const buf = alloc(1) + writeU8(buf, 0, ModbusFunctionCode.DEBUG_GET_BOARD_ID) + return buf +} + // --------------------------------------------------------------------------- // Parse responses // --------------------------------------------------------------------------- @@ -248,6 +276,100 @@ export function parseSetVariableResponse(data: Uint8Array): DebugSetResult { return { success: true } } +/** + * Parse a status response (FC 0x46). + * Layout: `[FC][status][running:u8][tick:u32BE][uptime:u32BE]` (11 PDU bytes). + */ +export function parseGetStatusResponse(data: Uint8Array): DebugStatusResult { + if (data.length < 2) { + return { success: false, error: `Invalid response: too short (${data.length} bytes)` } + } + + const fc = readU8(data, 0) + const status = readU8(data, 1) + + if (fc !== ModbusFunctionCode.DEBUG_GET_STATUS) { + return { success: false, error: 'Function code mismatch' } + } + + if (status !== ModbusDebugResponse.SUCCESS) { + return { success: false, error: statusError(status) } + } + + if (data.length < 11) { + return { success: false, error: `Incomplete status response (${data.length} bytes, expected 11)` } + } + + return { + success: true, + running: readU8(data, 2) !== 0, + tick: readU32BE(data, 3), + uptimeMs: readU32BE(data, 7), + } +} + +/** + * Parse a version response (FC 0x47). + * Layout: `[FC][status][version ASCII...]` (no NUL terminator on the wire). + */ +export function parseGetVersionResponse(data: Uint8Array): DebugVersionResult { + if (data.length < 2) { + return { success: false, error: `Invalid response: too short (${data.length} bytes)` } + } + + const fc = readU8(data, 0) + const status = readU8(data, 1) + + if (fc !== ModbusFunctionCode.DEBUG_GET_VERSION) { + return { success: false, error: 'Function code mismatch' } + } + + if (status !== ModbusDebugResponse.SUCCESS) { + return { success: false, error: statusError(status) } + } + + const version = new TextDecoder('utf-8').decode(data.subarray(2)).replace(/\0+$/, '').trim() + return { success: true, version } +} + +/** + * Parse a board-id response (FC 0x48). + * Layout: `[FC][status][id_len:u8][id_bytes...]`. `id_len === 0` means the + * target has no unique-id support — success with an empty id. + */ +export function parseGetBoardIdResponse(data: Uint8Array): DebugBoardIdResult { + if (data.length < 2) { + return { success: false, error: `Invalid response: too short (${data.length} bytes)` } + } + + const fc = readU8(data, 0) + const status = readU8(data, 1) + + if (fc !== ModbusFunctionCode.DEBUG_GET_BOARD_ID) { + return { success: false, error: 'Function code mismatch' } + } + + if (status !== ModbusDebugResponse.SUCCESS) { + return { success: false, error: statusError(status) } + } + + if (data.length < 3) { + return { success: false, error: `Incomplete board-id response (${data.length} bytes, expected at least 3)` } + } + + const idLen = readU8(data, 2) + if (data.length < 3 + idLen) { + return { + success: false, + error: `Incomplete board-id data (expected ${idLen} bytes, got ${data.length - 3})`, + } + } + + const boardId = data.slice(3, 3 + idLen) + const boardIdHex = Array.from(boardId, (b) => b.toString(16).padStart(2, '0')).join('') + return { success: true, boardId, boardIdHex } +} + /** * Extract the function code from a Modbus PDU response. * Returns `undefined` if the buffer is empty. diff --git a/src/backend/shared/debug/types.ts b/src/backend/shared/debug/types.ts index 8b2ebe373..05cd678ca 100644 --- a/src/backend/shared/debug/types.ts +++ b/src/backend/shared/debug/types.ts @@ -23,6 +23,38 @@ export interface DebugSetResult { error?: string } +/** + * Result of the always-on debugger status probe (FC 0x46). `running` is the + * PLC scan liveness flag, `tick` the scan counter (advances each cycle), and + * `uptimeMs` the milliseconds since the board booted. + */ +export interface DebugStatusResult { + success: boolean + running?: boolean + tick?: number + uptimeMs?: number + error?: string +} + +/** Result of the runtime version probe (FC 0x47) — ASCII version string. */ +export interface DebugVersionResult { + success: boolean + version?: string + error?: string +} + +/** + * Result of the board-id probe (FC 0x48). `boardId` is the raw unique-id bytes + * (empty when the target has no unique-id support); `boardIdHex` is the same + * bytes as a lowercase hex string for display. + */ +export interface DebugBoardIdResult { + success: boolean + boardId?: Uint8Array + boardIdHex?: string + error?: string +} + /** * Result of an MD5-probe call. The `md5` is the runtime's program hash; * `targetEndian` is the byte order detected from the 2-byte sentinel the diff --git a/src/backend/shared/simulator/__tests__/debug-e2e.test.ts b/src/backend/shared/simulator/__tests__/debug-e2e.test.ts index 5bb6cfb29..a18b18eee 100644 --- a/src/backend/shared/simulator/__tests__/debug-e2e.test.ts +++ b/src/backend/shared/simulator/__tests__/debug-e2e.test.ts @@ -85,4 +85,34 @@ describeIfHex('Phase 4 debugger end-to-end (avr8js + ModbusRtuClient)', () => { // non-deterministic and this test already validated at the SET layer // that the protocol accepts the unforce request. }, 30000) + + it('FC 0x46 DEBUG_GET_STATUS reports the PLC running with an advancing tick', async () => { + const first = await client.getStatus() + expect(first.success).toBe(true) + expect(first.running).toBe(true) + expect(typeof first.tick).toBe('number') + expect(typeof first.uptimeMs).toBe('number') + + // Let a few scan cycles run — the scan counter must advance. + await new Promise((r) => setTimeout(r, 200)) + const second = await client.getStatus() + expect(second.success).toBe(true) + expect(second.tick!).toBeGreaterThan(first.tick!) + }, 30000) + + it('FC 0x47 DEBUG_GET_VERSION returns the runtime version string', async () => { + const result = await client.getVersion() + expect(result.success).toBe(true) + // OPENPLC_RUNTIME_VERSION is a dotted version like "4.2.7". + expect(result.version).toMatch(/^\d+\.\d+\.\d+/) + }, 30000) + + it('FC 0x48 DEBUG_GET_BOARD_ID returns the AVR unique id (9 bytes on ATmega2560)', async () => { + const result = await client.getBoardId() + expect(result.success).toBe(true) + // ArduinoUniqueID reports 9 bytes on AVR (10 on ATmega328PB). The + // emulated ATmega2560 yields a non-empty id; assert it round-trips. + expect(result.boardId!.length).toBeGreaterThan(0) + expect(result.boardIdHex).toMatch(/^[0-9a-f]+$/) + }, 30000) }) diff --git a/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts b/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts index 462c8c1b4..56ff8af21 100644 --- a/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts +++ b/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts @@ -551,6 +551,209 @@ describe('ModbusRtuClient', () => { }) }) + // ----------------------------------------------------------------------- + // getStatus (FC 0x46) + // ----------------------------------------------------------------------- + describe('getStatus', () => { + function statusPayload(running: number, tick: number, uptime: number): Uint8Array { + const payload = new Uint8Array(10) + payload[0] = ModbusDebugResponse.SUCCESS + payload[1] = running + payload[2] = (tick >>> 24) & 0xff + payload[3] = (tick >>> 16) & 0xff + payload[4] = (tick >>> 8) & 0xff + payload[5] = tick & 0xff + payload[6] = (uptime >>> 24) & 0xff + payload[7] = (uptime >>> 16) & 0xff + payload[8] = (uptime >>> 8) & 0xff + payload[9] = uptime & 0xff + return payload + } + + it('returns running / tick / uptime on success', async () => { + await connectClient() + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_STATUS, statusPayload(1, 42, 256))) + const result = await client.getStatus() + expect(result).toEqual({ success: true, running: true, tick: 42, uptimeMs: 256 }) + }) + + it('reports running=false when the flag byte is zero', async () => { + await connectClient() + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_STATUS, statusPayload(0, 1, 1))) + const result = await client.getStatus() + expect(result.running).toBe(false) + }) + + it('returns error on function code mismatch', async () => { + await connectClient() + autoRespond(buildResponse(1, 0x99, new Uint8Array([ModbusDebugResponse.SUCCESS]))) + const result = await client.getStatus() + expect(result.success).toBe(false) + expect(result.error).toBe('Function code mismatch') + }) + + it('returns error on unknown status code', async () => { + await connectClient() + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_STATUS, new Uint8Array([0x99]))) + const result = await client.getStatus() + expect(result.success).toBe(false) + expect(result.error).toContain('Unknown error code') + }) + + it('returns error on incomplete success payload', async () => { + await connectClient() + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_STATUS, new Uint8Array([ModbusDebugResponse.SUCCESS, 1]))) + const result = await client.getStatus() + expect(result.success).toBe(false) + expect(result.error).toContain('Incomplete status response') + }) + + it('returns error on too-short response', async () => { + await connectClient() + const frame = new Uint8Array([0x01, ModbusFunctionCode.DEBUG_GET_STATUS]) + const crc = calculateCrc(frame) + const full = new Uint8Array(4) + full.set(frame, 0) + full[2] = (crc >>> 8) & 0xff + full[3] = crc & 0xff + autoRespond(full) + const result = await client.getStatus() + expect(result.success).toBe(false) + expect(result.error).toContain('too short') + }) + + it('returns error on timeout', async () => { + await connectClient() + const result = await client.getStatus() + expect(result.success).toBe(false) + expect(result.error).toContain('timeout') + }) + }) + + // ----------------------------------------------------------------------- + // getVersion (FC 0x47) + // ----------------------------------------------------------------------- + describe('getVersion', () => { + it('returns the ASCII version string on success', async () => { + await connectClient() + const ver = new TextEncoder().encode('4.2.7') + const payload = new Uint8Array(1 + ver.length) + payload[0] = ModbusDebugResponse.SUCCESS + payload.set(ver, 1) + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_VERSION, payload)) + const result = await client.getVersion() + expect(result).toEqual({ success: true, version: '4.2.7' }) + }) + + it('returns error on function code mismatch', async () => { + await connectClient() + autoRespond(buildResponse(1, 0x99, new Uint8Array([ModbusDebugResponse.SUCCESS]))) + const result = await client.getVersion() + expect(result.success).toBe(false) + expect(result.error).toBe('Function code mismatch') + }) + + it('returns error on unknown status code', async () => { + await connectClient() + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_VERSION, new Uint8Array([0x99]))) + const result = await client.getVersion() + expect(result.success).toBe(false) + expect(result.error).toContain('Unknown error code') + }) + + it('returns error on too-short response', async () => { + await connectClient() + const frame = new Uint8Array([0x01, ModbusFunctionCode.DEBUG_GET_VERSION]) + const crc = calculateCrc(frame) + const full = new Uint8Array(4) + full.set(frame, 0) + full[2] = (crc >>> 8) & 0xff + full[3] = crc & 0xff + autoRespond(full) + const result = await client.getVersion() + expect(result.success).toBe(false) + expect(result.error).toContain('too short') + }) + + it('returns error on timeout', async () => { + await connectClient() + const result = await client.getVersion() + expect(result.success).toBe(false) + expect(result.error).toContain('timeout') + }) + }) + + // ----------------------------------------------------------------------- + // getBoardId (FC 0x48) + // ----------------------------------------------------------------------- + describe('getBoardId', () => { + it('returns id bytes and hex on success', async () => { + await connectClient() + const payload = new Uint8Array([ModbusDebugResponse.SUCCESS, 0x03, 0x0a, 0xbc, 0x01]) + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_BOARD_ID, payload)) + const result = await client.getBoardId() + expect(result.success).toBe(true) + expect(Array.from(result.boardId!)).toEqual([0x0a, 0xbc, 0x01]) + expect(result.boardIdHex).toBe('0abc01') + }) + + it('handles id_len = 0 (unsupported core) as success with empty id', async () => { + await connectClient() + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_BOARD_ID, new Uint8Array([ModbusDebugResponse.SUCCESS, 0x00]))) + const result = await client.getBoardId() + expect(result.success).toBe(true) + expect(result.boardIdHex).toBe('') + expect(Array.from(result.boardId!)).toEqual([]) + }) + + it('returns error on function code mismatch', async () => { + await connectClient() + autoRespond(buildResponse(1, 0x99, new Uint8Array([ModbusDebugResponse.SUCCESS, 0x00]))) + const result = await client.getBoardId() + expect(result.success).toBe(false) + expect(result.error).toBe('Function code mismatch') + }) + + it('returns error on unknown status code', async () => { + await connectClient() + autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_BOARD_ID, new Uint8Array([0x99, 0x00]))) + const result = await client.getBoardId() + expect(result.success).toBe(false) + expect(result.error).toContain('Unknown error code') + }) + + it('returns error on incomplete id data', async () => { + await connectClient() + autoRespond( + buildResponse(1, ModbusFunctionCode.DEBUG_GET_BOARD_ID, new Uint8Array([ModbusDebugResponse.SUCCESS, 0x04, 0x0a, 0x0b])), + ) + const result = await client.getBoardId() + expect(result.success).toBe(false) + expect(result.error).toContain('Incomplete board-id data') + }) + + it('returns error on too-short response', async () => { + await connectClient() + const frame = new Uint8Array([0x01, ModbusFunctionCode.DEBUG_GET_BOARD_ID, ModbusDebugResponse.SUCCESS]) + const crc = calculateCrc(frame) + const full = new Uint8Array(frame.length + 2) + full.set(frame, 0) + full[frame.length] = (crc >>> 8) & 0xff + full[frame.length + 1] = crc & 0xff + autoRespond(full) + const result = await client.getBoardId() + expect(result.success).toBe(false) + expect(result.error).toContain('too short') + }) + + it('returns error on timeout', async () => { + await connectClient() + const result = await client.getBoardId() + expect(result.success).toBe(false) + expect(result.error).toContain('timeout') + }) + }) + // ----------------------------------------------------------------------- // sendRequestImpl edge cases // ----------------------------------------------------------------------- @@ -684,6 +887,51 @@ describe('ModbusRtuClient', () => { expect(result.success).toBe(false) expect(result.error).toBe('non-error string') }) + + it('getStatus handles response too short (<9 bytes)', async () => { + await connectClient() + mockSendRequest(client, new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0])) + const result = await client.getStatus() + expect(result.success).toBe(false) + expect(result.error).toContain('too short') + }) + + it('getStatus handles non-Error exception', async () => { + await connectClient() + mockSendRequest(client, 'non-error string') + const result = await client.getStatus() + expect(result.error).toBe('non-error string') + }) + + it('getVersion handles response too short (<9 bytes)', async () => { + await connectClient() + mockSendRequest(client, new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0])) + const result = await client.getVersion() + expect(result.success).toBe(false) + expect(result.error).toContain('too short') + }) + + it('getVersion handles non-Error exception', async () => { + await connectClient() + mockSendRequest(client, 'non-error string') + const result = await client.getVersion() + expect(result.error).toBe('non-error string') + }) + + it('getBoardId handles response too short (<10 bytes)', async () => { + await connectClient() + mockSendRequest(client, new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0])) + const result = await client.getBoardId() + expect(result.success).toBe(false) + expect(result.error).toContain('too short') + }) + + it('getBoardId handles non-Error exception', async () => { + await connectClient() + mockSendRequest(client, 'non-error string') + const result = await client.getBoardId() + expect(result.error).toBe('non-error string') + }) }) // ----------------------------------------------------------------------- diff --git a/src/backend/shared/simulator/modbus-rtu-client.ts b/src/backend/shared/simulator/modbus-rtu-client.ts index 0e628d582..8d712af56 100644 --- a/src/backend/shared/simulator/modbus-rtu-client.ts +++ b/src/backend/shared/simulator/modbus-rtu-client.ts @@ -462,4 +462,110 @@ export class ModbusRtuClient { return { success: false, error: error instanceof Error ? error.message : String(error) } } } + + // ------------------------------------------------------------------------- + // Always-on debugger extras (FC 0x46/0x47/0x48). Each is a bare-FC request. + // Response offsets account for the 6-byte TCP-compat padding sendRequestImpl + // prepends: slaveId@6, FC@7, status@8, payload@9+. + // ------------------------------------------------------------------------- + + async getStatus(): Promise<{ + success: boolean + running?: boolean + tick?: number + uptimeMs?: number + error?: string + }> { + try { + const request = this.assembleRequest(ModbusFunctionCode.DEBUG_GET_STATUS, allocBytes(0)) + const response = await this.sendRequest(request) + + 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_GET_STATUS as number)) { + return { success: false, error: 'Function code mismatch' } + } + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } + } + if (response.length < 18) { + return { success: false, error: `Incomplete status response (${response.length} bytes, expected at least 18)` } + } + + return { + success: true, + running: readUint8(response, 9) !== 0, + tick: readUint32BE(response, 10), + uptimeMs: readUint32BE(response, 14), + } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + + async getVersion(): Promise<{ success: boolean; version?: string; error?: string }> { + try { + const request = this.assembleRequest(ModbusFunctionCode.DEBUG_GET_VERSION, allocBytes(0)) + const response = await this.sendRequest(request) + + 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_GET_VERSION as number)) { + return { success: false, error: 'Function code mismatch' } + } + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } + } + + const version = new TextDecoder().decode(response.slice(9)).replace(/\0+$/, '').trim() + return { success: true, version } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } + + async getBoardId(): Promise<{ success: boolean; boardId?: Uint8Array; boardIdHex?: string; error?: string }> { + try { + const request = this.assembleRequest(ModbusFunctionCode.DEBUG_GET_BOARD_ID, allocBytes(0)) + const response = await this.sendRequest(request) + + if (response.length < 10) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 10)` } + } + + const functionCodeResponse = readUint8(response, 7) + const statusCode = readUint8(response, 8) + + if (functionCodeResponse !== (ModbusFunctionCode.DEBUG_GET_BOARD_ID as number)) { + return { success: false, error: 'Function code mismatch' } + } + if (statusCode !== (ModbusDebugResponse.SUCCESS as number)) { + return { success: false, error: `Unknown error code: 0x${statusCode.toString(16)}` } + } + + const idLen = readUint8(response, 9) + if (response.length < 10 + idLen) { + return { + success: false, + error: `Incomplete board-id data (expected ${idLen} bytes, got ${response.length - 10})`, + } + } + + const boardId = response.slice(10, 10 + idLen) + const boardIdHex = Array.from(boardId, (b) => b.toString(16).padStart(2, '0')).join('') + return { success: true, boardId, boardIdHex } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } } diff --git a/src/backend/shared/simulator/types.ts b/src/backend/shared/simulator/types.ts index a2ac377f6..006f4651a 100644 --- a/src/backend/shared/simulator/types.ts +++ b/src/backend/shared/simulator/types.ts @@ -4,6 +4,9 @@ export enum ModbusFunctionCode { DEBUG_GET = 0x43, DEBUG_GET_LIST = 0x44, DEBUG_GET_MD5 = 0x45, + DEBUG_GET_STATUS = 0x46, + DEBUG_GET_VERSION = 0x47, + DEBUG_GET_BOARD_ID = 0x48, } export enum ModbusDebugResponse { From 232da71e4bd92db2a11fc5af86bbf9167f2dd53e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 9 Jul 2026 11:40:45 +0200 Subject: [PATCH 02/19] feat(vpp): resolve VPP select options dynamically from board data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 (serial-network-modbus split): let a VPP screen `select` field source its options from per-board context via `optionsRef` (e.g. "board.serialPorts"), so a shared screen adapts to each board — the Modbus RTU serial-port picker now lists only the UARTs the board actually exposes instead of a static Serial/ Serial1/2/3 list. - BoardInfo + PackageManifest device gain serialPorts/defaultSerial; the hardware module forwards them from the manifest onto the board info. - utils/vpp/field-options: resolveFieldOptions helper (optionsRef wins when it resolves to a non-empty array, else falls back to static options). 100% covered. - form-layout: select uses resolveFieldOptions with the current board as context. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/hardware/hardware-module.ts | 2 + .../vendor-screen/layouts/form-layout.tsx | 13 ++++- .../utils/vpp/__tests__/field-options.test.ts | 46 ++++++++++++++++++ src/frontend/utils/vpp/field-options.ts | 48 +++++++++++++++++++ src/middleware/shared/ports/types.ts | 19 ++++++++ 5 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 src/frontend/utils/vpp/__tests__/field-options.test.ts create mode 100644 src/frontend/utils/vpp/field-options.ts diff --git a/src/backend/editor/hardware/hardware-module.ts b/src/backend/editor/hardware/hardware-module.ts index ea312170a..ea7889e39 100644 --- a/src/backend/editor/hardware/hardware-module.ts +++ b/src/backend/editor/hardware/hardware-module.ts @@ -358,6 +358,8 @@ class HardwareModule { } : null, }, + ...(device.serialPorts ? { serialPorts: device.serialPorts } : {}), + ...(device.defaultSerial ? { defaultSerial: device.defaultSerial } : {}), ...(device.debug ? { debug: device.debug } : {}), }) } diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/form-layout.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/form-layout.tsx index 99071c2eb..bc920f2bf 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/form-layout.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/vendor-screen/layouts/form-layout.tsx @@ -4,6 +4,7 @@ import { ToggleSwitch } from '@root/frontend/components/_atoms/toggle-switch' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@root/frontend/components/_atoms/tooltip' import { useOpenPLCStore } from '@root/frontend/store' import { evalVisible, type VisibleCondition } from '@root/frontend/utils/vpp/eval-visible' +import { resolveFieldOptions } from '@root/frontend/utils/vpp/field-options' import { getSectionPersistenceKey } from '@root/frontend/utils/vpp/persistence-keys' import type { ScreenSection } from '../index' @@ -19,6 +20,10 @@ type FieldDef = { unit?: string help?: string options?: string[] | Array<{ value: string; label: string }> + // Dynamic option source (VPP screen schema): a dotted path resolved against + // per-board context, e.g. "board.serialPorts". Wins over `options` when it + // resolves to a non-empty array; otherwise `options` is the fallback. + optionsRef?: string // Honored by text-like inputs (text, password, ip-address, mac-address). // Mirrors the VPP screen schema's optional field props — empty strings // are skipped so HTML5 placeholder/maxLength/pattern stay unset when @@ -77,6 +82,10 @@ function FormLayout({ section }: FormLayoutProps) { const vendorScreenData = useOpenPLCStore((s) => s.deviceDefinitions.configuration.vendorScreenData) const setVendorScreenData = useOpenPLCStore((s) => s.deviceActions.setVendorScreenData) + // Board context for dynamic `optionsRef` resolution (e.g. the Modbus RTU + // serial-port picker reading `board.serialPorts`). + const deviceBoard = useOpenPLCStore((s) => s.deviceDefinitions.configuration.deviceBoard) + const currentBoardInfo = useOpenPLCStore((s) => s.deviceAvailableOptions.availableBoards.get(deviceBoard)) // Single-source-of-truth for the per-section storage key — see // `getSectionPersistenceKey` in ../index.tsx. Every layout that // persists must derive its key through this helper so the @@ -162,7 +171,9 @@ function FormLayout({ section }: FormLayoutProps) { align='center' side='bottom' > - {(field.options ?? []).map((opt) => { + {resolveFieldOptions(field, { + board: currentBoardInfo as Record | undefined, + }).map((opt) => { const value = typeof opt === 'string' ? opt : opt.value const label = typeof opt === 'string' ? opt : opt.label return ( diff --git a/src/frontend/utils/vpp/__tests__/field-options.test.ts b/src/frontend/utils/vpp/__tests__/field-options.test.ts new file mode 100644 index 000000000..5ca4b9381 --- /dev/null +++ b/src/frontend/utils/vpp/__tests__/field-options.test.ts @@ -0,0 +1,46 @@ +import { resolveFieldOptions } from '../field-options' + +describe('resolveFieldOptions', () => { + it('returns static options when there is no optionsRef', () => { + expect(resolveFieldOptions({ options: ['a', 'b'] }, { board: undefined })).toEqual(['a', 'b']) + }) + + it('returns an empty array when neither options nor optionsRef are set', () => { + expect(resolveFieldOptions({}, { board: undefined })).toEqual([]) + }) + + it('resolves a dynamic optionsRef from board context', () => { + expect( + resolveFieldOptions({ optionsRef: 'board.serialPorts', options: ['Serial'] }, { board: { serialPorts: ['Serial', 'Serial1'] } }), + ).toEqual(['Serial', 'Serial1']) + }) + + it('falls back to static options when optionsRef resolves to undefined', () => { + expect(resolveFieldOptions({ optionsRef: 'board.serialPorts', options: ['Serial'] }, { board: {} })).toEqual(['Serial']) + }) + + it('falls back to static options when the board is absent', () => { + expect(resolveFieldOptions({ optionsRef: 'board.serialPorts', options: ['Serial'] }, { board: undefined })).toEqual(['Serial']) + }) + + it('falls back to static options when optionsRef resolves to an empty array', () => { + expect( + resolveFieldOptions({ optionsRef: 'board.serialPorts', options: ['Serial'] }, { board: { serialPorts: [] } }), + ).toEqual(['Serial']) + }) + + it('preserves object-shaped options ({ value, label })', () => { + const opts = [{ value: 'a', label: 'A' }] + expect(resolveFieldOptions({ options: opts }, { board: undefined })).toEqual(opts) + }) + + it('filters out non-option entries resolved from optionsRef', () => { + expect( + resolveFieldOptions({ optionsRef: 'board.serialPorts' }, { board: { serialPorts: ['Serial', 42, null] } }), + ).toEqual(['Serial']) + }) + + it('returns empty array fallback when optionsRef yields only invalid entries and no static options', () => { + expect(resolveFieldOptions({ optionsRef: 'board.serialPorts' }, { board: { serialPorts: [42] } })).toEqual([]) + }) +}) diff --git a/src/frontend/utils/vpp/field-options.ts b/src/frontend/utils/vpp/field-options.ts new file mode 100644 index 000000000..2fb38eda2 --- /dev/null +++ b/src/frontend/utils/vpp/field-options.ts @@ -0,0 +1,48 @@ +/** + * Resolve the option list for a VPP screen `select` field. + * + * A field may declare static `options` and/or a dynamic `optionsRef` — a dotted + * path (e.g. `"board.serialPorts"`) resolved against per-board context so the + * same shared screen adapts to each board (the Modbus RTU serial-port picker + * lists only the UARTs the board actually exposes). When `optionsRef` resolves + * to a non-empty array it wins; otherwise the static `options` are the fallback, + * so a board that doesn't declare the referenced data still renders sensibly. + * + * Pure — no store, no I/O. + */ + +export type FieldOption = string | { value: string; label: string } + +export interface FieldOptionSource { + options?: FieldOption[] + optionsRef?: string +} + +/** Walk a dotted path (`a.b.c`) into a context object; undefined on any miss. */ +function lookupPath(path: string, context: Record): unknown { + let cursor: unknown = context + for (const part of path.split('.')) { + if (cursor === null || cursor === undefined || typeof cursor !== 'object') return undefined + cursor = (cursor as Record)[part] + } + return cursor +} + +function isFieldOption(value: unknown): value is FieldOption { + return typeof value === 'string' || (typeof value === 'object' && value !== null && 'value' in value) +} + +export function resolveFieldOptions( + field: FieldOptionSource, + context: { board?: Record | undefined }, +): FieldOption[] { + if (field.optionsRef) { + const resolved = lookupPath(field.optionsRef, context as Record) + if (Array.isArray(resolved)) { + const opts = resolved.filter(isFieldOption) + if (opts.length > 0) return opts + } + // optionsRef present but unresolved / empty → fall back to static options. + } + return field.options ?? [] +} diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index f795de683..090044a9f 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -627,6 +627,18 @@ export interface BoardInfo { * declare it. */ platformOptions?: PlatformOption[] + /** + * Hardware serial ports this board exposes (e.g. `['Serial', 'Serial1']`), + * mirrored from the VPP manifest device's `serialPorts`. Consumed by VPP + * screen `select` fields via `optionsRef: 'board.serialPorts'` (the Modbus + * RTU port picker) and by the always-on serial/debugger. Absent → the editor + * assumes a single `Serial`. + */ + serialPorts?: string[] + /** Name of the default serial port (usually the USB CDC port) where the + * debugger runs. Mirrors the manifest device's `defaultSerial`. Absent → + * `Serial`. */ + defaultSerial?: string /** * Declarative debug-channel resolver spec carried through from the * source catalog (hals.json or VPP manifest). Consumed by @@ -786,6 +798,13 @@ export interface PackageManifest { } } screens?: Record + /** Hardware serial ports this device exposes (e.g. `['Serial', 'Serial1']`). + * Surfaced onto `BoardInfo.serialPorts` and consumed by VPP screen + * `select` fields via `optionsRef: 'board.serialPorts'`. */ + serialPorts?: string[] + /** Name of the default serial port (usually the USB CDC port). Surfaced onto + * `BoardInfo.defaultSerial`. Absent → `Serial`. */ + defaultSerial?: string /** Declarative debug-channel resolver spec, consumed by * `backend/shared/hardware/debug-spec.ts`. Same shape as * the `debug` field on built-in hals.json entries — the From 631c895ca00ddd8e46fc66d2f7ceb23cd7565b31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 9 Jul 2026 12:03:16 +0200 Subject: [PATCH 03/19] feat(compile): generate defines for the serial/network/modbus split Phase 2: emit an always-on serial debugger and read Modbus config from the new sections. - generate-defines: the Debugger block is now unconditional for baremetal Arduino targets, emitting DEBUG_IFACE (default serial) and DEBUG_BAUD (from the Serial section). The old "only when Modbus is off" gate is gone. - modbus-defines: RTU reads serial_port (legacy rtu_interface fallback) and takes its baud from the Serial section on the default port or its own baud on a secondary port; emits MBSERIAL_SHARES_DEBUG_SERIAL when it runs on the default port so the firmware begins the port once. TCP reads network config from the Network section (legacy modbus_tcp.* fallback). New optional defaultSerial arg. - compiler-module: feed the serial/network sections into vppModbusState. Backward-tolerant: pre-migration projects (legacy modbus_rtu/modbus_tcp shape) still generate correct defines. 55 tests pass, 100% stmts/lines/functions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/compiler/compiler-module.ts | 2 + .../__tests__/generate-defines.test.ts | 29 +++++- .../compile/__tests__/modbus-defines.test.ts | 50 ++++++++++ .../shared/compile/steps/generate-defines.ts | 33 ++++--- .../shared/compile/steps/modbus-defines.ts | 97 +++++++++++++------ 5 files changed, 163 insertions(+), 48 deletions(-) diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 74f9bf34b..a086d6eb4 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -2618,6 +2618,8 @@ class CompilerModule { const deviceConfig = await CompilerModule.readJSONFile(devicesConfigurationFilePath) const vendorScreenData = deviceConfig.vendorScreenData ?? {} vppModbusState = { + serial: vendorScreenData['serial'] as VppModbusScreenState['serial'], + network: vendorScreenData['network'] as VppModbusScreenState['network'], modbus_rtu: vendorScreenData['modbus_rtu'] as VppModbusScreenState['modbus_rtu'], modbus_tcp: vendorScreenData['modbus_tcp'] as VppModbusScreenState['modbus_tcp'], } diff --git a/src/backend/shared/compile/__tests__/generate-defines.test.ts b/src/backend/shared/compile/__tests__/generate-defines.test.ts index 05a9b75ce..a7b7b750c 100644 --- a/src/backend/shared/compile/__tests__/generate-defines.test.ts +++ b/src/backend/shared/compile/__tests__/generate-defines.test.ts @@ -168,15 +168,36 @@ describe('generateDefinesContent — Debugger block (always-on debug)', () => { expect(out).toContain('#define DEBUGGER_ENABLED') }) - it('does NOT emit DEBUGGER_ENABLED when full Modbus is enabled (debugger rides Modbus)', () => { + it('emits DEBUGGER_ENABLED even when full Modbus is enabled (always-on serial debugger)', () => { const out = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'arduino-cli', vppModbusState: { - modbus_rtu: { enabled: true, rtu_interface: 'Serial1', rtu_baud_rate: '115200', rtu_slave_id: 1 }, + serial: { baud_rate: '9600' }, + modbus_rtu: { enabled: true, serial_port: 'Serial', rtu_slave_id: 1 }, }, + defaultSerial: 'Serial', }) - expect(out).not.toContain('DEBUGGER_ENABLED') + expect(out).toContain('#define DEBUGGER_ENABLED') + // RTU on the default serial → shares the debugger's port (single begin()). + expect(out).toContain('#define MBSERIAL_SHARES_DEBUG_SERIAL') + }) + + it('emits DEBUG_IFACE from defaultSerial and DEBUG_BAUD from the Serial section', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + defaultSerial: 'Serial', + vppModbusState: { serial: { baud_rate: '9600' } }, + }) + expect(out).toContain('#define DEBUG_IFACE Serial') + expect(out).toContain('#define DEBUG_BAUD 9600') + }) + + it('falls back to DEBUG_IFACE Serial and DEBUG_BAUD 115200 when unset', () => { + const out = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'arduino-cli' }) + expect(out).toContain('#define DEBUG_IFACE Serial') + expect(out).toContain('#define DEBUG_BAUD 115200') }) it('does NOT emit DEBUGGER_ENABLED for the simulator (it uses the full Modbus path)', () => { @@ -397,6 +418,8 @@ describe('generateDefinesContent — full output snapshot', () => { '', '//Debugger', '#define DEBUGGER_ENABLED', + '#define DEBUG_IFACE Serial', + '#define DEBUG_BAUD 115200', '', '', '//IO Config', diff --git a/src/backend/shared/compile/__tests__/modbus-defines.test.ts b/src/backend/shared/compile/__tests__/modbus-defines.test.ts index a16fe762e..72c8155f6 100644 --- a/src/backend/shared/compile/__tests__/modbus-defines.test.ts +++ b/src/backend/shared/compile/__tests__/modbus-defines.test.ts @@ -22,6 +22,7 @@ describe('generateModbusDefines', () => { '#define MBSERIAL_IFACE Serial', '#define MBSERIAL_BAUD 115200', '#define MBSERIAL_SLAVE 1', + '#define MBSERIAL_SHARES_DEBUG_SERIAL', '#define MBSERIAL', '#define MODBUS_ENABLED', '', @@ -29,6 +30,55 @@ describe('generateModbusDefines', () => { ) }) + it('Phase 2: RTU on the default port takes its baud from the Serial section and shares the debug serial', () => { + const out = generateModbusDefines({ + serial: { baud_rate: '9600' }, + modbus_rtu: { enabled: true, serial_port: 'Serial', rtu_slave_id: 1 }, + }) + expect(out).toContain('#define MBSERIAL_IFACE Serial') + expect(out).toContain('#define MBSERIAL_BAUD 9600') + expect(out).toContain('#define MBSERIAL_SHARES_DEBUG_SERIAL') + }) + + it('Phase 2: RTU on a secondary port uses its own baud and does NOT share the debug serial', () => { + const out = generateModbusDefines( + { + serial: { baud_rate: '9600' }, + modbus_rtu: { enabled: true, serial_port: 'Serial1', baud_rate: '19200', rtu_slave_id: 1 }, + }, + 'Serial', + ) + expect(out).toContain('#define MBSERIAL_IFACE Serial1') + expect(out).toContain('#define MBSERIAL_BAUD 19200') + expect(out).not.toContain('MBSERIAL_SHARES_DEBUG_SERIAL') + }) + + it('Phase 2: honors a non-default `defaultSerial` when deciding the shares flag', () => { + const out = generateModbusDefines( + { serial: { baud_rate: '9600' }, modbus_rtu: { enabled: true, serial_port: 'Serial1' } }, + 'Serial1', + ) + // serial_port === defaultSerial → shares, and baud from the Serial section. + expect(out).toContain('#define MBSERIAL_IFACE Serial1') + expect(out).toContain('#define MBSERIAL_BAUD 9600') + expect(out).toContain('#define MBSERIAL_SHARES_DEBUG_SERIAL') + }) + + it('Phase 2: reads TCP network config from the network section', () => { + const out = generateModbusDefines({ + network: { + interface: 'Wi-Fi', + wifi_ssid: 'MyNet', + wifi_password: 'super-secret', + enable_dhcp: true, + }, + modbus_tcp: { enabled: true, unit_id: 1 }, + }) + expect(out).toContain('#define MBTCP_SSID "MyNet"') + expect(out).toContain('#define MBTCP_PWD "super-secret"') + expect(out).toContain('#define MBTCP_WIFI') + }) + it('applies RTU schema defaults when only `enabled: true` is persisted (form-layout writes only touched fields)', () => { // Real-world scenario: user toggles "Enable Modbus RTU" without // editing baud/interface/slave — form-layout writes only the field diff --git a/src/backend/shared/compile/steps/generate-defines.ts b/src/backend/shared/compile/steps/generate-defines.ts index a5e0b55c2..0510cbb86 100644 --- a/src/backend/shared/compile/steps/generate-defines.ts +++ b/src/backend/shared/compile/steps/generate-defines.ts @@ -79,6 +79,10 @@ export interface GenerateDefinesInput { * fixed RTU-over-USART0 block. Web passes `undefined` until * VPP screens land on the web build. */ vppModbusState?: VppModbusScreenState + /** Name of the board's default serial port (from the VPP manifest device's + * `defaultSerial`; `BoardInfo.defaultSerial`). Drives `DEBUG_IFACE` and the + * RTU "shares the debug serial" flag. Absent → `Serial`. */ + defaultSerial?: string } /** @@ -100,7 +104,8 @@ export interface GenerateDefinesInput { * editor-produced and web-produced firmware comes out clean). */ export function generateDefinesContent(input: GenerateDefinesInput): string { - const { boardEntry, devicePinMapping, stProgramFileContent, buildMD5Hash, boardRuntime, vppModbusState } = input + const { boardEntry, devicePinMapping, stProgramFileContent, buildMD5Hash, boardRuntime, vppModbusState, defaultSerial } = + input let DEFINES_CONTENT = '' @@ -145,7 +150,6 @@ export function generateDefinesContent(input: GenerateDefinesInput): string { // Runtime-v4 / runtime-v3 targets route Modbus config through // `conf/modbus_slave.json` in the upload bundle and emit no // macros here. - let modbusEnabled = false if (boardRuntime === 'simulator') { DEFINES_CONTENT += '//Comms Configuration\n' DEFINES_CONTENT += '#define SIMULATOR_MODE\n' @@ -155,28 +159,27 @@ export function generateDefinesContent(input: GenerateDefinesInput): string { DEFINES_CONTENT += '#define MBSERIAL\n' DEFINES_CONTENT += '#define MODBUS_ENABLED\n' DEFINES_CONTENT += `\n\n` - modbusEnabled = true } else if (boardRuntime !== 'openplc-compiler' && vppModbusState) { - const modbusBlock = generateModbusDefines(vppModbusState) + const modbusBlock = generateModbusDefines(vppModbusState, defaultSerial) if (modbusBlock.length > 0) { DEFINES_CONTENT += modbusBlock DEFINES_CONTENT += '\n\n' - modbusEnabled = true } } - // 4b. Debugger — always-on debug over serial for baremetal Arduino targets. - // Emitted only when full Modbus is NOT active: with Modbus off, this is - // the gate that brings up the serial port and the debug function codes - // (0x41-0x48) WITHOUT allocating any operation buffers (coils/holding/ - // etc.), saving SRAM on small boards. When Modbus IS active the debugger - // already rides Modbus's own transport, so emitting DEBUGGER_ENABLED then - // would be redundant (and, in a TCP-only Modbus build, would leave the - // serial port uninitialised). Simulator gets MODBUS_ENABLED above; - // openplc-compiler runtimes don't use this firmware at all. - if (boardRuntime !== 'simulator' && boardRuntime !== 'openplc-compiler' && !modbusEnabled) { + // 4b. Debugger — always-on serial debugger for baremetal Arduino targets. + // The default serial port is ALWAYS initialised (DEBUG_IFACE @ DEBUG_BAUD) + // so the debug function codes (0x41-0x48) respond over serial regardless + // of whether Modbus is configured — without allocating operation buffers. + // When Modbus RTU runs on that same default port, generateModbusDefines + // emits MBSERIAL_SHARES_DEBUG_SERIAL so the firmware begins the port once. + // Simulator uses its fixed MODBUS_ENABLED block above; openplc-compiler + // runtimes don't use this firmware at all. + if (boardRuntime !== 'simulator' && boardRuntime !== 'openplc-compiler') { DEFINES_CONTENT += '//Debugger\n' DEFINES_CONTENT += '#define DEBUGGER_ENABLED\n' + DEFINES_CONTENT += `#define DEBUG_IFACE ${defaultSerial ?? 'Serial'}\n` + DEFINES_CONTENT += `#define DEBUG_BAUD ${vppModbusState?.serial?.baud_rate ?? '115200'}\n` DEFINES_CONTENT += `\n\n` } diff --git a/src/backend/shared/compile/steps/modbus-defines.ts b/src/backend/shared/compile/steps/modbus-defines.ts index 693eb0b96..077e9c062 100644 --- a/src/backend/shared/compile/steps/modbus-defines.ts +++ b/src/backend/shared/compile/steps/modbus-defines.ts @@ -28,9 +28,32 @@ * VPP screen field set evolves. */ export interface VppModbusScreenState { + /** Phase 2 Serial section — always-on serial baud (debugger + RTU on the + * default port). */ + serial?: { + baud_rate?: string + } + /** Phase 2 Network section — Ethernet/Wi-Fi config lifted out of modbus_tcp. */ + network?: { + enabled?: boolean + interface?: 'Ethernet' | 'Wi-Fi' + mac_address?: string + wifi_ssid?: string + wifi_password?: string + enable_dhcp?: boolean + ip_address?: string + gateway?: string + subnet?: string + dns?: string + } modbus_rtu?: { enabled?: boolean + /** Phase 2: chosen serial port. Legacy projects use `rtu_interface`. */ + serial_port?: string rtu_interface?: string + /** Phase 2: baud for RTU on a secondary port. On the default port the + * Serial section's baud is used. Legacy projects use `rtu_baud_rate`. */ + baud_rate?: string rtu_baud_rate?: string rtu_slave_id?: number enable_rs485_en_pin?: boolean @@ -38,6 +61,9 @@ export interface VppModbusScreenState { } modbus_tcp?: { enabled?: boolean + unit_id?: number + // Legacy network fields (pre-Phase-2 projects still on the old screen). + // Read as a fallback when the `network` section is absent. tcp_interface?: 'Ethernet' | 'Wi-Fi' tcp_mac_address?: string tcp_wifi_ssid?: string @@ -88,7 +114,6 @@ function formatIpForDefine(raw: string): string { // to compile (ModbusSlave.cpp uses them as object/literal values). // Keep these in sync if the screen schema's defaults change. const RTU_DEFAULTS = { - rtu_interface: 'Serial', rtu_baud_rate: '115200', rtu_slave_id: 1, } as const @@ -110,9 +135,10 @@ const TCP_DEFAULTS = { * The output always ends with a trailing newline so callers can * concatenate without adding their own. */ -export function generateModbusDefines(state: VppModbusScreenState): string { +export function generateModbusDefines(state: VppModbusScreenState, defaultSerial: string = 'Serial'): string { const rtu = state.modbus_rtu ?? {} const tcp = state.modbus_tcp ?? {} + const net = state.network ?? {} const rtuOn = rtu.enabled === true const tcpOn = tcp.enabled === true @@ -122,12 +148,23 @@ export function generateModbusDefines(state: VppModbusScreenState): string { lines.push('//Comms Configuration') if (rtuOn) { - const iface = rtu.rtu_interface ?? RTU_DEFAULTS.rtu_interface - const baud = rtu.rtu_baud_rate ?? RTU_DEFAULTS.rtu_baud_rate + // Phase 2: RTU picks a serial port (`serial_port`); legacy projects carry + // `rtu_interface`. On the default port the RTU shares the always-on Serial + // baud; on a secondary port it uses its own (`baud_rate`), with the legacy + // `rtu_baud_rate` as a fallback for pre-migration projects. + const iface = rtu.serial_port ?? rtu.rtu_interface ?? defaultSerial + const onDefaultPort = iface === defaultSerial + const baud = onDefaultPort + ? (state.serial?.baud_rate ?? rtu.rtu_baud_rate ?? RTU_DEFAULTS.rtu_baud_rate) + : (rtu.baud_rate ?? rtu.rtu_baud_rate ?? RTU_DEFAULTS.rtu_baud_rate) const slave = typeof rtu.rtu_slave_id === 'number' ? rtu.rtu_slave_id : RTU_DEFAULTS.rtu_slave_id lines.push(`#define MBSERIAL_IFACE ${iface}`) lines.push(`#define MBSERIAL_BAUD ${baud}`) lines.push(`#define MBSERIAL_SLAVE ${slave}`) + // The RTU port IS the debugger's default serial → tell the firmware to + // initialise the port once (the always-on debugger already begins it) + // instead of calling begin() twice. + if (onDefaultPort) lines.push('#define MBSERIAL_SHARES_DEBUG_SERIAL') if (rtu.enable_rs485_en_pin === true && rtu.rtu_rs485_en_pin) { lines.push(`#define MBSERIAL_TXPIN ${rtu.rtu_rs485_en_pin}`) } @@ -135,34 +172,34 @@ export function generateModbusDefines(state: VppModbusScreenState): string { } if (tcpOn) { - // MBTCP_MAC / MBTCP_IP / MBTCP_DNS / MBTCP_GATEWAY / MBTCP_SUBNET - // are referenced unconditionally inside the `#ifdef MBTCP` block in - // `resources/sources/Baremetal/Baremetal.ino` (it builds five byte - // arrays and uses `sizeof(arr) < 4` as a compile-time DHCP-vs-static - // selector that cascades through to `mbconfig_ethernet_iface(mac, - // …, NULL, NULL, …)`). Missing a single macro fails compilation; an - // unset macro is signalled by emitting a single-byte `0` so the - // array has `sizeof == 1`, the `< 4` check fires, and the runtime - // falls back to the DHCP/NULL path. Wi-Fi mode ignores these args - // inside `mbconfig_ethernet_iface` (see `ModbusSlave.cpp:199-225`), - // so the placeholder values are harmless there too. - const macLiteral = tcp.tcp_mac_address ? formatMacForDefine(tcp.tcp_mac_address) : '0' - lines.push(`#define MBTCP_MAC ${macLiteral}`) + // Network config comes from the Phase 2 `network` section, falling back to + // the legacy `modbus_tcp` fields for pre-migration projects. + // + // MBTCP_MAC / MBTCP_IP / MBTCP_DNS / MBTCP_GATEWAY / MBTCP_SUBNET are + // referenced unconditionally inside the `#ifdef MBTCP` block in + // `Baremetal.ino` (five byte arrays, `sizeof(arr) < 4` as a compile-time + // DHCP-vs-static selector). A missing macro fails compilation; an unset + // value is signalled by a single-byte `0` so the `< 4` check fires and the + // runtime falls back to the DHCP/NULL path. + const mac = net.mac_address ?? tcp.tcp_mac_address + const ifaceSel = net.interface ?? tcp.tcp_interface ?? TCP_DEFAULTS.tcp_interface + const dhcpOn = (net.enable_dhcp ?? tcp.enable_dhcp) === true + const ip = net.ip_address ?? tcp.ip_address + const dns = net.dns ?? tcp.dns + const gateway = net.gateway ?? tcp.gateway + const subnet = net.subnet ?? tcp.subnet + const ssid = net.wifi_ssid ?? tcp.tcp_wifi_ssid + const pwd = net.wifi_password ?? tcp.tcp_wifi_password - const dhcpOn = tcp.enable_dhcp === true - const ipLiteral = !dhcpOn && tcp.ip_address ? formatIpForDefine(tcp.ip_address) : '0' - const dnsLiteral = !dhcpOn && tcp.dns ? formatIpForDefine(tcp.dns) : '0' - const gatewayLiteral = !dhcpOn && tcp.gateway ? formatIpForDefine(tcp.gateway) : '0' - const subnetLiteral = !dhcpOn && tcp.subnet ? formatIpForDefine(tcp.subnet) : '0' - lines.push(`#define MBTCP_IP ${ipLiteral}`) - lines.push(`#define MBTCP_DNS ${dnsLiteral}`) - lines.push(`#define MBTCP_GATEWAY ${gatewayLiteral}`) - lines.push(`#define MBTCP_SUBNET ${subnetLiteral}`) + lines.push(`#define MBTCP_MAC ${mac ? formatMacForDefine(mac) : '0'}`) + lines.push(`#define MBTCP_IP ${!dhcpOn && ip ? formatIpForDefine(ip) : '0'}`) + lines.push(`#define MBTCP_DNS ${!dhcpOn && dns ? formatIpForDefine(dns) : '0'}`) + lines.push(`#define MBTCP_GATEWAY ${!dhcpOn && gateway ? formatIpForDefine(gateway) : '0'}`) + lines.push(`#define MBTCP_SUBNET ${!dhcpOn && subnet ? formatIpForDefine(subnet) : '0'}`) - const iface = tcp.tcp_interface ?? TCP_DEFAULTS.tcp_interface - if (iface === 'Wi-Fi') { - if (tcp.tcp_wifi_ssid) lines.push(`#define MBTCP_SSID "${tcp.tcp_wifi_ssid}"`) - if (tcp.tcp_wifi_password) lines.push(`#define MBTCP_PWD "${tcp.tcp_wifi_password}"`) + if (ifaceSel === 'Wi-Fi') { + if (ssid) lines.push(`#define MBTCP_SSID "${ssid}"`) + if (pwd) lines.push(`#define MBTCP_PWD "${pwd}"`) lines.push('#define MBTCP_WIFI') } else { lines.push('#define MBTCP_ETHERNET') From cfd7e9429207c086af0ce68c6d9c11532f279354 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 9 Jul 2026 12:13:43 +0200 Subject: [PATCH 04/19] feat(firmware): keep the debugger serial up on Modbus TCP-only builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2: with the always-on debugger now emitted unconditionally (DEBUGGER_ENABLED + DEBUG_IFACE/DEBUG_BAUD), a Modbus-TCP-only build (MODBUS_ENABLED without MBSERIAL) previously left the default serial uninitialised, so the debugger had no port. setup() now brings up DEBUG_IFACE @ DEBUG_BAUD on mb_serialport in that case. Single-serial model note: the debugger and Modbus RTU share one mb_serialport; when MBSERIAL_SHARES_DEBUG_SERIAL is set the RTU port IS the debugger's default serial (single begin). Running the debugger on the default serial while RTU uses a different UART simultaneously needs a second serial handler — documented follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- resources/sources/Baremetal/Baremetal.ino | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/resources/sources/Baremetal/Baremetal.ino b/resources/sources/Baremetal/Baremetal.ino index adcbeaf32..005b78773 100644 --- a/resources/sources/Baremetal/Baremetal.ino +++ b/resources/sources/Baremetal/Baremetal.ino @@ -155,6 +155,18 @@ void setup() mbconfig_serial_iface(&MBSERIAL_IFACE, MBSERIAL_BAUD, -1); #endif modbus.slaveid = MBSERIAL_SLAVE; + // NOTE (single-serial model): the debugger and Modbus RTU share one + // mb_serialport. When MBSERIAL_SHARES_DEBUG_SERIAL is defined the RTU + // port IS the debugger's default serial, so this single begin() also + // brings up the debugger. Running the debugger on the default USB + // serial while RTU uses a *different* UART simultaneously would need + // a second serial handler — a documented follow-up. + #elif defined(DEBUGGER_ENABLED) + // Modbus TCP-only build: no MBSERIAL, but the always-on debugger + // still needs the default serial up on mb_serialport to respond. + DEBUG_IFACE.begin(DEBUG_BAUD); + mbconfig_serial_iface(&DEBUG_IFACE, DEBUG_BAUD, -1); + modbus.slaveid = DEBUG_SLAVE; #endif #ifdef MBTCP From 74ab1b2ebccec4eb7263d4782745a4c0bb8cbfa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 9 Jul 2026 12:16:54 +0200 Subject: [PATCH 05/19] feat(debugger): fall back to the serial channel when no channel matches Phase 2: the always-on debugger keeps serial debug compiled into every baremetal firmware even with Modbus disabled, so resolveDebugConnection now falls back to the serial (rtu) channel instead of surfacing "Modbus Required" when no channel's enabledWhen matches. A TCP-only Modbus build leaves the tcp channel eligible, so it never hits the fallback and correctly debugs over TCP. Errors only when there is no serial channel at all. The serial channel's baud is sourced from the Serial section (screens.serial. baud_rate) via the VPP debug spec (NodeMCU pilot). 31 tests pass, 100% stmts/lines/functions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../hardware/__tests__/debug-spec.test.ts | 48 +++++++++++++++++-- src/backend/shared/hardware/debug-spec.ts | 29 +++++++---- 2 files changed, 64 insertions(+), 13 deletions(-) diff --git a/src/backend/shared/hardware/__tests__/debug-spec.test.ts b/src/backend/shared/hardware/__tests__/debug-spec.test.ts index d34d78455..cfa6efbbf 100644 --- a/src/backend/shared/hardware/__tests__/debug-spec.test.ts +++ b/src/backend/shared/hardware/__tests__/debug-spec.test.ts @@ -65,7 +65,10 @@ describe('resolveDebugConnection', () => { }) describe('channel selection', () => { - it('errors with `noneEnabled` message when no channel matches', () => { + it('falls back to the serial (rtu) channel when no channel matches (always-on debugger)', () => { + // The always-on debugger keeps serial debug compiled into every + // baremetal firmware even with Modbus disabled, so an rtu channel is + // always usable as a fallback instead of surfacing "Modbus Required". const spec: DebugSpec = { channels: [ { label: 'RTU', channel: 'rtu', enabledWhen: { $ref: 'screens.modbus_rtu.enabled' }, params: {} }, @@ -74,14 +77,30 @@ describe('resolveDebugConnection', () => { messages: { noneEnabled: { title: 'Modbus Required', body: 'Enable RTU or TCP.' } }, } const result = resolveDebugConnection(spec, makeContext()) + expect(result.kind).toBe('config') + if (result.kind === 'config') { + expect(result.config.connectionType).toBe('rtu') + expect(result.channelLabel).toBe('RTU') + } + }) + + it('errors with `noneEnabled` message when nothing matches and there is no serial fallback', () => { + // Only a non-serial channel exists, so there is no always-on serial + // fallback — the board genuinely has no usable debug channel. + const spec: DebugSpec = { + channels: [{ label: 'TCP', channel: 'tcp', enabledWhen: { $ref: 'screens.modbus_tcp.enabled' }, params: {} }], + messages: { noneEnabled: { title: 'Modbus Required', body: 'Enable RTU or TCP.' } }, + } + const result = resolveDebugConnection(spec, makeContext()) expect(result).toEqual({ kind: 'error', title: 'Modbus Required', body: 'Enable RTU or TCP.' }) }) - it('falls back to generic copy when `noneEnabled` message is absent', () => { - // `messages.noneEnabled` is optional — boards may omit it and - // expect the resolver to provide a sensible default. + it('falls back to generic copy when `noneEnabled` message is absent and no serial fallback', () => { + // `messages.noneEnabled` is optional — boards may omit it and expect the + // resolver to provide a sensible default. Uses a tcp-only spec so the + // serial fallback does not apply. const spec: DebugSpec = { - channels: [{ label: 'RTU', channel: 'rtu', enabledWhen: { $ref: 'screens.modbus_rtu.enabled' }, params: {} }], + channels: [{ label: 'TCP', channel: 'tcp', enabledWhen: { $ref: 'screens.modbus_tcp.enabled' }, params: {} }], } const result = resolveDebugConnection(spec, makeContext()) expect(result).toEqual({ @@ -91,6 +110,25 @@ describe('resolveDebugConnection', () => { }) }) + it('does NOT fall back to serial when a non-serial channel is enabled (TCP-only Modbus)', () => { + // TCP-only Modbus build: the tcp channel matches, so the resolver uses + // it and never offers serial (which the firmware does not expose here). + const spec: DebugSpec = { + channels: [ + { label: 'RTU', channel: 'rtu', enabledWhen: { $ref: 'screens.modbus_rtu.enabled' }, params: {} }, + { label: 'TCP', channel: 'tcp', enabledWhen: { $ref: 'screens.modbus_tcp.enabled' }, params: {} }, + ], + } + const result = resolveDebugConnection( + spec, + makeContext({ state: { screens: { modbus_tcp: { enabled: true } } } }), + ) + expect(result.kind).toBe('config') + if (result.kind === 'config') { + expect(result.config.connectionType).toBe('tcp') + } + }) + it('returns `pick` when multiple channels match', () => { const spec: DebugSpec = { channels: [ diff --git a/src/backend/shared/hardware/debug-spec.ts b/src/backend/shared/hardware/debug-spec.ts index fabd68fab..ba192a973 100644 --- a/src/backend/shared/hardware/debug-spec.ts +++ b/src/backend/shared/hardware/debug-spec.ts @@ -199,14 +199,26 @@ export function resolveDebugConnection( .map((channel, index) => ({ channel, index })) .filter(({ channel }) => evaluateCondition(channel.enabledWhen, context.state)) if (enabled.length === 0) { - const msg = spec.messages?.noneEnabled - return { - kind: 'error', - title: msg?.title ?? 'No Debug Channel', - body: msg?.body ?? 'No debug channel is enabled for this board.', + // Always-on debugger: every baremetal firmware keeps the serial debug + // function codes compiled in even when no Modbus transport is enabled + // (the DEBUGGER_ENABLED gate brings up the serial port without Modbus + // operation buffers). So when no channel's `enabledWhen` matches, fall + // back to the serial (`rtu`) channel instead of erroring — serial debug + // is always available. A TCP-only Modbus build leaves `enabled` non-empty + // (the `tcp` channel matches), so it never reaches this fallback and + // correctly debugs over TCP, matching the firmware which does NOT bring + // up the serial debugger in that configuration. + const rtuFallbackIndex = spec.channels.findIndex((channel) => channel.channel === 'rtu') + if (rtuFallbackIndex < 0) { + const msg = spec.messages?.noneEnabled + return { + kind: 'error', + title: msg?.title ?? 'No Debug Channel', + body: msg?.body ?? 'No debug channel is enabled for this board.', + } } - } - if (enabled.length > 1) { + activeIndex = rtuFallbackIndex + } else if (enabled.length > 1) { const msg = spec.messages?.pickProtocol return { kind: 'pick', @@ -214,8 +226,9 @@ export function resolveDebugConnection( title: msg?.title ?? 'Select Debug Channel', body: msg?.body ?? 'Multiple debug channels are enabled. Which one should the debugger use?', } + } else { + activeIndex = enabled[0].index } - activeIndex = enabled[0].index } const channel = spec.channels[activeIndex] From 4b3c1386f57a766c1263f0d849a1c4ee9e2ae27c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 9 Jul 2026 13:19:39 +0200 Subject: [PATCH 06/19] feat(firmware): run Modbus RTU on a secondary serial while the debugger keeps the default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 dual-serial: support the debugger on the default (USB) serial AND Modbus RTU on a distinct UART simultaneously. - modbus-defines: emit MBSERIAL_ON_SECONDARY when the RTU serial_port differs from the board's default serial (and MBSERIAL_SHARES_DEBUG_SERIAL when it's the same port). Tested, 100% stmts/lines/functions. - ModbusSlave.cpp: factor the serial servicing into handle_serial_port(port, txpin, slaveid, buf, len, last) with a parametrised mb_rtu_drop_front. Under MBSERIAL_ON_SECONDARY, two contexts each own an RX buffer (debug + rtu) and mb_frame is transient process/TX scratch; otherwise the single-port path is unchanged (buf IS mb_frame, no copy) — zero RAM cost on single-UART boards. - Baremetal.ino: bring up both serials in setup() under the dual-serial macro. - ModbusSlave.h: DEBUG_* defaults now apply whenever DEBUGGER_ENABLED (the dual path needs DEBUG_SLAVE even with MBSERIAL defined). RAM cost (dual builds only): 2*MAX_MB_FRAME + 12 bytes (268 B on 32U4, 524 B on 256-frame boards). Firmware verified via arduino-cli build (multi-UART board). Co-Authored-By: Claude Opus 4.8 (1M context) --- resources/sources/Baremetal/Baremetal.ino | 5 + resources/sources/Baremetal/ModbusSlave.cpp | 117 +++++++++++++----- resources/sources/Baremetal/ModbusSlave.h | 10 +- .../compile/__tests__/modbus-defines.test.ts | 3 + .../shared/compile/steps/modbus-defines.ts | 13 +- 5 files changed, 106 insertions(+), 42 deletions(-) diff --git a/resources/sources/Baremetal/Baremetal.ino b/resources/sources/Baremetal/Baremetal.ino index 005b78773..ce3de63b7 100644 --- a/resources/sources/Baremetal/Baremetal.ino +++ b/resources/sources/Baremetal/Baremetal.ino @@ -130,6 +130,11 @@ void setup() #ifdef MODBUS_ENABLED #ifdef MBSERIAL + #ifdef MBSERIAL_ON_SECONDARY + // Dual-serial: Modbus RTU runs on a secondary UART (below) while + // the always-on debugger keeps the default serial — bring it up. + DEBUG_IFACE.begin(DEBUG_BAUD); + #endif #ifdef MBSERIAL_TXPIN // Disable TX pin from OpenPLC hardware layer for (int i = 0; i < NUM_DISCRETE_INPUT; i++) diff --git a/resources/sources/Baremetal/ModbusSlave.cpp b/resources/sources/Baremetal/ModbusSlave.cpp index 8350f6b1d..a8212ae82 100644 --- a/resources/sources/Baremetal/ModbusSlave.cpp +++ b/resources/sources/Baremetal/ModbusSlave.cpp @@ -428,6 +428,23 @@ void handle_tcp() static uint16_t mb_rx_len = 0; static uint32_t mb_rx_last_ms = 0; +#ifdef MBSERIAL_ON_SECONDARY +// Dual-serial: the debugger keeps the default serial while Modbus RTU runs on a +// distinct UART. Each port needs its OWN RX assembly buffer — a partial frame on +// one port must survive while the other is serviced. `mb_frame` becomes a +// transient process/TX buffer, borrowed for one complete transaction at a time +// (safe: Modbus RTU is half-duplex turn-taking and the ports are polled +// sequentially). These extra buffers are compiled ONLY for boards that use a +// secondary Modbus serial (multi-UART, RAM-rich), so single-UART boards keep the +// original single-buffer footprint. +static uint8_t mb_rx_dbg[MAX_MB_FRAME]; +static uint16_t mb_rx_dbg_len = 0; +static uint32_t mb_rx_dbg_last_ms = 0; +static uint8_t mb_rx_rtu[MAX_MB_FRAME]; +static uint16_t mb_rx_rtu_len = 0; +static uint32_t mb_rx_rtu_last_ms = 0; +#endif + // Total on-wire length (slave id + PDU + 2 CRC bytes) of the request whose // first `n` bytes are in `f`. Returns >0 for a known length, 0 when more header // bytes are needed to size it, and -1 for a function code we do not serve (so @@ -475,25 +492,32 @@ static int32_t mb_rtu_frame_len(const uint8_t *f, uint16_t n) // so a genuine frame head sitting further into the buffer always survives and // is eventually found (guarantees resync convergence; no "discard every frame" // loop). Slides only run on the error path, so the O(n) cost is irrelevant. -static void mb_rtu_drop_front(uint16_t k) +static void mb_rtu_drop_front(uint8_t *buf, uint16_t *plen, uint16_t k) { - if (k >= mb_rx_len) { mb_rx_len = 0; return; } - for (uint16_t i = k; i < mb_rx_len; i++) - mb_frame[i - k] = mb_frame[i]; - mb_rx_len = (uint16_t)(mb_rx_len - k); + if (k >= *plen) { *plen = 0; return; } + for (uint16_t i = k; i < *plen; i++) + buf[i - k] = buf[i]; + *plen = (uint16_t)(*plen - k); } -void handle_serial() +// Service ONE serial port. `buf`/`plen`/`plast` are the port's own RX-assembly +// state; `slaveid` is its framing id; `txpin` its RS485 driver-enable pin (-1 +// when none). A complete frame is copied into the shared `mb_frame`, processed, +// and the response written back to `port`. In the single-serial build `buf` IS +// `mb_frame` (in-place, no copy); in the dual-serial build each port owns a +// distinct buffer and `mb_frame` is the transient process/TX scratch. +static void handle_serial_port(Stream *port, int8_t txpin, uint8_t slaveid, + uint8_t *buf, uint16_t *plen, uint32_t *plast) { uint16_t packet_crc; // 1) Drain the RX buffer without blocking. One frame's bytes may arrive // across several calls; the scan cycle is never stalled waiting on them. - while ((*mb_serialport).available() > 0) + while (port->available() > 0) { - if (mb_rx_len >= MAX_MB_FRAME) break; // full — let the parser drain it - mb_frame[mb_rx_len++] = (uint8_t)(*mb_serialport).read(); - mb_rx_last_ms = millis(); + if (*plen >= MAX_MB_FRAME) break; // full — let the parser drain it + buf[(*plen)++] = (uint8_t)port->read(); + *plast = millis(); } // 2) Extract every complete frame in the buffer. Each iteration either @@ -501,36 +525,43 @@ void handle_serial() // loop always terminates. for (;;) { - if (mb_rx_len == 0) + if (*plen == 0) return; - // Header byte-alignment: the first byte must be OUR slave id. This is - // the cheap framing check, and it is the ONLY validation applied to - // debugger frames (CRC is deliberately skipped on debug FCs for + // Header byte-alignment: the first byte must be THIS port's slave id. + // This is the cheap framing check, and it is the ONLY validation applied + // to debugger frames (CRC is deliberately skipped on debug FCs for // performance — those function codes are private and well-formed). - if (mb_frame[0] != modbus.slaveid) + if (buf[0] != slaveid) { - mb_rtu_drop_front(1); // foreign/garbage head — slide + mb_rtu_drop_front(buf, plen, 1); // foreign/garbage head — slide continue; } - int32_t expected = mb_rtu_frame_len(mb_frame, mb_rx_len); + int32_t expected = mb_rtu_frame_len(buf, *plen); if (expected < 0 || expected > MAX_MB_FRAME) { - mb_rtu_drop_front(1); // illegal FC / impossible length + mb_rtu_drop_front(buf, plen, 1); // illegal FC / impossible length continue; } - if (expected == 0 || mb_rx_len < (uint16_t)expected) + if (expected == 0 || *plen < (uint16_t)expected) { // Header incomplete, or the frame's tail has not arrived yet. Wait // for it; abandon the partial only if its remainder never comes. - if ((uint32_t)(millis() - mb_rx_last_ms) > MB_RTU_FRAME_GAP_MS) - mb_rx_len = 0; + if ((uint32_t)(millis() - *plast) > MB_RTU_FRAME_GAP_MS) + *plen = 0; return; } - // 3) A full candidate frame occupies mb_frame[0 .. expected). + // 3) A full candidate frame occupies buf[0 .. expected). Move it into the + // shared process buffer (a no-op self-copy on the single-serial path, + // where buf already IS mb_frame). + if (buf != mb_frame) + { + for (int32_t i = 0; i < expected; i++) mb_frame[i] = buf[i]; + } + // Standard FCs are validated by CRC (the arbiter that makes resync // trustworthy); a mismatch means corruption or misalignment, so we // slide one byte and retry instead of discarding the whole buffer. @@ -540,7 +571,7 @@ void handle_serial() packet_crc = ((mb_frame[expected - 2] << 8) | mb_frame[expected - 1]); if (packet_crc != calcCrc()) { - mb_rtu_drop_front(1); + mb_rtu_drop_front(buf, plen, 1); continue; } } @@ -558,34 +589,34 @@ void handle_serial() mb_frame[mb_frame_len - 2] = (uint8_t)(packet_crc >> 8); mb_frame[mb_frame_len - 1] = (uint8_t)(packet_crc & 0x00FF); - if (mb_txpin >= 0) + if (txpin >= 0) { - digitalWrite(mb_txpin, HIGH); + digitalWrite(txpin, HIGH); delayMicroseconds(mb_t35); } #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) - if (mb_serialport == &Serial3) // RS485 serial port + if (port == &Serial3) // RS485 serial port Controllino_RS485TxEnable(); // Enable RS485 chip to transmit #elif defined(CONTROLLINO_MICRO) - if (mb_serialport == &Serial2) { + if (port == &Serial2) { digitalWrite(CUSTOM_RS485_DEFAULT_DE_PIN, HIGH); digitalWrite(CUSTOM_RS485_DEFAULT_RE_PIN, HIGH); } #endif - (*mb_serialport).write(mb_frame, mb_frame_len); - (*mb_serialport).flush(); + port->write(mb_frame, mb_frame_len); + port->flush(); delayMicroseconds(mb_t35); - if (mb_txpin >= 0) - digitalWrite(mb_txpin, LOW); + if (txpin >= 0) + digitalWrite(txpin, LOW); #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) - if (mb_serialport == &Serial3) // RS485 serial port + if (port == &Serial3) // RS485 serial port Controllino_RS485RxEnable(); // Go back to receive mode after transmitted data #elif defined(CONTROLLINO_MICRO) - if (mb_serialport == &Serial2) { + if (port == &Serial2) { digitalWrite(CUSTOM_RS485_DEFAULT_DE_PIN, LOW); digitalWrite(CUSTOM_RS485_DEFAULT_RE_PIN, LOW); } @@ -597,10 +628,28 @@ void handle_serial() // can already be buffered. Reset for the next request. A // non-conformant pipelining master simply retransmits after its // timeout, and the gap/realignment logic above recovers cleanly. - mb_rx_len = 0; + *plen = 0; return; } } + +// Dispatch to one or two serial ports. Single-serial: the debugger and Modbus +// RTU (if any) share one port, assembled in-place in mb_frame. Dual-serial +// (MBSERIAL_ON_SECONDARY): the debugger keeps the default serial while Modbus +// RTU runs on a distinct UART — each with its own RX buffer. +void handle_serial() +{ +#ifdef MBSERIAL_ON_SECONDARY + handle_serial_port(&DEBUG_IFACE, -1, DEBUG_SLAVE, mb_rx_dbg, &mb_rx_dbg_len, &mb_rx_dbg_last_ms); + #ifdef MBSERIAL_TXPIN + handle_serial_port(&MBSERIAL_IFACE, MBSERIAL_TXPIN, MBSERIAL_SLAVE, mb_rx_rtu, &mb_rx_rtu_len, &mb_rx_rtu_last_ms); + #else + handle_serial_port(&MBSERIAL_IFACE, -1, MBSERIAL_SLAVE, mb_rx_rtu, &mb_rx_rtu_len, &mb_rx_rtu_last_ms); + #endif +#else + handle_serial_port(mb_serialport, mb_txpin, modbus.slaveid, mb_frame, &mb_rx_len, &mb_rx_last_ms); +#endif +} #endif diff --git a/resources/sources/Baremetal/ModbusSlave.h b/resources/sources/Baremetal/ModbusSlave.h index 5591eb387..506d9c0f7 100644 --- a/resources/sources/Baremetal/ModbusSlave.h +++ b/resources/sources/Baremetal/ModbusSlave.h @@ -19,10 +19,12 @@ Copyright (C) 2022 OpenPLC - Thiago Alves #define MB_SERIAL_ACTIVE #endif -// Default serial config for the always-on debugger when full Modbus RTU -// (MBSERIAL_*) is NOT configured. Override any of these in defines.h to change -// the port/baud/slave the debugger listens on. -#if defined(DEBUGGER_ENABLED) && !defined(MBSERIAL) +// Default serial config for the always-on debugger. `defines.h` normally emits +// DEBUG_IFACE / DEBUG_BAUD explicitly (from the Serial screen); these `#ifndef` +// defaults cover anything it left unset. Defined whenever the debugger is on — +// including alongside full Modbus RTU, since the dual-serial path (RTU on a +// secondary UART, MBSERIAL_ON_SECONDARY) needs DEBUG_SLAVE for the default port. +#ifdef DEBUGGER_ENABLED #ifndef DEBUG_IFACE #define DEBUG_IFACE Serial #endif diff --git a/src/backend/shared/compile/__tests__/modbus-defines.test.ts b/src/backend/shared/compile/__tests__/modbus-defines.test.ts index 72c8155f6..94758541b 100644 --- a/src/backend/shared/compile/__tests__/modbus-defines.test.ts +++ b/src/backend/shared/compile/__tests__/modbus-defines.test.ts @@ -38,6 +38,7 @@ describe('generateModbusDefines', () => { expect(out).toContain('#define MBSERIAL_IFACE Serial') expect(out).toContain('#define MBSERIAL_BAUD 9600') expect(out).toContain('#define MBSERIAL_SHARES_DEBUG_SERIAL') + expect(out).not.toContain('MBSERIAL_ON_SECONDARY') }) it('Phase 2: RTU on a secondary port uses its own baud and does NOT share the debug serial', () => { @@ -51,6 +52,8 @@ describe('generateModbusDefines', () => { expect(out).toContain('#define MBSERIAL_IFACE Serial1') expect(out).toContain('#define MBSERIAL_BAUD 19200') expect(out).not.toContain('MBSERIAL_SHARES_DEBUG_SERIAL') + // Distinct UART from the debugger's default → firmware services two serials. + expect(out).toContain('#define MBSERIAL_ON_SECONDARY') }) it('Phase 2: honors a non-default `defaultSerial` when deciding the shares flag', () => { diff --git a/src/backend/shared/compile/steps/modbus-defines.ts b/src/backend/shared/compile/steps/modbus-defines.ts index 077e9c062..260d7c00e 100644 --- a/src/backend/shared/compile/steps/modbus-defines.ts +++ b/src/backend/shared/compile/steps/modbus-defines.ts @@ -161,10 +161,15 @@ export function generateModbusDefines(state: VppModbusScreenState, defaultSerial lines.push(`#define MBSERIAL_IFACE ${iface}`) lines.push(`#define MBSERIAL_BAUD ${baud}`) lines.push(`#define MBSERIAL_SLAVE ${slave}`) - // The RTU port IS the debugger's default serial → tell the firmware to - // initialise the port once (the always-on debugger already begins it) - // instead of calling begin() twice. - if (onDefaultPort) lines.push('#define MBSERIAL_SHARES_DEBUG_SERIAL') + // On the default port the RTU IS the debugger's serial → tell the firmware + // to begin the port once (the always-on debugger already begins it). On a + // secondary port the RTU runs on a DISTINCT UART while the debugger keeps + // the default serial, so the firmware services two serial ports. + if (onDefaultPort) { + lines.push('#define MBSERIAL_SHARES_DEBUG_SERIAL') + } else { + lines.push('#define MBSERIAL_ON_SECONDARY') + } if (rtu.enable_rs485_en_pin === true && rtu.rtu_rs485_en_pin) { lines.push(`#define MBSERIAL_TXPIN ${rtu.rtu_rs485_en_pin}`) } From 86940ef7c80c13ef31d1c34da243ea113d418b30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Fri, 10 Jul 2026 11:55:08 +0200 Subject: [PATCH 07/19] refactor(firmware): split ModbusSlave into per-entity modules Break the monolithic ModbusSlave.{cpp,h} into 10 cohesive modbus_* translation units, each owning one concern and its own build gate. Behavior-preserving; validated by arduino-cli builds (RTU single-serial, debug-only/TCP, dual-serial). - modbus_config.h build gates (defines.h + MB_SERIAL_ACTIVE/DEBUG_* derived) - modbus_types.h enums, MBinfo, frame-size/status constants - modbus_frame.* shared seam (mb_frame/mb_frame_len/modbus, exceptionResponse) - modbus_crc.* CRC-16 + tables (defined once; fixes latent per-TU flash dup) - modbus_registers.* register store + operation FCs (#ifdef MODBUS_ENABLED) - modbus_debug.* debugger FCs 0x41-0x48 (home for future licensing FCs) - modbus_pdu.* process_mbpacket + mb_pdu_request_len/mb_pdu_skips_crc - modbus_serial.* RTU/debugger serial transport (single + dual-serial) - modbus_tcp.* Modbus TCP transport (Ethernet/WiFi/ETH) - ModbusSlave.* umbrella header + mbtask() facade (Baremetal.ino unchanged) Key decoupling: the serial transport no longer knows the function-code set. It asks modbus_pdu for per-FC frame shape (mb_pdu_request_len) and CRC policy (mb_pdu_skips_crc), so adding a function code touches only modbus_pdu + its handler, never the transports. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018esifhUpuyPmJB29BneUqr --- resources/sources/Baremetal/ModbusSlave.cpp | 1604 +---------------- resources/sources/Baremetal/ModbusSlave.h | 273 +-- resources/sources/Baremetal/modbus_config.h | 46 + resources/sources/Baremetal/modbus_crc.cpp | 72 + resources/sources/Baremetal/modbus_crc.h | 18 + resources/sources/Baremetal/modbus_debug.cpp | 397 ++++ resources/sources/Baremetal/modbus_debug.h | 29 + resources/sources/Baremetal/modbus_frame.cpp | 21 + resources/sources/Baremetal/modbus_frame.h | 23 + resources/sources/Baremetal/modbus_pdu.cpp | 182 ++ resources/sources/Baremetal/modbus_pdu.h | 34 + .../sources/Baremetal/modbus_registers.cpp | 525 ++++++ .../sources/Baremetal/modbus_registers.h | 31 + resources/sources/Baremetal/modbus_serial.cpp | 269 +++ resources/sources/Baremetal/modbus_serial.h | 32 + resources/sources/Baremetal/modbus_tcp.cpp | 250 +++ resources/sources/Baremetal/modbus_tcp.h | 77 + resources/sources/Baremetal/modbus_types.h | 90 + 18 files changed, 2143 insertions(+), 1830 deletions(-) create mode 100644 resources/sources/Baremetal/modbus_config.h create mode 100644 resources/sources/Baremetal/modbus_crc.cpp create mode 100644 resources/sources/Baremetal/modbus_crc.h create mode 100644 resources/sources/Baremetal/modbus_debug.cpp create mode 100644 resources/sources/Baremetal/modbus_debug.h create mode 100644 resources/sources/Baremetal/modbus_frame.cpp create mode 100644 resources/sources/Baremetal/modbus_frame.h create mode 100644 resources/sources/Baremetal/modbus_pdu.cpp create mode 100644 resources/sources/Baremetal/modbus_pdu.h create mode 100644 resources/sources/Baremetal/modbus_registers.cpp create mode 100644 resources/sources/Baremetal/modbus_registers.h create mode 100644 resources/sources/Baremetal/modbus_serial.cpp create mode 100644 resources/sources/Baremetal/modbus_serial.h create mode 100644 resources/sources/Baremetal/modbus_tcp.cpp create mode 100644 resources/sources/Baremetal/modbus_tcp.h create mode 100644 resources/sources/Baremetal/modbus_types.h diff --git a/resources/sources/Baremetal/ModbusSlave.cpp b/resources/sources/Baremetal/ModbusSlave.cpp index a8212ae82..807399e50 100644 --- a/resources/sources/Baremetal/ModbusSlave.cpp +++ b/resources/sources/Baremetal/ModbusSlave.cpp @@ -4,247 +4,16 @@ Copyright (C) 2022 OpenPLC - Thiago Alves */ #include "ModbusSlave.h" -// Debug surface comes via the extern "C" shims in arduino_runtime_glue.h -// (openplc_debug_*) so this TU stays free of strucpp template-heavy headers -// and compiles cleanly in arduino-cli's path with the core's default C++ -// standard (gnu++14 on mbed and others). The shims forward to -// strucpp::debug::handle_* inside arduino_runtime_glue.cpp, which is part -// of the precompiled OpenPLCUserLib archive built with -std=gnu++17. -#include "arduino_runtime_glue.h" +// The debugger handlers (and their arduino_runtime_glue.h / ArduinoUniqueID +// dependencies) moved to modbus_debug.cpp. -// ArduinoUniqueID (ricaun) backs the DEBUG_GET_BOARD_ID (0x48) function code. -// It supports AVR/megaAVR/SAM/SAMD/STM32/ESP/RP2040/Teensy. On a core without -// support (or when a board intentionally opts out via OPENPLC_NO_UNIQUE_ID), -// the board-id handler returns id_len = 0 instead of failing to compile. -#ifndef OPENPLC_NO_UNIQUE_ID - #include - #define OPENPLC_HAS_UNIQUE_ID -#endif +// Global Modbus vars — modbus / mb_frame / mb_frame_len moved to modbus_frame.cpp; +// the serial port/timing globals to modbus_serial.cpp; the TCP server state +// (mb_server / mb_serverClients / mb_mbap) to modbus_tcp.cpp. +// init_mbregs / get_discrete / write_discrete moved to modbus_registers.cpp. +// mbconfig_serial_iface() and the serial transport moved to modbus_serial.cpp. +// mbconfig_ethernet_iface() and handle_tcp() moved to modbus_tcp.cpp. -//Global Modbus vars -struct MBinfo modbus; -uint8_t mb_frame[MAX_MB_FRAME]; -uint16_t mb_frame_len; -Stream* mb_serialport; -int8_t mb_txpin; -uint16_t mb_t15; // inter character time out -uint16_t mb_t35; // frame delay - -#ifdef MBTCP_ETHERNET -#ifdef BOARD_ESP32 - WiFiServer mb_server(502); - WiFiClient mb_serverClients[MAX_SRV_CLIENTS]; -#else - EthernetServer mb_server(502); -#endif - uint8_t mb_mbap[MBAP_SIZE]; -#ifdef BOARD_PORTENTA - EthernetClient mb_serverClients[MAX_SRV_CLIENTS]; -#endif -#endif - -#ifdef MBTCP_WIFI - WiFiServer mb_server(502); - uint8_t mb_mbap[MBAP_SIZE]; -#if defined(BOARD_ESP8266) || defined(BOARD_ESP32) || defined(BOARD_PORTENTA) || defined(BOARD_PICOW) - WiFiClient mb_serverClients[MAX_SRV_CLIENTS]; -#endif -#endif - -bool init_mbregs(uint8_t size_holding, uint8_t size_dint_memory, uint8_t size_lint_memory, uint8_t size_coils, uint8_t size_inputregs, uint8_t size_inputstatus) -{ - //Save sizes - modbus.holding_size = size_holding; - modbus.dint_memory_size = size_dint_memory; - modbus.lint_memory_size = size_lint_memory; - modbus.coils_size = size_coils; - modbus.input_regs_size = size_inputregs; - modbus.input_status_size = size_inputstatus; - - //round discrete regs sizes - if (size_coils % 8 > 0) - size_coils = (size_coils / 8) + 1; - else - size_coils = size_coils / 8; - if (size_inputstatus % 8 > 0) - size_inputstatus = (size_inputstatus / 8) + 1; - else - size_inputstatus = (size_inputstatus / 8); - - modbus.coils = (uint8_t *)malloc(size_coils * sizeof(uint8_t)); - if (modbus.coils == NULL) return false; - memset(modbus.coils, 0, size_coils * sizeof(uint8_t)); - - modbus.holding = (uint16_t *)malloc(size_holding * sizeof(uint16_t)); - if (modbus.holding == NULL) return false; - memset(modbus.holding, 0, size_holding * sizeof(uint16_t)); - - if (size_dint_memory > 0) - { - modbus.dint_memory = (uint32_t *)malloc(size_dint_memory * sizeof(uint32_t)); - if (modbus.dint_memory == NULL) return false; - memset(modbus.dint_memory, 0, size_dint_memory * sizeof(uint32_t)); - } - - if (size_lint_memory > 0) - { - modbus.lint_memory = (uint64_t *)malloc(size_lint_memory * sizeof(uint64_t)); - if (modbus.lint_memory == NULL) return false; - memset(modbus.lint_memory, 0, size_lint_memory * sizeof(uint64_t)); - } - - modbus.input_status = (uint8_t *)malloc(size_inputstatus * sizeof(uint8_t)); - if (modbus.input_status == NULL) return false; - memset(modbus.input_status, 0, size_inputstatus * sizeof(uint8_t)); - - modbus.input_regs = (uint16_t *)malloc(size_inputregs * sizeof(uint16_t)); - if (modbus.input_regs == NULL) return false; - memset(modbus.input_regs, 0, size_inputregs * sizeof(uint16_t)); - - return true; -} - -bool get_discrete(uint16_t addr, bool regtype) -{ - uint8_t byte_addr = addr / 8; - uint8_t bit_addr = addr % 8; - if (regtype == COILS) - return bitRead(modbus.coils[byte_addr], bit_addr); - else - return bitRead(modbus.input_status[byte_addr], bit_addr); -} - -void write_discrete(uint16_t addr, bool regtype, bool value) -{ - uint8_t byte_addr = addr / 8; - uint8_t bit_addr = addr % 8; - if (regtype == COILS) - bitWrite(modbus.coils[byte_addr], bit_addr, value); - else - bitWrite(modbus.input_status[byte_addr], bit_addr, value); -} - -void mbconfig_serial_iface(Stream* port, long baud, int txPin) -{ - mb_serialport = port; - mb_txpin = txPin; - //(*port).begin(baud); //Initialization already happened on main .ino file - - //RS-485 control - if (txPin >= 0) - { - pinMode(txPin, OUTPUT); - digitalWrite(txPin, LOW); - } - - #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) - if (mb_serialport == &Serial3) - Controllino_RS485Init(); - #elif defined(CONTROLLINO_MICRO) - if (mb_serialport == &Serial2) { - pinMode(CUSTOM_RS485_DEFAULT_DE_PIN, OUTPUT); - pinMode(CUSTOM_RS485_DEFAULT_RE_PIN, OUTPUT); - digitalWrite(CUSTOM_RS485_DEFAULT_DE_PIN, LOW); - digitalWrite(CUSTOM_RS485_DEFAULT_RE_PIN, HIGH); - } - #endif - - // Modbus states that a baud rate higher than 19200 must use a fixed 750 us - // for inter character time out. For baud rates below 19200 the timing - // is more critical and has to be calculated. - // E.g. 9600 baud in a 11 bit packet is 9600/11 = 872 characters per second - // In milliseconds this will be 872 characters per 1000ms. So for 1 character - // 1000ms/872 characters is 1.14583ms per character. Finally modbus states - // an inter-character must be 1.5T or 1.5 times longer than a character. Thus - // 1.5T = 1.14583ms * 1.5 = 1.71875ms. - // Thus the formula is T1.5(us) = (1000ms * 1000(us) * 1.5 * 11bits)/baud - // 1000ms * 1000(us) * 1.5 * 11bits = 16500000 can be calculated as a constant - - if (baud > 19200) - mb_t15 = 750; - else - mb_t15 = 16500000/baud; // 1T * 1.5 = T1.5 - - /* The modbus definition of a frame delay is a waiting period of 3.5 character times - between packets.*/ - - mb_t35 = mb_t15 * 3.5; -} - - -#ifdef MBTCP -void mbconfig_ethernet_iface(uint8_t *mac, uint8_t *ip, uint8_t *dns, uint8_t *gateway, uint8_t *subnet) -{ - #ifdef MBTCP_ETHERNET - #ifdef BOARD_ESP32 - - ETH.begin(); - - if (ip != NULL && subnet != NULL && gateway != NULL) - (ETH.config(ip, gateway, subnet, dns)); - - #else - if (ip == NULL) - Ethernet.begin(mac); - else if (dns == NULL) - Ethernet.begin(mac, IPAddress(ip)); - else if (gateway == NULL) - Ethernet.begin(mac, IPAddress(ip), IPAddress(dns)); - else if (subnet == NULL) - Ethernet.begin(mac, IPAddress(ip), IPAddress(dns), IPAddress(gateway)); - else - Ethernet.begin(mac, IPAddress(ip), IPAddress(dns), IPAddress(gateway), IPAddress(subnet)); - #endif - -// int num_tries = 0; -// while (!ETH.linkUp()) -// { -// delay(500); -// num_tries++; -// if (num_tries == 20) break; -// } - - #endif - #ifdef MBTCP_WIFI - #if defined(BOARD_ESP8266) || defined(BOARD_ESP32) - if (ip != NULL && gateway != NULL && subnet != NULL && dns != NULL) - { - uint8_t secondaryDNS[] = {8, 8, 8, 8}; - WiFi.config(IPAddress(ip), IPAddress(gateway), IPAddress(subnet), IPAddress(dns), IPAddress(secondaryDNS)); - } - mb_server.setNoDelay(true); - #elif defined(BOARD_PORTENTA) - if (ip != NULL && subnet != NULL && gateway != NULL) - { - WiFi.config(IPAddress(ip), IPAddress(subnet), IPAddress(gateway)); - } - #else - if (ip != NULL) - { - if (dns == NULL) - WiFi.config(IPAddress(ip)); - else if (gateway == NULL) - WiFi.config(IPAddress(ip), IPAddress(dns)); - else if (subnet == NULL) - WiFi.config(IPAddress(ip), IPAddress(dns), IPAddress(gateway)); - else - WiFi.config(IPAddress(ip), IPAddress(dns), IPAddress(gateway), IPAddress(subnet)); - } - #endif - WiFi.begin(MBTCP_SSID, MBTCP_PWD); - int num_tries = 0; - while (WiFi.status() != WL_CONNECTED) - { - delay(500); - num_tries++; - if (num_tries == 10) break; - } - #endif - - mb_server.begin(); - -} -#endif void mbtask() { @@ -256,1362 +25,19 @@ void mbtask() #endif } -#ifdef MBTCP -void handle_tcp() -{ - #ifdef MBTCP_ETHERNET - #ifdef BOARD_ESP32 - WiFiClient client = mb_server.available(); - #else - EthernetClient client = mb_server.available(); - #endif - #endif - - #if defined(MBTCP_WIFI) && !defined(BOARD_ESP8266) && !defined(BOARD_ESP32) - WiFiClient client = mb_server.available(); - #endif - - //ESP and Portenta boards have a slightly different implementation of the WiFi/Ethernet API - therefore their specific - //code lies below - #if (defined(BOARD_ESP8266) || defined(BOARD_ESP32) || defined(BOARD_PORTENTA)) || defined(BOARD_PICOW) && (defined(MBTCP_WIFI) || defined(MBTCP_ETHERNET)) - - - #if defined(BOARD_PORTENTA) || defined(BOARD_PICOW) || (defined(BOARD_ESP32) && defined(MBTCP_ETHERNET)) - if (client) - #else - if (mb_server.hasClient()) - #endif - { - for (int i = 0; i < MAX_SRV_CLIENTS; i++) - { - if (!mb_serverClients[i]) //equivalent to !serverClients[i].connected() - { - #if defined(BOARD_PORTENTA) || defined(BOARD_PICOW) || defined(BOARD_ESP32) && defined(MBTCP_ETHERNET) - mb_serverClients[i] = client; - #else - mb_serverClients[i] = mb_server.available(); - #endif - break; - } - } - } - - //search all clients for data - for (int i = 0; i < MAX_SRV_CLIENTS; i++) - { - int j = 0; - - - if (mb_serverClients[i].connected() && mb_serverClients[i].available()) - - { - //Read packet - - - while (mb_serverClients[i].available()) - { - mb_mbap[j] = mb_serverClients[i].read(); - j++; - if (j==MBAP_SIZE) break; //MBAP has 6 bytes (we use UnitID as SlaveID) - } - - mb_frame_len = mb_mbap[4] << 8 | mb_mbap[5]; - - if (mb_mbap[2] !=0 || mb_mbap[3] !=0) return; //Not a MODBUSIP packet - if (mb_frame_len < 6 || mb_frame_len > MAX_MB_FRAME) return; //Packet is too small or too big - - j = 0; - while (mb_serverClients[i].available()) - { - mb_frame[j] = mb_serverClients[i].read(); - j++; - if (j==mb_frame_len) break; - } - - //Safety check - discard packages that lie about their size - if (j != mb_frame_len) return; - - //Process packet and write back - process_mbpacket(); - //Calculate packet length for MBAP header (mb_frame_len + 1) - mb_mbap[4] = (mb_frame_len) >> 8; - mb_mbap[5] = (mb_frame_len) & 0x00FF; - - uint8_t sendbuffer[mb_frame_len + MBAP_SIZE]; - - //MBAP - for (j = 0 ; j < MBAP_SIZE ; j++) - sendbuffer[j] = mb_mbap[j]; - - //PDU Frame - for (j = 0 ; j < mb_frame_len ; j++) - sendbuffer[j+MBAP_SIZE] = mb_frame[j]; - - //Write back - mb_serverClients[i].write(sendbuffer, mb_frame_len + MBAP_SIZE); - } - } - - //If this is not an ESP board or Portenta board, then here is the default code - #else - if (client) - { - if (client.connected()) - { - int i = 0; - while (client.available()) - { - mb_mbap[i] = client.read(); - i++; - if (i==MBAP_SIZE) break; //MBAP has 6 bytes (we use UnitID as SlaveID) - } - - mb_frame_len = mb_mbap[4] << 8 | mb_mbap[5]; - - if (mb_mbap[2] !=0 || mb_mbap[3] !=0) return; //Not a MODBUSIP packet - if (mb_frame_len < 6 || mb_frame_len > MAX_MB_FRAME) return; //Packet is too small or too big - - i = 0; - while (client.available()) - { - mb_frame[i] = client.read(); - i++; - if (i==mb_frame_len || i==MAX_MB_FRAME) break; - } - - //Safety check - discard packages that lie about their size - if (i != mb_frame_len) return; - - //Process packet and write back - process_mbpacket(); - //Calculate packet length for MBAP header (mb_frame_len + 1) - mb_mbap[4] = (mb_frame_len) >> 8; - mb_mbap[5] = (mb_frame_len) & 0x00FF; - - uint8_t sendbuffer[mb_frame_len + MBAP_SIZE]; - - //MBAP - for (i = 0 ; i < MBAP_SIZE ; i++) - sendbuffer[i] = mb_mbap[i]; - - //PDU Frame - for (i = 0 ; i < mb_frame_len ; i++) - sendbuffer[i+MBAP_SIZE] = mb_frame[i]; - - //Write back - client.write(sendbuffer, mb_frame_len + MBAP_SIZE); - } - } - #endif -} -#endif - -#ifdef MB_SERIAL_ACTIVE -// Inter-frame idle, in milliseconds, used ONLY to abandon a frame whose -// remainder never arrives. Modbus RTU was defined for RS485, where bytes of a -// frame are ~one character time apart (T1.5/T3.5, tens of microseconds at -// 115200) and the byte cadence delimits frames. That assumption is INVALID on -// USB-CDC (and any store-and-forward link): a single request is split into -// 64-byte USB packets separated by USB-frame-scale gaps far longer than T1.5, -// so cadence framing tears requests apart (the bug that made the P1AM-100 / -// SAMD21 debugger crawl). We therefore frame by the request's DECLARED length -// (derived from the function code) and fall back to this idle only to drop a -// truncated partial. It must exceed any intra-frame USB gap yet stay well below -// a master's request timeout. -#define MB_RTU_FRAME_GAP_MS 8 - -// Persistent RX-assembly state. handle_serial() is called every scan cycle and -// never blocks; a request whose bytes straddle several calls is carried across -// them in mb_frame[0..mb_rx_len). (This shares mb_frame with handle_tcp, which -// is safe because an OpenPLC board is configured for a single Modbus transport; -// the two are not driven mid-frame at the same time.) -static uint16_t mb_rx_len = 0; -static uint32_t mb_rx_last_ms = 0; - -#ifdef MBSERIAL_ON_SECONDARY -// Dual-serial: the debugger keeps the default serial while Modbus RTU runs on a -// distinct UART. Each port needs its OWN RX assembly buffer — a partial frame on -// one port must survive while the other is serviced. `mb_frame` becomes a -// transient process/TX buffer, borrowed for one complete transaction at a time -// (safe: Modbus RTU is half-duplex turn-taking and the ports are polled -// sequentially). These extra buffers are compiled ONLY for boards that use a -// secondary Modbus serial (multi-UART, RAM-rich), so single-UART boards keep the -// original single-buffer footprint. -static uint8_t mb_rx_dbg[MAX_MB_FRAME]; -static uint16_t mb_rx_dbg_len = 0; -static uint32_t mb_rx_dbg_last_ms = 0; -static uint8_t mb_rx_rtu[MAX_MB_FRAME]; -static uint16_t mb_rx_rtu_len = 0; -static uint32_t mb_rx_rtu_last_ms = 0; -#endif - -// Total on-wire length (slave id + PDU + 2 CRC bytes) of the request whose -// first `n` bytes are in `f`. Returns >0 for a known length, 0 when more header -// bytes are needed to size it, and -1 for a function code we do not serve (so -// the byte cannot be a frame head). Length is implicit in Modbus RTU — derived -// per function code, exactly as `process_mbpacket()` later parses the fields. -static int32_t mb_rtu_frame_len(const uint8_t *f, uint16_t n) -{ - if (n < 2) return 0; // need at least id + FC - switch (f[1]) - { - case MB_FC_READ_COILS: - case MB_FC_READ_INPUT_STAT: - case MB_FC_READ_REGS: - case MB_FC_READ_INPUT_REGS: - case MB_FC_WRITE_COIL: - case MB_FC_WRITE_REG: - return 8; // [id][fc][a:2][b:2][crc:2] - case MB_FC_WRITE_COILS: - case MB_FC_WRITE_REGS: - if (n < 7) return 0; // byte count lives at f[6] - return 9 + (int32_t)f[6]; // + [bc:1][data:bc][crc:2] - case MB_FC_DEBUG_INFO: - return 4; // [id][fc][crc:2] - case MB_FC_DEBUG_GET: - return 9; // [id][fc][arr:1][s:2][e:2][crc:2] - case MB_FC_DEBUG_GET_LIST: - if (n < 4) return 0; // count lives at f[2..3] - return 6 + 3 * (int32_t)(((uint16_t)f[2] << 8) | f[3]); - case MB_FC_DEBUG_SET: - if (n < 8) return 0; // value len lives at f[6..7] - return 10 + (int32_t)(((uint16_t)f[6] << 8) | f[7]); - case MB_FC_DEBUG_GET_MD5: - return 8; // [id][fc][endian:2][00:2][crc:2] - case MB_FC_DEBUG_GET_STATUS: - case MB_FC_DEBUG_GET_VERSION: - case MB_FC_DEBUG_GET_BOARD_ID: - return 4; // [id][fc][crc:2] - default: - return -1; // not one of our function codes - } -} - -// Drop the first `k` bytes of the assembly buffer, keeping the remainder. Used -// for one-byte realignment on a bad/foreign frame head — NEVER a blind flush — -// so a genuine frame head sitting further into the buffer always survives and -// is eventually found (guarantees resync convergence; no "discard every frame" -// loop). Slides only run on the error path, so the O(n) cost is irrelevant. -static void mb_rtu_drop_front(uint8_t *buf, uint16_t *plen, uint16_t k) -{ - if (k >= *plen) { *plen = 0; return; } - for (uint16_t i = k; i < *plen; i++) - buf[i - k] = buf[i]; - *plen = (uint16_t)(*plen - k); -} - -// Service ONE serial port. `buf`/`plen`/`plast` are the port's own RX-assembly -// state; `slaveid` is its framing id; `txpin` its RS485 driver-enable pin (-1 -// when none). A complete frame is copied into the shared `mb_frame`, processed, -// and the response written back to `port`. In the single-serial build `buf` IS -// `mb_frame` (in-place, no copy); in the dual-serial build each port owns a -// distinct buffer and `mb_frame` is the transient process/TX scratch. -static void handle_serial_port(Stream *port, int8_t txpin, uint8_t slaveid, - uint8_t *buf, uint16_t *plen, uint32_t *plast) -{ - uint16_t packet_crc; - - // 1) Drain the RX buffer without blocking. One frame's bytes may arrive - // across several calls; the scan cycle is never stalled waiting on them. - while (port->available() > 0) - { - if (*plen >= MAX_MB_FRAME) break; // full — let the parser drain it - buf[(*plen)++] = (uint8_t)port->read(); - *plast = millis(); - } - - // 2) Extract every complete frame in the buffer. Each iteration either - // consumes/realigns by >=1 byte or returns to await more data, so the - // loop always terminates. - for (;;) - { - if (*plen == 0) - return; - - // Header byte-alignment: the first byte must be THIS port's slave id. - // This is the cheap framing check, and it is the ONLY validation applied - // to debugger frames (CRC is deliberately skipped on debug FCs for - // performance — those function codes are private and well-formed). - if (buf[0] != slaveid) - { - mb_rtu_drop_front(buf, plen, 1); // foreign/garbage head — slide - continue; - } - - int32_t expected = mb_rtu_frame_len(buf, *plen); - - if (expected < 0 || expected > MAX_MB_FRAME) - { - mb_rtu_drop_front(buf, plen, 1); // illegal FC / impossible length - continue; - } - if (expected == 0 || *plen < (uint16_t)expected) - { - // Header incomplete, or the frame's tail has not arrived yet. Wait - // for it; abandon the partial only if its remainder never comes. - if ((uint32_t)(millis() - *plast) > MB_RTU_FRAME_GAP_MS) - *plen = 0; - return; - } - - // 3) A full candidate frame occupies buf[0 .. expected). Move it into the - // shared process buffer (a no-op self-copy on the single-serial path, - // where buf already IS mb_frame). - if (buf != mb_frame) - { - for (int32_t i = 0; i < expected; i++) mb_frame[i] = buf[i]; - } - - // Standard FCs are validated by CRC (the arbiter that makes resync - // trustworthy); a mismatch means corruption or misalignment, so we - // slide one byte and retry instead of discarding the whole buffer. - if (mb_frame[1] != MB_FC_DEBUG_INFO && mb_frame[1] != MB_FC_DEBUG_SET && mb_frame[1] != MB_FC_DEBUG_GET && mb_frame[1] != MB_FC_DEBUG_GET_LIST && mb_frame[1] != MB_FC_DEBUG_GET_MD5 && mb_frame[1] != MB_FC_DEBUG_GET_STATUS && mb_frame[1] != MB_FC_DEBUG_GET_VERSION && mb_frame[1] != MB_FC_DEBUG_GET_BOARD_ID) - { - mb_frame_len = (uint16_t)expected; - packet_crc = ((mb_frame[expected - 2] << 8) | mb_frame[expected - 1]); - if (packet_crc != calcCrc()) - { - mb_rtu_drop_front(buf, plen, 1); - continue; - } - } - - // 4) Accepted. Hand the PDU (CRC stripped) to the shared processor, - // which builds the response back into mb_frame. - mb_frame_len = (uint16_t)expected - 2; - process_mbpacket(); - - //Add CRC - //Check if response message is too big for this device - if (mb_frame_len + 2 > MAX_MB_FRAME) exceptionResponse(mb_frame[1], MB_EX_SLAVE_FAILURE); - mb_frame_len += 2; //increase frame length by two bytes to acomodate CRC - packet_crc = calcCrc(); //calculate CRC of the new packet - mb_frame[mb_frame_len - 2] = (uint8_t)(packet_crc >> 8); - mb_frame[mb_frame_len - 1] = (uint8_t)(packet_crc & 0x00FF); - - if (txpin >= 0) - { - digitalWrite(txpin, HIGH); - delayMicroseconds(mb_t35); - } - - #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) - if (port == &Serial3) // RS485 serial port - Controllino_RS485TxEnable(); // Enable RS485 chip to transmit - #elif defined(CONTROLLINO_MICRO) - if (port == &Serial2) { - digitalWrite(CUSTOM_RS485_DEFAULT_DE_PIN, HIGH); - digitalWrite(CUSTOM_RS485_DEFAULT_RE_PIN, HIGH); - } - #endif - - port->write(mb_frame, mb_frame_len); - port->flush(); - delayMicroseconds(mb_t35); - - if (txpin >= 0) - digitalWrite(txpin, LOW); - - #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) - if (port == &Serial3) // RS485 serial port - Controllino_RS485RxEnable(); // Go back to receive mode after transmitted data - #elif defined(CONTROLLINO_MICRO) - if (port == &Serial2) { - digitalWrite(CUSTOM_RS485_DEFAULT_DE_PIN, LOW); - digitalWrite(CUSTOM_RS485_DEFAULT_RE_PIN, LOW); - } - #endif - - // 5) The request — and the response built over it — consumed the whole - // assembly buffer. Modbus RTU is turn-taking: the master waits for - // this reply before sending its next request, so no following frame - // can already be buffered. Reset for the next request. A - // non-conformant pipelining master simply retransmits after its - // timeout, and the gap/realignment logic above recovers cleanly. - *plen = 0; - return; - } -} - -// Dispatch to one or two serial ports. Single-serial: the debugger and Modbus -// RTU (if any) share one port, assembled in-place in mb_frame. Dual-serial -// (MBSERIAL_ON_SECONDARY): the debugger keeps the default serial while Modbus -// RTU runs on a distinct UART — each with its own RX buffer. -void handle_serial() -{ -#ifdef MBSERIAL_ON_SECONDARY - handle_serial_port(&DEBUG_IFACE, -1, DEBUG_SLAVE, mb_rx_dbg, &mb_rx_dbg_len, &mb_rx_dbg_last_ms); - #ifdef MBSERIAL_TXPIN - handle_serial_port(&MBSERIAL_IFACE, MBSERIAL_TXPIN, MBSERIAL_SLAVE, mb_rx_rtu, &mb_rx_rtu_len, &mb_rx_rtu_last_ms); - #else - handle_serial_port(&MBSERIAL_IFACE, -1, MBSERIAL_SLAVE, mb_rx_rtu, &mb_rx_rtu_len, &mb_rx_rtu_last_ms); - #endif -#else - handle_serial_port(mb_serialport, mb_txpin, modbus.slaveid, mb_frame, &mb_rx_len, &mb_rx_last_ms); -#endif -} -#endif - - -void process_mbpacket() -{ - uint8_t fcode = mb_frame[1]; -#ifdef MODBUS_ENABLED - // Standard Modbus fields — only used by the operation FCs, which are - // compiled out in debug-only builds (so guard to avoid unused-var warnings). - uint16_t field1 = (uint16_t)mb_frame[2] << 8 | (uint16_t)mb_frame[3]; - uint16_t field2 = (uint16_t)mb_frame[4] << 8 | (uint16_t)mb_frame[5]; -#endif - void *endianness_check = &mb_frame[2]; - - switch (fcode) - { -#ifdef MODBUS_ENABLED - // Standard Modbus operation FCs read/write the coil/register buffers, - // which only exist when full Modbus is enabled. In debug-only builds - // these cases are compiled out, so operation requests fall through to - // the default and get an ILLEGAL_FUNCTION exception. - case MB_FC_WRITE_REG: - //field1 = reg, field2 = value - writeSingleRegister(field1, field2); - break; - - case MB_FC_READ_REGS: - //field1 = startreg, field2 = numregs - readRegisters(field1, field2); - break; - - case MB_FC_WRITE_REGS: - //field1 = startreg, field2 = status - writeMultipleRegisters(field1, field2, mb_frame[6]); - break; - - case MB_FC_READ_COILS: - //field1 = startreg, field2 = numregs - readCoils(field1, field2); - break; - - case MB_FC_READ_INPUT_STAT: - //field1 = startreg, field2 = numregs - readInputStatus(field1, field2); - break; - - case MB_FC_READ_INPUT_REGS: - //field1 = startreg, field2 = numregs - readInputRegisters(field1, field2); - break; - - case MB_FC_WRITE_COIL: - //field1 = reg, field2 = status - writeSingleCoil(field1, field2); - break; - - case MB_FC_WRITE_COILS: - //field1 = startreg, field2 = numoutputs - writeMultipleCoils(field1, field2, mb_frame[6]); - break; -#endif // MODBUS_ENABLED - - case MB_FC_DEBUG_INFO: - debugInfo(); - break; - - case MB_FC_DEBUG_GET: - { - // PDU: [FC:1][arr:u8][start_elem:u16][end_elem:u16] - uint8_t arr = mb_frame[2]; - uint16_t startIdx = (uint16_t)mb_frame[3] << 8 | (uint16_t)mb_frame[4]; - uint16_t endIdx = (uint16_t)mb_frame[5] << 8 | (uint16_t)mb_frame[6]; - debugGetTrace(arr, startIdx, endIdx); - } - break; - - case MB_FC_DEBUG_GET_LIST: - { - // PDU: [FC:1][count:u16][(arr:u8, elem:u16)×count] - uint16_t numIndexes = (uint16_t)mb_frame[2] << 8 | (uint16_t)mb_frame[3]; - debugGetTraceList(numIndexes, &mb_frame[4]); - } - break; - - case MB_FC_DEBUG_SET: - { - // PDU: [FC:1][arr:u8][elem:u16][force:u8][len:u16][value...] - uint8_t arr = mb_frame[2]; - uint16_t elem = (uint16_t)mb_frame[3] << 8 | (uint16_t)mb_frame[4]; - uint8_t flag = mb_frame[5]; - uint16_t len = (uint16_t)mb_frame[6] << 8 | (uint16_t)mb_frame[7]; - void *value = &mb_frame[8]; - debugSetTrace(arr, elem, flag, len, value); - } - break; - - case MB_FC_DEBUG_GET_MD5: - debugGetMd5(endianness_check); - break; - - case MB_FC_DEBUG_GET_STATUS: - debugGetStatus(); - break; - - case MB_FC_DEBUG_GET_VERSION: - debugGetVersion(); - break; - - case MB_FC_DEBUG_GET_BOARD_ID: - debugGetBoardId(); - break; - - default: - exceptionResponse(fcode, MB_EX_ILLEGAL_FUNCTION); - } -} - - -//Modbus handling functions -void readRegisters(uint16_t startreg, uint16_t numregs) -{ - //Check value (numregs) - if (numregs < 0x0001 || numregs > 0x007D) - { - exceptionResponse(MB_FC_READ_REGS, MB_EX_ILLEGAL_VALUE); - return; - } - - //Check Address - if ((startreg+numregs) >= (modbus.holding_size + (2*modbus.dint_memory_size) + (4*modbus.lint_memory_size))) - { - exceptionResponse(MB_FC_READ_REGS, MB_EX_ILLEGAL_ADDRESS); - return; - } - - //calculate the query reply message length - mb_frame_len = 3 + (numregs * 2); - if (mb_frame_len > MAX_MB_FRAME) - { - //Response message is too big for this device - exceptionResponse(MB_FC_READ_REGS, MB_EX_SLAVE_FAILURE); - return; - } - - //Clean frame buffer (leave only SlaveID) - for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; - - mb_frame[1] = MB_FC_READ_REGS; - mb_frame[2] = mb_frame_len - 3; //byte count - - uint16_t val; - uint16_t i = 0; - uint8_t pos = 0; - while(numregs--) - { - if ((startreg + i) < modbus.holding_size) - { - //retrieve the value from the register bank for the current register - val = modbus.holding[startreg + i]; - } - else if ((startreg + i) < (modbus.holding_size + (2*modbus.dint_memory_size))) //32-bit registers - { - if ((startreg + i) % 2 == 0) //first word - { - pos = ((startreg + i) - modbus.holding_size) / 2; - val = (uint16_t)(modbus.dint_memory[pos] >> 16); - } - else //second word - { - pos = ((startreg + i) - modbus.holding_size - 1) / 2; - val = (uint16_t)(modbus.dint_memory[pos] & 0xffff); - } - } - else //64-bit registers - { - if ((startreg + i) % 4 == 0) //first word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size))) / 4; - val = (uint16_t)(modbus.lint_memory[pos] >> 48); - } - else if ((startreg + i) % 4 == 1) //second word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 1)) / 4; - val = (uint16_t)((modbus.lint_memory[pos] >> 32) & 0xffff); - } - else if ((startreg + i) % 4 == 2) //third word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 2)) / 4; - val = (uint16_t)((modbus.lint_memory[pos] >> 16) & 0xffff); - } - else //fourth word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 3)) / 4; - val = (uint16_t)(modbus.lint_memory[pos] & 0xffff); - } - } - - //write the high byte of the register value - mb_frame[3 + (i * 2)] = val >> 8; - //write the low byte of the register value - mb_frame[4 + (i * 2)] = val & 0xFF; - i++; - } -} - -void writeSingleRegister(uint16_t reg, uint16_t value) -{ - if (reg >= (modbus.holding_size + (2*modbus.dint_memory_size) + (4*modbus.lint_memory_size))) - { - exceptionResponse(MB_FC_WRITE_REG, MB_EX_ILLEGAL_ADDRESS); - return; - } - - uint8_t pos = 0; - - if (reg < modbus.holding_size) - { - modbus.holding[reg] = value; - } - else if (reg < (modbus.holding_size + (2*modbus.dint_memory_size))) //32-bit registers - { - if (reg % 2 == 0) //first word - { - pos = (reg - modbus.holding_size) / 2; - modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0x0000ffff; //zeroed first word - modbus.dint_memory[pos] = modbus.dint_memory[pos] | ((uint32_t)value << 16); //insert first word - } - else //second word - { - pos = (reg - modbus.holding_size - 1) / 2; - modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0xffff0000; - modbus.dint_memory[pos] = modbus.dint_memory[pos] | value; - } - - } - else //64-bit registers - { - if (reg % 4 == 0) //first word - { - pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size))) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0x0000ffffffffffff; //zeroed first word - modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 48); //insert first word - } - else if (reg % 4 == 1) //second word - { - pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size) - 1)) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffff0000ffffffff; - modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 32); - } - else if (reg % 4 == 2) //third word - { - pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size) - 2)) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffff0000ffff; - modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 16); - } - else //fourth word - { - pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size) - 3)) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffffffff0000; - modbus.lint_memory[pos] = modbus.lint_memory[pos] | value; - } - } -} - -void writeMultipleRegisters(uint16_t startreg, uint16_t numoutputs, uint8_t bytecount) -{ - //Check value - if (numoutputs < 0x0001 || numoutputs > 0x007B || bytecount != 2 * numoutputs) - { - exceptionResponse(MB_FC_WRITE_REGS, MB_EX_ILLEGAL_VALUE); - return; - } - - //Check Address (startreg...startreg + numregs) - if ((startreg + numoutputs) >= (modbus.holding_size + (2*modbus.dint_memory_size) + (4*modbus.lint_memory_size))) - { - exceptionResponse(MB_FC_WRITE_REGS, MB_EX_ILLEGAL_ADDRESS); - return; - } - - //Prepare answer frame buffer - mb_frame_len = 6; - mb_frame[1] = MB_FC_WRITE_REGS; - mb_frame[2] = startreg >> 8; - mb_frame[3] = startreg & 0x00FF; - mb_frame[4] = numoutputs >> 8; - mb_frame[5] = numoutputs & 0x00FF; - - uint16_t value; - uint16_t i = 0; - uint8_t pos = 0; - while(numoutputs--) - { - value = (uint16_t)mb_frame[7+i*2] << 8 | (uint16_t)mb_frame[8+i*2]; - - if ((startreg + i) < modbus.holding_size) - { - modbus.holding[(startreg + i)] = value; - } - else if ((startreg + i) < (modbus.holding_size + (2*modbus.dint_memory_size))) //32-bit registers - { - if ((startreg + i) % 2 == 0) //first word - { - pos = ((startreg + i) - modbus.holding_size) / 2; - modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0x0000ffff; //zeroed first word - modbus.dint_memory[pos] = modbus.dint_memory[pos] | ((uint32_t)value << 16); //insert first word - } - else //second word - { - pos = ((startreg + i) - modbus.holding_size - 1) / 2; - modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0xffff0000; - modbus.dint_memory[pos] = modbus.dint_memory[pos] | value; - } - - } - else //64-bit registers - { - if ((startreg + i) % 4 == 0) //first word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size))) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0x0000ffffffffffff; //zeroed first word - modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 48); //insert first word - } - else if ((startreg + i) % 4 == 1) //second word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 1)) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffff0000ffffffff; - modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 32); - } - else if ((startreg + i) % 4 == 2) //third word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 2)) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffff0000ffff; - modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 16); - } - else //fourth word - { - pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 3)) / 4; - modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffffffff0000; - modbus.lint_memory[pos] = modbus.lint_memory[pos] | value; - } - } - - i++; - } -} - -void exceptionResponse(uint16_t fcode, uint16_t excode) -{ - //Clean frame buffer (leave only SlaveID) - mb_frame_len = 3; - for (int i = 0; i < mb_frame_len; i++) mb_frame[i] = 0; - mb_frame[0] = modbus.slaveid; - mb_frame[1] = fcode + 0x80; - mb_frame[2] = excode; -} - -void readCoils(uint16_t startreg, uint16_t numregs) -{ - //Check value (numregs) - if (numregs < 0x0001 || numregs > 0x07D0) - { - exceptionResponse(MB_FC_READ_COILS, MB_EX_ILLEGAL_VALUE); - return; - } - - //Check Address - if (startreg + numregs > modbus.coils_size) - { - exceptionResponse(MB_FC_READ_COILS, MB_EX_ILLEGAL_ADDRESS); - return; - } - - //Determine the message length = slaveid + function type + byte count and - //for each group of 8 registers the message length increases by 1 - mb_frame_len = 3 + numregs/8; - if (numregs%8) mb_frame_len++; //Add 1 to the message length for the partial byte. - if (mb_frame_len > MAX_MB_FRAME) - { - //Response message is too big for this device - exceptionResponse(MB_FC_READ_COILS, MB_EX_SLAVE_FAILURE); - return; - } - - //Clean frame buffer (leave only SlaveID) - for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; - - mb_frame[1] = MB_FC_READ_COILS; - mb_frame[2] = mb_frame_len - 3; //byte count (mb_frame_len - slave id, function code and byte count) - - uint8_t bitn = 0; - uint16_t totregs = numregs; - uint16_t i; - while (numregs) - { - i = (totregs - numregs--) / 8; - if (get_discrete((uint8_t)startreg, COILS)) - bitSet(mb_frame[3+i], bitn); - else - bitClear(mb_frame[3+i], bitn); - - //increment the bit index - bitn++; - if (bitn == 8) bitn = 0; - //increment the register - startreg++; - } -} - -void readInputStatus(uint16_t startreg, uint16_t numregs) -{ - //Check value (numregs) - if (numregs < 0x0001 || numregs > 0x07D0) - { - exceptionResponse(MB_FC_READ_INPUT_STAT, MB_EX_ILLEGAL_VALUE); - return; - } - - //Check Address - if ((startreg + numregs) > modbus.input_status_size) - { - exceptionResponse(MB_FC_READ_INPUT_STAT, MB_EX_ILLEGAL_ADDRESS); - return; - } - - //Determine the message length = function type, byte count and - //for each group of 8 registers the message length increases by 1 - mb_frame_len = 3 + numregs/8; - if (numregs%8) mb_frame_len++; //Add 1 to the message length for the partial byte. - if (mb_frame_len > MAX_MB_FRAME) - { - //Response message is too big for this device - exceptionResponse(MB_FC_READ_INPUT_STAT, MB_EX_SLAVE_FAILURE); - return; - } - - //Clean frame buffer (leave only SlaveID) - for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; - - mb_frame[1] = MB_FC_READ_INPUT_STAT; - mb_frame[2] = mb_frame_len - 3; - - byte bitn = 0; - uint16_t totregs = numregs; - uint16_t i; - while (numregs) - { - i = (totregs - numregs--) / 8; - if (get_discrete(startreg, INPUTSTATUS)) - bitSet(mb_frame[3+i], bitn); - else - bitClear(mb_frame[3+i], bitn); - //increment the bit index - bitn++; - if (bitn == 8) bitn = 0; - //increment the register - startreg++; - } -} - -void readInputRegisters(uint16_t startreg, uint16_t numregs) -{ - //Check value (numregs) - if (numregs < 0x0001 || numregs > 0x007D) - { - exceptionResponse(MB_FC_READ_INPUT_REGS, MB_EX_ILLEGAL_VALUE); - return; - } - - //Check Address - if ((startreg + numregs) > modbus.input_regs_size) - { - exceptionResponse(MB_FC_READ_INPUT_REGS, MB_EX_ILLEGAL_ADDRESS); - return; - } - - //calculate the query reply message length - //for each register queried add 2 bytes - mb_frame_len = 3 + (numregs * 2); - if (mb_frame_len > MAX_MB_FRAME) - { - //Response message is too big for this device - exceptionResponse(MB_FC_READ_INPUT_REGS, MB_EX_SLAVE_FAILURE); - return; - } - - //Clean frame buffer (leave only SlaveID) - for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; - - mb_frame[1] = MB_FC_READ_INPUT_REGS; - mb_frame[2] = mb_frame_len - 3; - - uint16_t val; - uint16_t i = 0; - while(numregs--) - { - //retrieve the value from the register bank for the current register - val = modbus.input_regs[startreg + i]; - //write the high byte of the register value - mb_frame[3 + (i * 2)] = val >> 8; - //write the low byte of the register value - mb_frame[4 + (i * 2)] = val & 0xFF; - i++; - } -} - -void writeSingleCoil(uint16_t reg, uint16_t status) -{ - //Check value (status) - if (status != 0xFF00 && status != 0x0000) - { - exceptionResponse(MB_FC_WRITE_COIL, MB_EX_ILLEGAL_VALUE); - return; - } - - //Check Address - if (reg > (modbus.coils_size - 1)) - { - exceptionResponse(MB_FC_WRITE_COIL, MB_EX_ILLEGAL_ADDRESS); - return; - } - - //Execute - write_discrete(reg, COILS, status == 0xFF00 ? true : false); -} - -void writeMultipleCoils(uint16_t startreg, uint16_t numoutputs, uint16_t bytecount) -{ - //Check value - uint8_t bytecount_calc = numoutputs / 8; - if (numoutputs%8) bytecount_calc++; - if (numoutputs < 0x0001 || numoutputs > 0x07B0 || bytecount != bytecount_calc) - { - exceptionResponse(MB_FC_WRITE_COILS, MB_EX_ILLEGAL_VALUE); - return; - } - - //Check Address (startreg...startreg + numregs) - if ((startreg + numoutputs) > modbus.coils_size) - { - exceptionResponse(MB_FC_WRITE_COILS, MB_EX_ILLEGAL_ADDRESS); - return; - } - - //Prepare answer frame buffer - mb_frame_len = 6; - mb_frame[1] = MB_FC_WRITE_COILS; - mb_frame[2] = startreg >> 8; - mb_frame[3] = startreg & 0x00FF; - mb_frame[4] = numoutputs >> 8; - mb_frame[5] = numoutputs & 0x00FF; - - //Execute - uint8_t bitn = 0; - uint16_t totoutputs = numoutputs; - uint16_t i; - while (numoutputs) - { - i = (totoutputs - numoutputs--) / 8; - write_discrete(startreg, COILS, bitRead(mb_frame[7+i], bitn)); - //increment the bit index - bitn++; - if (bitn == 8) bitn = 0; - //increment the register - startreg++; - } -} - -/** - * @brief Sends a Modbus response frame for the DEBUG_INFO function code. - * - * This function constructs a Modbus response frame for the DEBUG_INFO function code. - * The response frame includes the number of variables defined in the PLC program. - * - * Modbus Response Frame (DEBUG_INFO): - * +-----+-------+-------+ - * | MB | Count | Count | - * | FC | | | - * +-----+-------+-------+ - * |0x41 | High | Low | - * | | Byte | Byte | - * | | | | - * +-----+-------+-------+ - * - * @return void - */ -// Phase 4 PDU: -// +-----+-------+------+-----------+-----------+-----------+ -// | FC | arrs | stat | count_0 | count_1 | ... | -// |0x41 | (u8) | (u8) | (u16 BE) | (u16 BE) | | -// +-----+-------+------+-----------+-----------+-----------+ -// Response: [FC, arrCount, STATUS_OK, (count×arrCount as u16 BE)] -void debugInfo() -{ - uint8_t arrCount = openplc_debug_array_count(); - - // Cap at what the Modbus frame can hold: 3 header bytes + 2 bytes/array. - // Realistic projects have <=10 arrays, so this is never a real limit. - uint8_t maxArrs = (MAX_MB_FRAME - 3) / 2; - if (arrCount > maxArrs) arrCount = maxArrs; - - mb_frame[1] = MB_FC_DEBUG_INFO; - mb_frame[2] = arrCount; - mb_frame[3] = MB_DEBUG_SUCCESS; - uint16_t pos = 4; - for (uint8_t i = 0; i < arrCount; i++) - { - uint16_t c = openplc_debug_elem_count(i); - mb_frame[pos++] = (uint8_t)(c >> 8); - mb_frame[pos++] = (uint8_t)(c & 0xFF); - } - mb_frame_len = pos; -} - -/** - * @brief Sends a Modbus response frame for the DEBUG_SET function code. - * - * This function constructs a Modbus response frame for the DEBUG_SET function code. - * The response frame indicates whether the set trace command was successful or if - * there was an error, such as an out-of-bounds index. - * - * Modbus Response Frame (DEBUG_SET): - * +-----+------+ - * | MB | Resp.| - * | FC | Code | - * +-----+------+ - * |0x42 | Code | - * +-----+------+ - * - * @param varidx The index of the variable to set trace for. - * @param flag The trace flag. - * @param len The length of the trace data. - * @param value Pointer to the trace data. - * - * @return void - */ -// Phase 4 PDU: [FC, arr, elem_hi, elem_lo, force, len_hi, len_lo, value...] -// Response: [FC, STATUS] -void debugSetTrace(uint8_t arr, uint16_t elem, uint8_t flag, - uint16_t len, void *value) -{ - if (len > (MAX_MB_FRAME - 8)) - { - mb_frame_len = 3; - mb_frame[1] = MB_FC_DEBUG_SET; - mb_frame[2] = MB_DEBUG_ERROR_OUT_OF_BOUNDS; - return; - } - - uint8_t status = openplc_debug_set( - arr, elem, (uint8_t)flag, (const uint8_t *)value, len); - - mb_frame_len = 3; - mb_frame[1] = MB_FC_DEBUG_SET; - mb_frame[2] = status; -} - -/** - * @brief Sends a Modbus response frame for the DEBUG_GET function code. - * - * This function constructs a Modbus response frame for the DEBUG_GET function code. - * The response frame includes the trace data for variables within the specified index range. - * - * Modbus Response Frame (DEBUG_GET): - * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ - * | MB | Resp. | Last | Last | Tick | Tick | Tick | Tick | Resp. | Resp.| Data | - * | FC | Code | Index | Index | | | | | Size | Size | Bytes | - * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ - * |0x44 | Code | High | Low | High | Mid | Mid | Low | High | Low | Data | - * | | | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Bytes | - * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ - * - * @param startidx The start index of the variables to get trace for. - * @param endidx The end index of the variables to get trace for. - * - * @return void - */ -// Phase 4 PDU: [FC, arr, start_hi, start_lo, end_hi, end_lo] -// Response: [FC, STATUS, last_elem_hi, last_elem_lo, -// tick_hi, tick_mh, tick_ml, tick_lo, -// size_hi, size_lo, data...] -void debugGetTrace(uint8_t arr, uint16_t startidx, uint16_t endidx) -{ - uint16_t arrCount = openplc_debug_elem_count(arr); - if (arrCount == 0 || startidx >= arrCount || - endidx >= arrCount || startidx > endidx) - { - mb_frame_len = 3; - mb_frame[1] = MB_FC_DEBUG_GET; - mb_frame[2] = MB_DEBUG_ERROR_OUT_OF_BOUNDS; - return; - } - - uint16_t lastElemIdx = startidx; - uint16_t responseSize = 0; - uint8_t *responsePtr = &(mb_frame[11]); - - for (uint16_t elem = startidx; elem <= endidx; elem++) - { - uint16_t varSize = openplc_debug_size(arr, elem); - // Bounds check — stop packing if this one won't fit. - if ((11 + responseSize + varSize) > MAX_MB_FRAME) break; - if (varSize == 0) { - // Entry has no readable bytes (string stub / out-of-bounds) - // — skip gracefully to keep the scan progressing. - lastElemIdx = elem; - continue; - } - uint16_t n = openplc_debug_read(arr, elem, responsePtr); - if (n == 0) { - lastElemIdx = elem; - continue; - } - responsePtr += n; - responseSize += n; - lastElemIdx = elem; - } - - mb_frame_len = 11 + responseSize; - mb_frame[1] = MB_FC_DEBUG_GET; - mb_frame[2] = MB_DEBUG_SUCCESS; - mb_frame[3] = (uint8_t)(lastElemIdx >> 8); - mb_frame[4] = (uint8_t)(lastElemIdx & 0xFF); - mb_frame[5] = (uint8_t)((scan_counter >> 24) & 0xFF); - mb_frame[6] = (uint8_t)((scan_counter >> 16) & 0xFF); - mb_frame[7] = (uint8_t)((scan_counter >> 8) & 0xFF); - mb_frame[8] = (uint8_t)(scan_counter & 0xFF); - mb_frame[9] = (uint8_t)(responseSize >> 8); - mb_frame[10] = (uint8_t)(responseSize & 0xFF); -} - -/** - * @brief Sends a Modbus response frame for the DEBUG_GET_LIST function code. - * - * This function constructs a Modbus response frame for the DEBUG_GET_LIST function code. - * The response frame includes the trace data for variables specified in the provided index list. - * - * Modbus Response Frame (DEBUG_GET_LIST): - * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ - * | MB | Resp. | Last | Last | Tick | Tick | Tick | Tick | Resp. | Resp.| Data | - * | FC | Code | Index | Index | | | | | Size | Size | Bytes | - * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ - * |0x44 | Code | High | Low | High | Mid | Mid | Low | High | Low | Data | - * | | | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Bytes | - * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ - * - * @param numIndexes The number of indexes requested. - * @param indexArray Pointer to the array containing variable indexes. - * - * @return void - */ -// Phase 4 PDU: [FC, count_hi, count_lo, (arr:u8, elem_hi, elem_lo)×count] -// Response: [FC, STATUS, last_idx_hi, last_idx_lo, -// tick_hi, tick_mh, tick_ml, tick_lo, -// size_hi, size_lo, data...] -// last_idx is the index *into the request list* that was last successfully -// included — the editor uses it to retry from the next item on overflow. -void debugGetTraceList(uint16_t numIndexes, uint8_t *indexArray) -{ - uint16_t response_idx = 11; - uint16_t responseSize = 0; - uint16_t lastReqIdx = 0; - - #ifdef MB_SERIAL_ACTIVE - #define VARIDX_SIZE 20 - #else - #define VARIDX_SIZE 60 - #endif - - if (numIndexes > VARIDX_SIZE) - { - mb_frame_len = 3; - mb_frame[1] = MB_FC_DEBUG_GET_LIST; - mb_frame[2] = MB_DEBUG_ERROR_OUT_OF_MEMORY; - return; - } - - // The request indexArray (at mb_frame[4..]) and the response buffer - // (mb_frame[11..]) overlap. Once handle_read writes the first response - // byte, later index entries inside mb_frame are clobbered. Snapshot the - // request first. - uint8_t localIndex[VARIDX_SIZE * 3]; - for (uint16_t i = 0; i < numIndexes * 3; i++) { - localIndex[i] = indexArray[i]; - } - - // Each address pair is 3 bytes: [arr:u8, elem_hi, elem_lo] - for (uint16_t i = 0; i < numIndexes; i++) - { - uint8_t arr = localIndex[i * 3]; - uint16_t elem = (uint16_t)localIndex[i * 3 + 1] << 8 | - (uint16_t)localIndex[i * 3 + 2]; - - uint16_t varSize = openplc_debug_size(arr, elem); - if (varSize == 0) - { - // Out-of-bounds or string stub — skip gracefully. - lastReqIdx = i; - continue; - } - if ((response_idx + varSize) > MAX_MB_FRAME) break; - - uint16_t n = openplc_debug_read(arr, elem, &mb_frame[response_idx]); - if (n == 0) - { - lastReqIdx = i; - continue; - } - response_idx += n; - responseSize += n; - lastReqIdx = i; - } - - mb_frame_len = response_idx; - mb_frame[1] = MB_FC_DEBUG_GET_LIST; - mb_frame[2] = MB_DEBUG_SUCCESS; - mb_frame[3] = (uint8_t)(lastReqIdx >> 8); - mb_frame[4] = (uint8_t)(lastReqIdx & 0xFF); - mb_frame[5] = (uint8_t)((scan_counter >> 24) & 0xFF); - mb_frame[6] = (uint8_t)((scan_counter >> 16) & 0xFF); - mb_frame[7] = (uint8_t)((scan_counter >> 8) & 0xFF); - mb_frame[8] = (uint8_t)(scan_counter & 0xFF); - mb_frame[9] = (uint8_t)(responseSize >> 8); - mb_frame[10] = (uint8_t)(responseSize & 0xFF); -} - -// PDU request: [FC, endian_check_hi, endian_check_lo] -// PDU response: [FC, STATUS, md5_ascii..., endian_marker_hi, endian_marker_lo] -// -// The target always writes variable data in native byte order — STruC++ does -// no server-side byte-order adaptation, force/read is pure memcpy. To let -// the editor detect what "native" means here, the MD5 response trailer -// writes the literal value 0xDEAD via a native `uint16_t*` store. The -// bytes that land in the response are therefore in the target's native -// byte order: -// -// LE target → trailer bytes = [0xAD, 0xDE] -// BE target → trailer bytes = [0xDE, 0xAD] -// -// The editor inspects those two bytes after MD5 verification and decides -// whether subsequent force/read traffic needs byte-swapping at its end. -// -// The probe bytes the editor sends are intentionally ignored — the trailer -// is a runtime-driven sentinel, not an echo. The argument stays in the -// signature for ABI compatibility with the dispatcher. -void debugGetMd5(void * /*endianness*/) -{ - mb_frame[1] = MB_FC_DEBUG_GET_MD5; - mb_frame[2] = MB_DEBUG_SUCCESS; - const char md5[] = PROGRAM_MD5; - int md5_len = 0; - for (md5_len = 0; md5[md5_len] != '\0'; md5_len++) - { - mb_frame[md5_len + 3] = md5[md5_len]; - } +// Serial transport (mbconfig_serial_iface, handle_serial/handle_serial_port, +// mb_rtu_drop_front, RX-assembly buffers, RS485 timing) moved to modbus_serial.cpp. - // Native-order store of the endianness sentinel. Written byte-wise - // (not via `*reinterpret_cast`) because `md5_len + 3` is an - // odd offset for a 32-char MD5, and a typed 16-bit store there is an - // unaligned access that HardFaults on Cortex-M0+ (SAMD21: MKR Zero / - // P1AM-100) — hanging the device on the first debugger request. Copying - // the two bytes of a native-order uint16_t preserves the target's byte - // ordering (the signal the editor uses to choose its swap behaviour) - // while keeping every access byte-aligned. - const uint16_t endian_sentinel = 0xDEAD; - const uint8_t *sentinel_bytes = reinterpret_cast(&endian_sentinel); - mb_frame[md5_len + 3] = sentinel_bytes[0]; - mb_frame[md5_len + 4] = sentinel_bytes[1]; - mb_frame_len = md5_len + 5; -} - -// PDU request: [FC] -// PDU response: [FC, STATUS, running:u8, tick:u32 BE, uptime_ms:u32 BE] -// -// Lightweight liveness/diagnostic probe that does not require a full debug -// session. `running` is always 1 on baremetal (the PLC scan is unconditional); -// `tick` is the scan counter (same value the read FCs report), so a client can -// tell whether the PLC is actually cycling by watching it advance. `uptime_ms` -// is millis() since boot. -void debugGetStatus() -{ - uint32_t uptime = (uint32_t)millis(); - - mb_frame[1] = MB_FC_DEBUG_GET_STATUS; - mb_frame[2] = MB_DEBUG_SUCCESS; - mb_frame[3] = 1; // PLC scan is always running on baremetal - mb_frame[4] = (uint8_t)((scan_counter >> 24) & 0xFF); - mb_frame[5] = (uint8_t)((scan_counter >> 16) & 0xFF); - mb_frame[6] = (uint8_t)((scan_counter >> 8) & 0xFF); - mb_frame[7] = (uint8_t)(scan_counter & 0xFF); - mb_frame[8] = (uint8_t)((uptime >> 24) & 0xFF); - mb_frame[9] = (uint8_t)((uptime >> 16) & 0xFF); - mb_frame[10] = (uint8_t)((uptime >> 8) & 0xFF); - mb_frame[11] = (uint8_t)(uptime & 0xFF); - mb_frame_len = 12; -} - -// PDU request: [FC] -// PDU response: [FC, STATUS, version_ascii...] (no NUL terminator) -// -// Reports OPENPLC_RUNTIME_VERSION (defined in openplc_version.h). The editor -// reads the ASCII bytes up to the end of the frame. -void debugGetVersion() -{ - mb_frame[1] = MB_FC_DEBUG_GET_VERSION; - mb_frame[2] = MB_DEBUG_SUCCESS; - const char ver[] = OPENPLC_RUNTIME_VERSION; - uint16_t i = 0; - for (i = 0; ver[i] != '\0'; i++) - { - if ((uint16_t)(3 + i) >= MAX_MB_FRAME) break; // never overrun the frame - mb_frame[3 + i] = (uint8_t)ver[i]; - } - mb_frame_len = 3 + i; -} +// process_mbpacket() + mb_pdu_request_len() + mb_pdu_skips_crc() moved to modbus_pdu.cpp. -// PDU request: [FC] -// PDU response: [FC, STATUS, id_len:u8, id_bytes...] -// -// Returns the unique hardware ID via ArduinoUniqueID. id_len is UniqueIDsize -// (architecture-dependent: AVR 9-10, ESP8266 4, ESP32 6, SAM/SAMD 16, STM32 -// 12, Teensy 8). On a core without support, id_len = 0 and no bytes follow. -void debugGetBoardId() -{ - mb_frame[1] = MB_FC_DEBUG_GET_BOARD_ID; - mb_frame[2] = MB_DEBUG_SUCCESS; -#ifdef OPENPLC_HAS_UNIQUE_ID - uint8_t idLen = (uint8_t)UniqueIDsize; - // Clamp so [FC][STATUS][id_len][id_bytes...] always fits the frame. - if ((uint16_t)(4 + idLen) > MAX_MB_FRAME) idLen = (uint8_t)(MAX_MB_FRAME - 4); - mb_frame[3] = idLen; - for (uint8_t i = 0; i < idLen; i++) - mb_frame[4 + i] = UniqueID[i]; - mb_frame_len = 4 + idLen; -#else - mb_frame[3] = 0; // no unique-id support on this core - mb_frame_len = 4; -#endif -} +// Register store + operation FCs (readRegisters..writeMultipleCoils) moved to modbus_registers.cpp. -uint16_t calcCrc() -{ - uint8_t CRCHi = 0xFF, CRCLo = 0x0FF, Index; +// Debugger FCs (debugInfo/debugSetTrace/debugGetTrace/debugGetTraceList/debugGetMd5/ +// debugGetStatus/debugGetVersion/debugGetBoardId) moved to modbus_debug.cpp. - int i = 0; - Index = CRCHi ^ mb_frame[i]; - CRCHi = CRCLo ^ _auchCRCHi[Index]; - CRCLo = _auchCRCLo[Index]; - i++; - - while (i < (mb_frame_len - 2)) - { - Index = CRCHi ^ mb_frame[i]; - i++; - CRCHi = CRCLo ^ _auchCRCHi[Index]; - CRCLo = _auchCRCLo[Index]; - } - - return ((uint16_t)CRCHi << 8) | (uint16_t)CRCLo; -} +// calcCrc() and the CRC lookup tables moved to modbus_crc.cpp. diff --git a/resources/sources/Baremetal/ModbusSlave.h b/resources/sources/Baremetal/ModbusSlave.h index 506d9c0f7..d7f3b5c80 100644 --- a/resources/sources/Baremetal/ModbusSlave.h +++ b/resources/sources/Baremetal/ModbusSlave.h @@ -7,88 +7,25 @@ Copyright (C) 2022 OpenPLC - Thiago Alves #define MODBUSSLAVE_H #include -#include "defines.h" #include "openplc_version.h" - -// Serial transport is active when full Modbus RTU (MBSERIAL) is enabled OR the -// always-on debugger (DEBUGGER_ENABLED) needs the serial port without the rest -// of Modbus. This gate guards the serial RX/framing code so the debugger works -// over serial even when no Modbus operation buffers (coils/holding/etc.) are -// allocated. -#if defined(MBSERIAL) || defined(DEBUGGER_ENABLED) - #define MB_SERIAL_ACTIVE -#endif - -// Default serial config for the always-on debugger. `defines.h` normally emits -// DEBUG_IFACE / DEBUG_BAUD explicitly (from the Serial screen); these `#ifndef` -// defaults cover anything it left unset. Defined whenever the debugger is on — -// including alongside full Modbus RTU, since the dual-serial path (RTU on a -// secondary UART, MBSERIAL_ON_SECONDARY) needs DEBUG_SLAVE for the default port. -#ifdef DEBUGGER_ENABLED - #ifndef DEBUG_IFACE - #define DEBUG_IFACE Serial - #endif - #ifndef DEBUG_BAUD - #define DEBUG_BAUD 115200 - #endif - #ifndef DEBUG_SLAVE - #define DEBUG_SLAVE 1 - #endif -#endif - -#ifndef bitRead - #define bitRead(value, bit) (((value) >> (bit)) & 0x01) -#endif -//#define bitSet(value, bit) ((value) |= (1UL << (bit))) -//#define bitClear(value, bit) ((value) &= ~(1UL << (bit))) -#ifndef bitWrite - #define bitWrite(value, bit, bitvalue) (bitvalue ? bitSet(value, bit) : bitClear(value, bit)) -#endif -#define COILS 0 -#define INPUTSTATUS 1 -#if defined(__AVR_ATmega328P__) || defined(__AVR_ATmega168__) || defined(__AVR_ATmega32U4__) || defined(__AVR_ATmega16U4__) - #define MAX_MB_FRAME 128 -#else - #define MAX_MB_FRAME 256 -#endif -#define MAX_SRV_CLIENTS 3 //how many clients should be able to connect to TCP server at the same time -#define MBAP_SIZE 6 - -//Platform specific defines and includes -#ifdef MBTCP_ETHERNET -#include -#ifdef BOARD_ESP32 - // I²C-address of Ethernet PHY (0 or 1 for LAN8720, 31 for TLK110) - #define ETH_PHY_ADDR 0 // DEFAULT VALUE IS 0 YOU CAN OMIT IT - // Type of the Ethernet PHY (LAN8720 or TLK110) - #define ETH_PHY_TYPE ETH_PHY_LAN8720 // DEFAULT VALUE YOU CAN OMIT IT - // Pin# of the enable signal for the external crystal oscillator (-1 to disable for internal APLL source) - #define ETH_PHY_POWER -1 // DEFAULT VALUE YOU CAN OMIT IT - // Pin# of the I²C clock signal for the Ethernet PHY - #define ETH_PHY_MDC 23 // DEFAULT VALUE YOU CAN OMIT IT - // Pin# of the I²C IO signal for the Ethernet PHY - #define ETH_PHY_MDIO 18 // DEFAULT VALUE YOU CAN OMIT IT - // External clock from crystal oscillator - #define ETH_CLK_MODE ETH_CLOCK_GPIO0_IN // DEFAULT VALUE YOU CAN OMIT IT - #include - #include -#else - #include -#endif -#endif - -#ifdef MBTCP_WIFI -#if defined(BOARD_ESP8266) -#include -#elif defined(BOARD_ESP32) -#include -#elif defined(BOARD_WIFININA) -#include -#else -#include -#include -#endif -#endif +// modbus_types.h pulls modbus_config.h, which brings defines.h and the composite +// build gates (MB_SERIAL_ACTIVE, DEBUG_* defaults). defines.h has no include +// guard, so it is deliberately NOT included directly here — only via that path. +#include "modbus_types.h" +#include "modbus_frame.h" +#include "modbus_crc.h" +#include "modbus_registers.h" +#include "modbus_debug.h" +#include "modbus_pdu.h" +#include "modbus_serial.h" +#include "modbus_tcp.h" + +// Shared type/constant declarations (enums, MBinfo, MAX_MB_FRAME, MBAP_SIZE, +// COILS/INPUTSTATUS, bit helpers, MB_DEBUG_* status codes) live in modbus_types.h; +// the build gates above come from modbus_config.h — both included above. + +// The TCP platform includes (SPI/Ethernet/WiFi/ETH + ESP32 PHY defines) and the +// TCP server state now live in modbus_tcp.h (included above). #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) #include "Controllino.h" @@ -99,169 +36,23 @@ Copyright (C) 2022 OpenPLC - Thiago Alves // file deliberately does NOT redeclare it (a second declaration would // conflict with the C-linkage one and break the build). -// Status codes (match strucpp::debug::STATUS_* in debug_dispatch.hpp, kept -// as macros here so the Modbus layer doesn't have to include the C++ -// runtime header when the rest of the protocol is C-style). -#define MB_DEBUG_SUCCESS 0x7E -#define MB_DEBUG_ERROR_OUT_OF_BOUNDS 0x81 -#define MB_DEBUG_ERROR_OUT_OF_MEMORY 0x82 - -//Modbus registers struct -struct MBinfo { - uint8_t slaveid; - uint16_t *holding; - uint8_t holding_size; - uint32_t *dint_memory; - uint8_t dint_memory_size; - uint64_t *lint_memory; - uint8_t lint_memory_size; - uint8_t *coils; - uint8_t coils_size; - uint16_t *input_regs; - uint8_t input_regs_size; - uint8_t *input_status; - uint8_t input_status_size; -}; - -//Function Codes -enum { - MB_FC_READ_COILS = 0x01, // Read Coils (Output) Status 0xxxx - MB_FC_READ_INPUT_STAT = 0x02, // Read Input Status (Discrete Inputs) 1xxxx - MB_FC_READ_REGS = 0x03, // Read Holding Registers 4xxxx - MB_FC_READ_INPUT_REGS = 0x04, // Read Input Registers 3xxxx - MB_FC_WRITE_COIL = 0x05, // Write Single Coil (Output) 0xxxx - MB_FC_WRITE_REG = 0x06, // Preset Single Register 4xxxx - MB_FC_WRITE_COILS = 0x0F, // Write Multiple Coils (Outputs) 0xxxx - MB_FC_WRITE_REGS = 0x10, // Write block of contiguous registers 4xxxx - MB_FC_DEBUG_INFO = 0x41, // Request debug variables count - MB_FC_DEBUG_SET = 0x42, // Debug set trace (force variable) - MB_FC_DEBUG_GET = 0x43, // Debug get trace (read variables) - MB_FC_DEBUG_GET_LIST = 0x44, // Debug get trace list (read list of variables) - MB_FC_DEBUG_GET_MD5 = 0x45, // Debug get current program MD5 - MB_FC_DEBUG_GET_STATUS = 0x46, // Debug get PLC status (running, scan tick, uptime) - MB_FC_DEBUG_GET_VERSION = 0x47, // Debug get runtime firmware version - MB_FC_DEBUG_GET_BOARD_ID = 0x48, // Debug get unique hardware board ID -}; +// MBinfo, the MB_FC_* / MB_EX_* enums and the MB_DEBUG_* status codes now live +// in modbus_types.h (included above). The shared frame seam (mb_frame, +// mb_frame_len, the `modbus` instance and exceptionResponse) lives in +// modbus_frame.h (included above). -//Exception Codes -enum { - MB_EX_ILLEGAL_FUNCTION = 0x01, // Function Code not Supported - MB_EX_ILLEGAL_ADDRESS = 0x02, // Output Address not exists - MB_EX_ILLEGAL_VALUE = 0x03, // Output Value not in Range - MB_EX_SLAVE_FAILURE = 0x04, // Slave Device Fails to process request -}; +// The serial port/timing globals (mb_serialport/mb_txpin/mb_t15/mb_t35) live in +// modbus_serial.h; the TCP server state (mb_server/mb_serverClients/mb_mbap) and +// mbconfig_ethernet_iface()/handle_tcp() in modbus_tcp.h — both included above. -//Global Modbus vars -extern struct MBinfo modbus; -extern uint8_t mb_frame[MAX_MB_FRAME]; -extern uint16_t mb_frame_len; -extern Stream* mb_serialport; -extern int8_t mb_txpin; -extern uint16_t mb_t15; // inter character time out -extern uint16_t mb_t35; // frame delay - -#ifdef MBTCP_ETHERNET -#ifdef BOARD_ESP32 - extern WiFiServer mb_server; -#else - extern EthernetServer mb_server; -#endif - extern uint8_t mb_mbap[MBAP_SIZE]; -#ifdef BOARD_PORTENTA - extern EthernetClient mb_serverClients[MAX_SRV_CLIENTS]; -#endif -#endif - -#ifdef MBTCP_WIFI - extern WiFiServer mb_server; - extern uint8_t mb_mbap[MBAP_SIZE]; -#if defined(BOARD_ESP8266) || defined(BOARD_ESP32) || defined(BOARD_PORTENTA) || defined(BOARD_PICOW) - extern WiFiClient mb_serverClients[MAX_SRV_CLIENTS]; -#endif -#endif - -bool init_mbregs(uint8_t size_holding, uint8_t size_dint_memory, uint8_t size_lint_memory, uint8_t size_coils, uint8_t size_inputregs, uint8_t size_inputstatus); -bool get_discrete(uint16_t addr, bool regtype); -void write_discrete(uint16_t addr, bool regtype, bool value); -void mbconfig_serial_iface(Stream* port, long baud, int txPin); -#ifdef MBTCP -void mbconfig_ethernet_iface(uint8_t *mac, uint8_t *ip, uint8_t *dns, uint8_t *gateway, uint8_t *subnet); -#endif void mbtask(); -#ifdef MBTCP -void handle_tcp(); -#endif -#ifdef MB_SERIAL_ACTIVE -void handle_serial(); -#endif -void process_mbpacket(); -uint16_t calcCrc(); - -//Modbus handling functions -void readRegisters(uint16_t startreg, uint16_t numregs); -void writeSingleRegister(uint16_t reg, uint16_t value); -void writeMultipleRegisters(uint16_t startreg, uint16_t numoutputs, uint8_t bytecount); -void exceptionResponse(uint16_t fcode, uint16_t excode); -void readCoils(uint16_t startreg, uint16_t numregs); -void readInputStatus(uint16_t startreg, uint16_t numregs); -void readInputRegisters(uint16_t startreg, uint16_t numregs); -void writeSingleCoil(uint16_t reg, uint16_t status); -void writeMultipleCoils(uint16_t startreg, uint16_t numoutputs, uint16_t bytecount); -// Phase 4 debugger entrypoints. Signatures changed from MatIEC-era -// (flat u16 index) to the (array_idx: u8, elem_idx: u16) addressing model. -void debugInfo(void); -void debugSetTrace(uint8_t arr, uint16_t elem, uint8_t flag, - uint16_t len, void *value); -void debugGetTrace(uint8_t arr, uint16_t startidx, uint16_t endidx); -void debugGetTraceList(uint16_t numIndexes, uint8_t *indexArray); -void debugGetMd5(void *endianness); -// Always-on debugger extras — served even without full Modbus (DEBUGGER_ENABLED). -void debugGetStatus(void); -void debugGetVersion(void); -void debugGetBoardId(void); - - -/* Table of CRC values for high-order byte */ -const byte _auchCRCHi[] = { - 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, - 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, - 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, - 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, - 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, - 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, - 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, - 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, - 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, - 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, - 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, - 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, - 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, - 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, - 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, - 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, - 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, - 0x40}; - -/* Table of CRC values for low-order byte */ -const byte _auchCRCLo[] = { - 0x00, 0xC0, 0xC1, 0x01, 0xC3, 0x03, 0x02, 0xC2, 0xC6, 0x06, 0x07, 0xC7, 0x05, 0xC5, 0xC4, - 0x04, 0xCC, 0x0C, 0x0D, 0xCD, 0x0F, 0xCF, 0xCE, 0x0E, 0x0A, 0xCA, 0xCB, 0x0B, 0xC9, 0x09, - 0x08, 0xC8, 0xD8, 0x18, 0x19, 0xD9, 0x1B, 0xDB, 0xDA, 0x1A, 0x1E, 0xDE, 0xDF, 0x1F, 0xDD, - 0x1D, 0x1C, 0xDC, 0x14, 0xD4, 0xD5, 0x15, 0xD7, 0x17, 0x16, 0xD6, 0xD2, 0x12, 0x13, 0xD3, - 0x11, 0xD1, 0xD0, 0x10, 0xF0, 0x30, 0x31, 0xF1, 0x33, 0xF3, 0xF2, 0x32, 0x36, 0xF6, 0xF7, - 0x37, 0xF5, 0x35, 0x34, 0xF4, 0x3C, 0xFC, 0xFD, 0x3D, 0xFF, 0x3F, 0x3E, 0xFE, 0xFA, 0x3A, - 0x3B, 0xFB, 0x39, 0xF9, 0xF8, 0x38, 0x28, 0xE8, 0xE9, 0x29, 0xEB, 0x2B, 0x2A, 0xEA, 0xEE, - 0x2E, 0x2F, 0xEF, 0x2D, 0xED, 0xEC, 0x2C, 0xE4, 0x24, 0x25, 0xE5, 0x27, 0xE7, 0xE6, 0x26, - 0x22, 0xE2, 0xE3, 0x23, 0xE1, 0x21, 0x20, 0xE0, 0xA0, 0x60, 0x61, 0xA1, 0x63, 0xA3, 0xA2, - 0x62, 0x66, 0xA6, 0xA7, 0x67, 0xA5, 0x65, 0x64, 0xA4, 0x6C, 0xAC, 0xAD, 0x6D, 0xAF, 0x6F, - 0x6E, 0xAE, 0xAA, 0x6A, 0x6B, 0xAB, 0x69, 0xA9, 0xA8, 0x68, 0x78, 0xB8, 0xB9, 0x79, 0xBB, - 0x7B, 0x7A, 0xBA, 0xBE, 0x7E, 0x7F, 0xBF, 0x7D, 0xBD, 0xBC, 0x7C, 0xB4, 0x74, 0x75, 0xB5, - 0x77, 0xB7, 0xB6, 0x76, 0x72, 0xB2, 0xB3, 0x73, 0xB1, 0x71, 0x70, 0xB0, 0x50, 0x90, 0x91, - 0x51, 0x93, 0x53, 0x52, 0x92, 0x96, 0x56, 0x57, 0x97, 0x55, 0x95, 0x94, 0x54, 0x9C, 0x5C, - 0x5D, 0x9D, 0x5F, 0x9F, 0x9E, 0x5E, 0x5A, 0x9A, 0x9B, 0x5B, 0x99, 0x59, 0x58, 0x98, 0x88, - 0x48, 0x49, 0x89, 0x4B, 0x8B, 0x8A, 0x4A, 0x4E, 0x8E, 0x8F, 0x4F, 0x8D, 0x4D, 0x4C, 0x8C, - 0x44, 0x84, 0x85, 0x45, 0x87, 0x47, 0x46, 0x86, 0x82, 0x42, 0x43, 0x83, 0x41, 0x81, 0x80, - 0x40}; +// mbconfig_serial_iface() and handle_serial() live in modbus_serial.h; +// process_mbpacket() and the per-FC frame-shape helpers (mb_pdu_request_len, +// mb_pdu_skips_crc) live in modbus_pdu.h; the register store and operation FCs +// (init_mbregs, get/write_discrete, readRegisters..writeMultipleCoils) in +// modbus_registers.h; the debugger FCs (debugInfo..debugGetBoardId) in +// modbus_debug.h; calcCrc() and the CRC tables in modbus_crc.{h,cpp} — all +// included above. #endif diff --git a/resources/sources/Baremetal/modbus_config.h b/resources/sources/Baremetal/modbus_config.h new file mode 100644 index 000000000..950869cf7 --- /dev/null +++ b/resources/sources/Baremetal/modbus_config.h @@ -0,0 +1,46 @@ +/* +modbus_config.h - Build configuration for the OpenPLC Modbus slave +Copyright (C) 2022 OpenPLC - Thiago Alves + +The single place every Modbus translation unit picks up its build gates from. +It pulls in the generated defines.h (MODBUS_ENABLED / MBSERIAL / DEBUGGER_ENABLED +/ MBTCP / MBSERIAL_ON_SECONDARY / ...) and derives the composite gates on top of +it. Because the modularized TUs are gated (e.g. modbus_registers.cpp is wrapped +in #ifdef MODBUS_ENABLED), EACH of them must see these macros — so every modbus_* +header includes this one (via modbus_types.h). defines.h has no include guard, so +it must reach a TU through exactly one path: this header. +*/ + +#ifndef MODBUS_CONFIG_H +#define MODBUS_CONFIG_H + +#include +#include "defines.h" + +// Serial transport is active when full Modbus RTU (MBSERIAL) is enabled OR the +// always-on debugger (DEBUGGER_ENABLED) needs the serial port without the rest +// of Modbus. This gate guards the serial RX/framing code so the debugger works +// over serial even when no Modbus operation buffers (coils/holding/etc.) are +// allocated. +#if defined(MBSERIAL) || defined(DEBUGGER_ENABLED) + #define MB_SERIAL_ACTIVE +#endif + +// Default serial config for the always-on debugger. `defines.h` normally emits +// DEBUG_IFACE / DEBUG_BAUD explicitly (from the Serial screen); these `#ifndef` +// defaults cover anything it left unset. Defined whenever the debugger is on — +// including alongside full Modbus RTU, since the dual-serial path (RTU on a +// secondary UART, MBSERIAL_ON_SECONDARY) needs DEBUG_SLAVE for the default port. +#ifdef DEBUGGER_ENABLED + #ifndef DEBUG_IFACE + #define DEBUG_IFACE Serial + #endif + #ifndef DEBUG_BAUD + #define DEBUG_BAUD 115200 + #endif + #ifndef DEBUG_SLAVE + #define DEBUG_SLAVE 1 + #endif +#endif + +#endif diff --git a/resources/sources/Baremetal/modbus_crc.cpp b/resources/sources/Baremetal/modbus_crc.cpp new file mode 100644 index 000000000..909836620 --- /dev/null +++ b/resources/sources/Baremetal/modbus_crc.cpp @@ -0,0 +1,72 @@ +/* +modbus_crc.cpp - Modbus RTU CRC-16 for the OpenPLC Modbus slave +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#include "modbus_crc.h" +// mb_frame / mb_frame_len come from the shared frame TU. Until T3 of the +// modularization they are declared in ModbusSlave.h; afterwards in modbus_frame.h. +#include "ModbusSlave.h" + +/* Table of CRC values for high-order byte */ +static const byte _auchCRCHi[] = { + 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, + 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, + 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, + 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, + 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, + 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, + 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, + 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, + 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, + 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, + 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, + 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, + 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, + 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, + 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, + 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, + 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, + 0x40}; + +/* Table of CRC values for low-order byte */ +static const byte _auchCRCLo[] = { + 0x00, 0xC0, 0xC1, 0x01, 0xC3, 0x03, 0x02, 0xC2, 0xC6, 0x06, 0x07, 0xC7, 0x05, 0xC5, 0xC4, + 0x04, 0xCC, 0x0C, 0x0D, 0xCD, 0x0F, 0xCF, 0xCE, 0x0E, 0x0A, 0xCA, 0xCB, 0x0B, 0xC9, 0x09, + 0x08, 0xC8, 0xD8, 0x18, 0x19, 0xD9, 0x1B, 0xDB, 0xDA, 0x1A, 0x1E, 0xDE, 0xDF, 0x1F, 0xDD, + 0x1D, 0x1C, 0xDC, 0x14, 0xD4, 0xD5, 0x15, 0xD7, 0x17, 0x16, 0xD6, 0xD2, 0x12, 0x13, 0xD3, + 0x11, 0xD1, 0xD0, 0x10, 0xF0, 0x30, 0x31, 0xF1, 0x33, 0xF3, 0xF2, 0x32, 0x36, 0xF6, 0xF7, + 0x37, 0xF5, 0x35, 0x34, 0xF4, 0x3C, 0xFC, 0xFD, 0x3D, 0xFF, 0x3F, 0x3E, 0xFE, 0xFA, 0x3A, + 0x3B, 0xFB, 0x39, 0xF9, 0xF8, 0x38, 0x28, 0xE8, 0xE9, 0x29, 0xEB, 0x2B, 0x2A, 0xEA, 0xEE, + 0x2E, 0x2F, 0xEF, 0x2D, 0xED, 0xEC, 0x2C, 0xE4, 0x24, 0x25, 0xE5, 0x27, 0xE7, 0xE6, 0x26, + 0x22, 0xE2, 0xE3, 0x23, 0xE1, 0x21, 0x20, 0xE0, 0xA0, 0x60, 0x61, 0xA1, 0x63, 0xA3, 0xA2, + 0x62, 0x66, 0xA6, 0xA7, 0x67, 0xA5, 0x65, 0x64, 0xA4, 0x6C, 0xAC, 0xAD, 0x6D, 0xAF, 0x6F, + 0x6E, 0xAE, 0xAA, 0x6A, 0x6B, 0xAB, 0x69, 0xA9, 0xA8, 0x68, 0x78, 0xB8, 0xB9, 0x79, 0xBB, + 0x7B, 0x7A, 0xBA, 0xBE, 0x7E, 0x7F, 0xBF, 0x7D, 0xBD, 0xBC, 0x7C, 0xB4, 0x74, 0x75, 0xB5, + 0x77, 0xB7, 0xB6, 0x76, 0x72, 0xB2, 0xB3, 0x73, 0xB1, 0x71, 0x70, 0xB0, 0x50, 0x90, 0x91, + 0x51, 0x93, 0x53, 0x52, 0x92, 0x96, 0x56, 0x57, 0x97, 0x55, 0x95, 0x94, 0x54, 0x9C, 0x5C, + 0x5D, 0x9D, 0x5F, 0x9F, 0x9E, 0x5E, 0x5A, 0x9A, 0x9B, 0x5B, 0x99, 0x59, 0x58, 0x98, 0x88, + 0x48, 0x49, 0x89, 0x4B, 0x8B, 0x8A, 0x4A, 0x4E, 0x8E, 0x8F, 0x4F, 0x8D, 0x4D, 0x4C, 0x8C, + 0x44, 0x84, 0x85, 0x45, 0x87, 0x47, 0x46, 0x86, 0x82, 0x42, 0x43, 0x83, 0x41, 0x81, 0x80, + 0x40}; + +uint16_t calcCrc() +{ + uint8_t CRCHi = 0xFF, CRCLo = 0x0FF, Index; + + int i = 0; + Index = CRCHi ^ mb_frame[i]; + CRCHi = CRCLo ^ _auchCRCHi[Index]; + CRCLo = _auchCRCLo[Index]; + i++; + + while (i < (mb_frame_len - 2)) + { + Index = CRCHi ^ mb_frame[i]; + i++; + CRCHi = CRCLo ^ _auchCRCHi[Index]; + CRCLo = _auchCRCLo[Index]; + } + + return ((uint16_t)CRCHi << 8) | (uint16_t)CRCLo; +} diff --git a/resources/sources/Baremetal/modbus_crc.h b/resources/sources/Baremetal/modbus_crc.h new file mode 100644 index 000000000..0655295da --- /dev/null +++ b/resources/sources/Baremetal/modbus_crc.h @@ -0,0 +1,18 @@ +/* +modbus_crc.h - Modbus RTU CRC-16 for the OpenPLC Modbus slave +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#ifndef MODBUS_CRC_H +#define MODBUS_CRC_H + +#include "modbus_types.h" + +// CRC-16 (Modbus) over mb_frame[0 .. mb_frame_len-2] — i.e. the whole frame +// except the trailing two CRC bytes. Reads the shared frame globals +// (mb_frame / mb_frame_len, declared in modbus_frame.h). The lookup tables are +// defined once in modbus_crc.cpp (they used to sit in the header, which risked +// one flash copy per translation unit). +uint16_t calcCrc(); + +#endif diff --git a/resources/sources/Baremetal/modbus_debug.cpp b/resources/sources/Baremetal/modbus_debug.cpp new file mode 100644 index 000000000..d6d586285 --- /dev/null +++ b/resources/sources/Baremetal/modbus_debug.cpp @@ -0,0 +1,397 @@ +/* +modbus_debug.cpp - OpenPLC always-on debugger function codes (0x41-0x48) +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#include "modbus_debug.h" +// Debug surface comes via the extern "C" shims in arduino_runtime_glue.h +// (openplc_debug_*, scan_counter) so this TU stays free of strucpp's +// template-heavy headers and compiles cleanly under arduino-cli's default C++ +// standard. The shims forward to strucpp::debug::handle_* inside +// arduino_runtime_glue.cpp (part of the precompiled OpenPLCUserLib archive). +#include "arduino_runtime_glue.h" +#include "openplc_version.h" + +// ArduinoUniqueID (ricaun) backs the DEBUG_GET_BOARD_ID (0x48) function code. +// It supports AVR/megaAVR/SAM/SAMD/STM32/ESP/RP2040/Teensy. On a core without +// support (or when a board intentionally opts out via OPENPLC_NO_UNIQUE_ID), +// the board-id handler returns id_len = 0 instead of failing to compile. +#ifndef OPENPLC_NO_UNIQUE_ID + #include + #define OPENPLC_HAS_UNIQUE_ID +#endif + +/** + * @brief Sends a Modbus response frame for the DEBUG_INFO function code. + * + * This function constructs a Modbus response frame for the DEBUG_INFO function code. + * The response frame includes the number of variables defined in the PLC program. + * + * Modbus Response Frame (DEBUG_INFO): + * +-----+-------+-------+ + * | MB | Count | Count | + * | FC | | | + * +-----+-------+-------+ + * |0x41 | High | Low | + * | | Byte | Byte | + * | | | | + * +-----+-------+-------+ + * + * @return void + */ +// Phase 4 PDU: +// +-----+-------+------+-----------+-----------+-----------+ +// | FC | arrs | stat | count_0 | count_1 | ... | +// |0x41 | (u8) | (u8) | (u16 BE) | (u16 BE) | | +// +-----+-------+------+-----------+-----------+-----------+ +// Response: [FC, arrCount, STATUS_OK, (count×arrCount as u16 BE)] +void debugInfo() +{ + uint8_t arrCount = openplc_debug_array_count(); + + // Cap at what the Modbus frame can hold: 3 header bytes + 2 bytes/array. + // Realistic projects have <=10 arrays, so this is never a real limit. + uint8_t maxArrs = (MAX_MB_FRAME - 3) / 2; + if (arrCount > maxArrs) arrCount = maxArrs; + + mb_frame[1] = MB_FC_DEBUG_INFO; + mb_frame[2] = arrCount; + mb_frame[3] = MB_DEBUG_SUCCESS; + uint16_t pos = 4; + for (uint8_t i = 0; i < arrCount; i++) + { + uint16_t c = openplc_debug_elem_count(i); + mb_frame[pos++] = (uint8_t)(c >> 8); + mb_frame[pos++] = (uint8_t)(c & 0xFF); + } + mb_frame_len = pos; +} + +/** + * @brief Sends a Modbus response frame for the DEBUG_SET function code. + * + * This function constructs a Modbus response frame for the DEBUG_SET function code. + * The response frame indicates whether the set trace command was successful or if + * there was an error, such as an out-of-bounds index. + * + * Modbus Response Frame (DEBUG_SET): + * +-----+------+ + * | MB | Resp.| + * | FC | Code | + * +-----+------+ + * |0x42 | Code | + * +-----+------+ + * + * @param varidx The index of the variable to set trace for. + * @param flag The trace flag. + * @param len The length of the trace data. + * @param value Pointer to the trace data. + * + * @return void + */ +// Phase 4 PDU: [FC, arr, elem_hi, elem_lo, force, len_hi, len_lo, value...] +// Response: [FC, STATUS] +void debugSetTrace(uint8_t arr, uint16_t elem, uint8_t flag, + uint16_t len, void *value) +{ + if (len > (MAX_MB_FRAME - 8)) + { + mb_frame_len = 3; + mb_frame[1] = MB_FC_DEBUG_SET; + mb_frame[2] = MB_DEBUG_ERROR_OUT_OF_BOUNDS; + return; + } + + uint8_t status = openplc_debug_set( + arr, elem, (uint8_t)flag, (const uint8_t *)value, len); + + mb_frame_len = 3; + mb_frame[1] = MB_FC_DEBUG_SET; + mb_frame[2] = status; +} + +/** + * @brief Sends a Modbus response frame for the DEBUG_GET function code. + * + * This function constructs a Modbus response frame for the DEBUG_GET function code. + * The response frame includes the trace data for variables within the specified index range. + * + * Modbus Response Frame (DEBUG_GET): + * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ + * | MB | Resp. | Last | Last | Tick | Tick | Tick | Tick | Resp. | Resp.| Data | + * | FC | Code | Index | Index | | | | | Size | Size | Bytes | + * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ + * |0x44 | Code | High | Low | High | Mid | Mid | Low | High | Low | Data | + * | | | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Bytes | + * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ + * + * @param startidx The start index of the variables to get trace for. + * @param endidx The end index of the variables to get trace for. + * + * @return void + */ +// Phase 4 PDU: [FC, arr, start_hi, start_lo, end_hi, end_lo] +// Response: [FC, STATUS, last_elem_hi, last_elem_lo, +// tick_hi, tick_mh, tick_ml, tick_lo, +// size_hi, size_lo, data...] +void debugGetTrace(uint8_t arr, uint16_t startidx, uint16_t endidx) +{ + uint16_t arrCount = openplc_debug_elem_count(arr); + if (arrCount == 0 || startidx >= arrCount || + endidx >= arrCount || startidx > endidx) + { + mb_frame_len = 3; + mb_frame[1] = MB_FC_DEBUG_GET; + mb_frame[2] = MB_DEBUG_ERROR_OUT_OF_BOUNDS; + return; + } + + uint16_t lastElemIdx = startidx; + uint16_t responseSize = 0; + uint8_t *responsePtr = &(mb_frame[11]); + + for (uint16_t elem = startidx; elem <= endidx; elem++) + { + uint16_t varSize = openplc_debug_size(arr, elem); + // Bounds check — stop packing if this one won't fit. + if ((11 + responseSize + varSize) > MAX_MB_FRAME) break; + if (varSize == 0) { + // Entry has no readable bytes (string stub / out-of-bounds) + // — skip gracefully to keep the scan progressing. + lastElemIdx = elem; + continue; + } + uint16_t n = openplc_debug_read(arr, elem, responsePtr); + if (n == 0) { + lastElemIdx = elem; + continue; + } + responsePtr += n; + responseSize += n; + lastElemIdx = elem; + } + + mb_frame_len = 11 + responseSize; + mb_frame[1] = MB_FC_DEBUG_GET; + mb_frame[2] = MB_DEBUG_SUCCESS; + mb_frame[3] = (uint8_t)(lastElemIdx >> 8); + mb_frame[4] = (uint8_t)(lastElemIdx & 0xFF); + mb_frame[5] = (uint8_t)((scan_counter >> 24) & 0xFF); + mb_frame[6] = (uint8_t)((scan_counter >> 16) & 0xFF); + mb_frame[7] = (uint8_t)((scan_counter >> 8) & 0xFF); + mb_frame[8] = (uint8_t)(scan_counter & 0xFF); + mb_frame[9] = (uint8_t)(responseSize >> 8); + mb_frame[10] = (uint8_t)(responseSize & 0xFF); +} + +/** + * @brief Sends a Modbus response frame for the DEBUG_GET_LIST function code. + * + * This function constructs a Modbus response frame for the DEBUG_GET_LIST function code. + * The response frame includes the trace data for variables specified in the provided index list. + * + * Modbus Response Frame (DEBUG_GET_LIST): + * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ + * | MB | Resp. | Last | Last | Tick | Tick | Tick | Tick | Resp. | Resp.| Data | + * | FC | Code | Index | Index | | | | | Size | Size | Bytes | + * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ + * |0x44 | Code | High | Low | High | Mid | Mid | Low | High | Low | Data | + * | | | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Byte | Bytes | + * +-----+-------+-------+-------+-------+-------+-------+-------+-------+------+-------+ + * + * @param numIndexes The number of indexes requested. + * @param indexArray Pointer to the array containing variable indexes. + * + * @return void + */ +// Phase 4 PDU: [FC, count_hi, count_lo, (arr:u8, elem_hi, elem_lo)×count] +// Response: [FC, STATUS, last_idx_hi, last_idx_lo, +// tick_hi, tick_mh, tick_ml, tick_lo, +// size_hi, size_lo, data...] +// last_idx is the index *into the request list* that was last successfully +// included — the editor uses it to retry from the next item on overflow. +void debugGetTraceList(uint16_t numIndexes, uint8_t *indexArray) +{ + uint16_t response_idx = 11; + uint16_t responseSize = 0; + uint16_t lastReqIdx = 0; + + #ifdef MB_SERIAL_ACTIVE + #define VARIDX_SIZE 20 + #else + #define VARIDX_SIZE 60 + #endif + + if (numIndexes > VARIDX_SIZE) + { + mb_frame_len = 3; + mb_frame[1] = MB_FC_DEBUG_GET_LIST; + mb_frame[2] = MB_DEBUG_ERROR_OUT_OF_MEMORY; + return; + } + + // The request indexArray (at mb_frame[4..]) and the response buffer + // (mb_frame[11..]) overlap. Once handle_read writes the first response + // byte, later index entries inside mb_frame are clobbered. Snapshot the + // request first. + uint8_t localIndex[VARIDX_SIZE * 3]; + for (uint16_t i = 0; i < numIndexes * 3; i++) { + localIndex[i] = indexArray[i]; + } + + // Each address pair is 3 bytes: [arr:u8, elem_hi, elem_lo] + for (uint16_t i = 0; i < numIndexes; i++) + { + uint8_t arr = localIndex[i * 3]; + uint16_t elem = (uint16_t)localIndex[i * 3 + 1] << 8 | + (uint16_t)localIndex[i * 3 + 2]; + + uint16_t varSize = openplc_debug_size(arr, elem); + if (varSize == 0) + { + // Out-of-bounds or string stub — skip gracefully. + lastReqIdx = i; + continue; + } + if ((response_idx + varSize) > MAX_MB_FRAME) break; + + uint16_t n = openplc_debug_read(arr, elem, &mb_frame[response_idx]); + if (n == 0) + { + lastReqIdx = i; + continue; + } + response_idx += n; + responseSize += n; + lastReqIdx = i; + } + + mb_frame_len = response_idx; + mb_frame[1] = MB_FC_DEBUG_GET_LIST; + mb_frame[2] = MB_DEBUG_SUCCESS; + mb_frame[3] = (uint8_t)(lastReqIdx >> 8); + mb_frame[4] = (uint8_t)(lastReqIdx & 0xFF); + mb_frame[5] = (uint8_t)((scan_counter >> 24) & 0xFF); + mb_frame[6] = (uint8_t)((scan_counter >> 16) & 0xFF); + mb_frame[7] = (uint8_t)((scan_counter >> 8) & 0xFF); + mb_frame[8] = (uint8_t)(scan_counter & 0xFF); + mb_frame[9] = (uint8_t)(responseSize >> 8); + mb_frame[10] = (uint8_t)(responseSize & 0xFF); +} + +// PDU request: [FC, endian_check_hi, endian_check_lo] +// PDU response: [FC, STATUS, md5_ascii..., endian_marker_hi, endian_marker_lo] +// +// The target always writes variable data in native byte order — STruC++ does +// no server-side byte-order adaptation, force/read is pure memcpy. To let +// the editor detect what "native" means here, the MD5 response trailer +// writes the literal value 0xDEAD via a native `uint16_t*` store. The +// bytes that land in the response are therefore in the target's native +// byte order: +// +// LE target → trailer bytes = [0xAD, 0xDE] +// BE target → trailer bytes = [0xDE, 0xAD] +// +// The editor inspects those two bytes after MD5 verification and decides +// whether subsequent force/read traffic needs byte-swapping at its end. +// +// The probe bytes the editor sends are intentionally ignored — the trailer +// is a runtime-driven sentinel, not an echo. The argument stays in the +// signature for ABI compatibility with the dispatcher. +void debugGetMd5(void * /*endianness*/) +{ + mb_frame[1] = MB_FC_DEBUG_GET_MD5; + mb_frame[2] = MB_DEBUG_SUCCESS; + + const char md5[] = PROGRAM_MD5; + int md5_len = 0; + for (md5_len = 0; md5[md5_len] != '\0'; md5_len++) + { + mb_frame[md5_len + 3] = md5[md5_len]; + } + + // Native-order store of the endianness sentinel. Written byte-wise + // (not via `*reinterpret_cast`) because `md5_len + 3` is an + // odd offset for a 32-char MD5, and a typed 16-bit store there is an + // unaligned access that HardFaults on Cortex-M0+ (SAMD21: MKR Zero / + // P1AM-100) — hanging the device on the first debugger request. Copying + // the two bytes of a native-order uint16_t preserves the target's byte + // ordering (the signal the editor uses to choose its swap behaviour) + // while keeping every access byte-aligned. + const uint16_t endian_sentinel = 0xDEAD; + const uint8_t *sentinel_bytes = reinterpret_cast(&endian_sentinel); + mb_frame[md5_len + 3] = sentinel_bytes[0]; + mb_frame[md5_len + 4] = sentinel_bytes[1]; + mb_frame_len = md5_len + 5; +} + +// PDU request: [FC] +// PDU response: [FC, STATUS, running:u8, tick:u32 BE, uptime_ms:u32 BE] +// +// Lightweight liveness/diagnostic probe that does not require a full debug +// session. `running` is always 1 on baremetal (the PLC scan is unconditional); +// `tick` is the scan counter (same value the read FCs report), so a client can +// tell whether the PLC is actually cycling by watching it advance. `uptime_ms` +// is millis() since boot. +void debugGetStatus() +{ + uint32_t uptime = (uint32_t)millis(); + + mb_frame[1] = MB_FC_DEBUG_GET_STATUS; + mb_frame[2] = MB_DEBUG_SUCCESS; + mb_frame[3] = 1; // PLC scan is always running on baremetal + mb_frame[4] = (uint8_t)((scan_counter >> 24) & 0xFF); + mb_frame[5] = (uint8_t)((scan_counter >> 16) & 0xFF); + mb_frame[6] = (uint8_t)((scan_counter >> 8) & 0xFF); + mb_frame[7] = (uint8_t)(scan_counter & 0xFF); + mb_frame[8] = (uint8_t)((uptime >> 24) & 0xFF); + mb_frame[9] = (uint8_t)((uptime >> 16) & 0xFF); + mb_frame[10] = (uint8_t)((uptime >> 8) & 0xFF); + mb_frame[11] = (uint8_t)(uptime & 0xFF); + mb_frame_len = 12; +} + +// PDU request: [FC] +// PDU response: [FC, STATUS, version_ascii...] (no NUL terminator) +// +// Reports OPENPLC_RUNTIME_VERSION (defined in openplc_version.h). The editor +// reads the ASCII bytes up to the end of the frame. +void debugGetVersion() +{ + mb_frame[1] = MB_FC_DEBUG_GET_VERSION; + mb_frame[2] = MB_DEBUG_SUCCESS; + + const char ver[] = OPENPLC_RUNTIME_VERSION; + uint16_t i = 0; + for (i = 0; ver[i] != '\0'; i++) + { + if ((uint16_t)(3 + i) >= MAX_MB_FRAME) break; // never overrun the frame + mb_frame[3 + i] = (uint8_t)ver[i]; + } + mb_frame_len = 3 + i; +} + +// PDU request: [FC] +// PDU response: [FC, STATUS, id_len:u8, id_bytes...] +// +// Returns the unique hardware ID via ArduinoUniqueID. id_len is UniqueIDsize +// (architecture-dependent: AVR 9-10, ESP8266 4, ESP32 6, SAM/SAMD 16, STM32 +// 12, Teensy 8). On a core without support, id_len = 0 and no bytes follow. +void debugGetBoardId() +{ + mb_frame[1] = MB_FC_DEBUG_GET_BOARD_ID; + mb_frame[2] = MB_DEBUG_SUCCESS; + +#ifdef OPENPLC_HAS_UNIQUE_ID + uint8_t idLen = (uint8_t)UniqueIDsize; + // Clamp so [FC][STATUS][id_len][id_bytes...] always fits the frame. + if ((uint16_t)(4 + idLen) > MAX_MB_FRAME) idLen = (uint8_t)(MAX_MB_FRAME - 4); + mb_frame[3] = idLen; + for (uint8_t i = 0; i < idLen; i++) + mb_frame[4 + i] = UniqueID[i]; + mb_frame_len = 4 + idLen; +#else + mb_frame[3] = 0; // no unique-id support on this core + mb_frame_len = 4; +#endif +} diff --git a/resources/sources/Baremetal/modbus_debug.h b/resources/sources/Baremetal/modbus_debug.h new file mode 100644 index 000000000..1c03df15d --- /dev/null +++ b/resources/sources/Baremetal/modbus_debug.h @@ -0,0 +1,29 @@ +/* +modbus_debug.h - OpenPLC always-on debugger function codes (0x41-0x48) +Copyright (C) 2022 OpenPLC - Thiago Alves + +The debugger PDU handlers, dispatched from process_mbpacket. Kept ungated: the +dispatch in modbus_pdu references them unconditionally (the always-on debugger is +present on every baremetal build). This is the growth home for future custom FCs +(e.g. the 0x49-0x4C licensing set). +*/ + +#ifndef MODBUS_DEBUG_H +#define MODBUS_DEBUG_H + +#include "modbus_frame.h" + +// Phase 4 debugger entrypoints. Signatures changed from MatIEC-era +// (flat u16 index) to the (array_idx: u8, elem_idx: u16) addressing model. +void debugInfo(void); +void debugSetTrace(uint8_t arr, uint16_t elem, uint8_t flag, + uint16_t len, void *value); +void debugGetTrace(uint8_t arr, uint16_t startidx, uint16_t endidx); +void debugGetTraceList(uint16_t numIndexes, uint8_t *indexArray); +void debugGetMd5(void *endianness); +// Always-on debugger extras — served even without full Modbus (DEBUGGER_ENABLED). +void debugGetStatus(void); +void debugGetVersion(void); +void debugGetBoardId(void); + +#endif diff --git a/resources/sources/Baremetal/modbus_frame.cpp b/resources/sources/Baremetal/modbus_frame.cpp new file mode 100644 index 000000000..ae4055108 --- /dev/null +++ b/resources/sources/Baremetal/modbus_frame.cpp @@ -0,0 +1,21 @@ +/* +modbus_frame.cpp - Shared Modbus message seam for the OpenPLC Modbus slave +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#include "modbus_frame.h" + +//Global Modbus vars — the shared frame buffer and the slave/register struct. +struct MBinfo modbus; +uint8_t mb_frame[MAX_MB_FRAME]; +uint16_t mb_frame_len; + +void exceptionResponse(uint16_t fcode, uint16_t excode) +{ + //Clean frame buffer (leave only SlaveID) + mb_frame_len = 3; + for (int i = 0; i < mb_frame_len; i++) mb_frame[i] = 0; + mb_frame[0] = modbus.slaveid; + mb_frame[1] = fcode + 0x80; + mb_frame[2] = excode; +} diff --git a/resources/sources/Baremetal/modbus_frame.h b/resources/sources/Baremetal/modbus_frame.h new file mode 100644 index 000000000..f751eabd8 --- /dev/null +++ b/resources/sources/Baremetal/modbus_frame.h @@ -0,0 +1,23 @@ +/* +modbus_frame.h - Shared Modbus message seam for the OpenPLC Modbus slave +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#ifndef MODBUS_FRAME_H +#define MODBUS_FRAME_H + +#include "modbus_types.h" + +// The one buffer every layer shares. A transport fills mb_frame[0..mb_frame_len), +// calls process_mbpacket() (which builds the response back into mb_frame), then +// writes it out. `modbus` carries the slave id — used in EVERY build, including +// debug-only — plus the operation register banks, which are allocated only when +// full Modbus is enabled (see modbus_registers.cpp under MODBUS_ENABLED). +extern struct MBinfo modbus; +extern uint8_t mb_frame[MAX_MB_FRAME]; +extern uint16_t mb_frame_len; + +// Build a Modbus exception response into mb_frame: [slaveid][fcode|0x80][excode]. +void exceptionResponse(uint16_t fcode, uint16_t excode); + +#endif diff --git a/resources/sources/Baremetal/modbus_pdu.cpp b/resources/sources/Baremetal/modbus_pdu.cpp new file mode 100644 index 000000000..ba1c963d2 --- /dev/null +++ b/resources/sources/Baremetal/modbus_pdu.cpp @@ -0,0 +1,182 @@ +/* +modbus_pdu.cpp - Transport-agnostic Modbus PDU dispatch + per-FC frame shape +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#include "modbus_pdu.h" +#include "modbus_registers.h" +#include "modbus_debug.h" + +// Derived per function code, exactly as process_mbpacket() below parses the +// fields — the single source of truth for the RTU frame shape. +int32_t mb_pdu_request_len(const uint8_t *f, uint16_t n) +{ + if (n < 2) return 0; // need at least id + FC + switch (f[1]) + { + case MB_FC_READ_COILS: + case MB_FC_READ_INPUT_STAT: + case MB_FC_READ_REGS: + case MB_FC_READ_INPUT_REGS: + case MB_FC_WRITE_COIL: + case MB_FC_WRITE_REG: + return 8; // [id][fc][a:2][b:2][crc:2] + case MB_FC_WRITE_COILS: + case MB_FC_WRITE_REGS: + if (n < 7) return 0; // byte count lives at f[6] + return 9 + (int32_t)f[6]; // + [bc:1][data:bc][crc:2] + case MB_FC_DEBUG_INFO: + return 4; // [id][fc][crc:2] + case MB_FC_DEBUG_GET: + return 9; // [id][fc][arr:1][s:2][e:2][crc:2] + case MB_FC_DEBUG_GET_LIST: + if (n < 4) return 0; // count lives at f[2..3] + return 6 + 3 * (int32_t)(((uint16_t)f[2] << 8) | f[3]); + case MB_FC_DEBUG_SET: + if (n < 8) return 0; // value len lives at f[6..7] + return 10 + (int32_t)(((uint16_t)f[6] << 8) | f[7]); + case MB_FC_DEBUG_GET_MD5: + return 8; // [id][fc][endian:2][00:2][crc:2] + case MB_FC_DEBUG_GET_STATUS: + case MB_FC_DEBUG_GET_VERSION: + case MB_FC_DEBUG_GET_BOARD_ID: + return 4; // [id][fc][crc:2] + default: + return -1; // not one of our function codes + } +} + +// The debug FCs are private, well-formed and performance-sensitive, so their RTU +// frames skip CRC. Keep this list in lockstep with the DEBUG cases below and in +// mb_pdu_request_len above. +bool mb_pdu_skips_crc(uint8_t fc) +{ + switch (fc) + { + case MB_FC_DEBUG_INFO: + case MB_FC_DEBUG_SET: + case MB_FC_DEBUG_GET: + case MB_FC_DEBUG_GET_LIST: + case MB_FC_DEBUG_GET_MD5: + case MB_FC_DEBUG_GET_STATUS: + case MB_FC_DEBUG_GET_VERSION: + case MB_FC_DEBUG_GET_BOARD_ID: + return true; + default: + return false; + } +} + +void process_mbpacket() +{ + uint8_t fcode = mb_frame[1]; +#ifdef MODBUS_ENABLED + // Standard Modbus fields — only used by the operation FCs, which are + // compiled out in debug-only builds (so guard to avoid unused-var warnings). + uint16_t field1 = (uint16_t)mb_frame[2] << 8 | (uint16_t)mb_frame[3]; + uint16_t field2 = (uint16_t)mb_frame[4] << 8 | (uint16_t)mb_frame[5]; +#endif + void *endianness_check = &mb_frame[2]; + + switch (fcode) + { +#ifdef MODBUS_ENABLED + // Standard Modbus operation FCs read/write the coil/register buffers, + // which only exist when full Modbus is enabled. In debug-only builds + // these cases are compiled out, so operation requests fall through to + // the default and get an ILLEGAL_FUNCTION exception. + case MB_FC_WRITE_REG: + //field1 = reg, field2 = value + writeSingleRegister(field1, field2); + break; + + case MB_FC_READ_REGS: + //field1 = startreg, field2 = numregs + readRegisters(field1, field2); + break; + + case MB_FC_WRITE_REGS: + //field1 = startreg, field2 = status + writeMultipleRegisters(field1, field2, mb_frame[6]); + break; + + case MB_FC_READ_COILS: + //field1 = startreg, field2 = numregs + readCoils(field1, field2); + break; + + case MB_FC_READ_INPUT_STAT: + //field1 = startreg, field2 = numregs + readInputStatus(field1, field2); + break; + + case MB_FC_READ_INPUT_REGS: + //field1 = startreg, field2 = numregs + readInputRegisters(field1, field2); + break; + + case MB_FC_WRITE_COIL: + //field1 = reg, field2 = status + writeSingleCoil(field1, field2); + break; + + case MB_FC_WRITE_COILS: + //field1 = startreg, field2 = numoutputs + writeMultipleCoils(field1, field2, mb_frame[6]); + break; +#endif // MODBUS_ENABLED + + case MB_FC_DEBUG_INFO: + debugInfo(); + break; + + case MB_FC_DEBUG_GET: + { + // PDU: [FC:1][arr:u8][start_elem:u16][end_elem:u16] + uint8_t arr = mb_frame[2]; + uint16_t startIdx = (uint16_t)mb_frame[3] << 8 | (uint16_t)mb_frame[4]; + uint16_t endIdx = (uint16_t)mb_frame[5] << 8 | (uint16_t)mb_frame[6]; + debugGetTrace(arr, startIdx, endIdx); + } + break; + + case MB_FC_DEBUG_GET_LIST: + { + // PDU: [FC:1][count:u16][(arr:u8, elem:u16)×count] + uint16_t numIndexes = (uint16_t)mb_frame[2] << 8 | (uint16_t)mb_frame[3]; + debugGetTraceList(numIndexes, &mb_frame[4]); + } + break; + + case MB_FC_DEBUG_SET: + { + // PDU: [FC:1][arr:u8][elem:u16][force:u8][len:u16][value...] + uint8_t arr = mb_frame[2]; + uint16_t elem = (uint16_t)mb_frame[3] << 8 | (uint16_t)mb_frame[4]; + uint8_t flag = mb_frame[5]; + uint16_t len = (uint16_t)mb_frame[6] << 8 | (uint16_t)mb_frame[7]; + void *value = &mb_frame[8]; + debugSetTrace(arr, elem, flag, len, value); + } + break; + + case MB_FC_DEBUG_GET_MD5: + debugGetMd5(endianness_check); + break; + + case MB_FC_DEBUG_GET_STATUS: + debugGetStatus(); + break; + + case MB_FC_DEBUG_GET_VERSION: + debugGetVersion(); + break; + + case MB_FC_DEBUG_GET_BOARD_ID: + debugGetBoardId(); + break; + + default: + exceptionResponse(fcode, MB_EX_ILLEGAL_FUNCTION); + } +} diff --git a/resources/sources/Baremetal/modbus_pdu.h b/resources/sources/Baremetal/modbus_pdu.h new file mode 100644 index 000000000..e0ff077bf --- /dev/null +++ b/resources/sources/Baremetal/modbus_pdu.h @@ -0,0 +1,34 @@ +/* +modbus_pdu.h - Transport-agnostic Modbus PDU dispatch + per-FC frame shape +Copyright (C) 2022 OpenPLC - Thiago Alves + +The protocol layer: it owns the set of function codes and their shapes. A +transport fills mb_frame with a request, calls process_mbpacket() to dispatch it +(operation FC -> modbus_registers, debug FC -> modbus_debug) and read the +response back out. The transport does NOT know the FC set — it asks this layer +via mb_pdu_request_len() / mb_pdu_skips_crc(), so adding a function code touches +only this file plus its handler, never the transports. +*/ + +#ifndef MODBUS_PDU_H +#define MODBUS_PDU_H + +#include "modbus_frame.h" + +// Dispatch the PDU in mb_frame[0..mb_frame_len) to its handler and build the +// response back into mb_frame. +void process_mbpacket(); + +// Total on-wire length (slave id + PDU + 2 CRC bytes) of the RTU request whose +// first `n` bytes are in `f`: >0 for a known length, 0 when more header bytes are +// needed to size it, -1 for a function code we do not serve (so the byte cannot +// be a frame head). Length is implicit in Modbus RTU — derived per FC, exactly as +// process_mbpacket() later parses the fields. +int32_t mb_pdu_request_len(const uint8_t *f, uint16_t n); + +// True for the private debugger FCs, whose RTU frames deliberately skip CRC +// validation (they are well-formed and performance-sensitive). Lets the serial +// transport decide CRC handling without hardcoding the debug FC list. +bool mb_pdu_skips_crc(uint8_t fc); + +#endif diff --git a/resources/sources/Baremetal/modbus_registers.cpp b/resources/sources/Baremetal/modbus_registers.cpp new file mode 100644 index 000000000..a1be65c24 --- /dev/null +++ b/resources/sources/Baremetal/modbus_registers.cpp @@ -0,0 +1,525 @@ +/* +modbus_registers.cpp - Modbus register store + operation function codes +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#include "modbus_registers.h" + +// The register banks and operation FCs only exist when full Modbus is enabled. +// In a debug-only build this whole TU compiles to nothing, saving flash/SRAM. +#ifdef MODBUS_ENABLED + +bool init_mbregs(uint8_t size_holding, uint8_t size_dint_memory, uint8_t size_lint_memory, uint8_t size_coils, uint8_t size_inputregs, uint8_t size_inputstatus) +{ + //Save sizes + modbus.holding_size = size_holding; + modbus.dint_memory_size = size_dint_memory; + modbus.lint_memory_size = size_lint_memory; + modbus.coils_size = size_coils; + modbus.input_regs_size = size_inputregs; + modbus.input_status_size = size_inputstatus; + + //round discrete regs sizes + if (size_coils % 8 > 0) + size_coils = (size_coils / 8) + 1; + else + size_coils = size_coils / 8; + if (size_inputstatus % 8 > 0) + size_inputstatus = (size_inputstatus / 8) + 1; + else + size_inputstatus = (size_inputstatus / 8); + + modbus.coils = (uint8_t *)malloc(size_coils * sizeof(uint8_t)); + if (modbus.coils == NULL) return false; + memset(modbus.coils, 0, size_coils * sizeof(uint8_t)); + + modbus.holding = (uint16_t *)malloc(size_holding * sizeof(uint16_t)); + if (modbus.holding == NULL) return false; + memset(modbus.holding, 0, size_holding * sizeof(uint16_t)); + + if (size_dint_memory > 0) + { + modbus.dint_memory = (uint32_t *)malloc(size_dint_memory * sizeof(uint32_t)); + if (modbus.dint_memory == NULL) return false; + memset(modbus.dint_memory, 0, size_dint_memory * sizeof(uint32_t)); + } + + if (size_lint_memory > 0) + { + modbus.lint_memory = (uint64_t *)malloc(size_lint_memory * sizeof(uint64_t)); + if (modbus.lint_memory == NULL) return false; + memset(modbus.lint_memory, 0, size_lint_memory * sizeof(uint64_t)); + } + + modbus.input_status = (uint8_t *)malloc(size_inputstatus * sizeof(uint8_t)); + if (modbus.input_status == NULL) return false; + memset(modbus.input_status, 0, size_inputstatus * sizeof(uint8_t)); + + modbus.input_regs = (uint16_t *)malloc(size_inputregs * sizeof(uint16_t)); + if (modbus.input_regs == NULL) return false; + memset(modbus.input_regs, 0, size_inputregs * sizeof(uint16_t)); + + return true; +} + +bool get_discrete(uint16_t addr, bool regtype) +{ + uint8_t byte_addr = addr / 8; + uint8_t bit_addr = addr % 8; + if (regtype == COILS) + return bitRead(modbus.coils[byte_addr], bit_addr); + else + return bitRead(modbus.input_status[byte_addr], bit_addr); +} + +void write_discrete(uint16_t addr, bool regtype, bool value) +{ + uint8_t byte_addr = addr / 8; + uint8_t bit_addr = addr % 8; + if (regtype == COILS) + bitWrite(modbus.coils[byte_addr], bit_addr, value); + else + bitWrite(modbus.input_status[byte_addr], bit_addr, value); +} + +//Modbus handling functions +void readRegisters(uint16_t startreg, uint16_t numregs) +{ + //Check value (numregs) + if (numregs < 0x0001 || numregs > 0x007D) + { + exceptionResponse(MB_FC_READ_REGS, MB_EX_ILLEGAL_VALUE); + return; + } + + //Check Address + if ((startreg+numregs) >= (modbus.holding_size + (2*modbus.dint_memory_size) + (4*modbus.lint_memory_size))) + { + exceptionResponse(MB_FC_READ_REGS, MB_EX_ILLEGAL_ADDRESS); + return; + } + + //calculate the query reply message length + mb_frame_len = 3 + (numregs * 2); + if (mb_frame_len > MAX_MB_FRAME) + { + //Response message is too big for this device + exceptionResponse(MB_FC_READ_REGS, MB_EX_SLAVE_FAILURE); + return; + } + + //Clean frame buffer (leave only SlaveID) + for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; + + mb_frame[1] = MB_FC_READ_REGS; + mb_frame[2] = mb_frame_len - 3; //byte count + + uint16_t val; + uint16_t i = 0; + uint8_t pos = 0; + while(numregs--) + { + if ((startreg + i) < modbus.holding_size) + { + //retrieve the value from the register bank for the current register + val = modbus.holding[startreg + i]; + } + else if ((startreg + i) < (modbus.holding_size + (2*modbus.dint_memory_size))) //32-bit registers + { + if ((startreg + i) % 2 == 0) //first word + { + pos = ((startreg + i) - modbus.holding_size) / 2; + val = (uint16_t)(modbus.dint_memory[pos] >> 16); + } + else //second word + { + pos = ((startreg + i) - modbus.holding_size - 1) / 2; + val = (uint16_t)(modbus.dint_memory[pos] & 0xffff); + } + } + else //64-bit registers + { + if ((startreg + i) % 4 == 0) //first word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size))) / 4; + val = (uint16_t)(modbus.lint_memory[pos] >> 48); + } + else if ((startreg + i) % 4 == 1) //second word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 1)) / 4; + val = (uint16_t)((modbus.lint_memory[pos] >> 32) & 0xffff); + } + else if ((startreg + i) % 4 == 2) //third word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 2)) / 4; + val = (uint16_t)((modbus.lint_memory[pos] >> 16) & 0xffff); + } + else //fourth word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 3)) / 4; + val = (uint16_t)(modbus.lint_memory[pos] & 0xffff); + } + } + + //write the high byte of the register value + mb_frame[3 + (i * 2)] = val >> 8; + //write the low byte of the register value + mb_frame[4 + (i * 2)] = val & 0xFF; + i++; + } +} + +void writeSingleRegister(uint16_t reg, uint16_t value) +{ + if (reg >= (modbus.holding_size + (2*modbus.dint_memory_size) + (4*modbus.lint_memory_size))) + { + exceptionResponse(MB_FC_WRITE_REG, MB_EX_ILLEGAL_ADDRESS); + return; + } + + uint8_t pos = 0; + + if (reg < modbus.holding_size) + { + modbus.holding[reg] = value; + } + else if (reg < (modbus.holding_size + (2*modbus.dint_memory_size))) //32-bit registers + { + if (reg % 2 == 0) //first word + { + pos = (reg - modbus.holding_size) / 2; + modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0x0000ffff; //zeroed first word + modbus.dint_memory[pos] = modbus.dint_memory[pos] | ((uint32_t)value << 16); //insert first word + } + else //second word + { + pos = (reg - modbus.holding_size - 1) / 2; + modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0xffff0000; + modbus.dint_memory[pos] = modbus.dint_memory[pos] | value; + } + + } + else //64-bit registers + { + if (reg % 4 == 0) //first word + { + pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size))) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0x0000ffffffffffff; //zeroed first word + modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 48); //insert first word + } + else if (reg % 4 == 1) //second word + { + pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size) - 1)) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffff0000ffffffff; + modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 32); + } + else if (reg % 4 == 2) //third word + { + pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size) - 2)) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffff0000ffff; + modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 16); + } + else //fourth word + { + pos = (reg - (modbus.holding_size + (2*modbus.dint_memory_size) - 3)) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffffffff0000; + modbus.lint_memory[pos] = modbus.lint_memory[pos] | value; + } + } +} + +void writeMultipleRegisters(uint16_t startreg, uint16_t numoutputs, uint8_t bytecount) +{ + //Check value + if (numoutputs < 0x0001 || numoutputs > 0x007B || bytecount != 2 * numoutputs) + { + exceptionResponse(MB_FC_WRITE_REGS, MB_EX_ILLEGAL_VALUE); + return; + } + + //Check Address (startreg...startreg + numregs) + if ((startreg + numoutputs) >= (modbus.holding_size + (2*modbus.dint_memory_size) + (4*modbus.lint_memory_size))) + { + exceptionResponse(MB_FC_WRITE_REGS, MB_EX_ILLEGAL_ADDRESS); + return; + } + + //Prepare answer frame buffer + mb_frame_len = 6; + mb_frame[1] = MB_FC_WRITE_REGS; + mb_frame[2] = startreg >> 8; + mb_frame[3] = startreg & 0x00FF; + mb_frame[4] = numoutputs >> 8; + mb_frame[5] = numoutputs & 0x00FF; + + uint16_t value; + uint16_t i = 0; + uint8_t pos = 0; + while(numoutputs--) + { + value = (uint16_t)mb_frame[7+i*2] << 8 | (uint16_t)mb_frame[8+i*2]; + + if ((startreg + i) < modbus.holding_size) + { + modbus.holding[(startreg + i)] = value; + } + else if ((startreg + i) < (modbus.holding_size + (2*modbus.dint_memory_size))) //32-bit registers + { + if ((startreg + i) % 2 == 0) //first word + { + pos = ((startreg + i) - modbus.holding_size) / 2; + modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0x0000ffff; //zeroed first word + modbus.dint_memory[pos] = modbus.dint_memory[pos] | ((uint32_t)value << 16); //insert first word + } + else //second word + { + pos = ((startreg + i) - modbus.holding_size - 1) / 2; + modbus.dint_memory[pos] = modbus.dint_memory[pos] & 0xffff0000; + modbus.dint_memory[pos] = modbus.dint_memory[pos] | value; + } + + } + else //64-bit registers + { + if ((startreg + i) % 4 == 0) //first word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size))) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0x0000ffffffffffff; //zeroed first word + modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 48); //insert first word + } + else if ((startreg + i) % 4 == 1) //second word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 1)) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffff0000ffffffff; + modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 32); + } + else if ((startreg + i) % 4 == 2) //third word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 2)) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffff0000ffff; + modbus.lint_memory[pos] = modbus.lint_memory[pos] | ((uint64_t)value << 16); + } + else //fourth word + { + pos = ((startreg + i) - (modbus.holding_size + (2*modbus.dint_memory_size) - 3)) / 4; + modbus.lint_memory[pos] = modbus.lint_memory[pos] & 0xffffffffffff0000; + modbus.lint_memory[pos] = modbus.lint_memory[pos] | value; + } + } + + i++; + } +} + +void readCoils(uint16_t startreg, uint16_t numregs) +{ + //Check value (numregs) + if (numregs < 0x0001 || numregs > 0x07D0) + { + exceptionResponse(MB_FC_READ_COILS, MB_EX_ILLEGAL_VALUE); + return; + } + + //Check Address + if (startreg + numregs > modbus.coils_size) + { + exceptionResponse(MB_FC_READ_COILS, MB_EX_ILLEGAL_ADDRESS); + return; + } + + //Determine the message length = slaveid + function type + byte count and + //for each group of 8 registers the message length increases by 1 + mb_frame_len = 3 + numregs/8; + if (numregs%8) mb_frame_len++; //Add 1 to the message length for the partial byte. + if (mb_frame_len > MAX_MB_FRAME) + { + //Response message is too big for this device + exceptionResponse(MB_FC_READ_COILS, MB_EX_SLAVE_FAILURE); + return; + } + + //Clean frame buffer (leave only SlaveID) + for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; + + mb_frame[1] = MB_FC_READ_COILS; + mb_frame[2] = mb_frame_len - 3; //byte count (mb_frame_len - slave id, function code and byte count) + + uint8_t bitn = 0; + uint16_t totregs = numregs; + uint16_t i; + while (numregs) + { + i = (totregs - numregs--) / 8; + if (get_discrete((uint8_t)startreg, COILS)) + bitSet(mb_frame[3+i], bitn); + else + bitClear(mb_frame[3+i], bitn); + + //increment the bit index + bitn++; + if (bitn == 8) bitn = 0; + //increment the register + startreg++; + } +} + +void readInputStatus(uint16_t startreg, uint16_t numregs) +{ + //Check value (numregs) + if (numregs < 0x0001 || numregs > 0x07D0) + { + exceptionResponse(MB_FC_READ_INPUT_STAT, MB_EX_ILLEGAL_VALUE); + return; + } + + //Check Address + if ((startreg + numregs) > modbus.input_status_size) + { + exceptionResponse(MB_FC_READ_INPUT_STAT, MB_EX_ILLEGAL_ADDRESS); + return; + } + + //Determine the message length = function type, byte count and + //for each group of 8 registers the message length increases by 1 + mb_frame_len = 3 + numregs/8; + if (numregs%8) mb_frame_len++; //Add 1 to the message length for the partial byte. + if (mb_frame_len > MAX_MB_FRAME) + { + //Response message is too big for this device + exceptionResponse(MB_FC_READ_INPUT_STAT, MB_EX_SLAVE_FAILURE); + return; + } + + //Clean frame buffer (leave only SlaveID) + for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; + + mb_frame[1] = MB_FC_READ_INPUT_STAT; + mb_frame[2] = mb_frame_len - 3; + + byte bitn = 0; + uint16_t totregs = numregs; + uint16_t i; + while (numregs) + { + i = (totregs - numregs--) / 8; + if (get_discrete(startreg, INPUTSTATUS)) + bitSet(mb_frame[3+i], bitn); + else + bitClear(mb_frame[3+i], bitn); + //increment the bit index + bitn++; + if (bitn == 8) bitn = 0; + //increment the register + startreg++; + } +} + +void readInputRegisters(uint16_t startreg, uint16_t numregs) +{ + //Check value (numregs) + if (numregs < 0x0001 || numregs > 0x007D) + { + exceptionResponse(MB_FC_READ_INPUT_REGS, MB_EX_ILLEGAL_VALUE); + return; + } + + //Check Address + if ((startreg + numregs) > modbus.input_regs_size) + { + exceptionResponse(MB_FC_READ_INPUT_REGS, MB_EX_ILLEGAL_ADDRESS); + return; + } + + //calculate the query reply message length + //for each register queried add 2 bytes + mb_frame_len = 3 + (numregs * 2); + if (mb_frame_len > MAX_MB_FRAME) + { + //Response message is too big for this device + exceptionResponse(MB_FC_READ_INPUT_REGS, MB_EX_SLAVE_FAILURE); + return; + } + + //Clean frame buffer (leave only SlaveID) + for (int i = 1; i < mb_frame_len; i++) mb_frame[i] = 0; + + mb_frame[1] = MB_FC_READ_INPUT_REGS; + mb_frame[2] = mb_frame_len - 3; + + uint16_t val; + uint16_t i = 0; + while(numregs--) + { + //retrieve the value from the register bank for the current register + val = modbus.input_regs[startreg + i]; + //write the high byte of the register value + mb_frame[3 + (i * 2)] = val >> 8; + //write the low byte of the register value + mb_frame[4 + (i * 2)] = val & 0xFF; + i++; + } +} + +void writeSingleCoil(uint16_t reg, uint16_t status) +{ + //Check value (status) + if (status != 0xFF00 && status != 0x0000) + { + exceptionResponse(MB_FC_WRITE_COIL, MB_EX_ILLEGAL_VALUE); + return; + } + + //Check Address + if (reg > (modbus.coils_size - 1)) + { + exceptionResponse(MB_FC_WRITE_COIL, MB_EX_ILLEGAL_ADDRESS); + return; + } + + //Execute + write_discrete(reg, COILS, status == 0xFF00 ? true : false); +} + +void writeMultipleCoils(uint16_t startreg, uint16_t numoutputs, uint16_t bytecount) +{ + //Check value + uint8_t bytecount_calc = numoutputs / 8; + if (numoutputs%8) bytecount_calc++; + if (numoutputs < 0x0001 || numoutputs > 0x07B0 || bytecount != bytecount_calc) + { + exceptionResponse(MB_FC_WRITE_COILS, MB_EX_ILLEGAL_VALUE); + return; + } + + //Check Address (startreg...startreg + numregs) + if ((startreg + numoutputs) > modbus.coils_size) + { + exceptionResponse(MB_FC_WRITE_COILS, MB_EX_ILLEGAL_ADDRESS); + return; + } + + //Prepare answer frame buffer + mb_frame_len = 6; + mb_frame[1] = MB_FC_WRITE_COILS; + mb_frame[2] = startreg >> 8; + mb_frame[3] = startreg & 0x00FF; + mb_frame[4] = numoutputs >> 8; + mb_frame[5] = numoutputs & 0x00FF; + + //Execute + uint8_t bitn = 0; + uint16_t totoutputs = numoutputs; + uint16_t i; + while (numoutputs) + { + i = (totoutputs - numoutputs--) / 8; + write_discrete(startreg, COILS, bitRead(mb_frame[7+i], bitn)); + //increment the bit index + bitn++; + if (bitn == 8) bitn = 0; + //increment the register + startreg++; + } +} + +#endif // MODBUS_ENABLED diff --git a/resources/sources/Baremetal/modbus_registers.h b/resources/sources/Baremetal/modbus_registers.h new file mode 100644 index 000000000..27258273b --- /dev/null +++ b/resources/sources/Baremetal/modbus_registers.h @@ -0,0 +1,31 @@ +/* +modbus_registers.h - Modbus register store + operation function codes +Copyright (C) 2022 OpenPLC - Thiago Alves + +The coil/holding/input register banks and the standard Modbus operation FCs +(0x01-0x10). Compiled only under MODBUS_ENABLED — a debug-only build never +references these symbols (the debugger reads IEC variables directly through the +strucpp debug table, needing no operation buffers). The `modbus` instance itself +lives in modbus_frame.* because its slave id is shared by every build. +*/ + +#ifndef MODBUS_REGISTERS_H +#define MODBUS_REGISTERS_H + +#include "modbus_frame.h" + +bool init_mbregs(uint8_t size_holding, uint8_t size_dint_memory, uint8_t size_lint_memory, uint8_t size_coils, uint8_t size_inputregs, uint8_t size_inputstatus); +bool get_discrete(uint16_t addr, bool regtype); +void write_discrete(uint16_t addr, bool regtype, bool value); + +//Modbus operation function-code handlers +void readRegisters(uint16_t startreg, uint16_t numregs); +void writeSingleRegister(uint16_t reg, uint16_t value); +void writeMultipleRegisters(uint16_t startreg, uint16_t numoutputs, uint8_t bytecount); +void readCoils(uint16_t startreg, uint16_t numregs); +void readInputStatus(uint16_t startreg, uint16_t numregs); +void readInputRegisters(uint16_t startreg, uint16_t numregs); +void writeSingleCoil(uint16_t reg, uint16_t status); +void writeMultipleCoils(uint16_t startreg, uint16_t numoutputs, uint16_t bytecount); + +#endif diff --git a/resources/sources/Baremetal/modbus_serial.cpp b/resources/sources/Baremetal/modbus_serial.cpp new file mode 100644 index 000000000..c68f36a5c --- /dev/null +++ b/resources/sources/Baremetal/modbus_serial.cpp @@ -0,0 +1,269 @@ +/* +modbus_serial.cpp - Modbus RTU / debugger serial transport +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#include "modbus_serial.h" +#include "modbus_pdu.h" // process_mbpacket, mb_pdu_request_len, mb_pdu_skips_crc +#include "modbus_crc.h" // calcCrc + +#if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) +#include "Controllino.h" +#endif + +//Serial timing/port state. +Stream* mb_serialport; +int8_t mb_txpin; +uint16_t mb_t15; // inter character time out +uint16_t mb_t35; // frame delay + +void mbconfig_serial_iface(Stream* port, long baud, int txPin) +{ + mb_serialport = port; + mb_txpin = txPin; + //(*port).begin(baud); //Initialization already happened on main .ino file + + //RS-485 control + if (txPin >= 0) + { + pinMode(txPin, OUTPUT); + digitalWrite(txPin, LOW); + } + + #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) + if (mb_serialport == &Serial3) + Controllino_RS485Init(); + #elif defined(CONTROLLINO_MICRO) + if (mb_serialport == &Serial2) { + pinMode(CUSTOM_RS485_DEFAULT_DE_PIN, OUTPUT); + pinMode(CUSTOM_RS485_DEFAULT_RE_PIN, OUTPUT); + digitalWrite(CUSTOM_RS485_DEFAULT_DE_PIN, LOW); + digitalWrite(CUSTOM_RS485_DEFAULT_RE_PIN, HIGH); + } + #endif + + // Modbus states that a baud rate higher than 19200 must use a fixed 750 us + // for inter character time out. For baud rates below 19200 the timing + // is more critical and has to be calculated. + // E.g. 9600 baud in a 11 bit packet is 9600/11 = 872 characters per second + // In milliseconds this will be 872 characters per 1000ms. So for 1 character + // 1000ms/872 characters is 1.14583ms per character. Finally modbus states + // an inter-character must be 1.5T or 1.5 times longer than a character. Thus + // 1.5T = 1.14583ms * 1.5 = 1.71875ms. + // Thus the formula is T1.5(us) = (1000ms * 1000(us) * 1.5 * 11bits)/baud + // 1000ms * 1000(us) * 1.5 * 11bits = 16500000 can be calculated as a constant + + if (baud > 19200) + mb_t15 = 750; + else + mb_t15 = 16500000/baud; // 1T * 1.5 = T1.5 + + /* The modbus definition of a frame delay is a waiting period of 3.5 character times + between packets.*/ + + mb_t35 = mb_t15 * 3.5; +} + +#ifdef MB_SERIAL_ACTIVE +// Inter-frame idle, in milliseconds, used ONLY to abandon a frame whose +// remainder never arrives. Modbus RTU was defined for RS485, where bytes of a +// frame are ~one character time apart (T1.5/T3.5, tens of microseconds at +// 115200) and the byte cadence delimits frames. That assumption is INVALID on +// USB-CDC (and any store-and-forward link): a single request is split into +// 64-byte USB packets separated by USB-frame-scale gaps far longer than T1.5, +// so cadence framing tears requests apart (the bug that made the P1AM-100 / +// SAMD21 debugger crawl). We therefore frame by the request's DECLARED length +// (derived from the function code) and fall back to this idle only to drop a +// truncated partial. It must exceed any intra-frame USB gap yet stay well below +// a master's request timeout. +#define MB_RTU_FRAME_GAP_MS 8 + +// Persistent RX-assembly state. handle_serial() is called every scan cycle and +// never blocks; a request whose bytes straddle several calls is carried across +// them in mb_frame[0..mb_rx_len). (This shares mb_frame with handle_tcp, which +// is safe because an OpenPLC board is configured for a single Modbus transport; +// the two are not driven mid-frame at the same time.) +static uint16_t mb_rx_len = 0; +static uint32_t mb_rx_last_ms = 0; + +#ifdef MBSERIAL_ON_SECONDARY +// Dual-serial: the debugger keeps the default serial while Modbus RTU runs on a +// distinct UART. Each port needs its OWN RX assembly buffer — a partial frame on +// one port must survive while the other is serviced. `mb_frame` becomes a +// transient process/TX buffer, borrowed for one complete transaction at a time +// (safe: Modbus RTU is half-duplex turn-taking and the ports are polled +// sequentially). These extra buffers are compiled ONLY for boards that use a +// secondary Modbus serial (multi-UART, RAM-rich), so single-UART boards keep the +// original single-buffer footprint. +static uint8_t mb_rx_dbg[MAX_MB_FRAME]; +static uint16_t mb_rx_dbg_len = 0; +static uint32_t mb_rx_dbg_last_ms = 0; +static uint8_t mb_rx_rtu[MAX_MB_FRAME]; +static uint16_t mb_rx_rtu_len = 0; +static uint32_t mb_rx_rtu_last_ms = 0; +#endif + +// Drop the first `k` bytes of the assembly buffer, keeping the remainder. Used +// for one-byte realignment on a bad/foreign frame head — NEVER a blind flush — +// so a genuine frame head sitting further into the buffer always survives and +// is eventually found (guarantees resync convergence; no "discard every frame" +// loop). Slides only run on the error path, so the O(n) cost is irrelevant. +static void mb_rtu_drop_front(uint8_t *buf, uint16_t *plen, uint16_t k) +{ + if (k >= *plen) { *plen = 0; return; } + for (uint16_t i = k; i < *plen; i++) + buf[i - k] = buf[i]; + *plen = (uint16_t)(*plen - k); +} + +// Service ONE serial port. `buf`/`plen`/`plast` are the port's own RX-assembly +// state; `slaveid` is its framing id; `txpin` its RS485 driver-enable pin (-1 +// when none). A complete frame is copied into the shared `mb_frame`, processed, +// and the response written back to `port`. In the single-serial build `buf` IS +// `mb_frame` (in-place, no copy); in the dual-serial build each port owns a +// distinct buffer and `mb_frame` is the transient process/TX scratch. +static void handle_serial_port(Stream *port, int8_t txpin, uint8_t slaveid, + uint8_t *buf, uint16_t *plen, uint32_t *plast) +{ + uint16_t packet_crc; + + // 1) Drain the RX buffer without blocking. One frame's bytes may arrive + // across several calls; the scan cycle is never stalled waiting on them. + while (port->available() > 0) + { + if (*plen >= MAX_MB_FRAME) break; // full — let the parser drain it + buf[(*plen)++] = (uint8_t)port->read(); + *plast = millis(); + } + + // 2) Extract every complete frame in the buffer. Each iteration either + // consumes/realigns by >=1 byte or returns to await more data, so the + // loop always terminates. + for (;;) + { + if (*plen == 0) + return; + + // Header byte-alignment: the first byte must be THIS port's slave id. + // This is the cheap framing check, and it is the ONLY validation applied + // to debugger frames (CRC is deliberately skipped on debug FCs for + // performance — those function codes are private and well-formed). + if (buf[0] != slaveid) + { + mb_rtu_drop_front(buf, plen, 1); // foreign/garbage head — slide + continue; + } + + int32_t expected = mb_pdu_request_len(buf, *plen); + + if (expected < 0 || expected > MAX_MB_FRAME) + { + mb_rtu_drop_front(buf, plen, 1); // illegal FC / impossible length + continue; + } + if (expected == 0 || *plen < (uint16_t)expected) + { + // Header incomplete, or the frame's tail has not arrived yet. Wait + // for it; abandon the partial only if its remainder never comes. + if ((uint32_t)(millis() - *plast) > MB_RTU_FRAME_GAP_MS) + *plen = 0; + return; + } + + // 3) A full candidate frame occupies buf[0 .. expected). Move it into the + // shared process buffer (a no-op self-copy on the single-serial path, + // where buf already IS mb_frame). + if (buf != mb_frame) + { + for (int32_t i = 0; i < expected; i++) mb_frame[i] = buf[i]; + } + + // Standard FCs are validated by CRC (the arbiter that makes resync + // trustworthy); a mismatch means corruption or misalignment, so we + // slide one byte and retry instead of discarding the whole buffer. + if (!mb_pdu_skips_crc(mb_frame[1])) + { + mb_frame_len = (uint16_t)expected; + packet_crc = ((mb_frame[expected - 2] << 8) | mb_frame[expected - 1]); + if (packet_crc != calcCrc()) + { + mb_rtu_drop_front(buf, plen, 1); + continue; + } + } + + // 4) Accepted. Hand the PDU (CRC stripped) to the shared processor, + // which builds the response back into mb_frame. + mb_frame_len = (uint16_t)expected - 2; + process_mbpacket(); + + //Add CRC + //Check if response message is too big for this device + if (mb_frame_len + 2 > MAX_MB_FRAME) exceptionResponse(mb_frame[1], MB_EX_SLAVE_FAILURE); + mb_frame_len += 2; //increase frame length by two bytes to acomodate CRC + packet_crc = calcCrc(); //calculate CRC of the new packet + mb_frame[mb_frame_len - 2] = (uint8_t)(packet_crc >> 8); + mb_frame[mb_frame_len - 1] = (uint8_t)(packet_crc & 0x00FF); + + if (txpin >= 0) + { + digitalWrite(txpin, HIGH); + delayMicroseconds(mb_t35); + } + + #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) + if (port == &Serial3) // RS485 serial port + Controllino_RS485TxEnable(); // Enable RS485 chip to transmit + #elif defined(CONTROLLINO_MICRO) + if (port == &Serial2) { + digitalWrite(CUSTOM_RS485_DEFAULT_DE_PIN, HIGH); + digitalWrite(CUSTOM_RS485_DEFAULT_RE_PIN, HIGH); + } + #endif + + port->write(mb_frame, mb_frame_len); + port->flush(); + delayMicroseconds(mb_t35); + + if (txpin >= 0) + digitalWrite(txpin, LOW); + + #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) + if (port == &Serial3) // RS485 serial port + Controllino_RS485RxEnable(); // Go back to receive mode after transmitted data + #elif defined(CONTROLLINO_MICRO) + if (port == &Serial2) { + digitalWrite(CUSTOM_RS485_DEFAULT_DE_PIN, LOW); + digitalWrite(CUSTOM_RS485_DEFAULT_RE_PIN, LOW); + } + #endif + + // 5) The request — and the response built over it — consumed the whole + // assembly buffer. Modbus RTU is turn-taking: the master waits for + // this reply before sending its next request, so no following frame + // can already be buffered. Reset for the next request. A + // non-conformant pipelining master simply retransmits after its + // timeout, and the gap/realignment logic above recovers cleanly. + *plen = 0; + return; + } +} + +// Dispatch to one or two serial ports. Single-serial: the debugger and Modbus +// RTU (if any) share one port, assembled in-place in mb_frame. Dual-serial +// (MBSERIAL_ON_SECONDARY): the debugger keeps the default serial while Modbus +// RTU runs on a distinct UART — each with its own RX buffer. +void handle_serial() +{ +#ifdef MBSERIAL_ON_SECONDARY + handle_serial_port(&DEBUG_IFACE, -1, DEBUG_SLAVE, mb_rx_dbg, &mb_rx_dbg_len, &mb_rx_dbg_last_ms); + #ifdef MBSERIAL_TXPIN + handle_serial_port(&MBSERIAL_IFACE, MBSERIAL_TXPIN, MBSERIAL_SLAVE, mb_rx_rtu, &mb_rx_rtu_len, &mb_rx_rtu_last_ms); + #else + handle_serial_port(&MBSERIAL_IFACE, -1, MBSERIAL_SLAVE, mb_rx_rtu, &mb_rx_rtu_len, &mb_rx_rtu_last_ms); + #endif +#else + handle_serial_port(mb_serialport, mb_txpin, modbus.slaveid, mb_frame, &mb_rx_len, &mb_rx_last_ms); +#endif +} +#endif // MB_SERIAL_ACTIVE diff --git a/resources/sources/Baremetal/modbus_serial.h b/resources/sources/Baremetal/modbus_serial.h new file mode 100644 index 000000000..eeca143db --- /dev/null +++ b/resources/sources/Baremetal/modbus_serial.h @@ -0,0 +1,32 @@ +/* +modbus_serial.h - Modbus RTU / debugger serial transport +Copyright (C) 2022 OpenPLC - Thiago Alves + +The serial wire: RTU framing (declared-length, not byte-cadence), RS485 tx-enable +timing, and single- or dual-serial polling. It fills mb_frame with a request, +asks modbus_pdu for the frame shape / CRC policy, calls process_mbpacket() and +writes the response back — it holds NO knowledge of the function-code set. +*/ + +#ifndef MODBUS_SERIAL_H +#define MODBUS_SERIAL_H + +#include "modbus_frame.h" + +// Serial timing/port state, configured once by mbconfig_serial_iface(). +extern Stream* mb_serialport; +extern int8_t mb_txpin; +extern uint16_t mb_t15; // inter character time out +extern uint16_t mb_t35; // frame delay + +// Bind + configure the serial interface (RS485 driver-enable pin, T1.5/T3.5 +// timing derived from baud). Serial.begin() itself happens in the .ino sketch. +void mbconfig_serial_iface(Stream* port, long baud, int txPin); + +#ifdef MB_SERIAL_ACTIVE +// Poll the serial port(s) for a complete RTU/debugger frame and answer it. +// Non-blocking; called every scan cycle. +void handle_serial(); +#endif + +#endif diff --git a/resources/sources/Baremetal/modbus_tcp.cpp b/resources/sources/Baremetal/modbus_tcp.cpp new file mode 100644 index 000000000..453267469 --- /dev/null +++ b/resources/sources/Baremetal/modbus_tcp.cpp @@ -0,0 +1,250 @@ +/* +modbus_tcp.cpp - Modbus TCP transport (Ethernet / WiFi / ESP ETH) +Copyright (C) 2022 OpenPLC - Thiago Alves +*/ + +#include "modbus_tcp.h" +#include "modbus_pdu.h" // process_mbpacket + +#ifdef MBTCP_ETHERNET +#ifdef BOARD_ESP32 + WiFiServer mb_server(502); + WiFiClient mb_serverClients[MAX_SRV_CLIENTS]; +#else + EthernetServer mb_server(502); +#endif + uint8_t mb_mbap[MBAP_SIZE]; +#ifdef BOARD_PORTENTA + EthernetClient mb_serverClients[MAX_SRV_CLIENTS]; +#endif +#endif + +#ifdef MBTCP_WIFI + WiFiServer mb_server(502); + uint8_t mb_mbap[MBAP_SIZE]; +#if defined(BOARD_ESP8266) || defined(BOARD_ESP32) || defined(BOARD_PORTENTA) || defined(BOARD_PICOW) + WiFiClient mb_serverClients[MAX_SRV_CLIENTS]; +#endif +#endif + +#ifdef MBTCP +void mbconfig_ethernet_iface(uint8_t *mac, uint8_t *ip, uint8_t *dns, uint8_t *gateway, uint8_t *subnet) +{ + #ifdef MBTCP_ETHERNET + #ifdef BOARD_ESP32 + + ETH.begin(); + + if (ip != NULL && subnet != NULL && gateway != NULL) + (ETH.config(ip, gateway, subnet, dns)); + + #else + if (ip == NULL) + Ethernet.begin(mac); + else if (dns == NULL) + Ethernet.begin(mac, IPAddress(ip)); + else if (gateway == NULL) + Ethernet.begin(mac, IPAddress(ip), IPAddress(dns)); + else if (subnet == NULL) + Ethernet.begin(mac, IPAddress(ip), IPAddress(dns), IPAddress(gateway)); + else + Ethernet.begin(mac, IPAddress(ip), IPAddress(dns), IPAddress(gateway), IPAddress(subnet)); + #endif + +// int num_tries = 0; +// while (!ETH.linkUp()) +// { +// delay(500); +// num_tries++; +// if (num_tries == 20) break; +// } + + #endif + #ifdef MBTCP_WIFI + #if defined(BOARD_ESP8266) || defined(BOARD_ESP32) + if (ip != NULL && gateway != NULL && subnet != NULL && dns != NULL) + { + uint8_t secondaryDNS[] = {8, 8, 8, 8}; + WiFi.config(IPAddress(ip), IPAddress(gateway), IPAddress(subnet), IPAddress(dns), IPAddress(secondaryDNS)); + } + mb_server.setNoDelay(true); + #elif defined(BOARD_PORTENTA) + if (ip != NULL && subnet != NULL && gateway != NULL) + { + WiFi.config(IPAddress(ip), IPAddress(subnet), IPAddress(gateway)); + } + #else + if (ip != NULL) + { + if (dns == NULL) + WiFi.config(IPAddress(ip)); + else if (gateway == NULL) + WiFi.config(IPAddress(ip), IPAddress(dns)); + else if (subnet == NULL) + WiFi.config(IPAddress(ip), IPAddress(dns), IPAddress(gateway)); + else + WiFi.config(IPAddress(ip), IPAddress(dns), IPAddress(gateway), IPAddress(subnet)); + } + #endif + WiFi.begin(MBTCP_SSID, MBTCP_PWD); + int num_tries = 0; + while (WiFi.status() != WL_CONNECTED) + { + delay(500); + num_tries++; + if (num_tries == 10) break; + } + #endif + + mb_server.begin(); + +} + +void handle_tcp() +{ + #ifdef MBTCP_ETHERNET + #ifdef BOARD_ESP32 + WiFiClient client = mb_server.available(); + #else + EthernetClient client = mb_server.available(); + #endif + #endif + + #if defined(MBTCP_WIFI) && !defined(BOARD_ESP8266) && !defined(BOARD_ESP32) + WiFiClient client = mb_server.available(); + #endif + + //ESP and Portenta boards have a slightly different implementation of the WiFi/Ethernet API - therefore their specific + //code lies below + #if (defined(BOARD_ESP8266) || defined(BOARD_ESP32) || defined(BOARD_PORTENTA)) || defined(BOARD_PICOW) && (defined(MBTCP_WIFI) || defined(MBTCP_ETHERNET)) + + + #if defined(BOARD_PORTENTA) || defined(BOARD_PICOW) || (defined(BOARD_ESP32) && defined(MBTCP_ETHERNET)) + if (client) + #else + if (mb_server.hasClient()) + #endif + { + for (int i = 0; i < MAX_SRV_CLIENTS; i++) + { + if (!mb_serverClients[i]) //equivalent to !serverClients[i].connected() + { + #if defined(BOARD_PORTENTA) || defined(BOARD_PICOW) || defined(BOARD_ESP32) && defined(MBTCP_ETHERNET) + mb_serverClients[i] = client; + #else + mb_serverClients[i] = mb_server.available(); + #endif + break; + } + } + } + + //search all clients for data + for (int i = 0; i < MAX_SRV_CLIENTS; i++) + { + int j = 0; + + + if (mb_serverClients[i].connected() && mb_serverClients[i].available()) + + { + //Read packet + + + while (mb_serverClients[i].available()) + { + mb_mbap[j] = mb_serverClients[i].read(); + j++; + if (j==MBAP_SIZE) break; //MBAP has 6 bytes (we use UnitID as SlaveID) + } + + mb_frame_len = mb_mbap[4] << 8 | mb_mbap[5]; + + if (mb_mbap[2] !=0 || mb_mbap[3] !=0) return; //Not a MODBUSIP packet + if (mb_frame_len < 6 || mb_frame_len > MAX_MB_FRAME) return; //Packet is too small or too big + + j = 0; + while (mb_serverClients[i].available()) + { + mb_frame[j] = mb_serverClients[i].read(); + j++; + if (j==mb_frame_len) break; + } + + //Safety check - discard packages that lie about their size + if (j != mb_frame_len) return; + + //Process packet and write back + process_mbpacket(); + //Calculate packet length for MBAP header (mb_frame_len + 1) + mb_mbap[4] = (mb_frame_len) >> 8; + mb_mbap[5] = (mb_frame_len) & 0x00FF; + + uint8_t sendbuffer[mb_frame_len + MBAP_SIZE]; + + //MBAP + for (j = 0 ; j < MBAP_SIZE ; j++) + sendbuffer[j] = mb_mbap[j]; + + //PDU Frame + for (j = 0 ; j < mb_frame_len ; j++) + sendbuffer[j+MBAP_SIZE] = mb_frame[j]; + + //Write back + mb_serverClients[i].write(sendbuffer, mb_frame_len + MBAP_SIZE); + } + } + + //If this is not an ESP board or Portenta board, then here is the default code + #else + if (client) + { + if (client.connected()) + { + int i = 0; + while (client.available()) + { + mb_mbap[i] = client.read(); + i++; + if (i==MBAP_SIZE) break; //MBAP has 6 bytes (we use UnitID as SlaveID) + } + + mb_frame_len = mb_mbap[4] << 8 | mb_mbap[5]; + + if (mb_mbap[2] !=0 || mb_mbap[3] !=0) return; //Not a MODBUSIP packet + if (mb_frame_len < 6 || mb_frame_len > MAX_MB_FRAME) return; //Packet is too small or too big + + i = 0; + while (client.available()) + { + mb_frame[i] = client.read(); + i++; + if (i==mb_frame_len || i==MAX_MB_FRAME) break; + } + + //Safety check - discard packages that lie about their size + if (i != mb_frame_len) return; + + //Process packet and write back + process_mbpacket(); + //Calculate packet length for MBAP header (mb_frame_len + 1) + mb_mbap[4] = (mb_frame_len) >> 8; + mb_mbap[5] = (mb_frame_len) & 0x00FF; + + uint8_t sendbuffer[mb_frame_len + MBAP_SIZE]; + + //MBAP + for (i = 0 ; i < MBAP_SIZE ; i++) + sendbuffer[i] = mb_mbap[i]; + + //PDU Frame + for (i = 0 ; i < mb_frame_len ; i++) + sendbuffer[i+MBAP_SIZE] = mb_frame[i]; + + //Write back + client.write(sendbuffer, mb_frame_len + MBAP_SIZE); + } + } + #endif +} +#endif diff --git a/resources/sources/Baremetal/modbus_tcp.h b/resources/sources/Baremetal/modbus_tcp.h new file mode 100644 index 000000000..dcfec3c38 --- /dev/null +++ b/resources/sources/Baremetal/modbus_tcp.h @@ -0,0 +1,77 @@ +/* +modbus_tcp.h - Modbus TCP transport (Ethernet / WiFi / ESP ETH) +Copyright (C) 2022 OpenPLC - Thiago Alves + +The TCP wire: brings the platform networking stack up, accepts up to +MAX_SRV_CLIENTS connections and services MBAP-framed requests. Like the serial +transport it fills mb_frame, calls process_mbpacket() and writes the response +back — no knowledge of the function-code set. +*/ + +#ifndef MODBUS_TCP_H +#define MODBUS_TCP_H + +#include "modbus_frame.h" + +//Platform specific defines and includes +#ifdef MBTCP_ETHERNET +#include +#ifdef BOARD_ESP32 + // I²C-address of Ethernet PHY (0 or 1 for LAN8720, 31 for TLK110) + #define ETH_PHY_ADDR 0 // DEFAULT VALUE IS 0 YOU CAN OMIT IT + // Type of the Ethernet PHY (LAN8720 or TLK110) + #define ETH_PHY_TYPE ETH_PHY_LAN8720 // DEFAULT VALUE YOU CAN OMIT IT + // Pin# of the enable signal for the external crystal oscillator (-1 to disable for internal APLL source) + #define ETH_PHY_POWER -1 // DEFAULT VALUE YOU CAN OMIT IT + // Pin# of the I²C clock signal for the Ethernet PHY + #define ETH_PHY_MDC 23 // DEFAULT VALUE YOU CAN OMIT IT + // Pin# of the I²C IO signal for the Ethernet PHY + #define ETH_PHY_MDIO 18 // DEFAULT VALUE YOU CAN OMIT IT + // External clock from crystal oscillator + #define ETH_CLK_MODE ETH_CLOCK_GPIO0_IN // DEFAULT VALUE YOU CAN OMIT IT + #include + #include +#else + #include +#endif +#endif + +#ifdef MBTCP_WIFI +#if defined(BOARD_ESP8266) +#include +#elif defined(BOARD_ESP32) +#include +#elif defined(BOARD_WIFININA) +#include +#else +#include +#include +#endif +#endif + +#ifdef MBTCP_ETHERNET +#ifdef BOARD_ESP32 + extern WiFiServer mb_server; +#else + extern EthernetServer mb_server; +#endif + extern uint8_t mb_mbap[MBAP_SIZE]; +#ifdef BOARD_PORTENTA + extern EthernetClient mb_serverClients[MAX_SRV_CLIENTS]; +#endif +#endif + +#ifdef MBTCP_WIFI + extern WiFiServer mb_server; + extern uint8_t mb_mbap[MBAP_SIZE]; +#if defined(BOARD_ESP8266) || defined(BOARD_ESP32) || defined(BOARD_PORTENTA) || defined(BOARD_PICOW) + extern WiFiClient mb_serverClients[MAX_SRV_CLIENTS]; +#endif +#endif + +#ifdef MBTCP +void mbconfig_ethernet_iface(uint8_t *mac, uint8_t *ip, uint8_t *dns, uint8_t *gateway, uint8_t *subnet); +void handle_tcp(); +#endif + +#endif diff --git a/resources/sources/Baremetal/modbus_types.h b/resources/sources/Baremetal/modbus_types.h new file mode 100644 index 000000000..f75e3af8e --- /dev/null +++ b/resources/sources/Baremetal/modbus_types.h @@ -0,0 +1,90 @@ +/* +modbus_types.h - Shared type/constant declarations for the OpenPLC Modbus slave +Copyright (C) 2022 OpenPLC - Thiago Alves + +Pure declarations only (enums, MBinfo, frame-size constants, status codes, +bit helpers). No storage, no functions — every Modbus TU includes this so the +protocol, transport, register and debug layers agree on the same contracts. +*/ + +#ifndef MODBUS_TYPES_H +#define MODBUS_TYPES_H + +// Brings , the generated defines.h and the composite build gates +// (MB_SERIAL_ACTIVE, DEBUG_* defaults). Every modbus_* TU reaches defines.h +// through this single path — defines.h itself has no include guard. +#include "modbus_config.h" + +#ifndef bitRead + #define bitRead(value, bit) (((value) >> (bit)) & 0x01) +#endif +//#define bitSet(value, bit) ((value) |= (1UL << (bit))) +//#define bitClear(value, bit) ((value) &= ~(1UL << (bit))) +#ifndef bitWrite + #define bitWrite(value, bit, bitvalue) (bitvalue ? bitSet(value, bit) : bitClear(value, bit)) +#endif + +#define COILS 0 +#define INPUTSTATUS 1 + +#if defined(__AVR_ATmega328P__) || defined(__AVR_ATmega168__) || defined(__AVR_ATmega32U4__) || defined(__AVR_ATmega16U4__) + #define MAX_MB_FRAME 128 +#else + #define MAX_MB_FRAME 256 +#endif +#define MAX_SRV_CLIENTS 3 //how many clients should be able to connect to TCP server at the same time +#define MBAP_SIZE 6 + +// Status codes (match strucpp::debug::STATUS_* in debug_dispatch.hpp, kept +// as macros here so the Modbus layer doesn't have to include the C++ +// runtime header when the rest of the protocol is C-style). +#define MB_DEBUG_SUCCESS 0x7E +#define MB_DEBUG_ERROR_OUT_OF_BOUNDS 0x81 +#define MB_DEBUG_ERROR_OUT_OF_MEMORY 0x82 + +//Modbus registers struct +struct MBinfo { + uint8_t slaveid; + uint16_t *holding; + uint8_t holding_size; + uint32_t *dint_memory; + uint8_t dint_memory_size; + uint64_t *lint_memory; + uint8_t lint_memory_size; + uint8_t *coils; + uint8_t coils_size; + uint16_t *input_regs; + uint8_t input_regs_size; + uint8_t *input_status; + uint8_t input_status_size; +}; + +//Function Codes +enum { + MB_FC_READ_COILS = 0x01, // Read Coils (Output) Status 0xxxx + MB_FC_READ_INPUT_STAT = 0x02, // Read Input Status (Discrete Inputs) 1xxxx + MB_FC_READ_REGS = 0x03, // Read Holding Registers 4xxxx + MB_FC_READ_INPUT_REGS = 0x04, // Read Input Registers 3xxxx + MB_FC_WRITE_COIL = 0x05, // Write Single Coil (Output) 0xxxx + MB_FC_WRITE_REG = 0x06, // Preset Single Register 4xxxx + MB_FC_WRITE_COILS = 0x0F, // Write Multiple Coils (Outputs) 0xxxx + MB_FC_WRITE_REGS = 0x10, // Write block of contiguous registers 4xxxx + MB_FC_DEBUG_INFO = 0x41, // Request debug variables count + MB_FC_DEBUG_SET = 0x42, // Debug set trace (force variable) + MB_FC_DEBUG_GET = 0x43, // Debug get trace (read variables) + MB_FC_DEBUG_GET_LIST = 0x44, // Debug get trace list (read list of variables) + MB_FC_DEBUG_GET_MD5 = 0x45, // Debug get current program MD5 + MB_FC_DEBUG_GET_STATUS = 0x46, // Debug get PLC status (running, scan tick, uptime) + MB_FC_DEBUG_GET_VERSION = 0x47, // Debug get runtime firmware version + MB_FC_DEBUG_GET_BOARD_ID = 0x48, // Debug get unique hardware board ID +}; + +//Exception Codes +enum { + MB_EX_ILLEGAL_FUNCTION = 0x01, // Function Code not Supported + MB_EX_ILLEGAL_ADDRESS = 0x02, // Output Address not exists + MB_EX_ILLEGAL_VALUE = 0x03, // Output Value not in Range + MB_EX_SLAVE_FAILURE = 0x04, // Slave Device Fails to process request +}; + +#endif From c582131bb9d029f1165c3ded844adc4eaf81f785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Fri, 10 Jul 2026 14:19:27 +0200 Subject: [PATCH 08/19] docs(firmware): add ARCHITECTURE.md for the Modbus slave modules Reference for the modularized ModbusSlave layer: layer diagram, per-module responsibility table (with build gates and dependencies), request lifecycle (RTU/TCP/dual-serial), the two invariants (transports don't know the FC set; mb_frame is the one seam), a "how to add a function code" guide, and the known single-serial + TCP shared-buffer constraint. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018esifhUpuyPmJB29BneUqr --- resources/sources/Baremetal/ARCHITECTURE.md | 126 ++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 resources/sources/Baremetal/ARCHITECTURE.md diff --git a/resources/sources/Baremetal/ARCHITECTURE.md b/resources/sources/Baremetal/ARCHITECTURE.md new file mode 100644 index 000000000..a9dcdf184 --- /dev/null +++ b/resources/sources/Baremetal/ARCHITECTURE.md @@ -0,0 +1,126 @@ +# Modbus slave — module architecture + +The `ModbusSlave` layer is split into 10 cohesive `modbus_*` translation units, +each owning one concern and its own build gate. Dependencies point **inward +only** (transport → protocol → handlers → data), and everything is glued by a +single shared buffer, `mb_frame`. + +`Baremetal.ino` is unchanged: it `#include "ModbusSlave.h"` (the umbrella) and +calls `mbtask()` once per scan cycle. + +## Layers + +``` + Baremetal.ino + │ (#include "ModbusSlave.h"; calls mbtask()) + ┌─────────▼──────────┐ + │ ModbusSlave.* │ umbrella header + mbtask() facade + └───┬────────────┬───┘ + ┌─────────────▼──┐ ┌──▼──────────────┐ + TRANSPORT │ modbus_serial │ │ modbus_tcp │ own the "wire" + │ (RTU single/dual)│ │ (Eth/WiFi/ETH) │ + └──────┬──────────┘ └────────┬───────┘ + │ fill mb_frame, │ + │ ask for frame shape, │ + └───────────┬──────────────┘ + ┌────────▼─────────┐ + PROTOCOL │ modbus_pdu │ dispatch + per-FC frame shape + └───┬──────────┬───┘ + ┌─────────────▼─┐ ┌──▼──────────────┐ + HANDLERS │ modbus_registers│ │ modbus_debug │ + │ (store + op FCs)│ │ (0x41-0x48 + …) │ + └──────┬─────────┘ └─────────────────┘ + │ + ┌─────────▼───────────────────────────────────────────────┐ + BASE │ modbus_frame (seam) · modbus_crc · modbus_types · modbus_config │ + └─────────────────────────────────────────────────────────┘ +``` + +## Modules + +| Module | Responsibility | Build gate | Depends on | +|--------|----------------|------------|------------| +| **`modbus_config.h`** | Build configuration. Pulls in the generated `defines.h` (which has **no include guard**) and derives the composite gates (`MB_SERIAL_ACTIVE`, `DEBUG_*` defaults). The single guarded path through which `defines.h` reaches every TU. | — | `defines.h` | +| **`modbus_types.h`** | Shared contracts: FC / exception enums, `struct MBinfo`, `MAX_MB_FRAME`, `MBAP_SIZE`, `MB_DEBUG_*` status codes, bit helpers. Pure declarations, no storage. | — | `modbus_config` | +| **`modbus_frame.*`** | The **seam**: the global `mb_frame` / `mb_frame_len` buffer, the `modbus` instance (slave id + register banks) and `exceptionResponse()`. Every transport fills it, every handler writes into it. | — | `types` | +| **`modbus_crc.*`** | Modbus RTU CRC-16 (`calcCrc`) + the two lookup tables, defined **once** in the `.cpp` (they used to live in a header → one flash copy per TU). | — (RTU) | `frame` | +| **`modbus_registers.*`** | Register store + the standard **operation** FCs (`0x01`–`0x10`): `init_mbregs`, `get/write_discrete`, `read*`/`write*`. Compiled out of debug-only builds. | `MODBUS_ENABLED` | `frame` | +| **`modbus_debug.*`** | The always-on **debugger** FCs (`0x41`–`0x48`): info / set / get / md5 / status / version / board-id. Growth home for future custom FCs (e.g. the `0x49+` licensing set). | — | `frame`, `arduino_runtime_glue`, `ArduinoUniqueID` | +| **`modbus_pdu.*`** | The **protocol** layer: `process_mbpacket()` dispatches each FC to its handler, and it owns the **per-FC frame shape** — `mb_pdu_request_len()` (RTU length by FC) and `mb_pdu_skips_crc()` (which FCs bypass CRC). Single source of truth for "the set of function codes". | — | `registers`, `debug` | +| **`modbus_serial.*`** | The **RTU** transport (single- and dual-serial). Declared-length framing (robust over USB-CDC), one-byte resync, RS485 tx-enable timing, per-port RX assembly buffers. | `MB_SERIAL_ACTIVE` | `pdu`, `crc`, `frame` | +| **`modbus_tcp.*`** | The **TCP** transport (Ethernet / WiFi / ESP ETH). Brings the network stack up, accepts up to `MAX_SRV_CLIENTS`, services MBAP-framed requests. | `MBTCP` | `pdu`, `frame` | +| **`ModbusSlave.*`** | **Umbrella** header (re-includes every `modbus_*.h`, so `Baremetal.ino` is untouched) + the `mbtask()` facade that fans out to `handle_tcp()` / `handle_serial()`. | — | all | + +## Build gates + +Which TUs actually compile is driven by `defines.h` (generated per build) and the +composite gates in `modbus_config.h`: + +- `MODBUS_ENABLED` — full Modbus operations. A debug-only build compiles + `modbus_registers.cpp` to an empty TU (the debugger reads IEC variables + directly through the strucpp debug table, needing no operation buffers). +- `MB_SERIAL_ACTIVE` = `MBSERIAL || DEBUGGER_ENABLED` — the serial transport. + Always on for baremetal (the debugger is always on). +- `MBTCP` (+ `MBTCP_ETHERNET` / `MBTCP_WIFI`) — the TCP transport. +- `MBSERIAL_ON_SECONDARY` — dual-serial: Modbus RTU on a distinct UART while the + debugger keeps the default serial (each with its own RX buffer). Otherwise + single-serial (`MBSERIAL_SHARES_DEBUG_SERIAL`), where RTU/debugger share the + default serial and `mb_frame` doubles as the RX-assembly buffer. + +> **Rule:** any gated TU must see `defines.h`. Because `defines.h` has no include +> guard, it reaches a TU through exactly one guarded path: `modbus_config.h` +> (via `modbus_types.h`). Every `modbus_*` header includes that chain. + +## Request lifecycle + +**RTU (single-serial):** +1. `mbtask()` → `handle_serial()` → `handle_serial_port(mb_serialport, …, mb_frame, …)`. +2. Drain available bytes into `mb_frame`; **ask `modbus_pdu`** via + `mb_pdu_request_len()` how many bytes the frame should be (derived per FC). +3. Unless the FC is a debug FC (`mb_pdu_skips_crc()`), validate the CRC with + `modbus_crc::calcCrc()`. +4. `process_mbpacket()` dispatches: operation FC → `modbus_registers`; debug FC → + `modbus_debug`. The response is built back into `mb_frame`. +5. `handle_serial_port` appends the CRC and writes to the serial port. + +**TCP:** same from step 4 onward, but `handle_tcp` reads/writes with an MBAP +header (no CRC) instead of RTU framing. + +**Dual-serial:** `handle_serial()` services two ports with dedicated RX buffers +(`mb_rx_dbg` / `mb_rx_rtu`); `mb_frame` is only transient process/TX scratch. + +## Invariants + +1. **Transports do not know the function-code set.** They ask `modbus_pdu` + (`mb_pdu_request_len` + `mb_pdu_skips_crc`). Adding a function code touches + only `modbus_debug` (the handler) and `modbus_pdu` (dispatch + shape) — never + the transports. +2. **`mb_frame` is the one seam.** Every transport fills it, calls + `process_mbpacket()`, and reads the response back out. Single-threaded + cooperative scheduling means the transports time-slice within a scan; there + are no data races, but persistent partial state in `mb_frame` is a hazard — + see the note below. + +## Adding a function code (e.g. custom `0x49+`) + +1. Add the handler in **`modbus_debug.cpp`** (+ prototype in `modbus_debug.h`). +2. In **`modbus_pdu.cpp`**: + - add a `case` in `process_mbpacket()` that calls the handler; + - add the FC's request length to `mb_pdu_request_len()`; + - if the FC should bypass CRC on RTU, add it to `mb_pdu_skips_crc()`. +3. Add the FC constant to the enum in **`modbus_types.h`**. + +That is the whole surface. `modbus_serial.*` and `modbus_tcp.*` are untouched. + +## Known constraint — single-serial + TCP + +`mb_frame` is shared between `handle_tcp()` and the single-serial assembly path. +In **single-serial** builds `mb_frame` doubles as the RX-assembly buffer and +holds a partial RTU/debug frame **across scan cycles**; since `mbtask()` runs +`handle_tcp()` first, an incoming TCP request can clobber that partial frame. +The framing logic resyncs, but the in-flight transaction is lost → intermittent +glitches under concurrent TCP load. Dual-serial + TCP is safe (dedicated RX +buffers; `mb_frame` only transient). The original design assumed a single Modbus +operation transport per board; the editor allowing RTU + TCP together violates +that. Fix is planned separately (dedicated single-serial RX buffer scoped to +`MBSERIAL && MBTCP`). From 231e11d9bde73ff2ab94aba26b1de54602c64c05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 4 Aug 2026 12:04:12 +0200 Subject: [PATCH 09/19] feat(editor): one device connection over serial or Modbus TCP, with run/stop Brings the connection work that the VPP licensing branch was carrying as an implicit dependency onto its own footing, with no licensing in it. DeviceLinkManager owns THE connection: it takes the ordered candidates resolved from the board's debug spec, tries them in order, and keeps the first that both opens and verifies. Modbus TCP is preferred when the project enables it and serial is always a candidate, since the always-on debugger keeps the serial protocol compiled into every baremetal firmware. Verification per candidate is what makes preferring TCP safe: a socket that opens but answers nothing falls through to the cable instead of stranding the user. Every command -- debug reads and writes, run/stop, md5, the status poll -- now borrows that one client; none of them can open a connection. That is what fixes run/stop over Modbus TCP: the command path used to recognise only an RTU client as reusable, so a tcp stop opened a transient SECOND socket, which an Arduino Modbus TCP server (one client at a time) never answers. Run/stop (FC 0x4b PLC_SET_STATE) rides the same link. Reads come from the status frame (FC 0x46), which already carries the run/stop state and the mode-switch position, so a switch flipped by hand shows up within one poll interval with no second timer and no extra traffic. A RUN request is refused -- not queued -- while the switch reads STOP, and `refusedBySwitch` says so. Session structure: two channel slots (control + debug), lazily opened, so a debug session shares the link rather than replacing it. Runtime v3/v4 come into the same session; the runtime-v4 WebSocket keeps its own client, being a different protocol to a different target. Also here: serial-port descriptors carried as data rather than a display string, a connection-lost warning that names the endpoint and the right advice per transport, and an upload that releases the link only when it is the serial one holding that port -- so a Modbus TCP link, and the debug session on it, survives an upload. Two fixes that are prerequisites rather than features: validate:arch now resolves SRC_ROOT via fileURLToPath (URL.pathname produced C:\C:\... on win32 and the check could not scan the tree at all), and it learned to read multi-line imports, which it never could -- so it had been reporting success while missing real violations of its own rules. No licensing: no license blob, no on-device license storage, no activation, no FC 0x48-0x4C licensing set, no isLicensable/licenseStore capabilities. The device screen confirms a held link and nothing more. `classifyDeviceLink` (backend/editor/hardware/device-probe.ts) keeps only the board-id read that says whether an OpenPLC firmware answered. Co-Authored-By: Claude Opus 5 --- resources/sources/Baremetal/Baremetal.ino | 7 + resources/sources/Baremetal/modbus_debug.cpp | 42 +- resources/sources/Baremetal/modbus_debug.h | 8 +- resources/sources/Baremetal/modbus_pdu.cpp | 8 + resources/sources/Baremetal/modbus_tcp.cpp | 48 +- resources/sources/Baremetal/modbus_types.h | 6 + .../sources/arduino/arduino_runtime_glue.cpp | 192 +++- .../sources/arduino/arduino_runtime_glue.h | 41 + resources/sources/arduino/openplc.h | 41 + src/__architecture__/validate.ts | 76 +- .../editor/compiler/compiler-module.ts | 27 + .../__tests__/device-link-policy.test.ts | 129 +++ .../__tests__/device-session-manager.test.ts | 632 +++++++++++ .../__tests__/serial-port-list.test.ts | 42 +- .../editor/hardware/device-link-policy.ts | 125 ++ src/backend/editor/hardware/device-probe.ts | 114 ++ .../editor/hardware/device-session-manager.ts | 623 ++++++++++ .../hardware/device-transport-factory.ts | 107 ++ .../editor/hardware/hardware-module.ts | 26 +- .../editor/hardware/serial-port-list.ts | 44 +- src/backend/editor/hardware/types.ts | 3 +- src/backend/editor/modbus/modbus-client.ts | 131 ++- .../editor/modbus/modbus-rtu-client.ts | 94 +- .../__tests__/modbus-pdu-plc-control.test.ts | 137 +++ .../shared/debug/__tests__/modbus-pdu.test.ts | 5 +- src/backend/shared/debug/modbus-pdu.ts | 81 +- src/backend/shared/debug/types.ts | 137 +++ .../shared/debug/websocket-debug-transport.ts | 11 +- .../connect-resolve-regression.test.ts | 312 +++++ src/backend/shared/hardware/debug-spec.ts | 164 +++ .../__tests__/modbus-rtu-client.test.ts | 4 +- .../__tests__/plc-control-e2e.test.ts | 254 +++++ .../shared/simulator/modbus-rtu-client.ts | 42 +- src/backend/shared/simulator/types.ts | 22 + .../editor/device/configuration/board.tsx | 93 +- .../__tests__/device-connect-button.test.tsx | 78 ++ .../device-connect-button/index.tsx | 69 ++ .../modals/confirm-device-switch-modal.tsx | 1 + .../modals/runtime-connection-lost-modal.tsx | 14 +- .../workspace-activity-bar/default.tsx | 523 +++++---- .../components/_templates/app-layout.tsx | 2 + .../__tests__/use-device-connect.test.ts | 209 ++++ .../use-device-connection-monitor.test.ts | 177 +++ .../__tests__/use-device-plc-state.test.ts | 92 ++ .../__tests__/use-runtime-polling.test.ts | 2 + src/frontend/hooks/use-device-connect.ts | 127 +++ .../hooks/use-device-connection-monitor.ts | 128 +++ src/frontend/hooks/use-device-plc-state.ts | 61 + src/frontend/hooks/use-runtime-polling.ts | 7 +- src/frontend/hooks/useDebugSession.ts | 44 +- src/frontend/screens/workspace-screen.tsx | 6 + .../__tests__/device-link-resolution.test.ts | 111 ++ .../services/device-link-resolution.ts | 319 ++++++ .../store/__tests__/device-slice.test.ts | 50 +- .../store/__tests__/device-types.test.ts | 23 + src/frontend/store/slices/device/index.ts | 2 + src/frontend/store/slices/device/slice.ts | 42 +- src/frontend/store/slices/device/types.ts | 49 + .../utils/__tests__/serial-port-label.test.ts | 98 ++ src/frontend/utils/device-connect-events.ts | 23 + src/frontend/utils/serial-port-label.ts | 41 + src/main/modules/ipc/main.ts | 1011 ++++++++++------- src/main/modules/ipc/renderer.ts | 98 +- .../editor/__tests__/debugger-adapter.test.ts | 113 +- .../editor/__tests__/device-adapter.test.ts | 33 +- .../adapters/editor/debugger-adapter.ts | 29 +- .../adapters/editor/device-adapter.ts | 46 +- src/middleware/shared/ports/debugger-port.ts | 28 +- src/middleware/shared/ports/device-port.ts | 126 +- src/middleware/shared/ports/runtime-port.ts | 5 + src/middleware/shared/ports/types.ts | 18 +- src/middleware/shared/utils/debug-endpoint.ts | 15 + .../utils/target-capabilities/presets.ts | 4 + .../utils/target-capabilities/resolve.ts | 1 + .../shared/utils/target-capabilities/types.ts | 7 + 75 files changed, 6769 insertions(+), 891 deletions(-) create mode 100644 src/backend/editor/hardware/__tests__/device-link-policy.test.ts create mode 100644 src/backend/editor/hardware/__tests__/device-session-manager.test.ts create mode 100644 src/backend/editor/hardware/device-link-policy.ts create mode 100644 src/backend/editor/hardware/device-probe.ts create mode 100644 src/backend/editor/hardware/device-session-manager.ts create mode 100644 src/backend/editor/hardware/device-transport-factory.ts create mode 100644 src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts create mode 100644 src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts create mode 100644 src/backend/shared/simulator/__tests__/plc-control-e2e.test.ts create mode 100644 src/frontend/components/_molecules/device-connect-button/__tests__/device-connect-button.test.tsx create mode 100644 src/frontend/components/_molecules/device-connect-button/index.tsx create mode 100644 src/frontend/hooks/__tests__/use-device-connect.test.ts create mode 100644 src/frontend/hooks/__tests__/use-device-connection-monitor.test.ts create mode 100644 src/frontend/hooks/__tests__/use-device-plc-state.test.ts create mode 100644 src/frontend/hooks/use-device-connect.ts create mode 100644 src/frontend/hooks/use-device-connection-monitor.ts create mode 100644 src/frontend/hooks/use-device-plc-state.ts create mode 100644 src/frontend/services/__tests__/device-link-resolution.test.ts create mode 100644 src/frontend/services/device-link-resolution.ts create mode 100644 src/frontend/utils/__tests__/serial-port-label.test.ts create mode 100644 src/frontend/utils/device-connect-events.ts create mode 100644 src/frontend/utils/serial-port-label.ts create mode 100644 src/middleware/shared/utils/debug-endpoint.ts diff --git a/resources/sources/Baremetal/Baremetal.ino b/resources/sources/Baremetal/Baremetal.ino index ce3de63b7..8ff5c460c 100644 --- a/resources/sources/Baremetal/Baremetal.ino +++ b/resources/sources/Baremetal/Baremetal.ino @@ -128,6 +128,13 @@ void setup() // Initialize hardware (HAL -- unchanged) hardwareInit(); + // Establish the run/stop state. Must follow hardwareInit() so the HAL has + // already configured its mode-switch pin: a board powered up with the + // switch in STOP must never execute a scan. Boards with no mode switch + // read RUN and start immediately, as they always have. + runtime_init_plc_state(); + + #ifdef MODBUS_ENABLED #ifdef MBSERIAL #ifdef MBSERIAL_ON_SECONDARY diff --git a/resources/sources/Baremetal/modbus_debug.cpp b/resources/sources/Baremetal/modbus_debug.cpp index d6d586285..85a56df5a 100644 --- a/resources/sources/Baremetal/modbus_debug.cpp +++ b/resources/sources/Baremetal/modbus_debug.cpp @@ -10,6 +10,9 @@ Copyright (C) 2022 OpenPLC - Thiago Alves // standard. The shims forward to strucpp::debug::handle_* inside // arduino_runtime_glue.cpp (part of the precompiled OpenPLCUserLib archive). #include "arduino_runtime_glue.h" +// PLC_STATE_* / PLC_SWITCH_* and runtime_get_plc_state(), for the run/stop +// reporting in debugGetStatus() and plcSetState(). +#include "openplc.h" #include "openplc_version.h" // ArduinoUniqueID (ricaun) backs the DEBUG_GET_BOARD_ID (0x48) function code. @@ -339,7 +342,11 @@ void debugGetStatus() mb_frame[1] = MB_FC_DEBUG_GET_STATUS; mb_frame[2] = MB_DEBUG_SUCCESS; - mb_frame[3] = 1; // PLC scan is always running on baremetal + // The real run/stop state, not a constant: the baremetal runtime has a + // state machine now (see arduino_runtime_glue.h). This byte being the + // state is why there is no separate query function code -- the editor's + // status poll already carries it. + mb_frame[3] = runtime_get_plc_state(); mb_frame[4] = (uint8_t)((scan_counter >> 24) & 0xFF); mb_frame[5] = (uint8_t)((scan_counter >> 16) & 0xFF); mb_frame[6] = (uint8_t)((scan_counter >> 8) & 0xFF); @@ -348,7 +355,38 @@ void debugGetStatus() mb_frame[9] = (uint8_t)((uptime >> 16) & 0xFF); mb_frame[10] = (uint8_t)((uptime >> 8) & 0xFF); mb_frame[11] = (uint8_t)(uptime & 0xFF); - mb_frame_len = 12; + // Mode-switch position, appended so the editor can gate a start locally. + // Boards with no physical switch report RUN, so a caller needs no "absent" + // case; an older editor that stops reading at byte 11 simply ignores it. + mb_frame[12] = runtime_get_switch_position(); + mb_frame_len = 13; +} + +// PDU request: [FC][state:u8] (0 = STOP, 1 = RUN) +// PDU response: [FC][status][plc_state:u8][switch_position:u8] +// +// Command only -- reading the state is debugGetStatus() (FC 0x46) above, which +// already reports it. A RUN request while the mode switch reads STOP is +// REFUSED, not queued, so the editor tells the user to flip the switch instead +// of leaving a start pending. Stop requests are always honoured. +// +// The reported state is read back after the request is applied, but the runtime +// derives it inside runtime_plc_cycle() -- so on a change the value here is the +// state as of the last cycle and the caller sees the new one on its next status +// poll (at most one scan period later). +void plcSetState(uint8_t desired) +{ + uint8_t status = MB_DEBUG_SUCCESS; + + const uint8_t target = (desired == 0x01) ? PLC_STATE_RUNNING : PLC_STATE_STOPPED; + if (runtime_request_plc_state(target) == PLC_CTRL_REFUSED_SWITCH_STOP) + status = MB_PLC_CTRL_REFUSED_SWITCH; + + mb_frame[1] = MB_FC_PLC_SET_STATE; + mb_frame[2] = status; + mb_frame[3] = runtime_get_plc_state(); + mb_frame[4] = runtime_get_switch_position(); + mb_frame_len = 5; } // PDU request: [FC] diff --git a/resources/sources/Baremetal/modbus_debug.h b/resources/sources/Baremetal/modbus_debug.h index 1c03df15d..a9cf7d1fc 100644 --- a/resources/sources/Baremetal/modbus_debug.h +++ b/resources/sources/Baremetal/modbus_debug.h @@ -1,11 +1,10 @@ /* -modbus_debug.h - OpenPLC always-on debugger function codes (0x41-0x48) +modbus_debug.h - OpenPLC always-on debugger function codes (0x41-0x48, 0x4B) Copyright (C) 2022 OpenPLC - Thiago Alves The debugger PDU handlers, dispatched from process_mbpacket. Kept ungated: the dispatch in modbus_pdu references them unconditionally (the always-on debugger is -present on every baremetal build). This is the growth home for future custom FCs -(e.g. the 0x49-0x4C licensing set). +present on every baremetal build). This is the growth home for future custom FCs. */ #ifndef MODBUS_DEBUG_H @@ -25,5 +24,8 @@ void debugGetMd5(void *endianness); void debugGetStatus(void); void debugGetVersion(void); void debugGetBoardId(void); +// FC 0x4B -- set the runtime run/stop state. Command only; the state is read +// back through debugGetStatus (FC 0x46), which reports it. +void plcSetState(uint8_t desired); #endif diff --git a/resources/sources/Baremetal/modbus_pdu.cpp b/resources/sources/Baremetal/modbus_pdu.cpp index ba1c963d2..c3a480676 100644 --- a/resources/sources/Baremetal/modbus_pdu.cpp +++ b/resources/sources/Baremetal/modbus_pdu.cpp @@ -41,6 +41,8 @@ int32_t mb_pdu_request_len(const uint8_t *f, uint16_t n) case MB_FC_DEBUG_GET_VERSION: case MB_FC_DEBUG_GET_BOARD_ID: return 4; // [id][fc][crc:2] + case MB_FC_PLC_SET_STATE: + return 5; // [id][fc][state:1][crc:2] default: return -1; // not one of our function codes } @@ -176,6 +178,12 @@ void process_mbpacket() debugGetBoardId(); break; + case MB_FC_PLC_SET_STATE: + // PDU: [FC:1][state:u8] (0 = STOP, 1 = RUN) + plcSetState(mb_frame[2]); + break; + + default: exceptionResponse(fcode, MB_EX_ILLEGAL_FUNCTION); } diff --git a/resources/sources/Baremetal/modbus_tcp.cpp b/resources/sources/Baremetal/modbus_tcp.cpp index 453267469..ec9e88afd 100644 --- a/resources/sources/Baremetal/modbus_tcp.cpp +++ b/resources/sources/Baremetal/modbus_tcp.cpp @@ -161,7 +161,29 @@ void handle_tcp() mb_frame_len = mb_mbap[4] << 8 | mb_mbap[5]; if (mb_mbap[2] !=0 || mb_mbap[3] !=0) return; //Not a MODBUSIP packet - if (mb_frame_len < 6 || mb_frame_len > MAX_MB_FRAME) return; //Packet is too small or too big + // Smallest legal frame is [unit][fc] = 2. The old floor of 6 was + // the minimum for a standard DATA request ([unit][fc][addr:2] + // [qty:2]), so it silently dropped any request SHORTER than that + // before process_mbpacket() ever saw it: + // + // 0x41 debug-info len 2 dropped + // 0x46 status len 2 dropped (run/stop state + switch) + // 0x47 version len 2 dropped + // 0x48 board id len 2 dropped (Connect's verification) + // 0x4b run/stop len 3 dropped (the Stop/Run button) + // 0x44 get-list len 4+3n passed + // 0x45 md5 len 6 passed + // + // Which is why this went unnoticed for so long: a debug SESSION + // only uses 0x44 and 0x45, so debugging over Modbus TCP worked + // fine, while Connect and run/stop over TCP could never work. The + // floor predates the split of the ModbusSlave monolith (it was in + // there twice, verbatim) and was harmless until function codes + // with no payload were introduced. + // + // Per-FC shape is validated in process_mbpacket(); over TCP the + // MBAP length is authoritative, there being no CRC to check. + if (mb_frame_len < 2 || mb_frame_len > MAX_MB_FRAME) return; //Packet is too small or too big j = 0; while (mb_serverClients[i].available()) @@ -212,7 +234,29 @@ void handle_tcp() mb_frame_len = mb_mbap[4] << 8 | mb_mbap[5]; if (mb_mbap[2] !=0 || mb_mbap[3] !=0) return; //Not a MODBUSIP packet - if (mb_frame_len < 6 || mb_frame_len > MAX_MB_FRAME) return; //Packet is too small or too big + // Smallest legal frame is [unit][fc] = 2. The old floor of 6 was + // the minimum for a standard DATA request ([unit][fc][addr:2] + // [qty:2]), so it silently dropped any request SHORTER than that + // before process_mbpacket() ever saw it: + // + // 0x41 debug-info len 2 dropped + // 0x46 status len 2 dropped (run/stop state + switch) + // 0x47 version len 2 dropped + // 0x48 board id len 2 dropped (Connect's verification) + // 0x4b run/stop len 3 dropped (the Stop/Run button) + // 0x44 get-list len 4+3n passed + // 0x45 md5 len 6 passed + // + // Which is why this went unnoticed for so long: a debug SESSION + // only uses 0x44 and 0x45, so debugging over Modbus TCP worked + // fine, while Connect and run/stop over TCP could never work. The + // floor predates the split of the ModbusSlave monolith (it was in + // there twice, verbatim) and was harmless until function codes + // with no payload were introduced. + // + // Per-FC shape is validated in process_mbpacket(); over TCP the + // MBAP length is authoritative, there being no CRC to check. + if (mb_frame_len < 2 || mb_frame_len > MAX_MB_FRAME) return; //Packet is too small or too big i = 0; while (client.available()) diff --git a/resources/sources/Baremetal/modbus_types.h b/resources/sources/Baremetal/modbus_types.h index f75e3af8e..6eb6ebd5f 100644 --- a/resources/sources/Baremetal/modbus_types.h +++ b/resources/sources/Baremetal/modbus_types.h @@ -41,6 +41,11 @@ protocol, transport, register and debug layers agree on the same contracts. #define MB_DEBUG_SUCCESS 0x7E #define MB_DEBUG_ERROR_OUT_OF_BOUNDS 0x81 #define MB_DEBUG_ERROR_OUT_OF_MEMORY 0x82 +// MB_FC_PLC_SET_STATE only: a RUN request was refused because the hardware mode +// switch reads STOP. The editor turns this into a "flip the switch to RUN" +// warning rather than a generic failure. It doesn't collide with Modbus +// exceptions (0x01-0x04) nor 0x7E/0x81/0x82. +#define MB_PLC_CTRL_REFUSED_SWITCH 0x86 //Modbus registers struct struct MBinfo { @@ -77,6 +82,7 @@ enum { MB_FC_DEBUG_GET_STATUS = 0x46, // Debug get PLC status (running, scan tick, uptime) MB_FC_DEBUG_GET_VERSION = 0x47, // Debug get runtime firmware version MB_FC_DEBUG_GET_BOARD_ID = 0x48, // Debug get unique hardware board ID + MB_FC_PLC_SET_STATE = 0x4B, // Set the runtime run/stop state }; //Exception Codes diff --git a/resources/sources/arduino/arduino_runtime_glue.cpp b/resources/sources/arduino/arduino_runtime_glue.cpp index 37ed22dae..2967f9d45 100644 --- a/resources/sources/arduino/arduino_runtime_glue.cpp +++ b/resources/sources/arduino/arduino_runtime_glue.cpp @@ -19,6 +19,15 @@ #include "generated.hpp" #include "debug_dispatch.hpp" +// Placement new, used by runtime_reinit_program() to re-run the program's +// initializers over storage that already exists. Available on every target the +// editor builds for, AVR included (the bundled avr-libstdcpp ships , and +// the strucpp headers above already pull it in transitively via ). +// Note this is the PLACEMENT form only -- it allocates nothing. +#include +// std::is_trivially_destructible, for the diagnostic static_assert below. +#include + // --------------------------------------------------------------------------- // Runtime fault hook // --------------------------------------------------------------------------- @@ -46,6 +55,53 @@ static size_t total_programs = 0; unsigned long long base_tick_ns = 20000000ULL; uint32_t scan_counter = 0; +// --------------------------------------------------------------------------- +// Run/stop state. See the contract comment in arduino_runtime_glue.h. +// +// `software_stop` is the latch set by runtime_request_plc_state(); `plc_state` +// is derived from it plus the switch every cycle, so it is never written from +// anywhere but runtime_plc_cycle() / runtime_init_plc_state(). +// --------------------------------------------------------------------------- +static uint8_t plc_state = PLC_STATE_RUNNING; +static uint8_t switch_position = PLC_SWITCH_RUN; +static uint8_t last_switch = PLC_SWITCH_RUN; +static bool software_stop = false; + +// Weak default: boards with no physical mode switch always read RUN, so the +// gate collapses to "software request only" and the boot state is RUNNING -- +// identical to the behaviour before this interface existed. A VPP HAL +// provides a strong extern "C" override. +extern "C" __attribute__((weak)) uint8_t hardwareStateSwitch(void) +{ + return PLC_SWITCH_RUN; +} + +extern "C" uint8_t runtime_get_plc_state(void) +{ + return plc_state; +} + +extern "C" uint8_t runtime_get_switch_position(void) +{ + return switch_position; +} + +extern "C" uint8_t runtime_request_plc_state(uint8_t desired_state) +{ + if (desired_state == PLC_STATE_RUNNING) { + // Hardware is authoritative: refuse rather than queue, so the caller + // can tell the user to flip the switch instead of silently waiting. + if (hardwareStateSwitch() == PLC_SWITCH_STOP) return PLC_CTRL_REFUSED_SWITCH_STOP; + software_stop = false; + return PLC_CTRL_OK; + } + if (desired_state == PLC_STATE_STOPPED) { + software_stop = true; + return PLC_CTRL_OK; + } + return PLC_CTRL_INVALID; +} + // --------------------------------------------------------------------------- // GCD utility — used by discoverTasks for the base-tick computation // --------------------------------------------------------------------------- @@ -235,25 +291,147 @@ void runtime_apply_located_forces() } // --------------------------------------------------------------------------- -// One scan cycle: copy inputs → run scheduled programs → copy outputs → -// advance IEC TIME() so TON/TOF/TP can progress. +// De-energise the output image. +// +// Called every cycle while stopped, immediately before updateOutputBuffers() +// pushes the image to hardware. Two consequences worth keeping in mind: +// +// - A Modbus client writing coils between cycles cannot energise a physical +// output while stopped: its write lands in the image and is zeroed here +// before the HAL ever sees it. +// - Memory areas (int_memory / dint_memory / lint_memory) are deliberately +// NOT cleared. They are not physical outputs. +// +// The image slots alias the located variables' IECVar storage, so this also +// zeroes the program's own %QX / %QW / %QD variables. That is intended: a +// stopped PLC holds no output state. +// --------------------------------------------------------------------------- +static void runtime_zero_output_image() +{ + for (int i = 0; i < MAX_DIGITAL_OUTPUT; ++i) { + if (bool_output[i / 8][i % 8]) *bool_output[i / 8][i % 8] = 0; + } + for (int i = 0; i < MAX_ANALOG_OUTPUT; ++i) { + if (int_output[i]) *int_output[i] = 0; + } +#if !defined(__AVR_ATmega328P__) && !defined(__AVR_ATmega168__) && !defined(__AVR_ATmega32U4__) && !defined(__AVR_ATmega16U4__) + for (int i = 0; i < MAX_REAL_OUTPUT; ++i) { + if (real_output[i]) *real_output[i] = 0.0f; + } +#endif +} + +// --------------------------------------------------------------------------- +// Cold-stop the program: re-run every IEC initial value so the next start +// begins at cycle 1 rather than resuming mid-flight. +// +// NO DYNAMIC ALLOCATION. g_config is a file-scope object with static storage +// duration (.bss/.data), and placement new constructs into that existing +// storage — it calls neither malloc nor operator new(size_t). Everything the +// generated Configuration holds is by value and fixed size, and nothing in +// the strucpp runtime allocates (IECVar is three value members; IEC_STRING is +// a fixed char array). +// +// Every pointer into g_config survives, because placement new reuses the same +// storage with the same layout: locatedVars[i].pointer, the image-table slots, +// the ProgramBase* entries cached in all_programs[] and in the configuration's +// own task_programs_storage[], and the flash-resident Entry tables in +// generated_debug.cpp that hold raw void* into g_config members. +// +// runtime_discover_tasks() is deliberately NOT re-run: it new[]-allocates +// all_programs / task_divisors, so calling it twice would leak. The tables it +// built stay correct. +// +// Two documented consequences: debugger forces are cleared (force state lives +// inside each IECVar), and a program using the explicit IEC NEW operator must +// DELETE before stopping or it leaks across restarts — nothing frees those +// allocations automatically, at re-init or otherwise. +// --------------------------------------------------------------------------- +static void runtime_reinit_program() +{ + // Destroy then re-construct in place. The destructor call matters: + // Configuration_CONFIG0 derives from strucpp::ConfigurationInstance, which + // declares `virtual ~ConfigurationInstance() = default` (iec_std_lib.hpp), + // so the type is NOT trivially destructible even though it owns nothing. + // Pairing the destructor with the placement new is correct either way -- + // for a defaulted virtual destructor it compiles to nothing, and if a + // future strucpp change adds a genuinely owning member it runs that + // member's cleanup instead of leaking it. Neither call allocates. + g_config.~Configuration_CONFIG0(); + new (&g_config) strucpp::Configuration_CONFIG0(); + + runtime_zero_output_image(); + runtime_bind_located_vars(); // idempotent, allocation-free + scan_counter = 0; +} + +// --------------------------------------------------------------------------- +// Establish the initial state. Called once from setup(), after hardwareInit() +// so the HAL's switch pin is already configured. +// --------------------------------------------------------------------------- +void runtime_init_plc_state() +{ + switch_position = hardwareStateSwitch(); + last_switch = switch_position; + software_stop = false; + plc_state = (switch_position == PLC_SWITCH_STOP) ? PLC_STATE_STOPPED : PLC_STATE_RUNNING; +} + +// --------------------------------------------------------------------------- +// One scan cycle: resolve run/stop → copy inputs → run scheduled programs → +// copy outputs → advance IEC TIME() so TON/TOF/TP can progress. +// +// While stopped the loop keeps cycling: inputs are still refreshed (so the +// debugger and Modbus clients see live field data during commissioning), +// outputs stay de-energised, updateOutputBuffers() is still called (so a HAL +// driving a status LED from it stays correct), and IEC time is frozen. // --------------------------------------------------------------------------- void runtime_plc_cycle() { + // 1. Resolve the state from the mode switch and the software latch. + const uint8_t sw = hardwareStateSwitch(); + // A physical flip to RUN always puts the PLC in RUN — clearing a software + // stop, so the switch is never overridden by a stale editor command. + if (sw == PLC_SWITCH_RUN && last_switch == PLC_SWITCH_STOP) software_stop = false; + last_switch = sw; + switch_position = sw; + + const uint8_t new_state = + (sw == PLC_SWITCH_STOP || software_stop) ? PLC_STATE_STOPPED : PLC_STATE_RUNNING; + + // Entering STOP is a cold stop: zero the outputs and re-initialise the + // program exactly once, on the transition. + if (new_state == PLC_STATE_STOPPED && plc_state != PLC_STATE_STOPPED) { + runtime_reinit_program(); + } + plc_state = new_state; + + // 2. Inputs, in both states. updateInputBuffers(); // HAL just wrote raw input storage directly — re-impose any forced input. runtime_apply_located_forces(); - for (size_t i = 0; i < total_programs; ++i) { - if (task_divisors[i] == 0 || (scan_counter % task_divisors[i]) == 0) { - all_programs[i]->run(); + if (plc_state == PLC_STATE_RUNNING) { + for (size_t i = 0; i < total_programs; ++i) { + if (task_divisors[i] == 0 || (scan_counter % task_divisors[i]) == 0) { + all_programs[i]->run(); + } } + ++scan_counter; + } else { + // Re-zero every stopped cycle, not just on the transition: a Modbus + // client may have written coils into the image since the last cycle. + runtime_zero_output_image(); } - ++scan_counter; + // 3. Outputs, in both states — zeros while stopped. updateOutputBuffers(); - strucpp::__CURRENT_TIME_NS += (int64_t)base_tick_ns; + // 4. IEC time advances only while running, so TON/TOF/TP resume where + // they left off instead of jumping by the stop duration. + if (plc_state == PLC_STATE_RUNNING) { + strucpp::__CURRENT_TIME_NS += (int64_t)base_tick_ns; + } } // --------------------------------------------------------------------------- diff --git a/resources/sources/arduino/arduino_runtime_glue.h b/resources/sources/arduino/arduino_runtime_glue.h index 0ffe2ca42..1aa1b59e4 100644 --- a/resources/sources/arduino/arduino_runtime_glue.h +++ b/resources/sources/arduino/arduino_runtime_glue.h @@ -39,9 +39,50 @@ extern uint32_t scan_counter; void runtime_bind_located_vars(); void runtime_discover_tasks(); +// Establish the initial run/stop state. Call once from setup() AFTER +// hardwareInit(), so the HAL has already configured its switch pin. Reads +// the mode switch: a board powered up with the switch in STOP never +// executes a scan. +void runtime_init_plc_state(); + // Per-cycle helpers (call once per scan cycle from scheduler()/loop()). void runtime_plc_cycle(); +// --------------------------------------------------------------------------- +// Run/stop control surface. +// +// State is derived every cycle from the mode switch (hardwareStateSwitch(), +// PLC_SWITCH_RUN when no HAL implements it) and a software-request latch set +// through runtime_request_plc_state(): +// +// switch software request state +// ------ ---------------- ----- +// RUN run (default) RUNNING <- every board with no switch +// RUN stop STOPPED +// STOP (ignored) STOPPED <- hardware is authoritative +// +// A STOP -> RUN edge on the switch resets the software request to `run`, so +// a physical flip to RUN always puts the PLC in RUN -- otherwise a +// software-stopped device would sit dead in the RUN position with no local +// way to recover. +// +// runtime_get_plc_state() is declared in openplc.h because HALs call it to +// drive a status LED. +// --------------------------------------------------------------------------- + +// Result codes for runtime_request_plc_state(). +#define PLC_CTRL_OK 0 +#define PLC_CTRL_REFUSED_SWITCH_STOP 1 +#define PLC_CTRL_INVALID 2 + +// Last value read from hardwareStateSwitch() (PLC_SWITCH_*). +uint8_t runtime_get_switch_position(void); + +// Ask for PLC_STATE_RUNNING or PLC_STATE_STOPPED. A request to run while the +// mode switch reads STOP is REFUSED, not queued -- the caller reports that +// to the user rather than retrying. Returns PLC_CTRL_*. +uint8_t runtime_request_plc_state(uint8_t desired_state); + // Re-impose forced located variables' values onto their raw storage. Call // after any code path that writes the image pointers directly (HAL input // refresh, Modbus reverse-copy) so a debugger force is not clobbered. Cheap diff --git a/resources/sources/arduino/openplc.h b/resources/sources/arduino/openplc.h index a9cde6270..eafb41d39 100644 --- a/resources/sources/arduino/openplc.h +++ b/resources/sources/arduino/openplc.h @@ -74,6 +74,20 @@ extern IEC_ULINT *lint_memory[MAX_MEMORY_LWORD]; #endif +/*********************/ +/* Run/stop state */ +/*********************/ + +// Mode-switch positions reported by hardwareStateSwitch(). +#define PLC_SWITCH_STOP 0 +#define PLC_SWITCH_RUN 1 + +// Externally visible runtime states, as reported by runtime_get_plc_state() +// and over Modbus FC 0x49. +#define PLC_STATE_STOPPED 0 +#define PLC_STATE_RUNNING 1 +#define PLC_STATE_ERROR 2 + //Hardware Layer (implemented in arduino.cpp HAL file, compiled as extern "C") #ifdef __cplusplus extern "C" { @@ -81,6 +95,33 @@ extern "C" { void hardwareInit(); void updateInputBuffers(); void updateOutputBuffers(); + +/* ---- Optional: physical mode switch ------------------------------------ + * Weak default in arduino_runtime_glue.cpp returns PLC_SWITCH_RUN, so a HAL + * that does not define this behaves exactly as before this interface + * existed: the runtime boots into RUNNING and the editor has full software + * control. + * + * Override with a strong extern "C" definition in the HAL .cpp -- the same + * mechanism the P1AM HAL already uses for strucpp::iec_runtime_fault. + * + * Called once per scan cycle, in every state, from the scan path. MUST + * return quickly and MUST NOT block. HOW it does so is the HAL's decision: + * a GPIO is cheap enough to read synchronously, while a switch behind a + * slow bus (I2C expander, fieldbus backplane) should be sampled elsewhere + * and returned here from a cached value. The runtime never polls on the + * HAL's behalf and never imposes a sampling period. + * ---------------------------------------------------------------------- */ +uint8_t hardwareStateSwitch(void); + +/* ---- Optional: state indication ---------------------------------------- + * There is no indication callback. The runtime holds the state; a HAL with + * a status LED reads it inside updateOutputBuffers() (which the runtime + * calls every cycle in every state, so the LED is correct from the first + * cycle even on a board that boots into STOP) and drives its own pin. A + * HAL with no LED reads nothing and the runtime never knows the difference. + * ---------------------------------------------------------------------- */ +uint8_t runtime_get_plc_state(void); #ifdef __cplusplus } #endif diff --git a/src/__architecture__/validate.ts b/src/__architecture__/validate.ts index 8070dc4b8..333812800 100644 --- a/src/__architecture__/validate.ts +++ b/src/__architecture__/validate.ts @@ -10,6 +10,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' // --------------------------------------------------------------------------- // Layer definitions @@ -118,7 +119,12 @@ const LAYER_RULES: Record = { // Helpers // --------------------------------------------------------------------------- -const SRC_ROOT = resolve(dirname(new URL(import.meta.url).pathname), '..') +// `new URL(...).pathname` yields a URL-encoded path that, on win32, carries a +// leading slash before the drive letter (/C:/Users/...). Passing that into +// resolve() prepends the current drive, producing a doubled C:\C:\Users\... +// prefix — which made validate:arch fail to even scan the tree. fileURLToPath +// does the file:// -> filesystem conversion correctly on every platform. +const SRC_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') function collectFiles(dir: string, ext: string[]): string[] { const results: string[] = [] @@ -180,36 +186,25 @@ function getLayer(filePath: string): LayerName | null { return null } -/** Extract import/export-from paths from a TypeScript source string */ +/** + * Every `import ... from '...'` / `export ... from '...'` / bare `import '...'`, + * with the line it starts on. + * + * Scans the whole source rather than line by line: a MULTI-LINE import — the + * default once a statement names more than a couple of symbols — puts the + * `import` keyword and the module path on different lines, so a per-line regex + * silently sees neither. That blind spot hid real violations of these very rules, + * which is worse than having no gate, because the gate reported success. + */ function extractImports(source: string): { path: string; line: number }[] { const results: { path: string; line: number }[] = [] - const lines = source.split('\n') - - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - - // Static imports: import ... from '...' - // Re-exports: export ... from '...' - const staticMatch = line.match(/(?:import|export)\s+.*?\s+from\s+['"]([^'"]+)['"]/) - if (staticMatch) { - results.push({ path: staticMatch[1], line: i + 1 }) - continue - } - - // Side-effect imports: import '...' - const sideEffectMatch = line.match(/^\s*import\s+['"]([^'"]+)['"]/) - if (sideEffectMatch) { - results.push({ path: sideEffectMatch[1], line: i + 1 }) - continue - } - - // Dynamic imports: import('...') - const dynamicMatch = line.match(/import\(\s*['"]([^'"]+)['"]\s*\)/) - if (dynamicMatch) { - results.push({ path: dynamicMatch[1], line: i + 1 }) - } + const pattern = /(?:^|\n)\s*(?:import|export)\b[\s\S]*?from\s+['"]([^'"]+)['"]|(?:^|\n)\s*import\s+['"]([^'"]+)['"]/g + let match: RegExpExecArray | null + while ((match = pattern.exec(source)) !== null) { + const path = match[1] ?? match[2] + if (!path) continue + results.push({ path, line: source.slice(0, match.index).split('\n').length }) } - return results } @@ -279,6 +274,31 @@ const KNOWN_EXCEPTIONS: Record = { 'frontend/store/slices/ladder/utils/index.ts': ['components'], // Ladder slice — needs nodesBuilder + defaultCustomNodesStyles for rung creation 'frontend/store/slices/ladder/slice.ts': ['components'], + // Device CONNECT flow (D72) — resolves RTU params from the board debug spec + // via the shared `resolveDebugConnection` resolver, same as the activity bar's + // debugger/post-flash paths. + 'frontend/hooks/use-device-connect.ts': ['backend-shared'], + // Baremetal run/stop mirror — maps the PROTOCOL's run/stop and switch wire + // values (`PlcRuntimeState` / `PlcSwitchPosition`, defined next to the RTU + // client that reads them) onto the store's `PlcStatus` union. Same D72 device + // link as the sibling entry above. The alternative is either duplicating the + // numeric constants in the frontend or hoisting the two enums into + // ports/types.ts; both were judged worse than one documented import. + 'frontend/hooks/use-device-plc-state.ts': ['backend-shared'], + // Run/stop control port — `PlcControlResult` is the FC 0x4b acknowledgement + // shape, defined with the protocol types it is built from (`PlcRuntimeState`). + // Type-only import; hoisting it into ports/types.ts would drag the wire enums + // along with it, so the contract stays where the protocol is described. + 'middleware/shared/ports/debugger-port.ts': ['backend-shared'], + // Device connect/debug resolution — interprets the board's declarative `debug` + // spec (backend/shared/hardware/debug-spec.ts), which is the ONE place that spec + // is read. The alternative is a second interpreter in the frontend, which is how + // Connect and the debugger came to disagree about what a spec meant. + 'frontend/services/device-link-resolution.ts': ['backend-shared'], + // Activity bar — resolves the same spec for the post-upload reconnect and the + // debug session. Pre-existing; it was invisible until `extractImports` learned + // to read multi-line imports. + 'frontend/components/_organisms/workspace-activity-bar/default.tsx': ['backend-shared'], // PLCopen export — needs the shared XmlGenerator composing function // (backend/shared/utils/PLC/xml-generator.ts) to turn the converted // project data into XML before handing it to the platform port. No diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index a086d6eb4..99ccebb69 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -2510,6 +2510,33 @@ class CompilerModule { }) } } + // An arduino-cli target CANNOT work without its HAL: `hardwareInit` / + // `updateInputBuffers` / `updateOutputBuffers` have no other definition, + // so the build either dies at link with an undefined-reference wall or — + // if anything ever weak-defines them — silently produces firmware that + // drives no I/O at all. Both were observed as "the program runs but the + // outputs never move", with the real cause (the board resolved without a + // HAL, e.g. a VPP whose manifest didn't load) reported only as a warning + // several hundred log lines earlier. + // + // Fail here instead, naming the board and the path, so the message says + // what is wrong rather than what it broke. + // Runtime v3 legitimately has no HAL (its on-device MatIEC compiles the + // ST itself and it never links Arduino firmware), so this only applies to + // targets that actually build a sketch. + if (!boardHalContent && !isRuntimeV3) { + const where = boardInfo.halSourceFile + ? `its HAL source could not be read from ${boardInfo.halSourceFile}` + : 'it did not resolve a HAL source file (a VPP package may have failed to load — try reinstalling it)' + _mainProcessPort.postMessage({ + logLevel: 'error', + message: + `Board "${boardTarget}" cannot be compiled: ${where}. ` + + 'Without a HAL the firmware has no hardware I/O layer.\nStopping compilation process.', + }) + _mainProcessPort.close() + return + } // Re-key strucpp runtime headers from // `strucpp_runtime/include/X` into `src/X` so arduino-cli's // `--library src` pass finds them; also drop the board HAL diff --git a/src/backend/editor/hardware/__tests__/device-link-policy.test.ts b/src/backend/editor/hardware/__tests__/device-link-policy.test.ts new file mode 100644 index 000000000..643f01df9 --- /dev/null +++ b/src/backend/editor/hardware/__tests__/device-link-policy.test.ts @@ -0,0 +1,129 @@ +/** + * The held link's counting rules — what a user feels as "it recovered by itself" + * vs "it gave up too early", and the only part of the connection manager that can + * be checked without a cable to pull. + */ +import { DeviceLinkPolicy } from '../device-link-policy' + +/** Production shape: 2 silent polls enter recovery, 2 failed reopens give up. */ +const newPolicy = () => new DeviceLinkPolicy(2, 2) + +const enterRecovery = () => { + const policy = newPolicy() + policy.onProbeResult('unresponsive') + policy.onProbeResult('unresponsive') + return policy +} + +describe('DeviceLinkPolicy', () => { + describe('a vanished endpoint fails immediately', () => { + it('does not spend the failure budget first', () => { + // A pulled USB cable is not a slow device. There is nothing to retry + // against, so the user hears about it on the very first tick. + const policy = newPolicy() + expect(policy.onProbeResult('gone')).toBe('fail-now') + expect(policy.recovering).toBe(false) + }) + + it('fails immediately even mid-recovery', () => { + // Recovering from noise, and then the port disappears outright: stop + // retrying and say so. + const policy = enterRecovery() + expect(policy.onProbeResult('gone')).toBe('fail-now') + expect(policy.recovering).toBe(false) + }) + + it('leaves the policy reusable for the next connect', () => { + const policy = newPolicy() + policy.onProbeResult('gone') + expect(policy.attempts).toBe(0) + expect(policy.onProbeResult('alive')).toBe('continue') + }) + }) + + describe('while healthy', () => { + it('stays healthy as long as the device answers', () => { + const policy = newPolicy() + for (let i = 0; i < 50; i++) expect(policy.onProbeResult('alive')).toBe('continue') + expect(policy.recovering).toBe(false) + }) + + it('tolerates a single silent poll', () => { + // Reopening a serial port resets an AVR board, so one dropped frame must + // not restart the user's program. + const policy = newPolicy() + expect(policy.onProbeResult('unresponsive')).toBe('continue') + expect(policy.recovering).toBe(false) + }) + + it('enters recovery on the configured number of consecutive failures', () => { + const policy = newPolicy() + policy.onProbeResult('unresponsive') + expect(policy.onProbeResult('unresponsive')).toBe('enter-recovery') + expect(policy.recovering).toBe(true) + }) + + it('requires the failures to be CONSECUTIVE', () => { + // Alternating silence and answers is a noisy link, not a dead one. + const policy = newPolicy() + for (let i = 0; i < 10; i++) { + expect(policy.onProbeResult('unresponsive')).toBe('continue') + expect(policy.onProbeResult('alive')).toBe('continue') + } + expect(policy.recovering).toBe(false) + }) + }) + + describe('while recovering', () => { + it('gives up quickly rather than retrying for half a minute', () => { + const policy = enterRecovery() + expect(policy.onReopenResult(false)).toBe('retry') + expect(policy.onReopenResult(false)).toBe('give-up') + expect(policy.recovering).toBe(false) + }) + + it('recovers at any point in the window and returns to healthy', () => { + const policy = enterRecovery() + policy.onReopenResult(false) + + expect(policy.onReopenResult(true)).toBe('recovered') + expect(policy.recovering).toBe(false) + expect(policy.attempts).toBe(0) + // Full budget again, not one poll away from another recovery. + expect(policy.onProbeResult('unresponsive')).toBe('continue') + }) + + it('gives a full window to a second outage', () => { + const policy = enterRecovery() + policy.onReopenResult(false) + policy.onReopenResult(true) + + policy.onProbeResult('unresponsive') + expect(policy.onProbeResult('unresponsive')).toBe('enter-recovery') + expect(policy.onReopenResult(false)).toBe('retry') + expect(policy.onReopenResult(false)).toBe('give-up') + }) + + it('counts an attempt that could not even be made', () => { + // No candidate could be built: if that did not count, recovery would spin + // forever and the user would never be told. + const policy = enterRecovery() + policy.onReopenResult(false) + expect(policy.onReopenResult(false)).toBe('give-up') + }) + }) + + describe('reset', () => { + it('returns a recovering policy to healthy', () => { + // A fresh Connect supersedes whatever the previous link was doing. + const policy = enterRecovery() + policy.onReopenResult(false) + + policy.reset() + + expect(policy.recovering).toBe(false) + expect(policy.attempts).toBe(0) + expect(policy.onProbeResult('unresponsive')).toBe('continue') + }) + }) +}) diff --git a/src/backend/editor/hardware/__tests__/device-session-manager.test.ts b/src/backend/editor/hardware/__tests__/device-session-manager.test.ts new file mode 100644 index 000000000..7c964bc5d --- /dev/null +++ b/src/backend/editor/hardware/__tests__/device-session-manager.test.ts @@ -0,0 +1,632 @@ +/** + * The single held connection: candidate fallback, one owner for every command, + * and what happens when the endpoint goes away. + * + * `tick()` is driven directly rather than through the poll timer, so the + * sequences here are the real ones a cable pull produces, without the waiting. + */ +import { + DeviceSessionManager, + type DeviceLinkCandidate, + type DeviceLinkHooks, + type DeviceLinkStatus, +} from '../device-session-manager' +import type { DeviceModbusTransport } from '../../../shared/debug/types' + +/** A client that records open/close and can be made to answer or not. */ +class FakeClient { + connected = false + disconnectCount = 0 + connectCount = 0 + constructor( + private readonly behaviour: { + connectFails?: boolean + answers?: boolean + } = {}, + ) {} + + connect = async (): Promise => { + this.connectCount += 1 + if (this.behaviour.connectFails) throw new Error('cannot open') + this.connected = true + } + + disconnect = (): void => { + this.disconnectCount += 1 + this.connected = false + } + + answers(): boolean { + return this.behaviour.answers !== false + } +} + +const asTransport = (client: FakeClient): DeviceModbusTransport => client as unknown as DeviceModbusTransport + +interface Harness { + manager: DeviceSessionManager + statuses: DeviceLinkStatus[] + /** Serial ports the OS currently reports. Mutate to pull or replug a cable. */ + ports: Set + clients: FakeClient[] + /** Overridable per test. */ + verifyResult: { value: boolean } +} + +function harness(overrides: Partial = {}): Harness { + const statuses: DeviceLinkStatus[] = [] + const ports = new Set(['/dev/ttyUSB0']) + const clients: FakeClient[] = [] + const verifyResult = { value: true } + + const hooks: DeviceLinkHooks = { + verify: async () => verifyResult.value, + probe: async (client) => (client as unknown as FakeClient).answers(), + serialPortPresent: async (port) => ports.has(port), + emit: (status) => statuses.push(status), + ...overrides, + } + + const manager = new DeviceSessionManager(hooks, { + pollIntervalMs: 10_000, + failuresBeforeRecovery: 2, + maxRecoveryAttempts: 2, + }) + return { manager, statuses, ports, clients, verifyResult } +} + +/** Candidate factory that hands out the clients a test prepared, in order. */ +function candidate( + transport: 'rtu' | 'tcp', + descriptor: string, + queue: FakeClient[], + registry: FakeClient[], +): DeviceLinkCandidate { + return { + transport, + descriptor, + create: () => { + const client = queue.shift() ?? new FakeClient() + registry.push(client) + return asTransport(client) + }, + } +} + +afterEach(() => { + jest.useRealTimers() +}) + +describe('DeviceSessionManager', () => { + describe('opening', () => { + it('takes the first candidate that works', async () => { + const h = harness() + const tcp = new FakeClient() + const serial = new FakeClient() + const result = await h.manager.open([ + candidate('tcp', '192.168.0.50', [tcp], h.clients), + candidate('rtu', '/dev/ttyUSB0', [serial], h.clients), + ]) + + expect(result.ok).toBe(true) + if (result.ok) expect(result.transport).toBe('tcp') + // The serial fallback must not have been touched at all. + expect(serial.connectCount).toBe(0) + expect(h.manager.getLink()).toEqual({ transport: 'tcp', descriptor: '192.168.0.50' }) + h.manager.close() + }) + + it('falls back to serial when Modbus TCP cannot connect', async () => { + const h = harness() + const tcp = new FakeClient({ connectFails: true }) + const serial = new FakeClient() + + const result = await h.manager.open([ + candidate('tcp', '192.168.0.50', [tcp], h.clients), + candidate('rtu', '/dev/ttyUSB0', [serial], h.clients), + ]) + + expect(result.ok).toBe(true) + if (result.ok) expect(result.transport).toBe('rtu') + h.manager.close() + }) + + it('falls back when Modbus TCP opens but nothing answers', async () => { + // A socket that connects proves a host, not a PLC. This is the case that + // makes "prefer TCP" safe: an IP that belongs to something else, or a stale + // DHCP address, must not strand the user on a dead link. + const h = harness({ verify: async (client) => (client as unknown as FakeClient).answers() }) + const tcp = new FakeClient({ answers: false }) + const serial = new FakeClient() + + const result = await h.manager.open([ + candidate('tcp', '192.168.0.50', [tcp], h.clients), + candidate('rtu', '/dev/ttyUSB0', [serial], h.clients), + ]) + + expect(result.ok).toBe(true) + if (result.ok) expect(result.transport).toBe('rtu') + // The rejected candidate is closed, not leaked. + expect(tcp.disconnectCount).toBeGreaterThan(0) + h.manager.close() + }) + + it('tells verify whether alternatives remain, so patience is spent last', async () => { + // Measured on a real board: ruling out one Modbus TCP address took 32.5s, + // because the id read is retried for a device that might still be booting. + // That patience belongs to the LAST candidate — with alternatives waiting, a + // stale address must not delay the cable that would have worked. + const seen: Array<{ descriptor: string; isLastCandidate: boolean }> = [] + const h = harness({ + verify: async (_client, candidate, context) => { + seen.push({ descriptor: candidate.descriptor, isLastCandidate: context.isLastCandidate }) + return candidate.transport === 'rtu' + }, + }) + + await h.manager.open([ + candidate('tcp', '192.168.0.50', [new FakeClient()], h.clients), + candidate('rtu', '/dev/ttyUSB0', [new FakeClient()], h.clients), + ]) + + expect(seen).toEqual([ + { descriptor: '192.168.0.50', isLastCandidate: false }, + { descriptor: '/dev/ttyUSB0', isLastCandidate: true }, + ]) + h.manager.close() + }) + + it('treats a sole candidate as the last one', async () => { + const seen: boolean[] = [] + const h = harness({ + verify: async (_client, _candidate, context) => { + seen.push(context.isLastCandidate) + return true + }, + }) + + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [new FakeClient()], h.clients)]) + + expect(seen).toEqual([true]) + h.manager.close() + }) + + it('fails when no candidate works, reporting each attempt', async () => { + const h = harness() + const result = await h.manager.open([ + candidate('tcp', '192.168.0.50', [new FakeClient({ connectFails: true })], h.clients), + candidate('rtu', '/dev/ttyUSB0', [new FakeClient({ connectFails: true })], h.clients), + ]) + + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.attempts).toHaveLength(2) + expect(result.attempts[0]).toMatchObject({ transport: 'tcp', descriptor: '192.168.0.50' }) + expect(result.attempts[1]).toMatchObject({ transport: 'rtu', descriptor: '/dev/ttyUSB0' }) + } + // Nothing is held, and the renderer is told — claiming "connected" without + // a connection is what made later requests time out mysteriously. + expect(h.manager.isConnected()).toBe(false) + expect(h.statuses.at(-1)).toEqual({ status: 'disconnected' }) + }) + + it('skips a serial candidate whose port is not enumerated', async () => { + const h = harness() + h.ports.clear() + const serial = new FakeClient() + + const result = await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [serial], h.clients)]) + + expect(result.ok).toBe(false) + // Not even opened: no connect timeout was waited out. + expect(serial.connectCount).toBe(0) + if (!result.ok) expect(result.attempts[0].error).toContain('not available') + }) + + it('supersedes a previously held link', async () => { + const h = harness() + const first = new FakeClient() + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [first], h.clients)]) + + const second = new FakeClient() + await h.manager.open([candidate('tcp', '192.168.0.50', [second], h.clients)]) + + expect(first.disconnectCount).toBe(1) + expect(h.manager.getLink()).toEqual({ transport: 'tcp', descriptor: '192.168.0.50' }) + h.manager.close() + }) + }) + + describe('one owner for every command', () => { + it('hands the same client to every caller', async () => { + // The whole point: the debugger, run/stop and the poll must share this, not + // open their own. A second socket to an Arduino Modbus TCP server is never + // answered, which is how a stop command died with a bare timeout. + const h = harness() + const tcp = new FakeClient() + await h.manager.open([candidate('tcp', '192.168.0.50', [tcp], h.clients)]) + + expect(h.manager.getClient()).toBe(asTransport(tcp)) + expect(h.manager.getClient()).toBe(h.manager.getClient()) + expect(tcp.connectCount).toBe(1) + h.manager.close() + }) + + it('reports no client while recovering, instead of a dead one', async () => { + const h = harness() + const live = new FakeClient({ answers: false }) + await h.manager.open([candidate('tcp', '192.168.0.50', [live], h.clients)]) + + await h.manager.tick() + await h.manager.tick() + + expect(h.manager.isRecovering()).toBe(true) + expect(h.manager.getClient()).toBeNull() + h.manager.close() + }) + }) + + describe('a pulled serial cable', () => { + it('fails immediately on the first tick, without retrying', async () => { + const h = harness() + const serial = new FakeClient() + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [serial], h.clients)]) + h.statuses.length = 0 + + h.ports.clear() // cable pulled + await h.manager.tick() + + expect(h.manager.isConnected()).toBe(false) + expect(h.manager.isRecovering()).toBe(false) + expect(h.statuses).toEqual([ + { status: 'error', transport: 'rtu', descriptor: '/dev/ttyUSB0', reason: 'lost' }, + ]) + }) + }) + + describe('a device that stops answering', () => { + it('recovers on its own when it comes back', async () => { + const h = harness() + const dying = new FakeClient({ answers: false }) + const revived = new FakeClient() + const cand = { + transport: 'tcp' as const, + descriptor: '192.168.0.50', + create: jest + .fn() + .mockImplementationOnce(() => asTransport(dying)) + .mockImplementation(() => asTransport(revived)), + } + + await h.manager.open([cand]) + h.statuses.length = 0 + + await h.manager.tick() // one silent poll: tolerated + expect(h.manager.isRecovering()).toBe(false) + await h.manager.tick() // second: enter recovery + expect(h.statuses).toEqual([{ status: 'connecting', transport: 'tcp', descriptor: '192.168.0.50' }]) + expect(dying.disconnectCount).toBe(1) + + await h.manager.tick() // reopen attempt succeeds + expect(h.manager.isRecovering()).toBe(false) + expect(h.manager.getClient()).toBe(asTransport(revived)) + expect(h.statuses.at(-1)).toEqual({ + status: 'connected', + transport: 'tcp', + // Shared session: one medium serves both roles, so both are reported as it. + debugTransport: 'tcp', + descriptor: '192.168.0.50', + }) + h.manager.close() + }) + + it('gives up after the retry budget and reports the link lost', async () => { + const h = harness() + const cand = { + transport: 'tcp' as const, + descriptor: '192.168.0.50', + create: () => asTransport(new FakeClient({ answers: false })), + } + await h.manager.open([cand]) + h.statuses.length = 0 + + await h.manager.tick() + await h.manager.tick() // enter recovery + await h.manager.tick() // attempt 1 + expect(h.manager.isRecovering()).toBe(true) + await h.manager.tick() // attempt 2 -> give up + + expect(h.manager.isConnected()).toBe(false) + expect(h.statuses.at(-1)).toEqual({ + status: 'error', + transport: 'tcp', + descriptor: '192.168.0.50', + reason: 'lost', + }) + }) + + it('can come back on the OTHER transport', async () => { + // Seamless across transports: the link was opened from a candidate list, so + // recovery tries the whole list. An ethernet link that drops while the USB + // cable is plugged in comes back over serial. + const h = harness() + const tcp = new FakeClient({ answers: false }) + const serial = new FakeClient() + const candidates = [ + { transport: 'tcp' as const, descriptor: '192.168.0.50', create: () => asTransport(tcp) }, + candidate('rtu', '/dev/ttyUSB0', [serial], h.clients), + ] + + await h.manager.open(candidates) + await h.manager.tick() + await h.manager.tick() // enter recovery + await h.manager.tick() // reopen: tcp still silent, serial answers + + expect(h.manager.getLink()).toEqual({ transport: 'rtu', descriptor: '/dev/ttyUSB0' }) + h.manager.close() + }) + + it('treats a throwing probe as unresponsive rather than crashing the tick', async () => { + const h = harness({ + probe: async () => { + throw new Error('read timeout') + }, + }) + await h.manager.open([candidate('tcp', '192.168.0.50', [new FakeClient()], h.clients)]) + + await expect(h.manager.tick()).resolves.toBeUndefined() + await h.manager.tick() + expect(h.manager.isRecovering()).toBe(true) + h.manager.close() + }) + }) + + describe('a REST-controlled session (Runtime v3/v4)', () => { + it('counts as connected without holding anything open', () => { + // REST is connectionless: there is no socket to hold, poll or recover, so the + // session records the address and routes control operations to it. + const h = harness() + h.manager.openRestSession({ + address: '10.0.0.5', + debugChannel: { transport: 'websocket', descriptor: 'websocket 10.0.0.5', create: () => asTransport(new FakeClient()) }, + }) + + expect(h.manager.isConnected()).toBe(true) + expect(h.manager.getRestAddress()).toBe('10.0.0.5') + expect(h.manager.getClient()).toBeNull() // nothing Modbus to hold + expect(h.statuses.at(-1)).toMatchObject({ status: 'connected', descriptor: '10.0.0.5' }) + h.manager.close() + }) + + it('publishes the DEBUG medium, which is not the control one', async () => { + // The debug poll sizes its batches to the frame budget: a WebSocket takes 500 + // variables per round trip, Modbus TCP 60, RTU 19. A v4 session is controlled + // over REST — no medium there at all — so publishing only the control medium + // left the poller with nothing and it silently used TCP-sized batches. + const h = harness() + h.manager.openRestSession({ + address: '10.0.0.5', + debugChannel: { transport: 'websocket', descriptor: 'websocket 10.0.0.5', create: () => asTransport(new FakeClient()) }, + }) + + expect(h.statuses.at(-1)).toEqual({ + status: 'connected', + debugTransport: 'websocket', + descriptor: '10.0.0.5', + }) + h.manager.close() + }) + + it('leaves its debug channel shut until something asks', async () => { + // Logging in to read logs or start the PLC must not open a debug channel — + // for v3 that would be a second Modbus connection to the same box. + const h = harness() + const debug = new FakeClient() + h.manager.openRestSession({ + address: '10.0.0.5', + debugChannel: { transport: 'tcp', descriptor: 'modbus-tcp 10.0.0.5:502', create: () => asTransport(debug) }, + }) + + expect(h.manager.isDebugShared()).toBe(false) + expect(debug.connectCount).toBe(0) + + await h.manager.acquireDebugChannel('debug session') + expect(debug.connectCount).toBe(1) + + h.manager.releaseDebugChannel('debug session') + expect(debug.disconnectCount).toBe(1) + h.manager.close() + }) + + it('forgets the session on close', () => { + const h = harness() + h.manager.openRestSession({ + address: '10.0.0.5', + debugChannel: { transport: 'websocket', descriptor: 'x', create: () => asTransport(new FakeClient()) }, + }) + + h.manager.close() + + expect(h.manager.isConnected()).toBe(false) + expect(h.manager.getRestAddress()).toBeNull() + expect(h.statuses.at(-1)).toEqual({ status: 'disconnected' }) + }) + + it('is superseded by a device connection', async () => { + // One target at a time: connecting a device replaces a runtime session rather + // than leaving two sessions claiming to be current. + const h = harness() + h.manager.openRestSession({ + address: '10.0.0.5', + debugChannel: { transport: 'websocket', descriptor: 'x', create: () => asTransport(new FakeClient()) }, + }) + + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [new FakeClient()], h.clients)]) + + expect(h.manager.getRestAddress()).toBeNull() + expect(h.manager.getLink()).toEqual({ transport: 'rtu', descriptor: '/dev/ttyUSB0' }) + h.manager.close() + }) + }) + + describe('the debug channel', () => { + it('IS the control channel when one medium serves both', async () => { + // A baremetal board answers control and debug over one connection. Opening a + // second client to it is what an Arduino Modbus TCP server never answers, and + // what the OS refuses on a serial port. + const h = harness() + const only = new FakeClient() + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [only], h.clients)]) + + expect(h.manager.isDebugShared()).toBe(true) + const acquired = await h.manager.acquireDebugChannel('debug session') + expect('client' in acquired && acquired.client).toBe(asTransport(only)) + expect(only.connectCount).toBe(1) + h.manager.close() + }) + + it('releasing a shared channel never closes the connection', async () => { + // Stopping the debugger must not take the connection run/stop and the status + // poll are using. + const h = harness() + const only = new FakeClient() + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [only], h.clients)]) + await h.manager.acquireDebugChannel('debug session') + + h.manager.releaseDebugChannel('debug session') + + expect(only.disconnectCount).toBe(0) + expect(h.manager.isConnected()).toBe(true) + expect(h.manager.getDebugClient()).toBe(asTransport(only)) + h.manager.close() + }) + + it('opens a channel of its own when the debug medium differs', async () => { + // A Runtime v3/v4 shape: control is elsewhere, debug is its own channel, and + // it stays shut until something asks for it. + const h = harness() + const control = new FakeClient() + const debug = new FakeClient() + await h.manager.open([candidate('tcp', '10.0.0.5', [control], h.clients)], { + debugChannel: { transport: 'tcp', descriptor: '10.0.0.5:502', create: () => asTransport(debug) }, + }) + + expect(h.manager.isDebugShared()).toBe(false) + expect(h.manager.getDebugClient()).toBeNull() + expect(debug.connectCount).toBe(0) + + const acquired = await h.manager.acquireDebugChannel('debug session') + expect('client' in acquired).toBe(true) + expect(debug.connectCount).toBe(1) + h.manager.close() + }) + + it('closes its own channel only when the last holder lets go', async () => { + const h = harness() + const debug = new FakeClient() + await h.manager.open([candidate('tcp', '10.0.0.5', [new FakeClient()], h.clients)], { + debugChannel: { transport: 'tcp', descriptor: '10.0.0.5:502', create: () => asTransport(debug) }, + }) + + await h.manager.acquireDebugChannel('debug session') + await h.manager.acquireDebugChannel('license check') + expect(debug.connectCount).toBe(1) // reused, not reopened + + // A license check finishing must not close the channel a live debug session + // is still reading through. + h.manager.releaseDebugChannel('license check') + expect(debug.disconnectCount).toBe(0) + expect(h.manager.getDebugClient()).not.toBeNull() + + h.manager.releaseDebugChannel('debug session') + expect(debug.disconnectCount).toBe(1) + expect(h.manager.getDebugClient()).toBeNull() + h.manager.close() + }) + + it('leaves control connected when its own channel will not open', async () => { + // Independent channels: port 502 firewalled is a debugging problem, not a + // reason to drop a working control connection. + const h = harness() + await h.manager.open([candidate('tcp', '10.0.0.5', [new FakeClient()], h.clients)], { + debugChannel: candidate('tcp', '10.0.0.5:502', [new FakeClient({ connectFails: true })], h.clients), + }) + + const acquired = await h.manager.acquireDebugChannel('debug session') + + expect('error' in acquired).toBe(true) + expect(h.manager.isConnected()).toBe(true) + expect(h.manager.getClient()).not.toBeNull() + h.manager.close() + }) + + it('closes its own channel when the session ends', async () => { + const h = harness() + const debug = new FakeClient() + await h.manager.open([candidate('tcp', '10.0.0.5', [new FakeClient()], h.clients)], { + debugChannel: { transport: 'tcp', descriptor: '10.0.0.5:502', create: () => asTransport(debug) }, + }) + await h.manager.acquireDebugChannel('debug session') + + h.manager.close() + + expect(debug.disconnectCount).toBe(1) + expect(h.manager.getDebugClient()).toBeNull() + }) + + it('refuses to acquire when nothing is connected', async () => { + const h = harness() + expect(await h.manager.acquireDebugChannel('debug session')).toEqual({ error: 'Not connected' }) + }) + }) + + describe('upload handoff', () => { + it('releases a serial link that holds the port being flashed', async () => { + const h = harness() + const serial = new FakeClient() + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [serial], h.clients)]) + + expect(h.manager.releaseSerialPort('/dev/ttyUSB0')).toBe(true) + expect(h.manager.isConnected()).toBe(false) + expect(serial.disconnectCount).toBe(1) + }) + + it('keeps a TCP link across an upload', async () => { + // Flashing over USB does not disturb an ethernet link, so debugging and + // run/stop keep working through an upload. + const h = harness() + await h.manager.open([candidate('tcp', '192.168.0.50', [new FakeClient()], h.clients)]) + + expect(h.manager.releaseSerialPort('/dev/ttyUSB0')).toBe(false) + expect(h.manager.isConnected()).toBe(true) + h.manager.close() + }) + + it('leaves a serial link on a different port alone', async () => { + const h = harness() + h.ports.add('/dev/ttyUSB1') + await h.manager.open([candidate('rtu', '/dev/ttyUSB1', [new FakeClient()], h.clients)]) + + expect(h.manager.releaseSerialPort('/dev/ttyUSB0')).toBe(false) + expect(h.manager.isConnected()).toBe(true) + h.manager.close() + }) + }) + + describe('polling', () => { + it('stops polling once the link is closed', async () => { + jest.useFakeTimers() + const probe = jest.fn().mockResolvedValue(true) + const h = harness({ probe }) + await h.manager.open([candidate('tcp', '192.168.0.50', [new FakeClient()], h.clients)]) + + jest.advanceTimersByTime(30_000) + const callsWhileOpen = probe.mock.calls.length + expect(callsWhileOpen).toBeGreaterThan(0) + + h.manager.close() + jest.advanceTimersByTime(30_000) + expect(probe.mock.calls.length).toBe(callsWhileOpen) + }) + }) +}) diff --git a/src/backend/editor/hardware/__tests__/serial-port-list.test.ts b/src/backend/editor/hardware/__tests__/serial-port-list.test.ts index be8c99782..5c28e34a6 100644 --- a/src/backend/editor/hardware/__tests__/serial-port-list.test.ts +++ b/src/backend/editor/hardware/__tests__/serial-port-list.test.ts @@ -22,28 +22,24 @@ describe('toCalloutPath', () => { }) describe('mergeSerialPortList', () => { - it('labels a port with the arduino-cli board name when identified', () => { + it('reports both descriptors when both scans knew one', () => { + // The merge no longer picks a winner: it reports what each scan found and + // lets `serialPortDisplay` apply the precedence. That split is why the + // renderer can no longer mistake one shape for another. const boards = boardMap([['/dev/cu.usbmodem1', 'Arduino Uno']]) const manufacturers = boardMap([['/dev/cu.usbmodem1', 'Arduino LLC']]) expect(mergeSerialPortList(boards, manufacturers)).toEqual([ - { name: '/dev/cu.usbmodem1 (Arduino Uno)', address: '/dev/cu.usbmodem1' }, + { address: '/dev/cu.usbmodem1', boardName: 'Arduino Uno', manufacturer: 'Arduino LLC' }, ]) }) - it('prefers the board name over the manufacturer when both are present', () => { - const boards = boardMap([['COM1', 'Opta']]) - const manufacturers = boardMap([['COM1', 'Arduino']]) - - expect(mergeSerialPortList(boards, manufacturers)[0].name).toBe('COM1 (Opta)') - }) - it('falls back to the manufacturer when the board is detected but not identified', () => { const boards = boardMap([['COM6', undefined]]) const manufacturers = boardMap([['COM6', 'com0com - serial port emulator']]) expect(mergeSerialPortList(boards, manufacturers)).toEqual([ - { name: 'COM6 (com0com - serial port emulator)', address: 'COM6' }, + { address: 'COM6', manufacturer: 'com0com - serial port emulator' }, ]) }) @@ -51,14 +47,14 @@ describe('mergeSerialPortList', () => { const boards = boardMap([]) const manufacturers = boardMap([['/dev/ttyUSB0', undefined]]) - expect(mergeSerialPortList(boards, manufacturers)).toEqual([{ name: '/dev/ttyUSB0', address: '/dev/ttyUSB0' }]) + expect(mergeSerialPortList(boards, manufacturers)).toEqual([{ address: '/dev/ttyUSB0' }]) }) it('treats an empty-string descriptor as absent', () => { const boards = boardMap([['COM1', '']]) const manufacturers = boardMap([['COM1', '']]) - expect(mergeSerialPortList(boards, manufacturers)).toEqual([{ name: 'COM1', address: 'COM1' }]) + expect(mergeSerialPortList(boards, manufacturers)).toEqual([{ address: 'COM1' }]) }) it('unions both scans, keeps serialport ordering, and dedupes by path', () => { @@ -73,9 +69,9 @@ describe('mergeSerialPortList', () => { ]) expect(mergeSerialPortList(boards, manufacturers)).toEqual([ - { name: 'COM3 (FTDI)', address: 'COM3' }, - { name: 'COM4 (Arduino Mega)', address: 'COM4' }, - { name: 'COM9 (Arduino Nano)', address: 'COM9' }, + { address: 'COM3', manufacturer: 'FTDI' }, + { address: 'COM4', boardName: 'Arduino Mega' }, + { address: 'COM9', boardName: 'Arduino Nano' }, ]) }) @@ -89,7 +85,7 @@ describe('mergeSerialPortList', () => { const boards = boardMap([['/dev/cu.usbmodem11301', 'Opta']]) expect(mergeSerialPortList(boards, manufacturers)).toEqual([ - { name: '/dev/cu.usbmodem11301 (Opta)', address: '/dev/cu.usbmodem11301' }, + { address: '/dev/cu.usbmodem11301', boardName: 'Opta', manufacturer: 'Arduino' }, ]) }) @@ -97,7 +93,7 @@ describe('mergeSerialPortList', () => { const manufacturers = boardMap([['/dev/tty.usbserial-99', 'FTDI']]) expect(mergeSerialPortList(boardMap([]), manufacturers)).toEqual([ - { name: '/dev/cu.usbserial-99 (FTDI)', address: '/dev/cu.usbserial-99' }, + { address: '/dev/cu.usbserial-99', manufacturer: 'FTDI' }, ]) }) @@ -117,10 +113,10 @@ describe('mergeSerialPortList', () => { ]) expect(mergeSerialPortList(boards, manufacturers)).toEqual([ - { name: '/dev/cu.debug-console', address: '/dev/cu.debug-console' }, - { name: '/dev/cu.Bluetooth-Incoming-Port', address: '/dev/cu.Bluetooth-Incoming-Port' }, - { name: '/dev/cu.usbserial-1140 (Prolific Technology Inc.)', address: '/dev/cu.usbserial-1140' }, - { name: '/dev/cu.usbmodem11301 (Opta)', address: '/dev/cu.usbmodem11301' }, + { address: '/dev/cu.debug-console' }, + { address: '/dev/cu.Bluetooth-Incoming-Port' }, + { address: '/dev/cu.usbserial-1140', manufacturer: 'Prolific Technology Inc.' }, + { address: '/dev/cu.usbmodem11301', boardName: 'Opta', manufacturer: 'Arduino' }, ]) }) @@ -132,8 +128,8 @@ describe('mergeSerialPortList', () => { const boards = boardMap([['/dev/ttyACM0', 'Arduino Uno']]) expect(mergeSerialPortList(boards, manufacturers)).toEqual([ - { name: '/dev/ttyUSB0 (FTDI)', address: '/dev/ttyUSB0' }, - { name: '/dev/ttyACM0 (Arduino Uno)', address: '/dev/ttyACM0' }, + { address: '/dev/ttyUSB0', manufacturer: 'FTDI' }, + { address: '/dev/ttyACM0', boardName: 'Arduino Uno' }, ]) }) }) diff --git a/src/backend/editor/hardware/device-link-policy.ts b/src/backend/editor/hardware/device-link-policy.ts new file mode 100644 index 000000000..d3cde5d7a --- /dev/null +++ b/src/backend/editor/hardware/device-link-policy.ts @@ -0,0 +1,125 @@ +/** + * When is a held device link down, coming back, or gone for good? + * + * The I/O around the link (open a port or socket, read the status frame, close a + * dead handle) lives in `DeviceSessionManager`. What lives HERE is only the + * counting, because that is where the off-by-ones hide and it is the one part + * that can be tested without a cable to pull. + * + * Two states: + * + * healthy - polling a live client. Consecutive silent polls accumulate; + * `failuresBeforeRecovery` of them enter recovery. Any answer + * resets the count, so one dropped frame is not a dropped link. + * recovering - the link is down and reopens are attempted, one per tick. A + * reopen that answers restores the link; `maxRecoveryAttempts` + * failures declare it lost. + * + * Two things set the pace, and they pull in opposite directions: + * + * - Failing fast is good. A link that is definitely gone should say so at once, + * not after half a minute of pointless retries. + * - Reopening is NOT free. Opening a serial port asserts DTR, which resets an + * AVR board — so a trigger-happy reconnect would restart the user's PLC + * program over a single dropped frame. (Native-USB parts like the SAMD in a + * P1AM do not reset, but the policy cannot know which board it is talking to.) + * + * Hence: a `gone` verdict — the serial port is no longer enumerated, so there is + * nothing to reset and nothing to wait for — fails IMMEDIATELY, bypassing the + * budget entirely. An `unresponsive` verdict, which may be noise, spends the + * budget first and only then reopens. + */ + +/** What a single probe of the held link concluded. */ +export type LinkProbeVerdict = + /** Answered. */ + | 'alive' + /** Open but silent: timed out, bad reply, or an unexplained error. */ + | 'unresponsive' + /** The endpoint itself is no longer there (serial port vanished from the OS). */ + | 'gone' + +/** What the caller should do after reporting a probe. */ +export type ProbeDecision = + /** Healthy, or not yet past the failure budget — keep polling. */ + | 'continue' + /** Link is down: drop the dead client, keep the link, start reopening. */ + | 'enter-recovery' + /** Endpoint is gone: tear down and tell the user now. No retries. */ + | 'fail-now' + +/** What the caller should do after reporting a reopen attempt. */ +export type ReopenDecision = + /** Not back yet, attempts remain — try again next tick. */ + | 'retry' + /** Back: adopt the fresh client and report connected. */ + | 'recovered' + /** Out of attempts: tear down and tell the user. */ + | 'give-up' + +export class DeviceLinkPolicy { + private consecutiveFailures = 0 + private recoveryAttempts = 0 + private inRecovery = false + + constructor( + private readonly failuresBeforeRecovery: number, + private readonly maxRecoveryAttempts: number, + ) {} + + /** True while reopens are being attempted rather than the client polled. */ + get recovering(): boolean { + return this.inRecovery + } + + /** Attempts made in the current recovery window (0 when healthy). */ + get attempts(): number { + return this.recoveryAttempts + } + + /** Back to a freshly connected, healthy link. */ + reset(): void { + this.consecutiveFailures = 0 + this.recoveryAttempts = 0 + this.inRecovery = false + } + + /** Report what this tick's probe of the held client concluded. */ + onProbeResult(verdict: LinkProbeVerdict): ProbeDecision { + if (verdict === 'alive') { + this.consecutiveFailures = 0 + return 'continue' + } + if (verdict === 'gone') { + // Nothing to retry against and nothing to reset: the endpoint is not there. + this.reset() + return 'fail-now' + } + this.consecutiveFailures += 1 + if (this.consecutiveFailures < this.failuresBeforeRecovery) return 'continue' + + this.inRecovery = true + this.recoveryAttempts = 0 + this.consecutiveFailures = 0 + return 'enter-recovery' + } + + /** + * Report whether this tick's reopen produced a link that answers. Counts the + * attempt, so a caller that could not even build a client (no candidates left, + * port gone) must still report `false` — otherwise recovery would retry forever + * and the user would never be told. + */ + onReopenResult(recovered: boolean): ReopenDecision { + this.recoveryAttempts += 1 + if (recovered) { + this.reset() + return 'recovered' + } + if (this.recoveryAttempts >= this.maxRecoveryAttempts) { + this.reset() + return 'give-up' + } + return 'retry' + } +} diff --git a/src/backend/editor/hardware/device-probe.ts b/src/backend/editor/hardware/device-probe.ts new file mode 100644 index 000000000..295658f5d --- /dev/null +++ b/src/backend/editor/hardware/device-probe.ts @@ -0,0 +1,114 @@ +/** + * Connect-time classification of a device link (D72), over an ALREADY-CONNECTED + * `DeviceChannelTransport`: it neither connects nor disconnects — the caller + * holds the client open for the live link, so classification happens over a + * SINGLE port open. + * + * Pure orchestration over the transport, so it is unit-testable with mocks. + * Never throws — failures resolve to a status. + */ +import { getErrorMessage } from '../../../frontend/utils/get-error-message' +import type { DebugBoardIdResult } from '../../shared/debug/types' + +/** Just enough of a channel to open it. */ +type Connectable = { connect(): Promise } + +/** + * Just enough of a channel to classify it. Narrower than + * `DeviceChannelTransport`, where `getBoardId` is optional: a channel that + * cannot answer the board-id read is not one this module can classify. + */ +type BoardIdReadable = { getBoardId(): Promise } + +/** Retry budget for a bounded connect/probe loop. */ +export interface ProbeBudget { + attempts: number + backoffMs: number +} + +/** + * How patiently to wait for a board to answer the id read (0x48). + * + * The generous default exists for ONE situation: a board that has just been + * flashed and is still coming up. Six attempts at a 5s request timeout is ~32s of + * patience, which is right when this is the only endpoint there is and the device + * is expected to appear. + * + * It is wrong when the caller is CHOOSING between endpoints: 32s spent ruling out + * a Modbus TCP address the user is not even using delays the serial connection + * that would have worked. Such callers pass a short budget and move on. + */ +export const PATIENT_BOARD_ID_PROBE: ProbeBudget = { attempts: 6, backoffMs: 500 } +export const QUICK_BOARD_ID_PROBE: ProbeBudget = { attempts: 2, backoffMs: 300 } + +/** + * Connect with a bounded retry/backoff loop. A device flashed over arduino-cli + * serial reboots as the programmer releases the port, so the first connect right + * after an upload frequently races the reboot; retry, rethrowing the last error + * only once every attempt is exhausted. + */ +export async function connectWithRetries(client: Connectable, { attempts, backoffMs }: ProbeBudget): Promise { + let lastError: unknown + for (let attempt = 0; attempt < attempts; attempt++) { + try { + await client.connect() + return + } catch (error) { + lastError = error + if (attempt < attempts - 1) await new Promise((resolve) => setTimeout(resolve, backoffMs)) + } + } + throw lastError +} + +/** + * Read the board id (FC 0x48) with a bounded retry/backoff loop -- a readiness + * probe for the firmware itself (the serial open auto-resets ESP8266/AVR boards). + * A non-empty board id means a firmware answered. + */ +export async function readBoardIdWithRetries( + client: BoardIdReadable, + { attempts, backoffMs }: ProbeBudget, +): Promise<{ success: boolean; boardId?: Uint8Array }> { + let last: { success: boolean; boardId?: Uint8Array } = { success: false } + for (let attempt = 0; attempt < attempts; attempt++) { + const result = await client.getBoardId() + last = { success: result.success, boardId: result.boardId } + if (last.success && !!last.boardId && last.boardId.length > 0) return last + if (attempt < attempts - 1) await new Promise((resolve) => setTimeout(resolve, backoffMs)) + } + return last +} + +/** How a freshly-opened channel classified. */ +export type DeviceProbeStatus = 'connected-with-firmware' | 'no-firmware' | 'no-response' | 'error' + +export interface DeviceProbeOutcome { + status: DeviceProbeStatus + error?: string +} + +/** + * Classify an already-connected candidate: did an OpenPLC firmware answer the + * debug protocol on it? + * + * Only the board-id read decides. A channel that opens but answers nothing — + * a blank board, or an IP that belongs to something else entirely — classifies + * as `no-firmware`, so the caller can fall through to the next candidate rather + * than keeping a link that cannot serve a single command. + */ +export async function classifyDeviceLink( + client: BoardIdReadable, + opts: { boardIdProbe?: ProbeBudget } = {}, +): Promise { + try { + const probe = await readBoardIdWithRetries(client, opts.boardIdProbe ?? PATIENT_BOARD_ID_PROBE) + if (!probe.success || !probe.boardId || probe.boardId.length === 0) { + // Channel opened but nothing spoke the debug protocol -> blank/non-OpenPLC. + return { status: 'no-firmware' } + } + return { status: 'connected-with-firmware' } + } catch (error) { + return { status: 'error', error: getErrorMessage(error) } + } +} diff --git a/src/backend/editor/hardware/device-session-manager.ts b/src/backend/editor/hardware/device-session-manager.ts new file mode 100644 index 000000000..54b0d7133 --- /dev/null +++ b/src/backend/editor/hardware/device-session-manager.ts @@ -0,0 +1,623 @@ +/** + * THE session with a device: what it is reached through, and by whom. + * + * A session has two channel slots — CONTROL (run/stop, status) and DEBUG + * (variables, md5, licensing) — because that is the shape real targets have: + * + * - a baremetal board answers both over ONE Modbus connection, serial or TCP; + * - a Runtime v3/v4 is controlled over REST but debugged over something else + * entirely (Modbus TCP / a WebSocket); + * - the simulator answers both over its in-process virtual serial port. + * + * When both roles share a medium the two slots hold the SAME channel, so nothing + * opens twice and releasing the debug role cannot close the connection out from + * under run/stop. When they differ the debug channel is opened on request and + * closed when the last requester lets go — an independent channel, whose failure + * leaves control untouched. + * + * Whatever the shape, every caller shares what the session holds: the debugger, + * run/stop, the status poll, licensing. + * That single-ownership rule is the point of this module. Before it, three + * places opened their own client for the same device (the debug session, the two + * lazy-reconnect paths, and a transient one per run/stop command), and each had + * its own idea of which transport counted as reusable. A run/stop command with a + * live Modbus TCP session therefore opened a SECOND socket to the board — which + * an Arduino Modbus TCP server, serving one client at a time, never answered, so + * the command failed with a bare timeout while a perfectly good connection sat + * idle. + * + * Transport is a detail here, not a branch. Both Modbus clients implement + * `DeviceModbusTransport`, so this module never asks which one it holds except to + * describe it to the user and to know whether a vanished serial port applies. + * + * What the manager does NOT decide: + * - which candidates to try, or in what order -> the caller resolves those + * from the board's debug spec (Modbus TCP first when the project enables it, + * serial otherwise), so this works for every baremetal target rather than + * any particular board; + * - what "this is really the device" means -> `hooks.verify`, which the + * main process implements as its existing classify + license recover; + * - the counting for down / back / lost -> `DeviceLinkPolicy`. + */ +import type { DeviceDebugChannel, DeviceModbusTransport } from '../../shared/debug/types' +import { DeviceLinkPolicy } from './device-link-policy' + +export type DeviceLinkTransport = 'rtu' | 'tcp' | 'simulator' + +/** + * How to open a DEBUG channel that is not the control channel. Simpler than a + * control candidate: there is nothing to choose between and nothing to classify — + * the control side already established what this target is. + */ +export interface DeviceDebugCandidate { + /** + * Medium this channel rides. Published with the session status because the debug + * poll sizes its batches to the frame budget (a WebSocket swallows 500 variables + * per round trip, Modbus TCP 60, RTU 19), and a poller that has to GUESS the + * medium either wastes round trips or overruns a frame. + */ + transport: DeviceLinkTransport | 'websocket' + descriptor: string + create: () => DeviceDebugChannel +} + +/** One way to reach the device, ready to be tried. */ +export interface DeviceLinkCandidate { + transport: DeviceLinkTransport + /** What the user calls this endpoint: "/dev/cu.usbmodem11101", "192.168.0.50". */ + descriptor: string + /** Build an unconnected client for this candidate. */ + create: () => DeviceModbusTransport +} + +/** Live link state, as pushed to the renderer. */ +export interface DeviceLinkStatus { + status: 'disconnected' | 'connecting' | 'connected' | 'error' + /** + * The CONTROL channel's medium. Absent for a REST-controlled session (v3/v4): + * REST holds no connection, so there is no medium to report or lose. + */ + transport?: DeviceLinkTransport + /** + * The DEBUG channel's medium — the same as `transport` when one channel serves + * both roles, and `websocket` (v4) or `tcp` (v3) when it does not. Reported + * separately because these are genuinely two facts: the control medium decides + * what "the connection dropped" means, the debug medium decides the poll's frame + * budget. + */ + debugTransport?: DeviceLinkTransport | 'websocket' + descriptor?: string + /** + * Set only when a link that WAS up died and could not be recovered. The one + * status the user must be told about; every other 'error' came straight out of + * something they just clicked and already has its own dialog. + */ + reason?: 'lost' +} + +export interface DeviceLinkOpenSuccess { + ok: true + transport: DeviceLinkTransport + descriptor: string + client: DeviceModbusTransport +} + +export interface DeviceLinkOpenFailure { + ok: false + /** Every candidate that was tried, with why it did not work. */ + attempts: Array<{ transport: DeviceLinkTransport; descriptor: string; error: string }> +} + +export type DeviceLinkOpenResult = DeviceLinkOpenSuccess | DeviceLinkOpenFailure + +export interface DeviceLinkHooks { + /** + * Is this freshly opened client really a device we can work with? Decides + * whether to keep a candidate or move on to the next one, so a Modbus TCP + * socket that opens but answers nothing correctly falls back to serial. + * + * The main process implements this as its classify + license recover, which is + * why it runs on open only — see `probe` for the per-tick check. + */ + verify: ( + client: DeviceModbusTransport, + candidate: DeviceLinkCandidate, + context: { isLastCandidate: boolean }, + ) => Promise + /** + * Cheap liveness read on the held client, also used to confirm a reopen. Kept + * separate from `verify` so recovery does not re-run licensing every couple of + * seconds for as long as a cable is out. + */ + probe: (client: DeviceModbusTransport) => Promise + /** Is this serial port still enumerated by the OS? */ + serialPortPresent: (port: string) => Promise + /** Report a link state change to the renderer. */ + emit: (status: DeviceLinkStatus) => void + /** + * Diagnostic trace of every decision this manager makes: which candidate was + * tried, how long its connect took, why it was kept or rejected, what each poll + * concluded. Optional, but in practice always supplied — a connection flow that + * spans two transports and a remote board cannot be diagnosed by watching the UI. + */ + log?: (message: string) => void +} + +export interface DeviceLinkTimings { + pollIntervalMs: number + failuresBeforeRecovery: number + maxRecoveryAttempts: number +} + +/** Fail fast, but not so fast that noise reopens a port. See DeviceLinkPolicy. */ +export const DEFAULT_DEVICE_LINK_TIMINGS: DeviceLinkTimings = { + pollIntervalMs: 2500, + failuresBeforeRecovery: 2, + maxRecoveryAttempts: 2, +} + +export class DeviceSessionManager { + /** The control channel's client, and the debug channel's too when shared. */ + private client: DeviceModbusTransport | null = null + private current: DeviceLinkCandidate | null = null + /** + * Debug channel, when it is NOT the control channel: its client (null until + * something asks for it) and how to open it. + */ + private debugClientHeld: DeviceDebugChannel | null = null + private debugCandidate: DeviceDebugCandidate | null = null + /** + * Who currently wants the debug channel, by reason. A set rather than a counter + * so the trace can say who is holding it, and so a double release from one + * caller cannot close a channel another still needs. + */ + private readonly debugHolders = new Set() + /** + * Runtime targets (v3/v4) are CONTROLLED over REST, which is connectionless: + * there is no socket to hold, poll or recover, so the session records the address + * and routes control operations to the HTTP client instead of holding a channel. + * That is why this slot is an address rather than a client — and why polling and + * recovery below apply only to a Modbus control channel. + */ + private restControl: { address: string } | null = null + /** The full list the link was opened from, so recovery can try them all again. */ + private candidates: DeviceLinkCandidate[] = [] + private readonly policy: DeviceLinkPolicy + private timer: ReturnType | null = null + private tickInFlight = false + + constructor( + private readonly hooks: DeviceLinkHooks, + private readonly timings: DeviceLinkTimings = DEFAULT_DEVICE_LINK_TIMINGS, + ) { + this.policy = new DeviceLinkPolicy(timings.failuresBeforeRecovery, timings.maxRecoveryAttempts) + } + + private trace(message: string): void { + this.hooks.log?.(message) + } + + /** + * The CONTROL channel's client, or null when nothing is connected (including + * mid-recovery). Run/stop and the status poll go here. + */ + getClient(): DeviceModbusTransport | null { + return this.client + } + + /** + * The DEBUG channel's client, or null when it is not open. + * + * For a shared session this IS the control client, so it needs no acquiring and + * cannot be closed independently. For a session whose debug medium differs, it + * is null until someone calls `acquireDebugChannel`. + */ + getDebugClient(): DeviceDebugChannel | null { + return this.debugCandidate ? this.debugClientHeld : this.client + } + + /** True when one medium serves both roles, so the slots hold the same channel. */ + isDebugShared(): boolean { + return this.debugCandidate === null + } + + /** + * Open the debug channel if it isn't already, and record `reason` as a holder. + * + * Independent of control on purpose: a debug channel that will not open is + * reported to whoever asked, and the control connection carries on. For a shared + * session there is nothing to open — the answer is the control channel, and the + * session having been established is the only precondition. + */ + async acquireDebugChannel(reason: string): Promise<{ client: DeviceDebugChannel } | { error: string }> { + if (this.debugCandidate === null) { + if (!this.client) return { error: 'Not connected' } + // (A REST session always has a debug candidate, so it never lands here.) + this.debugHolders.add(reason) + return { client: this.client } + } + + if (this.debugClientHeld) { + this.debugHolders.add(reason) + return { client: this.debugClientHeld } + } + + this.trace(`debug channel: opening ${this.debugCandidate.descriptor} for ${reason}`) + let client: DeviceDebugChannel + try { + client = this.debugCandidate.create() + await client.connect() + } catch (error) { + this.trace(`debug channel: could not open — ${describeError(error)} (control connection unaffected)`) + return { error: describeError(error) } + } + this.debugClientHeld = client + this.debugHolders.add(reason) + return { client } + } + + /** + * Let go of the debug channel. It closes only once nothing holds it AND it is a + * channel of its own — releasing a shared one must never take the connection + * that run/stop and the status poll are using. + */ + releaseDebugChannel(reason: string): void { + this.debugHolders.delete(reason) + if (this.debugHolders.size > 0) return + if (this.debugCandidate === null || !this.debugClientHeld) return + this.trace(`debug channel: closing (last holder ${reason} released)`) + this.debugClientHeld.disconnect() + this.debugClientHeld = null + } + + /** Transport + endpoint of the held link, for messages and handoff decisions. */ + getLink(): { transport: DeviceLinkTransport; descriptor: string } | null { + if (!this.current) return null + return { transport: this.current.transport, descriptor: this.current.descriptor } + } + + /** True while the link is down and reopens are being attempted. */ + isRecovering(): boolean { + return this.policy.recovering + } + + isConnected(): boolean { + return this.client !== null || this.restControl !== null + } + + /** + * Open the first candidate that works and hold it. + * + * Candidates are tried IN ORDER and the first one to both connect and verify + * wins; a candidate that connects but fails verification is closed before the + * next is tried, so no stray handles are left behind. If none work the attempt + * fails, reporting what was tried — an editor that claimed "connected" without + * a working connection is what made every later request time out mysteriously. + * + * A fresh open supersedes any held link (reconnect, transport change). + */ + async open( + candidates: DeviceLinkCandidate[], + options: { + /** + * How to reach this target for DEBUG when that is a different medium from + * control (Runtime v3/v4). Omit when one medium serves both — the slots then + * share a channel, which is what keeps a debug session from opening a second + * connection to a device that only answers one. + */ + debugChannel?: DeviceDebugCandidate + } = {}, + ): Promise { + this.close({ silent: true }) + this.debugCandidate = options.debugChannel ?? null + + if (candidates.length === 0) { + this.trace('open: refused, no usable candidate was resolved') + return { ok: false, attempts: [] } + } + + this.candidates = candidates + const attempts: DeviceLinkOpenFailure['attempts'] = [] + this.trace( + `open: ${candidates.length} candidate(s) in order: ${candidates + .map((candidate) => `${candidate.transport} ${candidate.descriptor}`) + .join(', ')}`, + ) + + for (const [index, candidate] of candidates.entries()) { + this.hooks.emit({ status: 'connecting', transport: candidate.transport, descriptor: candidate.descriptor }) + + const startedAt = Date.now() + const outcome = await this.tryCandidate(candidate, { isLastCandidate: index === candidates.length - 1 }) + const elapsed = Date.now() - startedAt + this.trace( + outcome.ok + ? `open: ${candidate.transport} ${candidate.descriptor} ACCEPTED in ${elapsed}ms` + : `open: ${candidate.transport} ${candidate.descriptor} rejected in ${elapsed}ms — ${outcome.error}`, + ) + if (outcome.ok) { + this.client = outcome.client + this.current = candidate + this.policy.reset() + this.startPolling() + this.hooks.emit({ + status: 'connected', + transport: candidate.transport, + // Shared unless the caller supplied a separate debug channel. + debugTransport: this.debugCandidate?.transport ?? candidate.transport, + descriptor: candidate.descriptor, + }) + return { ok: true, transport: candidate.transport, descriptor: candidate.descriptor, client: outcome.client } + } + attempts.push({ transport: candidate.transport, descriptor: candidate.descriptor, error: outcome.error }) + } + + this.candidates = [] + this.trace('open: FAILED, no candidate answered') + this.hooks.emit({ status: 'disconnected' }) + return { ok: false, attempts } + } + + /** + * Establish a session with a target CONTROLLED over REST (Runtime v3/v4). + * + * Nothing is opened here. REST needs no connection, and the debug channel is + * deliberately left shut until something asks for it — a user who logs in to look + * at logs or start the PLC should not be made to hold a debug channel open, and + * for v3 that channel is a second Modbus connection to the same box. + */ + openRestSession(options: { address: string; debugChannel: DeviceDebugCandidate }): void { + this.close({ silent: true }) + this.restControl = { address: options.address } + this.debugCandidate = options.debugChannel + this.trace(`session: control over REST at ${options.address}, debug via ${options.debugChannel.descriptor}`) + this.hooks.emit({ + status: 'connected', + // No control transport: REST holds nothing. The debug medium is what the + // debugger will actually ride, and what its poll must be sized for. + debugTransport: options.debugChannel.transport, + descriptor: options.address, + }) + } + + /** The REST address when control runs over REST, else null. */ + getRestAddress(): string | null { + return this.restControl?.address ?? null + } + + /** Open + verify a single candidate, leaving nothing open on failure. */ + private async tryCandidate( + candidate: DeviceLinkCandidate, + context: { isLastCandidate: boolean }, + ): Promise<{ ok: true; client: DeviceModbusTransport } | { ok: false; error: string }> { + // A serial candidate whose port is not even enumerated cannot be opened: + // say so instead of waiting out a connect timeout. + if (candidate.transport === 'rtu' && !(await this.hooks.serialPortPresent(candidate.descriptor))) { + this.trace(` ${candidate.descriptor}: serial port is not enumerated, skipping`) + return { ok: false, error: `${candidate.descriptor} is not available` } + } + + let client: DeviceModbusTransport + try { + client = candidate.create() + } catch (error) { + return { ok: false, error: describeError(error) } + } + + const connectStartedAt = Date.now() + try { + await client.connect() + this.trace(` ${candidate.descriptor}: transport opened in ${Date.now() - connectStartedAt}ms`) + } catch (error) { + client.disconnect() + this.trace(` ${candidate.descriptor}: transport would not open after ${Date.now() - connectStartedAt}ms`) + return { ok: false, error: describeError(error) } + } + + // Opening proves an endpoint, not a PLC. A Modbus TCP socket to something + // that is not an OpenPLC target connects instantly and then answers nothing, + // so this is the step that decides whether to keep the candidate. + const verifyStartedAt = Date.now() + try { + if (await this.hooks.verify(client, candidate, context)) { + this.trace(` ${candidate.descriptor}: answered the debug protocol in ${Date.now() - verifyStartedAt}ms`) + return { ok: true, client } + } + client.disconnect() + this.trace( + ` ${candidate.descriptor}: opened but did NOT answer the debug protocol (waited ${Date.now() - verifyStartedAt}ms)`, + ) + return { ok: false, error: 'No OpenPLC firmware answered' } + } catch (error) { + client.disconnect() + this.trace(` ${candidate.descriptor}: verification threw after ${Date.now() - verifyStartedAt}ms`) + return { ok: false, error: describeError(error) } + } + } + + /** + * Close the held link. `silent` skips the renderer notification, for the case + * where a new open is about to report its own state. + */ + close(options: { silent?: boolean } = {}): void { + this.stopPolling() + this.policy.reset() + this.debugHolders.clear() + if (this.debugClientHeld) { + this.debugClientHeld.disconnect() + this.debugClientHeld = null + } + this.debugCandidate = null + const hadRest = this.restControl !== null + this.restControl = null + const had = this.client !== null || hadRest + if (had) this.trace(`close: dropping ${this.current?.transport ?? (hadRest ? 'rest' : '?')} session`) + this.dropClient() + this.current = null + this.candidates = [] + if (had && !options.silent) this.hooks.emit({ status: 'disconnected' }) + } + + /** + * Give up the link if it holds `port` — the handoff before an upload takes the + * same serial port. Returns whether anything was released, so the caller knows + * whether to reconnect afterwards. + * + * A link running over Modbus TCP is untouched: flashing over USB does not + * disturb it, so debugging and run/stop keep working across an upload. + */ + releaseSerialPort(port: string | null | undefined): boolean { + if (!this.current || this.current.transport !== 'rtu') { + this.trace(`release ${String(port)}: nothing to release (held: ${this.current?.transport ?? 'none'})`) + return false + } + if (port !== undefined && port !== null && this.current.descriptor !== String(port)) { + this.trace(`release ${String(port)}: held connection is on ${this.current.descriptor}, leaving it alone`) + return false + } + this.trace(`release ${this.current.descriptor}: handing the port over for an upload`) + this.close() + return true + } + + private dropClient(): void { + this.client?.disconnect() + this.client = null + } + + private startPolling(): void { + this.stopPolling() + this.timer = setInterval(() => { + if (this.tickInFlight) return + this.tickInFlight = true + void this.tick().finally(() => { + this.tickInFlight = false + }) + }, this.timings.pollIntervalMs) + } + + private stopPolling(): void { + if (!this.timer) return + clearInterval(this.timer) + this.timer = null + } + + /** + * One step of the link's lifecycle: probe the held client, or make a single + * reopen attempt while recovering. Public so it can be driven directly in + * tests instead of waiting on a timer. + */ + async tick(): Promise { + if (this.policy.recovering) return this.attemptRecovery() + + const client = this.client + const candidate = this.current + if (!client || !candidate) return + + const verdict = await this.probeVerdict(client, candidate) + const decision = this.policy.onProbeResult(verdict) + if (verdict !== 'alive') { + this.trace(`poll: ${candidate.transport} ${candidate.descriptor} ${verdict} -> ${decision}`) + } + switch (decision) { + case 'enter-recovery': + // Drop the dead handle but KEEP the link: a stale open fd is what makes + // the reopen fail with "cannot lock port", while the candidate list is + // what lets the next ticks bring it back with nothing for the user to do. + this.dropClient() + this.hooks.emit({ status: 'connecting', transport: candidate.transport, descriptor: candidate.descriptor }) + return + case 'fail-now': + return this.declareLost(candidate) + default: + return + } + } + + /** Classify one probe of the held client. */ + private async probeVerdict( + client: DeviceModbusTransport, + candidate: DeviceLinkCandidate, + ): Promise<'alive' | 'unresponsive' | 'gone'> { + // Check the endpoint first: a pulled USB cable is not a slow device, and + // treating it as one would spend the whole failure budget waiting for + // timeouts on a port that no longer exists. + if (candidate.transport === 'rtu' && !(await this.hooks.serialPortPresent(candidate.descriptor))) { + return 'gone' + } + try { + return (await this.hooks.probe(client)) ? 'alive' : 'unresponsive' + } catch { + return 'unresponsive' + } + } + + /** + * One reopen attempt while recovering. Tries the SAME candidate list the link + * was opened from, so a device that comes back on either transport is picked + * up — and a serial port that has not reappeared is skipped without cost. + * + * Verification here is the cheap `probe`, not `verify`: the classification and + * license recover from the original open still stand, and re-running them every + * couple of seconds while a cable is out would hammer the licensing backend. + */ + private async attemptRecovery(): Promise { + const previous = this.current + if (!previous) return + + const reopened = await this.reopen() + this.trace( + `recovery: attempt ${this.policy.attempts + 1} ${reopened ? `restored over ${reopened.candidate.transport}` : 'failed'}`, + ) + + switch (this.policy.onReopenResult(reopened !== null)) { + case 'recovered': + this.client = reopened!.client + this.current = reopened!.candidate + this.hooks.emit({ + status: 'connected', + transport: reopened!.candidate.transport, + debugTransport: this.debugCandidate?.transport ?? reopened!.candidate.transport, + descriptor: reopened!.candidate.descriptor, + }) + return + case 'give-up': + return this.declareLost(previous) + default: + return + } + } + + /** Try every candidate once; return the first that opens and answers. */ + private async reopen(): Promise<{ client: DeviceModbusTransport; candidate: DeviceLinkCandidate } | null> { + for (const candidate of this.candidates) { + if (candidate.transport === 'rtu' && !(await this.hooks.serialPortPresent(candidate.descriptor))) continue + + let client: DeviceModbusTransport + try { + client = candidate.create() + } catch { + continue + } + try { + await client.connect() + if (await this.hooks.probe(client)) return { client, candidate } + } catch { + // Still out, or open but silent — fall through and close it. + } + client.disconnect() + } + return null + } + + private declareLost(candidate: DeviceLinkCandidate): void { + const { transport, descriptor } = candidate + this.trace(`LOST: ${transport} ${descriptor} could not be recovered`) + this.close({ silent: true }) + this.hooks.emit({ status: 'error', transport, descriptor, reason: 'lost' }) + } +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/backend/editor/hardware/device-transport-factory.ts b/src/backend/editor/hardware/device-transport-factory.ts new file mode 100644 index 000000000..56b6ba3e5 --- /dev/null +++ b/src/backend/editor/hardware/device-transport-factory.ts @@ -0,0 +1,107 @@ +/** + * THE place a Modbus client is built from connection params. + * + * There used to be nine: the debug session, two lazy-reconnect paths, a transient + * one per run/stop command, the md5 verify, the license probe, the connect probe. + * Each repeated the same option literals and, worse, each decided on its own which + * transport it would accept — which is how a run/stop command over Modbus TCP came + * to open a second socket instead of using the connection already open. + * + * Building a client is now the only transport-specific step in the whole flow; + * everything downstream talks to `DeviceModbusTransport`. + */ +import type { DeviceModbusTransport } from '../../shared/debug/types' +import { ModbusTcpClient } from '../modbus/modbus-client' +import { ModbusRtuClient } from '../modbus/modbus-rtu-client' + +/** Transports that speak Modbus to a device. `websocket` (runtime v4) does not. */ +export type DeviceModbusTransportKind = 'rtu' | 'tcp' | 'simulator' + +export interface DeviceTransportParams { + connectionType?: string + /** RTU: serial port path. TCP: optional numeric port override. */ + port?: string | number + baudRate?: number + slaveId?: number + /** TCP host. `ipAddress` is accepted as an alias, as the debug specs emit that. */ + host?: string + ipAddress?: string +} + +export interface DeviceTransportOptions { + /** + * Request timeout. The default suits interactive debug traffic; the license + * probe passes a shorter one because it retries while a board is still booting. + */ + timeoutMs?: number + /** + * In-process serial port for the simulator target. Required for + * `connectionType: 'simulator'`, meaningless otherwise. + */ + virtualSerialPort?: ConstructorParameters[0]['serialPort'] +} + +/** Standard Modbus TCP port. */ +const MODBUS_TCP_PORT = 502 +const DEFAULT_TIMEOUT_MS = 5000 + +/** Which Modbus transport do these params describe, if any? */ +export function modbusTransportKind(connectionType: string | undefined): DeviceModbusTransportKind | null { + if (connectionType === 'tcp' || connectionType === 'rtu' || connectionType === 'simulator') return connectionType + // An absent type means serial, matching the license factory's long-standing default. + return connectionType === undefined ? 'rtu' : null +} + +/** + * Build an unconnected Modbus client. Returns `{ error }` rather than throwing + * when the params for the chosen transport are incomplete, so every caller + * surfaces the same message instead of inventing its own. + */ +export function buildDeviceModbusTransport( + params: DeviceTransportParams, + options: DeviceTransportOptions = {}, +): { client: DeviceModbusTransport } | { error: string } { + const timeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + const kind = modbusTransportKind(params.connectionType) + + if (kind === 'simulator') { + if (!options.virtualSerialPort) return { error: 'The simulator transport needs an in-process serial port' } + return { + client: new ModbusRtuClient({ + port: 'simulator', + baudRate: 115200, + slaveId: 1, + timeout, + serialPort: options.virtualSerialPort, + }), + } + } + + if (kind === 'tcp') { + const host = params.host ?? params.ipAddress + if (!host) return { error: 'IP address is required for a Modbus TCP connection' } + return { + client: new ModbusTcpClient({ + host, + port: typeof params.port === 'number' ? params.port : MODBUS_TCP_PORT, + timeout, + }), + } + } + + if (kind === 'rtu') { + if (!params.port || typeof params.port !== 'string') { + return { error: 'A serial port is required for a Modbus RTU connection' } + } + return { + client: new ModbusRtuClient({ + port: params.port, + baudRate: params.baudRate ?? 115200, + slaveId: params.slaveId ?? 1, + timeout, + }), + } + } + + return { error: `Unsupported Modbus transport: ${String(params.connectionType)}` } +} diff --git a/src/backend/editor/hardware/hardware-module.ts b/src/backend/editor/hardware/hardware-module.ts index ea7889e39..882030a38 100644 --- a/src/backend/editor/hardware/hardware-module.ts +++ b/src/backend/editor/hardware/hardware-module.ts @@ -14,7 +14,7 @@ import { PackageManagerModule } from '../package-manager' import { logger } from '../services/logger-service' import { assertPathContained } from '../utils/path-containment' import { orderBoardsByVppGroup } from './order-boards-by-vpp-group' -import { mergeSerialPortList } from './serial-port-list' +import { mergeSerialPortList, toCalloutPath } from './serial-port-list' import type { AvailableBoards, HalsFile, SerialPort } from './types' const execFileAsync = promisify(execFile) @@ -113,6 +113,30 @@ class HardwareModule { return mergeSerialPortList(boardNamesByPath, manufacturersByPath) } + /** + * Is this serial port still attached? + * + * The `serialport` scan only — deliberately NOT `getAvailableSerialPorts()`, + * which also shells out to arduino-cli. This is called on every tick of the + * device link poll to tell a pulled USB cable (fail now, there is nothing to + * retry against) from a device that is merely slow to answer (retry), so it has + * to be instant. + * + * Fails SAFE: if enumeration itself breaks, the port is reported present. A + * false "gone" would tear down a working connection, which is worse than + * waiting out one timeout. + */ + async isSerialPortPresent(address: string): Promise { + try { + const ports = await NodeSerialPort.list() + const wanted = toCalloutPath(address) + return ports.some((port) => toCalloutPath(port.path) === wanted) + } catch (error: unknown) { + logger.error(`Failed to check serial port presence: ${String(error)}`) + return true + } + } + /** * `serialport` enumeration → `path → manufacturer`. This is the reliable, * instant, cross-platform source for the *set* of ports; arduino-cli only diff --git a/src/backend/editor/hardware/serial-port-list.ts b/src/backend/editor/hardware/serial-port-list.ts index 3a925a310..b752d865d 100644 --- a/src/backend/editor/hardware/serial-port-list.ts +++ b/src/backend/editor/hardware/serial-port-list.ts @@ -34,31 +34,26 @@ function toCalloutMap(byPath: Map): Map arduino-cli board name (`undefined` when * the port was detected but no board matched) - * @param manufacturersByPath path → `serialport` manufacturer/vendor string + * @param manufacturersByPath path -> `serialport` manufacturer/vendor string */ export function mergeSerialPortList( boardNamesByPath: Map, @@ -72,11 +67,14 @@ export function mergeSerialPortList( const addresses = new Set([...manufacturers.keys(), ...boardNames.keys()]) return [...addresses].map((address) => { - // Board name (more specific) wins; `||` so an empty descriptor falls through. - const descriptor = boardNames.get(address) || manufacturers.get(address) + // Empty strings are normalised away so the renderer only has to check for + // absence, not for blank-but-present descriptors. + const boardName = boardNames.get(address)?.trim() || undefined + const manufacturer = manufacturers.get(address)?.trim() || undefined return { - name: descriptor ? `${address} (${descriptor})` : address, address, + ...(boardName ? { boardName } : {}), + ...(manufacturer ? { manufacturer } : {}), } }) } diff --git a/src/backend/editor/hardware/types.ts b/src/backend/editor/hardware/types.ts index ba9d0f7b3..d42dd4fbb 100644 --- a/src/backend/editor/hardware/types.ts +++ b/src/backend/editor/hardware/types.ts @@ -4,8 +4,9 @@ import type { DebugSpec } from '../../../middleware/shared/ports/debug-spec-type import type { PlatformOption, TargetCapabilities } from '../../../middleware/shared/ports/types' const SerialPortSchema = z.object({ - name: z.string(), address: z.string(), + boardName: z.string().optional(), + manufacturer: z.string().optional(), }) type SerialPort = z.infer diff --git a/src/backend/editor/modbus/modbus-client.ts b/src/backend/editor/modbus/modbus-client.ts index fba1f2508..2e90011ba 100644 --- a/src/backend/editor/modbus/modbus-client.ts +++ b/src/backend/editor/modbus/modbus-client.ts @@ -1,4 +1,19 @@ -import type { Md5ProbeResult } from '@root/backend/shared/debug/types' +import { + buildGetBoardIdRequest, + buildGetStatusRequest, + buildPlcSetStateRequest, + parseGetBoardIdResponse, + parseGetStatusResponse, + parsePlcSetStateResponse, +} from '@root/backend/shared/debug/modbus-pdu' +import type { + DebugBoardIdResult, + DebugStatusResult, + DeviceModbusTransport, + Md5ProbeResult, + PlcControlResult, +} from '@root/backend/shared/debug/types' +import { PlcRuntimeState } from '@root/backend/shared/simulator/types' import { detectTargetEndian } from '@root/frontend/utils/endian' import { getErrorMessage } from '@root/frontend/utils/get-error-message' import { Socket } from 'net' @@ -12,12 +27,18 @@ export enum ModbusFunctionCode { DEBUG_GET_STATUS = 0x46, DEBUG_GET_VERSION = 0x47, DEBUG_GET_BOARD_ID = 0x48, + /** Set the runtime run/stop state. Reads go through DEBUG_GET_STATUS (0x46), + * which already reports it. */ + PLC_SET_STATE = 0x4b, } export enum ModbusDebugResponse { SUCCESS = 0x7e, ERROR_OUT_OF_BOUNDS = 0x81, ERROR_OUT_OF_MEMORY = 0x82, + /** PLC_SET_STATE only: a RUN request was refused because the hardware mode + * switch reads STOP. */ + REFUSED_BY_SWITCH = 0x86, } interface ModbusTcpClientOptions { @@ -26,7 +47,7 @@ interface ModbusTcpClientOptions { timeout: number } -export class ModbusTcpClient { +export class ModbusTcpClient implements DeviceModbusTransport { private host: string private port: number private timeout: number @@ -349,4 +370,110 @@ export class ModbusTcpClient { return { success: false, error: getErrorMessage(error) } } } + + /** + * FC 0x48 DEBUG_GET_BOARD_ID. Bare `[FC]` PDU (no payload). TCP frame is + * [MBAP:6][FC@7][...], so the pure PDU `[FC][status][id_len:u8][id_bytes...]` + * starts at offset 7 — hand it to the shared parseGetBoardIdResponse rather + * than parsing inline. + */ + async getBoardId(): Promise { + if (!this.socket) { + return { success: false, error: 'Not connected to target' } + } + + const transactionId = this.incrementTransactionId() + const protocolId = 0x0000 + const unitId = 0x00 + // buildGetBoardIdRequest() returns the [FC] PDU; the MBAP frame carries the + // function code + payload, empty for board-id. + const pdu = buildGetBoardIdRequest() + + const pduLength = 1 + pdu.length // unitId + PDU (FC only) + const request = Buffer.alloc(6 + pduLength) + request.writeUInt16BE(transactionId, 0) + request.writeUInt16BE(protocolId, 2) + request.writeUInt16BE(pduLength, 4) + request.writeUInt8(unitId, 6) + Buffer.from(pdu).copy(request as unknown as Uint8Array, 7) + + try { + const data = await this.sendTcpRequest(request) + + if (data.length < 9) { + return { success: false, error: `Invalid response: too short (${data.length} bytes, need at least 9)` } + } + + const responseTransactionId = data.readUInt16BE(0) + if (responseTransactionId !== transactionId) { + return { success: false, error: 'Transaction ID mismatch' } + } + + const pduResponse = Uint8Array.prototype.slice.call(data, 7) + return parseGetBoardIdResponse(pduResponse) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + /** + * Wrap a pure PDU in a Modbus-TCP MBAP header, returning the frame and the + * transaction id to match against the reply. + * + * The older methods in this class build the same six bytes inline; new ones + * use this so the layout lives in one place. Migrating the rest is a + * mechanical follow-up, deliberately not done here. + */ + private buildTcpFrame(pdu: Uint8Array): { request: Buffer; transactionId: number } { + const transactionId = this.incrementTransactionId() + const pduLength = 1 + pdu.length // unitId + PDU + const request = Buffer.alloc(6 + pduLength) + request.writeUInt16BE(transactionId, 0) + request.writeUInt16BE(0x0000, 2) // protocol id + request.writeUInt16BE(pduLength, 4) + request.writeUInt8(0x00, 6) // unit id + Buffer.from(pdu).copy(request as unknown as Uint8Array, 7) + return { request, transactionId } + } + + /** + * FC 0x46 -- runtime status (run/stop state, scan counter, uptime). + * Single read path for run/stop; see the RTU client for the rationale. + */ + async getStatus(): Promise { + if (!this.socket) return { success: false, error: 'Not connected to target' } + try { + const { request, transactionId } = this.buildTcpFrame(buildGetStatusRequest()) + const data = await this.sendTcpRequest(request) + if (data.length < 9) { + return { success: false, error: `Invalid response: too short (${data.length} bytes)` } + } + if (data.readUInt16BE(0) !== transactionId) { + return { success: false, error: 'Transaction ID mismatch' } + } + return parseGetStatusResponse(Uint8Array.prototype.slice.call(data, 7)) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + + /** + * FC 0x4b -- ask the runtime to run or stop. Command only; reads go through + * `getStatus()`. Refused while the mode switch reads STOP. + */ + async setPlcState(state: PlcRuntimeState.RUNNING | PlcRuntimeState.STOPPED): Promise { + if (!this.socket) return { success: false, error: 'Not connected to target' } + try { + const { request, transactionId } = this.buildTcpFrame(buildPlcSetStateRequest(state)) + const data = await this.sendTcpRequest(request) + if (data.length < 8) { + return { success: false, error: `Invalid response: too short (${data.length} bytes)` } + } + if (data.readUInt16BE(0) !== transactionId) { + return { success: false, error: 'Transaction ID mismatch' } + } + return parsePlcSetStateResponse(Uint8Array.prototype.slice.call(data, 7)) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } } diff --git a/src/backend/editor/modbus/modbus-rtu-client.ts b/src/backend/editor/modbus/modbus-rtu-client.ts index 08746ab7a..5a516b79f 100644 --- a/src/backend/editor/modbus/modbus-rtu-client.ts +++ b/src/backend/editor/modbus/modbus-rtu-client.ts @@ -1,6 +1,21 @@ // 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 type { Md5ProbeResult } from '@root/backend/shared/debug/types' +import { + buildGetBoardIdRequest, + buildGetStatusRequest, + buildPlcSetStateRequest, + parseGetBoardIdResponse, + parseGetStatusResponse, + parsePlcSetStateResponse, +} from '@root/backend/shared/debug/modbus-pdu' +import type { + DebugBoardIdResult, + DebugStatusResult, + DeviceModbusTransport, + Md5ProbeResult, + PlcControlResult, +} from '@root/backend/shared/debug/types' +import { PlcRuntimeState } from '@root/backend/shared/simulator/types' import { detectTargetEndian } from '@root/frontend/utils/endian' import { getErrorMessage } from '@root/frontend/utils/get-error-message' import { SerialPort } from 'serialport' @@ -22,7 +37,7 @@ const MD5_REQUEST_RETRY_DELAY_MS = 500 const FRAME_COMPLETE_TIMEOUT_MS = 10 -export class ModbusRtuClient { +export class ModbusRtuClient implements DeviceModbusTransport { private port: string private baudRate: number private slaveId: number @@ -451,4 +466,79 @@ export class ModbusRtuClient { return { success: false, error: getErrorMessage(error) } } } + + /** + * FC 0x48 DEBUG_GET_BOARD_ID. Bare `[FC]` PDU (no payload). Response offsets + * account for the 6-byte TCP-compat padding sendRequestImpl prepends, so the + * pure PDU `[FC][status][id_len:u8][id_bytes...]` starts at offset 7 — hand it + * to the shared parseGetBoardIdResponse rather than parsing inline. + */ + async getBoardId(): Promise { + try { + // buildGetBoardIdRequest() returns the [FC] PDU; assembleRequest writes + // the function code + slaveId itself and expects only the trailing payload + // (empty for board-id), so strip the leading FC byte. + const pdu = buildGetBoardIdRequest() + const payload = Buffer.from(pdu.subarray(1)) + const request = this.assembleRequest(ModbusFunctionCode.DEBUG_GET_BOARD_ID, payload) + const response = await this.sendRequest(request) + + if (response.length < 9) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + } + + const pduResponse = Uint8Array.prototype.slice.call(response, 7) + return parseGetBoardIdResponse(pduResponse) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + /** + * FC 0x46 -- runtime status. Reports the run/stop state, the scan counter and + * uptime in one bare-FC round trip. + * + * This is the single read path for run/stop state: the frame's `running` byte + * carries it, so no second function code is needed. It also doubles as the + * liveness probe for a held link (any successful reply proves the firmware is + * answering), which is why the device liveness poll uses it. + */ + async getStatus(): Promise { + try { + // buildGetStatusRequest() returns the [FC] PDU; assembleRequest writes the + // function code + slaveId itself and expects only the trailing payload + // (empty here), so strip the leading FC byte. + const pdu = buildGetStatusRequest() + const request = this.assembleRequest(ModbusFunctionCode.DEBUG_GET_STATUS, Buffer.from(pdu.subarray(1))) + const response = await this.sendRequest(request) + + if (response.length < 9) { + return { success: false, error: `Invalid response: too short (${response.length} bytes, need at least 9)` } + } + return parseGetStatusResponse(Uint8Array.prototype.slice.call(response, 7)) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } + + /** + * FC 0x4b -- ask the runtime to run or stop. + * + * Command only; reads go through `getStatus()`. A RUN request is refused (not + * queued) while the mode switch reads STOP, and the result says so via + * `refusedBySwitch` so the caller can tell the user to flip the switch. + */ + async setPlcState(state: PlcRuntimeState.RUNNING | PlcRuntimeState.STOPPED): Promise { + try { + const pdu = buildPlcSetStateRequest(state) + const request = this.assembleRequest(ModbusFunctionCode.PLC_SET_STATE, Buffer.from(pdu.subarray(1))) + const response = await this.sendRequest(request) + + if (response.length < 8) { + return { success: false, error: `Invalid response: too short (${response.length} bytes)` } + } + return parsePlcSetStateResponse(Uint8Array.prototype.slice.call(response, 7)) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + } } diff --git a/src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts b/src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts new file mode 100644 index 000000000..23f3adbb5 --- /dev/null +++ b/src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts @@ -0,0 +1,137 @@ +/** + * Run/stop wire-protocol codec tests (FC 0x4b command, FC 0x46 read). + * + * These bytes are the contract between the editor and the baremetal runtime's + * `plcSetState()` / `debugGetStatus()` handlers in modbus_debug.cpp, so the + * layouts are asserted + * byte-for-byte rather than round-tripped through the builder. + */ + +import { buildPlcSetStateRequest, parseGetStatusResponse, parsePlcSetStateResponse } from '../modbus-pdu' +import { ModbusDebugResponse, ModbusFunctionCode, PlcRuntimeState, PlcSwitchPosition } from '../../simulator/types' + +describe('run/stop command builder (FC 0x4b)', () => { + it('builds RUN as [FC][0x01]', () => { + expect(Array.from(buildPlcSetStateRequest(PlcRuntimeState.RUNNING))).toEqual([0x4b, 0x01]) + }) + + it('builds STOP as [FC][0x00]', () => { + expect(Array.from(buildPlcSetStateRequest(PlcRuntimeState.STOPPED))).toEqual([0x4b, 0x00]) + }) + + it('claims 0x4b, clear of every other debug function code', () => { + expect(ModbusFunctionCode.PLC_SET_STATE).toBe(0x4b) + const others = Object.values(ModbusFunctionCode).filter( + (value): value is number => typeof value === 'number' && value !== ModbusFunctionCode.PLC_SET_STATE, + ) + expect(others).not.toContain(ModbusFunctionCode.PLC_SET_STATE) + }) + + it('claims 0x86 for REFUSED_BY_SWITCH, clear of every other status code', () => { + expect(ModbusDebugResponse.REFUSED_BY_SWITCH).toBe(0x86) + const others = Object.values(ModbusDebugResponse).filter( + (value): value is number => typeof value === 'number' && value !== ModbusDebugResponse.REFUSED_BY_SWITCH, + ) + expect(others).not.toContain(ModbusDebugResponse.REFUSED_BY_SWITCH) + }) +}) + +describe('parsePlcSetStateResponse', () => { + const frame = (status: number, state: number, position: number) => + new Uint8Array([ModbusFunctionCode.PLC_SET_STATE, status, state, position]) + + it('parses a running device with the switch in RUN', () => { + const result = parsePlcSetStateResponse( + frame(ModbusDebugResponse.SUCCESS, PlcRuntimeState.RUNNING, PlcSwitchPosition.RUN), + ) + expect(result).toEqual({ + success: true, + state: PlcRuntimeState.RUNNING, + switchPosition: PlcSwitchPosition.RUN, + }) + }) + + it('parses a stopped device with the switch in STOP', () => { + const result = parsePlcSetStateResponse( + frame(ModbusDebugResponse.SUCCESS, PlcRuntimeState.STOPPED, PlcSwitchPosition.STOP), + ) + expect(result.success).toBe(true) + expect(result.state).toBe(PlcRuntimeState.STOPPED) + expect(result.switchPosition).toBe(PlcSwitchPosition.STOP) + expect(result.refusedBySwitch).toBeUndefined() + }) + + it('flags a RUN refused by the hardware switch', () => { + const result = parsePlcSetStateResponse( + frame(ModbusDebugResponse.REFUSED_BY_SWITCH, PlcRuntimeState.STOPPED, PlcSwitchPosition.STOP), + ) + // Not a success, and specifically identified so the editor shows the + // "flip the switch to RUN" warning rather than a generic failure. + expect(result.success).toBe(false) + expect(result.refusedBySwitch).toBe(true) + expect(result.state).toBe(PlcRuntimeState.STOPPED) + expect(result.switchPosition).toBe(PlcSwitchPosition.STOP) + }) + + it('reports ERROR state', () => { + const result = parsePlcSetStateResponse( + frame(ModbusDebugResponse.SUCCESS, PlcRuntimeState.ERROR, PlcSwitchPosition.RUN), + ) + expect(result.state).toBe(PlcRuntimeState.ERROR) + }) + + it('detects old firmware via the Modbus exception form', () => { + // A runtime built before the state machine answers (FC | 0x80). The editor turns this + // into "rebuild and upload", never an error, so field devices don't look + // broken after an editor upgrade. + const result = parsePlcSetStateResponse(new Uint8Array([0x4b + 0x80, 0x01])) + expect(result.unsupported).toBe(true) + expect(result.success).toBe(false) + }) + + it('rejects a mismatched function code', () => { + const result = parsePlcSetStateResponse(new Uint8Array([0x44, 0x7e, 0x01, 0x01])) + expect(result.success).toBe(false) + expect(result.unsupported).toBeUndefined() + expect(result.error).toMatch(/mismatch/i) + }) + + it('rejects a truncated response', () => { + expect(parsePlcSetStateResponse(new Uint8Array([])).success).toBe(false) + expect(parsePlcSetStateResponse(new Uint8Array([0x4b, 0x7e])).success).toBe(false) + }) +}) + +describe('status read carries run/stop state (FC 0x46)', () => { + /** [FC][status][running][tick:u32][uptime:u32][switch] */ + const statusFrame = (running: number, sw?: number) => { + const bytes = [ModbusFunctionCode.DEBUG_GET_STATUS, ModbusDebugResponse.SUCCESS, running, 0, 0, 0, 7, 0, 0, 0, 9] + if (sw !== undefined) bytes.push(sw) + return new Uint8Array(bytes) + } + + it('reports RUNNING plus the switch position', () => { + const r = parseGetStatusResponse(statusFrame(PlcRuntimeState.RUNNING, PlcSwitchPosition.RUN)) + expect(r.success).toBe(true) + expect(r.running).toBe(true) + expect(r.plcState).toBe(PlcRuntimeState.RUNNING) + expect(r.switchPosition).toBe(PlcSwitchPosition.RUN) + expect(r.tick).toBe(7) + expect(r.uptimeMs).toBe(9) + }) + + it('reports STOPPED with the switch in STOP', () => { + const r = parseGetStatusResponse(statusFrame(PlcRuntimeState.STOPPED, PlcSwitchPosition.STOP)) + expect(r.running).toBe(false) + expect(r.plcState).toBe(PlcRuntimeState.STOPPED) + expect(r.switchPosition).toBe(PlcSwitchPosition.STOP) + }) + + it('omits switchPosition on firmware that predates the state machine', () => { + // 11-byte frame: the field simply is not there, which callers read as + // "no switch gating" rather than a guessed RUN. + const r = parseGetStatusResponse(statusFrame(1)) + expect(r.success).toBe(true) + expect(r.switchPosition).toBeUndefined() + }) +}) diff --git a/src/backend/shared/debug/__tests__/modbus-pdu.test.ts b/src/backend/shared/debug/__tests__/modbus-pdu.test.ts index 385afa244..c65770c91 100644 --- a/src/backend/shared/debug/__tests__/modbus-pdu.test.ts +++ b/src/backend/shared/debug/__tests__/modbus-pdu.test.ts @@ -218,7 +218,10 @@ describe('buildGetStatusRequest / parseGetStatusResponse', () => { 0x00, // uptime = 256 ]) const result = parseGetStatusResponse(buf) - expect(result).toEqual({ success: true, running: true, tick: 42, uptimeMs: 256 }) + // `plcState` is the same byte as `running`, surfaced as the tri-state the + // run/stop state machine actually has. An 11-byte frame (this one) carries + // no switch position, so the field is absent. + expect(result).toEqual({ success: true, running: true, plcState: 1, tick: 42, uptimeMs: 256 }) }) it('reports running=false when the flag byte is zero', () => { diff --git a/src/backend/shared/debug/modbus-pdu.ts b/src/backend/shared/debug/modbus-pdu.ts index 5fcd2856b..ed2bb916e 100644 --- a/src/backend/shared/debug/modbus-pdu.ts +++ b/src/backend/shared/debug/modbus-pdu.ts @@ -23,6 +23,9 @@ * getList request: [FC=0x44] [numIndexes: U16BE] [arr0:U8 elem0:U16BE] [arr1:U8 elem1:U16BE] ... * getList response: [FC=0x44] [status] [lastIndex: U16BE] [tick: U32BE] [size: U16BE] [data...] * + * plcSetState request: [FC=0x4b] [state: U8] (0 = STOP, 1 = RUN) + * plcSetState response: [FC=0x4b] [status] [plcState: U8] [switchPosition: U8] + * * set request: [FC=0x42] [arr: U8] [elem: U16BE] [force: U8] [dataLen: U16BE] [value...] * set response: [FC=0x42] [status] * @@ -41,7 +44,7 @@ */ import { detectTargetEndian, type TargetEndian } from '../../../frontend/utils/endian' -import { ModbusDebugResponse, ModbusFunctionCode } from '../simulator/types' +import { ModbusDebugResponse, ModbusFunctionCode, PlcRuntimeState } from '../simulator/types' import type { DebugBoardIdResult, DebugSetResult, @@ -49,6 +52,7 @@ import type { DebugTransportResult, DebugVersionResult, Md5ProbeResult, + PlcControlResult, } from './types' // --------------------------------------------------------------------------- @@ -278,7 +282,14 @@ export function parseSetVariableResponse(data: Uint8Array): DebugSetResult { /** * Parse a status response (FC 0x46). - * Layout: `[FC][status][running:u8][tick:u32BE][uptime:u32BE]` (11 PDU bytes). + * Layout: `[FC][status][running:u8][tick:u32BE][uptime:u32BE][switch:u8]` + * (12 PDU bytes; 11 on firmware predating the run/stop state machine). + * + * This is the ONE read path for run/stop state — `running` was always this + * frame's first payload byte, so reporting the real state there rather than a + * hardcoded 1 costs no extra round trip and needs no second function code. The + * switch position is appended, which older parsers ignore and older firmware + * simply omits. */ export function parseGetStatusResponse(data: Uint8Array): DebugStatusResult { if (data.length < 2) { @@ -300,11 +311,18 @@ export function parseGetStatusResponse(data: Uint8Array): DebugStatusResult { return { success: false, error: `Incomplete status response (${data.length} bytes, expected 11)` } } + const running = readU8(data, 2) return { success: true, - running: readU8(data, 2) !== 0, + running: running !== 0, + // Same byte as `running`, as the tri-state the run/stop machine actually + // has (STOPPED / RUNNING / ERROR). + plcState: running, tick: readU32BE(data, 3), uptimeMs: readU32BE(data, 7), + // Appended by firmware carrying the run/stop state machine; absent on older + // firmware, which callers read as "no switch gating". + ...(data.length >= 12 ? { switchPosition: readU8(data, 11) } : {}), } } @@ -377,3 +395,60 @@ export function parseGetBoardIdResponse(data: Uint8Array): DebugBoardIdResult { export function responseFunctionCode(data: Uint8Array): number | undefined { return data.length > 0 ? readU8(data, 0) : undefined } + +// --------------------------------------------------------------------------- +// FC 0x4b — run/stop command +// +// Command only. Reading the state is `buildGetStatusRequest` / +// `parseGetStatusResponse` (FC 0x46) above, which already reports it. +// --------------------------------------------------------------------------- + +export function buildPlcSetStateRequest(state: PlcRuntimeState.RUNNING | PlcRuntimeState.STOPPED): Uint8Array { + const pdu = alloc(2) + writeU8(pdu, 0, ModbusFunctionCode.PLC_SET_STATE) + writeU8(pdu, 1, state === PlcRuntimeState.RUNNING ? 1 : 0) + return pdu +} + +/** + * Parse a run/stop command acknowledgement. + * + * Three outcomes the caller must tell apart: + * - success: the request was accepted; `state` is as of the last scan. + * - `refusedBySwitch`: a RUN was rejected because the hardware switch reads + * STOP. The editor turns this into the "flip the switch" warning, not an + * error. + * - `unsupported`: the target answered the Modbus exception form (FC | 0x80), + * i.e. firmware built before the run/stop state machine. The editor degrades + * to "rebuild and upload" so field devices never look broken. + */ +export function parsePlcSetStateResponse(data: Uint8Array): PlcControlResult { + if (data.length < 1) { + return { success: false, error: 'Response too short' } + } + + const fc = readU8(data, 0) + if (fc === (ModbusFunctionCode.PLC_SET_STATE as number) + 0x80) { + return { success: false, unsupported: true, error: 'Firmware does not implement run/stop control' } + } + if (fc !== (ModbusFunctionCode.PLC_SET_STATE as number)) { + return { success: false, error: 'Function code mismatch' } + } + if (data.length < 4) { + return { success: false, error: 'Response too short' } + } + + const status = readU8(data, 1) + const result: PlcControlResult = { + success: status === (ModbusDebugResponse.SUCCESS as number), + state: readU8(data, 2), + switchPosition: readU8(data, 3), + } + if (status === (ModbusDebugResponse.REFUSED_BY_SWITCH as number)) { + result.refusedBySwitch = true + result.error = 'Refused: the hardware mode switch is in STOP' + } else if (!result.success) { + result.error = statusError(status) + } + return result +} diff --git a/src/backend/shared/debug/types.ts b/src/backend/shared/debug/types.ts index 05cd678ca..78e934069 100644 --- a/src/backend/shared/debug/types.ts +++ b/src/backend/shared/debug/types.ts @@ -1,3 +1,5 @@ +import type { PlcRuntimeState } from '../simulator/types' + /** * Debug Transport Interface * @@ -33,6 +35,15 @@ export interface DebugStatusResult { running?: boolean tick?: number uptimeMs?: number + /** Run/stop state (0 = STOPPED, 1 = RUNNING, 2 = ERROR). This is the single + * read path for run/stop — there is no separate query FC. `running` above is + * the same information as a boolean, kept for callers that only need + * liveness. Absent on firmware predating the run/stop state machine. */ + plcState?: number + /** Mode-switch position (0 = STOP, 1 = RUN). Boards with no physical switch + * report RUN. Absent on firmware predating the run/stop state machine, which + * callers should read as "no gating". */ + switchPosition?: number error?: string } @@ -82,3 +93,129 @@ export interface DebugTransport { getVariablesList(indexes: number[]): Promise setVariable(index: number, force: boolean, valueBuffer?: Uint8Array): Promise } + +/** + * The channel-level operations every medium offers, independent of the debug + * payload surface: open/close, the board-id read (FC 0x48) that classifies + * whether a firmware is answering at all, and — for baremetal targets — run/stop. + * + * The same PDUs ride serial (ModbusRtuClient), TCP (ModbusTcpClient) and the + * runtime-v4 debug WebSocket (WebSocketDebugTransport), so a connection is + * established and classified identically on every target. + */ +export interface DeviceChannelTransport { + connect(): Promise + disconnect(): void + /** Board-id read (FC 0x48) — the readiness probe that says whether an OpenPLC + * firmware is answering at all. Optional for the same reason as `getStatus`: + * it is a BAREMETAL question. The runtime-v4 WebSocket talks to a target whose + * identity came from the REST login, so it never answers this. */ + getBoardId?(): Promise + /** Runtime status (FC 0x46): run/stop state, mode-switch position, scan + * counter, uptime. Doubles as the liveness probe for a held link — any + * successful reply proves the firmware is answering — so the device liveness + * poll prefers it and gets the run/stop state for free. + * + * Optional because run/stop is a BAREMETAL concern: the Modbus RTU/TCP + * clients implement it, while the runtime-v4 WebSocket transport does not — + * v4 drives run/stop over its REST API, so implementing it there would be + * dead code. */ + getStatus?(): Promise + /** Run/stop command (FC 0x4b). Reads go through `getStatus()`. Optional for + * the same reason as `getStatus`. */ + setPlcState?(state: PlcRuntimeState.RUNNING | PlcRuntimeState.STOPPED): Promise +} + +/** + * What a DEBUG channel must offer, whatever medium it runs over: the channel + * operations plus the debug payload surface. + * + * Deliberately narrower than `DeviceModbusTransport`: it does NOT require + * `getStatus` / `setPlcState`, because those are CONTROL operations and a debug + * channel is not always the control channel. The runtime-v4 WebSocket implements + * exactly this and nothing more — v4 is controlled over REST. + */ +export interface DeviceDebugChannel extends DeviceChannelTransport { + getMd5Hash(): Promise + getVariablesList(indexes: number[]): Promise<{ + success: boolean + tick?: number + lastIndex?: number + /** `Buffer | Uint8Array`: the Node Modbus clients hand back the former, the + * browser-shared WebSocket transport the latter, and TypeScript does not + * treat one as a substitute for the other. */ + data?: Uint8Array | Buffer + error?: string + }> + setVariable(index: number, force: boolean, valueBuffer?: Uint8Array | Buffer): Promise +} + +/** + * The full command surface of a Modbus link to a device: the debug operations + * (`DebugTransport`) plus the channel operations (`DeviceChannelTransport`) + * plus run/stop. + * + * `ModbusRtuClient` and `ModbusTcpClient` are separate classes that differ only + * in framing (RTU: slave id + CRC; TCP: MBAP header). The PDUs they carry, and + * therefore the operations they expose, are identical. Naming that shared + * surface is what lets ONE held connection serve every caller regardless of how + * it was established, instead of each caller picking a client class and opening + * its own connection. + * + * A caller-by-caller choice is exactly what broke run/stop over Modbus TCP: the + * command path recognised only RTU clients as reusable, so with a live TCP link + * it opened a second socket — which an Arduino Modbus TCP server, serving one + * client at a time, never answered. + * + * `getStatus` / `setPlcState` are REQUIRED here, narrowing the optionals on + * `DeviceChannelTransport`: both Modbus clients implement run/stop, and only the + * runtime-v4 WebSocket (a different protocol, driving run/stop over REST) does + * not. + */ +export interface DeviceModbusTransport + extends Omit, + DeviceChannelTransport { + getBoardId(): Promise + getStatus(): Promise + setPlcState(state: PlcRuntimeState.RUNNING | PlcRuntimeState.STOPPED): Promise + /** + * The two payload-carrying operations, restated for the main process. + * + * `DebugTransport` types payloads as `Uint8Array` because it is also + * implemented in the browser-shared layer; the Node Modbus clients hand back + * `Buffer`, which TypeScript does not treat as a substitute for `Uint8Array` + * since @types/node made `Buffer` generic. Everything else — connect, + * disconnect, getMd5Hash — is inherited unchanged. + */ + getVariablesList(indexes: number[]): Promise<{ + success: boolean + tick?: number + lastIndex?: number + data?: Buffer + error?: string + }> + setVariable(index: number, force: boolean, valueBuffer?: Buffer): Promise +} + +/** + * Result of a run/stop command (FC 0x4b `PLC_SET_STATE`). + * + * Reads are NOT done through this — they come from `DebugStatusResult` via + * FC 0x46. This is the command's acknowledgement, which carries the resulting + * state so a caller can react without waiting for the next poll. + */ +export interface PlcControlResult { + success: boolean + /** State as of the target's last scan cycle. The runtime derives the new + * state inside its next cycle, so a caller that needs the settled value + * reads it from the next status poll (at most one scan period later). */ + state?: number + switchPosition?: number + /** A RUN request was refused because the switch reads STOP. Drives the + * "flip the switch to RUN" warning. */ + refusedBySwitch?: boolean + /** Firmware predates the run/stop state machine. Drives an informational + * "rebuild and upload" message instead of an error. */ + unsupported?: boolean + error?: string +} diff --git a/src/backend/shared/debug/websocket-debug-transport.ts b/src/backend/shared/debug/websocket-debug-transport.ts index 5a9e39236..44b62678f 100644 --- a/src/backend/shared/debug/websocket-debug-transport.ts +++ b/src/backend/shared/debug/websocket-debug-transport.ts @@ -31,7 +31,14 @@ import { parseGetMd5Response, parseSetVariableResponse, } from './modbus-pdu' -import type { DebugSetResult, DebugTransport, DebugTransportResult, Md5ProbeResult } from './types' +import type { + DebugBoardIdResult, + DebugSetResult, + DebugTransport, + DebugTransportResult, + DeviceDebugChannel, + Md5ProbeResult, +} from './types' const REQUEST_TIMEOUT_MS = 5000 const CONNECT_TIMEOUT_MS = 5000 @@ -67,7 +74,7 @@ function hexSpacedToBytes(hex: string): Uint8Array { return out } -export class WebSocketDebugTransport implements DebugTransport { +export class WebSocketDebugTransport implements DebugTransport, DeviceDebugChannel { private host: string private port: number private token: string diff --git a/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts b/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts new file mode 100644 index 000000000..783414209 --- /dev/null +++ b/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts @@ -0,0 +1,312 @@ +/** + * The Connect flow and the debugger resolve the SAME debug spec. + * + * `use-device-connect.ts` resolves it (via `resolveDeviceLinkCandidates`) to derive + * the ways it can OPEN a connection, with nothing connected + * yet — that is the whole point of Connect. So a precondition on a baremetal + * board's spec gates Connect as well as the debugger, and Connect can then never + * succeed: it would need a connection to establish one. The user-visible symptom + * was "Select a communication port for this device first" with a port already + * selected, because the resolver returned `error` instead of `config`. + * + * A debugger-only requirement therefore cannot be expressed as a spec + * precondition; it belongs in the debugger entry point. These tests pin both + * halves of that. + */ +import { + resolveDebugConnection, + resolveDeviceLinkCandidates, + type DebugResolverContext, + type DebugSpec, +} from '../debug-spec' + +/** What an Arduino target declares: serial always, TCP when an ethernet shield is + * configured. The ORDER is the capability matrix's, not the resolver's. */ +const ARDUINO_TRANSPORTS = ['modbus-serial', 'modbus-tcp'] as const + +/** Mirrors what `buildUsbResolverContext` builds: nothing is connected. + * `port === undefined` models "no port selected", which is how the store looks + * before the user picks one — the builder omits the key entirely. */ +const disconnectedUsbContext = (port?: string): DebugResolverContext => ({ + state: { + configuration: { deviceBoard: 'AutomationDirect P1AM-100', ...(port !== undefined ? { communicationPort: port } : {}) }, + screens: { modbus_rtu: { enabled: true, rtu_baud_rate: '115200', rtu_slave_id: 1 } }, + runtimeConnection: {}, + promptCache: {}, + }, + capabilities: { runtimeConnected: false, jwtToken: false }, +}) + +/** Shaped like the P1AM package's `debug` block: an RTU channel whose params + * come from the selected port and the Modbus screen. */ +const baremetalSpec: DebugSpec = { + channels: [ + { + label: 'Modbus RTU', + channel: 'rtu', + enabledWhen: { $ref: 'screens.modbus_rtu.enabled' }, + params: { + port: { $ref: 'configuration.communicationPort', required: 'No serial port selected.' }, + baudRate: { $ref: 'screens.modbus_rtu.rtu_baud_rate', default: '115200', as: 'number' }, + slaveId: { $ref: 'screens.modbus_rtu.rtu_slave_id', default: 1, as: 'number' }, + }, + }, + ], +} + +/** A P1AM-shaped spec with BOTH transports declared — the real package shape. + * Which one is eligible is decided by the project's Modbus screens. */ +const bothChannelsSpec: DebugSpec = { + channels: [ + { + label: 'Modbus TCP', + channel: 'tcp', + enabledWhen: { $ref: 'screens.modbus_tcp.enabled' }, + params: { host: { $ref: 'screens.modbus_tcp.tcp_ip' }, port: 502 }, + }, + ...baremetalSpec.channels, + ], +} + +/** Only Modbus TCP enabled, with a serial port selected in the dropdown. */ +const tcpOnlyContext = (): DebugResolverContext => ({ + state: { + configuration: { deviceBoard: 'AutomationDirect P1AM-100', communicationPort: '/dev/cu.usbmodem11101' }, + screens: { + modbus_tcp: { enabled: true, tcp_ip: '192.168.0.50' }, + modbus_rtu: { enabled: false, rtu_baud_rate: '115200', rtu_slave_id: 1 }, + }, + runtimeConnection: {}, + promptCache: {}, + }, + capabilities: { runtimeConnected: false, jwtToken: false }, +}) + +describe('Connect resolves a baremetal debug spec while disconnected', () => { + it('returns an rtu config carrying the selected port', () => { + const result = resolveDebugConnection(baremetalSpec, disconnectedUsbContext('/dev/cu.usbmodem11101'), undefined) + + // `kind: 'config'` + rtu is exactly what use-device-connect requires before + // it will call device.connect(); anything else becomes "Select a + // communication port for this device first". + expect(result.kind).toBe('config') + if (result.kind === 'config') { + expect(result.config.connectionType).toBe('rtu') + expect(String(result.config.connectionParams.port)).toBe('/dev/cu.usbmodem11101') + expect(Number(result.config.connectionParams.baudRate)).toBe(115200) + } + }) + + it('still reports a genuinely missing port, so that message is not lost', () => { + // The store omits `communicationPort` until one is picked; that is what the + // spec's `required` message exists for, and it must survive the fix above. + const result = resolveDebugConnection(baremetalSpec, disconnectedUsbContext(), undefined) + expect(result.kind).toBe('error') + expect(result).toMatchObject({ body: 'No serial port selected.' }) + }) + + it('auto-selects TCP in a Modbus-TCP-only project, which Connect cannot use', () => { + // Second regression, same misleading dialog. A project with ONLY Modbus TCP + // enabled leaves exactly one eligible channel — tcp — so auto-select returns + // a tcp config. Connect opens serial and nothing else, so it rejected that + // config and reported "Select a communication port" with a port selected. + const result = resolveDebugConnection(bothChannelsSpec, tcpOnlyContext(), undefined) + + expect(result.kind).toBe('config') + if (result.kind === 'config') expect(result.config.connectionType).toBe('tcp') + }) + + it('offers BOTH transports, SERIAL first, when the project enables Modbus TCP', () => { + // Serial leads: it is the direct, local path, with no address to be stale and + // nothing to ask the user. Modbus TCP is the remote fallback. + const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS] }) + + expect(result.kind).toBe('candidates') + if (result.kind !== 'candidates') return + expect(result.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['rtu', 'tcp']) + expect(String(result.candidates[0].config.connectionParams.port)).toBe('/dev/cu.usbmodem11101') + }) + + it('offers serial even with Modbus RTU turned off', () => { + // Modbus RTU disabled does not mean serial is unreachable: the always-on + // debugger keeps the serial protocol compiled into every baremetal firmware. + // Requiring `enabledWhen` here is what made Connect refuse a Modbus-TCP-only + // project with "select a communication port" while one was plainly selected. + const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS] }) + if (result.kind !== 'candidates') throw new Error('expected candidates') + expect(result.candidates.some((candidate) => candidate.config.connectionType === 'rtu')).toBe(true) + }) + + it('offers serial ALONE when Modbus TCP is not enabled', () => { + const rtuOnly = resolveDeviceLinkCandidates(bothChannelsSpec, disconnectedUsbContext('/dev/cu.usbmodem11101'), { + transports: [...ARDUINO_TRANSPORTS], + }) + if (rtuOnly.kind !== 'candidates') throw new Error('expected candidates') + expect(rtuOnly.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['rtu']) + }) + + + it('resolves a Runtime v4 target, whose only transport is a WebSocket', () => { + // The regression that broke every v4 target: with eligibility hardcoded to + // serial-then-TCP, a `websocket` channel was never a candidate, so no session + // was opened and every command answered "not connected" on a target the user + // had connected to and uploaded to. Eligibility comes from the TARGET's + // declared transports, so this needs no special case — only the right facts. + const v4Spec: DebugSpec = { + preconditions: ['runtimeConnected', 'jwtToken'], + channels: [ + { + label: 'WebSocket', + channel: 'websocket', + enabledWhen: true, + params: { + ipAddress: { $ref: 'configuration.runtimeIpAddress', required: 'Runtime IP address is not configured.' }, + jwtToken: { $ref: 'runtimeConnection.jwtToken', required: 'JWT token missing.' }, + }, + }, + ], + } + const connectedRuntime: DebugResolverContext = { + state: { + configuration: { deviceBoard: 'OpenPLC Runtime v4', runtimeIpAddress: '192.168.0.42' }, + screens: {}, + runtimeConnection: { connectionStatus: 'connected', jwtToken: 'jwt' }, + promptCache: {}, + }, + capabilities: { runtimeConnected: true, jwtToken: true }, + } + + const result = resolveDeviceLinkCandidates(v4Spec, connectedRuntime, { transports: ['websocket'] }) + + expect(result.kind).toBe('candidates') + if (result.kind !== 'candidates') return + expect(result.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['websocket']) + expect(result.candidates[0].config.connectionParams.jwtToken).toBe('jwt') + }) + + it('resolves a Runtime v3 target over Modbus TCP', () => { + const v3Spec: DebugSpec = { + preconditions: ['runtimeConnected'], + channels: [ + { + label: 'Modbus TCP', + channel: 'tcp', + enabledWhen: true, + params: { ipAddress: { $ref: 'configuration.runtimeIpAddress', required: 'Runtime IP address is not set.' } }, + }, + ], + } + const connectedRuntime: DebugResolverContext = { + state: { + configuration: { deviceBoard: 'OpenPLC Runtime v3', runtimeIpAddress: '192.168.0.9' }, + screens: {}, + runtimeConnection: { connectionStatus: 'connected' }, + promptCache: {}, + }, + capabilities: { runtimeConnected: true, jwtToken: false }, + } + + const result = resolveDeviceLinkCandidates(v3Spec, connectedRuntime, { transports: ['modbus-tcp'] }) + + if (result.kind !== 'candidates') throw new Error('expected candidates') + expect(result.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['tcp']) + }) + + it('ignores a channel the target cannot actually speak', () => { + // A spec may declare more than the target supports; the capability matrix wins. + const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { transports: ['modbus-serial'] }) + + if (result.kind !== 'candidates') throw new Error('expected candidates') + expect(result.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['rtu']) + }) + + it('lets a caller skip a channel it has decided against', () => { + // Channel 0 in this spec is the TCP one. + const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS], skipChannels: [0] }) + if (result.kind !== 'candidates') throw new Error('expected candidates') + expect(result.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['rtu']) + }) + + describe('a DHCP address is asked for LAST, and only if needed', () => { + const dhcpSpec: DebugSpec = { + channels: [ + { + label: 'Modbus TCP', + channel: 'tcp', + enabledWhen: { $ref: 'screens.modbus_tcp.enabled' }, + params: { ipAddress: { $ref: 'screens.modbus_tcp.ip_address' } }, + prompts: [ + { + when: { $ref: 'screens.modbus_tcp.enable_dhcp' }, + field: 'ipAddress', + title: 'Target IP Address', + message: 'Enter the DHCP-assigned address.', + cacheKey: 'lastDhcpIp', + }, + ], + }, + ...baremetalSpec.channels, + ], + } + const dhcpContext = (): DebugResolverContext => { + const context = tcpOnlyContext() + context.state.screens.modbus_tcp = { enabled: true, enable_dhcp: true } + return context + } + + it('sets the DHCP channel aside instead of asking, when prompts are deferred', () => { + // The user's report: with DHCP on, Connect hung on a dialog before trying + // anything. With a cable attached, that question is pure interruption. + const result = resolveDeviceLinkCandidates(dhcpSpec, dhcpContext(), { transports: [...ARDUINO_TRANSPORTS], deferPrompts: true }) + + expect(result.kind).toBe('candidates') + if (result.kind !== 'candidates') return + expect(result.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['rtu']) + expect(result.awaitingInput).toHaveLength(1) + }) + + it('asks once the caller resolves that channel on its own', () => { + // The second pass, run only after everything silent has failed. + const deferred = resolveDeviceLinkCandidates(dhcpSpec, dhcpContext(), { transports: [...ARDUINO_TRANSPORTS], deferPrompts: true }) + if (deferred.kind !== 'candidates') throw new Error('expected candidates') + + const result = resolveDeviceLinkCandidates(dhcpSpec, dhcpContext(), { transports: [...ARDUINO_TRANSPORTS], onlyChannels: deferred.awaitingInput }) + expect(result.kind).toBe('prompt') + }) + + it('reports candidates even when ONLY a prompting channel is eligible', () => { + // No serial port selected and DHCP on: there is nothing to try silently, but + // the attempt must not be reported as impossible — the address dialog is + // exactly what is missing. + const context = dhcpContext() + delete context.state.configuration.communicationPort + const result = resolveDeviceLinkCandidates(dhcpSpec, context, { transports: [...ARDUINO_TRANSPORTS], deferPrompts: true }) + + expect(result.kind).toBe('candidates') + if (result.kind !== 'candidates') return + expect(result.candidates).toHaveLength(0) + expect(result.awaitingInput).toHaveLength(1) + }) + }) + + it('still reports a missing port when serial is the only candidate', () => { + // Candidate resolution must not swallow a channel's own `required` message. + const result = resolveDeviceLinkCandidates(baremetalSpec, disconnectedUsbContext(), { transports: [...ARDUINO_TRANSPORTS] }) + expect(result).toMatchObject({ kind: 'error', body: 'No serial port selected.' }) + }) + + it('reports unsupported when the board declares nothing reachable', () => { + const malformed = {} as unknown as DebugSpec + expect(resolveDeviceLinkCandidates(malformed, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS] }).kind).toBe('unsupported') + expect(resolveDeviceLinkCandidates(undefined, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS] }).kind).toBe('unsupported') + }) + + it('shows why a precondition cannot express a debugger-only requirement', () => { + // Adding ANY precondition to the spec above breaks Connect, because Connect + // resolves this same spec with nothing connected. + const gated: DebugSpec = { ...baremetalSpec, preconditions: ['runtimeConnected'] } + + const result = resolveDebugConnection(gated, disconnectedUsbContext('/dev/cu.usbmodem11101'), undefined) + expect(result.kind).toBe('error') + }) +}) diff --git a/src/backend/shared/hardware/debug-spec.ts b/src/backend/shared/hardware/debug-spec.ts index ba192a973..963dcf24a 100644 --- a/src/backend/shared/hardware/debug-spec.ts +++ b/src/backend/shared/hardware/debug-spec.ts @@ -19,6 +19,7 @@ import type { DebugCondition, DebugParam, DebugRef, DebugSpec } from '../../../middleware/shared/ports/debug-spec-types' import type { DebugConnectionConfig } from '../../../middleware/shared/ports/types' +import type { DebuggerTransport } from '../../../middleware/shared/utils/target-capabilities' // Re-export types so importers have one canonical entry point. The // types themselves live in the ports layer (architecture rule); the @@ -111,6 +112,44 @@ export type DebugResolverOutcome = | { kind: 'error'; title: string; body: string } | { kind: 'unsupported' } +/** + * Which capability transport a declared channel kind belongs to. + * + * `simulator` maps to `modbus-serial` because that is what it is: RTU over the + * emulated serial port the in-process simulator exposes. + */ +const CHANNEL_TRANSPORT: Record = { + rtu: 'modbus-serial', + simulator: 'modbus-serial', + tcp: 'modbus-tcp', + websocket: 'websocket', +} + +/** One resolved way to reach the device, with the channel it came from. */ +export interface DeviceLinkCandidateConfig { + config: DebugConnectionConfig + channelLabel: string + /** Index in `spec.channels`, so a caller can skip this channel on a re-resolve. */ + channelIndex: number +} + +/** + * Outcome of resolving link candidates. Shares `prompt` / `error` / `unsupported` + * with `DebugResolverOutcome` so ONE renderer loop can drive both this and the + * debugger's single-channel resolution. + */ +export type DeviceLinkCandidatesOutcome = + | { + kind: 'candidates' + candidates: DeviceLinkCandidateConfig[] + /** + * Channels that could be tried, but only after asking the user something + * (a DHCP address). Empty unless the caller passed `deferPrompts`. + */ + awaitingInput: number[] + } + | Extract + // --------------------------------------------------------------------------- // Resolver // --------------------------------------------------------------------------- @@ -292,3 +331,128 @@ export function resolveDebugConnection( }, } } + +/** + * Resolve the ordered ways to reach a target — ANY target. + * + * The caller does not pick a medium; it gets candidates in preference order and the + * connection manager tries them until one answers. That makes this the single + * interpreter of a `debug` spec, whatever kind of target declared it: + * + * 1. `rtu` — serial. Always a candidate for a board that declares it, regardless + * of whether Modbus RTU is enabled, because the always-on debugger keeps the + * serial protocol compiled into every baremetal firmware. Preferred because it + * is the direct, local, physically unambiguous path: if a cable is attached, + * that is the device in front of you, with no address to be stale and nothing + * to ask the user. + * 2. `tcp` — Modbus TCP, when enabled. A baremetal board's remote path, and a + * Runtime v3's debug channel. + * 3. `websocket` — a Runtime v4's debug channel. + * 4. `simulator` — in-process. + * + * In practice the sets are disjoint: a baremetal board declares serial and possibly + * Modbus TCP, a runtime declares exactly one network channel, the simulator one + * in-process channel. So the ordering only ever decides anything for a baremetal + * board — but it is expressed once, for every kind, rather than once per caller. + * Resolving a runtime's channel through a serial-and-TCP-only version of this + * function is what left Runtime v4 targets with no session at all. + * + * Order is a preference, not a promise: the manager still verifies each candidate + * before keeping it, so a cable attached to a board with no firmware falls through + * to the next option rather than stranding the user. + * + * A channel needing user input (a DHCP address) is asked for LAST — see + * `deferPrompts` — so a user with a cable attached is never interrupted by a + * question about an address they do not need to know. + * + * If nothing can be built the caller gets the reason the first candidate failed: an + * editor reporting "connected" with nothing connected is what makes every later + * request time out for no visible reason. + */ +export function resolveDeviceLinkCandidates( + spec: DebugSpec | undefined, + context: DebugResolverContext, + options: { + /** + * The target's `debuggerTransports`, in preference order, from its capability + * matrix. Required: which media a target speaks is a fact about the target, and + * the resolver has no business guessing it. + */ + transports: DebuggerTransport[] + /** Channels to leave out — e.g. one the user has declined. */ + skipChannels?: number[] + /** Consider ONLY these channels. Used for the second pass, after a prompt. */ + onlyChannels?: number[] + /** + * Don't ask the user anything: a channel that needs input is left out and + * reported in `awaitingInput` instead of bubbling up as a `prompt`. Lets a + * caller try everything that works silently before interrupting anyone. + */ + deferPrompts?: boolean + }, +): DeviceLinkCandidatesOutcome { + // `channels` arrives from a VPP manifest, so treat it as possibly absent + // rather than trusting the type: a malformed package must produce a dialog, + // not an exception inside a click handler. + const channels = spec?.channels + if (!spec || !channels?.length) return { kind: 'unsupported' } + + const transports = options.transports + const skip = new Set(options.skipChannels ?? []) + const only = options.onlyChannels ? new Set(options.onlyChannels) : null + const included = (index: number): boolean => !skip.has(index) && (only === null || only.has(index)) + + // Order and eligibility come from the TARGET's declared transports, not from any + // list kept here: `debuggerTransports` already says which media a target speaks + // and in what order (`['modbus-serial', 'modbus-tcp']` for an Arduino board, + // `['websocket']` for a Runtime v4, `['modbus-tcp']` for a v3). Honouring that + // declaration is what makes one resolver serve every kind of target — and a + // channel the target cannot actually speak is not a candidate, however the spec + // describes it. + const eligible: number[] = [] + for (const transport of transports) { + channels.forEach((channel, index) => { + if (!included(index) || CHANNEL_TRANSPORT[channel.channel] !== transport) return + // Serial is exempt from `enabledWhen`: "Modbus RTU disabled" does not mean the + // board is unreachable over serial, because the always-on debugger keeps the + // serial protocol compiled in either way. Every other kind must be turned on. + if (channel.channel !== 'rtu' && !evaluateCondition(channel.enabledWhen, context.state)) return + eligible.push(index) + }) + } + + if (eligible.length === 0) { + const message = spec.messages?.noneEnabled + return { + kind: 'error', + title: message?.title ?? 'No Connection Channel', + body: message?.body ?? 'This target declares no channel the editor can connect through.', + } + } + + const candidates: DeviceLinkCandidateConfig[] = [] + const awaitingInput: number[] = [] + let firstFailure: Extract | null = null + + for (const index of eligible) { + const outcome = resolveDebugConnection(spec, context, index) + if (outcome.kind === 'config') { + candidates.push({ config: outcome.config, channelLabel: outcome.channelLabel, channelIndex: index }) + continue + } + if (outcome.kind === 'prompt') { + if (options.deferPrompts) { + awaitingInput.push(index) + continue + } + return outcome + } + // 'pick' cannot occur: every resolve above names its channel by index. + if (outcome.kind === 'error' || outcome.kind === 'unsupported') firstFailure ??= outcome + } + + if (candidates.length === 0 && awaitingInput.length === 0) { + return firstFailure ?? { kind: 'unsupported' } + } + return { kind: 'candidates', candidates, awaitingInput } +} diff --git a/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts b/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts index 56ff8af21..8f5e6edc1 100644 --- a/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts +++ b/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts @@ -574,7 +574,9 @@ describe('ModbusRtuClient', () => { await connectClient() autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_STATUS, statusPayload(1, 42, 256))) const result = await client.getStatus() - expect(result).toEqual({ success: true, running: true, tick: 42, uptimeMs: 256 }) + // `plcState` mirrors `running` as the run/stop machine's tri-state; the + // fixture frame carries no switch byte, so that field stays absent. + expect(result).toEqual({ success: true, running: true, plcState: 1, tick: 42, uptimeMs: 256 }) }) it('reports running=false when the flag byte is zero', async () => { diff --git a/src/backend/shared/simulator/__tests__/plc-control-e2e.test.ts b/src/backend/shared/simulator/__tests__/plc-control-e2e.test.ts new file mode 100644 index 000000000..6b540b7f0 --- /dev/null +++ b/src/backend/shared/simulator/__tests__/plc-control-e2e.test.ts @@ -0,0 +1,254 @@ +/** + * End-to-end validation of the baremetal run/stop state machine over avr8js. + * + * Boots simulator firmware in avr8js and exercises the run/stop wire protocol against it: + * query, stop, output de-energisation, program re-initialisation, restart, and + * that the debug channel survives a stop. + * + * The firmware must be built first, by the editor's own compile pipeline. Same + * gating style as debug-e2e.test.ts: the test skips unless the artefacts are + * pointed at, so CI without an AVR toolchain stays green. + * + * PLC_CONTROL_HEX=/path/to/Baremetal.ino.hex \ + * PLC_CONTROL_DEBUG_MAP=/path/to/debug-map.json \ + * npx jest src/backend/shared/simulator/__tests__/plc-control-e2e.test.ts + * + * The firmware must be built from a program with a `counter : INT := 0` + * variable incremented every scan and a `pulse AT %QX0.0 : BOOL` driven + * unconditionally TRUE -- `counter` proves execution and re-init, `pulse` + * proves the stop clamp. + */ + +import fs from 'node:fs' +import path from 'node:path' + +import { ModbusRtuClient } from '../modbus-rtu-client' +import { SimulatorModule } from '../simulator-module' +import { PlcRuntimeState, PlcSwitchPosition } from '../types' +import { VirtualSerialPort } from '../virtual-serial-port' + +// jsdom polyfill -- matches modbus-rtu-client.test.ts. +if (typeof globalThis.TextDecoder === 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { TextEncoder, TextDecoder } = require('util') + globalThis.TextEncoder = TextEncoder + globalThis.TextDecoder = TextDecoder as typeof globalThis.TextDecoder +} + +const HEX_PATH = process.env.PLC_CONTROL_HEX ?? '' +const MAP_PATH = process.env.PLC_CONTROL_DEBUG_MAP ?? '' +const ENABLED = HEX_PATH !== '' && MAP_PATH !== '' && fs.existsSync(HEX_PATH) && fs.existsSync(MAP_PATH) +const describeIfEnabled: typeof describe = ENABLED ? describe : describe.skip + +// Second firmware, built from the same program but with a HAL that overrides +// `hardwareStateSwitch()` to read STOP for the first 3 seconds of uptime and +// RUN afterwards. Exercises the paths a switchless board can't reach. +const SWITCH_HEX_PATH = process.env.PLC_CONTROL_SWITCH_HEX ?? '' +const SWITCH_ENABLED = SWITCH_HEX_PATH !== '' && fs.existsSync(SWITCH_HEX_PATH) +const describeIfSwitch: typeof describe = SWITCH_ENABLED ? describe : describe.skip + +/** Resolve a variable path from debug-map.json into the packed + * `(arr << 16) | elem` address the debug FCs take. */ +function resolveDebugAddr(debugMapJson: string, pathSuffix: string): number { + const map = JSON.parse(debugMapJson) as { + leaves: Array<{ arrayIdx: number; elemIdx: number; path: string }> + } + const leaf = map.leaves.find((l) => l.path.toUpperCase().endsWith(pathSuffix.toUpperCase())) + if (!leaf) { + throw new Error(`No debug leaf matching "${pathSuffix}". Available: ${map.leaves.map((l) => l.path).join(', ')}`) + } + return (leaf.arrayIdx << 16) | leaf.elemIdx +} + +describeIfEnabled('Baremetal run/stop state machine end-to-end (FC 0x4b + 0x46 over avr8js)', () => { + let sim: SimulatorModule + let client: ModbusRtuClient + let counterAddr: number + let pulseAddr: number + + /** Read a single variable's raw bytes via FC 0x44. */ + async function readVar(addr: number): Promise { + const res = await client.getVariablesList([addr]) + if (!res.success || !res.data) throw new Error(`getVariablesList failed: ${res.error ?? 'no data'}`) + return res.data + } + + async function readCounter(): Promise { + // FC 0x44's payload is the requested variables' raw bytes concatenated, + // with no per-variable size prefix. One INT => 2 bytes, little-endian. + const data = await readVar(counterAddr) + expect(data.length).toBe(2) + return data[0] | (data[1] << 8) + } + + async function readPulse(): Promise { + const data = await readVar(pulseAddr) + expect(data.length).toBe(1) + return data[0] + } + + /** Let the target run for a while in real time so scan cycles elapse. */ + async function settle(ms = 400): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)) + } + + beforeAll(async () => { + const hex = fs.readFileSync(path.resolve(HEX_PATH), 'utf-8') + const debugMapJson = fs.readFileSync(path.resolve(MAP_PATH), 'utf-8') + counterAddr = resolveDebugAddr(debugMapJson, 'counter') + pulseAddr = resolveDebugAddr(debugMapJson, 'pulse') + + sim = new SimulatorModule() + sim.loadAndRun(hex) + client = new ModbusRtuClient({ slaveId: 1, timeout: 5000, serialPort: new VirtualSerialPort(sim) }) + await client.connect() + }, 600000) + + afterAll(() => { + client?.disconnect() + sim?.stop() + }) + + it('boots RUNNING with the virtual switch in RUN', async () => { + const state = await client.getStatus() + expect(state.success).toBe(true) + expect(state.plcState).toBe(PlcRuntimeState.RUNNING) + // The simulator HAL implements no hardwareStateSwitch() override, so the + // weak default must report RUN -- this is the "nothing changes for boards + // that opt out" guarantee. + expect(state.switchPosition).toBe(PlcSwitchPosition.RUN) + }, 30000) + + it('executes the program while running', async () => { + const first = await readCounter() + await settle() + const second = await readCounter() + expect(second).not.toBe(first) + }, 30000) + + it('drives the located output TRUE while running', async () => { + expect(await readPulse()).toBe(1) + }, 30000) + + it('run/stop command STOPs the PLC to STOPPED', async () => { + const res = await client.setPlcState(PlcRuntimeState.STOPPED) + expect(res.success).toBe(true) + await settle() + const state = await client.getStatus() + expect(state.plcState).toBe(PlcRuntimeState.STOPPED) + }, 30000) + + it('freezes the program while stopped', async () => { + const first = await readCounter() + await settle() + expect(await readCounter()).toBe(first) + }, 30000) + + it('de-energises the located output while stopped', async () => { + expect(await readPulse()).toBe(0) + }, 30000) + + it('re-initialised the program on the STOP edge', async () => { + // counter is declared `INT := 0` and increments every scan, so a value of + // 0 while stopped can only come from the STOP-edge re-init. + expect(await readCounter()).toBe(0) + }, 30000) + + it('run/stop command RUNs it again, from cycle 1', async () => { + const res = await client.setPlcState(PlcRuntimeState.RUNNING) + expect(res.success).toBe(true) + expect(res.refusedBySwitch).toBeFalsy() + await settle() + + const state = await client.getStatus() + expect(state.plcState).toBe(PlcRuntimeState.RUNNING) + + // Counting resumed, and the output is driven again. + const first = await readCounter() + await settle() + expect(await readCounter()).not.toBe(first) + expect(await readPulse()).toBe(1) + }, 30000) + + it('reading the status never changes state', async () => { + const before = await client.getStatus() + const after = await client.getStatus() + expect(after.plcState).toBe(before.plcState) + expect(after.switchPosition).toBe(before.switchPosition) + }, 30000) + + it('keeps the debug channel alive across a stop/start cycle', async () => { + await client.setPlcState(PlcRuntimeState.STOPPED) + await settle() + // FC 0x45 must still answer while stopped -- the control channel IS the + // Modbus link, so it cannot depend on the PLC running. + const md5 = await client.getMd5Hash() + expect(md5.md5).toMatch(/^[0-9a-f]{32}$/) + await client.setPlcState(PlcRuntimeState.RUNNING) + await settle() + }, 60000) +}) + +describeIfSwitch('Hardware mode switch (HAL override, FC 0x4b + 0x46 over avr8js)', () => { + let sim: SimulatorModule + let client: ModbusRtuClient + + beforeAll(async () => { + sim = new SimulatorModule() + sim.loadAndRun(fs.readFileSync(path.resolve(SWITCH_HEX_PATH), 'utf-8')) + client = new ModbusRtuClient({ slaveId: 1, timeout: 5000, serialPort: new VirtualSerialPort(sim) }) + // connect() already waits 2.5s for setup(); the override reads STOP until + // 3s of firmware uptime, so the first assertions land inside the STOP + // window. + await client.connect() + }, 120000) + + afterAll(() => { + client?.disconnect() + sim?.stop() + }) + + it('boots STOPPED when the switch reads STOP, and reports the position', async () => { + const state = await client.getStatus() + expect(state.success).toBe(true) + expect(state.switchPosition).toBe(PlcSwitchPosition.STOP) + expect(state.plcState).toBe(PlcRuntimeState.STOPPED) + }, 30000) + + it('refuses a RUN request while the switch reads STOP', async () => { + const res = await client.setPlcState(PlcRuntimeState.RUNNING) + // Refused, not queued: success is false and the reason is specific enough + // for the editor to tell the user to flip the switch. + expect(res.success).toBe(false) + expect(res.refusedBySwitch).toBe(true) + expect(res.state).toBe(PlcRuntimeState.STOPPED) + expect(res.switchPosition).toBe(PlcSwitchPosition.STOP) + + // Still stopped afterwards -- the refusal did not leave a pending start. + const after = await client.getStatus() + expect(after.plcState).toBe(PlcRuntimeState.STOPPED) + }, 30000) + + it('runs by itself on the STOP -> RUN rising edge, with no command sent', async () => { + // Wait past the override's 3s flip point. Nothing is sent to the target in + // between: the transition must come from the switch alone (rule 3). + await new Promise((resolve) => setTimeout(resolve, 2000)) + + const state = await client.getStatus() + expect(state.switchPosition).toBe(PlcSwitchPosition.RUN) + expect(state.plcState).toBe(PlcRuntimeState.RUNNING) + }, 30000) + + it('accepts software stop and start once the switch reads RUN', async () => { + const stopped = await client.setPlcState(PlcRuntimeState.STOPPED) + expect(stopped.success).toBe(true) + await new Promise((resolve) => setTimeout(resolve, 300)) + expect((await client.getStatus()).plcState).toBe(PlcRuntimeState.STOPPED) + + const started = await client.setPlcState(PlcRuntimeState.RUNNING) + expect(started.success).toBe(true) + expect(started.refusedBySwitch).toBeFalsy() + await new Promise((resolve) => setTimeout(resolve, 300)) + expect((await client.getStatus()).plcState).toBe(PlcRuntimeState.RUNNING) + }, 30000) +}) diff --git a/src/backend/shared/simulator/modbus-rtu-client.ts b/src/backend/shared/simulator/modbus-rtu-client.ts index 8d712af56..37d3c19f5 100644 --- a/src/backend/shared/simulator/modbus-rtu-client.ts +++ b/src/backend/shared/simulator/modbus-rtu-client.ts @@ -1,7 +1,8 @@ -import type { Md5ProbeResult } from '@root/backend/shared/debug/types' +import { buildPlcSetStateRequest, parsePlcSetStateResponse } from '@root/backend/shared/debug/modbus-pdu' +import type { DebugStatusResult, Md5ProbeResult, PlcControlResult } from '@root/backend/shared/debug/types' import { detectTargetEndian } from '@root/frontend/utils/endian' -import { ModbusDebugResponse, ModbusFunctionCode } from './types' +import { ModbusDebugResponse, ModbusFunctionCode, PlcRuntimeState } from './types' export interface SerialPortLike { isOpen: boolean @@ -469,13 +470,7 @@ export class ModbusRtuClient { // prepends: slaveId@6, FC@7, status@8, payload@9+. // ------------------------------------------------------------------------- - async getStatus(): Promise<{ - success: boolean - running?: boolean - tick?: number - uptimeMs?: number - error?: string - }> { + async getStatus(): Promise { try { const request = this.assembleRequest(ModbusFunctionCode.DEBUG_GET_STATUS, allocBytes(0)) const response = await this.sendRequest(request) @@ -500,8 +495,13 @@ export class ModbusRtuClient { return { success: true, running: readUint8(response, 9) !== 0, + // Same byte as `running`, as the tri-state the run/stop machine has. + plcState: readUint8(response, 9), tick: readUint32BE(response, 10), uptimeMs: readUint32BE(response, 14), + // Appended by firmware carrying the run/stop state machine; absent on + // older firmware, which callers read as "no switch gating". + ...(response.length >= 19 ? { switchPosition: readUint8(response, 18) } : {}), } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) } @@ -568,4 +568,28 @@ export class ModbusRtuClient { return { success: false, error: error instanceof Error ? error.message : String(error) } } } + + /** + * FC 0x4b -- ask the runtime to run or stop. + * + * Command only: reading the state is `getStatus()` (FC 0x46), which already + * reports it. A RUN request is refused (not queued) while the mode switch + * reads STOP; `refusedBySwitch` says so. + */ + async setPlcState(state: PlcRuntimeState.RUNNING | PlcRuntimeState.STOPPED): Promise { + try { + // buildPlcSetStateRequest returns [FC][state]; assembleRequest writes the + // FC + slaveId itself, so hand it only the trailing payload. + const pdu = buildPlcSetStateRequest(state) + const response = await this.sendRequest( + this.assembleRequest(ModbusFunctionCode.PLC_SET_STATE, pdu.subarray(1)), + ) + if (response.length < 8) { + return { success: false, error: `Invalid response: too short (${response.length} bytes)` } + } + return parsePlcSetStateResponse(response.subarray(7)) + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } } diff --git a/src/backend/shared/simulator/types.ts b/src/backend/shared/simulator/types.ts index 006f4651a..e6609f8f8 100644 --- a/src/backend/shared/simulator/types.ts +++ b/src/backend/shared/simulator/types.ts @@ -7,10 +7,32 @@ export enum ModbusFunctionCode { DEBUG_GET_STATUS = 0x46, DEBUG_GET_VERSION = 0x47, DEBUG_GET_BOARD_ID = 0x48, + /** Set the runtime run/stop state. Reads go through DEBUG_GET_STATUS (0x46), + * which already reports the state — there is deliberately no second FC for + * querying it. */ + PLC_SET_STATE = 0x4b, } export enum ModbusDebugResponse { SUCCESS = 0x7e, ERROR_OUT_OF_BOUNDS = 0x81, ERROR_OUT_OF_MEMORY = 0x82, + /** PLC_SET_STATE only: a RUN request was refused because the hardware mode + * switch reads STOP. */ + REFUSED_BY_SWITCH = 0x86, +} + +/** Runtime states reported by DEBUG_GET_STATUS and PLC_SET_STATE (and by + * Runtime v4's `/api/status`). */ +export enum PlcRuntimeState { + STOPPED = 0, + RUNNING = 1, + ERROR = 2, +} + +/** Mode-switch positions. Boards with no physical switch always report RUN, so + * callers need no "absent" case. */ +export enum PlcSwitchPosition { + STOP = 0, + RUN = 1, } diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx index ca74ca98e..d4b8c9f15 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -1,22 +1,27 @@ /* eslint-disable @typescript-eslint/no-misused-promises */ +import * as Popover from '@radix-ui/react-popover' import type { TimingStats } from '@root/middleware/shared/ports/types' import { useCapabilities, useDevice, useRuntime } from '@root/middleware/shared/providers/platform-context' import { resolveTargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' +import { Copy } from 'lucide-react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { MagnifierIcon } from '../../../../../../assets/icons/interface/Magnifier' import { MinusIcon } from '../../../../../../assets/icons/interface/Minus' import { PlusIcon } from '../../../../../../assets/icons/interface/Plus' import { RefreshIcon } from '../../../../../../assets/icons/interface/Refresh' +import { useDeviceConnect } from '../../../../../../hooks/use-device-connect' import { boardSelectors, pinSelectors } from '../../../../../../hooks/use-store-selectors' import { useOpenPLCStore } from '../../../../../../store' import type { RuntimeConnection } from '../../../../../../store/slices/device/types' import { cn } from '../../../../../../utils/cn' import { isOpenPLCRuntimeTarget, isSimulatorTarget, validateRuntimeVersion } from '../../../../../../utils/device' +import { serialPortDisplay } from '../../../../../../utils/serial-port-label' import { DropdownSearchInput } from '../../../../../_atoms/dropdown-search-input' import { Label } from '../../../../../_atoms/label' import { Select, SelectContent, SelectItem, SelectTrigger } from '../../../../../_atoms/select' import TableActions from '../../../../../_atoms/table-actions' +import { DeviceConnectButton } from '../../../../../_molecules/device-connect-button' import { EtherCATStats } from '../../../../../_molecules/ethercat-stats' import { Modal, ModalContent, ModalFooter, ModalHeader, ModalTitle } from '../../../../../_molecules/modal' import { PluginStatsPanel } from '../../../../../_molecules/plugin-stats-panel' @@ -24,6 +29,17 @@ import { ScanCycleStats } from '../../../../../_molecules/scan-cycle-stats' import { DeviceEditorSlot } from '../../../../../_templates/[editors]/device-editor-slot' import { PinMappingTable } from './components/pin-mapping-table' +/** + * Confirms the held device link on the device screen: a quiet, monochrome line + * that appears once Connect has settled on a channel a firmware answered. + */ +function DeviceConnectedIndicator({ isConnected }: { isConnected: boolean }) { + if (!isConnected) return null + return ( + Connected + ) +} + const Board = memo(function () { const capabilities = useCapabilities() const device = useDevice() @@ -50,12 +66,32 @@ const Board = memo(function () { const currentBoardInfo = availableBoards.get(deviceBoard) + // CONNECT flow (D72): open the device channel, classify it, and drive the + // flash follow-up when nothing answered the debug protocol. + const { + connect: connectDevice, + disconnect: disconnectDevice, + isConnected, + status: serialStatus, + } = useDeviceConnect(currentBoardInfo) + // Whether this target exposes the GPIO pin-mapping table. Arduino boards // enable it via their preset; runtime-v4 GPIO boards (e.g. the Raspberry // Pi HAL) opt in with `capabilities.pinMapping` in their VPP manifest. const pinMappingEnabled = resolveTargetCapabilities(currentBoardInfo).pinMapping const runtimeIpAddress = useOpenPLCStore((state) => state.deviceDefinitions.configuration.runtimeIpAddress || '') + // Read from the same place the connection resolver reads it, so the button and + // the resolution never disagree about whether a network path exists. + const modbusTcpConfigured = useOpenPLCStore( + (state) => + ( + (state.deviceDefinitions.configuration.vendorScreenData ?? {}) as Record< + string, + Record | undefined + > + )['modbus_tcp']?.['enabled'] === true, + ) const connectionStatus = useOpenPLCStore((state) => state.runtimeConnection.connectionStatus) const setRuntimeIpAddress = useOpenPLCStore((state) => state.deviceActions.setRuntimeIpAddress) const setRuntimeConnectionStatus = useOpenPLCStore((state) => state.deviceActions.setRuntimeConnectionStatus) @@ -349,6 +385,9 @@ const Board = memo(function () { setRuntimeJwtToken(null) setRuntimeConnectionStatus('disconnected') await runtime.clearCredentials() + // The session goes with it: control was this REST connection, and any debug + // channel opened off it has nothing left to belong to. + await device.closeRuntimeSession?.() return } @@ -593,33 +632,24 @@ const Board = memo(function () { Search -
- + {connectionStatus === 'connected' && ( -
- ● Connected + <> {plcStatus && ( | PLC: {plcStatus} )} -
- )} - {connectionStatus === 'error' && ( - ● Connection failed + + )} -
+ ) : capabilities.hasLocalSerialPorts ? ( + <>
+ + + + ) : null} {!isOpenPLCRuntimeTarget(currentBoardInfo) && !isSimulatorTarget(currentBoardInfo) && (
diff --git a/src/frontend/components/_molecules/device-connect-button/__tests__/device-connect-button.test.tsx b/src/frontend/components/_molecules/device-connect-button/__tests__/device-connect-button.test.tsx new file mode 100644 index 000000000..6fc9bdc76 --- /dev/null +++ b/src/frontend/components/_molecules/device-connect-button/__tests__/device-connect-button.test.tsx @@ -0,0 +1,78 @@ +/** + * One Connect button serves both target families. These pin the behaviour that + * had drifted between the two hand-written copies: the label, when the button is + * disabled, and whether a connection is confirmed on screen at all. + */ +import { fireEvent, render, screen } from '@testing-library/react' + +import { DeviceConnectButton } from '../index' + +describe('DeviceConnectButton', () => { + it('reads Connect when disconnected and calls onConnect', () => { + const onConnect = jest.fn() + const onDisconnect = jest.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + + expect(onConnect).toHaveBeenCalledTimes(1) + expect(onDisconnect).not.toHaveBeenCalled() + }) + + it('reads Disconnect when connected and calls onDisconnect', () => { + const onConnect = jest.fn() + const onDisconnect = jest.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'Disconnect' })) + + expect(onDisconnect).toHaveBeenCalledTimes(1) + expect(onConnect).not.toHaveBeenCalled() + }) + + it('confirms a live connection on screen', () => { + // The baremetal copy never showed this, so a connected device looked the same + // as a disconnected one apart from the button label. + render() + expect(screen.getByText('● Connected')).not.toBeNull() + }) + + it('reports a failed attempt', () => { + render() + expect(screen.getByText('● Connection failed')).not.toBeNull() + }) + + it('is inert while connecting', () => { + render() + expect((screen.getByRole('button', { name: 'Connecting...' }) as HTMLButtonElement).disabled).toBe(true) + }) + + it('explains itself when something blocks connecting', () => { + render( + , + ) + + const button = screen.getByRole('button', { name: 'Connect' }) as HTMLButtonElement + expect(button.disabled).toBe(true) + expect(button.title).toBe('Select a communication port first') + }) + + it('stays live when nothing blocks it, so resolution can report the real reason', () => { + render() + expect((screen.getByRole('button', { name: 'Connect' }) as HTMLButtonElement).disabled).toBe(false) + }) + + it('renders caller-supplied detail beside the status', () => { + render( + + | PLC: RUNNING + , + ) + expect(screen.getByText('| PLC: RUNNING')).not.toBeNull() + }) +}) diff --git a/src/frontend/components/_molecules/device-connect-button/index.tsx b/src/frontend/components/_molecules/device-connect-button/index.tsx new file mode 100644 index 000000000..796c59ac4 --- /dev/null +++ b/src/frontend/components/_molecules/device-connect-button/index.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from 'react' + +import type { ConnectionStatus } from '../../../store/slices/device/types' +import { cn } from '../../../utils/cn' + +type DeviceConnectButtonProps = { + /** Live connection state. Both target families use the same four states. */ + status: ConnectionStatus + /** Establish the connection. Called only when not already connected. */ + onConnect: () => void + /** Tear the connection down. Called only when connected. */ + onDisconnect: () => void + /** + * When set, the button is disabled and this says why (also the tooltip) — e.g. + * no communication port has been picked yet. + */ + blockedReason?: string + /** Detail shown beside the status: PLC state, license badge. */ + children?: ReactNode + /** DOM id kept for the existing onboarding/tour anchors. */ + containerId?: string +} + +/** + * Connect / Disconnect, for every target type. + * + * One component on purpose. A Runtime v4 target and a baremetal target are + * connected in completely different ways — REST login versus a held Modbus link — + * but to the user it is the same action in the same place, and it had drifted: the + * two buttons differed in colour when connected, in when they were disabled, and + * one of them never showed the green "Connected" confirmation at all. Those are + * the kind of differences nobody decides on; they accumulate. The connection + * mechanics stay with each caller, and only the appearance lives here. + */ +const DeviceConnectButton = ({ + status, + onConnect, + onDisconnect, + blockedReason, + children, + containerId, +}: DeviceConnectButtonProps) => { + const isConnected = status === 'connected' + const isConnecting = status === 'connecting' + const disabled = isConnecting || blockedReason !== undefined + + return ( +
+ + + {isConnected && ● Connected} + {status === 'error' && ● Connection failed} + {children} +
+ ) +} + +export { DeviceConnectButton } diff --git a/src/frontend/components/_organisms/modals/confirm-device-switch-modal.tsx b/src/frontend/components/_organisms/modals/confirm-device-switch-modal.tsx index 15743c701..a9826a62b 100644 --- a/src/frontend/components/_organisms/modals/confirm-device-switch-modal.tsx +++ b/src/frontend/components/_organisms/modals/confirm-device-switch-modal.tsx @@ -20,6 +20,7 @@ const ConfirmDeviceSwitchModal = () => { deviceActions.setRuntimeJwtToken(null) deviceActions.setRuntimeConnectionStatus('disconnected') deviceActions.setPlcRuntimeStatus(null) + deviceActions.setPlcSwitchPosition(null) if (modalData.onConfirm) { modalData.onConfirm() diff --git a/src/frontend/components/_organisms/modals/runtime-connection-lost-modal.tsx b/src/frontend/components/_organisms/modals/runtime-connection-lost-modal.tsx index 472af5cb5..8297151b4 100644 --- a/src/frontend/components/_organisms/modals/runtime-connection-lost-modal.tsx +++ b/src/frontend/components/_organisms/modals/runtime-connection-lost-modal.tsx @@ -6,7 +6,11 @@ const RuntimeConnectionLostModal = () => { const { modals, modalActions } = useOpenPLCStore() const isOpen = modals['runtime-connection-lost']?.open || false - const modalData = modals['runtime-connection-lost']?.data as { label?: string } | undefined + // `body` lets a caller state WHICH link died and what to do about it, while the + // default keeps the Runtime v4 copy this modal was written for. The serial link + // (baremetal Connect) reuses the same dialog rather than cloning it — the shape + // of the news is identical: a held connection is gone after retries failed. + const modalData = modals['runtime-connection-lost']?.data as { label?: string; body?: string } | undefined const label = modalData?.label ?? 'Unknown' const handleClose = () => { @@ -32,8 +36,12 @@ const RuntimeConnectionLostModal = () => { Connection to runtime lost

- The connection to {label} has been lost after multiple failed attempts. Please check that - the runtime is running and accessible. + {modalData?.body ?? ( + <> + The connection to {label} has been lost after multiple failed attempts. Please check + that the runtime is running and accessible. + + )}

diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index a11b96cda..43c62094c 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -1,17 +1,13 @@ import { resolveTargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' import { useCallback, useEffect, useRef, useState } from 'react' -import { - type DebugResolverContext, - type DebugSpec, - resolveDebugConnection, -} from '../../../../backend/shared/hardware/debug-spec' -import type { DebugConnectionConfig } from '../../../../middleware/shared/ports/types' +import { resolveDeviceLinkCandidates } from '../../../../backend/shared/hardware/debug-spec' import { projectCapabilities } from '../../../../middleware/shared/ports/types' import { useCapabilities, useCompiler, useDebugger, + useDevice, useProject, useRuntime, useSimulator, @@ -19,11 +15,14 @@ import { import { StopIcon } from '../../../assets/icons/interface/Stop' import { useDebugPolling } from '../../../hooks/useDebugPolling' import { useDebugSession } from '../../../hooks/useDebugSession' +import { buildDeviceResolverContext, showDeviceDialog } from '../../../services/device-link-resolution' import { executeSaveProject } from '../../../services/save-actions' import { useOpenPLCStore } from '../../../store' import type { RuntimeConnection } from '../../../store/slices/device/types' import { cn } from '../../../utils/cn' import { logCompilerEvent } from '../../../utils/debugger-session' +import { isOpenPLCRuntimeTarget } from '../../../utils/device' +import { onDeviceFlashRequest } from '../../../utils/device-connect-events' import { getErrorMessage } from '../../../utils/get-error-message' import { type BuildOption, BuildOptionsPopover } from '../../_features/[workspace]/build-options' import { ChatButton } from '../../_molecules/workspace-activity-bar/default/chat' @@ -33,37 +32,6 @@ import { SearchButton } from '../../_molecules/workspace-activity-bar/default/se import { ZoomButton } from '../../_molecules/workspace-activity-bar/default/zoom' import { TooltipSidebarWrapperButton } from '../../_molecules/workspace-activity-bar/tooltip-button' -const showDebuggerMessage = ( - type: 'info' | 'warning' | 'error' | 'question', - title: string, - message: string, - buttons: string[], - options?: { primaryButtonIndex?: number; dismissButtonIndex?: number }, -): Promise => { - return new Promise((resolve) => { - useOpenPLCStore.getState().modalActions.openModal('debugger-message', { - type, - title, - message, - buttons, - ...options, - onResponse: (buttonIndex: number) => resolve(buttonIndex), - }) - }) -} - -const showDebuggerIpInput = (title: string, message: string, defaultValue: string): Promise => { - return new Promise((resolve) => { - useOpenPLCStore.getState().modalActions.openModal('debugger-ip-input', { - title, - message, - defaultValue, - onSubmit: (value: string) => resolve(value), - onCancel: () => resolve(null), - }) - }) -} - const disabledButtonClass = 'cursor-not-allowed opacity-50 [&>*:first-child]:hover:bg-transparent' type DefaultWorkspaceActivityBarProps = { @@ -91,6 +59,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const runtime = useRuntime() const simulator = useSimulator() const debuggerPort = useDebugger() + const device = useDevice() const projectPort = useProject() const capabilities = useCapabilities() const debugSession = useDebugSession() @@ -100,9 +69,16 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const [isDebuggerProcessing, setIsDebuggerProcessing] = useState(false) const [simulatorRunning, setSimulatorRunning] = useState(false) const pendingSimulatorDebugRef = useRef(false) + // True while a debug session is running OVER THE DEVICE CONNECTION (a baremetal + // target, whatever transport that connection uses). Such a session shares the + // connection, so it has to end when the connection does — which the drop handler + // below acts on. A runtime or simulator session owns its own channel and is + // unaffected, so this stays false for them. + const debugSessionRidesDeviceRef = useRef(false) const connectionStatus = useOpenPLCStore((state) => state.runtimeConnection.connectionStatus) const plcStatus = useOpenPLCStore((state): RuntimeConnection['plcStatus'] => state.runtimeConnection.plcStatus) + const switchPosition = useOpenPLCStore((state) => state.runtimeConnection.switchPosition) const jwtToken = useOpenPLCStore((state) => state.runtimeConnection.jwtToken) const isDebuggerVisible = useOpenPLCStore((state) => state.workspace.isDebuggerVisible) const canEdit = useOpenPLCStore((state) => state.workspace.canEdit) @@ -110,18 +86,60 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const currentBoardInfo = availableBoards.get(deviceDefinitions.configuration.deviceBoard) const isSimulatorBoard = resolveTargetCapabilities(currentBoardInfo).isInProcessSimulator - // Sync simulatorRunning when the simulator stops externally + const deviceConnectionStatus = useOpenPLCStore((state) => state.deviceConnection.status) + + // Run/stop travels over the session's control channel, so the button is live + // exactly when a SESSION exists — one question, asked once, for every target type. + // Every session publishes its status: a device connection, a runtime login, a + // running simulator. Asking the target's kind first (`directUsbUpload ? … : …`) + // meant asking "did the user log in" for a runtime, which is not the same question + // and diverged in practice: logged in, session never opened, every command + // refused. "Can a payload be delivered?" is what the button actually needs. + // + // Deliberately NOT applied to Build & Upload. Uploading is how a blank board stops + // being blank, so it cannot require a connection — see `handleBuild`, where the + // connection is consulted only to hand the serial port over to arduino-cli. + const plcControlBlocked = deviceConnectionStatus !== 'connected' + const plcControlBlockedReason = 'Connect to the target first' + + // The emulator stopping is a session ending, and a debug session riding it ends + // with it — which the drop handler below already does for every target. This + // only mirrors the emulator's own state into the button. useEffect(() => { const unsub = simulator.onStopped(() => { pendingSimulatorDebugRef.current = false setSimulatorRunning(false) - const { workspace } = useOpenPLCStore.getState() - if (workspace.isDebuggerVisible) { - void debugSession.stopSession() - } }) return unsub - }, [simulator, debugSession]) + }, [simulator]) + + // A serial debug session lives on the device connection: it shares that + // client, so when the link drops (unplug, reset, liveness failure, or the user + // pressing Disconnect) the session has no transport left and must end. Leaving + // it "active" would show a frozen variable table over a dead port and leave the + // debugger unable to reconnect. + // + // Modbus TCP sessions are deliberately untouched — they own their own socket + // and never depended on the serial link. + // Only 'connected' is tolerated. 'connecting' covers RECOVERY too (the + // connection died and the main process is reopening it), and by then the client + // the session was sharing is already closed — waiting for the recovery verdict + // would just keep a dead session on screen for the whole retry window. A session + // can only have started from 'connected', so the initial connect's 'connecting' + // never reaches this: no session is active to stop. + useEffect(() => { + if (deviceConnectionStatus === 'connected') return + if (!debugSessionRidesDeviceRef.current) return + if (!useOpenPLCStore.getState().workspace.isDebuggerVisible) return + + addLog({ + id: crypto.randomUUID(), + level: 'warning', + message: 'Device disconnected — stopping the debug session (serial debugging runs over the device connection).', + }) + debugSessionRidesDeviceRef.current = false + void debugSession.stopSession() + }, [deviceConnectionStatus, debugSession, addLog]) // Stop simulator if the board is switched away while it's running const prevIsSimulatorBoardRef = useRef(isSimulatorBoard) @@ -204,7 +222,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const requiresRuntimeConnection = !resolveTargetCapabilities(boardInfo).directUsbUpload const { connectionStatus: connStatus, plcStatus: runStatus } = state.runtimeConnection if (requiresRuntimeConnection && connStatus === 'connected' && runStatus === 'RUNNING') { - const response = await showDebuggerMessage( + const response = await showDeviceDialog( 'warning', 'Stop PLC', 'The PLC must be stopped before continuing.', @@ -218,7 +236,12 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa setIsCompiling(false) return } - const stopResult = await runtime.stopPlc() + // Same unified control path as the Start/Stop button: the session routes + // it, so this works for a runtime and a device alike. + const stopResult = (await debuggerPort.setPlcState?.('STOPPED')) ?? { + success: false, + error: 'This target does not support run/stop control', + } if (!stopResult.success) { addLog({ id: crypto.randomUUID(), @@ -242,6 +265,26 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // aliases. const freshProjectData = useOpenPLCStore.getState().projectActions.getCompileReadyProjectData() + // Serial handoff (D72): a held device connection owns the serial port that + // arduino-cli needs for a direct-USB upload. Release it before the build so + // the upload can take the port; reconnect afterwards (auto-reconnect). + const caps = resolveTargetCapabilities(currentBoardInfo) + const willUpload = !isSimulatorBoard && !(overrides?.compileOnly ?? false) && caps.directUsbUpload + // Release ONLY if the held connection is the serial one arduino-cli needs. + // A connection over Modbus TCP is untouched, so debugging and run/stop keep + // working across the upload; disconnecting unconditionally used to throw it + // away. `released` also tells us whether to reconnect afterwards. + let serialWasReleased = false + if (willUpload && useOpenPLCStore.getState().deviceConnection.status === 'connected') { + try { + serialWasReleased = await device.releaseSerialPort( + useOpenPLCStore.getState().deviceDefinitions.configuration.communicationPort ?? null, + ) + } catch { + // best-effort: never block a build on the handoff. + } + } + try { // Track whether the compile stream already surfaced an error so we // don't log a second, generic "Compilation failed" after a failed @@ -290,13 +333,12 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa addLog({ id: crypto.randomUUID(), level: 'info', message: 'Simulator is running.' }) if (pendingSimulatorDebugRef.current) { pendingSimulatorDebugRef.current = false - // Simulator's debug spec resolves to the trivial - // `{ connectionType: 'simulator' }` config — see - // the hals.json entry. Pass it explicitly so the - // session's downstream MD5-verification path has - // the right transport instead of falling back to - // `connectAndStart`'s internal default. - void debugSession.connectAndStart({ connectionType: 'simulator', connectionParams: {} }) + // Rides the emulator's session, so it ends when the emulator + // does — through the same handler a pulled cable goes through. + debugSessionRidesDeviceRef.current = true + // No config: starting the emulator opened its session, so the + // connection manager already knows how to reach it. + void debugSession.connectAndStart() } } else { pendingSimulatorDebugRef.current = false @@ -314,6 +356,32 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa if (!result.success && !streamedError) { addLog({ id: crypto.randomUUID(), level: 'error', message: result.error ?? 'Compilation failed' }) } + + // Serial handoff (D72): if we released a held device connection for this + // upload, reconnect it now that arduino-cli is done with the port. + // Silent (no dialogs) — the user just flashed on purpose. + if (serialWasReleased && result.success) { + const boardTarget = deviceDefinitions.configuration.deviceBoard + const spec = currentBoardInfo?.debug + // Same candidate resolution Connect uses, so the link comes back the way + // the user established it. Only the serial link is ever released for an + // upload, but resolving the full list lets the reconnect land on Modbus + // TCP if that is what now answers. + // `deferPrompts`: this reconnect is silent and automatic (the user just + // flashed), so it must never pop an address dialog behind their back. A + // DHCP-only target simply stays disconnected until they press Connect. + const candidates = resolveDeviceLinkCandidates(spec, buildDeviceResolverContext(boardTarget), { + transports: caps.debuggerTransports, + deferPrompts: true, + }) + if (candidates.kind === 'candidates') { + try { + await window.bridge.deviceConnect(candidates.candidates.map((candidate) => candidate.config)) + } catch { + // best-effort: the user can press Connect again. + } + } + } } catch (err: unknown) { addLog({ id: crypto.randomUUID(), level: 'error', message: `Build error: ${getErrorMessage(err)}` }) } finally { @@ -322,9 +390,9 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa }, [ compiler, - projectData, projectMeta, deviceDefinitions, + currentBoardInfo, isSimulatorBoard, simulator, debugSession, @@ -341,6 +409,15 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const handleBuildRef = useRef(handleBuild) handleBuildRef.current = handleBuild + // CONNECT flow (D72): the device screen's "No Firmware Detected" dialog lives + // in board.tsx but Build & Upload lives here. When the user chooses to flash, + // that dialog fires a decoupled event we answer by running the same build. + useEffect(() => { + return onDeviceFlashRequest(() => { + void handleBuildRef.current() + }) + }, []) + // --------------------------------------------------------------------------- // Build Library (.stlib) // --------------------------------------------------------------------------- @@ -423,46 +500,111 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // PLC control (Start/Stop for runtime targets) // --------------------------------------------------------------------------- - const handlePlcControl = useCallback(async (): Promise => { - if (!jwtToken || connectionStatus !== 'connected') return + /** + * Tell the user the hardware mode switch is holding the device in STOP. + * + * Shown both when the editor blocks a start locally (pre-check) and when the + * device refuses one, so the two paths read identically. `switchLabel` comes + * from the VPP manifest's optional `stateControl.modeSwitch.label` when the + * package provides it, so a P1AM says "CPU switch" rather than the generic + * wording. + */ + const warnSwitchInStop = useCallback( + async (deviceName: string, switchLabel?: string): Promise => { + await showDeviceDialog( + 'warning', + 'Device is in STOP', + `The ${switchLabel ?? 'mode switch'} on ${deviceName} is in the STOP position. ` + + 'The PLC cannot be started from the editor while the switch is in STOP.\n\n' + + 'Flip the switch to RUN and try again.', + ['OK'], + ) + }, + [], + ) + const handlePlcControl = useCallback(async (): Promise => { + const boardTarget = deviceDefinitions.configuration.deviceBoard + const boardInfo = availableBoards.get(boardTarget) + const caps = resolveTargetCapabilities(boardInfo) + if (!caps.plcStateControl) return + + const switchLabel = ( + boardInfo as { stateControl?: { modeSwitch?: { label?: string } } } | undefined + )?.stateControl?.modeSwitch?.label + + // ONE path for every target. "Start the PLC" is the same request whether it + // travels as Modbus FC 0x4b down a cable or as an HTTP POST to a runtime; the + // connection manager routes it over whatever the session's control channel is. + // Branching here on target type is what kept two copies of the switch + // pre-check, the refusal handling and the error reporting in step by hand. + // + // Reads are NOT done here: the session's status poll keeps `plcStatus` and + // `switchPosition` in the store, so the pre-check is a store lookup rather than + // another round trip over a medium the poll is already using. try { - if (plcStatus === 'RUNNING') { - const result = await runtime.stopPlc() - if (!result.success) { - addLog({ - id: crypto.randomUUID(), - level: 'error', - message: `Failed to stop PLC: ${result.error ?? 'Unknown error'}`, - }) - return - } - } else { - const result = await runtime.startPlc() - if (!result.success) { - addLog({ - id: crypto.randomUUID(), - level: 'error', - message: `Failed to start PLC: ${result.error ?? 'Unknown error'}`, - }) - return - } + const wantRun = plcStatus !== 'RUNNING' + + // Never send a start to a device whose switch reads STOP. `null` means + // "unknown / no switch", which must NOT block: a board with no physical + // switch, or firmware predating the state machine, would otherwise be + // un-startable. + if (wantRun && switchPosition === 'stop') { + await warnSwitchInStop(boardTarget, switchLabel) + return } - const statusResult = await runtime.getStatus() - if (statusResult.success && statusResult.status) { + const result = await debuggerPort.setPlcState?.(wantRun ? 'RUNNING' : 'STOPPED') + if (!result) return + + if (result.unsupported) { + addLog({ + id: crypto.randomUUID(), + level: 'info', + message: 'This firmware predates run/stop control. Rebuild and upload the program to enable Start/Stop.', + }) + return + } + // Covers the race where the switch moved between the store's last poll and + // this command: the device is authoritative, so its refusal wins. + if (result.refusedBySwitch) { + await warnSwitchInStop(boardTarget, switchLabel) + return + } + if (!result.success) { + addLog({ + id: crypto.randomUUID(), + level: 'error', + message: `Failed to ${wantRun ? 'start' : 'stop'} PLC: ${result.error ?? 'Unknown error'}`, + }) + return + } + + // No re-read: the target settles into the new state on its next scan and the + // status poll picks it up within one tick. Reflecting the acknowledgement + // keeps the button responsive without a round trip that could still read the + // pre-change value. + if (result.state !== undefined) { useOpenPLCStore .getState() - .deviceActions.setPlcRuntimeStatus(statusResult.status as NonNullable) + .deviceActions.setPlcRuntimeStatus( + (result.state === 1 ? 'RUNNING' : result.state === 2 ? 'ERROR' : 'STOPPED') as NonNullable< + RuntimeConnection['plcStatus'] + >, + ) } } catch (error: unknown) { addLog({ id: crypto.randomUUID(), level: 'error', message: `PLC control error: ${getErrorMessage(error)}` }) } - }, [runtime, jwtToken, connectionStatus, plcStatus, addLog]) - - // --------------------------------------------------------------------------- - // Simulator control (Start/Stop simulator + auto-debug) - // --------------------------------------------------------------------------- + }, [ + deviceDefinitions.configuration.deviceBoard, + availableBoards, + plcStatus, + switchPosition, + debuggerPort, + addLog, + warnSwitchInStop, + ]) const handleSimulatorControl = useCallback(async (): Promise => { try { @@ -486,18 +628,13 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // MD5 verification — runs after debug compilation for non-simulator // --------------------------------------------------------------------------- - const handleMd5Verification = async ( - projectPath: string, - boardTarget: string, - debugConfig: DebugConnectionConfig, - isRuntimeTarget: boolean, - ) => { + const handleMd5Verification = async (projectPath: string, boardTarget: string, isRuntimeTarget: boolean) => { const { consoleActions, runtimeConnection, deviceActions } = useOpenPLCStore.getState() try { // If runtime target + PLC stopped, offer to start if (isRuntimeTarget && runtimeConnection.plcStatus === 'STOPPED' && runtimeConnection.jwtToken) { - const response = await showDebuggerMessage( + const response = await showDeviceDialog( 'question', 'PLC Stopped', 'The PLC is currently stopped. The debugger requires the PLC to be running. Would you like to start the PLC now?', @@ -510,9 +647,12 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa } consoleActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'Starting PLC...' }) - const startResult = await runtime.startPlc() + const startResult = (await debuggerPort.setPlcState?.('RUNNING')) ?? { + success: false, + error: 'This target does not support run/stop control', + } if (!startResult.success) { - await showDebuggerMessage( + await showDeviceDialog( 'error', 'Start PLC Failed', `Could not start the PLC: ${startResult.error || 'Unknown error'}`, @@ -529,7 +669,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa consoleActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'Verifying program MD5...' }) const md5Result = await debuggerPort.readProgramMd5(projectPath, boardTarget) if (!md5Result.success || !md5Result.md5) { - await showDebuggerMessage('error', 'MD5 Extraction Failed', md5Result.error ?? 'Could not extract MD5', ['OK']) + await showDeviceDialog('error', 'MD5 Extraction Failed', md5Result.error ?? 'Could not extract MD5', ['OK']) setIsDebuggerProcessing(false) return } @@ -537,22 +677,22 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // Connect debug transport before MD5 verification — the web platform // needs an active transport (WebRTC or HTTP fallback) to query the device. // connect() is idempotent: connectAndStart will reuse this connection. - const preConnectResult = await debuggerPort.connect(debugConfig) + const preConnectResult = await debuggerPort.connect() if (!preConnectResult.success) { - await showDebuggerMessage( + await showDeviceDialog( 'error', - 'Connection Error', - `Could not connect to debug target: ${preConnectResult.error ?? 'Unknown error'}`, + "Can't Start Debugger", + `Can't start the debugger — ${preConnectResult.error ?? 'unknown error'}.`, ['OK'], ) setIsDebuggerProcessing(false) return } - const verifyResult = await debuggerPort.verifyMd5(md5Result.md5, debugConfig) + const verifyResult = await debuggerPort.verifyMd5(md5Result.md5) if (!verifyResult.success) { await debuggerPort.disconnect() - await showDebuggerMessage( + await showDeviceDialog( 'error', 'Connection Error', `Could not verify MD5: ${verifyResult.error ?? 'Unknown error'}`, @@ -564,17 +704,22 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa if (verifyResult.match) { consoleActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'MD5 verified. Starting debugger...' }) - // Surface the active transport in the store so transport-specific - // pollers (useDebugPolling) can size their batches against the - // real frame budget rather than guessing from the board target. - useOpenPLCStore.getState().workspaceActions.setDebugConnectionType(debugConfig.connectionType) + // The debug poll sizes its batches to the frame budget, so it needs the + // medium the DEBUG channel rides — published by the manager and mirrored in + // the store. Reading the control medium instead left a v4 session (control + // over REST, debug over a WebSocket) with no medium at all, and it silently + // polled with TCP-sized batches: 60 variables per round trip instead of 500. + const activeTransport = useOpenPLCStore.getState().deviceConnection.debugTransport + if (activeTransport) { + useOpenPLCStore.getState().workspaceActions.setDebugConnectionType(activeTransport) + } // Persist the target's byte order — detected from the MD5 // response trailer in the runtime — so the swap layer at the // read / write boundaries flips on BE targets. Default to // `'le'` when the trailer was missing or malformed (older // runtimes); detectTargetEndian already logged a warning. useOpenPLCStore.getState().workspaceActions.setDebugTargetEndian(verifyResult.targetEndian ?? 'le') - await debugSession.connectAndStart(debugConfig) + await debugSession.connectAndStart() setIsDebuggerProcessing(false) } else { // Disconnect before re-upload; the recursive call will reconnect @@ -585,7 +730,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa level: 'warning', message: `MD5 mismatch. Target: ${verifyResult.targetMd5}, Expected: ${md5Result.md5}`, }) - const response = await showDebuggerMessage( + const response = await showDeviceDialog( 'warning', 'Program Mismatch', 'The program on the target does not match. Upload the current project?', @@ -615,7 +760,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa message: 'Upload completed. Re-verifying...', }) await new Promise((resolve) => setTimeout(resolve, 2000)) - void handleMd5Verification(projectPath, boardTarget, debugConfig, isRuntimeTarget) + void handleMd5Verification(projectPath, boardTarget, isRuntimeTarget) } else { consoleActions.addLog({ id: crypto.randomUUID(), @@ -639,111 +784,6 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa } } - // --------------------------------------------------------------------------- - // Debug-spec resolver — surface picker / prompt / error dialogs and - // return a connection-ready DebugConnectionConfig, or null if the - // user cancelled or no config could be resolved. - // --------------------------------------------------------------------------- - - // Renderer-local prompt cache for the DHCP-IP-style flows. Keyed - // by `||` (or `builtin||` - // for hals.json entries) so two boards sharing a `cacheKey` value - // don't see each other's last-entered IP. Lives on a ref so it - // survives across re-renders without triggering them. - const promptCacheRef = useRef>>({}) - - const resolveDebugConfigWithUx = useCallback( - async (boardTarget: string, spec: DebugSpec | undefined): Promise => { - if (!spec) { - await showDebuggerMessage( - 'warning', - 'Debugging Not Available', - "This board hasn't declared a debug spec. The VPP package (or hals.json entry) must provide a `debug` block.", - ['OK'], - ) - return null - } - - // Build resolver context from current store state on each call — - // captures the user's freshest screen edits without forcing the - // user to save first. - const buildContext = (): DebugResolverContext => { - const store = useOpenPLCStore.getState() - const cfg = store.deviceDefinitions.configuration - const rtConn = store.runtimeConnection - // `vendorScreenData` is already keyed by section ID (e.g. - // `modbus_rtu`); resolver state's `screens` shape matches - // 1:1 so we pass it straight through. - const screens = (cfg.vendorScreenData ?? {}) as Record> - const cacheBucketKey = `${cfg.deviceBoard}` - const promptCache = promptCacheRef.current[cacheBucketKey] ?? {} - return { - state: { - configuration: { - deviceBoard: cfg.deviceBoard, - ...(cfg.communicationPort ? { communicationPort: cfg.communicationPort } : {}), - ...(cfg.runtimeIpAddress ? { runtimeIpAddress: cfg.runtimeIpAddress } : {}), - }, - screens, - runtimeConnection: { - ...(rtConn.connectionStatus ? { connectionStatus: rtConn.connectionStatus } : {}), - ...(rtConn.jwtToken ? { jwtToken: rtConn.jwtToken } : {}), - }, - promptCache, - }, - capabilities: { - runtimeConnected: runtime.isReadyForDebug?.() === true && rtConn.connectionStatus === 'connected', - jwtToken: Boolean(rtConn.jwtToken), - }, - } - } - - let selectedChannelIndex: number | undefined - // Loop: pickers/prompts re-invoke the resolver with extra state - // until it returns config or error/unsupported/cancelled. - // Capped at 8 iterations as a defensive guard against spec - // bugs that could otherwise loop forever. - for (let iteration = 0; iteration < 8; iteration += 1) { - const outcome = resolveDebugConnection(spec, buildContext(), selectedChannelIndex) - if (outcome.kind === 'config') { - return outcome.config - } - if (outcome.kind === 'error') { - await showDebuggerMessage('warning', outcome.title, outcome.body, ['OK']) - return null - } - if (outcome.kind === 'unsupported') { - // Defensive — buildContext already errored at top-level on - // missing spec, so we shouldn't reach here normally. - return null - } - if (outcome.kind === 'pick') { - const buttons = outcome.channels.map((c) => c.label) - const choice = await showDebuggerMessage('question', outcome.title, outcome.body, buttons) - if (choice < 0 || choice >= outcome.channels.length) return null - selectedChannelIndex = outcome.channels[choice].index - continue - } - if (outcome.kind === 'prompt') { - const bucketKey = boardTarget - const bucket = (promptCacheRef.current[bucketKey] ??= {}) - for (const field of outcome.fields) { - const previous = field.cacheKey ? bucket[field.cacheKey] : undefined - const result = await showDebuggerIpInput(field.title, field.message, previous ?? field.defaultValue ?? '') - if (result === null) return null - const trimmed = result.trim() - if (!trimmed) return null - if (field.cacheKey) bucket[field.cacheKey] = trimmed - } - selectedChannelIndex = outcome.channelIndex - continue - } - } - return null - }, - [runtime], - ) - // --------------------------------------------------------------------------- // Debugger click — full orchestration for non-simulator targets // --------------------------------------------------------------------------- @@ -759,11 +799,13 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // Toggle off if (workspace.isDebuggerVisible) { + debugSessionRidesDeviceRef.current = false await debugSession.stopSession() return } if (isDebuggerProcessing) return + setIsDebuggerProcessing(true) try { @@ -785,8 +827,38 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const projectPath = project.meta.path const boardInfo = availableBoards.get(boardTarget) - const debugConfig = await resolveDebugConfigWithUx(boardTarget, boardInfo?.debug) - if (!debugConfig) { + // No resolution here at all. Every target's session is established before a + // debug session can start — a device by Connect, a runtime by logging in, the + // simulator by pressing Start — so the only question left is whether that + // session exists. Which medium it uses is the connection manager's to know. + const isRuntime = isOpenPLCRuntimeTarget(boardInfo) + + // A session the manager holds (a device or the simulator) also OWNS the debug + // channel, so the session ending ends the debug session — see the drop handler + // above. A runtime's debug channel is its own and outlives nothing. + debugSessionRidesDeviceRef.current = !isRuntime + + // One question for every target: does the manager hold a session? A simulator's + // session is its running emulator, a device's is Connect, a runtime's is the + // login — all three publish the same status. + const sessionStatus = useOpenPLCStore.getState().deviceConnection.status + addLog({ + id: crypto.randomUUID(), + level: 'info', + message: `[connection] debug session requested for ${boardTarget}; session is "${sessionStatus}"`, + }) + + // Connect first. Starting a debug session must never establish the connection + // itself: connecting is the user's explicit action and reports what it found. + if (sessionStatus !== 'connected') { + await showDeviceDialog( + 'warning', + 'Connection Required', + isRuntime + ? 'Connect to the runtime first. The debugger runs over that connection, so it must be established before a debug session can start.' + : 'Connect to the device first. The debugger runs over the device connection, so the device must be connected before a debug session can start.', + ['OK'], + ) setIsDebuggerProcessing(false) return } @@ -810,12 +882,10 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa return } - // `isRuntimeTarget` here only gates the "PLC stopped, start it?" - // dialog inside MD5 verification. Tied to whether the active - // channel needs the runtime alive — websocket/tcp targets do, - // rtu/simulator targets don't. - const isRuntimeTarget = debugConfig.connectionType === 'websocket' || debugConfig.connectionType === 'tcp' - void handleMd5Verification(projectPath, boardTarget, debugConfig, isRuntimeTarget) + // Only gates the "PLC stopped, start it?" dialog inside MD5 verification, + // which applies to an OpenPLC runtime (v3/v4) — a fact about the TARGET, not + // about which transport happens to carry the session. + void handleMd5Verification(projectPath, boardTarget, isRuntime) } catch (error: unknown) { consoleActions.addLog({ id: crypto.randomUUID(), @@ -838,8 +908,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa canEdit, executeSave, addLog, - resolveDebugConfigWithUx, - ]) + currentBoardInfo]) // --------------------------------------------------------------------------- // JSX @@ -902,8 +971,8 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa ? simulatorRunning ? 'Stop Simulator' : 'Start Simulator' - : connectionStatus !== 'connected' - ? 'Connect to runtime first' + : plcControlBlocked + ? plcControlBlockedReason : plcStatus === 'RUNNING' ? 'Stop PLC' : 'Start PLC' @@ -911,13 +980,17 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa > void handleSimulatorControl() : () => void handlePlcControl()} - disabled={isSimulatorBoard ? isCompiling || isDebuggerProcessing : connectionStatus !== 'connected'} + disabled={ + isSimulatorBoard + ? isCompiling || isDebuggerProcessing + : plcControlBlocked + } className={cn( isSimulatorBoard ? isCompiling || isDebuggerProcessing ? disabledButtonClass : '' - : connectionStatus !== 'connected' + : plcControlBlocked ? disabledButtonClass : '', )} diff --git a/src/frontend/components/_templates/app-layout.tsx b/src/frontend/components/_templates/app-layout.tsx index 1f696ed8f..ad740bc96 100644 --- a/src/frontend/components/_templates/app-layout.tsx +++ b/src/frontend/components/_templates/app-layout.tsx @@ -13,6 +13,7 @@ import { RuntimeCreateUserModal, RuntimeDiscoverDevicesModal, RuntimeLoginModal import { ConfirmDeleteProjectModal } from '../_organisms/modals/confirm-delete-project-modal' import { ConfirmInstallLibrariesModal } from '../_organisms/modals/confirm-install-libraries-modal' import { ConfirmPlcopenImportModal } from '../_organisms/modals/confirm-plcopen-import-modal' +import { DebuggerIpInputModal } from '../_organisms/modals/debugger-ip-input-modal' import { DebuggerMessageModal } from '../_organisms/modals/debugger-message-modal' import { ConfirmDeleteElementModal } from '../_organisms/modals/delete-confirmation-modal' import { MissingLibrariesModal } from '../_organisms/modals/missing-libraries-modal' @@ -138,6 +139,7 @@ const AppLayout = ({ children, ...rest }: AppLayoutProps): ReactNode => { )} {modals?.['runtime-connection-lost']?.open === true && } {modals?.['debugger-message']?.open === true && } + {modals?.['debugger-ip-input']?.open === true && } {modals?.['missing-libraries']?.open === true && } {modals?.['public-catalog-browser']?.open === true && } {modals?.['confirm-install-libraries']?.open === true && } diff --git a/src/frontend/hooks/__tests__/use-device-connect.test.ts b/src/frontend/hooks/__tests__/use-device-connect.test.ts new file mode 100644 index 000000000..33a5e2ef9 --- /dev/null +++ b/src/frontend/hooks/__tests__/use-device-connect.test.ts @@ -0,0 +1,209 @@ +import { renderHook } from '@testing-library/react' + +// `mock*`-prefixed refs are hoisted into the jest.mock factories below. +const mockOpenModal = jest.fn() +const mockSetDeviceConnectionStatus = jest.fn() +const mockAddLog = jest.fn() + +const mockState: Record = { + deviceDefinitions: { configuration: { deviceBoard: 'Test Board', communicationPort: 'COM5', vendorScreenData: {} } }, + deviceConnection: { status: 'disconnected', port: null }, + runtimeConnection: { ipAddress: '192.168.0.128', jwtToken: 'jwt-tok' }, + modalActions: { openModal: mockOpenModal }, + consoleActions: { addLog: mockAddLog }, + deviceActions: { + setDeviceConnectionStatus: mockSetDeviceConnectionStatus, + }, +} + +type Selector = (s: typeof mockState) => T +const mockUseOpenPLCStore = ((selector?: Selector) => + selector ? selector(mockState) : mockState) as unknown as jest.Mock & { getState: () => typeof mockState } +mockUseOpenPLCStore.getState = () => mockState + +const mockConnect = jest.fn() +const mockDisconnect = jest.fn().mockResolvedValue({ success: true }) +const mockOnConnectionStatus = jest.fn().mockReturnValue(() => undefined) +const mockResolveDeviceLinkWithUx = jest.fn((..._args: unknown[]) => Promise.resolve(mockResolution)) +const mockRequestDeviceFlash = jest.fn() + +/** What the shared resolution returns: ordered ways to reach the device. */ +const serialCandidate = { + channelLabel: 'Modbus RTU', + channelIndex: 0, + config: { connectionType: 'rtu', connectionParams: { port: 'COM5', baudRate: 115200, slaveId: 1 } }, +} +/** Shape the hook consumes: what can be tried now, and what needs input first. */ +let mockResolution: unknown = { candidates: [serialCandidate], awaitingInput: [] } + +jest.mock('../../store', () => ({ useOpenPLCStore: mockUseOpenPLCStore })) +jest.mock('@root/middleware/shared/providers/platform-context', () => ({ + useDevice: () => ({ + connect: mockConnect, + disconnect: mockDisconnect, + onConnectionStatus: mockOnConnectionStatus, + }), +})) +jest.mock('../../services/device-link-resolution', () => ({ + resolveDeviceLinkWithUx: (...args: unknown[]) => mockResolveDeviceLinkWithUx(...args), +})) +jest.mock('../../utils/device-connect-events', () => ({ requestDeviceFlash: mockRequestDeviceFlash })) + +import type { BoardInfo } from '@root/middleware/shared/ports/types' + +import { useDeviceConnect } from '../use-device-connect' + +const board = { debug: {} } as unknown as BoardInfo + +function latestOnResponse(): (index: number) => void { + const [, props] = mockOpenModal.mock.calls[mockOpenModal.mock.calls.length - 1] + return (props as { onResponse: (i: number) => void }).onResponse +} + +beforeEach(() => { + jest.clearAllMocks() + mockState.deviceConnection = { status: 'disconnected', port: null } + mockState.runtimeConnection = { ipAddress: '192.168.0.128', jwtToken: 'jwt-tok' } + mockResolution = { candidates: [serialCandidate], awaitingInput: [] } + mockDisconnect.mockResolvedValue({ success: true }) + mockOnConnectionStatus.mockReturnValue(() => undefined) +}) + +describe('useDeviceConnect', () => { + // Mirroring pushed link status is NOT this hook's job: the link outlives the + // device screen, so that subscription lives in `useDeviceConnectionMonitor` + // (mounted at workspace level) and is tested there. + + const tcpCandidate = { + channelLabel: 'Modbus TCP', + channelIndex: 0, + config: { connectionType: 'tcp', connectionParams: { ipAddress: '192.168.0.50' } }, + } + + it('hands the connection EVERY resolved candidate, in order', async () => { + // Connect does not choose a transport: the main process tries the list and + // keeps the first that answers, which is what lets a stale Modbus TCP address + // fall through to the cable. Choosing here is what previously stranded a + // Modbus-TCP-only project on "select a communication port". + mockResolution = { candidates: [serialCandidate, tcpCandidate], awaitingInput: [] } + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + expect(mockConnect).toHaveBeenCalledWith([serialCandidate.config, tcpCandidate.config]) + }) + + it('does nothing when resolution was cancelled or impossible', async () => { + // The shared resolution has already told the user why (a cancelled prompt is + // the user's answer), so this must not stack a second dialog on top. + mockResolution = null + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + expect(mockConnect).not.toHaveBeenCalled() + expect(mockOpenModal).not.toHaveBeenCalled() + }) + + it('never asks for a DHCP address when a silent candidate connects', async () => { + // The user's report: with DHCP on, Connect asked for an address before trying + // anything. With a cable attached that question is pure interruption, so the + // deferred channel must stay unasked when the cable works. + mockResolution = { candidates: [serialCandidate], awaitingInput: [1] } + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + expect(mockResolveDeviceLinkWithUx).toHaveBeenCalledTimes(1) + expect(mockResolveDeviceLinkWithUx.mock.calls[0][2]).toMatchObject({ deferPrompts: true }) + expect(mockConnect).toHaveBeenCalledTimes(1) + }) + + it('asks for the deferred address only after the silent candidates fail', async () => { + mockResolveDeviceLinkWithUx + .mockImplementationOnce(() => Promise.resolve({ candidates: [serialCandidate], awaitingInput: [1] })) + .mockImplementationOnce(() => Promise.resolve({ candidates: [tcpCandidate], awaitingInput: [] })) + mockConnect + .mockResolvedValueOnce({ status: 'no-response' }) + .mockResolvedValueOnce({ status: 'connected-with-firmware' }) + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + // Second resolve targets ONLY the channel that needed input. + expect(mockResolveDeviceLinkWithUx).toHaveBeenCalledTimes(2) + expect(mockResolveDeviceLinkWithUx.mock.calls[1][2]).toMatchObject({ onlyChannels: [1] }) + expect(mockConnect).toHaveBeenNthCalledWith(2, [tcpCandidate.config]) + // It connected on the second pass, so no failure dialog. + expect(mockOpenModal).not.toHaveBeenCalled() + }) + + it('names every endpoint it tried when nothing answers', async () => { + mockResolution = { candidates: [serialCandidate, tcpCandidate], awaitingInput: [] } + mockConnect.mockResolvedValue({ status: 'no-response' }) + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + const [, props] = mockOpenModal.mock.calls[0] + expect((props as { message: string }).message).toContain('192.168.0.50') + expect((props as { message: string }).message).toContain('COM5') + }) + + it('marks the link as connecting before handing the candidates over', async () => { + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + expect(mockSetDeviceConnectionStatus).toHaveBeenCalledWith('connecting', null) + expect(mockConnect).toHaveBeenCalledWith([serialCandidate.config]) + }) + + it('opens no dialog when a firmware answered', async () => { + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + expect(mockOpenModal).not.toHaveBeenCalled() + }) + + it('shows a no-response error dialog', async () => { + mockConnect.mockResolvedValue({ status: 'no-response' }) + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + expect(mockOpenModal.mock.calls[0][1]).toMatchObject({ title: 'No Response' }) + }) + + it('surfaces a connection error', async () => { + mockConnect.mockResolvedValue({ status: 'error', error: 'boom' }) + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + expect(mockOpenModal.mock.calls[0][1]).toMatchObject({ title: 'Connection Error', message: 'boom' }) + }) + + it('offers to flash on no-firmware and requests a build when accepted', async () => { + mockConnect.mockResolvedValue({ status: 'no-firmware' }) + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + expect(mockOpenModal.mock.calls[0][1]).toMatchObject({ title: 'No Firmware Detected' }) + latestOnResponse()(0) + expect(mockRequestDeviceFlash).toHaveBeenCalledTimes(1) + latestOnResponse()(1) + expect(mockRequestDeviceFlash).toHaveBeenCalledTimes(1) + }) + + it('disconnect closes the held link', async () => { + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.disconnect() + expect(mockDisconnect).toHaveBeenCalledTimes(1) + expect(mockSetDeviceConnectionStatus).toHaveBeenCalledWith('disconnected', null) + }) + + it('derives isConnecting / isConnected from the store status', () => { + mockState.deviceConnection = { status: 'connected', port: 'COM5' } + const { result } = renderHook(() => useDeviceConnect(board)) + expect(result.current.isConnected).toBe(true) + expect(result.current.isConnecting).toBe(false) + expect(result.current.status).toBe('connected') + }) +}) diff --git a/src/frontend/hooks/__tests__/use-device-connection-monitor.test.ts b/src/frontend/hooks/__tests__/use-device-connection-monitor.test.ts new file mode 100644 index 000000000..0f7516fd7 --- /dev/null +++ b/src/frontend/hooks/__tests__/use-device-connection-monitor.test.ts @@ -0,0 +1,177 @@ +import { renderHook } from '@testing-library/react' + +// `mock*`-prefixed refs are hoisted into the jest.mock factories below. +const mockSetDeviceConnectionStatus = jest.fn() +const mockOpenModal = jest.fn() +const mockAddLog = jest.fn() + +const mockOpenRuntimeSession = jest.fn().mockResolvedValue({ success: true }) +const mockCloseRuntimeSession = jest.fn().mockResolvedValue({ success: true }) +const mockResolveRuntimeDebugChannel = jest.fn(() => null as unknown) + +const mockState: Record = { + modalActions: { openModal: mockOpenModal }, + consoleActions: { addLog: mockAddLog }, + runtimeConnection: { connectionStatus: 'disconnected', jwtToken: null, ipAddress: null }, + deviceDefinitions: { configuration: { deviceBoard: 'OpenPLC Runtime v4' } }, + deviceAvailableOptions: { availableBoards: new Map() }, + deviceActions: { + setDeviceConnectionStatus: mockSetDeviceConnectionStatus, + }, +} + +type Selector = (s: typeof mockState) => T +const mockUseOpenPLCStore = ((selector?: Selector) => + selector ? selector(mockState) : mockState) as unknown as jest.Mock & { getState: () => typeof mockState } +mockUseOpenPLCStore.getState = () => mockState + +const mockOnConnectionStatus = jest.fn().mockReturnValue(() => undefined) +const mockOnLinkLog = jest.fn().mockReturnValue(() => undefined) + +jest.mock('../../store', () => ({ useOpenPLCStore: mockUseOpenPLCStore })) +jest.mock('../../../middleware/shared/providers', () => ({ + useDevice: () => ({ + onConnectionStatus: mockOnConnectionStatus, + onLinkLog: mockOnLinkLog, + openRuntimeSession: mockOpenRuntimeSession, + closeRuntimeSession: mockCloseRuntimeSession, + }), +})) +jest.mock('../../services/device-link-resolution', () => ({ + resolveRuntimeDebugChannel: (...args: unknown[]) => mockResolveRuntimeDebugChannel(...(args as [])), +})) + +import { useDeviceConnectionMonitor } from '../use-device-connection-monitor' + +type Payload = { + status: string + descriptor?: string + transport?: 'rtu' | 'tcp' + debugTransport?: 'rtu' | 'tcp' | 'websocket' + reason?: 'lost' +} + +/** Mount the hook and hand back the main-process push callback. */ +function mountAndPush(): (payload: Payload) => void { + renderHook(() => useDeviceConnectionMonitor()) + return mockOnConnectionStatus.mock.calls[0][0] as (payload: Payload) => void +} + +beforeEach(() => { + jest.clearAllMocks() + mockOnConnectionStatus.mockReturnValue(() => undefined) + mockOnLinkLog.mockReturnValue(() => undefined) + mockResolveRuntimeDebugChannel.mockReturnValue(null) + mockState.runtimeConnection = { connectionStatus: 'disconnected', jwtToken: null, ipAddress: null } +}) + +describe('useDeviceConnectionMonitor', () => { + describe('runtime sessions', () => { + it('opens a session when a runtime login comes up', () => { + // A runtime is controlled over REST, which is connectionless — logging in IS + // what establishes its session. + mockState.runtimeConnection = { connectionStatus: 'connected', jwtToken: 'jwt', ipAddress: '10.0.0.5' } + mockState.deviceAvailableOptions = { + availableBoards: new Map([['OpenPLC Runtime v4', { debug: { channels: [] } }]]), + } + const debugChannel = { connectionType: 'websocket', connectionParams: { ipAddress: '10.0.0.5' } } + mockResolveRuntimeDebugChannel.mockReturnValue(debugChannel) + + renderHook(() => useDeviceConnectionMonitor()) + + expect(mockOpenRuntimeSession).toHaveBeenCalledWith({ address: '10.0.0.5', debug: debugChannel }) + }) + + it('closes the session when the runtime connection goes down', () => { + renderHook(() => useDeviceConnectionMonitor()) + expect(mockCloseRuntimeSession).toHaveBeenCalledTimes(1) + expect(mockOpenRuntimeSession).not.toHaveBeenCalled() + }) + }) + + it('mirrors the main-process connection trace into the console', () => { + // The decisions worth reading happen in the main process; the console is where + // a user can actually see and copy them while reproducing a problem. + renderHook(() => useDeviceConnectionMonitor()) + const emit = mockOnLinkLog.mock.calls[0][0] as (message: string) => void + + emit('open: 2 candidate(s) in order: tcp 192.168.2.20, rtu /dev/ttyACM0') + + expect(mockAddLog).toHaveBeenCalledWith( + expect.objectContaining({ level: 'info', message: expect.stringContaining('tcp 192.168.2.20') }), + ) + }) + + it('subscribes once on mount and unsubscribes on unmount', () => { + const unsubscribe = jest.fn() + mockOnConnectionStatus.mockReturnValue(unsubscribe) + + const { unmount } = renderHook(() => useDeviceConnectionMonitor()) + expect(mockOnConnectionStatus).toHaveBeenCalledTimes(1) + + unmount() + expect(unsubscribe).toHaveBeenCalledTimes(1) + }) + + it('mirrors every pushed status into the store', () => { + const push = mountAndPush() + + for (const status of ['connecting', 'connected', 'disconnected', 'error'] as const) { + push({ status, descriptor: 'COM5', transport: 'rtu', debugTransport: 'rtu' }) + expect(mockSetDeviceConnectionStatus).toHaveBeenCalledWith(status, 'COM5', 'rtu', 'rtu') + } + }) + + it('mirrors a recovery attempt as connecting, with no transport claimed yet', () => { + const push = mountAndPush() + + push({ status: 'connecting', descriptor: 'COM5' }) + expect(mockSetDeviceConnectionStatus).toHaveBeenCalledWith('connecting', 'COM5', null, null) + }) + + it('warns the user only when recovery gave up', () => { + const push = mountAndPush() + + // An 'error' from something the user just clicked already has its own dialog. + push({ status: 'error', descriptor: 'COM5' }) + expect(mockOpenModal).not.toHaveBeenCalled() + + push({ status: 'error', descriptor: 'COM5', reason: 'lost' }) + expect(mockOpenModal).toHaveBeenCalledTimes(1) + const [modalId, data] = mockOpenModal.mock.calls[0] + expect(modalId).toBe('runtime-connection-lost') + expect(data).toMatchObject({ label: 'COM5' }) + expect(String((data as { body: string }).body)).toContain('COM5') + }) + + it('does not warn while the link is merely reconnecting', () => { + // The whole point of recovery: a cable pulled and plugged back in must not + // interrupt the user with a dialog. + const push = mountAndPush() + + push({ status: 'connecting', descriptor: 'COM5' }) + push({ status: 'connected', descriptor: 'COM5' }) + + expect(mockOpenModal).not.toHaveBeenCalled() + }) + + it('still names the device when the endpoint is unknown', () => { + const push = mountAndPush() + push({ status: 'error', reason: 'lost' }) + expect(mockOpenModal).toHaveBeenCalledWith('runtime-connection-lost', { + label: 'the device', + body: expect.stringContaining('the device'), + }) + }) + + it('advises the right thing to check for the transport that dropped', () => { + // "Check the cable" is useless advice for a link that ran over ethernet. + const push = mountAndPush() + push({ status: 'error', descriptor: '192.168.0.50', transport: 'tcp', reason: 'lost' }) + + const [, data] = mockOpenModal.mock.calls[0] + expect((data as { body: string }).body).toContain('192.168.0.50') + expect((data as { body: string }).body).toContain('network') + expect((data as { body: string }).body).not.toContain('cable') + }) +}) diff --git a/src/frontend/hooks/__tests__/use-device-plc-state.test.ts b/src/frontend/hooks/__tests__/use-device-plc-state.test.ts new file mode 100644 index 000000000..9d784b603 --- /dev/null +++ b/src/frontend/hooks/__tests__/use-device-plc-state.test.ts @@ -0,0 +1,92 @@ +/** + * useDevicePlcState — mirrors the held device link's run/stop state into the + * store. The hook owns no timer; it only translates what the main process + * already pushes on each liveness tick. + */ +import { renderHook } from '@testing-library/react' + +const mockSetPlcRuntimeStatus = jest.fn() +const mockSetPlcSwitchPosition = jest.fn() + +/** Captures the callback the hook subscribes with, so tests can drive it. */ +let pushed: ((payload: { port: string; plcState?: number; switchPosition?: number }) => void) | null = null +const mockUnsubscribe = jest.fn() +let onPlcStateImpl: unknown = (cb: (p: { port: string; plcState?: number; switchPosition?: number }) => void) => { + pushed = cb + return mockUnsubscribe +} + +jest.mock('../../../middleware/shared/providers', () => ({ + useDevice: () => ({ onPlcState: onPlcStateImpl }), +})) + +jest.mock('../../store', () => ({ + useOpenPLCStore: (selector: (s: unknown) => unknown) => + selector({ + deviceActions: { + setPlcRuntimeStatus: mockSetPlcRuntimeStatus, + setPlcSwitchPosition: mockSetPlcSwitchPosition, + }, + }), +})) + +import { useDevicePlcState } from '../use-device-plc-state' + +describe('useDevicePlcState', () => { + beforeEach(() => { + jest.clearAllMocks() + pushed = null + onPlcStateImpl = (cb: (p: { port: string; plcState?: number; switchPosition?: number }) => void) => { + pushed = cb + return mockUnsubscribe + } + }) + + it('maps a RUNNING push with the switch in RUN', () => { + renderHook(() => useDevicePlcState()) + pushed!({ port: '/dev/x', plcState: 1, switchPosition: 1 }) + + expect(mockSetPlcRuntimeStatus).toHaveBeenCalledWith('RUNNING') + expect(mockSetPlcSwitchPosition).toHaveBeenCalledWith('run') + }) + + it('maps STOPPED with the switch in STOP', () => { + renderHook(() => useDevicePlcState()) + pushed!({ port: '/dev/x', plcState: 0, switchPosition: 0 }) + + expect(mockSetPlcRuntimeStatus).toHaveBeenCalledWith('STOPPED') + expect(mockSetPlcSwitchPosition).toHaveBeenCalledWith('stop') + }) + + it('maps the ERROR state', () => { + renderHook(() => useDevicePlcState()) + pushed!({ port: '/dev/x', plcState: 2, switchPosition: 1 }) + + expect(mockSetPlcRuntimeStatus).toHaveBeenCalledWith('ERROR') + }) + + it('leaves the status untouched when the firmware reports no state', () => { + // Firmware predating the run/stop state machine omits the field. Inventing a + // status would make the button lie, so the hook writes nothing. + renderHook(() => useDevicePlcState()) + pushed!({ port: '/dev/x' }) + + expect(mockSetPlcRuntimeStatus).not.toHaveBeenCalled() + // ...and the switch reads as "unknown", which the start pre-check must treat + // as "no gating" rather than blocking. + expect(mockSetPlcSwitchPosition).toHaveBeenCalledWith(null) + }) + + it('is inert on a platform whose DevicePort has no held link', () => { + // The web platform has no serial link, so the optional method is absent. + onPlcStateImpl = undefined + expect(() => renderHook(() => useDevicePlcState())).not.toThrow() + expect(mockSetPlcRuntimeStatus).not.toHaveBeenCalled() + }) + + it('unsubscribes on unmount', () => { + const { unmount } = renderHook(() => useDevicePlcState()) + unmount() + expect(mockUnsubscribe).toHaveBeenCalled() + }) +}) diff --git a/src/frontend/hooks/__tests__/use-runtime-polling.test.ts b/src/frontend/hooks/__tests__/use-runtime-polling.test.ts index 8f956442e..8c482650a 100644 --- a/src/frontend/hooks/__tests__/use-runtime-polling.test.ts +++ b/src/frontend/hooks/__tests__/use-runtime-polling.test.ts @@ -5,6 +5,7 @@ import { renderHook } from '@testing-library/react' // rule Vitest's `vi.hoisted` was originally written against. Spelled out // long-hand (no Vitest API) so the suite runs under plain Jest. const mockSetPlcRuntimeStatus = jest.fn() +const mockSetPlcSwitchPosition = jest.fn() const mockSetTimingStats = jest.fn() const mockSetEthercatStatus = jest.fn() const mockSetRuntimeJwtToken = jest.fn() @@ -28,6 +29,7 @@ const mockState: Record = { workspace: { plcLogs: '', plcLogsLastId: null }, deviceActions: { setPlcRuntimeStatus: mockSetPlcRuntimeStatus, + setPlcSwitchPosition: mockSetPlcSwitchPosition, setTimingStats: mockSetTimingStats, setEthercatStatus: mockSetEthercatStatus, setRuntimeJwtToken: mockSetRuntimeJwtToken, diff --git a/src/frontend/hooks/use-device-connect.ts b/src/frontend/hooks/use-device-connect.ts new file mode 100644 index 000000000..25c9cf53a --- /dev/null +++ b/src/frontend/hooks/use-device-connect.ts @@ -0,0 +1,127 @@ +/** + * useDeviceConnect (D72) — the persistent CONNECT for USB device screens. + * + * "Connect" opens the device channel and — unlike the earlier transient probe — + * the main process HOLDS the link open (a liveness poll keeps it honest, the + * port yields to upload/debug and reconnects afterwards). This hook drives that + * toggle and the follow-up UX from the initial classification: + * + * - no-response → channel wouldn't open (wrong port / busy) → error dialog. + * - no-firmware → opened, but nothing spoke the debug protocol → offer to + * Build & Upload (flash) the firmware, then reconnect. + * - connected-with-firmware → link held. + * + * Live link state (`deviceConnection.status`) is pushed from the main process. + */ +import type { BoardInfo } from '@root/middleware/shared/ports/types' +import { useDevice } from '@root/middleware/shared/providers/platform-context' +import { describeDebugEndpoint } from '@root/middleware/shared/utils/debug-endpoint' +import { useCallback } from 'react' + +import { resolveDeviceLinkWithUx } from '../services/device-link-resolution' +import { useOpenPLCStore } from '../store' +import { requestDeviceFlash } from '../utils/device-connect-events' + +export interface UseDeviceConnectResult { + /** Open + hold the link for the given board. Never throws. */ + connect: () => Promise + /** Close the held link. */ + disconnect: () => Promise + /** Live link status, mirrored from the main process. */ + status: 'disconnected' | 'connecting' | 'connected' | 'error' + /** Convenience flags derived from `status`. */ + isConnecting: boolean + isConnected: boolean +} + +export function useDeviceConnect(boardInfo: BoardInfo | undefined): UseDeviceConnectResult { + const device = useDevice() + const openModal = useOpenPLCStore((s) => s.modalActions.openModal) + const setDeviceConnectionStatus = useOpenPLCStore((s) => s.deviceActions.setDeviceConnectionStatus) + const status = useOpenPLCStore((s) => s.deviceConnection.status) + + const connect = useCallback(async (): Promise => { + const deviceBoard = useOpenPLCStore.getState().deviceDefinitions.configuration.deviceBoard + + // FIRST PASS: everything that needs nothing from the user — serial, then + // Modbus TCP on a static address. `deferPrompts` means a DHCP channel is set + // aside rather than interrupting with an address dialog, because with a cable + // attached the user should never be asked for one. + const silent = await resolveDeviceLinkWithUx(deviceBoard, boardInfo, { deferPrompts: true }) + if (!silent) return + if (silent.candidates.length === 0 && silent.awaitingInput.length === 0) return + + const tried: string[] = [] + setDeviceConnectionStatus('connecting', null) + + let result: { status: 'connected-with-firmware' | 'no-firmware' | 'no-response' | 'error'; error?: string } = { + status: 'no-response', + } + if (silent.candidates.length > 0) { + tried.push(...silent.candidates.map((candidate) => describeDebugEndpoint(candidate.config))) + result = await device.connect(silent.candidates.map((candidate) => candidate.config)) + } + + // SECOND PASS: nothing silent worked, so now it is worth asking. Resolving + // only the deferred channels surfaces the address dialog, and a cancel here + // ends the attempt rather than looping. + if (result.status !== 'connected-with-firmware' && silent.awaitingInput.length > 0) { + const prompted = await resolveDeviceLinkWithUx(deviceBoard, boardInfo, { + onlyChannels: silent.awaitingInput, + }) + if (prompted && prompted.candidates.length > 0) { + tried.push(...prompted.candidates.map((candidate) => describeDebugEndpoint(candidate.config))) + result = await device.connect(prompted.candidates.map((candidate) => candidate.config)) + } + } + + const endpoints = tried.join(' or ') || 'this device' + + if (result.status === 'no-response') { + openModal('debugger-message', { + type: 'error', + title: 'No Response', + message: `Could not reach the device on ${endpoints}. Check that it is powered and plugged in, and that the port or IP address is correct.`, + buttons: ['OK'], + onResponse: () => undefined, + }) + return + } + + if (result.status === 'error') { + openModal('debugger-message', { + type: 'error', + title: 'Connection Error', + message: result.error ?? 'An unexpected error occurred while connecting to the device.', + buttons: ['OK'], + onResponse: () => undefined, + }) + return + } + + if (result.status === 'no-firmware') { + openModal('debugger-message', { + type: 'question', + title: 'No Firmware Detected', + message: `No OpenPLC firmware responded on ${endpoints}. Build & Upload the program to flash this device, then Connect again.`, + buttons: ['Build & Upload', 'Cancel'], + onResponse: (buttonIndex: number) => { + if (buttonIndex === 0) requestDeviceFlash() + }, + }) + } + }, [boardInfo, device, openModal, setDeviceConnectionStatus]) + + const disconnect = useCallback(async (): Promise => { + await device.disconnect() + setDeviceConnectionStatus('disconnected', null) + }, [device, setDeviceConnectionStatus]) + + return { + connect, + disconnect, + status, + isConnecting: status === 'connecting', + isConnected: status === 'connected', + } +} diff --git a/src/frontend/hooks/use-device-connection-monitor.ts b/src/frontend/hooks/use-device-connection-monitor.ts new file mode 100644 index 000000000..4916a2c67 --- /dev/null +++ b/src/frontend/hooks/use-device-connection-monitor.ts @@ -0,0 +1,128 @@ +/** + * useDeviceConnectionMonitor — mirrors the held baremetal serial link into the + * store, and warns when it is lost for good. + * + * This is a WORKSPACE-level concern, not a device-screen one. The link outlives + * the screen that opened it: an upload, a debug session and the Start/Stop button + * all depend on `deviceConnection.status` being true. Subscribing inside the + * device screen left the store reading 'connected' after the cable was pulled + * whenever the user happened to be editing a POU, so every request timed out + * against a link the UI still advertised as up. + * + * The main process owns the state machine (liveness poll -> reopen attempts -> + * give up); this hook only reflects it. A cable pulled and plugged back in shows + * up as connected -> connecting -> connected with nothing to click. The warning + * fires only on `reason: 'lost'`, i.e. recovery gave up — an 'error' raised by + * something the user just clicked already has its own dialog, and warning twice + * for one click is worse than not warning at all. + * + * Mount once at the workspace level, next to `useDevicePlcState`. + */ +import { useEffect } from 'react' + +import { useDevice } from '../../middleware/shared/providers' +import { resolveRuntimeDebugChannel } from '../services/device-link-resolution' +import { useOpenPLCStore } from '../store' + +/** + * Keep the main process's session in step with a Runtime v3/v4 login. + * + * A runtime target is CONTROLLED over REST, which is connectionless — logging in + * is what establishes its session. This mirrors that: when the runtime connection + * comes up, tell the manager where control lives and how this target debugs; when + * it goes down, close the session. The debug channel itself is not opened here — + * the debugger asks for it when it needs it. + * + * The debug channel is DESCRIBED by resolving the board's spec (in the resolution + * service, which owns spec interpretation) — legitimate at session-establishment + * time. No command ever resolves anything. + */ +const useRuntimeSession = (): void => { + const device = useDevice() + const connectionStatus = useOpenPLCStore((state) => state.runtimeConnection.connectionStatus) + const jwtToken = useOpenPLCStore((state) => state.runtimeConnection.jwtToken) + + useEffect(() => { + if (!device.openRuntimeSession) return + + if (connectionStatus !== 'connected') { + void device.closeRuntimeSession?.() + return + } + + const store = useOpenPLCStore.getState() + const boardTarget = store.deviceDefinitions.configuration.deviceBoard + const boardInfo = store.deviceAvailableOptions.availableBoards.get(boardTarget) + const address = store.runtimeConnection.ipAddress + + // Every early return says why. Returning quietly is what let a runtime target + // end up with no session at all while the UI showed it connected, so that every + // command answered "not connected" on a target the user had just uploaded to. + if (!address) { + store.consoleActions.addLog({ + id: crypto.randomUUID(), + level: 'warning', + message: '[connection] runtime is connected but has no address recorded; no session opened', + }) + return + } + const debugChannel = resolveRuntimeDebugChannel(boardTarget, boardInfo) + if (!debugChannel) { + store.consoleActions.addLog({ + id: crypto.randomUUID(), + level: 'warning', + message: `[connection] no debug channel could be described for ${boardTarget}; debugging will not be available`, + }) + return + } + + void device.openRuntimeSession({ address, debug: debugChannel }).then((result) => { + if (!result.success) { + store.consoleActions.addLog({ + id: crypto.randomUUID(), + level: 'error', + message: `[connection] could not open the runtime session: ${result.error ?? 'unknown error'}`, + }) + } + }) + }, [device, connectionStatus, jwtToken]) +} + +export const useDeviceConnectionMonitor = (): void => { + useRuntimeSession() + const device = useDevice() + const addLog = useOpenPLCStore((state) => state.consoleActions.addLog) + const setDeviceConnectionStatus = useOpenPLCStore((state) => state.deviceActions.setDeviceConnectionStatus) + const openModal = useOpenPLCStore((state) => state.modalActions.openModal) + + // Mirror the main process's connection trace into the console. The interesting + // decisions (which candidate was tried, what each poll concluded, which + // connection served a command) happen in main; without this the user watching + // the UI sees only "connecting..." and then a failure. + useEffect(() => { + if (!device.onLinkLog) return + return device.onLinkLog((message) => { + addLog({ id: crypto.randomUUID(), level: 'info', message: `[connection] ${message}` }) + }) + }, [device, addLog]) + + useEffect(() => { + return device.onConnectionStatus(({ status, descriptor, transport, debugTransport, reason }) => { + setDeviceConnectionStatus(status, descriptor ?? null, transport ?? null, debugTransport ?? null) + + if (status === 'error' && reason === 'lost') { + const endpoint = descriptor ?? 'the device' + // Name the endpoint AND what to check for that transport: "the cable" is + // useless advice for a link that was running over ethernet. + const advice = + transport === 'tcp' + ? 'Check that the device is powered and reachable on the network, then Connect again.' + : 'Check that the cable is plugged in and the port is not in use, then Connect again.' + openModal('runtime-connection-lost', { + label: endpoint, + body: `The connection to ${endpoint} was lost and could not be restored. ${advice}`, + }) + } + }) + }, [device, setDeviceConnectionStatus, openModal]) +} diff --git a/src/frontend/hooks/use-device-plc-state.ts b/src/frontend/hooks/use-device-plc-state.ts new file mode 100644 index 000000000..9ce1ffa88 --- /dev/null +++ b/src/frontend/hooks/use-device-plc-state.ts @@ -0,0 +1,61 @@ +/** + * useDevicePlcState — mirrors a baremetal target's run/stop state into the store. + * + * There is no timer here. The main process already polls the held device link to + * keep it honest, and that liveness read is the status frame (FC 0x46), which + * carries the run/stop state and the mode-switch position. This hook only + * subscribes to what that tick already pushes, so the Start/Stop button tracks + * the device — including a switch flipped by hand at the panel — without any + * extra traffic, a second timer, or a transient connection. + * + * Writes the SAME `runtimeConnection.plcStatus` the Runtime v4 poll writes, so + * every consumer (the button icon, its tooltip, the debugger's "PLC is stopped" + * prompt) works unchanged regardless of target type. + * + * Mount once at the workspace level, next to `useRuntimePolling`. + */ +import { useEffect } from 'react' + +import { PlcRuntimeState, PlcSwitchPosition } from '../../backend/shared/simulator/types' +import type { PlcStatus } from '../../middleware/shared/ports/types' +import { useDevice } from '../../middleware/shared/providers' +import { useOpenPLCStore } from '../store' + +/** Map the wire value to the store's PlcStatus union. */ +function toPlcStatus(state: number | undefined): PlcStatus | null { + switch (state) { + case PlcRuntimeState.RUNNING: + return 'RUNNING' + case PlcRuntimeState.STOPPED: + return 'STOPPED' + case PlcRuntimeState.ERROR: + return 'ERROR' + default: + // Firmware predating the run/stop state machine omits the field. Leave the + // status untouched rather than inventing one — the button then behaves as + // it did before, and the Start path reports `unsupported` if used. + return null + } +} + +export const useDevicePlcState = (): void => { + const device = useDevice() + const setPlcRuntimeStatus = useOpenPLCStore((state) => state.deviceActions.setPlcRuntimeStatus) + const setPlcSwitchPosition = useOpenPLCStore((state) => state.deviceActions.setPlcSwitchPosition) + + useEffect(() => { + // Optional on the port: the web platform has no held serial link. + if (!device.onPlcState) return + return device.onPlcState(({ plcState, switchPosition }) => { + const status = toPlcStatus(plcState) + if (status !== null) setPlcRuntimeStatus(status) + + // Absent on older firmware, which means "no switch gating" — null rather + // than a guessed 'run', so the pre-check can tell "no switch" from + // "switch says RUN". + setPlcSwitchPosition( + switchPosition === PlcSwitchPosition.STOP ? 'stop' : switchPosition === PlcSwitchPosition.RUN ? 'run' : null, + ) + }) + }, [device, setPlcRuntimeStatus, setPlcSwitchPosition]) +} diff --git a/src/frontend/hooks/use-runtime-polling.ts b/src/frontend/hooks/use-runtime-polling.ts index 294722135..c7986612a 100644 --- a/src/frontend/hooks/use-runtime-polling.ts +++ b/src/frontend/hooks/use-runtime-polling.ts @@ -22,6 +22,7 @@ export const useRuntimePolling = () => { const connectionStatus = useOpenPLCStore((state) => state.runtimeConnection.connectionStatus) const jwtToken = useOpenPLCStore((state) => state.runtimeConnection.jwtToken) const setPlcRuntimeStatus = useOpenPLCStore((state) => state.deviceActions.setPlcRuntimeStatus) + const setPlcSwitchPosition = useOpenPLCStore((state) => state.deviceActions.setPlcSwitchPosition) const setTimingStats = useOpenPLCStore((state) => state.deviceActions.setTimingStats) const setEthercatStatus = useOpenPLCStore((state) => state.deviceActions.setEthercatStatus) const openModal = useOpenPLCStore((state) => state.modalActions.openModal) @@ -36,6 +37,7 @@ export const useRuntimePolling = () => { deviceActions.setRuntimeJwtToken(null) deviceActions.setRuntimeConnectionStatus('disconnected') deviceActions.setPlcRuntimeStatus(null) + deviceActions.setPlcSwitchPosition(null) deviceActions.setTimingStats(null) deviceActions.setEthercatStatus(null) }, []) @@ -116,6 +118,9 @@ export const useRuntimePolling = () => { ? (rawStatus as PlcStatus) : 'UNKNOWN' setPlcRuntimeStatus(plcStatus) + // Runtime v4 reports the mode-switch position alongside the state; + // absent on older runtimes, which means "no gating". + setPlcSwitchPosition(statusResult.switchPosition ?? null) if (includeTimingStatsInPolling && statusResult.timingStats) { setTimingStats(statusResult.timingStats) @@ -169,7 +174,7 @@ export const useRuntimePolling = () => { } finally { isPollingRef.current = false } - }, [runtime, handleConnectionLost, setPlcRuntimeStatus, setTimingStats, setEthercatStatus]) + }, [runtime, handleConnectionLost, setPlcRuntimeStatus, setPlcSwitchPosition, setTimingStats, setEthercatStatus]) // Keep the store's connection token in lock-step with the platform's token // authority. When the authority transparently refreshes an expired token diff --git a/src/frontend/hooks/useDebugSession.ts b/src/frontend/hooks/useDebugSession.ts index f309f5fa1..99d70ed4b 100644 --- a/src/frontend/hooks/useDebugSession.ts +++ b/src/frontend/hooks/useDebugSession.ts @@ -12,8 +12,8 @@ import { useCallback, useRef } from 'react' -import type { DebugConnectionConfig, DebugTreeNode, FbInstanceInfo } from '../../middleware/shared/ports/types' -import { useDebugger, useSimulator } from '../../middleware/shared/providers' +import type { DebugTreeNode, FbInstanceInfo } from '../../middleware/shared/ports/types' +import { useDebugger } from '../../middleware/shared/providers' import { useOpenPLCStore } from '../store' import { parseDebugMap } from '../utils/debug-parser' import { @@ -32,10 +32,10 @@ export interface UseDebugSessionReturn { * connects via the debugger port, stores all artifacts in workspace, * and activates the debugger UI. * - * @param config — Connection target (simulator, TCP, RTU, WebSocket). - * If omitted, defaults to simulator. + * Takes nothing: the connection manager holds the session for every target by the + * time a debug session can start, so there is no medium for a caller to name. */ - connectAndStart: (config?: DebugConnectionConfig) => Promise<{ success: boolean; error?: string }> + connectAndStart: () => Promise<{ success: boolean; error?: string }> /** Disconnect from the debug target and clear all debug state. */ stopSession: () => Promise @@ -49,7 +49,6 @@ export interface UseDebugSessionReturn { export function useDebugSession(): UseDebugSessionReturn { const debuggerPort = useDebugger() - const simulator = useSimulator() const { project: { data: projectData, meta: projectMeta }, @@ -61,8 +60,7 @@ export function useDebugSession(): UseDebugSessionReturn { const debugTreesRef = useRef>({}) const connectAndStart = useCallback( - async (config?: DebugConnectionConfig): Promise<{ success: boolean; error?: string }> => { - const debugConfig = config ?? ({ connectionType: 'simulator', connectionParams: {} } as DebugConnectionConfig) + async (): Promise<{ success: boolean; error?: string }> => { const { project, workspaceActions: wsActions, consoleActions: logActions } = useOpenPLCStore.getState() const boardTarget = deviceDefinitions.configuration.deviceBoard const projectPath = project.meta.path @@ -154,7 +152,7 @@ export function useDebugSession(): UseDebugSessionReturn { } // Connect debugger via port - const connectResult = await debuggerPort.connect(debugConfig) + const connectResult = await debuggerPort.connect() if (!connectResult.success) { const error = `Debugger connection failed: ${connectResult.error ?? 'Unknown error'}` logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) @@ -174,9 +172,10 @@ export function useDebugSession(): UseDebugSessionReturn { }) // Set target IP for non-simulator connections - if (debugConfig.connectionType !== 'simulator' && debugConfig.connectionParams.ipAddress) { - wsActions.setDebuggerTargetIp(debugConfig.connectionParams.ipAddress) - } + // The target's address, for the debugger's own display. Comes from the + // session the manager holds, not from a config the caller chose. + const sessionEndpoint = useOpenPLCStore.getState().deviceConnection.port + if (sessionEndpoint) wsActions.setDebuggerTargetIp(sessionEndpoint) // Record the active transport so useDebugPolling picks the right // poll cadence + batch size. Set on EVERY start path (runtime @@ -186,7 +185,9 @@ export function useDebugSession(): UseDebugSessionReturn { // the default 200ms instead of its intended 50ms). Must be set // before `setDebuggerVisible(true)`, which is what triggers the // polling effect. - wsActions.setDebugConnectionType(debugConfig.connectionType) + // The medium is the manager's choice, mirrored in the store; read it rather + // than assuming one. `debugTransport` because this sizes the debug poll. + wsActions.setDebugConnectionType(useOpenPLCStore.getState().deviceConnection.debugTransport ?? 'simulator') wsActions.setDebuggerVisible(true) logActions.addLog({ @@ -205,19 +206,20 @@ export function useDebugSession(): UseDebugSessionReturn { [debuggerPort, deviceDefinitions, projectData, projectMeta], ) + /** + * End the debug session — and ONLY the debug session. + * + * It used to stop the simulator too, which had the ownership backwards: a debug + * session is a consumer of a connection, not the owner of the thing on the other + * end. Stopping the simulator is the Stop button's job (`handleSimulatorControl`), + * and closing that session is the connection manager's. + */ const stopSession = useCallback(async () => { - // If simulator is running, stop it - if (simulator.isRunning()) { - await simulator.stop() - } - - // Disconnect debugger await debuggerPort.disconnect() - // Clear all debug state workspaceActions.clearDebugState() debugTreesRef.current = {} - }, [simulator, debuggerPort, workspaceActions]) + }, [debuggerPort, workspaceActions]) const forceVariable = useCallback( async (index: number, force: boolean, value?: string, type?: string, enumValues?: string[]): Promise => { diff --git a/src/frontend/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index 8bad1b9f9..fc73bacab 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -52,6 +52,8 @@ import { useDebugNonBoolValuesMap, useIsDebuggerVisible, } from '../hooks/use-debug-value' +import { useDeviceConnectionMonitor } from '../hooks/use-device-connection-monitor' +import { useDevicePlcState } from '../hooks/use-device-plc-state' import { useRuntimePolling } from '../hooks/use-runtime-polling' import { forceDebugVariable, releaseDebugVariable } from '../services/debug-force-variable' import { useOpenPLCStore } from '../store' @@ -151,6 +153,10 @@ const WorkspaceScreen = () => { // Start global runtime polling for status and logs useRuntimePolling() + // Mirrors a baremetal target's run/stop state from the held device link's + // existing liveness tick (no timer of its own). + useDevicePlcState() + useDeviceConnectionMonitor() // Build debug variables from POUs with debug=true const allDebugVariables = useMemo(() => { diff --git a/src/frontend/services/__tests__/device-link-resolution.test.ts b/src/frontend/services/__tests__/device-link-resolution.test.ts new file mode 100644 index 000000000..566a67c8a --- /dev/null +++ b/src/frontend/services/__tests__/device-link-resolution.test.ts @@ -0,0 +1,111 @@ +/** + * Describing a Runtime v3/v4 target's debug channel — through the SAME resolver + * Connect uses, with the target's declared transports deciding what is eligible. + * + * The regression these pin: eligibility used to be a hardcoded serial-then-TCP list + * inside the resolver, so a `websocket` channel was never a candidate. No runtime + * session was ever opened, and every command then answered "not connected" on a + * target the user had connected to and uploaded a program to. The fix is not a + * second code path for runtimes — it is asking the target which media it speaks. + */ +const mockAddLog = jest.fn() + +const mockState: Record = { + deviceDefinitions: { configuration: { deviceBoard: 'OpenPLC Runtime v4', runtimeIpAddress: '192.168.0.42' } }, + runtimeConnection: { connectionStatus: 'connected', jwtToken: 'jwt-token' }, + consoleActions: { addLog: mockAddLog }, +} + +type Selector = (s: typeof mockState) => T +const mockUseOpenPLCStore = ((selector?: Selector) => + selector ? selector(mockState) : mockState) as unknown as jest.Mock & { getState: () => typeof mockState } +mockUseOpenPLCStore.getState = () => mockState + +jest.mock('../../store', () => ({ useOpenPLCStore: mockUseOpenPLCStore })) + +import type { DebugSpec } from '../../../backend/shared/hardware/debug-spec' +import type { BoardInfo } from '../../../middleware/shared/ports/types' +import { resolveRuntimeDebugChannel } from '../device-link-resolution' + +/** A board carries BOTH halves: the spec says how a channel is built, the + * capability matrix says which channels the target can actually speak. */ +const boardWith = (spec: DebugSpec, transports: string[]): BoardInfo => + ({ debug: spec, capabilities: { debuggerTransports: transports } }) as unknown as BoardInfo + +/** The shape a Runtime v4 board declares — an SLM-RP4's, verbatim. */ +const v4Spec: DebugSpec = { + preconditions: ['runtimeConnected', 'jwtToken'], + channels: [ + { + label: 'WebSocket', + channel: 'websocket', + enabledWhen: true, + params: { + ipAddress: { $ref: 'configuration.runtimeIpAddress', required: 'Runtime IP address is not configured.' }, + jwtToken: { $ref: 'runtimeConnection.jwtToken', required: 'JWT token missing. Reconnect to the runtime.' }, + }, + }, + ], +} + +/** Runtime v3: same shape, debugged over Modbus TCP instead. */ +const v3Spec: DebugSpec = { + preconditions: ['runtimeConnected'], + channels: [ + { + label: 'Modbus TCP', + channel: 'tcp', + enabledWhen: true, + params: { ipAddress: { $ref: 'configuration.runtimeIpAddress', required: 'Runtime IP address is not set.' } }, + }, + ], +} + +beforeEach(() => { + jest.clearAllMocks() + mockState.runtimeConnection = { connectionStatus: 'connected', jwtToken: 'jwt-token' } +}) + +describe('resolveRuntimeDebugChannel', () => { + it('describes a v4 target as its WebSocket channel', () => { + const config = resolveRuntimeDebugChannel('OpenPLC Runtime v4', boardWith(v4Spec, ['websocket'])) + + expect(config).not.toBeNull() + expect(config?.connectionType).toBe('websocket') + expect(config?.connectionParams.ipAddress).toBe('192.168.0.42') + expect(config?.connectionParams.jwtToken).toBe('jwt-token') + }) + + it('describes a v3 target as its Modbus TCP channel', () => { + const config = resolveRuntimeDebugChannel('OpenPLC Runtime v3', boardWith(v3Spec, ['modbus-tcp'])) + + expect(config?.connectionType).toBe('tcp') + expect(config?.connectionParams.ipAddress).toBe('192.168.0.42') + }) + + it('returns null and SAYS SO when a board declares no debug spec', () => { + // Failing quietly is what hid the bug above until it reached hardware. + expect(resolveRuntimeDebugChannel('Some Board', undefined)).toBeNull() + expect(mockAddLog).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('no debug spec') }), + ) + }) + + it('returns null and says why when the spec cannot be satisfied', () => { + // v4 requires a JWT; without one the resolver refuses, and the user should be + // able to see that rather than meet "not connected" later. + mockState.runtimeConnection = { connectionStatus: 'connected', jwtToken: null } + + expect(resolveRuntimeDebugChannel('OpenPLC Runtime v4', boardWith(v4Spec, ['websocket']))).toBeNull() + expect(mockAddLog).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('could NOT describe a debug channel') }), + ) + }) + + it('traces the channel it settled on', () => { + resolveRuntimeDebugChannel('OpenPLC Runtime v4', boardWith(v4Spec, ['websocket'])) + expect(mockAddLog).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('debug channel is websocket') }), + ) + }) +}) diff --git a/src/frontend/services/device-link-resolution.ts b/src/frontend/services/device-link-resolution.ts new file mode 100644 index 000000000..c882b7d17 --- /dev/null +++ b/src/frontend/services/device-link-resolution.ts @@ -0,0 +1,319 @@ +/** + * Turning a board's declarative `debug` spec into something connectable, with the + * dialogs that sometimes takes. + * + * Two flows need this and they used to share nothing: + * + * - Connect (and the reconnect after an upload) needs the ORDERED CANDIDATES for + * the device link — Modbus TCP when the project enables it, serial otherwise. + * - The debugger needs ONE channel for a session (or, for a runtime target, its + * WebSocket). + * + * What they have in common is the interactive part: a spec can ask for input (the + * DHCP address) or offer a choice, which means resolve → ask → resolve again. That + * loop lives here once. When only Connect had it, Connect could not ask for a DHCP + * address at all; when only the debugger had it, Connect silently mis-resolved. + * + * Deliberately not a hook: it is called from click handlers, keeps no React state, + * and the modal helpers below reach the store directly — so the same functions + * serve the activity bar and the device screen without either owning the other. + */ +import { + type DebugResolverContext, + type DeviceLinkCandidateConfig, + resolveDeviceLinkCandidates, +} from '../../backend/shared/hardware/debug-spec' +import type { BoardInfo, DebugConnectionConfig } from '../../middleware/shared/ports/types' +import { describeDebugEndpoint } from '../../middleware/shared/utils/debug-endpoint' +import { resolveTargetCapabilities } from '../../middleware/shared/utils/target-capabilities' +import { useOpenPLCStore } from '../store' + +/** + * Answers the user has already given, keyed by board then by the spec's + * `cacheKey`. Scoped per board so two devices sharing a cache key (`lastDhcpIp`) + * do not inherit each other's address. Module-level: it should outlive any one + * screen, since the same answer serves Connect and the debugger. + */ +const promptCache: Record> = {} + +/** Discard cached answers for a board — used when an entered value stops working. */ +export function forgetPromptAnswers(boardTarget: string): void { + delete promptCache[boardTarget] +} + +/** + * The editor's device dialogs, in one place. Exported because the flows that + * surround resolution — the debug gate, the switch warning, upload prompts — need + * to speak in the same voice, and a second copy of this two-line promise wrapper + * is how two callers end up with subtly different buttons. + */ +export const showDeviceDialog = ( + type: 'info' | 'warning' | 'error' | 'question', + title: string, + message: string, + buttons: string[], + /** Which button is the primary, and which one Escape / click-away chooses. */ + options?: { primaryButtonIndex?: number; dismissButtonIndex?: number }, +): Promise => + new Promise((resolve) => { + useOpenPLCStore.getState().modalActions.openModal('debugger-message', { + type, + title, + message, + buttons, + ...options, + onResponse: (buttonIndex: number) => resolve(buttonIndex), + }) + }) + +export const showDeviceInput = (title: string, message: string, defaultValue: string): Promise => + new Promise((resolve) => { + useOpenPLCStore.getState().modalActions.openModal('debugger-ip-input', { + title, + message, + defaultValue, + onSubmit: (value: string) => resolve(value), + onCancel: () => resolve(null), + }) + }) + +/** + * Build resolver context from current store state on every call, so the user's + * freshest screen edits count without saving first. `boardTarget` selects the + * prompt-cache bucket. + * + * `runtimeReadyForDebug` is passed in rather than read here: it comes from the + * runtime port, which only a component can reach, and it is meaningless for the + * baremetal flows. + */ +export function buildDeviceResolverContext( + boardTarget: string, + options: { runtimeReadyForDebug?: boolean } = {}, +): DebugResolverContext { + const store = useOpenPLCStore.getState() + const cfg = store.deviceDefinitions.configuration + const runtimeConnection = store.runtimeConnection + // `vendorScreenData` is already keyed by section id (`modbus_rtu`), which is + // the resolver's `screens` shape 1:1. + const screens = (cfg.vendorScreenData ?? {}) as Record> + + return { + state: { + configuration: { + deviceBoard: cfg.deviceBoard, + ...(cfg.communicationPort ? { communicationPort: cfg.communicationPort } : {}), + ...(cfg.runtimeIpAddress ? { runtimeIpAddress: cfg.runtimeIpAddress } : {}), + }, + screens, + runtimeConnection: { + ...(runtimeConnection.connectionStatus ? { connectionStatus: runtimeConnection.connectionStatus } : {}), + ...(runtimeConnection.jwtToken ? { jwtToken: runtimeConnection.jwtToken } : {}), + }, + promptCache: promptCache[boardTarget] ?? {}, + }, + capabilities: { + runtimeConnected: options.runtimeReadyForDebug === true && runtimeConnection.connectionStatus === 'connected', + jwtToken: Boolean(runtimeConnection.jwtToken), + }, + } +} + +/** Outcomes both resolvers can return besides their own success shape. */ +type InteractiveOutcome = + | { kind: 'pick'; channels: Array<{ index: number; label: string }>; title: string; body: string } + | { + kind: 'prompt' + fields: Array<{ field: string; title: string; message: string; cacheKey?: string; defaultValue?: string }> + channelIndex: number + } + | { kind: 'error'; title: string; body: string } + | { kind: 'unsupported' } + +/** What the caller should do next after a non-success outcome. */ +type NextStep = + /** Resolve again; `channelIndex` is the user's pick, if they made one. */ + | { retry: true; channelIndex?: number } + /** Stop: the user cancelled, or there is nothing to connect to. */ + | { retry: false } + +/** + * Surface whatever the resolver asked for, and say whether to resolve again. + * + * A cancelled prompt or picker stops the flow — the user said no, so no dialog is + * repeated and nothing is guessed on their behalf. + */ +async function handleInteractiveOutcome(outcome: InteractiveOutcome, boardTarget: string): Promise { + if (outcome.kind === 'error') { + await showDeviceDialog('warning', outcome.title, outcome.body, ['OK']) + return { retry: false } + } + if (outcome.kind === 'unsupported') return { retry: false } + + if (outcome.kind === 'pick') { + const choice = await showDeviceDialog( + 'question', + outcome.title, + outcome.body, + outcome.channels.map((channel) => channel.label), + ) + if (choice < 0 || choice >= outcome.channels.length) return { retry: false } + return { retry: true, channelIndex: outcome.channels[choice].index } + } + + // prompt: collect every field, caching answers the spec asked to remember. + const bucket = (promptCache[boardTarget] ??= {}) + for (const field of outcome.fields) { + const previous = field.cacheKey ? bucket[field.cacheKey] : undefined + const answer = await showDeviceInput(field.title, field.message, previous ?? field.defaultValue ?? '') + if (answer === null) return { retry: false } + const trimmed = answer.trim() + if (!trimmed) return { retry: false } + if (field.cacheKey) bucket[field.cacheKey] = trimmed + } + return { retry: true, channelIndex: outcome.channelIndex } +} + +/** + * Trace resolution into the console. Resolution happens HERE, in the renderer, + * from the project's screen data — so when a transport is not attempted at all, + * this is the only place that can say why. + */ +function trace(message: string): void { + useOpenPLCStore.getState().consoleActions.addLog({ + id: crypto.randomUUID(), + level: 'info', + message: `[connection] ${message}`, + }) +} + +/** Guard against a malformed spec bouncing between prompts forever. */ +const MAX_RESOLVE_ROUNDS = 8 + +/** What resolution found, and what it deliberately left unasked. */ +export interface ResolvedDeviceLink { + /** Ways to reach the device that need nothing from the user, in try-order. */ + candidates: DeviceLinkCandidateConfig[] + /** + * Channel indexes that could also be tried, but only after asking the user + * something. Resolve again with `onlyChannels: awaitingInput` to ask. + */ + awaitingInput: number[] +} + +/** + * Resolve the ways to reach a baremetal device: serial first, then Modbus TCP. + * + * By default this asks the user NOTHING — a channel needing input is reported in + * `awaitingInput` instead. That is what lets Connect try the cable before asking + * for a DHCP address, so a user with a cable attached is never interrupted by a + * dialog about an address they do not need to know. + * + * Pass `onlyChannels` to resolve just those channels, asking whatever they need; + * that is the second pass, run only once everything silent has failed. + * + * Returns null if the user cancelled, or the board declares nothing connectable + * (the dialog explaining why has already been shown). + */ +export async function resolveDeviceLinkWithUx( + boardTarget: string, + boardInfo: BoardInfo | undefined, + options: { runtimeReadyForDebug?: boolean; onlyChannels?: number[]; deferPrompts?: boolean } = {}, +): Promise { + const spec = boardInfo?.debug + const transports = resolveTargetCapabilities(boardInfo).debuggerTransports + if (!spec) { + await showDeviceDialog( + 'warning', + 'Cannot Connect', + 'This board has not declared a debug spec, so the editor has no way to reach it. The VPP package must provide a `debug` block.', + ['OK'], + ) + return null + } + + const resolverOptions = { + transports, + ...(options.onlyChannels ? { onlyChannels: options.onlyChannels } : {}), + ...(options.deferPrompts ? { deferPrompts: true } : {}), + } + + for (let round = 0; round < MAX_RESOLVE_ROUNDS; round += 1) { + const context = buildDeviceResolverContext(boardTarget, options) + const outcome = resolveDeviceLinkCandidates(spec, context, resolverOptions) + if (outcome.kind === 'candidates') { + trace( + `resolved ${outcome.candidates.length} candidate(s) for ${boardTarget}: ${ + outcome.candidates + .map((candidate) => `${candidate.config.connectionType} ${describeDebugEndpoint(candidate.config)}`) + .join(', ') || 'none' + }${outcome.awaitingInput.length ? ` (+${outcome.awaitingInput.length} needing input, not asked yet)` : ''}`, + ) + return { candidates: outcome.candidates, awaitingInput: outcome.awaitingInput } + } + // Say what the spec concluded and what it was reading, so a transport that is + // never attempted can be traced to the screen value that ruled it out. + trace( + `resolution returned "${outcome.kind}"${outcome.kind === 'error' ? `: ${outcome.body}` : ''} — modbus_tcp=${JSON.stringify( + context.state.screens.modbus_tcp ?? null, + )} modbus_rtu=${JSON.stringify(context.state.screens.modbus_rtu ?? null)} port=${String( + context.state.configuration.communicationPort ?? 'none', + )}`, + ) + + const next = await handleInteractiveOutcome(outcome, boardTarget) + if (!next.retry) return null + } + return null +} + +/** + * The channel a Runtime v3/v4 debugs over: the WebSocket for v4, Modbus TCP for v3. + * Used when a runtime login establishes a session, so the manager knows how to open + * that channel later. + * + * Uses the SINGLE-channel resolver, not the candidate one. A runtime declares + * exactly one debug channel and there is nothing to choose between or order — while + * `resolveDeviceLinkCandidates` exists to order a baremetal board's serial and + * Modbus TCP options, and collects only those two kinds. Pointing it at a + * `websocket` channel therefore found nothing eligible, returned an error, and left + * every runtime target without a session: "nothing is connected" for both the + * debugger and run/stop, on a target the user had plainly connected to. + * + * Never prompts (v3/v4 specs declare no prompts) and traces its own failure, because + * a session that cannot be described must not fail silently — that silence is what + * hid this until it reached hardware. + */ +export function resolveRuntimeDebugChannel( + boardTarget: string, + boardInfo: BoardInfo | undefined, +): DebugConnectionConfig | null { + const spec = boardInfo?.debug + if (!spec) { + trace(`${boardTarget}: no debug spec, so no debug channel can be described`) + return null + } + + // The SAME resolver Connect uses. A runtime declares exactly one debug transport + // in its capability matrix (`['websocket']` for v4, `['modbus-tcp']` for v3), so + // the ordered candidate list has one entry — no separate code path, and no + // hardcoded assumption here about what a runtime debugs over. + const outcome = resolveDeviceLinkCandidates( + spec, + buildDeviceResolverContext(boardTarget, { runtimeReadyForDebug: true }), + { transports: resolveTargetCapabilities(boardInfo).debuggerTransports, deferPrompts: true }, + ) + if (outcome.kind === 'candidates' && outcome.candidates.length > 0) { + const [channel] = outcome.candidates + trace(`${boardTarget}: debug channel is ${channel.config.connectionType} (${channel.channelLabel})`) + return channel.config + } + + // Never silently: a session that cannot be described leaves every later command + // answering "not connected" on a target the user believes they are connected to. + trace( + `${boardTarget}: could NOT describe a debug channel — resolver returned "${outcome.kind}"${ + outcome.kind === 'error' ? `: ${outcome.body}` : '' + }`, + ) + return null +} diff --git a/src/frontend/store/__tests__/device-slice.test.ts b/src/frontend/store/__tests__/device-slice.test.ts index d92bc5ee4..604401a1e 100644 --- a/src/frontend/store/__tests__/device-slice.test.ts +++ b/src/frontend/store/__tests__/device-slice.test.ts @@ -155,6 +155,43 @@ describe('createDeviceSlice', () => { expect(store.getState().deviceActions).toBeDefined() expect(typeof store.getState().deviceActions.setAvailableOptions).toBe('function') }) + + it('has a disconnected serial connection', () => { + const store = makeStore() + expect(store.getState().deviceConnection).toEqual({ status: 'disconnected', port: null, transport: null, debugTransport: null }) + }) + }) + + // ----------------------------------------------------------------------- + // serial connection (D72 persistent link) + // ----------------------------------------------------------------------- + describe('serial connection', () => { + it('setDeviceConnectionStatus updates status and port', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connecting', 'COM5') + expect(store.getState().deviceConnection).toEqual({ status: 'connecting', port: 'COM5', transport: null, debugTransport: null }) + }) + + it('setDeviceConnectionStatus leaves the port unchanged when omitted', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connecting', 'COM5') + store.getState().deviceActions.setDeviceConnectionStatus('connected') + expect(store.getState().deviceConnection).toEqual({ status: 'connected', port: 'COM5', transport: null, debugTransport: null }) + }) + + it('setDeviceConnectionStatus can explicitly clear the port with null', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connected', 'COM5') + store.getState().deviceActions.setDeviceConnectionStatus('error', null) + expect(store.getState().deviceConnection).toEqual({ status: 'error', port: null, transport: null, debugTransport: null }) + }) + + it('clearDeviceConnection resets to disconnected/null', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connected', 'COM5') + store.getState().deviceActions.clearDeviceConnection() + expect(store.getState().deviceConnection).toEqual({ status: 'disconnected', port: null, transport: null, debugTransport: null }) + }) }) // ----------------------------------------------------------------------- @@ -173,7 +210,7 @@ describe('createDeviceSlice', () => { it('sets available communication ports', () => { const store = makeStore() - const ports: CommunicationPort[] = [{ name: 'COM3', address: '/dev/ttyUSB0' }] + const ports: CommunicationPort[] = [{ address: '/dev/ttyUSB0', manufacturer: 'FTDI' }] store.getState().deviceActions.setAvailableOptions({ availableCommunicationPorts: ports }) expect(store.getState().deviceAvailableOptions.availableCommunicationPorts).toEqual(ports) }) @@ -185,14 +222,14 @@ describe('createDeviceSlice', () => { ]) store.getState().deviceActions.setAvailableOptions({ availableBoards: boards }) store.getState().deviceActions.setAvailableOptions({ - availableCommunicationPorts: [{ name: 'COM1', address: '/dev/tty1' }], + availableCommunicationPorts: [{ address: '/dev/tty1' }], }) expect(store.getState().deviceAvailableOptions.availableBoards.size).toBe(1) }) it('does not overwrite ports when only boards given', () => { const store = makeStore() - const ports: CommunicationPort[] = [{ name: 'COM1', address: '/dev/tty1' }] + const ports: CommunicationPort[] = [{ address: '/dev/tty1' }] store.getState().deviceActions.setAvailableOptions({ availableCommunicationPorts: ports }) store.getState().deviceActions.setAvailableOptions({ availableBoards: new Map(), @@ -304,6 +341,13 @@ describe('createDeviceSlice', () => { expect(rc.ethercatStatus).toBeNull() expect(rc.includeEthercatStatsInPolling).toBe(false) }) + + it('resets the serial connection', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connected', 'COM5') + store.getState().deviceActions.clearDeviceDefinitions() + expect(store.getState().deviceConnection).toEqual({ status: 'disconnected', port: null, transport: null, debugTransport: null }) + }) }) // ----------------------------------------------------------------------- diff --git a/src/frontend/store/__tests__/device-types.test.ts b/src/frontend/store/__tests__/device-types.test.ts index 41de632aa..ee6a2dba3 100644 --- a/src/frontend/store/__tests__/device-types.test.ts +++ b/src/frontend/store/__tests__/device-types.test.ts @@ -9,6 +9,8 @@ import type { PinUpdateResponse, RuntimeConnection, SelectedDevice, + DeviceConnection, + DeviceConnectionStatus, StoredCredentials, } from '../slices/device' @@ -95,6 +97,7 @@ describe('Device slice types', () => { jwtToken: null, connectionStatus: 'disconnected', plcStatus: null, + switchPosition: null, ipAddress: null, runtimeVersion: null, selectedDevice: null, @@ -137,6 +140,7 @@ describe('Device slice types', () => { jwtToken: 'token', connectionStatus: 'connected', plcStatus: 'RUNNING', + switchPosition: 'run', ipAddress: '192.168.1.1', runtimeVersion: 'v4.1.9', selectedDevice: { @@ -178,6 +182,7 @@ describe('Device slice types', () => { jwtToken: null, connectionStatus: 'disconnected', plcStatus: null, + switchPosition: null, ipAddress: null, runtimeVersion: null, selectedDevice: null, @@ -187,11 +192,29 @@ describe('Device slice types', () => { ethercatStatus: null, includeEthercatStatsInPolling: false, }, + deviceConnection: { status: 'disconnected', port: null, transport: null, debugTransport: null }, } expect(state.deviceAvailableOptions).toBeDefined() expect(state.deviceDefinitions).toBeDefined() expect(state.deviceUpdated).toBeDefined() expect(state.runtimeConnection).toBeDefined() + expect(state.deviceConnection).toBeDefined() + }) + }) + + // ----------------------------------------------------------------------- + // DeviceConnection + // ----------------------------------------------------------------------- + describe('DeviceConnection', () => { + it('accepts every status', () => { + const statuses: DeviceConnectionStatus[] = ['disconnected', 'connecting', 'connected', 'error'] + const conns: DeviceConnection[] = statuses.map((status) => ({ + status, + port: status === 'connected' ? 'COM5' : null, + transport: status === 'connected' ? 'rtu' : null, + debugTransport: status === 'connected' ? 'rtu' : null, + })) + expect(conns).toHaveLength(4) }) }) diff --git a/src/frontend/store/slices/device/index.ts b/src/frontend/store/slices/device/index.ts index fb1725ef7..89bfbc0aa 100644 --- a/src/frontend/store/slices/device/index.ts +++ b/src/frontend/store/slices/device/index.ts @@ -3,6 +3,8 @@ export type { ConnectionStatus, DeviceActions, DeviceAvailableOptions, + DeviceConnection, + DeviceConnectionStatus, DevicePinMapping, DeviceSlice, DeviceState, diff --git a/src/frontend/store/slices/device/slice.ts b/src/frontend/store/slices/device/slice.ts index 3740ab094..f5f77384e 100644 --- a/src/frontend/store/slices/device/slice.ts +++ b/src/frontend/store/slices/device/slice.ts @@ -50,6 +50,7 @@ const createDeviceSlice: StateCreator = (s jwtToken: null, connectionStatus: 'disconnected', plcStatus: null, + switchPosition: null, ipAddress: null, runtimeVersion: null, selectedDevice: null, @@ -59,6 +60,12 @@ const createDeviceSlice: StateCreator = (s ethercatStatus: null, includeEthercatStatsInPolling: false, }, + deviceConnection: { + status: 'disconnected', + port: null, + transport: null, + debugTransport: null, + }, deviceActions: { setAvailableOptions: ({ availableBoards, availableCommunicationPorts }): void => { @@ -109,7 +116,7 @@ const createDeviceSlice: StateCreator = (s }, clearDeviceDefinitions: (): void => { setState( - produce(({ deviceDefinitions, runtimeConnection }: DeviceSlice) => { + produce(({ deviceDefinitions, runtimeConnection, deviceConnection }: DeviceSlice) => { deviceDefinitions.configuration = defaultDeviceConfiguration deviceDefinitions.pinMapping = { pinsByBoard: {}, @@ -126,6 +133,12 @@ const createDeviceSlice: StateCreator = (s runtimeConnection.includeTimingStatsInPolling = false runtimeConnection.ethercatStatus = null runtimeConnection.includeEthercatStatsInPolling = false + // The held device link is meaningless once the project is closed — + // reset it so a stale connection can't leak into the next one. + deviceConnection.status = 'disconnected' + deviceConnection.port = null + deviceConnection.transport = null + deviceConnection.debugTransport = null }), ) }, @@ -461,6 +474,13 @@ const createDeviceSlice: StateCreator = (s }), ) }, + setPlcSwitchPosition: (position): void => { + setState( + produce(({ runtimeConnection }: DeviceSlice) => { + runtimeConnection.switchPosition = position + }), + ) + }, setSelectedDevice: (device): void => { setState( produce(({ runtimeConnection }: DeviceSlice) => { @@ -527,6 +547,26 @@ const createDeviceSlice: StateCreator = (s }), ) }, + setDeviceConnectionStatus: (status, port, transport, debugTransport): void => { + setState( + produce(({ deviceConnection }: DeviceSlice) => { + deviceConnection.status = status + if (port !== undefined) deviceConnection.port = port + if (transport !== undefined) deviceConnection.transport = transport + if (debugTransport !== undefined) deviceConnection.debugTransport = debugTransport + }), + ) + }, + clearDeviceConnection: (): void => { + setState( + produce(({ deviceConnection }: DeviceSlice) => { + deviceConnection.status = 'disconnected' + deviceConnection.port = null + deviceConnection.transport = null + deviceConnection.debugTransport = null + }), + ) + }, setVendorScreenData: (persistenceKey, data): void => { setState( produce(({ deviceDefinitions, deviceUpdated }: DeviceSlice) => { diff --git a/src/frontend/store/slices/device/types.ts b/src/frontend/store/slices/device/types.ts index 5a035b325..daaf7251f 100644 --- a/src/frontend/store/slices/device/types.ts +++ b/src/frontend/store/slices/device/types.ts @@ -63,6 +63,13 @@ export type RuntimeConnection = { jwtToken: string | null connectionStatus: ConnectionStatus plcStatus: PlcStatus | null + /** Run/stop mode-switch position of the connected target, or null when + * unknown. Lives next to `plcStatus` so the Start/Stop button, its tooltip + * and the start pre-check all read one value, whatever the target type: + * Runtime v4 fills it from `/api/status`, baremetal from the device status + * poll. `'run'` on any device without a physical switch, so a null-safe + * caller treats absence as "no gating". */ + switchPosition: 'run' | 'stop' | null ipAddress: string | null /** Version string reported by the connected runtime (from * get-users-info / the X-OpenPLC-Runtime-Version header), or null @@ -76,6 +83,36 @@ export type RuntimeConnection = { includeEthercatStatsInPolling: boolean } +// --------------------------------------------------------------------------- +// Persistent serial connection (D72) — baremetal "stay connected" +// --------------------------------------------------------------------------- + +export type DeviceConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error' + +/** + * Live state of the connection the main process holds to a baremetal target, + * mirroring the connection manager — which remains the source of truth. Purely + * whether the connection is up, and over what. + */ +export type DeviceConnection = { + status: DeviceConnectionStatus + /** Endpoint the connection is on (or was last attempted on): a serial path or an IP. */ + port: string | null + /** + * Medium the CONTROL channel uses. Read-only mirror — nothing in the renderer + * picks a transport. Null for a REST-controlled runtime session, which holds no + * connection. + */ + transport: 'rtu' | 'tcp' | 'simulator' | null + /** + * Medium the DEBUG channel uses. The debug poll sizes its batches to this, because + * the frame budget differs enormously by wire (WebSocket 500 variables per round + * trip, Modbus TCP 60, RTU 19). Mirrored from the manager rather than inferred: a + * v4 session left this unset and silently polled with TCP-sized batches. + */ + debugTransport: 'rtu' | 'tcp' | 'simulator' | 'websocket' | null +} + // --------------------------------------------------------------------------- // Device state // --------------------------------------------------------------------------- @@ -91,6 +128,7 @@ export type DeviceState = { updated: boolean } runtimeConnection: RuntimeConnection + deviceConnection: DeviceConnection } // --------------------------------------------------------------------------- @@ -150,6 +188,8 @@ export type DeviceActions = { setRuntimeConnectionStatus: (status: ConnectionStatus) => void setRuntimeVersion: (version: string | null) => void setPlcRuntimeStatus: (status: PlcStatus | null) => void + /** Set the mode-switch position (null clears it, e.g. on disconnect). */ + setPlcSwitchPosition: (position: 'run' | 'stop' | null) => void setSelectedDevice: (device: SelectedDevice | null) => void setStoredCredentials: (credentials: StoredCredentials | null) => void setTimingStats: (stats: TimingStats | null) => void @@ -158,6 +198,15 @@ export type DeviceActions = { setIncludeEthercatStatsInPolling: (include: boolean) => void setTemporaryDhcpIp: (ipAddress?: string) => void clearRuntimeConnection: () => void + /** Set the persistent serial link state (optionally the port it's on). */ + setDeviceConnectionStatus: ( + status: DeviceConnectionStatus, + port?: string | null, + transport?: DeviceConnection['transport'], + debugTransport?: DeviceConnection['debugTransport'], + ) => void + /** Reset the serial link to disconnected/null. */ + clearDeviceConnection: () => void setVendorScreenData: (persistenceKey: string, data: unknown) => void /** Restore `vendorScreenData[k]` for every k in `ownedKeys`: from * `snapshot[k]` when present, else by deleting the key. Used by diff --git a/src/frontend/utils/__tests__/serial-port-label.test.ts b/src/frontend/utils/__tests__/serial-port-label.test.ts new file mode 100644 index 000000000..132b867af --- /dev/null +++ b/src/frontend/utils/__tests__/serial-port-label.test.ts @@ -0,0 +1,98 @@ +import { serialPortDisplay } from '../serial-port-label' + +/** + * This helper is the ONE place a port's label is decided, so the platform + * expectations are asserted here rather than inferred from the producer. + */ +describe('serialPortDisplay', () => { + describe('the path always leads', () => { + it('labels a Windows port by its COM number', () => { + expect(serialPortDisplay({ address: 'COM5', boardName: 'Arduino Uno' })).toEqual({ + label: 'COM5 (Arduino Uno)', + title: 'COM5 (Arduino Uno)', + }) + }) + + it('labels a macOS port by its /dev/cu path', () => { + expect(serialPortDisplay({ address: '/dev/cu.usbmodem11101', boardName: 'Arduino MKR' })).toEqual({ + label: '/dev/cu.usbmodem11101 (Arduino MKR)', + title: '/dev/cu.usbmodem11101 (Arduino MKR)', + }) + }) + + it('labels a Linux port by its /dev/tty path', () => { + expect(serialPortDisplay({ address: '/dev/ttyACM0', boardName: 'Arduino Mega' })).toEqual({ + label: '/dev/ttyACM0 (Arduino Mega)', + title: '/dev/ttyACM0 (Arduino Mega)', + }) + }) + + it('never replaces the path with the descriptor', () => { + // The original bug: a NodeMCU reading "wch.cn" instead of "COM5". + const { label } = serialPortDisplay({ address: 'COM5', manufacturer: 'wch.cn' }) + expect(label.startsWith('COM5')).toBe(true) + expect(label).not.toBe('wch.cn') + }) + }) + + describe('descriptor precedence', () => { + it('prefers the arduino-cli board name over the manufacturer', () => { + // Both scans found something; the board name is the specific one. + expect( + serialPortDisplay({ address: 'COM1', boardName: 'Opta', manufacturer: 'Arduino' }), + ).toEqual({ label: 'COM1 (Opta)', title: 'COM1 (Opta)' }) + }) + + it('falls back to the manufacturer when arduino-cli identified no board', () => { + expect(serialPortDisplay({ address: 'COM6', manufacturer: 'com0com - serial port emulator' })).toEqual({ + label: 'COM6 (com0com - serial port emulator)', + title: 'COM6 (com0com - serial port emulator)', + }) + }) + + it('shows the bare path when neither scan knew a descriptor', () => { + expect(serialPortDisplay({ address: '/dev/ttyUSB0' })).toEqual({ + label: '/dev/ttyUSB0', + title: undefined, + }) + }) + + it('treats a blank descriptor as absent', () => { + expect(serialPortDisplay({ address: 'COM3', boardName: ' ', manufacturer: '' })).toEqual({ + label: 'COM3', + title: undefined, + }) + }) + + it('falls through a blank board name to the manufacturer', () => { + expect(serialPortDisplay({ address: 'COM3', boardName: ' ', manufacturer: 'FTDI' })).toEqual({ + label: 'COM3 (FTDI)', + title: 'COM3 (FTDI)', + }) + }) + }) + + describe('degenerate inputs', () => { + it('falls back to the descriptor when the address is empty', () => { + expect(serialPortDisplay({ address: '', boardName: 'Arduino Uno' })).toEqual({ + label: 'Arduino Uno', + title: undefined, + }) + }) + + it('trims surrounding whitespace', () => { + expect(serialPortDisplay({ address: ' COM5 ', manufacturer: ' wch.cn ' })).toEqual({ + label: 'COM5 (wch.cn)', + title: 'COM5 (wch.cn)', + }) + }) + + it('cannot double-wrap, because it never receives a composed string', () => { + // The regression this structure prevents: when the producer pre-composed + // `name`, the renderer had to guess whether it already contained the path. + const { label } = serialPortDisplay({ address: 'COM5', boardName: 'Arduino Uno' }) + expect(label).toBe('COM5 (Arduino Uno)') + expect(label).not.toContain('COM5 (COM5') + }) + }) +}) diff --git a/src/frontend/utils/device-connect-events.ts b/src/frontend/utils/device-connect-events.ts new file mode 100644 index 000000000..d1f06e2d8 --- /dev/null +++ b/src/frontend/utils/device-connect-events.ts @@ -0,0 +1,23 @@ +/** + * Tiny decoupled bridge for the CONNECT flow (D72). The device screen's + * "no firmware" dialog lives in `board.tsx`, but Build & Upload lives in the + * workspace activity bar (`default.tsx`). Rather than hoist build state up or + * thread refs across the component tree, the dialog fires a DOM CustomEvent the + * activity bar listens for. Same window, synchronous dispatch — no payload. + */ +const FLASH_REQUEST_EVENT = 'openplc:device-flash-request' + +/** Ask the workspace activity bar to run Build & Upload (flash the firmware). */ +export function requestDeviceFlash(): void { + window.dispatchEvent(new CustomEvent(FLASH_REQUEST_EVENT)) +} + +/** + * Subscribe to flash requests. Returns an unsubscribe function suitable for a + * React effect cleanup. + */ +export function onDeviceFlashRequest(handler: () => void): () => void { + const listener = (): void => handler() + window.addEventListener(FLASH_REQUEST_EVENT, listener) + return () => window.removeEventListener(FLASH_REQUEST_EVENT, listener) +} diff --git a/src/frontend/utils/serial-port-label.ts b/src/frontend/utils/serial-port-label.ts new file mode 100644 index 000000000..3b697d4e6 --- /dev/null +++ b/src/frontend/utils/serial-port-label.ts @@ -0,0 +1,41 @@ +import type { CommunicationPort } from '../../middleware/shared/ports/types' + +/** + * How a serial port reads in the communication-port picker: + * `/dev/cu.usbmodem11101 (Arduino MKR)`, `COM5 (Arduino Uno)`, `COM5 (wch.cn)`. + * + * Two rules, and they used to pull against each other: + * + * 1. The path always leads. It is what the user recognizes and what we + * actually open — `COM5` on Windows, `/dev/ttyUSB0` on Linux, + * `/dev/cu.usbmodem*` on macOS — so it is never replaced by a descriptor. + * (The bug that motivated this: a NodeMCU reading "wch.cn" instead of + * "COM5", because the label took a manufacturer string over the path.) + * 2. The descriptor survives, in parentheses. It is what distinguishes two + * identical-looking `/dev/cu.usbmodem*` nodes, and dropping it was the + * regression that followed. + * + * Both hold because this composes rather than choosing, and it is the ONE place + * that decides — `CommunicationPort` carries facts (`address`, `boardName`, + * `manufacturer`), never a pre-composed string. That is what makes every + * platform behave identically: there is no second labelling path to drift. + * + * Descriptor precedence: arduino-cli's identified board name first (it is the + * specific, useful one), falling back to the OS vendor/manufacturer string, and + * to nothing at all when neither scan knew anything. + */ +export function serialPortDisplay(port: CommunicationPort): { label: string; title?: string } { + const address = port.address?.trim() ?? '' + const descriptor = port.boardName?.trim() || port.manufacturer?.trim() || '' + + // No path to lead with (shouldn't happen — the enumerator keys on it) — the + // descriptor is all there is. + if (!address) return { label: descriptor, title: undefined } + + if (!descriptor) return { label: address, title: undefined } + + const label = `${address} (${descriptor})` + // Offer the full string on hover as well: a composed label is the one most + // likely to be truncated by the dropdown's width. + return { label, title: label } +} diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index e0f8cc87b..4b31d9765 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -1,8 +1,15 @@ import { ESIService } from '@root/backend/editor/ethercat' import { createDesktopCatalogTransport } from '@root/backend/editor/library-manager/desktop-catalog-transport' import { getRuntimeHttpsOptions } from '@root/backend/editor/utils/runtime-https-config' +import type { + DebugStatusResult, + DeviceDebugChannel, + DeviceModbusTransport, + PlcControlResult, +} from '@root/backend/shared/debug/types' import { parseESIDeviceFull } from '@root/backend/shared/ethercat/esi-parser-main' import { listPublicLibraries } from '@root/backend/shared/library/public-catalog-client' +import { PlcRuntimeState } from '@root/backend/shared/simulator/types' import { PLCProjectData } from '@root/backend/shared/types/PLC/open-plc' import { getErrorMessage } from '@root/frontend/utils/get-error-message' import { RuntimeLogEntry } from '@root/middleware/shared/ports' @@ -22,6 +29,7 @@ import type { ListPublicLibrariesResponse, } from '@root/middleware/shared/ports/public-catalog-types' import type { RuntimeUser, RuntimeUserRole, UpdateUserParams } from '@root/middleware/shared/ports/runtime-port' +import type { DebugConnectionConfig } from '@root/middleware/shared/ports/types' import { createRuntimeTokenManager } from '@root/middleware/shared/runtime-auth/runtime-token-manager' import { CreatePouFileProps } from '@root/types/IPC/pou-service' import { CreateProjectFileProps } from '@root/types/IPC/project-service' @@ -38,9 +46,23 @@ import { join, resolve, sep } from 'path' import { platform } from 'process' import { MainIpcModule, MainIpcModuleConstructor } from '../../../backend/editor/contracts/types/modules/ipc/main' +import { + type DeviceDebugCandidate, + type DeviceLinkCandidate, + type DeviceLinkStatus, + DeviceSessionManager, +} from '../../../backend/editor/hardware/device-session-manager' +import { + buildDeviceModbusTransport, + modbusTransportKind, +} from '../../../backend/editor/hardware/device-transport-factory' +import { + classifyDeviceLink, + type DeviceProbeOutcome, + PATIENT_BOARD_ID_PROBE, + QUICK_BOARD_ID_PROBE, +} from '../../../backend/editor/hardware/device-probe' import { LibraryManagerModule } from '../../../backend/editor/library-manager' -import { ModbusTcpClient } from '../../../backend/editor/modbus/modbus-client' -import { ModbusRtuClient } from '../../../backend/editor/modbus/modbus-rtu-client' import { PackageManagerModule } from '../../../backend/editor/package-manager' import { logger } from '../../../backend/editor/services' import { @@ -52,6 +74,18 @@ import { import { WebSocketDebugTransport } from '../../../backend/shared/debug/websocket-debug-transport' import { SimulatorModule } from '../../../backend/shared/simulator/simulator-module' import { VirtualSerialPort } from '../../../backend/shared/simulator/virtual-serial-port' +import { describeDebugEndpoint } from '../../../middleware/shared/utils/debug-endpoint' + +/** Why a channel could not be handed out. */ +interface ChannelUnavailable { + error: string + needsReconnect: true +} + +/** Program-identity comparison, case-insensitively — targets report either case. */ +function matchesMd5(targetMd5: string, expectedMd5: string): boolean { + return targetMd5.toLowerCase() === expectedMd5.toLowerCase() +} class MainProcessBridge implements MainIpcModule { ipcMain @@ -63,15 +97,29 @@ class MainProcessBridge implements MainIpcModule { compilerModule hardwareModule private registeredHandleChannels: string[] = [] - private debuggerModbusClient: ModbusTcpClient | ModbusRtuClient | null = null - private debuggerWebSocketClient: WebSocketDebugTransport | null = null - private debuggerTargetIp: string | null = null - private debuggerReconnecting: boolean = false + // --------------------------------------------------------------------------- + // Talking to a baremetal device + // + // ONE session, owned by `deviceSession`, whatever media it runs over: the + // debugger, run/stop and the status poll all borrow that one client. + // Nothing else here opens a Modbus client — see `device-link-manager.ts` for + // why (in short: three owners meant a run/stop command could open a second + // socket the board would not answer). + // + // The runtime-v4 WebSocket is the one transport that is NOT a device link: it + // is a different protocol to a different kind of target, so it keeps its own + // client and its own session identity. + // --------------------------------------------------------------------------- + private readonly deviceSession = new DeviceSessionManager({ + verify: (client, candidate, context) => this.verifyDeviceCandidate(client, candidate, context), + probe: (client) => this.probeDeviceLink(client), + serialPortPresent: (port) => this.hardwareModule.isSerialPortPresent(port), + emit: (status) => this.emitDeviceLinkStatus(status), + log: (message) => this.traceDeviceLink(message), + }) + /** Classification of the candidate the held link came from. */ + private deviceLinkProbe: DeviceProbeOutcome | null = null private debuggerConnectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator' | null = null - private debuggerRtuPort: string | null = null - private debuggerRtuBaudRate: number | null = null - private debuggerRtuSlaveId: number | null = null - private debuggerJwtToken: string | null = null // Address of the runtime this session is authenticated against. Captured at // login so the token authority can re-authenticate against the same device. private runtimeIp: string | null = null @@ -636,10 +684,14 @@ class MainProcessBridge implements MainIpcModule { const result = await this.makeRuntimeApiRequest<{ status: string timing_stats?: TimingStatsResponse + // Run/stop mode-switch position. Absent on runtimes older than the + // run/stop interface — treat undefined as "no gating". + switchPosition?: 'run' | 'stop' }>(ipAddress, endpoint, (data: string) => { const response = JSON.parse(data) as { status: string timing_stats?: TimingStatsResponse + switchPosition?: 'run' | 'stop' } return response }) @@ -664,6 +716,7 @@ class MainProcessBridge implements MainIpcModule { success: true, status: result.data.status, timingStats, + ...(result.data.switchPosition ? { switchPosition: result.data.switchPosition } : {}), } } else { return { success: false, error: !result.success ? result.error : 'Unknown error' } @@ -673,24 +726,7 @@ class MainProcessBridge implements MainIpcModule { } } - handleRuntimeStartPlc = async (_event: IpcMainInvokeEvent, ipAddress: string) => { - try { - // Parse the body so the renderer can drive a retry-on-BUSY - // loop around `COMMAND:BUSY` replies (the runtime answers BUSY - // while it's still unloading the previous program after an - // upload). See `backend/shared/library/start-plc-after-build.ts`. - const result = await this.makeRuntimeApiRequest<{ status?: string }>( - ipAddress, - '/api/start-plc', - (data: string) => JSON.parse(data) as { status?: string }, - ) - if (!result.success) return { success: false, error: result.error } - const status = (result.data?.status ?? '').trim() - return { success: true, status } - } catch (error) { - return { success: false, error: getErrorMessage(error) } - } - } + handleRuntimeStartPlc = (_event: IpcMainInvokeEvent, ipAddress: string) => this.restStartPlc(ipAddress) handleRuntimeStopPlc = async (_event: IpcMainInvokeEvent, ipAddress: string) => { try { @@ -1017,11 +1053,17 @@ class MainProcessBridge implements MainIpcModule { // ===================== DEBUGGER ===================== this.registerHandle('debugger:verify-md5', this.handleDebuggerVerifyMd5) + this.registerHandle('debugger:plc-control', this.handleDebuggerPlcControl) this.registerHandle('debugger:read-program-st-md5', this.handleReadProgramStMd5) this.registerHandle('debugger:get-variables-list', this.handleDebuggerGetVariablesList) this.registerHandle('debugger:set-variable', this.handleDebuggerSetVariable) this.registerHandle('debugger:connect', this.handleDebuggerConnect) this.registerHandle('debugger:disconnect', this.handleDebuggerDisconnect) + this.registerHandle('device:connect', this.handleDeviceConnect) + this.registerHandle('device:disconnect', this.handleDeviceDisconnect) + this.registerHandle('device:release-serial-port', this.handleDeviceReleaseSerialPort) + this.registerHandle('session:open-runtime', this.handleOpenRuntimeSession) + this.registerHandle('session:close-runtime', this.handleCloseRuntimeSession) // ===================== RUNTIME API ===================== this.registerHandle('runtime:get-users-info', this.handleRuntimeGetUsersInfo) @@ -1629,16 +1671,16 @@ class MainProcessBridge implements MainIpcModule { } } + /** + * Confirm the target is running the program that was just built. + * + * Modbus targets (serial, TCP, simulator) read this over the ONE held device + * link, so the check runs on the same connection every later command uses — no + * second client, and nothing to reconnect afterwards. Runtime v4 reads it over + * its own WebSocket, which is a different protocol to a different target. + */ handleDebuggerVerifyMd5 = async ( _event: IpcMainInvokeEvent, - connectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator', - connectionParams: { - ipAddress?: string - port?: string - baudRate?: number - slaveId?: number - jwtToken?: string - }, expectedMd5: string, ): Promise<{ success: boolean @@ -1647,111 +1689,134 @@ class MainProcessBridge implements MainIpcModule { targetEndian?: 'le' | 'be' error?: string }> => { - let client: ModbusTcpClient | ModbusRtuClient | null = null - let wsClient: WebSocketDebugTransport | null = null try { - if (connectionType === 'simulator') { - const virtualPort = new VirtualSerialPort(this.simulatorModule) - client = new ModbusRtuClient({ - port: 'simulator', - baudRate: 115200, - slaveId: 1, - timeout: 5000, - serialPort: virtualPort, - }) - await client.connect() - const { md5: targetMd5, targetEndian } = await client.getMd5Hash() - const match = targetMd5.toLowerCase() === expectedMd5.toLowerCase() - - // Keep the client for subsequent debug operations - this.debuggerModbusClient = client - this.debuggerConnectionType = 'simulator' - - return { success: true, match, targetMd5, targetEndian } - } else if (connectionType === 'websocket') { - if (!connectionParams.ipAddress || !connectionParams.jwtToken) { - return { success: false, error: 'IP address and JWT token are required for WebSocket connection' } - } - if (!this.debuggerWebSocketClient) { - wsClient = new WebSocketDebugTransport({ - host: connectionParams.ipAddress, - port: 8443, - token: connectionParams.jwtToken, - rejectUnauthorized: false, - }) - await wsClient.connect() - } else { - wsClient = this.debuggerWebSocketClient - } - - const { md5: targetMd5, targetEndian } = await wsClient.getMd5Hash() + const channel = await this.requireDebug('verify md5') + if ('error' in channel) return { success: false, error: channel.error } - const match = targetMd5.toLowerCase() === expectedMd5.toLowerCase() + const probe = await channel.client.getMd5Hash() + return { success: true, match: matchesMd5(probe.md5, expectedMd5), ...probe } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error during MD5 verification', + } + } + } - if (!this.debuggerWebSocketClient) { - this.debuggerWebSocketClient = wsClient - this.debuggerTargetIp = connectionParams.ipAddress - this.debuggerJwtToken = connectionParams.jwtToken - this.debuggerConnectionType = 'websocket' - } + /** + * FC 0x4b run/stop for a baremetal target. + * + * Command only — the state is READ from the status poll (FC 0x46), which already + * reports it, so there is no second round trip here. + * + * Goes over the ONE held device link, whatever transport that link runs over. + * The transport the DEBUGGER is using is not consulted, and no client is opened: + * this is a command to the device, and the connection to it already exists. + * + * That is precisely what was broken. The old code only recognised an RTU client + * as reusable, so with a live Modbus TCP session a Stop fell through to opening a + * transient second socket — which an Arduino Modbus TCP server, serving one + * client at a time, never answered. The user saw "Failed to stop PLC: Request + * timeout" while a working connection sat idle. + */ + handleDebuggerPlcControl = async ( + _event: IpcMainInvokeEvent, + action: 'run' | 'stop', + ): Promise => { + this.traceDeviceLink(`run/stop: ${action} requested`) - return { success: true, match, targetMd5, targetEndian } - } else if (connectionType === 'tcp') { - if (!connectionParams.ipAddress) { - return { success: false, error: 'IP address is required for TCP connection' } - } - client = new ModbusTcpClient({ - host: connectionParams.ipAddress, - port: 502, - timeout: 5000, - }) - } else { - if (!connectionParams.port || !connectionParams.baudRate || connectionParams.slaveId === undefined) { - return { success: false, error: 'Port, baud rate, and slave ID are required for RTU connection' } - } + // Routed by the session's CONTROL channel, which is the whole point: the caller + // said "stop the PLC" and does not know or care whether that means a Modbus + // function code on a cable or an HTTP POST to a runtime. + const restAddress = this.deviceSession.getRestAddress() + if (restAddress !== null) return this.restSetPlcState(restAddress, action) - // Reuse existing RTU client if already connected to the same port - if ( - this.debuggerModbusClient && - this.debuggerConnectionType === 'rtu' && - this.debuggerRtuPort === connectionParams.port - ) { - const { md5: targetMd5, targetEndian } = await this.debuggerModbusClient.getMd5Hash() - const match = targetMd5.toLowerCase() === expectedMd5.toLowerCase() - return { success: true, match, targetMd5, targetEndian } - } + const link = this.requireControl('run/stop') + if ('error' in link) return { success: false, error: link.error } - client = new ModbusRtuClient({ - port: connectionParams.port, - baudRate: connectionParams.baudRate, - slaveId: connectionParams.slaveId, - timeout: 5000, - }) + const target = action === 'run' ? PlcRuntimeState.RUNNING : PlcRuntimeState.STOPPED + try { + return await link.client.setPlcState(target) + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error during PLC control request', } + } + } - await client.connect() - const { md5: targetMd5, targetEndian } = await client.getMd5Hash() + /** + * Run/stop over a REST control channel, reported in the same shape the Modbus + * path returns — so the caller handles one result type, not two. + * + * `ERROR_SWITCH_STOP` in the runtime's reply is its way of saying the hardware + * mode switch refused a start, which is exactly what `refusedBySwitch` means on + * the Modbus side (FC 0x4b status 0x86). + */ + private async restSetPlcState(address: string, action: 'run' | 'stop'): Promise { + const result = + action === 'run' ? await this.restStartPlc(address) : await this.makeRuntimeApiRequest(address, '/api/stop-plc') + if (!result.success) return { success: false, error: result.error } - const match = targetMd5.toLowerCase() === expectedMd5.toLowerCase() + const status = 'status' in result ? (result.status ?? '') : '' + if (status.includes('ERROR_SWITCH_STOP')) return { success: false, refusedBySwitch: true } - if (connectionType === 'tcp') { - client.disconnect() - } else { - this.debuggerModbusClient = client - this.debuggerConnectionType = 'rtu' - this.debuggerRtuPort = connectionParams.port! - this.debuggerRtuBaudRate = connectionParams.baudRate! - this.debuggerRtuSlaveId = connectionParams.slaveId! - } + // The runtime settles into the new state on its next scan; report the state the + // command asked for so the button can reflect it without a second round trip. + return { success: true, state: action === 'run' ? PlcRuntimeState.RUNNING : PlcRuntimeState.STOPPED } + } - return { success: true, match, targetMd5, targetEndian } + /** The `/api/start-plc` call, shared by the session router and the IPC handler. */ + private async restStartPlc(address: string): Promise<{ success: boolean; status?: string; error?: string }> { + try { + // The body is parsed because the runtime answers `COMMAND:BUSY` while it is + // still unloading a previous program after an upload, and callers drive a + // retry loop on that. See `backend/shared/library/start-plc-after-build.ts`. + const result = await this.makeRuntimeApiRequest<{ status?: string }>( + address, + '/api/start-plc', + (data: string) => JSON.parse(data) as { status?: string }, + ) + if (!result.success) return { success: false, error: result.error } + return { success: true, status: (result.data?.status ?? '').trim() } } catch (error) { - client?.disconnect() - wsClient?.disconnect() - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error during MD5 verification', + return { success: false, error: getErrorMessage(error) } + } + } + + handleDebuggerGetVariablesList = async ( + _event: IpcMainInvokeEvent, + variableIndexes: number[], + ): Promise<{ + success: boolean + tick?: number + lastIndex?: number + data?: number[] + error?: string + needsReconnect?: boolean + }> => { + // A null connection type means the debugger was intentionally disconnected. + // Fail silently so the renderer's poll loop ignores it. + if (this.debuggerConnectionType === null) { + return { success: false, error: 'Debugger not connected' } + } + + // Every target reads over its session's DEBUG channel — Modbus for a device or + // a v3 runtime, the WebSocket for v4. There is nothing to reconnect here: if a + // connection dropped, the manager is already reopening it (or has reported it + // lost), and `needsReconnect` tells the renderer to stop the session rather + // than race it for the medium. + const link = await this.requireDebug('read variables') + if ('error' in link) return { success: false, error: link.error, needsReconnect: true } + + try { + const result = await link.client.getVariablesList(variableIndexes) + if (result.success && result.data) { + return { success: true, tick: result.tick, lastIndex: result.lastIndex, data: Array.from(result.data) } } + return { success: false, error: result.error } + } catch (error) { + return { success: false, error: getErrorMessage(error), needsReconnect: true } } } @@ -1787,289 +1852,419 @@ class MainProcessBridge implements MainIpcModule { } } - handleDebuggerGetVariablesList = async ( - _event: IpcMainInvokeEvent, - variableIndexes: number[], - ): Promise<{ - success: boolean - tick?: number - lastIndex?: number - data?: number[] - error?: string - needsReconnect?: boolean - }> => { - // If connection type is null, the debugger was intentionally disconnected. - // Return a silent failure so the renderer polling ignores it. - if (this.debuggerConnectionType === null) { - return { success: false, error: 'Debugger not connected' } + + /** + * Read the run/stop state over an already-open client and push it to the + * renderer. Throttled, because its two callers tick at very different rates: + * the device liveness poll (2.5s) and the debugger's variable poll (fast). + * + * Both callers use the ONE held connection, so there is no handoff to survive: + * a debug session shares the link rather than replacing it, and the Start/Stop + * button keeps tracking the device while debugging. + */ + private plcStatePushedAt = 0 + + private async pushPlcState( + client: { getStatus?: () => Promise }, + port: string, + minIntervalMs = 2000, + ): Promise { + if (!client.getStatus) return + const now = Date.now() + if (now - this.plcStatePushedAt < minIntervalMs) return + this.plcStatePushedAt = now + + const r = await client.getStatus() + if (!r.success) return + this.mainWindow?.webContents?.send('device:plc-state', { + port, + plcState: r.plcState, + switchPosition: r.switchPosition, + }) + } + + + + /** + * Start a debug session against a target. + * + * For a Modbus target there is nothing to open: the session runs over the ONE + * held device link, which Connect established and which the status poll keeps + * honest. That is what makes serial debugging work at all (the OS will not lock + * a port twice), and it is equally right for Modbus TCP (an Arduino TCP server + * serves one client). The simulator is the exception only because it is + * in-process, so it can bring its own link up on demand. + * + * Runtime v4 keeps its own WebSocket: different protocol, different target. + */ + handleDebuggerConnect = async (_event: IpcMainInvokeEvent): Promise<{ success: boolean; error?: string }> => { + try { + // For a shared session this opens nothing — it is the connection Connect + // established, already proven. For a runtime target it opens that target's + // own debug channel, which is why the debugger asks for it here rather than + // at login: the channel exists only while a session needs it. + const channel = await this.requireDebug('debug session') + if ('error' in channel) return { success: false, error: channel.error } + + // Session identity comes from the SESSION, not from what the caller guessed: + // a connected target names no transport at all. + this.debuggerConnectionType = this.deviceSession.getLink()?.transport ?? 'tcp' + return { success: true } + } catch (error) { + this.debuggerConnectionType = null + return { success: false, error: getErrorMessage(error) } } + } - if (this.debuggerConnectionType === 'websocket') { - if (!this.debuggerWebSocketClient) { - if (this.debuggerReconnecting) { - return { success: false, error: 'Reconnection in progress', needsReconnect: true } - } + /** + * Stop a debug session: let go of the debug channel, nothing more. + * + * The SESSION is deliberately untouched — it belongs to Connect (or to the + * runtime login), not to the debugger. Closing it here would drop the user's + * connection, and the status poll driving the Start/Stop button with it, just + * because they stopped debugging. Releasing closes a channel of its own once + * nothing holds it, and never closes a channel shared with control. + */ + handleDebuggerDisconnect = (_event: IpcMainInvokeEvent): Promise<{ success: boolean }> => { + this.deviceSession.releaseDebugChannel('debug session') + this.debuggerConnectionType = null + return Promise.resolve({ success: true }) + } - this.debuggerReconnecting = true - try { - if (!this.debuggerTargetIp || !this.debuggerJwtToken) { - this.debuggerReconnecting = false - return { success: false, error: 'No target IP or JWT token stored', needsReconnect: true } - } - this.debuggerWebSocketClient = new WebSocketDebugTransport({ - host: this.debuggerTargetIp, - port: 8443, - token: this.debuggerJwtToken, - rejectUnauthorized: false, - }) - await this.debuggerWebSocketClient.connect() - this.debuggerReconnecting = false - } catch (error) { - this.debuggerWebSocketClient = null - this.debuggerReconnecting = false - return { success: false, error: `Failed to reconnect: ${getErrorMessage(error)}`, needsReconnect: true } - } - } + // =================================================================== + // The device link — "stay connected", over serial or Modbus TCP + // =================================================================== - try { - const result = await this.debuggerWebSocketClient.getVariablesList(variableIndexes) + /** Push a link state change to the renderer. */ + private emitDeviceLinkStatus(status: DeviceLinkStatus): void { + this.traceDeviceLink( + `status -> ${status.status}${status.descriptor ? ` (${status.transport ?? '?'} ${status.descriptor})` : ''}${ + status.reason ? ` [${status.reason}]` : '' + }`, + ) + this.mainWindow?.webContents?.send('device:connection-status', status) + } - if (result.success && result.data) { - return { - success: true, - tick: result.tick, - lastIndex: result.lastIndex, - data: Array.from(result.data), - } - } + /** + * Diagnostic trace for the device connection, to BOTH sinks on purpose: the + * main-process log file keeps it after the fact, and the renderer console puts + * it where a user can read and copy it while reproducing something. Connection + * problems span two processes and a piece of hardware; without this the only + * evidence is "it hangs". + */ + private traceDeviceLink(message: string): void { + logger.info(`[link] ${message}`) + this.mainWindow?.webContents?.send('device:link-log', message) + } - return { success: false, error: result.error } - } catch (error) { - if (this.debuggerWebSocketClient) { - this.debuggerWebSocketClient.disconnect() - this.debuggerWebSocketClient = null - } - return { success: false, error: getErrorMessage(error), needsReconnect: true } + /** + * Turn a resolved channel config into something the link manager can try. + * The only transport-specific step left in the flow; a config that names a + * transport this build cannot speak is dropped rather than half-built. + */ + private toDeviceLinkCandidates(configs: DebugConnectionConfig[]): DeviceLinkCandidate[] { + const candidates: DeviceLinkCandidate[] = [] + for (const config of configs) { + const kind = modbusTransportKind(config.connectionType) + if (kind === null) continue + const params = { + connectionType: config.connectionType, + port: config.connectionParams.port, + baudRate: config.connectionParams.baudRate, + slaveId: config.connectionParams.slaveId, + host: config.connectionParams.ipAddress, } + // Only the simulator needs an in-process serial port; building one for a real + // transport would allocate a virtual port nobody reads. + const options = kind === 'simulator' ? { virtualSerialPort: new VirtualSerialPort(this.simulatorModule) } : {} + // Probe the params now so a malformed config fails resolution rather than + // becoming a candidate that always throws on `create()`. + if ('error' in buildDeviceModbusTransport(params, options)) continue + candidates.push({ + transport: kind, + descriptor: describeDebugEndpoint(config), + create: () => { + const built = buildDeviceModbusTransport(params, options) + if ('error' in built) throw new Error(built.error) + return built.client + }, + }) } + return candidates + } - if (!this.debuggerModbusClient) { - if (this.debuggerReconnecting) { - return { success: false, error: 'Reconnection in progress', needsReconnect: true } - } + /** Consume the classification the last verified candidate produced. */ + private takeDeviceLinkProbe(): DeviceProbeOutcome | null { + const probe = this.deviceLinkProbe + this.deviceLinkProbe = null + return probe + } - this.debuggerReconnecting = true - try { - if (this.debuggerConnectionType === 'simulator') { - const virtualPort = new VirtualSerialPort(this.simulatorModule) - this.debuggerModbusClient = new ModbusRtuClient({ - port: 'simulator', - baudRate: 115200, - slaveId: 1, - timeout: 5000, - serialPort: virtualPort, - }) - } else if (this.debuggerConnectionType === 'tcp') { - if (!this.debuggerTargetIp) { - this.debuggerReconnecting = false - return { success: false, error: 'No target IP address stored', needsReconnect: true } - } - this.debuggerModbusClient = new ModbusTcpClient({ - host: this.debuggerTargetIp, - port: 502, - timeout: 5000, - }) - } else if (this.debuggerConnectionType === 'rtu') { - if (!this.debuggerRtuPort || !this.debuggerRtuBaudRate || this.debuggerRtuSlaveId === null) { - this.debuggerReconnecting = false - return { success: false, error: 'No RTU connection parameters stored', needsReconnect: true } - } - this.debuggerModbusClient = new ModbusRtuClient({ - port: this.debuggerRtuPort, - baudRate: this.debuggerRtuBaudRate, - slaveId: this.debuggerRtuSlaveId, - timeout: 5000, - }) - } else { - this.debuggerReconnecting = false - return { success: false, error: 'No connection type stored', needsReconnect: true } - } + /** + * Establish a session with a Runtime v3/v4: control over REST, debug over the + * channel its board declares (v3: Modbus TCP on the runtime's address; v4: the + * debug WebSocket). Called once the renderer has logged in. + * + * The debug channel is only DESCRIBED here, not opened — see `acquireDebugChannel`. + */ + handleOpenRuntimeSession = ( + _event: IpcMainInvokeEvent, + params: { address: string; debug: DebugConnectionConfig }, + ): Promise<{ success: boolean; error?: string }> => { + const candidate = this.toDebugCandidate(params.debug) + if (!candidate) { + return Promise.resolve({ + success: false, + error: `This target declares a debug channel this build cannot open: ${params.debug.connectionType}`, + }) + } + this.deviceSession.openRestSession({ address: params.address, debugChannel: candidate }) + this.debuggerConnectionType = params.debug.connectionType + return Promise.resolve({ success: true }) + } - await this.debuggerModbusClient.connect() - this.debuggerReconnecting = false - } catch (error) { - this.debuggerModbusClient = null - this.debuggerReconnecting = false - return { success: false, error: `Failed to reconnect: ${getErrorMessage(error)}`, needsReconnect: true } - } + /** Close a REST-controlled session (the user logged out / disconnected). */ + handleCloseRuntimeSession = (_event: IpcMainInvokeEvent): Promise<{ success: boolean }> => { + if (this.deviceSession.getRestAddress() !== null) { + this.deviceSession.close() + this.debuggerConnectionType = null } + return Promise.resolve({ success: true }) + } - try { - const result = await this.debuggerModbusClient.getVariablesList(variableIndexes) + /** + * Turn a resolved channel config into an openable DEBUG channel. The one place + * that knows a WebSocket is a debug channel too. + */ + private toDebugCandidate(config: DebugConnectionConfig): DeviceDebugCandidate | null { + if (config.connectionType === 'websocket') { + const host = config.connectionParams.ipAddress + const token = config.connectionParams.jwtToken + if (!host || !token) return null + return { + transport: 'websocket', + descriptor: `websocket ${host}`, + create: () => new WebSocketDebugTransport({ host, port: 8443, token, rejectUnauthorized: false }), + } + } + const [candidate] = this.toDeviceLinkCandidates([config]) + if (!candidate) return null + return { + transport: candidate.transport, + descriptor: `${candidate.transport} ${candidate.descriptor}`, + create: candidate.create, + } + } - if (result.success && result.data) { - return { - success: true, - tick: result.tick, - lastIndex: result.lastIndex, - data: Array.from(result.data), + /** + * Is this freshly opened candidate a device we can work with? Runs the + * connect-time classification (`classifyDeviceLink`) and keeps its verdict for + * the renderer. + * + * Only `connected-with-firmware` keeps a candidate. A port that opens but has + * no firmware, or an IP that answers something else, therefore falls through to + * the next candidate instead of becoming a link that cannot serve a single + * command. + */ + private async verifyDeviceCandidate( + client: DeviceModbusTransport, + candidate: DeviceLinkCandidate, + context: { isLastCandidate: boolean }, + ): Promise { + // The simulator is in-process: there is no hardware to identify, so the + // board-id read is not the right question to ask of it. + // + // Retried because the session is opened the instant the emulator starts, and + // the sketch inside it still has to reach the point where it services Modbus. + // Failing here would stop an emulator that was merely still booting. + if (candidate.transport === 'simulator') { + for (let attempt = 0; attempt < MainProcessBridge.SIMULATOR_PROBE_ATTEMPTS; attempt += 1) { + try { + if (await this.probeDeviceLink(client)) return true + } catch { + // Not up yet — fall through to the wait below. } + await new Promise((resolve) => setTimeout(resolve, MainProcessBridge.SIMULATOR_PROBE_INTERVAL_MS)) } + return false + } + + // Be patient only with the LAST candidate. The id read is retried because a + // board that was just flashed may still be booting — worth ~32s when this is + // the only way in, but not while alternatives are waiting: a Modbus TCP + // address that no longer answers should not delay the cable that would have + // worked. (Measured on a real board: 32.5s to rule out one endpoint.) + const boardIdProbe = context.isLastCandidate ? PATIENT_BOARD_ID_PROBE : QUICK_BOARD_ID_PROBE + this.traceDeviceLink( + ` ${candidate.descriptor}: verifying with up to ${boardIdProbe.attempts} id read(s)` + + `${context.isLastCandidate ? ' (last candidate, being patient)' : ''}`, + ) + const result = await classifyDeviceLink(client, { boardIdProbe }) + this.deviceLinkProbe = result + this.traceDeviceLink( + ` ${candidate.descriptor}: classified as "${result.status}"${result.error ? ` (${result.error})` : ''}`, + ) + if (result.status !== 'connected-with-firmware') return false + // The status frame doubles as the run/stop state source; push it straight + // away so the Start/Stop button is right before the first poll lands. + await this.pushPlcState(client, candidate.descriptor, 0) + return true + } - return { success: false, error: result.error } - } catch (error) { - if (this.debuggerModbusClient) { - this.debuggerModbusClient.disconnect() - this.debuggerModbusClient = null - } - return { success: false, error: getErrorMessage(error), needsReconnect: true } + /** + * Per-tick liveness read, and the ONE place baremetal run/stop state is polled. + * + * Prefers the status read (FC 0x46) over the board id (0x48): both prove the + * firmware is answering, but the status frame also carries the run/stop state + * and the mode-switch position — so a switch flipped by hand at the panel shows + * up within one interval, with no second timer and no extra traffic. + */ + private async probeDeviceLink(client: DeviceModbusTransport): Promise { + const descriptor = this.deviceSession.getLink()?.descriptor ?? '' + if (client.getStatus) { + const status = await client.getStatus() + if (!status.success) return false + this.plcStatePushedAt = 0 + await this.pushPlcState(client, descriptor, 0) + return true } + return (await client.getBoardId()).success } - handleDebuggerConnect = async ( + /** + * Release the link if it holds `port` — the handoff before an upload takes the + * same serial port. A Modbus TCP link is left alone: flashing over USB does not + * disturb it, so debugging and run/stop survive an upload. + * + * Returns whether anything was released, so the caller knows to reconnect. + */ + handleDeviceReleaseSerialPort = async ( _event: IpcMainInvokeEvent, - connectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator', - connectionParams: { - ipAddress?: string - port?: string - baudRate?: number - slaveId?: number - jwtToken?: string - }, - ): Promise<{ success: boolean; error?: string }> => { - try { - if (connectionType === 'simulator') { - if (this.debuggerModbusClient) { - this.debuggerModbusClient.disconnect() - this.debuggerModbusClient = null - } + port: string | null | undefined, + ): Promise<{ released: boolean }> => { + return { released: this.deviceSession.releaseSerialPort(port) } + } - const virtualPort = new VirtualSerialPort(this.simulatorModule) - this.debuggerModbusClient = new ModbusRtuClient({ - port: 'simulator', - baudRate: 115200, - slaveId: 1, - timeout: 5000, - serialPort: virtualPort, - }) - await this.debuggerModbusClient.connect() - - // MD5 fetch warms the connection and exercises the - // runtime's endianness-sentinel path. Endianness detection - // itself is handled at the editor's verify-MD5 step (see - // handleDebuggerVerifyMd5) where the result feeds the swap - // layer; here we just need the connection live. - await this.debuggerModbusClient.getMd5Hash() - } else if (connectionType === 'websocket') { - if (this.debuggerModbusClient) { - this.debuggerModbusClient.disconnect() - this.debuggerModbusClient = null - } + /** The CONTROL channel's client (run/stop, status). */ + private deviceClient(): DeviceModbusTransport | null { + return this.deviceSession.getClient() + } - if (!connectionParams.ipAddress || !connectionParams.jwtToken) { - return { success: false, error: 'IP address and JWT token are required for WebSocket connection' } - } + /** + * The channel for this operation family, or a reason there isn't one. Every + * device command funnels through here, so "not connected" and "reconnecting" + * read the same everywhere instead of each handler inventing its own message — + * or, worse, opening its own connection. + * + * `debug` operations take the debug channel, which for a shared session IS the + * control channel and for a runtime target is one of its own. + */ + private requireControl(what: string): { client: DeviceModbusTransport } | ChannelUnavailable { + const client = this.deviceClient() + if (!client) return this.explainMissingChannel(what) + this.traceChannelUse(what, 'control') + return { client } + } - if (!this.debuggerWebSocketClient || this.debuggerConnectionType !== 'websocket') { - if (this.debuggerWebSocketClient) { - this.debuggerWebSocketClient.disconnect() - this.debuggerWebSocketClient = null - } + /** + * The DEBUG channel, opening it if this session's debug medium is one of its own. + * Every debug caller passes a distinct `what`, which doubles as the holder name — + * so two callers can hold it at once without either closing it on the other. + */ + private async requireDebug(what: string): Promise<{ client: DeviceDebugChannel } | ChannelUnavailable> { + const acquired = await this.deviceSession.acquireDebugChannel(what) + if ('error' in acquired) { + if (!this.deviceSession.isConnected()) return this.explainMissingChannel(what) + this.traceDeviceLink(`${what}: debug channel unavailable — ${acquired.error}`) + return { error: acquired.error, needsReconnect: true } + } + this.traceChannelUse(what, 'debug') + return acquired + } - this.debuggerWebSocketClient = new WebSocketDebugTransport({ - host: connectionParams.ipAddress, - port: 8443, - token: connectionParams.jwtToken, - rejectUnauthorized: false, - }) - await this.debuggerWebSocketClient.connect() - } + private traceChannelUse(what: string, family: 'control' | 'debug'): void { + const link = this.deviceSession.getLink() + this.traceDeviceLink(`${what}: using the ${family} channel (${link?.transport ?? '?'} ${link?.descriptor ?? '?'})`) + } - this.debuggerTargetIp = connectionParams.ipAddress - this.debuggerJwtToken = connectionParams.jwtToken - } else if (connectionType === 'tcp') { - if (this.debuggerModbusClient) { - this.debuggerModbusClient.disconnect() - this.debuggerModbusClient = null - } + private explainMissingChannel(what: string): ChannelUnavailable { + if (this.deviceSession.isRecovering()) { + this.traceDeviceLink(`${what}: refused, the connection is mid-recovery`) + return { error: 'the connection dropped and is being restored — try again in a moment', needsReconnect: true } + } + this.traceDeviceLink(`${what}: refused, nothing is connected`) + return { error: MainProcessBridge.DEVICE_NOT_CONNECTED, needsReconnect: true } + } - if (!connectionParams.ipAddress) { - return { success: false, error: 'IP address is required for TCP connection' } - } - this.debuggerModbusClient = new ModbusTcpClient({ - host: connectionParams.ipAddress, - port: 502, - timeout: 5000, - }) - await this.debuggerModbusClient.connect() - this.debuggerTargetIp = connectionParams.ipAddress - } else { - if (!connectionParams.port || !connectionParams.baudRate || connectionParams.slaveId === undefined) { - return { success: false, error: 'Port, baud rate, and slave ID are required for RTU connection' } - } + /** + * The emulator boots in milliseconds, but "milliseconds" is not "instantly", and + * its session is opened the instant it starts. + */ + private static readonly SIMULATOR_PROBE_ATTEMPTS = 10 + private static readonly SIMULATOR_PROBE_INTERVAL_MS = 200 - if ( - this.debuggerModbusClient && - this.debuggerConnectionType === 'rtu' && - this.debuggerRtuPort === connectionParams.port && - this.debuggerRtuBaudRate === connectionParams.baudRate && - this.debuggerRtuSlaveId === connectionParams.slaveId - ) { - this.debuggerReconnecting = false - return { success: true } - } + /** + * Reported when a command arrives and no session exists. + * + * Short and neutral on purpose. The caller already says which action failed + * ("Failed to stop PLC: …", "Could not connect to debug target: …"), so this only + * has to supply the reason. It used to explain the reason as well — "the debugger + * and run/stop share the device connection" — which was written for a baremetal + * board and read as nonsense on a Runtime v4, whose debug channel is its own + * WebSocket and shares nothing. Worse, it appeared on a target the user HAD + * connected to, so the explanation was not merely irrelevant but wrong. + */ + private static readonly DEVICE_NOT_CONNECTED = 'not connected to the target' - if (this.debuggerModbusClient) { - this.debuggerModbusClient.disconnect() - this.debuggerModbusClient = null - } + /** + * Open and HOLD the link to a baremetal device (D72). + * + * `candidates` is the ordered list the renderer resolved from the board's debug + * spec — Modbus TCP first when the project enables it, then serial. The manager + * tries them in order and keeps the first that both opens and answers, so a + * stale DHCP address or an unplugged ethernet shield falls through to the cable + * instead of stranding the user on a link that cannot serve a command. + * + * The classification that used to be this method's job now happens per candidate + * (`verifyDeviceCandidate`), because it is also what decides whether a candidate + * is worth keeping. + */ + handleDeviceConnect = async ( + _event: IpcMainInvokeEvent, + candidates: DebugConnectionConfig[], + ): Promise => { + this.deviceLinkProbe = null + this.traceDeviceLink( + `connect requested with ${candidates.length} candidate(s): ${ + candidates.map((config) => `${config.connectionType} ${describeDebugEndpoint(config)}`).join(', ') || '(none)' + }`, + ) - this.debuggerModbusClient = new ModbusRtuClient({ - port: connectionParams.port, - baudRate: connectionParams.baudRate, - slaveId: connectionParams.slaveId, - timeout: 5000, - }) - await this.debuggerModbusClient.connect() - this.debuggerRtuPort = connectionParams.port - this.debuggerRtuBaudRate = connectionParams.baudRate - this.debuggerRtuSlaveId = connectionParams.slaveId - } + const linkCandidates = this.toDeviceLinkCandidates(candidates) + if (linkCandidates.length === 0) { + return { status: 'error', error: 'No usable serial or Modbus TCP connection was configured for this device.' } + } - this.debuggerConnectionType = connectionType - this.debuggerReconnecting = false + const result = await this.deviceSession.open(linkCandidates) + // Read the verdict out of the field after the open: verification runs inside + // it, one candidate at a time, and this is where its conclusion lands. + const probe = this.takeDeviceLinkProbe() + if (result.ok) return probe ?? { status: 'connected-with-firmware' } - return { success: true } - } catch (error) { - this.debuggerModbusClient = null - this.debuggerWebSocketClient = null - this.debuggerTargetIp = null - this.debuggerConnectionType = null - this.debuggerRtuPort = null - this.debuggerRtuBaudRate = null - this.debuggerRtuSlaveId = null - this.debuggerJwtToken = null - return { success: false, error: getErrorMessage(error) } - } + // Nothing worked. A candidate that got far enough to be classified gives the + // better message ("no firmware" beats "could not connect"); otherwise report + // what was tried. + if (probe && probe.status !== 'connected-with-firmware') return probe + const tried = result.attempts.map((attempt) => `${attempt.descriptor}: ${attempt.error}`).join('; ') + return { status: 'no-response', error: tried || 'No connection could be established.' } } - handleDebuggerDisconnect = (_event: IpcMainInvokeEvent): Promise<{ success: boolean }> => { - if (this.debuggerModbusClient) { - this.debuggerModbusClient.disconnect() - this.debuggerModbusClient = null - } - if (this.debuggerWebSocketClient) { - this.debuggerWebSocketClient.disconnect() - this.debuggerWebSocketClient = null - } - this.debuggerTargetIp = null - this.debuggerConnectionType = null - this.debuggerRtuPort = null - this.debuggerRtuBaudRate = null - this.debuggerRtuSlaveId = null - this.debuggerJwtToken = null - this.debuggerReconnecting = false - return Promise.resolve({ success: true }) + + /** Close the held link (user pressed Disconnect). */ + handleDeviceDisconnect = async (): Promise<{ success: boolean }> => { + this.deviceSession.close() + this.deviceLinkProbe = null + return { success: true } } handleDebuggerSetVariable = async ( @@ -2080,33 +2275,13 @@ class MainProcessBridge implements MainIpcModule { ): Promise<{ success: boolean; error?: string }> => { const buffer = valueBuffer ? Buffer.from(valueBuffer) : undefined - if (this.debuggerConnectionType === 'websocket') { - if (!this.debuggerWebSocketClient) { - logger.info('[IPC Handler] WebSocket client not connected') - return { success: false, error: 'Not connected to debugger' } - } - - try { - // Shared transport takes Uint8Array; convert from the IPC's - // Buffer payload (Buffer is a Uint8Array subclass so the cast - // is a no-op at runtime, but TS wants the explicit step). - const valueBytes = buffer ? new Uint8Array(buffer) : undefined - const result = await this.debuggerWebSocketClient.setVariable(variableIndex, force, valueBytes) - logger.info('[IPC Handler] WebSocket setVariable result: ' + JSON.stringify(result)) - return result - } catch (error) { - logger.error('[IPC Handler] WebSocket setVariable error: ' + getErrorMessage(error)) - return { success: false, error: getErrorMessage(error) } - } - } - - if (!this.debuggerModbusClient) { - logger.info('[IPC Handler] Modbus client not connected') - return { success: false, error: 'Not connected to debugger' } + const link = await this.requireDebug('write variable') + if ('error' in link) { + return { success: false, error: link.error } } try { - const result = await this.debuggerModbusClient.setVariable(variableIndex, force, buffer) + const result = await link.client.setVariable(variableIndex, force, buffer) logger.info('[IPC Handler] Modbus setVariable result: ' + JSON.stringify(result)) return result } catch (error) { @@ -2140,6 +2315,7 @@ class MainProcessBridge implements MainIpcModule { /** Stops the simulator and notifies the renderer so it can update UI state. */ private stopSimulatorAndNotify(): void { if (this.simulatorModule.isRunning()) { + this.closeSimulatorSession() this.simulatorModule.stop() this.mainWindow?.webContents.send('simulator:stopped') } @@ -2368,6 +2544,16 @@ class MainProcessBridge implements MainIpcModule { handleESIMigrateRepository = async (_event: IpcMainInvokeEvent, projectPath: string) => this.wrapServiceCall(() => this.esiService.migrateRepositoryToV2(projectPath)) + /** + * Start the emulator, then open its session. + * + * The running emulator IS the simulator's connection — there is no port to pick + * and no address to configure, so nothing about it is ever resolved from a spec + * or asked of the user. Opening the session here (rather than when the debugger + * asks) is what makes the simulator behave like every other target downstream: + * commands go to a session the manager holds, and when the emulator stops the + * session ends the same way a pulled cable ends a serial one. + */ handleSimulatorLoadFirmware = async ( _event: IpcMainInvokeEvent, hexPath: string, @@ -2376,17 +2562,40 @@ class MainProcessBridge implements MainIpcModule { const fs = await import('fs/promises') const hexContent = await fs.readFile(hexPath, 'utf-8') this.simulatorModule.loadAndRun(hexContent) + + const opened = await this.deviceSession.open( + this.toDeviceLinkCandidates([{ connectionType: 'simulator', connectionParams: {} }]), + ) + if (!opened.ok) { + this.simulatorModule.stop() + const reason = opened.attempts.map((attempt) => attempt.error).join('; ') + return { success: false, error: reason || 'The simulator did not answer its debug protocol' } + } + this.debuggerConnectionType = 'simulator' return { success: true } } catch (error) { return { success: false, error: getErrorMessage(error) } } } + /** + * Stop the emulator entirely — the simulator's Stop button means "stop the + * simulator", not "stop the program it is running". The session closes first so + * the client is dropped before the thing it talks to disappears. + */ handleSimulatorStop = (_event: IpcMainInvokeEvent): Promise<{ success: boolean }> => { + this.closeSimulatorSession() this.simulatorModule.stop() return Promise.resolve({ success: true }) } + /** Close the session if it is the simulator's. No-op for any other target. */ + private closeSimulatorSession(): void { + if (this.deviceSession.getLink()?.transport !== 'simulator') return + this.deviceSession.close() + this.debuggerConnectionType = null + } + handleSimulatorIsRunning = (_event: IpcMainInvokeEvent): Promise => { return Promise.resolve(this.simulatorModule.isRunning()) } diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 3fb915a2f..120ff0ccc 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -1,4 +1,5 @@ import type { DiscoveredRuntimeDevice, RuntimeLogEntry } from '@root/middleware/shared/ports' +import type { DeviceConnectionStatusPayload } from '@root/middleware/shared/ports/device-port' import type { ESIDevice, ESIRepositoryItemLight } from '@root/middleware/shared/ports/esi-types' import type { EtherCATRuntimeStatusResponse, @@ -21,6 +22,7 @@ import type { UpdateUserParams, WhoAmIResult, } from '@root/middleware/shared/ports/runtime-port' +import type { DebugConnectionConfig } from '@root/middleware/shared/ports/types' import type { PLCProjectData } from '@root/middleware/shared/ports/types' import { CreatePouFileProps, PouServiceResponse } from '@root/types/IPC/pou-service' import { CreateProjectFileProps, IProjectServiceResponse } from '@root/types/IPC/project-service' @@ -347,11 +349,11 @@ const rendererProcessBridge = { } > > => ipcRenderer.invoke('hardware:get-available-boards'), - getAvailableCommunicationPorts: (): Promise<{ name: string; address: string }[]> => + getAvailableCommunicationPorts: (): Promise<{ address: string; boardName?: string; manufacturer?: string }[]> => ipcRenderer.invoke('hardware:get-available-communication-ports'), refreshAvailableBoards: (): Promise<{ board: string; version: string }[]> => ipcRenderer.invoke('hardware:refresh-available-boards'), - refreshCommunicationPorts: (): Promise<{ name: string; address: string }[]> => + refreshCommunicationPorts: (): Promise<{ address: string; boardName?: string; manufacturer?: string }[]> => ipcRenderer.invoke('hardware:refresh-communication-ports'), // ===================== PACKAGE MANAGER METHODS ===================== @@ -404,17 +406,22 @@ const rendererProcessBridge = { ipcRenderer.invoke('util:read-debug-file', projectPath, boardTarget), debuggerVerifyMd5: ( - connectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator', - connectionParams: { - ipAddress?: string - port?: string - baudRate?: number - slaveId?: number - jwtToken?: string - }, expectedMd5: string, ): Promise<{ success: boolean; match?: boolean; targetMd5?: string; error?: string }> => - ipcRenderer.invoke('debugger:verify-md5', connectionType, connectionParams, expectedMd5), + ipcRenderer.invoke('debugger:verify-md5', expectedMd5), + + /** FC 0x4b run/stop command. Reads come from `onDevicePlcState` (the device + * status poll), not from here. */ + debuggerPlcControl: ( + action: 'run' | 'stop', + ): Promise<{ + success: boolean + state?: number + switchPosition?: number + refusedBySwitch?: boolean + unsupported?: boolean + error?: string + }> => ipcRenderer.invoke('debugger:plc-control', action), debuggerReadProgramStMd5: ( projectPath: string, @@ -440,20 +447,65 @@ const rendererProcessBridge = { ): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('debugger:set-variable', variableIndex, force, valueBuffer), - debuggerConnect: ( - connectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator', - connectionParams: { - ipAddress?: string - port?: string - baudRate?: number - slaveId?: number - jwtToken?: string - }, - ): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('debugger:connect', connectionType, connectionParams), + debuggerConnect: (): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('debugger:connect'), debuggerDisconnect: (): Promise<{ success: boolean }> => ipcRenderer.invoke('debugger:disconnect'), + // Persistent device connection (D72): try the ordered candidates and HOLD the + // first that answers, returning how the kept channel classified. + deviceConnect: ( + candidates: DebugConnectionConfig[], + ): Promise<{ + status: 'connected-with-firmware' | 'no-firmware' | 'no-response' | 'error' + error?: string + }> => ipcRenderer.invoke('device:connect', candidates), + + // Close the held serial link (Disconnect). + deviceDisconnect: (): Promise<{ success: boolean }> => ipcRenderer.invoke('device:disconnect'), + + // A Runtime v3/v4 session: control over REST at `address`, debug over the channel + // the board declares (opened later, on the debugger's request). + openRuntimeSession: (params: { + address: string + debug: DebugConnectionConfig + }): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('session:open-runtime', params), + + closeRuntimeSession: (): Promise<{ success: boolean }> => ipcRenderer.invoke('session:close-runtime'), + + // Upload handoff: give up the link ONLY if it is the serial one holding `port`. + deviceReleaseSerialPort: (port: string | null | undefined): Promise<{ released: boolean }> => + ipcRenderer.invoke('device:release-serial-port', port), + + // Diagnostic trace of the device connection (candidate attempts, poll verdicts, + // which connection served each command), mirrored into the editor console so it + // can be read and copied while reproducing a problem. + onDeviceLinkLog: (callback: (message: string) => void): (() => void) => { + const listener = (_event: unknown, message: string) => callback(message) + ipcRenderer.on('device:link-log', listener) + return () => ipcRenderer.removeListener('device:link-log', listener) + }, + + // Main pushes live link status here (liveness failure, upload/debug handoff). + onDeviceConnectionStatus: (callback: (payload: DeviceConnectionStatusPayload) => void): (() => void) => { + const listener = (_event: unknown, payload: DeviceConnectionStatusPayload) => callback(payload) + ipcRenderer.on('device:connection-status', listener) + return () => ipcRenderer.removeListener('device:connection-status', listener) + }, + + /** + * Subscribe to run/stop state pushed from the held device link. Emitted on + * every liveness tick (FC 0x46 carries the state), so a switch flipped by hand + * at the panel surfaces within one interval without any extra traffic. + */ + onDevicePlcState: ( + callback: (payload: { port: string; plcState?: number; switchPosition?: number }) => void, + ): (() => void) => { + const listener = (_event: unknown, payload: { port: string; plcState?: number; switchPosition?: number }) => + callback(payload) + ipcRenderer.on('device:plc-state', listener) + return () => ipcRenderer.removeListener('device:plc-state', listener) + }, + // ===================== RUNTIME API METHODS ===================== runtimeGetUsersInfo: (ipAddress: string): Promise<{ hasUsers: boolean; runtimeVersion?: string; error?: string }> => ipcRenderer.invoke('runtime:get-users-info', ipAddress), @@ -503,6 +555,8 @@ const rendererProcessBridge = { overruns: number }> } + /** Run/stop mode-switch position; absent on older runtimes. */ + switchPosition?: 'run' | 'stop' error?: string }> => ipcRenderer.invoke('runtime:get-status', ipAddress, includeStats), runtimeStartPlc: (ipAddress: string): Promise<{ success: boolean; error?: string; status?: string }> => diff --git a/src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts b/src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts index 97a36a61a..bbbece386 100644 --- a/src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts @@ -35,6 +35,7 @@ beforeEach(() => { success: true, content: 'debug_vars[] = { ... }', }), + debuggerPlcControl: jest.fn().mockResolvedValue({ success: true, state: 0 }), } as unknown as typeof window.bridge adapter = createEditorDebuggerAdapter() @@ -46,18 +47,15 @@ beforeEach(() => { describe('connect', () => { it('delegates to bridge with connection type and params', async () => { - const result = await adapter.connect(tcpConfig) + const result = await adapter.connect() - expect(window.bridge.debuggerConnect).toHaveBeenCalledWith('tcp', { - ipAddress: '192.168.1.100', - port: '502', - }) + expect(window.bridge.debuggerConnect).toHaveBeenCalledWith() expect(result).toEqual({ success: true }) }) it('sets connected state on success', async () => { expect(adapter.isConnected()).toBe(false) - await adapter.connect(tcpConfig) + await adapter.connect() expect(adapter.isConnected()).toBe(true) }) @@ -66,55 +64,33 @@ describe('connect', () => { success: false, error: 'Connection refused', }) - await adapter.connect(tcpConfig) + await adapter.connect() expect(adapter.isConnected()).toBe(false) }) it('catches bridge errors', async () => { ;(window.bridge.debuggerConnect as jest.Mock).mockRejectedValue(new Error('IPC failed')) - const result = await adapter.connect(tcpConfig) + const result = await adapter.connect() expect(result).toEqual({ success: false, error: 'IPC failed' }) }) it('supports simulator connection type', async () => { - await adapter.connect({ connectionType: 'simulator', connectionParams: {} }) + await adapter.connect() - expect(window.bridge.debuggerConnect).toHaveBeenCalledWith('simulator', {}) + expect(window.bridge.debuggerConnect).toHaveBeenCalledWith() }) it('supports websocket connection type with JWT', async () => { - await adapter.connect({ - connectionType: 'websocket', - connectionParams: { - ipAddress: '10.0.0.1', - port: '8443', - jwtToken: 'my-jwt', - }, - }) + await adapter.connect() - expect(window.bridge.debuggerConnect).toHaveBeenCalledWith('websocket', { - ipAddress: '10.0.0.1', - port: '8443', - jwtToken: 'my-jwt', - }) + expect(window.bridge.debuggerConnect).toHaveBeenCalledWith() }) it('supports RTU connection type with serial params', async () => { - await adapter.connect({ - connectionType: 'rtu', - connectionParams: { - port: '/dev/ttyUSB0', - baudRate: 115200, - slaveId: 1, - }, - }) + await adapter.connect() - expect(window.bridge.debuggerConnect).toHaveBeenCalledWith('rtu', { - port: '/dev/ttyUSB0', - baudRate: 115200, - slaveId: 1, - }) + expect(window.bridge.debuggerConnect).toHaveBeenCalledWith() }) }) @@ -124,7 +100,7 @@ describe('connect', () => { describe('disconnect', () => { it('delegates to bridge', async () => { - await adapter.connect(tcpConfig) + await adapter.connect() const result = await adapter.disconnect() expect(window.bridge.debuggerDisconnect).toHaveBeenCalled() @@ -132,7 +108,7 @@ describe('disconnect', () => { }) it('clears connected state', async () => { - await adapter.connect(tcpConfig) + await adapter.connect() expect(adapter.isConnected()).toBe(true) await adapter.disconnect() @@ -145,7 +121,7 @@ describe('disconnect', () => { adapter.onDisconnected(cb1) adapter.onDisconnected(cb2) - await adapter.connect(tcpConfig) + await adapter.connect() await adapter.disconnect() expect(cb1).toHaveBeenCalledTimes(1) @@ -157,7 +133,7 @@ describe('disconnect', () => { const cb = jest.fn() adapter.onDisconnected(cb) - await adapter.connect(tcpConfig) + await adapter.connect() const result = await adapter.disconnect() expect(adapter.isConnected()).toBe(false) @@ -232,19 +208,35 @@ describe('setVariable', () => { }) }) +// --------------------------------------------------------------------------- +// setPlcState — the operation whose transport parameter caused the bug +// --------------------------------------------------------------------------- + +describe('setPlcState', () => { + it('sends the payload and nothing else', () => { + // Naming a medium here is what made Stop resolve the debug spec, which for a + // DHCP-configured project popped an address dialog — over a serial connection + // that then carried the command anyway. Payload only: the connection manager + // routes it. + void adapter.setPlcState?.('STOPPED') + expect(window.bridge.debuggerPlcControl).toHaveBeenCalledWith('stop') + }) + + it('maps RUNNING to the run action', () => { + void adapter.setPlcState?.('RUNNING') + expect(window.bridge.debuggerPlcControl).toHaveBeenCalledWith('run') + }) +}) + // --------------------------------------------------------------------------- // verifyMd5 // --------------------------------------------------------------------------- describe('verifyMd5', () => { it('delegates to bridge with connection config and expected MD5', async () => { - const result = await adapter.verifyMd5('abc123def456abc123def456abc123de', tcpConfig) + const result = await adapter.verifyMd5('abc123def456abc123def456abc123de') - expect(window.bridge.debuggerVerifyMd5).toHaveBeenCalledWith( - 'tcp', - { ipAddress: '192.168.1.100', port: '502' }, - 'abc123def456abc123def456abc123de', - ) + expect(window.bridge.debuggerVerifyMd5).toHaveBeenCalledWith('abc123def456abc123def456abc123de') expect(result).toEqual({ success: true, match: true, @@ -253,21 +245,24 @@ describe('verifyMd5', () => { }) it('uses different configs per call', async () => { - await adapter.verifyMd5('md5-1', tcpConfig) - expect(window.bridge.debuggerVerifyMd5).toHaveBeenCalledWith( - 'tcp', - { ipAddress: '192.168.1.100', port: '502' }, - 'md5-1', - ) + await adapter.verifyMd5('md5-1') + expect(window.bridge.debuggerVerifyMd5).toHaveBeenCalledWith('md5-1') const simConfig: DebugConnectionConfig = { connectionType: 'simulator', connectionParams: {} } - await adapter.verifyMd5('md5-2', simConfig) - expect(window.bridge.debuggerVerifyMd5).toHaveBeenCalledWith('simulator', {}, 'md5-2') + await adapter.verifyMd5('md5-2') + expect(window.bridge.debuggerVerifyMd5).toHaveBeenCalledWith('md5-2') + }) + + it('omits the transport for a target the connection manager already holds', async () => { + // A connected baremetal board: naming a medium here is what made a debug start + // over serial ask for a DHCP address. + await adapter.verifyMd5('md5-held') + expect(window.bridge.debuggerVerifyMd5).toHaveBeenCalledWith('md5-held') }) it('catches bridge errors', async () => { ;(window.bridge.debuggerVerifyMd5 as jest.Mock).mockRejectedValue(new Error('MD5 check failed')) - const result = await adapter.verifyMd5('abc123', tcpConfig) + const result = await adapter.verifyMd5('abc123') expect(result).toEqual({ success: false, error: 'MD5 check failed' }) }) @@ -328,7 +323,7 @@ describe('onDisconnected', () => { const cb = jest.fn() const unsub = adapter.onDisconnected(cb) - await adapter.connect(tcpConfig) + await adapter.connect() unsub() await adapter.disconnect() @@ -344,7 +339,7 @@ describe('onDisconnected', () => { adapter.onDisconnected(cb3) unsub2() - await adapter.connect(tcpConfig) + await adapter.connect() await adapter.disconnect() expect(cb1).toHaveBeenCalledTimes(1) @@ -372,12 +367,12 @@ describe('isConnected', () => { }) it('returns true after successful connect', async () => { - await adapter.connect(tcpConfig) + await adapter.connect() expect(adapter.isConnected()).toBe(true) }) it('returns false after disconnect', async () => { - await adapter.connect(tcpConfig) + await adapter.connect() await adapter.disconnect() expect(adapter.isConnected()).toBe(false) }) diff --git a/src/middleware/adapters/editor/__tests__/device-adapter.test.ts b/src/middleware/adapters/editor/__tests__/device-adapter.test.ts index a2a79eb59..16bf956b4 100644 --- a/src/middleware/adapters/editor/__tests__/device-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/device-adapter.test.ts @@ -15,10 +15,7 @@ const mockBoards = new Map([ ], ]) -const mockPorts: CommunicationPort[] = [ - { name: '/dev/ttyUSB0', address: '/dev/ttyUSB0' }, - { name: '/dev/ttyACM0', address: '/dev/ttyACM0' }, -] +const mockPorts: CommunicationPort[] = [{ address: '/dev/ttyUSB0' }, { address: '/dev/ttyACM0' }] const mockRefreshResult = [{ board: 'Arduino Uno', version: '1.8.6' }] @@ -29,6 +26,9 @@ beforeEach(() => { refreshAvailableBoards: jest.fn().mockResolvedValue(mockRefreshResult), refreshCommunicationPorts: jest.fn().mockResolvedValue(mockPorts), getPreviewImage: jest.fn().mockResolvedValue('data:image/png;base64,abc123'), + deviceConnect: jest.fn().mockResolvedValue({ status: 'connected-with-firmware' }), + deviceDisconnect: jest.fn().mockResolvedValue({ success: true }), + onDeviceConnectionStatus: jest.fn().mockReturnValue(() => undefined), } as unknown as typeof window.bridge }) @@ -73,4 +73,29 @@ describe('createEditorDeviceAdapter', () => { await adapter.getPreviewImage('motor-shield.png', '/path/to/pkg') expect(window.bridge.getPreviewImage).toHaveBeenCalledWith('motor-shield.png', '/path/to/pkg') }) + + it('delegates connect to window.bridge with the candidate list', async () => { + // Connect passes every way to reach the device, in order; the main process + // tries them and keeps the first that answers. + const candidates = [ + { connectionType: 'tcp' as const, connectionParams: { ipAddress: '192.168.0.50' } }, + { connectionType: 'rtu' as const, connectionParams: { port: 'COM5', baudRate: 115200 } }, + ] + const result = await adapter.connect(candidates) + expect(window.bridge.deviceConnect).toHaveBeenCalledWith(candidates) + expect(result).toMatchObject({ status: 'connected-with-firmware' }) + }) + + it('delegates disconnect to window.bridge', async () => { + const result = await adapter.disconnect() + expect(window.bridge.deviceDisconnect).toHaveBeenCalledTimes(1) + expect(result).toEqual({ success: true }) + }) + + it('delegates onConnectionStatus subscription to window.bridge and returns its unsubscribe', () => { + const cb = jest.fn() + const unsub = adapter.onConnectionStatus(cb) + expect(window.bridge.onDeviceConnectionStatus).toHaveBeenCalledWith(cb) + expect(typeof unsub).toBe('function') + }) }) diff --git a/src/middleware/adapters/editor/debugger-adapter.ts b/src/middleware/adapters/editor/debugger-adapter.ts index 169a0f16f..864f63191 100644 --- a/src/middleware/adapters/editor/debugger-adapter.ts +++ b/src/middleware/adapters/editor/debugger-adapter.ts @@ -10,10 +10,10 @@ * auto-reconnection using stored connection parameters. */ +import type { PlcControlResult } from '../../../backend/shared/debug/types' import { getErrorMessage } from '../../../frontend/utils/get-error-message' import type { DebuggerPort } from '../../shared/ports/debugger-port' import type { - DebugConnectionConfig, DebugSetResult, DebugVariableResult, Md5VerifyResult, @@ -25,13 +25,15 @@ export function createEditorDebuggerAdapter(): DebuggerPort { const disconnectCallbacks: Array<() => void> = [] return { - async connect(config: DebugConnectionConfig): Promise<{ success: boolean; error?: string }> { + async connect(): Promise<{ success: boolean; error?: string }> { try { - const result = await window.bridge.debuggerConnect(config.connectionType, config.connectionParams) + // Nothing to pass: the connection manager already holds this target's + // session, so there is no medium for the caller to name. + const result = await window.bridge.debuggerConnect() if (result.success) connected = true return result - } catch (err) { - return { success: false, error: getErrorMessage(err) } + } catch (error) { + return { success: false, error: getErrorMessage(error) } } }, @@ -64,11 +66,20 @@ export function createEditorDebuggerAdapter(): DebuggerPort { } }, - async verifyMd5(expectedMd5: string, config: DebugConnectionConfig): Promise { + async verifyMd5(expectedMd5: string): Promise { try { - return await window.bridge.debuggerVerifyMd5(config.connectionType, config.connectionParams, expectedMd5) - } catch (err) { - return { success: false, error: getErrorMessage(err) } + return await window.bridge.debuggerVerifyMd5(expectedMd5) + } catch (error) { + return { success: false, error: getErrorMessage(error) } + } + }, + + async setPlcState(state: 'RUNNING' | 'STOPPED'): Promise { + try { + // Payload only. Which medium carries it is the connection manager's business. + return await window.bridge.debuggerPlcControl(state === 'RUNNING' ? 'run' : 'stop') + } catch (error) { + return { success: false, error: getErrorMessage(error) } } }, diff --git a/src/middleware/adapters/editor/device-adapter.ts b/src/middleware/adapters/editor/device-adapter.ts index 9e59afadf..000f0d69f 100644 --- a/src/middleware/adapters/editor/device-adapter.ts +++ b/src/middleware/adapters/editor/device-adapter.ts @@ -14,8 +14,12 @@ * util:get-preview-image (invoke) */ -import type { DevicePort } from '../../shared/ports/device-port' -import type { BoardInfo, CommunicationPort } from '../../shared/ports/types' +import type { + DeviceConnectionStatusPayload, + DeviceConnectResult, + DevicePort, +} from '../../shared/ports/device-port' +import type { BoardInfo, CommunicationPort, DebugConnectionConfig } from '../../shared/ports/types' export function createEditorDeviceAdapter(): DevicePort { return { @@ -38,5 +42,43 @@ export function createEditorDeviceAdapter(): DevicePort { getPreviewImage(imageName: string, packagePath?: string): Promise { return window.bridge.getPreviewImage(imageName, packagePath) }, + + connect(candidates: DebugConnectionConfig[]): Promise { + return window.bridge.deviceConnect(candidates) + }, + + openRuntimeSession(params: { address: string; debug: DebugConnectionConfig }): Promise<{ + success: boolean + error?: string + }> { + return window.bridge.openRuntimeSession(params) + }, + + closeRuntimeSession(): Promise<{ success: boolean }> { + return window.bridge.closeRuntimeSession() + }, + + async releaseSerialPort(port: string | null | undefined): Promise { + const result = await window.bridge.deviceReleaseSerialPort(port) + return result.released + }, + + disconnect(): Promise<{ success: boolean }> { + return window.bridge.deviceDisconnect() + }, + + onLinkLog(callback: (message: string) => void): () => void { + return window.bridge.onDeviceLinkLog(callback) + }, + + onConnectionStatus(callback: (payload: DeviceConnectionStatusPayload) => void): () => void { + return window.bridge.onDeviceConnectionStatus(callback) + }, + + onPlcState( + callback: (payload: { port: string; plcState?: number; switchPosition?: number }) => void, + ): () => void { + return window.bridge.onDevicePlcState(callback) + }, } } diff --git a/src/middleware/shared/ports/debugger-port.ts b/src/middleware/shared/ports/debugger-port.ts index 5a1bd8830..530ecfc00 100644 --- a/src/middleware/shared/ports/debugger-port.ts +++ b/src/middleware/shared/ports/debugger-port.ts @@ -28,15 +28,18 @@ * - DebugTransport interface implementations */ -import type { DebugConnectionConfig, DebugSetResult, DebugVariableResult, Md5VerifyResult, Unsubscribe } from './types' +import type { PlcControlResult } from '../../../backend/shared/debug/types' +import type { DebugSetResult, DebugVariableResult, Md5VerifyResult, Unsubscribe } from './types' export interface DebuggerPort { /** - * Connect to a debug target. - * @param config — Connection target (TCP host, RTU serial, WebSocket, or simulator). - * The adapter maps this to the platform's transport mechanism. + * Start a debug session over the session the connection manager holds. + * + * Takes nothing: every target's session is established before this — a device by + * Connect, a runtime by logging in, the simulator by starting. Naming a medium + * here is what made a Stop over serial ask for a DHCP address. */ - connect(config: DebugConnectionConfig): Promise<{ success: boolean; error?: string }> + connect(): Promise<{ success: boolean; error?: string }> /** Disconnect from the current debug target. */ disconnect(): Promise<{ success: boolean }> @@ -61,7 +64,7 @@ export interface DebuggerPort { * Used to detect program mismatch before starting a debug session. * @param config — Connection target used for the verification request. */ - verifyMd5(expectedMd5: string, config: DebugConnectionConfig): Promise + verifyMd5(expectedMd5: string): Promise /** * Read the MD5 hash of the compiled ST program from the debug artifacts. @@ -86,6 +89,19 @@ export interface DebuggerPort { */ onDisconnected(callback: () => void): Unsubscribe + /** + * Ask the target to run or stop (Modbus FC 0x4b). + * + * Command only — the state is READ from the device status poll (FC 0x46), + * which already reports it, so there is deliberately no `getPlcState` here. + * + * A RUN request is REFUSED, not queued, while the hardware mode switch reads + * STOP; the result carries `refusedBySwitch` so the caller shows the "flip the + * switch to RUN" warning. `unsupported` means the firmware predates the + * run/stop state machine. + */ + setPlcState?(state: 'RUNNING' | 'STOPPED'): Promise + /** Check if the debugger is currently connected. */ isConnected(): boolean } diff --git a/src/middleware/shared/ports/device-port.ts b/src/middleware/shared/ports/device-port.ts index c6606533a..98538d72d 100644 --- a/src/middleware/shared/ports/device-port.ts +++ b/src/middleware/shared/ports/device-port.ts @@ -21,7 +21,57 @@ * - getDeviceStatus() */ -import type { BoardInfo, CommunicationPort } from './types' +import type { BoardInfo, CommunicationPort, DebugConnectionConfig } from './types' + +// --------------------------------------------------------------------------- +// Connect-time classification (D72) — platform contract shared by the port and +// its editor adapter. The store can't reach into `backend/`, so the canonical +// shape lives here. +// --------------------------------------------------------------------------- + +/** How a freshly-opened channel classified. */ +export type DeviceProbeStatus = 'connected-with-firmware' | 'no-firmware' | 'no-response' | 'error' + +/** + * Result of opening a persistent device link (D72): how the channel the main + * process settled on classified. + */ +export interface DeviceConnectResult { + status: DeviceProbeStatus + /** Transport failure text when `status === 'error'`. */ + error?: string +} + +/** + * Live status of the held baremetal serial link, pushed by the main process. + * + * `reason: 'lost'` distinguishes the one failure the user must be TOLD about — a + * link that was up, died, and could not be recovered — from an 'error' that came + * straight out of something they just clicked (which already has its own dialog). + */ +export interface DeviceConnectionStatusPayload { + status: 'disconnected' | 'connecting' | 'connected' | 'error' + /** + * Medium the CONTROL channel uses (or was using, when it dropped). Absent for a + * REST-controlled runtime session: REST holds no connection. + */ + transport?: 'rtu' | 'tcp' | 'simulator' + /** + * Medium the DEBUG channel uses — the same as `transport` when one channel serves + * both roles, else `websocket` (v4) or `tcp` (v3). Consumers that must size work + * to the wire (the debug poll's frame budget) read THIS, rather than inferring a + * medium they have no business choosing. + */ + debugTransport?: 'rtu' | 'tcp' | 'simulator' | 'websocket' + /** + * The endpoint, as the user would name it: a serial path ("/dev/ttyACM0", + * "COM5") or an IP address. Not called `port`, because for a Modbus TCP link it + * is an address — and a name that implies serial is what led callers to branch + * on the wrong thing in the first place. + */ + descriptor?: string + reason?: 'lost' +} export interface DevicePort { /** @@ -59,4 +109,78 @@ export interface DevicePort { * Web: returns URL to bundled image asset. */ getPreviewImage(imageName: string, packagePath?: string): Promise + + /** + * Open and HOLD the connection to a baremetal device (D72). + * + * `candidates` is the ordered list of ways to reach it, resolved from the + * board's debug spec: Modbus TCP first when the project enables it, then serial. + * The main process tries them in order and keeps the first that both opens and + * answers, so a stale DHCP address or an unplugged ethernet shield falls through + * to the cable instead of leaving the editor claiming a connection it does not + * have. It then holds that ONE connection — every command (debug, run/stop, the + * status poll) rides it — polls it, and pushes status changes through + * `onConnectionStatus`. + * + * Editor: `device:connect`. Web: not applicable locally. + */ + connect(candidates: DebugConnectionConfig[]): Promise + + /** + * Establish a session with a target CONTROLLED over REST (Runtime v3/v4), after + * the renderer has logged in: `debug` describes the channel that target debugs + * over (v3 Modbus TCP, v4 the WebSocket), which is opened later, only if a debug + * session asks for it. + * + * Editor: `session:open-runtime`. Web: no-op. + */ + openRuntimeSession?(params: { address: string; debug: DebugConnectionConfig }): Promise<{ + success: boolean + error?: string + }> + + /** Close a REST-controlled session (logout / disconnect). */ + closeRuntimeSession?(): Promise<{ success: boolean }> + + /** + * Hand the serial port over for an upload: releases the held connection only if + * it IS the serial one occupying `port`, and reports whether it did. + * + * A connection over Modbus TCP is left alone — flashing over USB does not + * disturb it, so debugging and run/stop survive the upload. Disconnecting + * unconditionally (what the upload flow used to do) threw away a working link + * for no reason. + * + * Editor: `device:release-serial-port`. Web: no-op, returns false. + */ + releaseSerialPort(port: string | null | undefined): Promise + + /** Close a held serial link. Editor: `device:disconnect`. */ + disconnect(): Promise<{ success: boolean }> + + /** + * Subscribe to live serial-link status pushed by the main process (liveness + * failure, upload/debug handoff). Returns an unsubscribe function. Editor: + * `device:connection-status` IPC event. Web: no-op. + */ + /** + * Subscribe to the device connection's diagnostic trace. Returns an unsubscribe + * function. Editor: `device:link-log`. Web: no-op. + */ + onLinkLog?(callback: (message: string) => void): () => void + + onConnectionStatus(callback: (payload: DeviceConnectionStatusPayload) => void): () => void + + /** + * Subscribe to run/stop state from the held device link (baremetal targets). + * + * Pushed on the same liveness tick that keeps the link honest — the status + * frame (FC 0x46) carries the run/stop state and the mode-switch position — so + * this costs no extra round trip and needs no second timer. `plcState` is + * 0/1/2 (STOPPED/RUNNING/ERROR); `switchPosition` is 0/1 (STOP/RUN) and is + * absent on firmware predating the run/stop state machine. + */ + onPlcState?( + callback: (payload: { port: string; plcState?: number; switchPosition?: number }) => void, + ): () => void } diff --git a/src/middleware/shared/ports/runtime-port.ts b/src/middleware/shared/ports/runtime-port.ts index 12d3e51b1..dd021eeaf 100644 --- a/src/middleware/shared/ports/runtime-port.ts +++ b/src/middleware/shared/ports/runtime-port.ts @@ -116,6 +116,11 @@ export interface RuntimeStatusResult { success: boolean status?: PlcStatus | (string & {}) timingStats?: TimingStats + /** Run/stop mode-switch position reported by the runtime (`'run'` / + * `'stop'`). Devices with no switch-aware VPP plugin always report + * `'run'`, and runtimes older than this field omit it entirely — treat + * `undefined` as "no gating". */ + switchPosition?: 'run' | 'stop' error?: string } diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index 090044a9f..8715b9010 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -915,9 +915,25 @@ export interface VendorIoMapping { entries: IoMappingEntry[] } +/** + * A serial port offered in the communication-port picker. + * + * Deliberately NOT a pre-composed display string. The producer reports facts + * and the renderer decides how they read (`serialPortDisplay`) — conflating the + * two is what let the board name get dropped: the label had to guess whether + * `name` held a bare manufacturer or an already-composed `"COM5 (Arduino Uno)"`. + */ export interface CommunicationPort { - name: string + /** OS-canonical port identifier, and the value actually opened: `COM5` on + * Windows, `/dev/ttyUSB0` on Linux, `/dev/cu.usbmodem*` on macOS. Always the + * primary label — never replaced by a descriptor. */ address: string + /** Board name identified by arduino-cli from the connected core's VID/PID + * (e.g. `Arduino MKR`). Absent when no core matched the device. */ + boardName?: string + /** Manufacturer / vendor string from `serialport` (e.g. `wch.cn` for a + * CH340). The fallback descriptor when arduino-cli identified no board. */ + manufacturer?: string } export interface SerialPort { diff --git a/src/middleware/shared/utils/debug-endpoint.ts b/src/middleware/shared/utils/debug-endpoint.ts new file mode 100644 index 000000000..1916adc06 --- /dev/null +++ b/src/middleware/shared/utils/debug-endpoint.ts @@ -0,0 +1,15 @@ +import type { DebugConnectionConfig } from '../ports/types' + +/** + * How a connection endpoint reads to a user: a serial path ("/dev/ttyACM0", + * "COM5") or an IP address. + * + * Shared between the main process (which labels the connection it holds) and the + * renderer (which names endpoints in dialogs), so "could not reach X" and the + * status bar always spell X the same way. + */ +export function describeDebugEndpoint(config: DebugConnectionConfig): string { + if (config.connectionType === 'tcp') return String(config.connectionParams.ipAddress ?? 'the configured IP address') + if (config.connectionType === 'simulator') return 'simulator' + return String(config.connectionParams.port ?? 'the selected port') +} diff --git a/src/middleware/shared/utils/target-capabilities/presets.ts b/src/middleware/shared/utils/target-capabilities/presets.ts index 7fe88efb1..80f1bd18e 100644 --- a/src/middleware/shared/utils/target-capabilities/presets.ts +++ b/src/middleware/shared/utils/target-capabilities/presets.ts @@ -30,6 +30,7 @@ export const SIMULATOR_CAPABILITIES: TargetCapabilities = { arduinoApiCompletions: true, hasRuntimeStats: false, isInProcessSimulator: true, + plcStateControl: false, directUsbUpload: true, } @@ -46,6 +47,7 @@ export const RUNTIME_V3_CAPABILITIES: TargetCapabilities = { arduinoApiCompletions: false, hasRuntimeStats: false, isInProcessSimulator: false, + plcStateControl: false, directUsbUpload: false, } @@ -64,6 +66,7 @@ export const RUNTIME_V4_CAPABILITIES: TargetCapabilities = { arduinoApiCompletions: false, hasRuntimeStats: true, isInProcessSimulator: false, + plcStateControl: true, directUsbUpload: false, } @@ -83,5 +86,6 @@ export const ARDUINO_CLI_CAPABILITIES: TargetCapabilities = { arduinoApiCompletions: true, hasRuntimeStats: false, isInProcessSimulator: false, + plcStateControl: true, directUsbUpload: true, } diff --git a/src/middleware/shared/utils/target-capabilities/resolve.ts b/src/middleware/shared/utils/target-capabilities/resolve.ts index 186e1e370..740806caf 100644 --- a/src/middleware/shared/utils/target-capabilities/resolve.ts +++ b/src/middleware/shared/utils/target-capabilities/resolve.ts @@ -49,6 +49,7 @@ const EMPTY_CAPABILITIES: TargetCapabilities = { arduinoApiCompletions: false, hasRuntimeStats: false, isInProcessSimulator: false, + plcStateControl: false, directUsbUpload: false, } diff --git a/src/middleware/shared/utils/target-capabilities/types.ts b/src/middleware/shared/utils/target-capabilities/types.ts index d88f88954..1572c367f 100644 --- a/src/middleware/shared/utils/target-capabilities/types.ts +++ b/src/middleware/shared/utils/target-capabilities/types.ts @@ -93,6 +93,13 @@ export interface TargetCapabilities { * whether the host *can* run a simulator. */ isInProcessSimulator: boolean + /** Target implements the runtime run/stop state machine, so the + * Start/Stop control is meaningful. Runtime v4 drives it over REST; + * arduino-cli targets drive it over the debugger transport (Modbus + * FC 0x49). Runtime v3 has its own web UI and is excluded; the + * Simulator keeps its dedicated start/stop path. */ + plcStateControl: boolean + /** Upload happens over a local connection (USB / loopback) and * doesn't require a separate "Connect" step. Arduino-CLI + the * in-process Simulator. Runtime v3 / v4 require an established From 8ac77750c84429209e16aee72759a53eb8fc8b25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 4 Aug 2026 12:18:09 +0200 Subject: [PATCH 10/19] style: prettier + import sort over the connection surface Formatting only, no behaviour change. Also drops three imports left unused by the licensing removal (Popover/Copy in board.tsx, DebugBoardIdResult in the WebSocket transport) and normalises two files to LF. Co-Authored-By: Claude Opus 5 --- .../__tests__/device-session-manager.test.ts | 16 +- .../shared/compile/steps/generate-defines.ts | 11 +- .../shared/debug/__tests__/modbus-pdu.test.ts | 9 +- .../shared/debug/websocket-debug-transport.ts | 9 +- .../connect-resolve-regression.test.ts | 51 +++- .../__tests__/modbus-rtu-client.test.ts | 14 +- .../shared/simulator/modbus-rtu-client.ts | 4 +- .../editor/device/configuration/board.tsx | 152 ++++++----- .../workspace-activity-bar/default.tsx | 37 ++- src/frontend/hooks/useDebugSession.ts | 243 +++++++++--------- .../store/__tests__/device-slice.test.ts | 42 ++- .../store/__tests__/device-types.test.ts | 2 +- .../utils/__tests__/serial-port-label.test.ts | 7 +- .../utils/vpp/__tests__/field-options.test.ts | 13 +- src/main/modules/ipc/main.ts | 21 +- .../adapters/editor/debugger-adapter.ts | 7 +- .../adapters/editor/device-adapter.ts | 10 +- src/middleware/shared/ports/device-port.ts | 4 +- 18 files changed, 351 insertions(+), 301 deletions(-) diff --git a/src/backend/editor/hardware/__tests__/device-session-manager.test.ts b/src/backend/editor/hardware/__tests__/device-session-manager.test.ts index 7c964bc5d..7b0801ffd 100644 --- a/src/backend/editor/hardware/__tests__/device-session-manager.test.ts +++ b/src/backend/editor/hardware/__tests__/device-session-manager.test.ts @@ -278,9 +278,7 @@ describe('DeviceSessionManager', () => { expect(h.manager.isConnected()).toBe(false) expect(h.manager.isRecovering()).toBe(false) - expect(h.statuses).toEqual([ - { status: 'error', transport: 'rtu', descriptor: '/dev/ttyUSB0', reason: 'lost' }, - ]) + expect(h.statuses).toEqual([{ status: 'error', transport: 'rtu', descriptor: '/dev/ttyUSB0', reason: 'lost' }]) }) }) @@ -388,7 +386,11 @@ describe('DeviceSessionManager', () => { const h = harness() h.manager.openRestSession({ address: '10.0.0.5', - debugChannel: { transport: 'websocket', descriptor: 'websocket 10.0.0.5', create: () => asTransport(new FakeClient()) }, + debugChannel: { + transport: 'websocket', + descriptor: 'websocket 10.0.0.5', + create: () => asTransport(new FakeClient()), + }, }) expect(h.manager.isConnected()).toBe(true) @@ -406,7 +408,11 @@ describe('DeviceSessionManager', () => { const h = harness() h.manager.openRestSession({ address: '10.0.0.5', - debugChannel: { transport: 'websocket', descriptor: 'websocket 10.0.0.5', create: () => asTransport(new FakeClient()) }, + debugChannel: { + transport: 'websocket', + descriptor: 'websocket 10.0.0.5', + create: () => asTransport(new FakeClient()), + }, }) expect(h.statuses.at(-1)).toEqual({ diff --git a/src/backend/shared/compile/steps/generate-defines.ts b/src/backend/shared/compile/steps/generate-defines.ts index 0510cbb86..61445cc95 100644 --- a/src/backend/shared/compile/steps/generate-defines.ts +++ b/src/backend/shared/compile/steps/generate-defines.ts @@ -104,8 +104,15 @@ export interface GenerateDefinesInput { * editor-produced and web-produced firmware comes out clean). */ export function generateDefinesContent(input: GenerateDefinesInput): string { - const { boardEntry, devicePinMapping, stProgramFileContent, buildMD5Hash, boardRuntime, vppModbusState, defaultSerial } = - input + const { + boardEntry, + devicePinMapping, + stProgramFileContent, + buildMD5Hash, + boardRuntime, + vppModbusState, + defaultSerial, + } = input let DEFINES_CONTENT = '' diff --git a/src/backend/shared/debug/__tests__/modbus-pdu.test.ts b/src/backend/shared/debug/__tests__/modbus-pdu.test.ts index c65770c91..5daee4a1a 100644 --- a/src/backend/shared/debug/__tests__/modbus-pdu.test.ts +++ b/src/backend/shared/debug/__tests__/modbus-pdu.test.ts @@ -287,7 +287,14 @@ describe('buildGetVersionRequest / parseGetVersionResponse', () => { }) it('strips a trailing NUL terminator', () => { - const buf = new Uint8Array([ModbusFunctionCode.DEBUG_GET_VERSION, ModbusDebugResponse.SUCCESS, 0x31, 0x2e, 0x30, 0x00]) + const buf = new Uint8Array([ + ModbusFunctionCode.DEBUG_GET_VERSION, + ModbusDebugResponse.SUCCESS, + 0x31, + 0x2e, + 0x30, + 0x00, + ]) expect(parseGetVersionResponse(buf).version).toBe('1.0') }) diff --git a/src/backend/shared/debug/websocket-debug-transport.ts b/src/backend/shared/debug/websocket-debug-transport.ts index 44b62678f..4192c0df3 100644 --- a/src/backend/shared/debug/websocket-debug-transport.ts +++ b/src/backend/shared/debug/websocket-debug-transport.ts @@ -31,14 +31,7 @@ import { parseGetMd5Response, parseSetVariableResponse, } from './modbus-pdu' -import type { - DebugBoardIdResult, - DebugSetResult, - DebugTransport, - DebugTransportResult, - DeviceDebugChannel, - Md5ProbeResult, -} from './types' +import type { DebugSetResult, DebugTransport, DebugTransportResult, DeviceDebugChannel, Md5ProbeResult } from './types' const REQUEST_TIMEOUT_MS = 5000 const CONNECT_TIMEOUT_MS = 5000 diff --git a/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts b/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts index 783414209..e2c285995 100644 --- a/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts +++ b/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts @@ -29,7 +29,10 @@ const ARDUINO_TRANSPORTS = ['modbus-serial', 'modbus-tcp'] as const * before the user picks one — the builder omits the key entirely. */ const disconnectedUsbContext = (port?: string): DebugResolverContext => ({ state: { - configuration: { deviceBoard: 'AutomationDirect P1AM-100', ...(port !== undefined ? { communicationPort: port } : {}) }, + configuration: { + deviceBoard: 'AutomationDirect P1AM-100', + ...(port !== undefined ? { communicationPort: port } : {}), + }, screens: { modbus_rtu: { enabled: true, rtu_baud_rate: '115200', rtu_slave_id: 1 } }, runtimeConnection: {}, promptCache: {}, @@ -119,7 +122,9 @@ describe('Connect resolves a baremetal debug spec while disconnected', () => { it('offers BOTH transports, SERIAL first, when the project enables Modbus TCP', () => { // Serial leads: it is the direct, local path, with no address to be stale and // nothing to ask the user. Modbus TCP is the remote fallback. - const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS] }) + const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { + transports: [...ARDUINO_TRANSPORTS], + }) expect(result.kind).toBe('candidates') if (result.kind !== 'candidates') return @@ -132,7 +137,9 @@ describe('Connect resolves a baremetal debug spec while disconnected', () => { // debugger keeps the serial protocol compiled into every baremetal firmware. // Requiring `enabledWhen` here is what made Connect refuse a Modbus-TCP-only // project with "select a communication port" while one was plainly selected. - const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS] }) + const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { + transports: [...ARDUINO_TRANSPORTS], + }) if (result.kind !== 'candidates') throw new Error('expected candidates') expect(result.candidates.some((candidate) => candidate.config.connectionType === 'rtu')).toBe(true) }) @@ -145,7 +152,6 @@ describe('Connect resolves a baremetal debug spec while disconnected', () => { expect(rtuOnly.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['rtu']) }) - it('resolves a Runtime v4 target, whose only transport is a WebSocket', () => { // The regression that broke every v4 target: with eligibility hardcoded to // serial-then-TCP, a `websocket` channel was never a candidate, so no session @@ -222,7 +228,10 @@ describe('Connect resolves a baremetal debug spec while disconnected', () => { it('lets a caller skip a channel it has decided against', () => { // Channel 0 in this spec is the TCP one. - const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS], skipChannels: [0] }) + const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { + transports: [...ARDUINO_TRANSPORTS], + skipChannels: [0], + }) if (result.kind !== 'candidates') throw new Error('expected candidates') expect(result.candidates.map((candidate) => candidate.config.connectionType)).toEqual(['rtu']) }) @@ -257,7 +266,10 @@ describe('Connect resolves a baremetal debug spec while disconnected', () => { it('sets the DHCP channel aside instead of asking, when prompts are deferred', () => { // The user's report: with DHCP on, Connect hung on a dialog before trying // anything. With a cable attached, that question is pure interruption. - const result = resolveDeviceLinkCandidates(dhcpSpec, dhcpContext(), { transports: [...ARDUINO_TRANSPORTS], deferPrompts: true }) + const result = resolveDeviceLinkCandidates(dhcpSpec, dhcpContext(), { + transports: [...ARDUINO_TRANSPORTS], + deferPrompts: true, + }) expect(result.kind).toBe('candidates') if (result.kind !== 'candidates') return @@ -267,10 +279,16 @@ describe('Connect resolves a baremetal debug spec while disconnected', () => { it('asks once the caller resolves that channel on its own', () => { // The second pass, run only after everything silent has failed. - const deferred = resolveDeviceLinkCandidates(dhcpSpec, dhcpContext(), { transports: [...ARDUINO_TRANSPORTS], deferPrompts: true }) + const deferred = resolveDeviceLinkCandidates(dhcpSpec, dhcpContext(), { + transports: [...ARDUINO_TRANSPORTS], + deferPrompts: true, + }) if (deferred.kind !== 'candidates') throw new Error('expected candidates') - const result = resolveDeviceLinkCandidates(dhcpSpec, dhcpContext(), { transports: [...ARDUINO_TRANSPORTS], onlyChannels: deferred.awaitingInput }) + const result = resolveDeviceLinkCandidates(dhcpSpec, dhcpContext(), { + transports: [...ARDUINO_TRANSPORTS], + onlyChannels: deferred.awaitingInput, + }) expect(result.kind).toBe('prompt') }) @@ -280,7 +298,10 @@ describe('Connect resolves a baremetal debug spec while disconnected', () => { // exactly what is missing. const context = dhcpContext() delete context.state.configuration.communicationPort - const result = resolveDeviceLinkCandidates(dhcpSpec, context, { transports: [...ARDUINO_TRANSPORTS], deferPrompts: true }) + const result = resolveDeviceLinkCandidates(dhcpSpec, context, { + transports: [...ARDUINO_TRANSPORTS], + deferPrompts: true, + }) expect(result.kind).toBe('candidates') if (result.kind !== 'candidates') return @@ -291,14 +312,20 @@ describe('Connect resolves a baremetal debug spec while disconnected', () => { it('still reports a missing port when serial is the only candidate', () => { // Candidate resolution must not swallow a channel's own `required` message. - const result = resolveDeviceLinkCandidates(baremetalSpec, disconnectedUsbContext(), { transports: [...ARDUINO_TRANSPORTS] }) + const result = resolveDeviceLinkCandidates(baremetalSpec, disconnectedUsbContext(), { + transports: [...ARDUINO_TRANSPORTS], + }) expect(result).toMatchObject({ kind: 'error', body: 'No serial port selected.' }) }) it('reports unsupported when the board declares nothing reachable', () => { const malformed = {} as unknown as DebugSpec - expect(resolveDeviceLinkCandidates(malformed, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS] }).kind).toBe('unsupported') - expect(resolveDeviceLinkCandidates(undefined, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS] }).kind).toBe('unsupported') + expect(resolveDeviceLinkCandidates(malformed, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS] }).kind).toBe( + 'unsupported', + ) + expect(resolveDeviceLinkCandidates(undefined, tcpOnlyContext(), { transports: [...ARDUINO_TRANSPORTS] }).kind).toBe( + 'unsupported', + ) }) it('shows why a precondition cannot express a debugger-only requirement', () => { diff --git a/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts b/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts index 8f5e6edc1..15a85d885 100644 --- a/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts +++ b/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts @@ -604,7 +604,9 @@ describe('ModbusRtuClient', () => { it('returns error on incomplete success payload', async () => { await connectClient() - autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_STATUS, new Uint8Array([ModbusDebugResponse.SUCCESS, 1]))) + autoRespond( + buildResponse(1, ModbusFunctionCode.DEBUG_GET_STATUS, new Uint8Array([ModbusDebugResponse.SUCCESS, 1])), + ) const result = await client.getStatus() expect(result.success).toBe(false) expect(result.error).toContain('Incomplete status response') @@ -701,7 +703,9 @@ describe('ModbusRtuClient', () => { it('handles id_len = 0 (unsupported core) as success with empty id', async () => { await connectClient() - autoRespond(buildResponse(1, ModbusFunctionCode.DEBUG_GET_BOARD_ID, new Uint8Array([ModbusDebugResponse.SUCCESS, 0x00]))) + autoRespond( + buildResponse(1, ModbusFunctionCode.DEBUG_GET_BOARD_ID, new Uint8Array([ModbusDebugResponse.SUCCESS, 0x00])), + ) const result = await client.getBoardId() expect(result.success).toBe(true) expect(result.boardIdHex).toBe('') @@ -727,7 +731,11 @@ describe('ModbusRtuClient', () => { it('returns error on incomplete id data', async () => { await connectClient() autoRespond( - buildResponse(1, ModbusFunctionCode.DEBUG_GET_BOARD_ID, new Uint8Array([ModbusDebugResponse.SUCCESS, 0x04, 0x0a, 0x0b])), + buildResponse( + 1, + ModbusFunctionCode.DEBUG_GET_BOARD_ID, + new Uint8Array([ModbusDebugResponse.SUCCESS, 0x04, 0x0a, 0x0b]), + ), ) const result = await client.getBoardId() expect(result.success).toBe(false) diff --git a/src/backend/shared/simulator/modbus-rtu-client.ts b/src/backend/shared/simulator/modbus-rtu-client.ts index 37d3c19f5..8de172813 100644 --- a/src/backend/shared/simulator/modbus-rtu-client.ts +++ b/src/backend/shared/simulator/modbus-rtu-client.ts @@ -581,9 +581,7 @@ export class ModbusRtuClient { // buildPlcSetStateRequest returns [FC][state]; assembleRequest writes the // FC + slaveId itself, so hand it only the trailing payload. const pdu = buildPlcSetStateRequest(state) - const response = await this.sendRequest( - this.assembleRequest(ModbusFunctionCode.PLC_SET_STATE, pdu.subarray(1)), - ) + const response = await this.sendRequest(this.assembleRequest(ModbusFunctionCode.PLC_SET_STATE, pdu.subarray(1))) if (response.length < 8) { return { success: false, error: `Invalid response: too short (${response.length} bytes)` } } diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx index d4b8c9f15..6a14c8240 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -1,9 +1,7 @@ /* eslint-disable @typescript-eslint/no-misused-promises */ -import * as Popover from '@radix-ui/react-popover' import type { TimingStats } from '@root/middleware/shared/ports/types' import { useCapabilities, useDevice, useRuntime } from '@root/middleware/shared/providers/platform-context' import { resolveTargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' -import { Copy } from 'lucide-react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { MagnifierIcon } from '../../../../../../assets/icons/interface/Magnifier' @@ -35,9 +33,7 @@ import { PinMappingTable } from './components/pin-mapping-table' */ function DeviceConnectedIndicator({ isConnected }: { isConnected: boolean }) { if (!isConnected) return null - return ( - Connected - ) + return Connected } const Board = memo(function () { @@ -650,81 +646,81 @@ const Board = memo(function () { ) : capabilities.hasLocalSerialPorts ? ( <> -
- - - {availableCommunicationPorts.map((port) => { - // Label by the OS-canonical port path (COM5 / /dev/ttyUSB0 / - // /dev/tty.usbserial-*); the chip/vendor name rides as a hover hint. - const { label, title } = serialPortDisplay(port) - return ( - - - {label} - - - ) - })} - - - +
+ - - -
- - - + + ) : null} {!isOpenPLCRuntimeTarget(currentBoardInfo) && !isSimulatorTarget(currentBoardInfo) && ( diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index 43c62094c..df4f97cd6 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -509,19 +509,16 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa * package provides it, so a P1AM says "CPU switch" rather than the generic * wording. */ - const warnSwitchInStop = useCallback( - async (deviceName: string, switchLabel?: string): Promise => { - await showDeviceDialog( - 'warning', - 'Device is in STOP', - `The ${switchLabel ?? 'mode switch'} on ${deviceName} is in the STOP position. ` + - 'The PLC cannot be started from the editor while the switch is in STOP.\n\n' + - 'Flip the switch to RUN and try again.', - ['OK'], - ) - }, - [], - ) + const warnSwitchInStop = useCallback(async (deviceName: string, switchLabel?: string): Promise => { + await showDeviceDialog( + 'warning', + 'Device is in STOP', + `The ${switchLabel ?? 'mode switch'} on ${deviceName} is in the STOP position. ` + + 'The PLC cannot be started from the editor while the switch is in STOP.\n\n' + + 'Flip the switch to RUN and try again.', + ['OK'], + ) + }, []) const handlePlcControl = useCallback(async (): Promise => { const boardTarget = deviceDefinitions.configuration.deviceBoard @@ -529,9 +526,8 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const caps = resolveTargetCapabilities(boardInfo) if (!caps.plcStateControl) return - const switchLabel = ( - boardInfo as { stateControl?: { modeSwitch?: { label?: string } } } | undefined - )?.stateControl?.modeSwitch?.label + const switchLabel = (boardInfo as { stateControl?: { modeSwitch?: { label?: string } } } | undefined)?.stateControl + ?.modeSwitch?.label // ONE path for every target. "Start the PLC" is the same request whether it // travels as Modbus FC 0x4b down a cable or as an HTTP POST to a runtime; the @@ -908,7 +904,8 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa canEdit, executeSave, addLog, - currentBoardInfo]) + currentBoardInfo, + ]) // --------------------------------------------------------------------------- // JSX @@ -980,11 +977,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa > void handleSimulatorControl() : () => void handlePlcControl()} - disabled={ - isSimulatorBoard - ? isCompiling || isDebuggerProcessing - : plcControlBlocked - } + disabled={isSimulatorBoard ? isCompiling || isDebuggerProcessing : plcControlBlocked} className={cn( isSimulatorBoard ? isCompiling || isDebuggerProcessing diff --git a/src/frontend/hooks/useDebugSession.ts b/src/frontend/hooks/useDebugSession.ts index 99d70ed4b..8581b85a0 100644 --- a/src/frontend/hooks/useDebugSession.ts +++ b/src/frontend/hooks/useDebugSession.ts @@ -59,152 +59,149 @@ export function useDebugSession(): UseDebugSessionReturn { const debugTreesRef = useRef>({}) - const connectAndStart = useCallback( - async (): Promise<{ success: boolean; error?: string }> => { - const { project, workspaceActions: wsActions, consoleActions: logActions } = useOpenPLCStore.getState() - const boardTarget = deviceDefinitions.configuration.deviceBoard - const projectPath = project.meta.path + const connectAndStart = useCallback(async (): Promise<{ success: boolean; error?: string }> => { + const { project, workspaceActions: wsActions, consoleActions: logActions } = useOpenPLCStore.getState() + const boardTarget = deviceDefinitions.configuration.deviceBoard + const projectPath = project.meta.path - logActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'Connecting debugger...' }) + logActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'Connecting debugger...' }) - try { - const debugFileResult = await debuggerPort.readDebugFile(projectPath, boardTarget) - if (!debugFileResult.success || !debugFileResult.content) { - const error = `Failed to read debug-map.json: ${debugFileResult.error ?? 'No content'}` - logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) - return { success: false, error } - } + try { + const debugFileResult = await debuggerPort.readDebugFile(projectPath, boardTarget) + if (!debugFileResult.success || !debugFileResult.content) { + const error = `Failed to read debug-map.json: ${debugFileResult.error ?? 'No content'}` + logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) + return { success: false, error } + } - wsActions.setDebugCContent(debugFileResult.content) + wsActions.setDebugCContent(debugFileResult.content) - const instances = project.data.configurations.resource.instances + const instances = project.data.configurations.resource.instances - const debugMap = parseDebugMap(debugFileResult.content) - if (!debugMap) { - const error = 'Invalid debug-map.json (expected schema version 2)' - logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) - return { success: false, error } + const debugMap = parseDebugMap(debugFileResult.content) + if (!debugMap) { + const error = 'Invalid debug-map.json (expected schema version 2)' + logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) + return { success: false, error } + } + + const entriesForTree = debugMapToEntries(debugMap) + logActions.addLog({ + id: crypto.randomUUID(), + level: 'info', + message: `Debug map: ${debugMap.leaves.length} leaves across ${debugMap.arrays.length} arrays.`, + }) + + // Build the debug variable tree — the single enumeration walk. The + // composite-key → index map (used by the LD/FBD editors and the poller) + // is derived from this same tree, so every consumer resolves a + // variable's address identically. + let treeMap = new Map() + const pouTrees: Record = {} + try { + const treeResult = buildDebugVariableTreeMap( + project.data.pous, + instances, + entriesForTree, + project.data, + useOpenPLCStore.getState().libraries.system, + ) + treeMap = treeResult.treeMap + + // Group trees by POU name for polling hook + for (const node of treeResult.trees) { + const pouName = node.compositeKey.split(':')[0] + if (!pouTrees[pouName]) pouTrees[pouName] = [] + pouTrees[pouName].push(node) + } + + for (const w of treeResult.warnings) { + logActions.addLog({ id: crypto.randomUUID(), level: 'warning', message: w }) } - const entriesForTree = debugMapToEntries(debugMap) logActions.addLog({ id: crypto.randomUUID(), level: 'info', - message: `Debug map: ${debugMap.leaves.length} leaves across ${debugMap.arrays.length} arrays.`, + message: `Debug tree builder: Built ${treeResult.trees.length} trees (${treeResult.complexCount} complex).`, }) + } catch { + logActions.addLog({ + id: crypto.randomUUID(), + level: 'warning', + message: 'Debug tree builder encountered errors.', + }) + } - // Build the debug variable tree — the single enumeration walk. The - // composite-key → index map (used by the LD/FBD editors and the poller) - // is derived from this same tree, so every consumer resolves a - // variable's address identically. - let treeMap = new Map() - const pouTrees: Record = {} - try { - const treeResult = buildDebugVariableTreeMap( - project.data.pous, - instances, - entriesForTree, - project.data, - useOpenPLCStore.getState().libraries.system, - ) - treeMap = treeResult.treeMap - - // Group trees by POU name for polling hook - for (const node of treeResult.trees) { - const pouName = node.compositeKey.split(':')[0] - if (!pouTrees[pouName]) pouTrees[pouName] = [] - pouTrees[pouName].push(node) - } - - for (const w of treeResult.warnings) { - logActions.addLog({ id: crypto.randomUUID(), level: 'warning', message: w }) - } - - logActions.addLog({ - id: crypto.randomUUID(), - level: 'info', - message: `Debug tree builder: Built ${treeResult.trees.length} trees (${treeResult.complexCount} complex).`, - }) - } catch { - logActions.addLog({ - id: crypto.randomUUID(), - level: 'warning', - message: 'Debug tree builder encountered errors.', - }) - } - - debugTreesRef.current = pouTrees - - // Derive the composite-key → packed-address map from the tree leaves. - const indexMap = deriveVariableIndexMap(treeMap, debugMap) - - // Build FB instance map - const fbDebugInstancesMap = buildFbInstanceMap(project.data.pous, instances) - - const fbTypesCount = fbDebugInstancesMap.size - const totalFbInstances = Array.from(fbDebugInstancesMap.values()).reduce((sum, list) => sum + list.length, 0) - if (fbTypesCount > 0) { - logActions.addLog({ - id: crypto.randomUUID(), - level: 'info', - message: `FB instance map: Found ${totalFbInstances} instances across ${fbTypesCount} FB types.`, - }) - } - - // Connect debugger via port - const connectResult = await debuggerPort.connect() - if (!connectResult.success) { - const error = `Debugger connection failed: ${connectResult.error ?? 'Unknown error'}` - logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) - return { success: false, error } - } + debugTreesRef.current = pouTrees - // Store debug artifacts in workspace - wsActions.setDebugVariableIndexes(indexMap) - wsActions.setDebugVariableTree(treeMap) - wsActions.setFbDebugInstances(fbDebugInstancesMap) + // Derive the composite-key → packed-address map from the tree leaves. + const indexMap = deriveVariableIndexMap(treeMap, debugMap) - // Set default selected instance for each FB type - fbDebugInstancesMap.forEach((instanceList: FbInstanceInfo[], fbTypeName: string) => { - if (instanceList.length > 0) { - wsActions.setFbSelectedInstance(fbTypeName, instanceList[0].key) - } - }) + // Build FB instance map + const fbDebugInstancesMap = buildFbInstanceMap(project.data.pous, instances) - // Set target IP for non-simulator connections - // The target's address, for the debugger's own display. Comes from the - // session the manager holds, not from a config the caller chose. - const sessionEndpoint = useOpenPLCStore.getState().deviceConnection.port - if (sessionEndpoint) wsActions.setDebuggerTargetIp(sessionEndpoint) - - // Record the active transport so useDebugPolling picks the right - // poll cadence + batch size. Set on EVERY start path (runtime - // targets also set it earlier in handleMd5Verification; this - // additionally covers the simulator path, which doesn't go - // through MD5 verification — without it the simulator stayed at - // the default 200ms instead of its intended 50ms). Must be set - // before `setDebuggerVisible(true)`, which is what triggers the - // polling effect. - // The medium is the manager's choice, mirrored in the store; read it rather - // than assuming one. `debugTransport` because this sizes the debug poll. - wsActions.setDebugConnectionType(useOpenPLCStore.getState().deviceConnection.debugTransport ?? 'simulator') - - wsActions.setDebuggerVisible(true) + const fbTypesCount = fbDebugInstancesMap.size + const totalFbInstances = Array.from(fbDebugInstancesMap.values()).reduce((sum, list) => sum + list.length, 0) + if (fbTypesCount > 0) { logActions.addLog({ id: crypto.randomUUID(), level: 'info', - message: `Debugger connected. Found ${indexMap.size} debug variables.`, + message: `FB instance map: Found ${totalFbInstances} instances across ${fbTypesCount} FB types.`, }) + } - return { success: true } - } catch (err: unknown) { - const error = `Debugger error: ${err instanceof Error ? err.message : String(err)}` + // Connect debugger via port + const connectResult = await debuggerPort.connect() + if (!connectResult.success) { + const error = `Debugger connection failed: ${connectResult.error ?? 'Unknown error'}` logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) return { success: false, error } } - }, - [debuggerPort, deviceDefinitions, projectData, projectMeta], - ) + + // Store debug artifacts in workspace + wsActions.setDebugVariableIndexes(indexMap) + wsActions.setDebugVariableTree(treeMap) + wsActions.setFbDebugInstances(fbDebugInstancesMap) + + // Set default selected instance for each FB type + fbDebugInstancesMap.forEach((instanceList: FbInstanceInfo[], fbTypeName: string) => { + if (instanceList.length > 0) { + wsActions.setFbSelectedInstance(fbTypeName, instanceList[0].key) + } + }) + + // Set target IP for non-simulator connections + // The target's address, for the debugger's own display. Comes from the + // session the manager holds, not from a config the caller chose. + const sessionEndpoint = useOpenPLCStore.getState().deviceConnection.port + if (sessionEndpoint) wsActions.setDebuggerTargetIp(sessionEndpoint) + + // Record the active transport so useDebugPolling picks the right + // poll cadence + batch size. Set on EVERY start path (runtime + // targets also set it earlier in handleMd5Verification; this + // additionally covers the simulator path, which doesn't go + // through MD5 verification — without it the simulator stayed at + // the default 200ms instead of its intended 50ms). Must be set + // before `setDebuggerVisible(true)`, which is what triggers the + // polling effect. + // The medium is the manager's choice, mirrored in the store; read it rather + // than assuming one. `debugTransport` because this sizes the debug poll. + wsActions.setDebugConnectionType(useOpenPLCStore.getState().deviceConnection.debugTransport ?? 'simulator') + + wsActions.setDebuggerVisible(true) + logActions.addLog({ + id: crypto.randomUUID(), + level: 'info', + message: `Debugger connected. Found ${indexMap.size} debug variables.`, + }) + + return { success: true } + } catch (err: unknown) { + const error = `Debugger error: ${err instanceof Error ? err.message : String(err)}` + logActions.addLog({ id: crypto.randomUUID(), level: 'error', message: error }) + return { success: false, error } + } + }, [debuggerPort, deviceDefinitions, projectData, projectMeta]) /** * End the debug session — and ONLY the debug session. diff --git a/src/frontend/store/__tests__/device-slice.test.ts b/src/frontend/store/__tests__/device-slice.test.ts index 604401a1e..92ffed027 100644 --- a/src/frontend/store/__tests__/device-slice.test.ts +++ b/src/frontend/store/__tests__/device-slice.test.ts @@ -158,7 +158,12 @@ describe('createDeviceSlice', () => { it('has a disconnected serial connection', () => { const store = makeStore() - expect(store.getState().deviceConnection).toEqual({ status: 'disconnected', port: null, transport: null, debugTransport: null }) + expect(store.getState().deviceConnection).toEqual({ + status: 'disconnected', + port: null, + transport: null, + debugTransport: null, + }) }) }) @@ -169,28 +174,48 @@ describe('createDeviceSlice', () => { it('setDeviceConnectionStatus updates status and port', () => { const store = makeStore() store.getState().deviceActions.setDeviceConnectionStatus('connecting', 'COM5') - expect(store.getState().deviceConnection).toEqual({ status: 'connecting', port: 'COM5', transport: null, debugTransport: null }) + expect(store.getState().deviceConnection).toEqual({ + status: 'connecting', + port: 'COM5', + transport: null, + debugTransport: null, + }) }) it('setDeviceConnectionStatus leaves the port unchanged when omitted', () => { const store = makeStore() store.getState().deviceActions.setDeviceConnectionStatus('connecting', 'COM5') store.getState().deviceActions.setDeviceConnectionStatus('connected') - expect(store.getState().deviceConnection).toEqual({ status: 'connected', port: 'COM5', transport: null, debugTransport: null }) + expect(store.getState().deviceConnection).toEqual({ + status: 'connected', + port: 'COM5', + transport: null, + debugTransport: null, + }) }) it('setDeviceConnectionStatus can explicitly clear the port with null', () => { const store = makeStore() store.getState().deviceActions.setDeviceConnectionStatus('connected', 'COM5') store.getState().deviceActions.setDeviceConnectionStatus('error', null) - expect(store.getState().deviceConnection).toEqual({ status: 'error', port: null, transport: null, debugTransport: null }) + expect(store.getState().deviceConnection).toEqual({ + status: 'error', + port: null, + transport: null, + debugTransport: null, + }) }) it('clearDeviceConnection resets to disconnected/null', () => { const store = makeStore() store.getState().deviceActions.setDeviceConnectionStatus('connected', 'COM5') store.getState().deviceActions.clearDeviceConnection() - expect(store.getState().deviceConnection).toEqual({ status: 'disconnected', port: null, transport: null, debugTransport: null }) + expect(store.getState().deviceConnection).toEqual({ + status: 'disconnected', + port: null, + transport: null, + debugTransport: null, + }) }) }) @@ -346,7 +371,12 @@ describe('createDeviceSlice', () => { const store = makeStore() store.getState().deviceActions.setDeviceConnectionStatus('connected', 'COM5') store.getState().deviceActions.clearDeviceDefinitions() - expect(store.getState().deviceConnection).toEqual({ status: 'disconnected', port: null, transport: null, debugTransport: null }) + expect(store.getState().deviceConnection).toEqual({ + status: 'disconnected', + port: null, + transport: null, + debugTransport: null, + }) }) }) diff --git a/src/frontend/store/__tests__/device-types.test.ts b/src/frontend/store/__tests__/device-types.test.ts index ee6a2dba3..dbad28dc3 100644 --- a/src/frontend/store/__tests__/device-types.test.ts +++ b/src/frontend/store/__tests__/device-types.test.ts @@ -182,7 +182,7 @@ describe('Device slice types', () => { jwtToken: null, connectionStatus: 'disconnected', plcStatus: null, - switchPosition: null, + switchPosition: null, ipAddress: null, runtimeVersion: null, selectedDevice: null, diff --git a/src/frontend/utils/__tests__/serial-port-label.test.ts b/src/frontend/utils/__tests__/serial-port-label.test.ts index 132b867af..0e9e5323a 100644 --- a/src/frontend/utils/__tests__/serial-port-label.test.ts +++ b/src/frontend/utils/__tests__/serial-port-label.test.ts @@ -38,9 +38,10 @@ describe('serialPortDisplay', () => { describe('descriptor precedence', () => { it('prefers the arduino-cli board name over the manufacturer', () => { // Both scans found something; the board name is the specific one. - expect( - serialPortDisplay({ address: 'COM1', boardName: 'Opta', manufacturer: 'Arduino' }), - ).toEqual({ label: 'COM1 (Opta)', title: 'COM1 (Opta)' }) + expect(serialPortDisplay({ address: 'COM1', boardName: 'Opta', manufacturer: 'Arduino' })).toEqual({ + label: 'COM1 (Opta)', + title: 'COM1 (Opta)', + }) }) it('falls back to the manufacturer when arduino-cli identified no board', () => { diff --git a/src/frontend/utils/vpp/__tests__/field-options.test.ts b/src/frontend/utils/vpp/__tests__/field-options.test.ts index 5ca4b9381..5e84ee136 100644 --- a/src/frontend/utils/vpp/__tests__/field-options.test.ts +++ b/src/frontend/utils/vpp/__tests__/field-options.test.ts @@ -11,16 +11,23 @@ describe('resolveFieldOptions', () => { it('resolves a dynamic optionsRef from board context', () => { expect( - resolveFieldOptions({ optionsRef: 'board.serialPorts', options: ['Serial'] }, { board: { serialPorts: ['Serial', 'Serial1'] } }), + resolveFieldOptions( + { optionsRef: 'board.serialPorts', options: ['Serial'] }, + { board: { serialPorts: ['Serial', 'Serial1'] } }, + ), ).toEqual(['Serial', 'Serial1']) }) it('falls back to static options when optionsRef resolves to undefined', () => { - expect(resolveFieldOptions({ optionsRef: 'board.serialPorts', options: ['Serial'] }, { board: {} })).toEqual(['Serial']) + expect(resolveFieldOptions({ optionsRef: 'board.serialPorts', options: ['Serial'] }, { board: {} })).toEqual([ + 'Serial', + ]) }) it('falls back to static options when the board is absent', () => { - expect(resolveFieldOptions({ optionsRef: 'board.serialPorts', options: ['Serial'] }, { board: undefined })).toEqual(['Serial']) + expect(resolveFieldOptions({ optionsRef: 'board.serialPorts', options: ['Serial'] }, { board: undefined })).toEqual( + ['Serial'], + ) }) it('falls back to static options when optionsRef resolves to an empty array', () => { diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 4b31d9765..5197a2df5 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -46,6 +46,12 @@ import { join, resolve, sep } from 'path' import { platform } from 'process' import { MainIpcModule, MainIpcModuleConstructor } from '../../../backend/editor/contracts/types/modules/ipc/main' +import { + classifyDeviceLink, + type DeviceProbeOutcome, + PATIENT_BOARD_ID_PROBE, + QUICK_BOARD_ID_PROBE, +} from '../../../backend/editor/hardware/device-probe' import { type DeviceDebugCandidate, type DeviceLinkCandidate, @@ -56,12 +62,6 @@ import { buildDeviceModbusTransport, modbusTransportKind, } from '../../../backend/editor/hardware/device-transport-factory' -import { - classifyDeviceLink, - type DeviceProbeOutcome, - PATIENT_BOARD_ID_PROBE, - QUICK_BOARD_ID_PROBE, -} from '../../../backend/editor/hardware/device-probe' import { LibraryManagerModule } from '../../../backend/editor/library-manager' import { PackageManagerModule } from '../../../backend/editor/package-manager' import { logger } from '../../../backend/editor/services' @@ -1719,10 +1719,7 @@ class MainProcessBridge implements MainIpcModule { * client at a time, never answered. The user saw "Failed to stop PLC: Request * timeout" while a working connection sat idle. */ - handleDebuggerPlcControl = async ( - _event: IpcMainInvokeEvent, - action: 'run' | 'stop', - ): Promise => { + handleDebuggerPlcControl = async (_event: IpcMainInvokeEvent, action: 'run' | 'stop'): Promise => { this.traceDeviceLink(`run/stop: ${action} requested`) // Routed by the session's CONTROL channel, which is the whole point: the caller @@ -1852,7 +1849,6 @@ class MainProcessBridge implements MainIpcModule { } } - /** * Read the run/stop state over an already-open client and push it to the * renderer. Throttled, because its two callers tick at very different rates: @@ -1883,8 +1879,6 @@ class MainProcessBridge implements MainIpcModule { }) } - - /** * Start a debug session against a target. * @@ -2259,7 +2253,6 @@ class MainProcessBridge implements MainIpcModule { return { status: 'no-response', error: tried || 'No connection could be established.' } } - /** Close the held link (user pressed Disconnect). */ handleDeviceDisconnect = async (): Promise<{ success: boolean }> => { this.deviceSession.close() diff --git a/src/middleware/adapters/editor/debugger-adapter.ts b/src/middleware/adapters/editor/debugger-adapter.ts index 864f63191..b4e76c776 100644 --- a/src/middleware/adapters/editor/debugger-adapter.ts +++ b/src/middleware/adapters/editor/debugger-adapter.ts @@ -13,12 +13,7 @@ import type { PlcControlResult } from '../../../backend/shared/debug/types' import { getErrorMessage } from '../../../frontend/utils/get-error-message' import type { DebuggerPort } from '../../shared/ports/debugger-port' -import type { - DebugSetResult, - DebugVariableResult, - Md5VerifyResult, - Unsubscribe, -} from '../../shared/ports/types' +import type { DebugSetResult, DebugVariableResult, Md5VerifyResult, Unsubscribe } from '../../shared/ports/types' export function createEditorDebuggerAdapter(): DebuggerPort { let connected = false diff --git a/src/middleware/adapters/editor/device-adapter.ts b/src/middleware/adapters/editor/device-adapter.ts index 000f0d69f..85185b214 100644 --- a/src/middleware/adapters/editor/device-adapter.ts +++ b/src/middleware/adapters/editor/device-adapter.ts @@ -14,11 +14,7 @@ * util:get-preview-image (invoke) */ -import type { - DeviceConnectionStatusPayload, - DeviceConnectResult, - DevicePort, -} from '../../shared/ports/device-port' +import type { DeviceConnectionStatusPayload, DeviceConnectResult, DevicePort } from '../../shared/ports/device-port' import type { BoardInfo, CommunicationPort, DebugConnectionConfig } from '../../shared/ports/types' export function createEditorDeviceAdapter(): DevicePort { @@ -75,9 +71,7 @@ export function createEditorDeviceAdapter(): DevicePort { return window.bridge.onDeviceConnectionStatus(callback) }, - onPlcState( - callback: (payload: { port: string; plcState?: number; switchPosition?: number }) => void, - ): () => void { + onPlcState(callback: (payload: { port: string; plcState?: number; switchPosition?: number }) => void): () => void { return window.bridge.onDevicePlcState(callback) }, } diff --git a/src/middleware/shared/ports/device-port.ts b/src/middleware/shared/ports/device-port.ts index 98538d72d..8539f78d5 100644 --- a/src/middleware/shared/ports/device-port.ts +++ b/src/middleware/shared/ports/device-port.ts @@ -180,7 +180,5 @@ export interface DevicePort { * 0/1/2 (STOPPED/RUNNING/ERROR); `switchPosition` is 0/1 (STOP/RUN) and is * absent on firmware predating the run/stop state machine. */ - onPlcState?( - callback: (payload: { port: string; plcState?: number; switchPosition?: number }) => void, - ): () => void + onPlcState?(callback: (payload: { port: string; plcState?: number; switchPosition?: number }) => void): () => void } From 0aa0c8af0ec7bdffbfe2c3f4856a038fd509b8a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 4 Aug 2026 13:27:48 +0200 Subject: [PATCH 11/19] fix(editor): align the debugger's baud with the firmware, and sweep on a miss Two defects, one symptom: "No Firmware Detected" on a board that is running perfectly well. FIRST, a real misalignment. `DEBUG_BAUD` was read from the `serial` screen section alone, falling back to 115200. A VPP published today has no `serial` section -- it configures the baud on the RTU section -- so every project built against a published package compiled a firmware listening at 115200 while the editor dialled the RTU's rate. The port opened, nothing decoded, and the editor concluded there was no firmware. `resolveDebugBaud` states the rule the firmware actually follows: an explicit `serial` section wins; otherwise the RTU's baud, but ONLY when the RTU is on the default port, because there the firmware brings that one port up at MBSERIAL_BAUD and the debugger shares it (MBSERIAL_SHARES_DEBUG_SERIAL); otherwise the firmware default. With the RTU on a SECOND UART the debugger keeps the default port to itself and nothing in the project states its speed -- which is what the second half of this commit is for. SECOND, defence in depth. A wrong baud is the one misconfiguration that presents as healthy silence, and the advice it produced -- reflash the device -- is exactly the wrong move on a device in the field. Connect now tries the configured rate first and then sweeps FALLBACK_BAUD_RATES, so a board whose rate nobody remembers is reachable instead of looking dead. The sweep is deliberately not free-form: - Guesses are appended AFTER every endpoint the project declared. A configured Modbus TCP address is a better next try than a rate nobody asked for. - Guesses carry `speculative`, and verification gives them SPECULATIVE_BOARD_ID_PROBE (2 attempts) instead of the patient budget. Two rather than one because opening the port asserts DTR and resets AVR/ESP8266 boards, so the first read can land mid-boot and reject a correct rate. - The patient budget stays with the last DECLARED endpoint (`patient`), not the last candidate overall. Without that the sweep would quietly take ~32s of patience away from the configured endpoint -- the one case that needs it, a board still booting right after a flash. - Each guess names its rate in the trace, so the console says which rate is being tried rather than listing the same port five times. - The debug channel of an already-established session opts out (`probeBaudRates: false`): there the rate is settled, and reopening the port at other rates would be wrong, not merely wasteful. Cost, stated plainly: a full sweep is four extra port opens, each resetting the board. It runs only after everything configured has already failed. Co-Authored-By: Claude Opus 5 --- .../hardware/__tests__/device-probe.test.ts | 121 ++++++++++++++++++ src/backend/editor/hardware/device-probe.ts | 57 +++++++++ .../editor/hardware/device-session-manager.ts | 13 ++ .../__tests__/generate-defines.test.ts | 34 +++++ .../compile/__tests__/modbus-defines.test.ts | 54 +++++++- .../shared/compile/steps/generate-defines.ts | 9 +- .../shared/compile/steps/modbus-defines.ts | 38 ++++++ src/main/modules/ipc/main.ts | 69 ++++++++-- 8 files changed, 380 insertions(+), 15 deletions(-) create mode 100644 src/backend/editor/hardware/__tests__/device-probe.test.ts diff --git a/src/backend/editor/hardware/__tests__/device-probe.test.ts b/src/backend/editor/hardware/__tests__/device-probe.test.ts new file mode 100644 index 000000000..192040d79 --- /dev/null +++ b/src/backend/editor/hardware/__tests__/device-probe.test.ts @@ -0,0 +1,121 @@ +import type { DebugBoardIdResult } from '@root/backend/shared/debug/types' + +import { classifyDeviceLink, FALLBACK_BAUD_RATES, planBaudAttempts, readBoardIdWithRetries } from '../device-probe' + +/** + * A channel that answers the board-id read (FC 0x48) according to a script, so + * the classification can be tested without a board. + */ +function fakeChannel(script: DebugBoardIdResult[]) { + const calls: number[] = [] + let index = 0 + return { + calls, + getBoardId: (): Promise => { + calls.push(index) + const answer = script[Math.min(index, script.length - 1)] + index += 1 + return Promise.resolve(answer) + }, + } +} + +const ANSWERED: DebugBoardIdResult = { success: true, boardId: Uint8Array.from([1, 2, 3, 4]) } +/** Opened, but nothing spoke the debug protocol — a blank board, or a wrong baud. */ +const SILENT: DebugBoardIdResult = { success: false } +/** Answered the frame but reported no unique id (a core without ArduinoUniqueID). */ +const EMPTY_ID: DebugBoardIdResult = { success: true, boardId: Uint8Array.from([]) } + +describe('planBaudAttempts', () => { + it('leads with the configured rate, then sweeps the rest', () => { + const plan = planBaudAttempts(9600) + + expect(plan[0]).toEqual({ baudRate: 9600, speculative: false }) + expect(plan.slice(1).every((attempt) => attempt.speculative)).toBe(true) + // The configured rate is never repeated as a guess. + expect(plan.filter((attempt) => attempt.baudRate === 9600)).toHaveLength(1) + expect(plan).toHaveLength(FALLBACK_BAUD_RATES.length) + }) + + it('covers every fallback rate exactly once', () => { + const rates = planBaudAttempts(115200).map((attempt) => attempt.baudRate) + + expect(new Set(rates).size).toBe(rates.length) + for (const rate of FALLBACK_BAUD_RATES) expect(rates).toContain(rate) + }) + + it('does not sweep an endpoint with no baud rate (TCP / WebSocket)', () => { + expect(planBaudAttempts(undefined)).toEqual([{ baudRate: undefined, speculative: false }]) + }) + + it('does not sweep when the caller opts out', () => { + // The debug channel of an established session: the rate is already settled, + // so re-opening the port at other rates would be wrong, not merely wasteful. + expect(planBaudAttempts(9600, { sweep: false })).toEqual([{ baudRate: 9600, speculative: false }]) + }) + + it('keeps a rate that is not in the fallback list as the leading attempt', () => { + const plan = planBaudAttempts(4800) + + expect(plan[0]).toEqual({ baudRate: 4800, speculative: false }) + expect(plan).toHaveLength(FALLBACK_BAUD_RATES.length + 1) + }) +}) + +describe('readBoardIdWithRetries', () => { + it('stops at the first answer', async () => { + const channel = fakeChannel([ANSWERED]) + const result = await readBoardIdWithRetries(channel, { attempts: 6, backoffMs: 0 }) + + expect(result.success).toBe(true) + expect(channel.calls).toHaveLength(1) + }) + + it('retries a silent port up to the budget — a board can still be booting', async () => { + const channel = fakeChannel([SILENT, SILENT, ANSWERED]) + const result = await readBoardIdWithRetries(channel, { attempts: 3, backoffMs: 0 }) + + expect(result.success).toBe(true) + expect(channel.calls).toHaveLength(3) + }) + + it('spends no more than the budget allows', async () => { + const channel = fakeChannel([SILENT]) + const result = await readBoardIdWithRetries(channel, { attempts: 2, backoffMs: 0 }) + + expect(result.success).toBe(false) + expect(channel.calls).toHaveLength(2) + }) +}) + +describe('classifyDeviceLink', () => { + it('keeps a channel a firmware answered on', async () => { + const result = await classifyDeviceLink(fakeChannel([ANSWERED]), { boardIdProbe: { attempts: 1, backoffMs: 0 } }) + + expect(result).toEqual({ status: 'connected-with-firmware' }) + }) + + // This is what a WRONG BAUD looks like from here: the port opened, so the + // transport is fine, and nothing decoded. Reporting it as `no-firmware` is what + // lets the caller fall through to the next rate instead of keeping a dead link. + it('reports no-firmware when the channel opens but nothing answers', async () => { + const result = await classifyDeviceLink(fakeChannel([SILENT]), { boardIdProbe: { attempts: 1, backoffMs: 0 } }) + + expect(result).toEqual({ status: 'no-firmware' }) + }) + + it('reports no-firmware when the frame came back with an empty id', async () => { + const result = await classifyDeviceLink(fakeChannel([EMPTY_ID]), { boardIdProbe: { attempts: 1, backoffMs: 0 } }) + + expect(result).toEqual({ status: 'no-firmware' }) + }) + + it('never throws — a transport that blows up resolves to an error status', async () => { + const result = await classifyDeviceLink( + { getBoardId: () => Promise.reject(new Error('port disappeared')) }, + { boardIdProbe: { attempts: 1, backoffMs: 0 } }, + ) + + expect(result).toEqual({ status: 'error', error: 'port disappeared' }) + }) +}) diff --git a/src/backend/editor/hardware/device-probe.ts b/src/backend/editor/hardware/device-probe.ts index 295658f5d..6ab3006b6 100644 --- a/src/backend/editor/hardware/device-probe.ts +++ b/src/backend/editor/hardware/device-probe.ts @@ -40,6 +40,63 @@ export interface ProbeBudget { */ export const PATIENT_BOARD_ID_PROBE: ProbeBudget = { attempts: 6, backoffMs: 500 } export const QUICK_BOARD_ID_PROBE: ProbeBudget = { attempts: 2, backoffMs: 300 } +/** + * For a SPECULATIVE candidate — an alternative baud rate nobody configured. + * + * Two attempts rather than one, and not out of optimism: opening the port asserts + * DTR, which resets an AVR or ESP8266, so the first read after the open can land + * while the board is still booting. One attempt would reject a correct rate for a + * reason that has nothing to do with the rate. Two is the floor that makes the + * sweep trustworthy; more would multiply across every rate tried. + */ +export const SPECULATIVE_BOARD_ID_PROBE: ProbeBudget = { attempts: 2, backoffMs: 400 } + +/** + * Baud rates tried, in this order, when the configured one does not answer. + * + * A board whose baud nobody remembers is otherwise unreachable, and it fails in + * the most misleading way available: the port opens (so it is not "no response"), + * nothing decodes (so it reads as "no firmware"), and the user is told to reflash + * a device that is running perfectly well. In the field that reflash is the + * expensive part — it is why this sweep exists. + * + * Ordered by how often they occur in practice, not numerically. Deliberately + * short: every wrong rate costs a port open, and on AVR/ESP8266 opening the port + * asserts DTR and RESETS the board, so a wide sweep is not free — it restarts the + * user's program once per guess. + */ +export const FALLBACK_BAUD_RATES = [115200, 9600, 57600, 19200, 38400] as const + +/** One baud rate to try, and whether trying it is a guess. */ +export interface BaudAttempt { + baudRate: number | undefined + /** True for a rate nobody configured — verification keeps these cheap. */ + speculative: boolean +} + +/** + * The order to try baud rates in for one serial endpoint: the configured rate + * first, then every fallback that isn't it. + * + * The configured rate leads because it is nearly always right, and a correct + * first try costs one port open. The guesses follow in `FALLBACK_BAUD_RATES` + * order. + * + * Returns a single non-speculative attempt when there is no rate to sweep — a + * TCP or WebSocket endpoint (`undefined`), or a caller that opted out. + */ +export function planBaudAttempts(declaredBaud: number | undefined, options: { sweep?: boolean } = {}): BaudAttempt[] { + const declared: BaudAttempt = { baudRate: declaredBaud, speculative: false } + if (options.sweep === false || typeof declaredBaud !== 'number') return [declared] + + return [ + declared, + ...FALLBACK_BAUD_RATES.filter((baud) => baud !== declaredBaud).map((baud) => ({ + baudRate: baud, + speculative: true, + })), + ] +} /** * Connect with a bounded retry/backoff loop. A device flashed over arduino-cli diff --git a/src/backend/editor/hardware/device-session-manager.ts b/src/backend/editor/hardware/device-session-manager.ts index 54b0d7133..3780d7700 100644 --- a/src/backend/editor/hardware/device-session-manager.ts +++ b/src/backend/editor/hardware/device-session-manager.ts @@ -68,6 +68,19 @@ export interface DeviceLinkCandidate { descriptor: string /** Build an unconnected client for this candidate. */ create: () => DeviceModbusTransport + /** + * A guess rather than something the project declared (an alternative baud + * rate). Verification spends a short budget on these — there may be several, + * and being wrong about one must not delay the next. + */ + speculative?: boolean + /** + * Worth waiting for. Set on the last candidate the project actually declared, + * so it keeps the patient probe budget even when speculative candidates queue + * up behind it — a board that was just flashed is still booting, and that wait + * belongs to the configured endpoint, not to a guess. + */ + patient?: boolean } /** Live link state, as pushed to the renderer. */ diff --git a/src/backend/shared/compile/__tests__/generate-defines.test.ts b/src/backend/shared/compile/__tests__/generate-defines.test.ts index a7b7b750c..a9196d313 100644 --- a/src/backend/shared/compile/__tests__/generate-defines.test.ts +++ b/src/backend/shared/compile/__tests__/generate-defines.test.ts @@ -200,6 +200,40 @@ describe('generateDefinesContent — Debugger block (always-on debug)', () => { expect(out).toContain('#define DEBUG_BAUD 115200') }) + // A PUBLISHED VPP has no `serial` section — only the legacy RTU fields. The + // debugger and the RTU then share one port, so ONE rate must come out of this + // file. Emitting 115200 while MBSERIAL_BAUD said 9600 built a firmware the + // editor could not talk to, and the user was told "No Firmware Detected" about + // a board that was running fine. + it('aligns DEBUG_BAUD with MBSERIAL_BAUD for a published VPP (no `serial` section)', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + defaultSerial: 'Serial', + vppModbusState: { + modbus_rtu: { enabled: true, rtu_interface: 'Serial', rtu_baud_rate: '9600', rtu_slave_id: 1 }, + }, + }) + expect(out).toContain('#define MBSERIAL_BAUD 9600') + expect(out).toContain('#define MBSERIAL_SHARES_DEBUG_SERIAL') + expect(out).toContain('#define DEBUG_BAUD 9600') + }) + + it('keeps DEBUG_BAUD at the firmware default when the RTU has its own second port', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + defaultSerial: 'Serial', + vppModbusState: { + modbus_rtu: { enabled: true, rtu_interface: 'Serial1', rtu_baud_rate: '9600', rtu_slave_id: 1 }, + }, + }) + // Two distinct ports, two distinct rates — and the debugger keeps the default. + expect(out).toContain('#define MBSERIAL_BAUD 9600') + expect(out).toContain('#define MBSERIAL_ON_SECONDARY') + expect(out).toContain('#define DEBUG_BAUD 115200') + }) + it('does NOT emit DEBUGGER_ENABLED for the simulator (it uses the full Modbus path)', () => { const out = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'simulator' }) expect(out).not.toContain('DEBUGGER_ENABLED') diff --git a/src/backend/shared/compile/__tests__/modbus-defines.test.ts b/src/backend/shared/compile/__tests__/modbus-defines.test.ts index 94758541b..90aa9e3c4 100644 --- a/src/backend/shared/compile/__tests__/modbus-defines.test.ts +++ b/src/backend/shared/compile/__tests__/modbus-defines.test.ts @@ -1,4 +1,56 @@ -import { generateModbusDefines } from '../steps/modbus-defines' +import { DEFAULT_DEBUG_BAUD, generateModbusDefines, resolveDebugBaud } from '../steps/modbus-defines' + +/** + * The baud the always-on debugger answers on. It has to agree with the rate the + * editor dials, and the two are derived in different places — so these pin the + * derivation against the shapes real projects actually persist. + */ +describe('resolveDebugBaud', () => { + it('prefers an explicit `serial` section when a package declares one', () => { + expect( + resolveDebugBaud({ serial: { baud_rate: '57600' }, modbus_rtu: { enabled: true, rtu_baud_rate: '9600' } }), + ).toBe('57600') + }) + + // The regression this function exists for: a PUBLISHED VPP has no `serial` + // section, so the RTU's baud is the only statement of the default port's speed. + // Reading 115200 instead compiled a firmware listening at one rate while the + // editor dialled another, and the board answered nothing at all. + it('takes the RTU baud when the RTU shares the default port (published VPP shape)', () => { + expect(resolveDebugBaud({ modbus_rtu: { enabled: true, rtu_interface: 'Serial', rtu_baud_rate: '9600' } })).toBe( + '9600', + ) + }) + + it('takes the RTU baud when the RTU names no port at all (defaults to the default one)', () => { + expect(resolveDebugBaud({ modbus_rtu: { enabled: true, rtu_baud_rate: '19200' } })).toBe('19200') + }) + + it('honours a board whose default serial is not called `Serial`', () => { + expect( + resolveDebugBaud( + { modbus_rtu: { enabled: true, rtu_interface: 'SerialUSB', rtu_baud_rate: '38400' } }, + 'SerialUSB', + ), + ).toBe('38400') + }) + + it('ignores the RTU baud when the RTU is on a SECOND port', () => { + // There the debugger keeps the default port to itself and nothing in the + // project states its speed, so the firmware default is the only answer. + expect(resolveDebugBaud({ modbus_rtu: { enabled: true, rtu_interface: 'Serial1', rtu_baud_rate: '9600' } })).toBe( + DEFAULT_DEBUG_BAUD, + ) + }) + + it('ignores the RTU baud when the RTU is disabled', () => { + expect(resolveDebugBaud({ modbus_rtu: { enabled: false, rtu_baud_rate: '9600' } })).toBe(DEFAULT_DEBUG_BAUD) + }) + + it('falls back for an empty project', () => { + expect(resolveDebugBaud({})).toBe(DEFAULT_DEBUG_BAUD) + }) +}) describe('generateModbusDefines', () => { it('returns an empty string when neither RTU nor TCP is enabled', () => { diff --git a/src/backend/shared/compile/steps/generate-defines.ts b/src/backend/shared/compile/steps/generate-defines.ts index 61445cc95..dc62c0899 100644 --- a/src/backend/shared/compile/steps/generate-defines.ts +++ b/src/backend/shared/compile/steps/generate-defines.ts @@ -18,7 +18,7 @@ */ import type { DevicePin } from '../../types/PLC/devices' -import { generateModbusDefines, type VppModbusScreenState } from './modbus-defines' +import { generateModbusDefines, resolveDebugBaud, type VppModbusScreenState } from './modbus-defines' export type { VppModbusScreenState } from './modbus-defines' @@ -186,7 +186,12 @@ export function generateDefinesContent(input: GenerateDefinesInput): string { DEFINES_CONTENT += '//Debugger\n' DEFINES_CONTENT += '#define DEBUGGER_ENABLED\n' DEFINES_CONTENT += `#define DEBUG_IFACE ${defaultSerial ?? 'Serial'}\n` - DEFINES_CONTENT += `#define DEBUG_BAUD ${vppModbusState?.serial?.baud_rate ?? '115200'}\n` + // Not `serial.baud_rate ?? 115200`: a package published without a `serial` + // section still configures a baud — on the RTU section — and when the RTU + // shares the default port that IS this port's speed. Ignoring it compiled a + // firmware listening at 115200 while the editor dialled the RTU's baud, and + // the board answered nothing ("No Firmware Detected" on a healthy board). + DEFINES_CONTENT += `#define DEBUG_BAUD ${resolveDebugBaud(vppModbusState ?? {}, defaultSerial)}\n` DEFINES_CONTENT += `\n\n` } diff --git a/src/backend/shared/compile/steps/modbus-defines.ts b/src/backend/shared/compile/steps/modbus-defines.ts index 260d7c00e..d2cd340b3 100644 --- a/src/backend/shared/compile/steps/modbus-defines.ts +++ b/src/backend/shared/compile/steps/modbus-defines.ts @@ -76,6 +76,44 @@ export interface VppModbusScreenState { } } +/** Baud the always-on debugger falls back to when nothing else says otherwise. */ +export const DEFAULT_DEBUG_BAUD = '115200' + +/** + * Baud rate the DEFAULT serial port comes up at — the one the always-on + * debugger answers on, and therefore the one the editor must dial to reach it. + * + * Three sources, in order: + * + * 1. The `serial` section, when a package declares one. It exists precisely to + * configure this port independently of Modbus. + * 2. Otherwise, the RTU's baud — but ONLY when the RTU is on the default port, + * because there the firmware brings that one port up at `MBSERIAL_BAUD` and + * the debugger shares it (`MBSERIAL_SHARES_DEBUG_SERIAL`). Honouring it is + * what keeps firmware and editor on the same wire speed for every package + * published without a `serial` section. + * 3. Otherwise `115200`: either no RTU at all, or an RTU on a SECOND UART while + * the debugger keeps the default port to itself. Nothing in the project + * states that port's speed, so the firmware's default is the only answer — + * which is why the connect flow probes alternative bauds rather than + * trusting this one blindly. + */ +export function resolveDebugBaud(state: VppModbusScreenState, defaultSerial: string = 'Serial'): string { + const declared = state.serial?.baud_rate + if (declared) return declared + + const rtu = state.modbus_rtu + if (rtu?.enabled === true) { + const iface = rtu.serial_port ?? rtu.rtu_interface ?? defaultSerial + if (iface === defaultSerial) { + const shared = rtu.baud_rate ?? rtu.rtu_baud_rate + if (shared) return shared + } + } + + return DEFAULT_DEBUG_BAUD +} + /** * `aa:bb:cc:dd:ee:ff` → `0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff` so it can * land verbatim in `byte mac[] = { MBTCP_MAC };`. Accepts the canonical diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 5197a2df5..dc314589c 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -50,7 +50,9 @@ import { classifyDeviceLink, type DeviceProbeOutcome, PATIENT_BOARD_ID_PROBE, + planBaudAttempts, QUICK_BOARD_ID_PROBE, + SPECULATIVE_BOARD_ID_PROBE, } from '../../../backend/editor/hardware/device-probe' import { type DeviceDebugCandidate, @@ -1956,15 +1958,22 @@ class MainProcessBridge implements MainIpcModule { * The only transport-specific step left in the flow; a config that names a * transport this build cannot speak is dropped rather than half-built. */ - private toDeviceLinkCandidates(configs: DebugConnectionConfig[]): DeviceLinkCandidate[] { - const candidates: DeviceLinkCandidate[] = [] - for (const config of configs) { + private toDeviceLinkCandidates( + configs: DebugConnectionConfig[], + opts: { probeBaudRates?: boolean } = {}, + ): DeviceLinkCandidate[] { + const declared: DeviceLinkCandidate[] = [] + // Baud guesses go AFTER everything the project declared: a configured Modbus + // TCP address is a better next try than a rate nobody asked for. + const speculative: DeviceLinkCandidate[] = [] + + const build = (config: DebugConnectionConfig, baudRate: number | undefined, isGuess: boolean): void => { const kind = modbusTransportKind(config.connectionType) - if (kind === null) continue + if (kind === null) return const params = { connectionType: config.connectionType, port: config.connectionParams.port, - baudRate: config.connectionParams.baudRate, + baudRate, slaveId: config.connectionParams.slaveId, host: config.connectionParams.ipAddress, } @@ -1973,10 +1982,14 @@ class MainProcessBridge implements MainIpcModule { const options = kind === 'simulator' ? { virtualSerialPort: new VirtualSerialPort(this.simulatorModule) } : {} // Probe the params now so a malformed config fails resolution rather than // becoming a candidate that always throws on `create()`. - if ('error' in buildDeviceModbusTransport(params, options)) continue - candidates.push({ + if ('error' in buildDeviceModbusTransport(params, options)) return + const base = describeDebugEndpoint(config) + ;(isGuess ? speculative : declared).push({ transport: kind, - descriptor: describeDebugEndpoint(config), + // Name the rate on a guess: the trace and the failure dialog list what was + // tried, and "COM5" five times over tells the user nothing. + descriptor: isGuess ? `${base} @ ${baudRate} baud` : base, + speculative: isGuess, create: () => { const built = buildDeviceModbusTransport(params, options) if ('error' in built) throw new Error(built.error) @@ -1984,7 +1997,27 @@ class MainProcessBridge implements MainIpcModule { }, }) } - return candidates + + for (const config of configs) { + // A wrong baud is the one misconfiguration that looks like healthy silence: + // the port opens, so it is not "no response", and nothing decodes, so it + // reads as "no firmware" — and the user gets told to reflash a board that is + // running fine. Sweeping the rates OpenPLC is ever built with turns that dead + // end into a connection. Serial only; a TCP address is either right or not. + for (const attempt of planBaudAttempts(config.connectionParams.baudRate, { sweep: opts.probeBaudRates })) { + build(config, attempt.baudRate, attempt.speculative) + } + } + + // The patient budget belongs to the last DECLARED endpoint, not to the last + // candidate overall. Without this the baud sweep would silently take that + // patience away from the configured endpoint and hand it to a guess — and a + // board that was just flashed, still booting on the right rate, would be ruled + // out in ~10s instead of the ~32s it sometimes needs. + const lastDeclared = declared[declared.length - 1] + if (lastDeclared) lastDeclared.patient = true + + return [...declared, ...speculative] } /** Consume the classification the last verified candidate produced. */ @@ -2041,7 +2074,9 @@ class MainProcessBridge implements MainIpcModule { create: () => new WebSocketDebugTransport({ host, port: 8443, token, rejectUnauthorized: false }), } } - const [candidate] = this.toDeviceLinkCandidates([config]) + // One config in, one candidate out: this builds the DEBUG channel for a + // session that already exists, so the rate is settled and guessing is wrong. + const [candidate] = this.toDeviceLinkCandidates([config], { probeBaudRates: false }) if (!candidate) return null return { transport: candidate.transport, @@ -2088,10 +2123,20 @@ class MainProcessBridge implements MainIpcModule { // the only way in, but not while alternatives are waiting: a Modbus TCP // address that no longer answers should not delay the cable that would have // worked. (Measured on a real board: 32.5s to rule out one endpoint.) - const boardIdProbe = context.isLastCandidate ? PATIENT_BOARD_ID_PROBE : QUICK_BOARD_ID_PROBE + // + // A speculative candidate never gets that patience, whether or not it happens + // to be last: it is a baud rate NOBODY configured, and there are several of + // them. Spending the patient budget on the final guess would put ~32s at the + // end of a sweep whose whole point is to finish quickly. + const isPatient = !candidate.speculative && (candidate.patient === true || context.isLastCandidate) + const boardIdProbe = candidate.speculative + ? SPECULATIVE_BOARD_ID_PROBE + : isPatient + ? PATIENT_BOARD_ID_PROBE + : QUICK_BOARD_ID_PROBE this.traceDeviceLink( ` ${candidate.descriptor}: verifying with up to ${boardIdProbe.attempts} id read(s)` + - `${context.isLastCandidate ? ' (last candidate, being patient)' : ''}`, + `${candidate.speculative ? ' (baud guess)' : isPatient ? ' (last configured endpoint, being patient)' : ''}`, ) const result = await classifyDeviceLink(client, { boardIdProbe }) this.deviceLinkProbe = result From ac16ea7d5713d09f6754c1ed9626cd8f1a49254e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 4 Aug 2026 13:47:09 +0200 Subject: [PATCH 12/19] fix(editor): keep the baud out of a candidate's descriptor, and dial the screen's rate when Modbus is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects the first field test exposed, both mine. THE SWEEP NEVER RAN. `descriptor` is an IDENTIFIER: it is matched against the OS serial-port list and against the port an upload asks to borrow. Writing "COM5 @ 115200 baud" into it meant every swept candidate matched no port and was dismissed as absent in 1ms: COM5 @ 115200 baud: serial port is not enumerated, skipping open: rtu COM5 @ 115200 baud rejected in 2ms — not available The rate now travels beside the endpoint as `baudRate`, and `describeLinkCandidate` composes the caption where a caption is wanted (trace, attempt list). The same slip would have broken the upload handoff, which compares the held descriptor against the port arduino-cli wants. The trace also named the rate on guesses only, so the log showed four rates being tried and never said which one the CONFIGURED attempt used — the reader could not tell that the 32s patient probe had already spent itself on 9600. Every serial candidate now reports its rate. DEBUG_BAUD IGNORED A DISABLED RTU. The editor dials `screens.modbus_rtu.rtu_baud_rate` whether or not the RTU is enabled: a debug spec's `params` are read independently of its `enabledWhen`. `resolveDebugBaud` required `enabled === true`, so a project with Modbus off and 9600 on the screen compiled a firmware at 115200 while the editor dialled 9600 — the exact shape of the reported failure. The rate is now taken whenever it exists, except when an ENABLED RTU owns a second UART: only there does it genuinely describe a different port. Regression tests, both of which the previous commit passed: - two candidates on one port, differing only in baud, are both opened rather than skipped as missing ports; - a failed open reports each attempt's rate; - DEBUG_BAUD follows the screen rate with the RTU disabled, and only falls back to 115200 for an enabled RTU on a second UART. Co-Authored-By: Claude Opus 5 --- .../__tests__/device-session-manager.test.ts | 54 +++++++++++++++ .../editor/hardware/device-session-manager.ts | 68 ++++++++++++++----- .../__tests__/generate-defines.test.ts | 17 +++++ .../compile/__tests__/modbus-defines.test.ts | 20 +++++- .../shared/compile/steps/modbus-defines.ts | 47 +++++++------ src/main/modules/ipc/main.ts | 13 ++-- 6 files changed, 174 insertions(+), 45 deletions(-) diff --git a/src/backend/editor/hardware/__tests__/device-session-manager.test.ts b/src/backend/editor/hardware/__tests__/device-session-manager.test.ts index 7b0801ffd..6b0b89a81 100644 --- a/src/backend/editor/hardware/__tests__/device-session-manager.test.ts +++ b/src/backend/editor/hardware/__tests__/device-session-manager.test.ts @@ -81,6 +81,7 @@ function candidate( descriptor: string, queue: FakeClient[], registry: FakeClient[], + extra: Partial = {}, ): DeviceLinkCandidate { return { transport, @@ -90,6 +91,7 @@ function candidate( registry.push(client) return asTransport(client) }, + ...extra, } } @@ -116,6 +118,58 @@ describe('DeviceSessionManager', () => { h.manager.close() }) + // The baud sweep sends several candidates down the SAME port, differing only + // by `baudRate`. `descriptor` is matched against the OS port list, so it has + // to stay the bare port name: when the rate was folded into it ("COM5 @ 9600 + // baud") every swept candidate matched no port and was skipped in 1ms — the + // sweep silently did nothing at all. + it('tries every baud rate on one port instead of skipping them as absent ports', async () => { + const h = harness() + const wrongBaud = new FakeClient() + const rightBaud = new FakeClient() + // Only the real port name is enumerated, exactly as the OS reports it. + h.ports.clear() + h.ports.add('COM5') + // First candidate opens but answers nothing (what a wrong baud looks like). + let verified = 0 + const h2 = harness({ + serialPortPresent: async (port) => h.ports.has(port), + verify: async () => { + verified += 1 + return verified > 1 + }, + }) + + const result = await h2.manager.open([ + candidate('rtu', 'COM5', [wrongBaud], h2.clients, { baudRate: 9600, patient: true }), + candidate('rtu', 'COM5', [rightBaud], h2.clients, { baudRate: 115200, speculative: true }), + ]) + + expect(result.ok).toBe(true) + // Both were actually opened — the second was not dismissed as a missing port. + expect(wrongBaud.connectCount).toBe(1) + expect(rightBaud.connectCount).toBe(1) + if (result.ok) expect(result.descriptor).toBe('COM5') + h2.manager.close() + }) + + it('names the baud rate when reporting what it tried', async () => { + const h = harness({ verify: async () => false }) + h.ports.clear() + h.ports.add('COM5') + + const result = await h.manager.open([ + candidate('rtu', 'COM5', [], h.clients, { baudRate: 9600 }), + candidate('rtu', 'COM5', [], h.clients, { baudRate: 115200, speculative: true }), + ]) + + expect(result.ok).toBe(false) + if (!result.ok) { + // Two entries for one port are only meaningful if each says its rate. + expect(result.attempts.map((attempt) => attempt.baudRate)).toEqual([9600, 115200]) + } + }) + it('falls back to serial when Modbus TCP cannot connect', async () => { const h = harness() const tcp = new FakeClient({ connectFails: true }) diff --git a/src/backend/editor/hardware/device-session-manager.ts b/src/backend/editor/hardware/device-session-manager.ts index 3780d7700..43394e398 100644 --- a/src/backend/editor/hardware/device-session-manager.ts +++ b/src/backend/editor/hardware/device-session-manager.ts @@ -64,8 +64,21 @@ export interface DeviceDebugCandidate { /** One way to reach the device, ready to be tried. */ export interface DeviceLinkCandidate { transport: DeviceLinkTransport - /** What the user calls this endpoint: "/dev/cu.usbmodem11101", "192.168.0.50". */ + /** + * What the user calls this endpoint: "/dev/cu.usbmodem11101", "192.168.0.50". + * + * This is an IDENTIFIER, not a caption: it is matched against the OS port list + * (`serialPortPresent`) and against the port an upload asks to borrow. Anything + * decorative belongs in `baudRate` or the trace, never here — a descriptor that + * read "COM5 @ 9600 baud" matched no port and no upload. + */ descriptor: string + /** + * Wire speed for a serial candidate, carried so the trace can name it. Two + * candidates on one port differ only by this, and a log that repeated the port + * five times said nothing about what was actually tried. + */ + baudRate?: number /** Build an unconnected client for this candidate. */ create: () => DeviceModbusTransport /** @@ -118,11 +131,27 @@ export interface DeviceLinkOpenSuccess { export interface DeviceLinkOpenFailure { ok: false /** Every candidate that was tried, with why it did not work. */ - attempts: Array<{ transport: DeviceLinkTransport; descriptor: string; error: string }> + attempts: Array<{ transport: DeviceLinkTransport; descriptor: string; baudRate?: number; error: string }> } export type DeviceLinkOpenResult = DeviceLinkOpenSuccess | DeviceLinkOpenFailure +/** + * How a candidate reads in the trace and in a failure message: the endpoint plus + * its wire speed, when it has one. + * + * Derived here rather than baked into `descriptor`, because that field is matched + * against OS port names and upload requests — see the note on it. + */ +export function describeLinkCandidate(candidate: { + transport: DeviceLinkTransport + descriptor: string + baudRate?: number +}): string { + const speed = candidate.baudRate === undefined ? '' : ` @ ${candidate.baudRate} baud` + return `${candidate.transport} ${candidate.descriptor}${speed}` +} + export interface DeviceLinkHooks { /** * Is this freshly opened client really a device we can work with? Decides @@ -331,11 +360,7 @@ export class DeviceSessionManager { this.candidates = candidates const attempts: DeviceLinkOpenFailure['attempts'] = [] - this.trace( - `open: ${candidates.length} candidate(s) in order: ${candidates - .map((candidate) => `${candidate.transport} ${candidate.descriptor}`) - .join(', ')}`, - ) + this.trace(`open: ${candidates.length} candidate(s) in order: ${candidates.map(describeLinkCandidate).join(', ')}`) for (const [index, candidate] of candidates.entries()) { this.hooks.emit({ status: 'connecting', transport: candidate.transport, descriptor: candidate.descriptor }) @@ -345,8 +370,8 @@ export class DeviceSessionManager { const elapsed = Date.now() - startedAt this.trace( outcome.ok - ? `open: ${candidate.transport} ${candidate.descriptor} ACCEPTED in ${elapsed}ms` - : `open: ${candidate.transport} ${candidate.descriptor} rejected in ${elapsed}ms — ${outcome.error}`, + ? `open: ${describeLinkCandidate(candidate)} ACCEPTED in ${elapsed}ms` + : `open: ${describeLinkCandidate(candidate)} rejected in ${elapsed}ms — ${outcome.error}`, ) if (outcome.ok) { this.client = outcome.client @@ -362,7 +387,12 @@ export class DeviceSessionManager { }) return { ok: true, transport: candidate.transport, descriptor: candidate.descriptor, client: outcome.client } } - attempts.push({ transport: candidate.transport, descriptor: candidate.descriptor, error: outcome.error }) + attempts.push({ + transport: candidate.transport, + descriptor: candidate.descriptor, + baudRate: candidate.baudRate, + error: outcome.error, + }) } this.candidates = [] @@ -406,7 +436,7 @@ export class DeviceSessionManager { // A serial candidate whose port is not even enumerated cannot be opened: // say so instead of waiting out a connect timeout. if (candidate.transport === 'rtu' && !(await this.hooks.serialPortPresent(candidate.descriptor))) { - this.trace(` ${candidate.descriptor}: serial port is not enumerated, skipping`) + this.trace(` ${describeLinkCandidate(candidate)}: serial port is not enumerated, skipping`) return { ok: false, error: `${candidate.descriptor} is not available` } } @@ -420,10 +450,12 @@ export class DeviceSessionManager { const connectStartedAt = Date.now() try { await client.connect() - this.trace(` ${candidate.descriptor}: transport opened in ${Date.now() - connectStartedAt}ms`) + this.trace(` ${describeLinkCandidate(candidate)}: transport opened in ${Date.now() - connectStartedAt}ms`) } catch (error) { client.disconnect() - this.trace(` ${candidate.descriptor}: transport would not open after ${Date.now() - connectStartedAt}ms`) + this.trace( + ` ${describeLinkCandidate(candidate)}: transport would not open after ${Date.now() - connectStartedAt}ms`, + ) return { ok: false, error: describeError(error) } } @@ -433,17 +465,19 @@ export class DeviceSessionManager { const verifyStartedAt = Date.now() try { if (await this.hooks.verify(client, candidate, context)) { - this.trace(` ${candidate.descriptor}: answered the debug protocol in ${Date.now() - verifyStartedAt}ms`) + this.trace( + ` ${describeLinkCandidate(candidate)}: answered the debug protocol in ${Date.now() - verifyStartedAt}ms`, + ) return { ok: true, client } } client.disconnect() this.trace( - ` ${candidate.descriptor}: opened but did NOT answer the debug protocol (waited ${Date.now() - verifyStartedAt}ms)`, + ` ${describeLinkCandidate(candidate)}: opened but did NOT answer the debug protocol (waited ${Date.now() - verifyStartedAt}ms)`, ) return { ok: false, error: 'No OpenPLC firmware answered' } } catch (error) { client.disconnect() - this.trace(` ${candidate.descriptor}: verification threw after ${Date.now() - verifyStartedAt}ms`) + this.trace(` ${describeLinkCandidate(candidate)}: verification threw after ${Date.now() - verifyStartedAt}ms`) return { ok: false, error: describeError(error) } } } @@ -530,7 +564,7 @@ export class DeviceSessionManager { const verdict = await this.probeVerdict(client, candidate) const decision = this.policy.onProbeResult(verdict) if (verdict !== 'alive') { - this.trace(`poll: ${candidate.transport} ${candidate.descriptor} ${verdict} -> ${decision}`) + this.trace(`poll: ${describeLinkCandidate(candidate)} ${verdict} -> ${decision}`) } switch (decision) { case 'enter-recovery': diff --git a/src/backend/shared/compile/__tests__/generate-defines.test.ts b/src/backend/shared/compile/__tests__/generate-defines.test.ts index a9196d313..fc924d860 100644 --- a/src/backend/shared/compile/__tests__/generate-defines.test.ts +++ b/src/backend/shared/compile/__tests__/generate-defines.test.ts @@ -219,6 +219,23 @@ describe('generateDefinesContent — Debugger block (always-on debug)', () => { expect(out).toContain('#define DEBUG_BAUD 9600') }) + // The reported failure, end to end: Modbus off, 9600 saved on the screen. The + // editor dials 9600 (spec params ignore `enabledWhen`), so a firmware built at + // 115200 opened the port and answered nothing — "No Firmware Detected" on a + // healthy board. + it('aligns DEBUG_BAUD with the screen baud when Modbus is DISABLED', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + defaultSerial: 'Serial', + vppModbusState: { modbus_rtu: { enabled: false, rtu_baud_rate: '9600' } }, + }) + expect(out).toContain('#define DEBUGGER_ENABLED') + expect(out).toContain('#define DEBUG_BAUD 9600') + // Modbus itself stays out of the build. + expect(out).not.toContain('#define MODBUS_ENABLED') + }) + it('keeps DEBUG_BAUD at the firmware default when the RTU has its own second port', () => { const out = generateDefinesContent({ ...EMPTY_INPUTS, diff --git a/src/backend/shared/compile/__tests__/modbus-defines.test.ts b/src/backend/shared/compile/__tests__/modbus-defines.test.ts index 90aa9e3c4..deeb036f9 100644 --- a/src/backend/shared/compile/__tests__/modbus-defines.test.ts +++ b/src/backend/shared/compile/__tests__/modbus-defines.test.ts @@ -43,8 +43,24 @@ describe('resolveDebugBaud', () => { ) }) - it('ignores the RTU baud when the RTU is disabled', () => { - expect(resolveDebugBaud({ modbus_rtu: { enabled: false, rtu_baud_rate: '9600' } })).toBe(DEFAULT_DEBUG_BAUD) + // The editor dials `rtu_baud_rate` whether or not the RTU is enabled — a debug + // spec's `params` are read independently of its `enabledWhen`. So the firmware + // must listen there too, or a project with Modbus turned off and a non-default + // baud saved on the screen is unreachable. + it('still takes the RTU baud when the RTU is DISABLED', () => { + expect(resolveDebugBaud({ modbus_rtu: { enabled: false, rtu_baud_rate: '9600' } })).toBe('9600') + }) + + it('takes the RTU baud when the RTU is disabled and names a second port', () => { + // The rate is unused by Modbus, and the debugger owns the default port. What + // decides this is what the editor dials, which is this value. + expect(resolveDebugBaud({ modbus_rtu: { enabled: false, rtu_interface: 'Serial1', rtu_baud_rate: '9600' } })).toBe( + '9600', + ) + }) + + it('falls back when the RTU section states no baud at all', () => { + expect(resolveDebugBaud({ modbus_rtu: { enabled: true } })).toBe(DEFAULT_DEBUG_BAUD) }) it('falls back for an empty project', () => { diff --git a/src/backend/shared/compile/steps/modbus-defines.ts b/src/backend/shared/compile/steps/modbus-defines.ts index d2cd340b3..11e657f3d 100644 --- a/src/backend/shared/compile/steps/modbus-defines.ts +++ b/src/backend/shared/compile/steps/modbus-defines.ts @@ -80,38 +80,43 @@ export interface VppModbusScreenState { export const DEFAULT_DEBUG_BAUD = '115200' /** - * Baud rate the DEFAULT serial port comes up at — the one the always-on - * debugger answers on, and therefore the one the editor must dial to reach it. + * Baud rate the DEFAULT serial port comes up at — the one the always-on debugger + * answers on, and therefore the one the editor must dial to reach it. * - * Three sources, in order: + * The two sides derive this independently (the firmware from here, the editor + * from the board's `debug` spec), so they have to agree or the port opens and + * decodes nothing. What the editor dials is + * `screens.modbus_rtu.rtu_baud_rate` — ALWAYS, whether or not the RTU is + * enabled, because a spec's `params` are read independently of its + * `enabledWhen`. This function mirrors that: * - * 1. The `serial` section, when a package declares one. It exists precisely to - * configure this port independently of Modbus. - * 2. Otherwise, the RTU's baud — but ONLY when the RTU is on the default port, - * because there the firmware brings that one port up at `MBSERIAL_BAUD` and - * the debugger shares it (`MBSERIAL_SHARES_DEBUG_SERIAL`). Honouring it is - * what keeps firmware and editor on the same wire speed for every package - * published without a `serial` section. - * 3. Otherwise `115200`: either no RTU at all, or an RTU on a SECOND UART while - * the debugger keeps the default port to itself. Nothing in the project - * states that port's speed, so the firmware's default is the only answer — - * which is why the connect flow probes alternative bauds rather than - * trusting this one blindly. + * 1. A `serial` section, when a package declares one — it exists precisely to + * configure this port, and a package that has it also points its debug spec + * at it. + * 2. Otherwise the RTU's baud, which for a package published today is the only + * serial speed the project states at all. This holds even when the RTU is + * DISABLED: the rate is then unused by Modbus, but the editor still dials it, + * so the firmware had better listen there. + * 3. `115200` only when the RTU is enabled on a SECOND UART — the one case where + * that rate genuinely belongs to a different port and the debugger keeps the + * default one to itself. Nothing states that port's speed, so this is a + * guess, and it is exactly the case the connect flow's baud sweep exists for. */ export function resolveDebugBaud(state: VppModbusScreenState, defaultSerial: string = 'Serial'): string { const declared = state.serial?.baud_rate if (declared) return declared const rtu = state.modbus_rtu - if (rtu?.enabled === true) { + if (!rtu) return DEFAULT_DEBUG_BAUD + + // An enabled RTU on its own UART takes its baud with it; the debugger is then + // on a port whose speed the project never mentions. + if (rtu.enabled === true) { const iface = rtu.serial_port ?? rtu.rtu_interface ?? defaultSerial - if (iface === defaultSerial) { - const shared = rtu.baud_rate ?? rtu.rtu_baud_rate - if (shared) return shared - } + if (iface !== defaultSerial) return DEFAULT_DEBUG_BAUD } - return DEFAULT_DEBUG_BAUD + return rtu.baud_rate ?? rtu.rtu_baud_rate ?? DEFAULT_DEBUG_BAUD } /** diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index dc314589c..5e3b09ace 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -55,6 +55,7 @@ import { SPECULATIVE_BOARD_ID_PROBE, } from '../../../backend/editor/hardware/device-probe' import { + describeLinkCandidate, type DeviceDebugCandidate, type DeviceLinkCandidate, type DeviceLinkStatus, @@ -1983,12 +1984,14 @@ class MainProcessBridge implements MainIpcModule { // Probe the params now so a malformed config fails resolution rather than // becoming a candidate that always throws on `create()`. if ('error' in buildDeviceModbusTransport(params, options)) return - const base = describeDebugEndpoint(config) ;(isGuess ? speculative : declared).push({ transport: kind, - // Name the rate on a guess: the trace and the failure dialog list what was - // tried, and "COM5" five times over tells the user nothing. - descriptor: isGuess ? `${base} @ ${baudRate} baud` : base, + // The endpoint ONLY. It is matched against the OS port list and against + // the port an upload asks to borrow, so the baud travels beside it rather + // than inside it — decorating this string made every swept candidate match + // no port and be skipped in 1ms. + descriptor: describeDebugEndpoint(config), + baudRate, speculative: isGuess, create: () => { const built = buildDeviceModbusTransport(params, options) @@ -2294,7 +2297,7 @@ class MainProcessBridge implements MainIpcModule { // better message ("no firmware" beats "could not connect"); otherwise report // what was tried. if (probe && probe.status !== 'connected-with-firmware') return probe - const tried = result.attempts.map((attempt) => `${attempt.descriptor}: ${attempt.error}`).join('; ') + const tried = result.attempts.map((attempt) => `${describeLinkCandidate(attempt)}: ${attempt.error}`).join('; ') return { status: 'no-response', error: tried || 'No connection could be established.' } } From 474c149bb8f142d167e890a6429e2afcf76cc55f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 4 Aug 2026 14:57:04 +0200 Subject: [PATCH 13/19] fix(compile): name precompiled objects `.cpp.o` so esp8266 links them into flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The link failure was not a size problem in the usual sense — it was 7387 bytes of ordinary code running from the wrong memory. esp8266's linker script decides flash-vs-IRAM by matching the OBJECT NAME: .irom0.text : { *.c.o(.literal* .text*) *.cpp.o(EXCLUDE_FILE (umm_malloc.cpp.o) .literal* … .text*) *.cc.o(.literal* .text*) … explicit list of SDK archives … } Anything unmatched falls through to `.text1`, a catch-all mapped into `iram1_0_seg` — 32 KB shared with the WiFi/SDK core. libOpenPLCUserLib.a is not in the archive list, so its members had to match `*.cpp.o` to reach flash. The precompile stripped the extension (`basename.replace(/\.cpp$/, '.o')`), naming them `arduino_runtime_glue.o` / `configuration.o` / `pou_MAIN.o` — none of which match anything, so every translation unit of the user library was placed in IRAM. Measured on the failing project (objdump -h over the archive): arduino_runtime_glue.o 3781 bytes -> IRAM configuration.o 3149 bytes -> IRAM pou_MAIN.o 457 bytes -> IRAM ------------------------------------------ 7387 bytes of 32768 Keeping the `.cpp` (`arduino_runtime_glue.cpp.o`) is the same convention arduino-cli uses for sketch objects — which is exactly why every `modbus_*.cpp.o` in the sketch was already in flash while the archive was not. Pre-existing, and independent of any board: the naming has always been this way. It surfaced now because the run/stop state machine grew arduino_runtime_glue.cpp, making the largest IRAM squatter larger still and pushing an ESP8266 NodeMCU over the segment. The reported error named neither the archive nor the cause: section `.text1' will not fit in region `iram1_0_seg' Covered by a test, because the symptom is this remote from the cause: the compile command must carry `arduino_runtime_glue.cpp.o` and must not carry the bare `.o` form. Co-Authored-By: Claude Opus 5 --- .../handle-precompile-user-lib.test.ts | 28 +++++++++++++++++++ .../editor/compiler/compiler-module.ts | 20 ++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/backend/editor/compiler/__tests__/handle-precompile-user-lib.test.ts b/src/backend/editor/compiler/__tests__/handle-precompile-user-lib.test.ts index 458a83c8c..6f29d2fb8 100644 --- a/src/backend/editor/compiler/__tests__/handle-precompile-user-lib.test.ts +++ b/src/backend/editor/compiler/__tests__/handle-precompile-user-lib.test.ts @@ -129,6 +129,34 @@ describe('handlePrecompileUserLib include-path injection', () => { expect(compileCmd).toContain('-I/fake/renesas/variants/UNOWIFIR4') }) + // esp8266 decides flash-vs-IRAM by matching the OBJECT NAME in its linker + // script (`*.cpp.o(.literal* .text*)` -> flash). Named `foo.o`, every TU of + // libOpenPLCUserLib.a missed that match and fell into `.text1`, a catch-all + // mapped into the 32 KB `iram1_0_seg` shared with the WiFi/SDK core — + // measured at 7387 bytes for a small project, which overflowed the segment + // and failed the link with "section `.text1' will not fit in region + // `iram1_0_seg'", naming neither this archive nor the cause. + it('names objects `.cpp.o` so esp8266 links them into flash, not IRAM', async () => { + fs.writeFileSync(join(srcDir, 'arduino_runtime_glue.cpp'), 'void glue() {}\n', 'utf-8') + + const execCalls: string[] = [] + execImpl.current = async (cmd) => { + execCalls.push(cmd) + return { stdout: '', stderr: '' } + } + + await compilerModule.handlePrecompileUserLib({ + compilationPath: buildDir, + fqbn: 'arduino:renesas_uno:unor4wifi', + handleOutputData: noopLog, + }) + + const compileCmd = execCalls.find((c) => c.includes('arduino_runtime_glue.cpp')) ?? '' + expect(compileCmd).toContain('arduino_runtime_glue.cpp.o') + // The bare `.o` form is what the esp8266 flash matcher misses. + expect(compileCmd).not.toMatch(/[/\\]arduino_runtime_glue\.o\b/) + }) + it('omits the variant -I when build.variant.path is unset (runtime-only / minimalist cores)', async () => { extractSpy.mockResolvedValue({ ...baseProps, diff --git a/src/backend/editor/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 99ccebb69..0aaf17a3e 100644 --- a/src/backend/editor/compiler/compiler-module.ts +++ b/src/backend/editor/compiler/compiler-module.ts @@ -1463,7 +1463,25 @@ class CompilerModule { // Build the .o path list synchronously up-front so the archive members // land in source-file order regardless of the concurrent compile result. - const objectFiles = sources.map((sourcePath) => join(objDir, path.basename(sourcePath).replace(/\.cpp$/, '.o'))) + // + // The `.cpp` is KEPT in the object name (`foo.cpp.o`, not `foo.o`) — the same + // convention arduino-cli uses for sketch objects, and on ESP8266 it decides + // whether the code runs from flash or from IRAM. + // + // esp8266's linker script sends code to flash by matching the OBJECT NAME: + // + // .irom0.text : { *.c.o(.literal* .text*) + // *.cpp.o(EXCLUDE_FILE (umm_malloc.cpp.o) .literal* … .text*) + // *.cc.o(.literal* .text*) … } + // + // Anything it does not match falls through to `.text1`, a catch-all mapped + // into `iram1_0_seg` — 32 KB shared with the WiFi/SDK core. Named `foo.o`, + // every translation unit of libOpenPLCUserLib.a landed there: measured at + // 7387 bytes of IRAM for a small project (glue 3781 + configuration 3149 + + // pou_MAIN 457), which overflowed the segment and failed the link with + // "section `.text1' will not fit in region `iram1_0_seg'" — a message that + // names neither this archive nor the reason. + const objectFiles = sources.map((sourcePath) => join(objDir, `${path.basename(sourcePath)}.o`)) // Cap concurrent toolchain spawns at the host's logical core count. // An unbounded `sources.map(async …)` was dispatching one g++ per TU From 976ed4c38ad26d1b84ec7646706a3a528c426b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 4 Aug 2026 15:32:38 +0200 Subject: [PATCH 14/19] fix(editor): stop the liveness poll from killing a busy debug session, and quiet its trace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems from the same source: the debug poll is by far the busiest thing on the link, and both the liveness check and the trace treated each of its requests as an event worth acting on. THE DISCONNECT. Every command queues on the ONE serial link (the RTU client serialises requests, correctly — two frames in flight on a half-duplex wire would corrupt each other). A debug session reads variables continuously, so on a slow wire the status read (FC 0x46) waits behind that queue and times out. Two timeouts enter recovery, recovery drops the client, and the renderer ends the debug session — over a link that was working the whole time. The reported trace says exactly this: `unresponsive -> continue` twice, then `enter-recovery`, with successful variable reads interleaved throughout. Roughly every ten seconds on a 9600-baud ESP8266. The fix is to stop asking a question already answered: a command that succeeded IS liveness evidence, so `noteTraffic()` records it and the poll skips its round trip when something spoke within the interval. Cheaper than the old behaviour and strictly better evidence — it happened, rather than being asked for. Deliberately kept: - the serial-port presence check runs FIRST, before the shortcut. It reads the OS port list, costs nothing, and a yanked cable must still be caught on the next tick rather than hidden by a stale timestamp; - `dropClient()` clears the stamp, so traffic over a client that is gone cannot vouch for its replacement and recovery still probes for real; - a fresh connection does NOT seed the stamp: nothing else is on the link yet, so the first tick does its own read and the poll behaves exactly as before. The trade: while a debug session is running, run/stop state refreshes only when the poll actually reads (the status frame is where it comes from). A Start/Stop button that lags a hand-flipped switch during a debug session is a far better outcome than a debug session that dies every ten seconds. THE LOG FLOOD. `traceChannelUse` fired on every channel acquisition, which for the debug poll is several times a second: ~8 identical `read variables: using the debug channel (rtu COM5)` lines per second, burying every other message — including the ones explaining the disconnect. The question it answers ("did run/stop really ride the same connection as the debugger?") is answered by the FIRST occurrence, so it is now logged once per distinct command+channel+endpoint, and the set is cleared on connect and disconnect so a new session says it again. Co-Authored-By: Claude Opus 5 --- .../__tests__/device-session-manager.test.ts | 71 +++++++++++++++++++ .../editor/hardware/device-session-manager.ts | 37 +++++++++- src/main/modules/ipc/main.ts | 26 ++++++- 3 files changed, 132 insertions(+), 2 deletions(-) diff --git a/src/backend/editor/hardware/__tests__/device-session-manager.test.ts b/src/backend/editor/hardware/__tests__/device-session-manager.test.ts index 6b0b89a81..e04ede5a7 100644 --- a/src/backend/editor/hardware/__tests__/device-session-manager.test.ts +++ b/src/backend/editor/hardware/__tests__/device-session-manager.test.ts @@ -170,6 +170,77 @@ describe('DeviceSessionManager', () => { } }) + // A debug session polls variables continuously, and every request queues on + // the ONE serial link. On a slow wire the liveness read waits behind that + // traffic and times out; two such timeouts entered recovery and tore down a + // debug session whose own reads were succeeding — measured at roughly every + // ten seconds on a 9600-baud ESP8266. Traffic IS liveness evidence. + it('treats recent successful traffic as liveness instead of polling the busy link', async () => { + const client = new FakeClient() + let probes = 0 + const h = harness({ + probe: async () => { + probes += 1 + return false + }, + }) + + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [client], h.clients)]) + const afterOpen = probes + + // The debugger just read something successfully. + h.manager.noteTraffic() + await h.manager.tick() + + // No probe was sent, and a probe that WOULD have failed did not count. + expect(probes).toBe(afterOpen) + expect(h.statuses.map((s) => s.status)).not.toContain('error') + expect(h.manager.getClient()).toBe(asTransport(client)) + h.manager.close() + }) + + it('polls normally on a fresh connection, before any traffic', async () => { + const client = new FakeClient() + let probes = 0 + const h = harness({ + probe: async () => { + probes += 1 + return true + }, + }) + + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [client], h.clients)]) + await h.manager.tick() + + // Nothing else is on the link yet, so the poll does its own read. + expect(probes).toBe(1) + h.manager.close() + }) + + it('resumes polling once the traffic evidence has aged out', async () => { + const client = new FakeClient() + let probes = 0 + const h = harness({ + probe: async () => { + probes += 1 + return true + }, + }) + + await h.manager.open([candidate('rtu', '/dev/ttyUSB0', [client], h.clients)]) + h.manager.noteTraffic() + await h.manager.tick() + expect(probes).toBe(0) + + // A whole interval with nothing on the link: the poll must ask again. + jest.spyOn(Date, 'now').mockReturnValue(Date.now() + 60_000) + await h.manager.tick() + expect(probes).toBe(1) + + jest.restoreAllMocks() + h.manager.close() + }) + it('falls back to serial when Modbus TCP cannot connect', async () => { const h = harness() const tcp = new FakeClient({ connectFails: true }) diff --git a/src/backend/editor/hardware/device-session-manager.ts b/src/backend/editor/hardware/device-session-manager.ts index 43394e398..d1cb8c725 100644 --- a/src/backend/editor/hardware/device-session-manager.ts +++ b/src/backend/editor/hardware/device-session-manager.ts @@ -227,6 +227,8 @@ export class DeviceSessionManager { private readonly policy: DeviceLinkPolicy private timer: ReturnType | null = null private tickInFlight = false + /** When a command last succeeded over the link. 0 = nothing since it opened. */ + private lastTrafficAt = 0 constructor( private readonly hooks: DeviceLinkHooks, @@ -239,6 +241,25 @@ export class DeviceSessionManager { this.hooks.log?.(message) } + /** + * A command just succeeded over the held link. + * + * This is liveness evidence, and better evidence than the poll's own read: it + * already happened, and it cost nothing extra. The poll uses it to skip its + * round trip entirely (see `probeVerdict`). + * + * Why that matters, not merely as an optimisation: a debug session polls + * variables continuously, and every request queues on the ONE serial link. + * On a slow wire the status read waits behind that traffic, times out, and two + * such timeouts enter recovery — tearing down a debug session over a link that + * was demonstrably working, which is what the traffic proves. Measured on a + * 9600-baud ESP8266: the debugger died roughly every ten seconds while its own + * reads kept succeeding. + */ + noteTraffic(): void { + this.lastTrafficAt = Date.now() + } + /** * The CONTROL channel's client, or null when nothing is connected (including * mid-recovery). Run/stop and the status poll go here. @@ -377,6 +398,10 @@ export class DeviceSessionManager { this.client = outcome.client this.current = candidate this.policy.reset() + // Deliberately NOT seeding `lastTrafficAt` from the verify that just + // passed: nothing else is on the link yet, so letting the first tick do + // its own read costs nothing and keeps the poll's behaviour on a fresh + // connection exactly as it was. this.startPolling() this.hooks.emit({ status: 'connected', @@ -530,6 +555,8 @@ export class DeviceSessionManager { private dropClient(): void { this.client?.disconnect() this.client = null + // Traffic over a client we just dropped proves nothing about the next one. + this.lastTrafficAt = 0 } private startPolling(): void { @@ -588,10 +615,18 @@ export class DeviceSessionManager { ): Promise<'alive' | 'unresponsive' | 'gone'> { // Check the endpoint first: a pulled USB cable is not a slow device, and // treating it as one would spend the whole failure budget waiting for - // timeouts on a port that no longer exists. + // timeouts on a port that no longer exists. Deliberately BEFORE the traffic + // shortcut below: the port list is local and instant, so a yanked cable is + // still caught on the very next tick. if (candidate.transport === 'rtu' && !(await this.hooks.serialPortPresent(candidate.descriptor))) { return 'gone' } + // Something already answered within this interval, so the link is up and + // asking again would only add traffic to a wire that is evidently busy — + // and on a slow one, queue behind it and time out. See `noteTraffic`. + if (this.lastTrafficAt > 0 && Date.now() - this.lastTrafficAt < this.timings.pollIntervalMs) { + return 'alive' + } try { return (await this.hooks.probe(client)) ? 'alive' : 'unresponsive' } catch { diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 5e3b09ace..8fe5277d8 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -1812,6 +1812,10 @@ class MainProcessBridge implements MainIpcModule { try { const result = await link.client.getVariablesList(variableIndexes) if (result.success && result.data) { + // The debug poll is the busiest thing on the link; telling the session + // about it is what stops the liveness read from queueing behind this + // traffic and timing out on a link that is plainly working. + this.deviceSession.noteTraffic() return { success: true, tick: result.tick, lastIndex: result.lastIndex, data: Array.from(result.data) } } return { success: false, error: result.error } @@ -2224,9 +2228,25 @@ class MainProcessBridge implements MainIpcModule { return acquired } + /** + * Which channel served which command — logged ONCE per distinct combination. + * + * The question this answers ("did run/stop really ride the same connection as + * the debugger?") is answered by the first occurrence. Logging every occurrence + * answered it several times a second: the debug poll reads variables + * continuously, so an unfiltered trace emitted ~8 identical lines per second + * and buried every other message in the console, including the ones explaining + * a disconnect. + */ + private tracedChannelUses = new Set() + private traceChannelUse(what: string, family: 'control' | 'debug'): void { const link = this.deviceSession.getLink() - this.traceDeviceLink(`${what}: using the ${family} channel (${link?.transport ?? '?'} ${link?.descriptor ?? '?'})`) + const endpoint = `${link?.transport ?? '?'} ${link?.descriptor ?? '?'}` + const key = `${what}|${family}|${endpoint}` + if (this.tracedChannelUses.has(key)) return + this.tracedChannelUses.add(key) + this.traceDeviceLink(`${what}: using the ${family} channel (${endpoint})`) } private explainMissingChannel(what: string): ChannelUnavailable { @@ -2276,6 +2296,9 @@ class MainProcessBridge implements MainIpcModule { candidates: DebugConnectionConfig[], ): Promise => { this.deviceLinkProbe = null + // A new connection is a new story: let each command say which channel served + // it again, since it may well be a different one this time. + this.tracedChannelUses.clear() this.traceDeviceLink( `connect requested with ${candidates.length} candidate(s): ${ candidates.map((config) => `${config.connectionType} ${describeDebugEndpoint(config)}`).join(', ') || '(none)' @@ -2305,6 +2328,7 @@ class MainProcessBridge implements MainIpcModule { handleDeviceDisconnect = async (): Promise<{ success: boolean }> => { this.deviceSession.close() this.deviceLinkProbe = null + this.tracedChannelUses.clear() return { success: true } } From 2596f1eee379ea24fa0a6773fb5121e8330117a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 4 Aug 2026 15:56:15 +0200 Subject: [PATCH 15/19] fix(editor): a firmware that reports no unique id is still a firmware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found reviewing the branch, not in the field — but it is the same false negative the last three commits were chasing, and it would have hit a whole class of boards. `classifyDeviceLink` required the FC 0x48 reply to carry a non-empty board id. `success: true` already means the frame came back with the right function code and a SUCCESS status, which only an OpenPLC firmware sends; the id is separate. Cores without ArduinoUniqueID support, and boards that opt out with `OPENPLC_NO_UNIQUE_ID`, deliberately answer `id_len = 0` rather than fail to compile — see `debugGetBoardId` in modbus_debug.cpp, which documents exactly that. So every such board was classified `no-firmware`: "No Firmware Detected", with an offer to reflash a device that was running and answering. It also burned the full patient budget (6 reads, ~32s) before saying so, and then swept every fallback baud rate, because none of them could satisfy a condition the firmware can never meet. The requirement came from `probeAndRecover`, where it was correct — licensing needs the anchor bytes, so an empty id really is a dead end there. Carrying it into connect classification was the mistake. Also: `setVariable` now notes traffic like `getVariablesList` does. Forcing values queues on the same link and proves the same liveness, so a force held while the poll is due no longer lets the status read wait behind it and time out. Co-Authored-By: Claude Opus 5 --- .../hardware/__tests__/device-probe.test.ts | 15 +++++++++++++-- src/backend/editor/hardware/device-probe.ts | 17 +++++++++++++---- src/main/modules/ipc/main.ts | 4 ++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/backend/editor/hardware/__tests__/device-probe.test.ts b/src/backend/editor/hardware/__tests__/device-probe.test.ts index 192040d79..55024374e 100644 --- a/src/backend/editor/hardware/__tests__/device-probe.test.ts +++ b/src/backend/editor/hardware/__tests__/device-probe.test.ts @@ -104,10 +104,21 @@ describe('classifyDeviceLink', () => { expect(result).toEqual({ status: 'no-firmware' }) }) - it('reports no-firmware when the frame came back with an empty id', async () => { + // Cores without ArduinoUniqueID, and boards opting out via + // OPENPLC_NO_UNIQUE_ID, answer FC 0x48 with `id_len = 0` on purpose rather than + // failing to compile. That is a firmware replying, not a blank board — treating + // the empty id as "no firmware" told those users to reflash a working device. + it('keeps a firmware that answers with no unique id at all', async () => { const result = await classifyDeviceLink(fakeChannel([EMPTY_ID]), { boardIdProbe: { attempts: 1, backoffMs: 0 } }) - expect(result).toEqual({ status: 'no-firmware' }) + expect(result).toEqual({ status: 'connected-with-firmware' }) + }) + + it('does not burn retries once a firmware has answered, empty id or not', async () => { + const channel = fakeChannel([EMPTY_ID]) + await classifyDeviceLink(channel, { boardIdProbe: { attempts: 6, backoffMs: 0 } }) + + expect(channel.calls).toHaveLength(1) }) it('never throws — a transport that blows up resolves to an error status', async () => { diff --git a/src/backend/editor/hardware/device-probe.ts b/src/backend/editor/hardware/device-probe.ts index 6ab3006b6..99babf7d4 100644 --- a/src/backend/editor/hardware/device-probe.ts +++ b/src/backend/editor/hardware/device-probe.ts @@ -121,7 +121,14 @@ export async function connectWithRetries(client: Connectable, { attempts, backof /** * Read the board id (FC 0x48) with a bounded retry/backoff loop -- a readiness * probe for the firmware itself (the serial open auto-resets ESP8266/AVR boards). - * A non-empty board id means a firmware answered. + * + * A SUCCESSFUL REPLY is the signal, not a non-empty id. `success` already means + * the frame came back with the right function code and a SUCCESS status, which + * only an OpenPLC firmware sends. The id itself is allowed to be empty: cores + * without ArduinoUniqueID support, and boards that opt out with + * `OPENPLC_NO_UNIQUE_ID`, deliberately answer `id_len = 0` rather than fail to + * compile (see `debugGetBoardId` in modbus_debug.cpp). Requiring bytes here + * reported those boards as having no firmware at all. */ export async function readBoardIdWithRetries( client: BoardIdReadable, @@ -131,7 +138,7 @@ export async function readBoardIdWithRetries( for (let attempt = 0; attempt < attempts; attempt++) { const result = await client.getBoardId() last = { success: result.success, boardId: result.boardId } - if (last.success && !!last.boardId && last.boardId.length > 0) return last + if (last.success) return last if (attempt < attempts - 1) await new Promise((resolve) => setTimeout(resolve, backoffMs)) } return last @@ -160,8 +167,10 @@ export async function classifyDeviceLink( ): Promise { try { const probe = await readBoardIdWithRetries(client, opts.boardIdProbe ?? PATIENT_BOARD_ID_PROBE) - if (!probe.success || !probe.boardId || probe.boardId.length === 0) { - // Channel opened but nothing spoke the debug protocol -> blank/non-OpenPLC. + if (!probe.success) { + // Channel opened but nothing spoke the debug protocol -> blank board, a + // non-OpenPLC device, or the wrong baud rate. Whether the reply carried a + // unique id is NOT part of this question — see `readBoardIdWithRetries`. return { status: 'no-firmware' } } return { status: 'connected-with-firmware' } diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 8fe5277d8..bebd36247 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -2347,6 +2347,10 @@ class MainProcessBridge implements MainIpcModule { try { const result = await link.client.setVariable(variableIndex, force, buffer) + // Forcing values is device traffic too: it queues on the same link and + // proves the same thing a read does. Without this, holding a force while + // the poll is due lets the liveness read wait behind it and time out. + if (result.success) this.deviceSession.noteTraffic() logger.info('[IPC Handler] Modbus setVariable result: ' + JSON.stringify(result)) return result } catch (error) { From 579cc05a83ac5a5d8eb138d830f7ee8d9c6bb75f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 4 Aug 2026 18:09:49 +0200 Subject: [PATCH 16/19] fix(connection): reconnect after upload through the device port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The silent post-upload reconnect called `window.bridge.deviceConnect` directly. That global only exists in the Electron preload, so a shared component reaching for it does not compile in openplc-web — the byte-identical mirror gate has no way to satisfy both repos. `device.connect(...)` is the same call: the editor's device adapter delegates straight to `window.bridge.deviceConnect`. The component already holds the port (`const device = useDevice()`) and already uses it a few lines up for `releaseSerialPort`, so this closes the one place the layer was bypassed. Co-Authored-By: Claude Opus 5 --- .../components/_organisms/workspace-activity-bar/default.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index df4f97cd6..ca57c6bc3 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -376,7 +376,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa }) if (candidates.kind === 'candidates') { try { - await window.bridge.deviceConnect(candidates.candidates.map((candidate) => candidate.config)) + await device.connect(candidates.candidates.map((candidate) => candidate.config)) } catch { // best-effort: the user can press Connect again. } From 1c8ee641feb37c05ff3d38c01b08ab1a88ea8034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Tue, 4 Aug 2026 21:43:01 +0200 Subject: [PATCH 17/19] refactor(connection): trace the outcome, not every step towards it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A successful connect wrote nine lines. Eight of them said the same thing the ninth already said, and the console it shares with the compiler and the debugger scrolled past whatever the user was actually reading. What is left is two lines: what was requested, and what happened. Silenced, all of it happy-path only: * `status -> connecting` — a transient already visible in the button, emitted once per candidate. On a baud sweep that is five identical lines around the one outcome worth reading. * `transport opened in Xms` — opening a port is a step towards the answer, not the answer. Failing to open still reports. * `answered the debug protocol in Xms` / `ACCEPTED in Xms` — both restate the `connected` status that immediately follows, with the same descriptor. * `classified as "connected-with-firmware"` — the interesting classifications are the other ones. * `open: N candidate(s) in order` — now deferred to the moment a fallback is actually in play (the second candidate). On the ordinary connection the first one answers, and listing four baud rates nobody will dial buries the rest. Every line that reports a PROBLEM is untouched: port not enumerated, transport would not open, candidate rejected with its elapsed time, verification threw. The negative classification is now more useful than before, because the probe budget moved onto it: COM5: "no-firmware" after up to 2 id read(s) (baud guess) COM5: "no-firmware" after up to 6 id read(s) (last configured endpoint, was patient) "No firmware after two reads at a rate I guessed" is a different problem from "no firmware after six on the port the project configured", and the old line announced that budget BEFORE the outcome it explains — on every success too. These traces are what surfaced the four field defects on this branch, so the aim is to quiet the successful case, not to lose the evidence. Co-Authored-By: Claude Opus 5 --- .../editor/hardware/device-session-manager.ts | 33 ++++++++++------- src/main/modules/ipc/main.ts | 37 ++++++++++++------- 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/src/backend/editor/hardware/device-session-manager.ts b/src/backend/editor/hardware/device-session-manager.ts index d1cb8c725..5e6b90e3c 100644 --- a/src/backend/editor/hardware/device-session-manager.ts +++ b/src/backend/editor/hardware/device-session-manager.ts @@ -381,19 +381,28 @@ export class DeviceSessionManager { this.candidates = candidates const attempts: DeviceLinkOpenFailure['attempts'] = [] - this.trace(`open: ${candidates.length} candidate(s) in order: ${candidates.map(describeLinkCandidate).join(', ')}`) for (const [index, candidate] of candidates.entries()) { + // The plan is only worth stating once something went wrong. On the ordinary + // connection the first candidate answers, and listing four baud rates nobody + // will dial just buries the two lines that matter. The moment a fallback IS + // in play, the order becomes the thing you need to read. + if (index === 1) { + this.trace( + `open: falling back — ${candidates.length} candidate(s): ${candidates.map(describeLinkCandidate).join(', ')}`, + ) + } this.hooks.emit({ status: 'connecting', transport: candidate.transport, descriptor: candidate.descriptor }) const startedAt = Date.now() const outcome = await this.tryCandidate(candidate, { isLastCandidate: index === candidates.length - 1 }) - const elapsed = Date.now() - startedAt - this.trace( - outcome.ok - ? `open: ${describeLinkCandidate(candidate)} ACCEPTED in ${elapsed}ms` - : `open: ${describeLinkCandidate(candidate)} rejected in ${elapsed}ms — ${outcome.error}`, - ) + // Only a rejection is traced. Acceptance is already announced by the + // `connected` status that follows, with the same descriptor. + if (!outcome.ok) { + this.trace( + `open: ${describeLinkCandidate(candidate)} rejected in ${Date.now() - startedAt}ms — ${outcome.error}`, + ) + } if (outcome.ok) { this.client = outcome.client this.current = candidate @@ -474,8 +483,9 @@ export class DeviceSessionManager { const connectStartedAt = Date.now() try { + // Nothing traced on success: opening is a step towards the answer, not the + // answer. Only failing to open is news. await client.connect() - this.trace(` ${describeLinkCandidate(candidate)}: transport opened in ${Date.now() - connectStartedAt}ms`) } catch (error) { client.disconnect() this.trace( @@ -489,12 +499,7 @@ export class DeviceSessionManager { // so this is the step that decides whether to keep the candidate. const verifyStartedAt = Date.now() try { - if (await this.hooks.verify(client, candidate, context)) { - this.trace( - ` ${describeLinkCandidate(candidate)}: answered the debug protocol in ${Date.now() - verifyStartedAt}ms`, - ) - return { ok: true, client } - } + if (await this.hooks.verify(client, candidate, context)) return { ok: true, client } client.disconnect() this.trace( ` ${describeLinkCandidate(candidate)}: opened but did NOT answer the debug protocol (waited ${Date.now() - verifyStartedAt}ms)`, diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index bebd36247..09a3ef041 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -1938,11 +1938,17 @@ class MainProcessBridge implements MainIpcModule { /** Push a link state change to the renderer. */ private emitDeviceLinkStatus(status: DeviceLinkStatus): void { - this.traceDeviceLink( - `status -> ${status.status}${status.descriptor ? ` (${status.transport ?? '?'} ${status.descriptor})` : ''}${ - status.reason ? ` [${status.reason}]` : '' - }`, - ) + // `connecting` is not traced. It is a transient the user can already see in the + // button, and it repeats once per candidate — on a baud sweep that is five + // identical lines around the one outcome worth reading. Every settled state + // (connected / disconnected / error) still gets its line. + if (status.status !== 'connecting') { + this.traceDeviceLink( + `status -> ${status.status}${status.descriptor ? ` (${status.transport ?? '?'} ${status.descriptor})` : ''}${ + status.reason ? ` [${status.reason}]` : '' + }`, + ) + } this.mainWindow?.webContents?.send('device:connection-status', status) } @@ -2141,16 +2147,21 @@ class MainProcessBridge implements MainIpcModule { : isPatient ? PATIENT_BOARD_ID_PROBE : QUICK_BOARD_ID_PROBE - this.traceDeviceLink( - ` ${candidate.descriptor}: verifying with up to ${boardIdProbe.attempts} id read(s)` + - `${candidate.speculative ? ' (baud guess)' : isPatient ? ' (last configured endpoint, being patient)' : ''}`, - ) const result = await classifyDeviceLink(client, { boardIdProbe }) this.deviceLinkProbe = result - this.traceDeviceLink( - ` ${candidate.descriptor}: classified as "${result.status}"${result.error ? ` (${result.error})` : ''}`, - ) - if (result.status !== 'connected-with-firmware') return false + if (result.status !== 'connected-with-firmware') { + // Traced only when the endpoint is REJECTED, and then with the budget it was + // given: "no firmware after 2 id reads (baud guess)" is a different problem + // from "no firmware after 6" on the port the project configured. Announcing + // the budget up front, as this used to, put the line before the outcome it + // explains and printed it on every success too. + this.traceDeviceLink( + ` ${candidate.descriptor}: "${result.status}" after up to ${boardIdProbe.attempts} id read(s)` + + `${candidate.speculative ? ' (baud guess)' : isPatient ? ' (last configured endpoint, was patient)' : ''}` + + `${result.error ? ` — ${result.error}` : ''}`, + ) + return false + } // The status frame doubles as the run/stop state source; push it straight // away so the Start/Stop button is right before the first poll lands. await this.pushPlcState(client, candidate.descriptor, 0) From 87001fd156a246fad45ba897b069d327e6e67540 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Wed, 5 Aug 2026 16:46:15 -0400 Subject: [PATCH 18/19] fix(runtime): address review findings on the device-connection PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight defects found reviewing this branch, plus the tests that pin each. Connection lifecycle - Connect could wedge the UI permanently. The renderer sets 'connecting' optimistically and only the main process clears it, but two paths return before `deviceSession.open()` and so publish nothing: a cancelled DHCP address prompt, and a candidate list that built no usable transport. The button is disabled while 'connecting' and Disconnect only fires when 'connected', so the user had to reopen the project. Both early returns now emit 'disconnected', and the hook settles any non-success outcome in a `finally` (not on success — the status push and the invoke reply travel separate IPC channels, so settling there would risk a flicker). - A Runtime v3/v4 debug channel never closed. `requireDebug` registered every per-command caller as a lifetime holder, and `read variables` runs on every poll tick, so `releaseDebugChannel('debug session')` always found the set non-empty. Stopping the debugger left an authenticated channel open to the PLC until logout. Per-command callers now go through `withDebugChannel`, which releases in a `finally`; `debug session` stays the only lifetime holder. Safe for baremetal by construction: `releaseDebugChannel` returns early on a shared channel before touching any client. Firmware / editor agreement - `DEBUG_SLAVE` was never emitted, so the firmware fell back to modbus_config.h's `1` while the editor addressed the RTU screen's slave id (spec `params` are read regardless of `enabledWhen`). Every frame was dropped at the id check and reported as "No Firmware Detected" on a healthy board — the same defect class as the DEBUG_BAUD fix, but with no baud sweep to recover it. Added `resolveDebugSlave` and emitted `#define DEBUG_SLAVE`. Run/stop - Runtime v3 lost Start/Stop silently. v3 exposes the same JWT-authenticated `/api/start-plc` and `/api/stop-plc` as v4 (webserver/restapi.py routes under `url_prefix='/api'`); only the debug channel differs. The main process already routes the command over REST for both, so `plcStateControl: false` was the only thing stopping it. Also folded the capability into `plcControlBlocked`, so a target that genuinely lacks run/stop shows a reason instead of an enabled button that does nothing. - `verifyMd5` dropped `targetMd5`: `Md5ProbeResult` names the hash `md5`, so `...probe` left the declared field undefined and a spread skips excess property checks. The mismatch report read "Target: undefined". - Corrected the run/stop function code in the capability doc (0x49 -> 0x4b). Tests - compiler-module.spec.ts asserted the old `foo.o` archive names and failed on this branch; updated to `foo.cpp.o`. - Restored coverage on every file this branch touches under a 100% threshold: device-adapter (100 -> 62.5%), debugger-adapter, the new device-connect-events, plus previously uncovered `setPlcSwitchPosition`, `setDeviceConnectionStatus`'s transport arguments, the simulator client's `setPlcState`, and two resolver/parser error paths. Measured against the branch's merge base, all four threshold directories are now at or above baseline and no file lost coverage. Co-Authored-By: Claude Opus 5 (1M context) --- resources/sources/Baremetal/modbus_config.h | 8 +- .../editor/compiler/compiler-module.spec.ts | 11 +- .../device-session-channel-lifetime.test.ts | 175 ++++++++++++++++++ .../editor/hardware/device-session-manager.ts | 5 + .../__tests__/generate-defines.test.ts | 35 ++++ .../compile/__tests__/modbus-defines.test.ts | 44 ++++- .../shared/compile/steps/generate-defines.ts | 9 +- .../shared/compile/steps/modbus-defines.ts | 30 +++ .../__tests__/modbus-pdu-plc-control.test.ts | 13 ++ .../connect-resolve-regression.test.ts | 20 ++ .../__tests__/modbus-rtu-client.test.ts | 128 ++++++++++++- .../workspace-activity-bar/default.tsx | 12 +- .../__tests__/use-device-connect.test.ts | 75 +++++++- src/frontend/hooks/use-device-connect.ts | 122 +++++++----- .../store/__tests__/device-slice.test.ts | 62 +++++++ .../__tests__/device-connect-events.test.ts | 79 ++++++++ src/main/modules/ipc/main.ts | 141 ++++++++++---- .../editor/__tests__/debugger-adapter.test.ts | 27 +++ .../editor/__tests__/device-adapter.test.ts | 53 ++++++ .../utils/target-capabilities/presets.ts | 7 +- .../shared/utils/target-capabilities/types.ts | 14 +- 21 files changed, 968 insertions(+), 102 deletions(-) create mode 100644 src/backend/editor/hardware/__tests__/device-session-channel-lifetime.test.ts create mode 100644 src/frontend/utils/__tests__/device-connect-events.test.ts diff --git a/resources/sources/Baremetal/modbus_config.h b/resources/sources/Baremetal/modbus_config.h index 950869cf7..642bd5b9f 100644 --- a/resources/sources/Baremetal/modbus_config.h +++ b/resources/sources/Baremetal/modbus_config.h @@ -27,8 +27,12 @@ it must reach a TU through exactly one path: this header. #endif // Default serial config for the always-on debugger. `defines.h` normally emits -// DEBUG_IFACE / DEBUG_BAUD explicitly (from the Serial screen); these `#ifndef` -// defaults cover anything it left unset. Defined whenever the debugger is on — +// DEBUG_IFACE / DEBUG_BAUD / DEBUG_SLAVE explicitly (from the Serial and Modbus +// RTU screens); these `#ifndef` defaults cover anything it left unset — they are +// a fallback for a hand-written defines.h, NOT the expected path. When they do +// apply, they must agree with what the editor dials, so they mirror +// `generate-defines.ts` (Serial @ 115200, slave id 1). +// Defined whenever the debugger is on — // including alongside full Modbus RTU, since the dual-serial path (RTU on a // secondary UART, MBSERIAL_ON_SECONDARY) needs DEBUG_SLAVE for the default port. #ifdef DEBUGGER_ENABLED diff --git a/src/backend/editor/compiler/compiler-module.spec.ts b/src/backend/editor/compiler/compiler-module.spec.ts index 7cea25be6..005146485 100644 --- a/src/backend/editor/compiler/compiler-module.spec.ts +++ b/src/backend/editor/compiler/compiler-module.spec.ts @@ -540,9 +540,12 @@ describe('CompilerModule', () => { handleOutputData: noopLog, }) - const aPos = arCmd.indexOf('a_first.o') - const mPos = arCmd.indexOf('m_middle.o') - const zPos = arCmd.indexOf('z_last.o') + // `foo.cpp.o`, not `foo.o` — the `.cpp` is KEPT so esp8266's linker script + // matches `*.cpp.o` and sends the code to flash instead of the 32 KB IRAM + // catch-all. See the objectFiles comment in handlePrecompileUserLib. + const aPos = arCmd.indexOf('a_first.cpp.o') + const mPos = arCmd.indexOf('m_middle.cpp.o') + const zPos = arCmd.indexOf('z_last.cpp.o') expect(aPos).toBeGreaterThan(-1) expect(mPos).toBeGreaterThan(aPos) expect(zPos).toBeGreaterThan(mPos) @@ -658,7 +661,7 @@ describe('CompilerModule', () => { // The TU set discovered from the stash matches the original two // strucpp sources — order is deterministic (sorted basenames). - expect(result.objectFiles.map((p) => p.split(/[\\/]/).pop())).toEqual(['configuration.o', 'pou_MAIN.o']) + expect(result.objectFiles.map((p) => p.split(/[\\/]/).pop())).toEqual(['configuration.cpp.o', 'pou_MAIN.cpp.o']) }) it('injects -I{build.core.path} and -I{build.variant.path} into every TU compile (so Arduino.h resolves)', async () => { diff --git a/src/backend/editor/hardware/__tests__/device-session-channel-lifetime.test.ts b/src/backend/editor/hardware/__tests__/device-session-channel-lifetime.test.ts new file mode 100644 index 000000000..c321698c0 --- /dev/null +++ b/src/backend/editor/hardware/__tests__/device-session-channel-lifetime.test.ts @@ -0,0 +1,175 @@ +/** + * Who may close the debug channel, and when. + * + * Two rules that pull against each other, which is why they are pinned here: + * + * - A session whose debug medium is its OWN (Runtime v3's second Modbus TCP + * connection, v4's WebSocket) must close that channel when the debug session + * ends. Leaving it open holds an authenticated channel to the user's PLC for + * no reason, and contradicts the whole point of opening it lazily. + * - A BAREMETAL session must NOT close anything: control and debug are the same + * connection, so closing on debug-stop would disconnect the device and take + * run/stop and the status poll down with it. + * + * The bug these cover: per-command callers (`read variables` on every poll tick, + * `write variable`, `verify md5`) were registered as lifetime holders, so the + * holder set was never empty and the v3/v4 channel never closed. + */ +import type { DeviceDebugChannel, DeviceModbusTransport } from '../../../shared/debug/types' +import { type DeviceLinkHooks, DeviceSessionManager } from '../device-session-manager' + +/** A debug channel that records whether it was closed. */ +function fakeDebugChannel() { + const channel = { + connect: () => Promise.resolve(), + disconnect: () => { + channel.disconnects += 1 + }, + disconnects: 0, + } + return channel +} + +/** A Modbus client standing in for a held baremetal link. */ +function fakeModbusClient() { + const client = { + connect: () => Promise.resolve(), + disconnect: () => { + client.disconnects += 1 + }, + disconnects: 0, + } + return client +} + +function managerWith(overrides: Partial = {}) { + return new DeviceSessionManager({ + verify: () => Promise.resolve(true), + probe: () => Promise.resolve(true), + serialPortPresent: () => Promise.resolve(true), + emit: () => undefined, + log: () => undefined, + ...overrides, + }) +} + +describe('debug channel lifetime — a session with its own debug medium (v3 / v4)', () => { + it('closes the channel once the debug session releases, even after per-command use', async () => { + const channel = fakeDebugChannel() + const manager = managerWith() + manager.openRestSession({ + address: '192.168.0.9', + debugChannel: { + transport: 'websocket', + descriptor: 'websocket 192.168.0.9', + create: () => channel as unknown as DeviceDebugChannel, + }, + }) + + // The real order main.ts uses: connect (the lifetime holder), then commands. + await manager.acquireDebugChannel('debug session') + await manager.acquireDebugChannel('verify md5') + manager.releaseDebugChannel('verify md5') + for (let tick = 0; tick < 3; tick += 1) { + await manager.acquireDebugChannel('read variables') + manager.releaseDebugChannel('read variables') + } + expect(channel.disconnects).toBe(0) // still debugging — nothing may close it + + manager.releaseDebugChannel('debug session') + + expect(channel.disconnects).toBe(1) + expect(manager.getDebugClient()).toBeNull() + }) + + it('opens exactly one channel across the whole session', async () => { + let created = 0 + const channel = fakeDebugChannel() + const manager = managerWith() + manager.openRestSession({ + address: '192.168.0.9', + debugChannel: { + transport: 'websocket', + descriptor: 'websocket 192.168.0.9', + create: () => { + created += 1 + return channel as unknown as DeviceDebugChannel + }, + }, + }) + + await manager.acquireDebugChannel('debug session') + await manager.acquireDebugChannel('read variables') + manager.releaseDebugChannel('read variables') + + expect(created).toBe(1) + }) + + it('keeps the channel while a second holder still needs it', async () => { + const channel = fakeDebugChannel() + const manager = managerWith() + manager.openRestSession({ + address: '192.168.0.9', + debugChannel: { + transport: 'websocket', + descriptor: 'websocket 192.168.0.9', + create: () => channel as unknown as DeviceDebugChannel, + }, + }) + + await manager.acquireDebugChannel('debug session') + await manager.acquireDebugChannel('licensing') + manager.releaseDebugChannel('debug session') + + // Ref-counting still holds: one release does not close a channel another holds. + expect(channel.disconnects).toBe(0) + manager.releaseDebugChannel('licensing') + expect(channel.disconnects).toBe(1) + }) +}) + +describe('debug channel lifetime — a baremetal session (one shared channel)', () => { + it('never closes the device connection when a debug caller releases', async () => { + const client = fakeModbusClient() + const manager = managerWith() + const opened = await manager.open([ + { + transport: 'rtu', + descriptor: '/dev/ttyACM0', + baudRate: 115200, + create: () => client as unknown as DeviceModbusTransport, + }, + ]) + expect(opened.ok).toBe(true) + + await manager.acquireDebugChannel('debug session') + await manager.acquireDebugChannel('read variables') + manager.releaseDebugChannel('read variables') + manager.releaseDebugChannel('debug session') + + // The control channel IS the debug channel here. Stopping the debugger must + // leave run/stop and the status poll with a live connection. + expect(client.disconnects).toBe(0) + expect(manager.isConnected()).toBe(true) + expect(manager.getDebugClient()).not.toBeNull() + manager.close() + }) +}) + +describe('open() always reports a settled state', () => { + it('emits disconnected when no candidate could be built', async () => { + // Otherwise the renderer, which set 'connecting' the moment the user clicked, + // is left there forever with its Connect button disabled. + const emitted: string[] = [] + const manager = managerWith({ + emit: (status) => { + emitted.push(status.status) + }, + }) + + const result = await manager.open([]) + + expect(result.ok).toBe(false) + expect(emitted).toContain('disconnected') + }) +}) diff --git a/src/backend/editor/hardware/device-session-manager.ts b/src/backend/editor/hardware/device-session-manager.ts index 5e6b90e3c..6c27f658e 100644 --- a/src/backend/editor/hardware/device-session-manager.ts +++ b/src/backend/editor/hardware/device-session-manager.ts @@ -376,6 +376,11 @@ export class DeviceSessionManager { if (candidates.length === 0) { this.trace('open: refused, no usable candidate was resolved') + // Still report a settled state. The `close({ silent: true })` above suppressed + // its own notification on the assumption that this open would publish one, so + // returning quietly here leaves the renderer showing 'connecting' forever — + // and its Connect button disabled with no way back. + this.hooks.emit({ status: 'disconnected' }) return { ok: false, attempts: [] } } diff --git a/src/backend/shared/compile/__tests__/generate-defines.test.ts b/src/backend/shared/compile/__tests__/generate-defines.test.ts index fc924d860..3b9dfb64f 100644 --- a/src/backend/shared/compile/__tests__/generate-defines.test.ts +++ b/src/backend/shared/compile/__tests__/generate-defines.test.ts @@ -251,6 +251,40 @@ describe('generateDefinesContent — Debugger block (always-on debug)', () => { expect(out).toContain('#define DEBUG_BAUD 115200') }) + it('emits DEBUG_SLAVE from the RTU screen so it matches the id the editor addresses', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + defaultSerial: 'Serial', + vppModbusState: { + modbus_rtu: { enabled: true, rtu_interface: 'Serial', rtu_baud_rate: '9600', rtu_slave_id: 3 }, + }, + }) + expect(out).toContain('#define MBSERIAL_SLAVE 3') + expect(out).toContain('#define DEBUG_SLAVE 3') + }) + + // The slave-id twin of the DEBUG_BAUD regression above, and the harsher one: + // Connect sweeps baud rates, but nothing sweeps slave ids. With Modbus off and + // slave id 7 saved on the screen, the editor addresses 7 while a firmware left + // on modbus_config.h's `#ifndef DEBUG_SLAVE 1` fallback frames on 1 — every + // frame dropped at the id check, reported as "No Firmware Detected". + it('aligns DEBUG_SLAVE with the screen slave id when Modbus is DISABLED', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + defaultSerial: 'Serial', + vppModbusState: { modbus_rtu: { enabled: false, rtu_slave_id: 7 } }, + }) + expect(out).toContain('#define DEBUG_SLAVE 7') + expect(out).not.toContain('#define MODBUS_ENABLED') + }) + + it('falls back to DEBUG_SLAVE 1 when the project states no slave id', () => { + const out = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'arduino-cli' }) + expect(out).toContain('#define DEBUG_SLAVE 1') + }) + it('does NOT emit DEBUGGER_ENABLED for the simulator (it uses the full Modbus path)', () => { const out = generateDefinesContent({ ...EMPTY_INPUTS, boardRuntime: 'simulator' }) expect(out).not.toContain('DEBUGGER_ENABLED') @@ -471,6 +505,7 @@ describe('generateDefinesContent — full output snapshot', () => { '#define DEBUGGER_ENABLED', '#define DEBUG_IFACE Serial', '#define DEBUG_BAUD 115200', + '#define DEBUG_SLAVE 1', '', '', '//IO Config', diff --git a/src/backend/shared/compile/__tests__/modbus-defines.test.ts b/src/backend/shared/compile/__tests__/modbus-defines.test.ts index deeb036f9..918fa67ed 100644 --- a/src/backend/shared/compile/__tests__/modbus-defines.test.ts +++ b/src/backend/shared/compile/__tests__/modbus-defines.test.ts @@ -1,4 +1,10 @@ -import { DEFAULT_DEBUG_BAUD, generateModbusDefines, resolveDebugBaud } from '../steps/modbus-defines' +import { + DEFAULT_DEBUG_BAUD, + DEFAULT_DEBUG_SLAVE, + generateModbusDefines, + resolveDebugBaud, + resolveDebugSlave, +} from '../steps/modbus-defines' /** * The baud the always-on debugger answers on. It has to agree with the rate the @@ -68,6 +74,42 @@ describe('resolveDebugBaud', () => { }) }) +/** + * The slave id the always-on debugger frames on. Unlike the baud, a mismatch here + * is NOT recoverable by the connect flow's rate sweep — the firmware silently + * drops every frame whose first byte isn't this id, and that check is the only + * validation debug function codes get. So these pin exact agreement with the id + * the editor addresses (`screens.modbus_rtu.rtu_slave_id`, read regardless of + * whether the RTU is enabled). + */ +describe('resolveDebugSlave', () => { + it('uses the RTU screen slave id when the RTU is enabled', () => { + expect(resolveDebugSlave({ modbus_rtu: { enabled: true, rtu_slave_id: 3 } })).toBe(3) + }) + + it('uses the RTU screen slave id even when the RTU is DISABLED', () => { + // The regression this exists for: a TCP-only (or Modbus-off) project still + // has the editor addressing the RTU screen's id over serial, because a debug + // spec's `params` are read independently of its `enabledWhen`. Defaulting to + // 1 here made a healthy board report "No Firmware Detected". + expect(resolveDebugSlave({ modbus_rtu: { enabled: false, rtu_slave_id: 7 } })).toBe(7) + }) + + it('uses the RTU screen slave id even when the RTU runs on a secondary UART', () => { + // Not a conflict: MBSERIAL_SLAVE frames that id on Serial1 while DEBUG_SLAVE + // frames it on Serial. Two distinct ports, and the editor still dials this id. + expect(resolveDebugSlave({ modbus_rtu: { enabled: true, serial_port: 'Serial1', rtu_slave_id: 4 } })).toBe(4) + }) + + it('falls back when the RTU section states no slave id', () => { + expect(resolveDebugSlave({ modbus_rtu: { enabled: true } })).toBe(DEFAULT_DEBUG_SLAVE) + }) + + it('falls back for an empty project', () => { + expect(resolveDebugSlave({})).toBe(DEFAULT_DEBUG_SLAVE) + }) +}) + describe('generateModbusDefines', () => { it('returns an empty string when neither RTU nor TCP is enabled', () => { expect(generateModbusDefines({})).toBe('') diff --git a/src/backend/shared/compile/steps/generate-defines.ts b/src/backend/shared/compile/steps/generate-defines.ts index dc62c0899..adc10c136 100644 --- a/src/backend/shared/compile/steps/generate-defines.ts +++ b/src/backend/shared/compile/steps/generate-defines.ts @@ -18,7 +18,7 @@ */ import type { DevicePin } from '../../types/PLC/devices' -import { generateModbusDefines, resolveDebugBaud, type VppModbusScreenState } from './modbus-defines' +import { generateModbusDefines, resolveDebugBaud, resolveDebugSlave, type VppModbusScreenState } from './modbus-defines' export type { VppModbusScreenState } from './modbus-defines' @@ -192,6 +192,13 @@ export function generateDefinesContent(input: GenerateDefinesInput): string { // firmware listening at 115200 while the editor dialled the RTU's baud, and // the board answered nothing ("No Firmware Detected" on a healthy board). DEFINES_CONTENT += `#define DEBUG_BAUD ${resolveDebugBaud(vppModbusState ?? {}, defaultSerial)}\n` + // Same two-sided agreement as the baud, and the same symptom when it breaks: + // the firmware drops every frame whose slave id doesn't match, and that check + // is the only validation debug function codes get. The editor addresses the + // RTU screen's slave id whether or not the RTU is enabled, so emit it rather + // than leaving modbus_config.h's `#ifndef DEBUG_SLAVE 1` fallback to disagree + // with a project that configured anything else. + DEFINES_CONTENT += `#define DEBUG_SLAVE ${resolveDebugSlave(vppModbusState ?? {})}\n` DEFINES_CONTENT += `\n\n` } diff --git a/src/backend/shared/compile/steps/modbus-defines.ts b/src/backend/shared/compile/steps/modbus-defines.ts index 11e657f3d..7921c5022 100644 --- a/src/backend/shared/compile/steps/modbus-defines.ts +++ b/src/backend/shared/compile/steps/modbus-defines.ts @@ -119,6 +119,36 @@ export function resolveDebugBaud(state: VppModbusScreenState, defaultSerial: str return rtu.baud_rate ?? rtu.rtu_baud_rate ?? DEFAULT_DEBUG_BAUD } +/** Slave id the always-on debugger frames on when the project states none. */ +export const DEFAULT_DEBUG_SLAVE = 1 + +/** + * Modbus slave id the always-on debugger answers on — and therefore the id the + * editor must address to reach it. + * + * The same two-sided agreement `resolveDebugBaud` describes, and the same failure + * when it breaks: `handle_serial_port` drops any frame whose first byte is not + * this id, and that check is the ONLY validation applied to debug function codes + * (CRC is skipped on them). A mismatch is therefore total silence on a healthy + * board — reported as "No Firmware Detected". + * + * What the editor addresses is `screens.modbus_rtu.rtu_slave_id`, ALWAYS: a + * spec's `params` are read independently of its `enabledWhen`, so an RTU screen + * left at slave id 7 with the RTU toggle OFF still sends id 7 down the cable. + * So this returns that id unconditionally — including when the RTU runs on a + * SECOND UART, where it is not a conflict but the same number on two distinct + * ports. + * + * Deliberately NOT the `resolveDebugBaud` shape of "guess 115200 for a secondary + * port": a wrong baud is recoverable, because Connect sweeps the plausible rates. + * There is no sweep for slave ids, so this has to match exactly rather than + * approximately. + */ +export function resolveDebugSlave(state: VppModbusScreenState): number { + const slave = state.modbus_rtu?.rtu_slave_id + return typeof slave === 'number' ? slave : DEFAULT_DEBUG_SLAVE +} + /** * `aa:bb:cc:dd:ee:ff` → `0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff` so it can * land verbatim in `byte mac[] = { MBTCP_MAC };`. Accepts the canonical diff --git a/src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts b/src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts index 23f3adbb5..79772b12d 100644 --- a/src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts +++ b/src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts @@ -80,6 +80,19 @@ describe('parsePlcSetStateResponse', () => { expect(result.state).toBe(PlcRuntimeState.ERROR) }) + it('describes any other failure status rather than failing silently', () => { + // Neither SUCCESS nor REFUSED_BY_SWITCH — e.g. the runtime ran out of memory + // servicing the request. Without the error text the editor would report + // "Failed to start PLC: Unknown error" and give the user nothing to act on. + const result = parsePlcSetStateResponse( + frame(ModbusDebugResponse.ERROR_OUT_OF_MEMORY, PlcRuntimeState.STOPPED, PlcSwitchPosition.RUN), + ) + expect(result.success).toBe(false) + expect(result.refusedBySwitch).toBeUndefined() + expect(result.unsupported).toBeUndefined() + expect(result.error).toBeTruthy() + }) + it('detects old firmware via the Modbus exception form', () => { // A runtime built before the state machine answers (FC | 0x80). The editor turns this // into "rebuild and upload", never an error, so field devices don't look diff --git a/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts b/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts index e2c285995..20f47ab24 100644 --- a/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts +++ b/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts @@ -328,6 +328,26 @@ describe('Connect resolves a baremetal debug spec while disconnected', () => { ) }) + it('reports an error when no declared channel matches a transport the target speaks', () => { + // A target whose capability matrix says `['websocket']` cannot use a spec that + // only declares serial and TCP — nothing is eligible. Reported as an error, not + // silently as an empty candidate list, because an empty list downstream reads as + // "connected to nothing" and every later command then times out unexplained. + const result = resolveDeviceLinkCandidates(bothChannelsSpec, tcpOnlyContext(), { transports: ['websocket'] }) + + expect(result.kind).toBe('error') + if (result.kind === 'error') expect(result.body).toBeTruthy() + }) + + it('prefers the spec-supplied noneEnabled message when it has one', () => { + const withMessage: DebugSpec = { + ...bothChannelsSpec, + messages: { noneEnabled: { title: 'Nope', body: 'This board needs an ethernet shield.' } }, + } + const result = resolveDeviceLinkCandidates(withMessage, tcpOnlyContext(), { transports: ['websocket'] }) + expect(result).toMatchObject({ kind: 'error', title: 'Nope', body: 'This board needs an ethernet shield.' }) + }) + it('shows why a precondition cannot express a debugger-only requirement', () => { // Adding ANY precondition to the spec above breaks Connect, because Connect // resolves this same spec with nothing connected. diff --git a/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts b/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts index 15a85d885..a64336794 100644 --- a/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts +++ b/src/backend/shared/simulator/__tests__/modbus-rtu-client.test.ts @@ -7,7 +7,7 @@ */ import { ModbusRtuClient, type SerialPortLike } from '../modbus-rtu-client' -import { ModbusDebugResponse, ModbusFunctionCode } from '../types' +import { ModbusDebugResponse, ModbusFunctionCode, PlcRuntimeState, PlcSwitchPosition } from '../types' // jsdom polyfill if (typeof globalThis.TextEncoder === 'undefined') { @@ -936,6 +936,26 @@ describe('ModbusRtuClient', () => { expect(result.error).toContain('too short') }) + it('setPlcState handles response too short (<8 bytes)', async () => { + // Belongs here rather than with the wire-framing tests: `sendRequest` prepends + // 6 bytes of TCP-compat padding, so no real reply can be short enough to reach + // this guard — a 4-byte frame lands on the PARSER's own "too short" instead. + // Both messages read alike, which is how a fixture can appear to cover this + // branch while never entering it. + await connectClient() + mockSendRequest(client, new Uint8Array([0, 0, 0, 0, 0, 0, 0])) + const result = await client.setPlcState(PlcRuntimeState.RUNNING) + expect(result.success).toBe(false) + expect(result.error).toContain('Invalid response: too short') + }) + + it('setPlcState reports a non-Error rejection', async () => { + await connectClient() + mockSendRequest(client, 'serial port vanished') + const result = await client.setPlcState(PlcRuntimeState.STOPPED) + expect(result).toEqual({ success: false, error: 'serial port vanished' }) + }) + it('getBoardId handles non-Error exception', async () => { await connectClient() mockSendRequest(client, 'non-error string') @@ -1033,4 +1053,110 @@ describe('ModbusRtuClient', () => { expect(result.success).toBe(true) }) }) + + // ----------------------------------------------------------------------- + // setPlcState (FC 0x4b) + // + // The end-to-end coverage for run/stop lives in plc-control-e2e.test.ts, which + // only runs when it is pointed at built firmware — so without these the wire + // framing here is unexercised on any ordinary test run. + // ----------------------------------------------------------------------- + describe('setPlcState', () => { + /** `[status][plcState][switchPosition]` — the FC 0x4b acknowledgement payload. */ + function ackPayload(status: number, state: number, switchPosition: number): Uint8Array { + return new Uint8Array([status, state, switchPosition]) + } + + it('sends the run request and returns the acknowledged state', async () => { + await connectClient() + const written: number[][] = [] + port._interceptWrite = (data: Uint8Array) => { + written.push(Array.from(data)) + setTimeout( + () => + port._emit( + 'data', + buildResponse( + 1, + ModbusFunctionCode.PLC_SET_STATE, + ackPayload(ModbusDebugResponse.SUCCESS, PlcRuntimeState.RUNNING, PlcSwitchPosition.RUN), + ), + ), + 0, + ) + } + + const result = await client.setPlcState(PlcRuntimeState.RUNNING) + + // Request is [slaveId][FC][state] + CRC — the state byte is what distinguishes + // run from stop, and getting it backwards would stop a PLC on a start click. + expect(written[0][1]).toBe(ModbusFunctionCode.PLC_SET_STATE) + expect(written[0][2]).toBe(1) + expect(result).toMatchObject({ success: true, state: PlcRuntimeState.RUNNING }) + }) + + it('encodes a stop request as state 0', async () => { + await connectClient() + const written: number[][] = [] + port._interceptWrite = (data: Uint8Array) => { + written.push(Array.from(data)) + setTimeout( + () => + port._emit( + 'data', + buildResponse( + 1, + ModbusFunctionCode.PLC_SET_STATE, + ackPayload(ModbusDebugResponse.SUCCESS, PlcRuntimeState.STOPPED, PlcSwitchPosition.RUN), + ), + ), + 0, + ) + } + + const result = await client.setPlcState(PlcRuntimeState.STOPPED) + + expect(written[0][2]).toBe(0) + expect(result).toMatchObject({ success: true, state: PlcRuntimeState.STOPPED }) + }) + + it('surfaces a RUN refused by the hardware mode switch', async () => { + await connectClient() + autoRespond( + buildResponse( + 1, + ModbusFunctionCode.PLC_SET_STATE, + ackPayload(ModbusDebugResponse.REFUSED_BY_SWITCH, PlcRuntimeState.STOPPED, PlcSwitchPosition.STOP), + ), + ) + + const result = await client.setPlcState(PlcRuntimeState.RUNNING) + + // Drives the "flip the switch to RUN" warning rather than a generic failure. + expect(result).toMatchObject({ success: false, refusedBySwitch: true }) + }) + + it('rejects a well-framed reply whose PDU is truncated', async () => { + await connectClient() + const frame = new Uint8Array([0x01, ModbusFunctionCode.PLC_SET_STATE]) + const crc = calculateCrc(frame) + const full = new Uint8Array(4) + full.set(frame, 0) + full[2] = (crc >>> 8) & 0xff + full[3] = crc & 0xff + autoRespond(full) + + const result = await client.setPlcState(PlcRuntimeState.RUNNING) + + expect(result.success).toBe(false) + expect(result.error).toContain('too short') + }) + + it('returns error on timeout', async () => { + await connectClient() + const result = await client.setPlcState(PlcRuntimeState.STOPPED) + expect(result.success).toBe(false) + expect(result.error).toContain('timeout') + }) + }) }) diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index ca57c6bc3..63645144f 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -99,8 +99,16 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa // Deliberately NOT applied to Build & Upload. Uploading is how a blank board stops // being blank, so it cannot require a connection — see `handleBuild`, where the // connection is consulted only to hand the serial port over to arduino-cli. - const plcControlBlocked = deviceConnectionStatus !== 'connected' - const plcControlBlockedReason = 'Connect to the target first' + // + // A target that does not implement run/stop at all is blocked for a DIFFERENT + // reason, and says so. `handlePlcControl` refuses such a target anyway, so + // without this the button looked live and the click did nothing at all — no + // command, no error, no log line. + const plcStateControlSupported = resolveTargetCapabilities(currentBoardInfo).plcStateControl + const plcControlBlocked = !plcStateControlSupported || deviceConnectionStatus !== 'connected' + const plcControlBlockedReason = plcStateControlSupported + ? 'Connect to the target first' + : 'This target does not support Start/Stop from the editor' // The emulator stopping is a session ending, and a debug session riding it ends // with it — which the drop handler below already does for every target. This diff --git a/src/frontend/hooks/__tests__/use-device-connect.test.ts b/src/frontend/hooks/__tests__/use-device-connect.test.ts index 33a5e2ef9..f72b09517 100644 --- a/src/frontend/hooks/__tests__/use-device-connect.test.ts +++ b/src/frontend/hooks/__tests__/use-device-connect.test.ts @@ -2,9 +2,20 @@ import { renderHook } from '@testing-library/react' // `mock*`-prefixed refs are hoisted into the jest.mock factories below. const mockOpenModal = jest.fn() -const mockSetDeviceConnectionStatus = jest.fn() const mockAddLog = jest.fn() +/** + * Writes through to `mockState`, like the real action does. The hook reads the + * live status back to decide whether a settled state still needs publishing, so a + * write-only spy would make that branch untestable. + */ +const mockSetDeviceConnectionStatus = jest.fn((status: string, port: string | null = null) => { + mockState.deviceConnection = { status, port } +}) + +/** The status the store ended up in — what the Connect button actually reads. */ +const currentStatus = (): string => (mockState.deviceConnection as { status: string }).status + const mockState: Record = { deviceDefinitions: { configuration: { deviceBoard: 'Test Board', communicationPort: 'COM5', vendorScreenData: {} } }, deviceConnection: { status: 'disconnected', port: null }, @@ -206,4 +217,66 @@ describe('useDeviceConnect', () => { expect(result.current.isConnecting).toBe(false) expect(result.current.status).toBe('connected') }) + + /** + * The Connect button is disabled while the status reads 'connecting', and + * Disconnect only fires when it reads 'connected'. So a status left at + * 'connecting' is a dead button with no way back short of reopening the project. + * Every path out of `connect()` must therefore leave a settled status — including + * the ones that never reach the main process, which is where the wedge was. + */ + describe('never leaves the button stuck on "connecting"', () => { + it('settles when the user cancels the address prompt and nothing else was tried', async () => { + // A DHCP-only target: no silent candidate at all, one channel awaiting input. + // Cancelling the prompt used to leave 'connecting' set forever, because + // device.connect() was never called and so nothing ever pushed a status. + mockResolveDeviceLinkWithUx + .mockImplementationOnce(() => Promise.resolve({ candidates: [], awaitingInput: [0] })) + .mockImplementationOnce(() => Promise.resolve(null)) + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + expect(mockConnect).not.toHaveBeenCalled() + expect(currentStatus()).toBe('disconnected') + // Nothing was attempted, so there is no failure to report either. + expect(mockOpenModal).not.toHaveBeenCalled() + }) + + it('settles when the prompted pass resolves no usable candidate', async () => { + mockResolveDeviceLinkWithUx + .mockImplementationOnce(() => Promise.resolve({ candidates: [], awaitingInput: [0] })) + .mockImplementationOnce(() => Promise.resolve({ candidates: [], awaitingInput: [] })) + + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + + expect(currentStatus()).toBe('disconnected') + }) + + it('settles after a failure dialog', async () => { + mockConnect.mockResolvedValue({ status: 'no-response' }) + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + expect(mockOpenModal.mock.calls[0][1]).toMatchObject({ title: 'No Response' }) + expect(currentStatus()).toBe('disconnected') + }) + + it('settles when the connect IPC call rejects outright', async () => { + mockConnect.mockRejectedValue(new Error('bridge is gone')) + const { result } = renderHook(() => useDeviceConnect(board)) + await expect(result.current.connect()).rejects.toThrow('bridge is gone') + expect(currentStatus()).toBe('disconnected') + }) + + it('leaves a successful connection alone for the main process to publish', async () => { + // The status push and this reply travel separate IPC channels, so settling on + // success too would risk overwriting 'connected' with a spurious flicker. + mockConnect.mockResolvedValue({ status: 'connected-with-firmware' }) + const { result } = renderHook(() => useDeviceConnect(board)) + await result.current.connect() + expect(currentStatus()).toBe('connecting') + expect(mockSetDeviceConnectionStatus).not.toHaveBeenCalledWith('disconnected', null) + }) + }) }) diff --git a/src/frontend/hooks/use-device-connect.ts b/src/frontend/hooks/use-device-connect.ts index 25c9cf53a..f6ba9e5af 100644 --- a/src/frontend/hooks/use-device-connect.ts +++ b/src/frontend/hooks/use-device-connect.ts @@ -54,61 +54,91 @@ export function useDeviceConnect(boardInfo: BoardInfo | undefined): UseDeviceCon const tried: string[] = [] setDeviceConnectionStatus('connecting', null) + // Declared out here so the `finally` can tell "we never got a connection" from + // "we did, and the main process has already published it". let result: { status: 'connected-with-firmware' | 'no-firmware' | 'no-response' | 'error'; error?: string } = { status: 'no-response', } - if (silent.candidates.length > 0) { - tried.push(...silent.candidates.map((candidate) => describeDebugEndpoint(candidate.config))) - result = await device.connect(silent.candidates.map((candidate) => candidate.config)) - } - // SECOND PASS: nothing silent worked, so now it is worth asking. Resolving - // only the deferred channels surfaces the address dialog, and a cancel here - // ends the attempt rather than looping. - if (result.status !== 'connected-with-firmware' && silent.awaitingInput.length > 0) { - const prompted = await resolveDeviceLinkWithUx(deviceBoard, boardInfo, { - onlyChannels: silent.awaitingInput, - }) - if (prompted && prompted.candidates.length > 0) { - tried.push(...prompted.candidates.map((candidate) => describeDebugEndpoint(candidate.config))) - result = await device.connect(prompted.candidates.map((candidate) => candidate.config)) + try { + if (silent.candidates.length > 0) { + tried.push(...silent.candidates.map((candidate) => describeDebugEndpoint(candidate.config))) + result = await device.connect(silent.candidates.map((candidate) => candidate.config)) } - } - const endpoints = tried.join(' or ') || 'this device' + // SECOND PASS: nothing silent worked, so now it is worth asking. Resolving + // only the deferred channels surfaces the address dialog, and a cancel here + // ends the attempt rather than looping. + if (result.status !== 'connected-with-firmware' && silent.awaitingInput.length > 0) { + const prompted = await resolveDeviceLinkWithUx(deviceBoard, boardInfo, { + onlyChannels: silent.awaitingInput, + }) + if (prompted && prompted.candidates.length > 0) { + tried.push(...prompted.candidates.map((candidate) => describeDebugEndpoint(candidate.config))) + result = await device.connect(prompted.candidates.map((candidate) => candidate.config)) + } else if (tried.length === 0) { + // The user declined to supply the address and there was nothing else to + // try, so nothing was attempted at all. Saying "could not reach the + // device" would be reporting a failure that never happened — they + // cancelled. The `finally` below clears the button. + return + } + } - if (result.status === 'no-response') { - openModal('debugger-message', { - type: 'error', - title: 'No Response', - message: `Could not reach the device on ${endpoints}. Check that it is powered and plugged in, and that the port or IP address is correct.`, - buttons: ['OK'], - onResponse: () => undefined, - }) - return - } + const endpoints = tried.join(' or ') || 'this device' - if (result.status === 'error') { - openModal('debugger-message', { - type: 'error', - title: 'Connection Error', - message: result.error ?? 'An unexpected error occurred while connecting to the device.', - buttons: ['OK'], - onResponse: () => undefined, - }) - return - } + if (result.status === 'no-response') { + openModal('debugger-message', { + type: 'error', + title: 'No Response', + message: `Could not reach the device on ${endpoints}. Check that it is powered and plugged in, and that the port or IP address is correct.`, + buttons: ['OK'], + onResponse: () => undefined, + }) + return + } + + if (result.status === 'error') { + openModal('debugger-message', { + type: 'error', + title: 'Connection Error', + message: result.error ?? 'An unexpected error occurred while connecting to the device.', + buttons: ['OK'], + onResponse: () => undefined, + }) + return + } - if (result.status === 'no-firmware') { - openModal('debugger-message', { - type: 'question', - title: 'No Firmware Detected', - message: `No OpenPLC firmware responded on ${endpoints}. Build & Upload the program to flash this device, then Connect again.`, - buttons: ['Build & Upload', 'Cancel'], - onResponse: (buttonIndex: number) => { - if (buttonIndex === 0) requestDeviceFlash() - }, - }) + if (result.status === 'no-firmware') { + openModal('debugger-message', { + type: 'question', + title: 'No Firmware Detected', + message: `No OpenPLC firmware responded on ${endpoints}. Build & Upload the program to flash this device, then Connect again.`, + buttons: ['Build & Upload', 'Cancel'], + onResponse: (buttonIndex: number) => { + if (buttonIndex === 0) requestDeviceFlash() + }, + }) + } + } finally { + // 'connecting' is set OPTIMISTICALLY above, and normally only the main process + // clears it — every settled state is pushed from there. But a path that + // returns without ever reaching `deviceSession.open()` leaves nothing to push: + // a cancelled address prompt, a config that built no usable candidate, or an + // IPC rejection. The button is disabled while 'connecting' and Disconnect only + // fires when 'connected', so a stuck 'connecting' is not recoverable from the + // UI at all — the user has to close and reopen the project. Settle it here. + // + // Only on a NON-success outcome. On success the main process has published + // 'connected', but that push and this invoke's reply travel separate IPC + // channels with no ordering guarantee between them, so settling here as well + // would risk a visible flicker for no reason. + if ( + result.status !== 'connected-with-firmware' && + useOpenPLCStore.getState().deviceConnection.status === 'connecting' + ) { + setDeviceConnectionStatus('disconnected', null) + } } }, [boardInfo, device, openModal, setDeviceConnectionStatus]) diff --git a/src/frontend/store/__tests__/device-slice.test.ts b/src/frontend/store/__tests__/device-slice.test.ts index 92ffed027..cbc0b7843 100644 --- a/src/frontend/store/__tests__/device-slice.test.ts +++ b/src/frontend/store/__tests__/device-slice.test.ts @@ -206,6 +206,41 @@ describe('createDeviceSlice', () => { }) }) + it('setDeviceConnectionStatus records both media when the manager reports them', () => { + // What `useDeviceConnectionMonitor` actually forwards. `debugTransport` is a + // separate fact from `transport`: the debug poll sizes its batches to the + // debug medium, and a v4 session (control over REST, debug over a WebSocket) + // has no control transport at all. + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connected', '192.168.0.9', null, 'websocket') + expect(store.getState().deviceConnection).toEqual({ + status: 'connected', + port: '192.168.0.9', + transport: null, + debugTransport: 'websocket', + }) + }) + + it('setDeviceConnectionStatus records a shared medium on both slots', () => { + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connected', '/dev/ttyACM0', 'rtu', 'rtu') + expect(store.getState().deviceConnection).toMatchObject({ transport: 'rtu', debugTransport: 'rtu' }) + }) + + it('setDeviceConnectionStatus leaves the media untouched when they are omitted', () => { + // A status-only update (the optimistic 'connecting') must not wipe what the + // manager last reported. + const store = makeStore() + store.getState().deviceActions.setDeviceConnectionStatus('connected', '/dev/ttyACM0', 'rtu', 'rtu') + store.getState().deviceActions.setDeviceConnectionStatus('connecting') + expect(store.getState().deviceConnection).toEqual({ + status: 'connecting', + port: '/dev/ttyACM0', + transport: 'rtu', + debugTransport: 'rtu', + }) + }) + it('clearDeviceConnection resets to disconnected/null', () => { const store = makeStore() store.getState().deviceActions.setDeviceConnectionStatus('connected', 'COM5') @@ -1329,6 +1364,33 @@ describe('createDeviceSlice', () => { }) }) + // ----------------------------------------------------------------------- + // setPlcSwitchPosition + // ----------------------------------------------------------------------- + describe('setPlcSwitchPosition', () => { + it('defaults to null — unknown, not "no gating"', () => { + // The start pre-check must be able to tell "the switch says RUN" from "this + // target has no switch / firmware too old to report one". Only 'stop' blocks + // a start; null must not, or a board without a switch is un-startable. + expect(makeStore().getState().runtimeConnection.switchPosition).toBeNull() + }) + + it('records the switch reading', () => { + const store = makeStore() + store.getState().deviceActions.setPlcSwitchPosition('stop') + expect(store.getState().runtimeConnection.switchPosition).toBe('stop') + store.getState().deviceActions.setPlcSwitchPosition('run') + expect(store.getState().runtimeConnection.switchPosition).toBe('run') + }) + + it('clears back to null on disconnect', () => { + const store = makeStore() + store.getState().deviceActions.setPlcSwitchPosition('stop') + store.getState().deviceActions.setPlcSwitchPosition(null) + expect(store.getState().runtimeConnection.switchPosition).toBeNull() + }) + }) + // ----------------------------------------------------------------------- // setSelectedDevice // ----------------------------------------------------------------------- diff --git a/src/frontend/utils/__tests__/device-connect-events.test.ts b/src/frontend/utils/__tests__/device-connect-events.test.ts new file mode 100644 index 000000000..c3f9f17c3 --- /dev/null +++ b/src/frontend/utils/__tests__/device-connect-events.test.ts @@ -0,0 +1,79 @@ +/** + * The decoupled bridge between the device screen's "No Firmware Detected" dialog + * and Build & Upload, which live in different component trees. + * + * Worth pinning despite being three lines: the event NAME is the contract between + * the two sides, and a typo on either would be silent — the dialog's "Build & + * Upload" button would simply do nothing, with no error to trace. These tests only + * ever go through the public functions, so they cannot drift from that name. + */ +import { onDeviceFlashRequest, requestDeviceFlash } from '../device-connect-events' + +describe('device flash-request bridge', () => { + it('delivers a request to a subscriber', () => { + const handler = jest.fn() + const unsubscribe = onDeviceFlashRequest(handler) + + requestDeviceFlash() + + expect(handler).toHaveBeenCalledTimes(1) + unsubscribe() + }) + + it('delivers synchronously, so the dialog handler can rely on it having run', () => { + const order: string[] = [] + const unsubscribe = onDeviceFlashRequest(() => order.push('handler')) + + requestDeviceFlash() + order.push('after dispatch') + + expect(order).toEqual(['handler', 'after dispatch']) + unsubscribe() + }) + + it('delivers to every subscriber', () => { + const first = jest.fn() + const second = jest.fn() + const unsubFirst = onDeviceFlashRequest(first) + const unsubSecond = onDeviceFlashRequest(second) + + requestDeviceFlash() + + expect(first).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalledTimes(1) + unsubFirst() + unsubSecond() + }) + + it('stops delivering once unsubscribed', () => { + // The returned function is used as a React effect cleanup, so a listener that + // survived it would fire once per remount — the user pressing "Build & Upload" + // once would trigger several builds. + const handler = jest.fn() + const unsubscribe = onDeviceFlashRequest(handler) + + unsubscribe() + requestDeviceFlash() + + expect(handler).not.toHaveBeenCalled() + }) + + it('unsubscribing one subscriber leaves the others listening', () => { + const kept = jest.fn() + const dropped = jest.fn() + const unsubKept = onDeviceFlashRequest(kept) + const unsubDropped = onDeviceFlashRequest(dropped) + + unsubDropped() + requestDeviceFlash() + + expect(kept).toHaveBeenCalledTimes(1) + expect(dropped).not.toHaveBeenCalled() + unsubKept() + }) + + it('is safe to call with nobody listening', () => { + // The device screen can be closed between the dialog opening and the response. + expect(() => requestDeviceFlash()).not.toThrow() + }) +}) diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 09a3ef041..a0d775095 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -90,6 +90,19 @@ function matchesMd5(targetMd5: string, expectedMd5: string): boolean { return targetMd5.toLowerCase() === expectedMd5.toLowerCase() } +/** + * What `debugger:verify-md5` answers. Named so the success and unavailable paths + * are typed against ONE shape — inferred separately, the success branch narrowed + * `success` to the literal `true` and the two stopped being assignable. + */ +interface Md5VerifyReply { + success: boolean + match?: boolean + targetMd5?: string + targetEndian?: 'le' | 'be' + error?: string +} + class MainProcessBridge implements MainIpcModule { ipcMain mainWindow @@ -1682,22 +1695,27 @@ class MainProcessBridge implements MainIpcModule { * second client, and nothing to reconnect afterwards. Runtime v4 reads it over * its own WebSocket, which is a different protocol to a different target. */ - handleDebuggerVerifyMd5 = async ( - _event: IpcMainInvokeEvent, - expectedMd5: string, - ): Promise<{ - success: boolean - match?: boolean - targetMd5?: string - targetEndian?: 'le' | 'be' - error?: string - }> => { + handleDebuggerVerifyMd5 = async (_event: IpcMainInvokeEvent, expectedMd5: string): Promise => { try { - const channel = await this.requireDebug('verify md5') - if ('error' in channel) return { success: false, error: channel.error } - - const probe = await channel.client.getMd5Hash() - return { success: true, match: matchesMd5(probe.md5, expectedMd5), ...probe } + return await this.withDebugChannel( + 'verify md5', + async (client) => { + const probe = await client.getMd5Hash() + // `targetMd5` spelled out rather than spread: `Md5ProbeResult` names the + // hash `md5`, so `...probe` silently left the declared `targetMd5` + // undefined — and TypeScript does not apply excess-property checks to a + // spread, so nothing caught it. The mismatch report then read + // "MD5 mismatch. Target: undefined", losing the one value that tells the + // user which program is actually on the board. + return { + success: true, + match: matchesMd5(probe.md5, expectedMd5), + targetMd5: probe.md5, + targetEndian: probe.targetEndian, + } + }, + (reason) => ({ success: false, error: reason.error }), + ) } catch (error) { return { success: false, @@ -1806,19 +1824,22 @@ class MainProcessBridge implements MainIpcModule { // connection dropped, the manager is already reopening it (or has reported it // lost), and `needsReconnect` tells the renderer to stop the session rather // than race it for the medium. - const link = await this.requireDebug('read variables') - if ('error' in link) return { success: false, error: link.error, needsReconnect: true } - try { - const result = await link.client.getVariablesList(variableIndexes) - if (result.success && result.data) { - // The debug poll is the busiest thing on the link; telling the session - // about it is what stops the liveness read from queueing behind this - // traffic and timing out on a link that is plainly working. - this.deviceSession.noteTraffic() - return { success: true, tick: result.tick, lastIndex: result.lastIndex, data: Array.from(result.data) } - } - return { success: false, error: result.error } + return await this.withDebugChannel( + 'read variables', + async (client) => { + const result = await client.getVariablesList(variableIndexes) + if (result.success && result.data) { + // The debug poll is the busiest thing on the link; telling the session + // about it is what stops the liveness read from queueing behind this + // traffic and timing out on a link that is plainly working. + this.deviceSession.noteTraffic() + return { success: true, tick: result.tick, lastIndex: result.lastIndex, data: Array.from(result.data) } + } + return { success: false, error: result.error } + }, + (reason) => ({ success: false, error: reason.error, needsReconnect: true }), + ) } catch (error) { return { success: false, error: getErrorMessage(error), needsReconnect: true } } @@ -2227,6 +2248,11 @@ class MainProcessBridge implements MainIpcModule { * The DEBUG channel, opening it if this session's debug medium is one of its own. * Every debug caller passes a distinct `what`, which doubles as the holder name — * so two callers can hold it at once without either closing it on the other. + * + * A holder acquired here MUST be released, or the channel can never close. Only + * the debug session itself is a long-lived holder (acquired by `debugger:connect`, + * released by `debugger:disconnect`); every per-command caller goes through + * `withDebugChannel`, which releases in a `finally`. */ private async requireDebug(what: string): Promise<{ client: DeviceDebugChannel } | ChannelUnavailable> { const acquired = await this.deviceSession.acquireDebugChannel(what) @@ -2239,6 +2265,37 @@ class MainProcessBridge implements MainIpcModule { return acquired } + /** + * Run one command over the DEBUG channel, holding it only for the duration. + * + * The holder set is a reference count, and a per-command caller is not a holder + * of the channel's LIFETIME — it just needs the channel to exist while it runs. + * Registering those callers permanently is what kept a Runtime v3/v4 debug channel + * open after the debug session ended: `read variables` is acquired on every poll + * tick, so once one had run, `releaseDebugChannel('debug session')` always found + * the set non-empty and skipped the close. The user stopped debugging and the + * editor held an authenticated debug channel to their PLC until they logged out. + * + * Releasing here is safe for a BAREMETAL target, where control and debug are the + * same channel: `releaseDebugChannel` returns early on `debugCandidate === null` + * before touching any client, so it can never disconnect the device out from + * under run/stop or the status poll. Only a session whose debug medium is its + * own — v3's second Modbus TCP connection, v4's WebSocket — is ever closed. + */ + private async withDebugChannel( + what: string, + run: (client: DeviceDebugChannel) => Promise, + onUnavailable: (reason: ChannelUnavailable) => T, + ): Promise { + const acquired = await this.requireDebug(what) + if ('error' in acquired) return onUnavailable(acquired) + try { + return await run(acquired.client) + } finally { + this.deviceSession.releaseDebugChannel(what) + } + } + /** * Which channel served which command — logged ONCE per distinct combination. * @@ -2318,6 +2375,11 @@ class MainProcessBridge implements MainIpcModule { const linkCandidates = this.toDeviceLinkCandidates(candidates) if (linkCandidates.length === 0) { + // Publish a settled state before returning: this path never reaches + // `deviceSession.open()`, so nothing else will, and the renderer set + // 'connecting' the moment the user clicked. Left unsaid, its Connect button + // stays disabled for the rest of the project's life. + this.emitDeviceLinkStatus({ status: 'disconnected' }) return { status: 'error', error: 'No usable serial or Modbus TCP connection was configured for this device.' } } @@ -2351,19 +2413,20 @@ class MainProcessBridge implements MainIpcModule { ): Promise<{ success: boolean; error?: string }> => { const buffer = valueBuffer ? Buffer.from(valueBuffer) : undefined - const link = await this.requireDebug('write variable') - if ('error' in link) { - return { success: false, error: link.error } - } - try { - const result = await link.client.setVariable(variableIndex, force, buffer) - // Forcing values is device traffic too: it queues on the same link and - // proves the same thing a read does. Without this, holding a force while - // the poll is due lets the liveness read wait behind it and time out. - if (result.success) this.deviceSession.noteTraffic() - logger.info('[IPC Handler] Modbus setVariable result: ' + JSON.stringify(result)) - return result + return await this.withDebugChannel( + 'write variable', + async (client) => { + const result = await client.setVariable(variableIndex, force, buffer) + // Forcing values is device traffic too: it queues on the same link and + // proves the same thing a read does. Without this, holding a force while + // the poll is due lets the liveness read wait behind it and time out. + if (result.success) this.deviceSession.noteTraffic() + logger.info('[IPC Handler] Modbus setVariable result: ' + JSON.stringify(result)) + return result + }, + (reason) => ({ success: false, error: reason.error }), + ) } catch (error) { logger.error('[IPC Handler] Modbus setVariable error: ' + getErrorMessage(error)) return { success: false, error: getErrorMessage(error) } diff --git a/src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts b/src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts index bbbece386..dca64d16d 100644 --- a/src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/debugger-adapter.test.ts @@ -226,6 +226,33 @@ describe('setPlcState', () => { void adapter.setPlcState?.('RUNNING') expect(window.bridge.debuggerPlcControl).toHaveBeenCalledWith('run') }) + + it('returns the acknowledgement the main process produced', async () => { + // `refusedBySwitch` / `unsupported` drive two distinct dialogs upstream, so the + // adapter must pass the whole shape through rather than reducing it to a boolean. + ;(window.bridge.debuggerPlcControl as jest.Mock).mockResolvedValue({ + success: false, + refusedBySwitch: true, + state: 0, + switchPosition: 0, + }) + + await expect(adapter.setPlcState?.('RUNNING')).resolves.toMatchObject({ + success: false, + refusedBySwitch: true, + }) + }) + + it('reports a rejected IPC call as a failed command instead of throwing', async () => { + // Run/stop is driven straight from a click handler. An escaping rejection would + // surface as an unhandled promise and the button would look like it did nothing. + ;(window.bridge.debuggerPlcControl as jest.Mock).mockRejectedValue(new Error('bridge is gone')) + + await expect(adapter.setPlcState?.('STOPPED')).resolves.toEqual({ + success: false, + error: 'bridge is gone', + }) + }) }) // --------------------------------------------------------------------------- diff --git a/src/middleware/adapters/editor/__tests__/device-adapter.test.ts b/src/middleware/adapters/editor/__tests__/device-adapter.test.ts index 16bf956b4..def534176 100644 --- a/src/middleware/adapters/editor/__tests__/device-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/device-adapter.test.ts @@ -29,6 +29,11 @@ beforeEach(() => { deviceConnect: jest.fn().mockResolvedValue({ status: 'connected-with-firmware' }), deviceDisconnect: jest.fn().mockResolvedValue({ success: true }), onDeviceConnectionStatus: jest.fn().mockReturnValue(() => undefined), + openRuntimeSession: jest.fn().mockResolvedValue({ success: true }), + closeRuntimeSession: jest.fn().mockResolvedValue({ success: true }), + deviceReleaseSerialPort: jest.fn().mockResolvedValue({ released: true }), + onDeviceLinkLog: jest.fn().mockReturnValue(() => undefined), + onDevicePlcState: jest.fn().mockReturnValue(() => undefined), } as unknown as typeof window.bridge }) @@ -98,4 +103,52 @@ describe('createEditorDeviceAdapter', () => { expect(window.bridge.onDeviceConnectionStatus).toHaveBeenCalledWith(cb) expect(typeof unsub).toBe('function') }) + + /** + * The session and handoff members. All pure IPC delegation, but each one is a + * name pairing between the port and the preload bridge — the kind of mismatch + * that type-checks on neither side once `window.bridge` is cast, and then fails + * only at runtime as "not a function" in the middle of a connect. + */ + it('delegates openRuntimeSession with the address and debug channel', async () => { + const params = { + address: '192.168.0.9', + debug: { connectionType: 'websocket' as const, connectionParams: { ipAddress: '192.168.0.9' } }, + } + const result = await adapter.openRuntimeSession?.(params) + expect(window.bridge.openRuntimeSession).toHaveBeenCalledWith(params) + expect(result).toMatchObject({ success: true }) + }) + + it('delegates closeRuntimeSession', async () => { + const result = await adapter.closeRuntimeSession?.() + expect(window.bridge.closeRuntimeSession).toHaveBeenCalledTimes(1) + expect(result).toMatchObject({ success: true }) + }) + + it('unwraps releaseSerialPort to the bare `released` flag', async () => { + // The caller decides whether to reconnect after an upload from this boolean, + // so the unwrapping is the part worth pinning — not the passthrough. + await expect(adapter.releaseSerialPort('COM5')).resolves.toBe(true) + expect(window.bridge.deviceReleaseSerialPort).toHaveBeenCalledWith('COM5') + }) + + it('reports releaseSerialPort false when nothing was held on that port', async () => { + ;(window.bridge.deviceReleaseSerialPort as jest.Mock).mockResolvedValue({ released: false }) + await expect(adapter.releaseSerialPort(null)).resolves.toBe(false) + }) + + it('delegates onLinkLog subscription and returns its unsubscribe', () => { + const cb = jest.fn() + const unsub = adapter.onLinkLog?.(cb) + expect(window.bridge.onDeviceLinkLog).toHaveBeenCalledWith(cb) + expect(typeof unsub).toBe('function') + }) + + it('delegates onPlcState subscription and returns its unsubscribe', () => { + const cb = jest.fn() + const unsub = adapter.onPlcState?.(cb) + expect(window.bridge.onDevicePlcState).toHaveBeenCalledWith(cb) + expect(typeof unsub).toBe('function') + }) }) diff --git a/src/middleware/shared/utils/target-capabilities/presets.ts b/src/middleware/shared/utils/target-capabilities/presets.ts index 80f1bd18e..9b97fb113 100644 --- a/src/middleware/shared/utils/target-capabilities/presets.ts +++ b/src/middleware/shared/utils/target-capabilities/presets.ts @@ -47,7 +47,12 @@ export const RUNTIME_V3_CAPABILITIES: TargetCapabilities = { arduinoApiCompletions: false, hasRuntimeStats: false, isInProcessSimulator: false, - plcStateControl: false, + // v3 exposes the SAME run/stop REST API as v4 (`/api/start-plc`, + // `/api/stop-plc`, JWT-authenticated) — only the debug channel differs + // (v3: Modbus TCP, v4: WebSocket). The main process already routes the + // command over REST for both, so the only thing that ever stopped v3 + // was this flag. + plcStateControl: true, directUsbUpload: false, } diff --git a/src/middleware/shared/utils/target-capabilities/types.ts b/src/middleware/shared/utils/target-capabilities/types.ts index 1572c367f..4671405bd 100644 --- a/src/middleware/shared/utils/target-capabilities/types.ts +++ b/src/middleware/shared/utils/target-capabilities/types.ts @@ -94,10 +94,16 @@ export interface TargetCapabilities { isInProcessSimulator: boolean /** Target implements the runtime run/stop state machine, so the - * Start/Stop control is meaningful. Runtime v4 drives it over REST; - * arduino-cli targets drive it over the debugger transport (Modbus - * FC 0x49). Runtime v3 has its own web UI and is excluded; the - * Simulator keeps its dedicated start/stop path. */ + * Start/Stop control is meaningful. Runtime v3 AND v4 drive it over + * the same REST API (`/api/start-plc`, `/api/stop-plc`, both + * JWT-authenticated); arduino-cli targets drive it over the device + * connection (Modbus FC 0x4b). Only the Simulator is excluded, and + * only because it keeps its dedicated start/stop path. + * + * Runtime v3 having its own web UI is NOT a reason to exclude it: the + * editor's REST access is unaffected by that, the editor has shipped + * this button working against v3, and gating it off here made Start / + * Stop a silent no-op on a target where it had always worked. */ plcStateControl: boolean /** Upload happens over a local connection (USB / loopback) and From 58bdcdaff91d50d70218b54dae87957fa849e42a Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Wed, 5 Aug 2026 17:42:45 -0400 Subject: [PATCH 19/19] refactor(debug): one published medium drives the debug poll on both platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The poller had three sources of truth for the same fact, and the browser's two ways of reaching a runtime had no name at all. What was wrong - `workspace.debugConnectionType` — a copy of the spec's channel kind, set at session start — drove batch size. - `session.debugTransport` ('http' | 'webrtc') in the WebRTC slice drove the cadence, behind a `!capabilities.isNativeApplication` platform check. - `backend/shared/debug/types.ts` declared a THIRD `DebugConnectionType` ('webrtc' | 'http' | 'simulator'), same name as the ports one but different members, stored by the debug bridge and read by nothing. - `deviceConnection.debugTransport` then added a fourth. They could and did disagree: a session whose medium was not yet known fell through to `'simulator'`, which is both the smallest batch (19 vs 500) and the fastest cadence (50ms) — and, because the RTU branch short-circuits first, it also defeated the deliberate 1000ms relay throttle. What replaces it `DebugMedium` names every medium a live session can ride, including the two the browser distinguishes and no spec does: `webrtc` (data channel to the orchestrator agent) and `http-relay` (browser -> Autonomy Edge -> agent websocket -> runtime). `DEBUG_MEDIUM_PROFILE` maps each to a batch size and a cadence, because those are two independent physical limits: batch is the frame budget at the far end (identical for websocket / webrtc / http-relay — all three terminate at the same debug socket on the runtime), cadence is link latency (200ms direct or peer-to-peer, 1000ms through two relays). The poller now reads one field, `deviceConnection.debugTransport`, published by the connection manager — the main process on the editor, the WebRTC lifecycle manager in the browser. It reads it LIVE, so a data channel that drops to the relay re-paces mid-session. The `isNativeApplication` branch is gone; nothing in the poller asks which platform it is on any more. Removed, not deprecated: `workspace.debugConnectionType` + its action, `session.debugTransport` (now `debugChannelOpen`, a boolean about the WebRTC channel rather than a second medium vocabulary), the vestigial `DebugConnectionType` in backend/shared/debug, and the debug bridge's unread transport label. `capabilities.debugHttpFallbackPollIntervalMs` is now `debugRelayPollIntervalMs`; its env key keeps the old spelling, being a deployment contract in other people's .env files. Co-Authored-By: Claude Opus 5 (1M context) --- .../editor/hardware/device-session-manager.ts | 9 +- src/backend/shared/debug/index.ts | 2 +- src/backend/shared/debug/types.ts | 10 +- .../workspace-activity-bar/default.tsx | 9 - .../__tests__/debug-medium-profile.test.ts | 77 ++++++++ src/frontend/hooks/useDebugPolling.ts | 167 ++++++++---------- src/frontend/hooks/useDebugSession.ts | 17 +- .../store/__tests__/webrtc-slice.test.ts | 26 +-- .../store/__tests__/workspace-slice.test.ts | 17 -- src/frontend/store/slices/device/types.ts | 17 +- src/frontend/store/slices/webrtc/index.ts | 9 +- src/frontend/store/slices/webrtc/slice.ts | 8 +- src/frontend/store/slices/webrtc/types.ts | 15 +- src/frontend/store/slices/workspace/slice.ts | 10 -- src/frontend/store/slices/workspace/types.ts | 6 - src/middleware/shared/ports/device-port.ts | 13 +- .../shared/ports/platform-capabilities.ts | 6 +- src/middleware/shared/ports/types.ts | 32 ++++ 18 files changed, 249 insertions(+), 201 deletions(-) create mode 100644 src/frontend/hooks/__tests__/debug-medium-profile.test.ts diff --git a/src/backend/editor/hardware/device-session-manager.ts b/src/backend/editor/hardware/device-session-manager.ts index 6c27f658e..eb2662ad5 100644 --- a/src/backend/editor/hardware/device-session-manager.ts +++ b/src/backend/editor/hardware/device-session-manager.ts @@ -39,10 +39,13 @@ * main process implements as its existing classify + license recover; * - the counting for down / back / lost -> `DeviceLinkPolicy`. */ +import type { DebugMedium, DeviceLinkTransport } from '../../../middleware/shared/ports/types' import type { DeviceDebugChannel, DeviceModbusTransport } from '../../shared/debug/types' import { DeviceLinkPolicy } from './device-link-policy' -export type DeviceLinkTransport = 'rtu' | 'tcp' | 'simulator' +// Re-exported so callers in this layer keep one import site; the definition is +// shared with the renderer, which mirrors these media into the store. +export type { DebugMedium, DeviceLinkTransport } /** * How to open a DEBUG channel that is not the control channel. Simpler than a @@ -56,7 +59,7 @@ export interface DeviceDebugCandidate { * per round trip, Modbus TCP 60, RTU 19), and a poller that has to GUESS the * medium either wastes round trips or overruns a frame. */ - transport: DeviceLinkTransport | 'websocket' + transport: DebugMedium descriptor: string create: () => DeviceDebugChannel } @@ -111,7 +114,7 @@ export interface DeviceLinkStatus { * what "the connection dropped" means, the debug medium decides the poll's frame * budget. */ - debugTransport?: DeviceLinkTransport | 'websocket' + debugTransport?: DebugMedium descriptor?: string /** * Set only when a link that WAS up died and could not be recovered. The one diff --git a/src/backend/shared/debug/index.ts b/src/backend/shared/debug/index.ts index 8cc69ef57..3c4dfe443 100644 --- a/src/backend/shared/debug/index.ts +++ b/src/backend/shared/debug/index.ts @@ -1,2 +1,2 @@ export { ModbusRtuTransport } from './modbus-rtu-transport' -export type { DebugConnectionType, DebugSetResult, DebugTransport, DebugTransportResult } from './types' +export type { DebugSetResult, DebugTransport, DebugTransportResult } from './types' diff --git a/src/backend/shared/debug/types.ts b/src/backend/shared/debug/types.ts index 78e934069..1674ed6b9 100644 --- a/src/backend/shared/debug/types.ts +++ b/src/backend/shared/debug/types.ts @@ -7,11 +7,15 @@ import type { PlcRuntimeState } from '../simulator/types' * Mirrors the implicit interface from openplc-editor where ModbusTcpClient, * ModbusRtuClient, and WebSocketDebugClient all implement the same methods. * - * openplc-web transports: ModbusRtuTransport (simulator), ModbusDataChannelTransport (WebRTC), HttpTransport. + * openplc-web transports: ModbusRtuTransport (simulator), ModbusDataChannelTransport + * (a WebRTC data channel, falling back to the Autonomy Edge relay per request). + * + * Which medium a session ends up on is NOT named here: the connection manager + * publishes it as a `DebugMedium` (middleware/shared/ports/types), which is the one + * vocabulary the debug poller reads. A second, near-identical union living here is + * how the poller came to have two disagreeing sources for the same fact. */ -export type DebugConnectionType = 'webrtc' | 'http' | 'simulator' - export interface DebugTransportResult { success: boolean tick?: number diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index 63645144f..f50a79eac 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -708,15 +708,6 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa if (verifyResult.match) { consoleActions.addLog({ id: crypto.randomUUID(), level: 'info', message: 'MD5 verified. Starting debugger...' }) - // The debug poll sizes its batches to the frame budget, so it needs the - // medium the DEBUG channel rides — published by the manager and mirrored in - // the store. Reading the control medium instead left a v4 session (control - // over REST, debug over a WebSocket) with no medium at all, and it silently - // polled with TCP-sized batches: 60 variables per round trip instead of 500. - const activeTransport = useOpenPLCStore.getState().deviceConnection.debugTransport - if (activeTransport) { - useOpenPLCStore.getState().workspaceActions.setDebugConnectionType(activeTransport) - } // Persist the target's byte order — detected from the MD5 // response trailer in the runtime — so the swap layer at the // read / write boundaries flips on BE targets. Default to diff --git a/src/frontend/hooks/__tests__/debug-medium-profile.test.ts b/src/frontend/hooks/__tests__/debug-medium-profile.test.ts new file mode 100644 index 000000000..cd3013ba3 --- /dev/null +++ b/src/frontend/hooks/__tests__/debug-medium-profile.test.ts @@ -0,0 +1,77 @@ +/** + * How the debug poll is paced and sized, per medium. + * + * This table is the whole reason the poller no longer asks which platform it is + * running on. It replaced two independent sources — a copy of the spec's channel + * kind (for batch size) and a WebRTC-slice flag behind an `isNativeApplication` + * check (for cadence) — which could and did disagree. + * + * The invariants below are the ones that were violated in practice, so they are + * asserted as properties rather than as a snapshot of the numbers. + */ +import type { DebugMedium } from '@root/middleware/shared/ports/types' + +import { DEBUG_MEDIUM_PROFILE, debugProfileFor } from '../useDebugPolling' + +/** Every medium the type admits. A new one must be added here deliberately. */ +const ALL_MEDIA: DebugMedium[] = ['rtu', 'simulator', 'tcp', 'websocket', 'webrtc', 'http-relay'] + +describe('DEBUG_MEDIUM_PROFILE', () => { + it('covers every medium, with no extras', () => { + // A medium with no row would fall through to `undefined` and crash the poller + // on `profile.batchSize`. + expect(Object.keys(DEBUG_MEDIUM_PROFILE).sort()).toEqual([...ALL_MEDIA].sort()) + }) + + it.each(ALL_MEDIA)('%s has a usable batch size and cadence', (medium) => { + const { batchSize, pollIntervalMs } = DEBUG_MEDIUM_PROFILE[medium] + expect(batchSize).toBeGreaterThan(1) + expect(pollIntervalMs).toBeGreaterThan(0) + }) + + it('sizes serial-framed media to one USB-CDC packet', () => { + // 6 + 3·19 = 63 ≤ 64. A 20th variable splits the request across two packets, + // which older serial framers drop. + for (const medium of ['rtu', 'simulator'] as const) { + expect(DEBUG_MEDIUM_PROFILE[medium].batchSize).toBe(19) + expect(6 + 3 * DEBUG_MEDIUM_PROFILE[medium].batchSize).toBeLessThanOrEqual(64) + } + }) + + it('gives every runtime medium the same batch size', () => { + // websocket / webrtc / http-relay all terminate at the SAME debug socket on the + // runtime, so they share its frame budget. Only the hops in front of it differ. + const runtimeMedia = ['websocket', 'webrtc', 'http-relay'] as const + const sizes = new Set(runtimeMedia.map((m) => DEBUG_MEDIUM_PROFILE[m].batchSize)) + expect(sizes.size).toBe(1) + expect(DEBUG_MEDIUM_PROFILE.websocket.batchSize).toBe(500) + }) + + it('backs the relay cadence off well below the direct media', () => { + // The regression this guards: a v4 web session fell through to the simulator + // row, polling the Edge relay every 50ms instead of every 1000ms. + expect(DEBUG_MEDIUM_PROFILE['http-relay'].pollIntervalMs).toBeGreaterThan( + DEBUG_MEDIUM_PROFILE.webrtc.pollIntervalMs, + ) + expect(DEBUG_MEDIUM_PROFILE['http-relay'].pollIntervalMs).toBe(1000) + }) + + it('paces a peer-to-peer data channel like any other direct link', () => { + // WebRTC reaches the agent directly, so it is not the relay's problem. + expect(DEBUG_MEDIUM_PROFILE.webrtc.pollIntervalMs).toBe(DEBUG_MEDIUM_PROFILE.websocket.pollIntervalMs) + expect(DEBUG_MEDIUM_PROFILE.webrtc.pollIntervalMs).toBe(200) + }) +}) + +describe('debugProfileFor', () => { + it.each(ALL_MEDIA)('returns the %s row', (medium) => { + expect(debugProfileFor(medium)).toBe(DEBUG_MEDIUM_PROFILE[medium]) + }) + + it('falls back to tcp when the manager has published no medium yet', () => { + // Middle of the range, and what the poller defaulted to before the media were + // named. Deliberately NOT the simulator row, which is the fastest cadence and + // the smallest batch — the worst possible guess for an unknown remote link. + expect(debugProfileFor(null)).toBe(DEBUG_MEDIUM_PROFILE.tcp) + }) +}) diff --git a/src/frontend/hooks/useDebugPolling.ts b/src/frontend/hooks/useDebugPolling.ts index 7805f45cf..c5d699fd0 100644 --- a/src/frontend/hooks/useDebugPolling.ts +++ b/src/frontend/hooks/useDebugPolling.ts @@ -17,71 +17,69 @@ * - Diagram/source scan results are cached per {pouName, language, fbContext} * since the editor is read-only during debug * - * Polling intervals: - * - Modbus RTU / simulator: 50ms (no network; keep the UI snappy) - * - Web HTTP fallback: platform-capability-driven, 1000ms by default - * (WebRTC failed; each poll is a slow - * orchestrator round-trip, so back off) - * - Everything else: 200ms (general purpose — TCP / WebSocket / - * web WebRTC data channel) + * Batch size and poll interval both come from the session's medium — see + * `DEBUG_MEDIUM_PROFILE`. */ import { useCallback, useEffect, useRef } from 'react' -import type { DebugConnectionType, DebugTreeNode } from '../../middleware/shared/ports/types' +import type { DebugMedium, DebugTreeNode } from '../../middleware/shared/ports/types' import { useCapabilities, useDebugger } from '../../middleware/shared/providers' import { openPLCStoreBase, useOpenPLCStore } from '../store' import { buildActiveIndexSet } from '../utils/debug-polling-filter' import { applySwapToVariableBytes } from '../utils/endian' import { getTypeSizeByName, parseValueByTypeName } from '../utils/variable-sizes' -/** Polling interval for transports with serial framing (RTU / simulator). */ -const RTU_POLL_INTERVAL_MS = 50 -/** Polling interval for higher-bandwidth transports (TCP / WebSocket). */ -const DEFAULT_POLL_INTERVAL_MS = 200 - -// Batch size is transport-dependent. The wire request packs 3 bytes per -// variable (arr:u8 + elem:u16); the response packs raw type-sized values -// after a small header. The right ceiling is set by the transport's -// frame budget and the runtime's MAX_DEBUG_FRAME — never the target -// board, since the same board can run over RTU or TCP depending on the -// user's communication preferences. -// -// Modbus RTU : capped at 19 so the REQUEST stays ≤63 bytes and -// fits in a single 64-byte USB-CDC packet. A 20-var -// request is 6 + 3·20 = 66 bytes, which a USB-CDC -// target (e.g. SAMD21 / P1AM-100) receives split -// across two USB packets; older firmware whose serial -// framer can't reassemble a multi-packet request then -// drops it. Newer firmware (length-aware handle_serial) -// handles any size, but 19 keeps us compatible with -// field devices on both. 19·3 + 6 = 63 ≤ 64. -// Modbus TCP : Arduino sketch's MAX_MB_FRAME caps it; 60 is -// well within the headroom. -// WebSocket (Runtime v4) : Linux runtime's MAX_DEBUG_FRAME=4096; ~500 -// vars fits comfortably with room for value -// bytes. Anything bigger is unusual and the -// ERROR_OUT_OF_MEMORY fallback halves us back -// down to a safe size. -// Simulator : virtual serial port mirrors the RTU framing, -// so it shares the RTU ceiling. -const RTU_BATCH_SIZE = 19 -const TCP_BATCH_SIZE = 60 -const WEBSOCKET_BATCH_SIZE = 500 +/** + * How to pace and size the debug poll, per medium. + * + * These are two INDEPENDENT physical limits, which is why they live in one table + * rather than being derived from each other: + * + * `batchSize` — the frame budget at the far end. The request packs 3 bytes per + * variable (arr:u8 + elem:u16) and the response packs raw type-sized values after + * a small header. It is a property of the TARGET, never of the board the user + * picked, since the same board can be reached over RTU or TCP. + * rtu / simulator : 19, so the request stays ≤63 bytes and fits one 64-byte + * USB-CDC packet (6 + 3·19 = 63). A 20-variable request is 66 + * bytes, which a SAMD21 / P1AM-100 receives split across two + * packets — older firmware whose serial framer cannot + * reassemble then drops it. The simulator's virtual serial + * port mirrors the same framing. + * tcp : the Arduino sketch's MAX_MB_FRAME caps it; 60 has headroom. + * websocket / : the Linux runtime's MAX_DEBUG_FRAME=4096 — ~500 variables + * webrtc / with room for value bytes. All three reach the SAME debug + * http-relay socket on the runtime, so they share its budget; only the + * number of hops in front of it differs. + * + * `pollIntervalMs` — round-trip latency of the link. + * rtu / simulator : 50ms, no network in the way; keep the UI responsive. + * tcp / websocket : 200ms, one network hop. + * webrtc : 200ms, peer-to-peer to the agent — as direct as it gets. + * http-relay : 1000ms. Every poll is browser -> Edge -> agent websocket -> + * runtime and back. Polling this at the direct rate buries the + * relay in requests for data that cannot arrive any faster. + * Overridable per deployment via + * `capabilities.debugRelayPollIntervalMs`. + * + * A medium the caller has not published yet reads as `tcp` — the middle of the + * range, and what this defaulted to before the media were named. + */ +export const DEBUG_MEDIUM_PROFILE: Record = { + rtu: { batchSize: 19, pollIntervalMs: 50 }, + simulator: { batchSize: 19, pollIntervalMs: 50 }, + tcp: { batchSize: 60, pollIntervalMs: 200 }, + websocket: { batchSize: 500, pollIntervalMs: 200 }, + webrtc: { batchSize: 500, pollIntervalMs: 200 }, + 'http-relay': { batchSize: 500, pollIntervalMs: 1000 }, +} + +const DEFAULT_MEDIUM: DebugMedium = 'tcp' const MIN_BATCH_SIZE = 2 -function batchSizeForTransport(transport: DebugConnectionType | null): number { - switch (transport) { - case 'websocket': - return WEBSOCKET_BATCH_SIZE - case 'rtu': - case 'simulator': - return RTU_BATCH_SIZE - case 'tcp': - case null: - default: - return TCP_BATCH_SIZE - } +/** The profile for a medium, tolerating one not yet published. */ +export function debugProfileFor(medium: DebugMedium | null): { batchSize: number; pollIntervalMs: number } { + return DEBUG_MEDIUM_PROFILE[medium ?? DEFAULT_MEDIUM] } interface LeafMeta { @@ -136,13 +134,6 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void const capabilities = useCapabilities() const isDebuggerVisible = useOpenPLCStore((state) => state.workspace.isDebuggerVisible) const { workspaceActions, consoleActions } = useOpenPLCStore() - // Web-only: which transport the debug session is actually running over. - // 'http' means WebRTC is unavailable and every poll is a slow - // orchestrator round-trip — so we back the cadence off (see below). - // On the desktop editor this is the unused 'http' default; the - // `!isNativeApplication` guard keeps the editor's real TCP/WebSocket - // transports on the standard 200ms regardless. - const sessionDebugTransport = useOpenPLCStore((state) => state.session.debugTransport) // Targeted selectors for active-index cache invalidation. // These only change on user interaction (not every poll cycle). @@ -159,10 +150,9 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void const batchOffsetRef = useRef(0) const isPollingRef = useRef(false) - // Dynamic batch size — overwritten with the transport-specific - // ceiling on session start; halves on ERROR_OUT_OF_MEMORY and - // resets on the next session start. - const batchSizeRef = useRef(TCP_BATCH_SIZE) + // Dynamic batch size — overwritten with the medium's ceiling on session start; + // halves on ERROR_OUT_OF_MEMORY and resets on the next session start. + const batchSizeRef = useRef(DEBUG_MEDIUM_PROFILE[DEFAULT_MEDIUM].batchSize) // Full leaf index→metadata map — computed once when debugger starts. // One index → many leaves (a shared global appears under each POU's key). @@ -403,36 +393,29 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void const pollRef = useRef(pollVariables) pollRef.current = pollVariables - // The transport in use for the current debug session. This drives both - // the batch size and the poll interval — neither should be conditioned - // on board target since the same board can speak RTU, TCP, etc. The - // workspace activity bar sets this when the debug session connects. - const debugConnectionType = useOpenPLCStore((state) => state.workspace.debugConnectionType) + // The medium this session is actually riding, published by the connection + // manager — the one place that knows. Read LIVE rather than latched at session + // start, because on web it can change mid-session: a WebRTC data channel that + // drops falls back to the Edge relay, and the cadence has to follow it down. + const debugMedium = useOpenPLCStore((state) => state.deviceConnection.debugTransport) // Set up polling interval when debugger becomes visible. useEffect(() => { if (isDebuggerVisible) { + const profile = debugProfileFor(debugMedium) + // Reset state on session start - batchSizeRef.current = batchSizeForTransport(debugConnectionType) + batchSizeRef.current = profile.batchSize batchOffsetRef.current = 0 lastResponseTimestampRef.current = 0 activeIndexesRef.current = null visibleVarsCacheRef.current = null - // RTU framing also covers the simulator's virtual serial port — - // both need the tighter cadence to keep up with toggling state. - const usesRtuFraming = debugConnectionType === 'rtu' || debugConnectionType === 'simulator' - // Web HTTP fallback: WebRTC unavailable, so reads go over the - // orchestrator proxy (high latency) — slow the cadence right down. - // Gated on `!isNativeApplication` so the desktop editor's real - // TCP/WebSocket transports never hit this branch (their - // `session.debugTransport` is an unused 'http' default). - const usesHttpFallback = !capabilities.isNativeApplication && sessionDebugTransport === 'http' - const pollIntervalMs = usesRtuFraming - ? RTU_POLL_INTERVAL_MS - : usesHttpFallback - ? capabilities.debugHttpFallbackPollIntervalMs - : DEFAULT_POLL_INTERVAL_MS + // One lookup, both axes. The medium already distinguishes a peer-to-peer + // data channel from the Edge relay, so nothing here needs to ask which + // platform it is running on. + const pollIntervalMs = + debugMedium === 'http-relay' ? capabilities.debugRelayPollIntervalMs : profile.pollIntervalMs // Fire first poll immediately, then schedule at fixed rate // Skip tick if previous poll is still in progress (isPolling guard) @@ -480,18 +463,10 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void visibleVarsCacheRef.current = null batchOffsetRef.current = 0 } - // `sessionDebugTransport` + `capabilities.isNativeApplication` are - // in the deps so the cadence re-evaluates if WebRTC drops to the HTTP - // fallback (or recovers) mid-session — the effect tears down the old - // interval and restarts at the new rate. - }, [ - isDebuggerVisible, - debugConnectionType, - sessionDebugTransport, - capabilities.isNativeApplication, - capabilities.debugHttpFallbackPollIntervalMs, - workspaceActions, - ]) + // `debugMedium` is in the deps so the cadence re-evaluates when a WebRTC data + // channel drops to the Edge relay (or recovers) mid-session — the effect tears + // down the old interval and restarts at the new rate. + }, [isDebuggerVisible, debugMedium, capabilities.debugRelayPollIntervalMs, workspaceActions]) // Clean up on unmount useEffect(() => { diff --git a/src/frontend/hooks/useDebugSession.ts b/src/frontend/hooks/useDebugSession.ts index 8581b85a0..aff9c47ea 100644 --- a/src/frontend/hooks/useDebugSession.ts +++ b/src/frontend/hooks/useDebugSession.ts @@ -176,18 +176,11 @@ export function useDebugSession(): UseDebugSessionReturn { const sessionEndpoint = useOpenPLCStore.getState().deviceConnection.port if (sessionEndpoint) wsActions.setDebuggerTargetIp(sessionEndpoint) - // Record the active transport so useDebugPolling picks the right - // poll cadence + batch size. Set on EVERY start path (runtime - // targets also set it earlier in handleMd5Verification; this - // additionally covers the simulator path, which doesn't go - // through MD5 verification — without it the simulator stayed at - // the default 200ms instead of its intended 50ms). Must be set - // before `setDebuggerVisible(true)`, which is what triggers the - // polling effect. - // The medium is the manager's choice, mirrored in the store; read it rather - // than assuming one. `debugTransport` because this sizes the debug poll. - wsActions.setDebugConnectionType(useOpenPLCStore.getState().deviceConnection.debugTransport ?? 'simulator') - + // Nothing to record about the transport: `useDebugPolling` reads the medium + // the connection manager published (`deviceConnection.debugTransport`) and + // derives both its batch size and its cadence from it. Copying that into a + // second store field is what let the two disagree — and made a session whose + // medium was not yet known silently poll as if it were the simulator. wsActions.setDebuggerVisible(true) logActions.addLog({ id: crypto.randomUUID(), diff --git a/src/frontend/store/__tests__/webrtc-slice.test.ts b/src/frontend/store/__tests__/webrtc-slice.test.ts index 5dbfab104..1b0537cf9 100644 --- a/src/frontend/store/__tests__/webrtc-slice.test.ts +++ b/src/frontend/store/__tests__/webrtc-slice.test.ts @@ -28,7 +28,7 @@ describe('createWebRTCSlice', () => { expect(session.status).toBe('disconnected') expect(session.error).toBeNull() expect(session.reconnectAttempt).toBe(0) - expect(session.debugTransport).toBe('http') + expect(session.debugChannelOpen).toBe(false) }) }) @@ -128,16 +128,16 @@ describe('createWebRTCSlice', () => { }) }) - describe('setDebugTransport', () => { + describe('setDebugChannelOpen', () => { it('switches transport to webrtc', () => { - store.getState().webrtcActions.setDebugTransport('webrtc') - expect(store.getState().session.debugTransport).toBe('webrtc') + store.getState().webrtcActions.setDebugChannelOpen(true) + expect(store.getState().session.debugChannelOpen).toBe(true) }) it('switches transport back to http', () => { - store.getState().webrtcActions.setDebugTransport('webrtc') - store.getState().webrtcActions.setDebugTransport('http') - expect(store.getState().session.debugTransport).toBe('http') + store.getState().webrtcActions.setDebugChannelOpen(true) + store.getState().webrtcActions.setDebugChannelOpen(false) + expect(store.getState().session.debugChannelOpen).toBe(false) }) }) @@ -167,7 +167,7 @@ describe('createWebRTCSlice', () => { it('does not modify sessionId, reconnectAttempt, or debugTransport', () => { store.getState().webrtcActions.setSessionId('existing-session') store.getState().webrtcActions.setReconnectAttempt(2) - store.getState().webrtcActions.setDebugTransport('webrtc') + store.getState().webrtcActions.setDebugChannelOpen(true) store.getState().webrtcActions.startSession({ deviceId: 'dev-1', @@ -178,7 +178,7 @@ describe('createWebRTCSlice', () => { const { session } = store.getState() expect(session.sessionId).toBe('existing-session') expect(session.reconnectAttempt).toBe(2) - expect(session.debugTransport).toBe('webrtc') + expect(session.debugChannelOpen).toBe(true) }) }) @@ -193,7 +193,7 @@ describe('createWebRTCSlice', () => { webrtcActions.setStatus('connected') webrtcActions.setError('some error') webrtcActions.setReconnectAttempt(3) - webrtcActions.setDebugTransport('webrtc') + webrtcActions.setDebugChannelOpen(true) webrtcActions.endSession() @@ -203,7 +203,7 @@ describe('createWebRTCSlice', () => { expect(session.status).toBe('disconnected') expect(session.error).toBeNull() expect(session.reconnectAttempt).toBe(0) - expect(session.debugTransport).toBe('http') + expect(session.debugChannelOpen).toBe(false) }) it('preserves deviceId and deviceName after ending session', () => { @@ -230,7 +230,7 @@ describe('createWebRTCSlice', () => { webrtcActions.setStatus('connected') webrtcActions.setError('timeout') webrtcActions.setReconnectAttempt(5) - webrtcActions.setDebugTransport('webrtc') + webrtcActions.setDebugChannelOpen(true) webrtcActions.reset() @@ -242,7 +242,7 @@ describe('createWebRTCSlice', () => { expect(session.status).toBe('disconnected') expect(session.error).toBeNull() expect(session.reconnectAttempt).toBe(0) - expect(session.debugTransport).toBe('http') + expect(session.debugChannelOpen).toBe(false) }) }) }) diff --git a/src/frontend/store/__tests__/workspace-slice.test.ts b/src/frontend/store/__tests__/workspace-slice.test.ts index d8fedc7f0..15e92a432 100644 --- a/src/frontend/store/__tests__/workspace-slice.test.ts +++ b/src/frontend/store/__tests__/workspace-slice.test.ts @@ -61,7 +61,6 @@ describe('createWorkspaceSlice', () => { expect(workspace.debugGraphList).toEqual([]) expect(workspace.debugDataStale).toBe(false) expect(workspace.debugMd5Mismatch).toBeNull() - expect(workspace.debugConnectionType).toBeNull() }) // ------------------------------------------------------------------------- @@ -431,19 +430,6 @@ describe('createWorkspaceSlice', () => { expect(store.getState().workspace.debugMd5Mismatch).toBeNull() }) - it('setDebugConnectionType', () => { - expect(store.getState().workspace.debugConnectionType).toBeNull() - - store.getState().workspaceActions.setDebugConnectionType('websocket') - expect(store.getState().workspace.debugConnectionType).toBe('websocket') - - store.getState().workspaceActions.setDebugConnectionType('rtu') - expect(store.getState().workspace.debugConnectionType).toBe('rtu') - - store.getState().workspaceActions.setDebugConnectionType(null) - expect(store.getState().workspace.debugConnectionType).toBeNull() - }) - // ------------------------------------------------------------------------- // clearDebugState // ------------------------------------------------------------------------- @@ -482,7 +468,6 @@ describe('createWorkspaceSlice', () => { store.getState().workspaceActions.setDebugGraphList(['a']) store.getState().workspaceActions.setDebugDataStale(true) store.getState().workspaceActions.setDebugMd5Mismatch({ runtimeMd5: 'r', localMd5: 'l' }) - store.getState().workspaceActions.setDebugConnectionType('websocket') store.getState().workspaceActions.clearDebugState() @@ -503,7 +488,6 @@ describe('createWorkspaceSlice', () => { expect(workspace.debugGraphList).toEqual([]) expect(workspace.debugDataStale).toBe(false) expect(workspace.debugMd5Mismatch).toBeNull() - expect(workspace.debugConnectionType).toBeNull() }) // ------------------------------------------------------------------------- @@ -616,7 +600,6 @@ describe('createWorkspaceSlice', () => { expect(workspace.debugGraphList).toEqual([]) expect(workspace.debugDataStale).toBe(false) expect(workspace.debugMd5Mismatch).toBeNull() - expect(workspace.debugConnectionType).toBeNull() expect(workspace.isPlcLogsVisible).toBe(false) expect(workspace.plcLogs).toBe('') expect(workspace.plcLogsLastId).toBeNull() diff --git a/src/frontend/store/slices/device/types.ts b/src/frontend/store/slices/device/types.ts index daaf7251f..19f5ce22d 100644 --- a/src/frontend/store/slices/device/types.ts +++ b/src/frontend/store/slices/device/types.ts @@ -2,7 +2,9 @@ import type { EtherCATRuntimeStatusResponse } from '../../../../middleware/share import type { BoardInfo, CommunicationPort, + DebugMedium, DeviceConfiguration, + DeviceLinkTransport, DevicePin, PlcStatus, TimingStats, @@ -103,14 +105,17 @@ export type DeviceConnection = { * picks a transport. Null for a REST-controlled runtime session, which holds no * connection. */ - transport: 'rtu' | 'tcp' | 'simulator' | null + transport: DeviceLinkTransport | null /** - * Medium the DEBUG channel uses. The debug poll sizes its batches to this, because - * the frame budget differs enormously by wire (WebSocket 500 variables per round - * trip, Modbus TCP 60, RTU 19). Mirrored from the manager rather than inferred: a - * v4 session left this unset and silently polled with TCP-sized batches. + * Medium the DEBUG channel uses — the ONE fact the debug poller reads, for both + * its batch size and its cadence (see `DEBUG_MEDIUM_PROFILE`). Published by the + * connection manager, which is the only component that knows: the main process on + * the editor, the WebRTC lifecycle manager in the browser. + * + * Can change mid-session on web, when a WebRTC data channel drops to the Edge + * relay — the poller follows it, so this is read live rather than latched. */ - debugTransport: 'rtu' | 'tcp' | 'simulator' | 'websocket' | null + debugTransport: DebugMedium | null } // --------------------------------------------------------------------------- diff --git a/src/frontend/store/slices/webrtc/index.ts b/src/frontend/store/slices/webrtc/index.ts index 219a7cd37..c17b16159 100644 --- a/src/frontend/store/slices/webrtc/index.ts +++ b/src/frontend/store/slices/webrtc/index.ts @@ -1,9 +1,2 @@ export { createWebRTCSlice } from './slice' -export type { - DebugTransport, - WebRTCActions, - WebRTCConnectionStatus, - WebRTCSession, - WebRTCSlice, - WebRTCState, -} from './types' +export type { WebRTCActions, WebRTCConnectionStatus, WebRTCSession, WebRTCSlice, WebRTCState } from './types' diff --git a/src/frontend/store/slices/webrtc/slice.ts b/src/frontend/store/slices/webrtc/slice.ts index 5878c8e9c..1e0aab35a 100644 --- a/src/frontend/store/slices/webrtc/slice.ts +++ b/src/frontend/store/slices/webrtc/slice.ts @@ -11,7 +11,7 @@ const initialSession: WebRTCSession = { status: 'disconnected', error: null, reconnectAttempt: 0, - debugTransport: 'http', + debugChannelOpen: false, } const createWebRTCSlice: StateCreator = (setState) => ({ @@ -67,10 +67,10 @@ const createWebRTCSlice: StateCreator = (setSt }), ) }, - setDebugTransport: (transport) => { + setDebugChannelOpen: (open) => { setState( produce(({ session }: WebRTCSlice) => { - session.debugTransport = transport + session.debugChannelOpen = open }), ) }, @@ -93,7 +93,7 @@ const createWebRTCSlice: StateCreator = (setSt session.status = 'disconnected' session.error = null session.reconnectAttempt = 0 - session.debugTransport = 'http' + session.debugChannelOpen = false }), ) }, diff --git a/src/frontend/store/slices/webrtc/types.ts b/src/frontend/store/slices/webrtc/types.ts index aab4d88ce..1c516b662 100644 --- a/src/frontend/store/slices/webrtc/types.ts +++ b/src/frontend/store/slices/webrtc/types.ts @@ -2,8 +2,6 @@ import type { WebRTCConnectionStatus } from '../../../../middleware/shared/ports export type { WebRTCConnectionStatus } -export type DebugTransport = 'http' | 'webrtc' - // --------------------------------------------------------------------------- // WebRTC session state // --------------------------------------------------------------------------- @@ -16,7 +14,16 @@ export type WebRTCSession = { status: WebRTCConnectionStatus error: string | null reconnectAttempt: number - debugTransport: DebugTransport + /** + * Is the WebRTC DEBUG data channel open? + * + * A fact about this WebRTC session, deliberately not a medium name: which + * medium the debug poller then rides is derived from this once, by the web + * connection manager, and published as `deviceConnection.debugTransport`. + * Naming a medium here as well gave two fields the same vocabulary and let + * them disagree. + */ + debugChannelOpen: boolean } export type WebRTCState = { @@ -35,7 +42,7 @@ export type WebRTCActions = { setStatus: (status: WebRTCConnectionStatus) => void setError: (error: string | null) => void setReconnectAttempt: (attempt: number) => void - setDebugTransport: (transport: DebugTransport) => void + setDebugChannelOpen: (open: boolean) => void startSession: (params: { deviceId: string; deviceName: string; agentId: string }) => void endSession: () => void reset: () => void diff --git a/src/frontend/store/slices/workspace/slice.ts b/src/frontend/store/slices/workspace/slice.ts index 86f132c1a..d461bc0af 100644 --- a/src/frontend/store/slices/workspace/slice.ts +++ b/src/frontend/store/slices/workspace/slice.ts @@ -53,7 +53,6 @@ const createWorkspaceSlice: StateCreator debugGraphList: [], debugDataStale: false, debugMd5Mismatch: null, - debugConnectionType: null, debugTargetEndian: 'le', // Project loading state isProjectLoading: false, @@ -173,7 +172,6 @@ const createWorkspaceSlice: StateCreator workspace.debugGraphList = [] workspace.debugDataStale = false workspace.debugMd5Mismatch = null - workspace.debugConnectionType = null workspace.debugTargetEndian = 'le' workspace.isPlcLogsVisible = false workspace.plcLogs = '' @@ -373,13 +371,6 @@ const createWorkspaceSlice: StateCreator }), ) }, - setDebugConnectionType: (connectionType) => { - setState( - produce(({ workspace }: WorkspaceSlice) => { - workspace.debugConnectionType = connectionType - }), - ) - }, setDebugTargetEndian: (endian) => { setState( produce(({ workspace }: WorkspaceSlice) => { @@ -406,7 +397,6 @@ const createWorkspaceSlice: StateCreator workspace.debugGraphList = [] workspace.debugDataStale = false workspace.debugMd5Mismatch = null - workspace.debugConnectionType = null workspace.debugTargetEndian = 'le' }), ) diff --git a/src/frontend/store/slices/workspace/types.ts b/src/frontend/store/slices/workspace/types.ts index 17b0f4c64..09cd89876 100644 --- a/src/frontend/store/slices/workspace/types.ts +++ b/src/frontend/store/slices/workspace/types.ts @@ -1,6 +1,5 @@ import type { Architecture, - DebugConnectionType, DebugTreeNode, FbInstanceInfo, Platform, @@ -97,10 +96,6 @@ export type WorkspaceState = { debugGraphList: string[] debugDataStale: boolean debugMd5Mismatch: { runtimeMd5: string; localMd5: string } | null - /** Active transport for the running debug session — drives the - * per-poll batch size and any other transport-specific behaviour. - * Null when no session is active. */ - debugConnectionType: DebugConnectionType | null /** Target's native byte order for multi-byte variable values on * the wire. Detected from the 0xDEAD sentinel in the MD5 * response: LE target writes the trailer as `[0xAD, 0xDE]`, BE @@ -185,7 +180,6 @@ export type WorkspaceActions = { setDebugGraphList: (list: string[]) => void setDebugDataStale: (stale: boolean) => void setDebugMd5Mismatch: (mismatch: { runtimeMd5: string; localMd5: string } | null) => void - setDebugConnectionType: (connectionType: DebugConnectionType | null) => void setDebugTargetEndian: (endian: 'le' | 'be') => void clearDebugState: () => void clearFbDebugContext: () => void diff --git a/src/middleware/shared/ports/device-port.ts b/src/middleware/shared/ports/device-port.ts index 8539f78d5..fad89c06d 100644 --- a/src/middleware/shared/ports/device-port.ts +++ b/src/middleware/shared/ports/device-port.ts @@ -21,7 +21,7 @@ * - getDeviceStatus() */ -import type { BoardInfo, CommunicationPort, DebugConnectionConfig } from './types' +import type { BoardInfo, CommunicationPort, DebugConnectionConfig, DebugMedium, DeviceLinkTransport } from './types' // --------------------------------------------------------------------------- // Connect-time classification (D72) — platform contract shared by the port and @@ -55,14 +55,15 @@ export interface DeviceConnectionStatusPayload { * Medium the CONTROL channel uses (or was using, when it dropped). Absent for a * REST-controlled runtime session: REST holds no connection. */ - transport?: 'rtu' | 'tcp' | 'simulator' + transport?: DeviceLinkTransport /** * Medium the DEBUG channel uses — the same as `transport` when one channel serves - * both roles, else `websocket` (v4) or `tcp` (v3). Consumers that must size work - * to the wire (the debug poll's frame budget) read THIS, rather than inferring a - * medium they have no business choosing. + * both roles, else `websocket` (editor / v4), `tcp` (v3), or `webrtc` / + * `http-relay` in the browser. Consumers that must pace or size work to the wire + * (the debug poll) read THIS, rather than inferring a medium they have no + * business choosing. */ - debugTransport?: 'rtu' | 'tcp' | 'simulator' | 'websocket' + debugTransport?: DebugMedium /** * The endpoint, as the user would name it: a serial path ("/dev/ttyACM0", * "COM5") or an IP address. Not called `port`, because for a Modbus TCP link it diff --git a/src/middleware/shared/ports/platform-capabilities.ts b/src/middleware/shared/ports/platform-capabilities.ts index 235ddc340..c943abc95 100644 --- a/src/middleware/shared/ports/platform-capabilities.ts +++ b/src/middleware/shared/ports/platform-capabilities.ts @@ -113,7 +113,7 @@ export interface PlatformCapabilities { * autonomy-node runs no WebRTC signaling relay and wants this to match * its general-purpose poll rate. */ - debugHttpFallbackPollIntervalMs: number + debugRelayPollIntervalMs: number // --- Environment --- @@ -150,7 +150,7 @@ export const EDITOR_CAPABILITIES: PlatformCapabilities = { hasDirectProgramUpload: false, hasPackageManager: true, hasEthercat: true, - debugHttpFallbackPollIntervalMs: 1000, + debugRelayPollIntervalMs: 1000, isDevMode: false, } @@ -194,6 +194,6 @@ export const WEB_CAPABILITIES: PlatformCapabilities = { hasDirectProgramUpload: true, hasPackageManager: false, hasEthercat: false, - debugHttpFallbackPollIntervalMs: 1000, + debugRelayPollIntervalMs: 1000, isDevMode: false, } diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index 8715b9010..eb644ceb4 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -1053,8 +1053,40 @@ export interface RuntimeLogEntry { // Debugger // --------------------------------------------------------------------------- +/** + * A channel kind a board's `debug` spec can declare. This is the SPEC's + * vocabulary — what a package author writes — not necessarily what a live + * session ends up riding. See `DebugMedium` for that. + */ export type DebugConnectionType = 'tcp' | 'rtu' | 'websocket' | 'simulator' +/** + * What a live debug session actually rides — the one fact the connection manager + * publishes and the debug poller consumes. + * + * Wider than `DebugConnectionType` because the browser reaches a runtime two ways + * that no spec distinguishes, and they behave differently enough that the poller + * must tell them apart: + * + * `webrtc` a data channel straight to the orchestrator agent, which relays + * to the runtime's debug socket. + * `http-relay` the same request, hop by hop: browser -> Autonomy Edge -> + * agent (over its always-on websocket) -> runtime. The fallback + * when a data channel cannot be opened. + * + * Both terminate at the SAME endpoint on the device, so they carry the same frame + * budget and differ only in latency — which is exactly the split + * `DEBUG_MEDIUM_PROFILE` encodes. + */ +export type DebugMedium = DebugConnectionType | 'webrtc' | 'http-relay' + +/** + * Media that can carry a CONTROL channel — one the connection manager physically + * holds open and polls. Narrower than `DebugMedium` on purpose: a REST-controlled + * runtime holds nothing, and the browser's media are debug-only. + */ +export type DeviceLinkTransport = 'rtu' | 'tcp' | 'simulator' + export interface DebugConnectionConfig { connectionType: DebugConnectionType connectionParams: {