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`). diff --git a/resources/sources/Baremetal/Baremetal.ino b/resources/sources/Baremetal/Baremetal.ino index 126e6478e..8ff5c460c 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 @@ -128,8 +128,20 @@ 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 + // 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++) @@ -155,6 +167,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 @@ -178,6 +202,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 +402,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 +429,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..807399e50 100644 --- a/resources/sources/Baremetal/ModbusSlave.cpp +++ b/resources/sources/Baremetal/ModbusSlave.cpp @@ -4,1460 +4,40 @@ 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. -//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 +// 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. -#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() { #ifdef MBTCP handle_tcp(); #endif - #ifdef MBSERIAL + #ifdef MB_SERIAL_ACTIVE handle_serial(); #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 MBSERIAL -// 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; - -// 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] - 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(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); -} - -void handle_serial() -{ - 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) - { - 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(); - } - - // 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 (mb_rx_len == 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 - // performance — those function codes are private and well-formed). - if (mb_frame[0] != modbus.slaveid) - { - mb_rtu_drop_front(1); // foreign/garbage head — slide - continue; - } - - int32_t expected = mb_rtu_frame_len(mb_frame, mb_rx_len); - - if (expected < 0 || expected > MAX_MB_FRAME) - { - mb_rtu_drop_front(1); // illegal FC / impossible length - continue; - } - if (expected == 0 || mb_rx_len < (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; - return; - } - - // 3) A full candidate frame occupies mb_frame[0 .. expected). - // 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_len = (uint16_t)expected; - packet_crc = ((mb_frame[expected - 2] << 8) | mb_frame[expected - 1]); - if (packet_crc != calcCrc()) - { - mb_rtu_drop_front(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 (mb_txpin >= 0) - { - digitalWrite(mb_txpin, HIGH); - delayMicroseconds(mb_t35); - } - - #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) - if (mb_serialport == &Serial3) // RS485 serial port - Controllino_RS485TxEnable(); // Enable RS485 chip to transmit - #elif defined(CONTROLLINO_MICRO) - if (mb_serialport == &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(); - delayMicroseconds(mb_t35); - - if (mb_txpin >= 0) - digitalWrite(mb_txpin, LOW); - - #if defined(CONTROLLINO_MAXI) || defined(CONTROLLINO_MEGA) - if (mb_serialport == &Serial3) // RS485 serial port - Controllino_RS485RxEnable(); // Go back to receive mode after transmitted data - #elif defined(CONTROLLINO_MICRO) - if (mb_serialport == &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. - mb_rx_len = 0; - return; - } -} -#endif - - -void process_mbpacket() -{ - uint8_t fcode = mb_frame[1]; - // Standard Modbus fields — preserved for the non-debug FCs. - 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]; - void *endianness_check = &mb_frame[2]; - - switch (fcode) - { - 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; +// Serial transport (mbconfig_serial_iface, handle_serial/handle_serial_port, +// mb_rtu_drop_front, RX-assembly buffers, RS485 timing) moved to modbus_serial.cpp. - 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; +// process_mbpacket() + mb_pdu_request_len() + mb_pdu_skips_crc() moved to modbus_pdu.cpp. - 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; +// Register store + operation FCs (readRegisters..writeMultipleCoils) moved to modbus_registers.cpp. - case MB_FC_DEBUG_INFO: - debugInfo(); - break; +// Debugger FCs (debugInfo/debugSetTrace/debugGetTrace/debugGetTraceList/debugGetMd5/ +// debugGetStatus/debugGetVersion/debugGetBoardId) moved to modbus_debug.cpp. - 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; - - 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 MBSERIAL - #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; -} - -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; -} +// 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 47236126d..d7f3b5c80 100644 --- a/resources/sources/Baremetal/ModbusSlave.h +++ b/resources/sources/Baremetal/ModbusSlave.h @@ -7,61 +7,25 @@ Copyright (C) 2022 OpenPLC - Thiago Alves #define MODBUSSLAVE_H #include -#include "defines.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 - -//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 +#include "openplc_version.h" +// 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" @@ -72,162 +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; -}; +// 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). -//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 -}; +// 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. -//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 -}; - -//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 MBSERIAL -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); - - -/* 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..642bd5b9f --- /dev/null +++ b/resources/sources/Baremetal/modbus_config.h @@ -0,0 +1,50 @@ +/* +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 / 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 + #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..85a56df5a --- /dev/null +++ b/resources/sources/Baremetal/modbus_debug.cpp @@ -0,0 +1,435 @@ +/* +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" +// 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. +// 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; + // 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); + 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); + // 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] +// 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..a9cf7d1fc --- /dev/null +++ b/resources/sources/Baremetal/modbus_debug.h @@ -0,0 +1,31 @@ +/* +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. +*/ + +#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); +// 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_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..c3a480676 --- /dev/null +++ b/resources/sources/Baremetal/modbus_pdu.cpp @@ -0,0 +1,190 @@ +/* +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] + case MB_FC_PLC_SET_STATE: + return 5; // [id][fc][state:1][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; + + 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_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..ec9e88afd --- /dev/null +++ b/resources/sources/Baremetal/modbus_tcp.cpp @@ -0,0 +1,294 @@ +/* +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 + // 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()) + { + 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 + // 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()) + { + 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..6eb6ebd5f --- /dev/null +++ b/resources/sources/Baremetal/modbus_types.h @@ -0,0 +1,96 @@ +/* +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 +// 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 { + 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 + MB_FC_PLC_SET_STATE = 0x4B, // Set the runtime run/stop state +}; + +//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 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/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/__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.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/compiler/compiler-module.ts b/src/backend/editor/compiler/compiler-module.ts index 6a3ae3cdb..0aaf17a3e 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', @@ -1459,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 @@ -2506,6 +2528,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 @@ -2614,6 +2663,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/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-probe.test.ts b/src/backend/editor/hardware/__tests__/device-probe.test.ts new file mode 100644 index 000000000..55024374e --- /dev/null +++ b/src/backend/editor/hardware/__tests__/device-probe.test.ts @@ -0,0 +1,132 @@ +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' }) + }) + + // 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: '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 () => { + 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/__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/__tests__/device-session-manager.test.ts b/src/backend/editor/hardware/__tests__/device-session-manager.test.ts new file mode 100644 index 000000000..e04ede5a7 --- /dev/null +++ b/src/backend/editor/hardware/__tests__/device-session-manager.test.ts @@ -0,0 +1,763 @@ +/** + * 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[], + extra: Partial = {}, +): DeviceLinkCandidate { + return { + transport, + descriptor, + create: () => { + const client = queue.shift() ?? new FakeClient() + registry.push(client) + return asTransport(client) + }, + ...extra, + } +} + +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() + }) + + // 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]) + } + }) + + // 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 }) + 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..99babf7d4 --- /dev/null +++ b/src/backend/editor/hardware/device-probe.ts @@ -0,0 +1,180 @@ +/** + * 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 } +/** + * 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 + * 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 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, + { 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) 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) { + // 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' } + } 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..eb2662ad5 --- /dev/null +++ b/src/backend/editor/hardware/device-session-manager.ts @@ -0,0 +1,718 @@ +/** + * 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 { DebugMedium, DeviceLinkTransport } from '../../../middleware/shared/ports/types' +import type { DeviceDebugChannel, DeviceModbusTransport } from '../../shared/debug/types' +import { DeviceLinkPolicy } from './device-link-policy' + +// 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 + * 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: DebugMedium + 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". + * + * 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 + /** + * 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. */ +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?: DebugMedium + 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; 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 + * 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 + /** When a command last succeeded over the link. 0 = nothing since it opened. */ + private lastTrafficAt = 0 + + 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) + } + + /** + * 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. + */ + 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') + // 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: [] } + } + + this.candidates = candidates + const attempts: DeviceLinkOpenFailure['attempts'] = [] + + 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 }) + // 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 + 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', + 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, + baudRate: candidate.baudRate, + 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(` ${describeLinkCandidate(candidate)}: 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 { + // Nothing traced on success: opening is a step towards the answer, not the + // answer. Only failing to open is news. + await client.connect() + } catch (error) { + client.disconnect() + this.trace( + ` ${describeLinkCandidate(candidate)}: 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)) return { ok: true, client } + client.disconnect() + this.trace( + ` ${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(` ${describeLinkCandidate(candidate)}: 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 + // Traffic over a client we just dropped proves nothing about the next one. + this.lastTrafficAt = 0 + } + + 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: ${describeLinkCandidate(candidate)} ${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. 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 { + 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 ea312170a..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 @@ -358,6 +382,8 @@ class HardwareModule { } : null, }, + ...(device.serialPorts ? { serialPorts: device.serialPorts } : {}), + ...(device.defaultSerial ? { defaultSerial: device.defaultSerial } : {}), ...(device.debug ? { debug: device.debug } : {}), }) } 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 5074b80bf..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' @@ -9,12 +24,21 @@ 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, + /** 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 { @@ -23,7 +47,7 @@ interface ModbusTcpClientOptions { timeout: number } -export class ModbusTcpClient { +export class ModbusTcpClient implements DeviceModbusTransport { private host: string private port: number private timeout: number @@ -346,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/compile/__tests__/generate-defines.test.ts b/src/backend/shared/compile/__tests__/generate-defines.test.ts index 0ef0902b3..3b9dfb64f 100644 --- a/src/backend/shared/compile/__tests__/generate-defines.test.ts +++ b/src/backend/shared/compile/__tests__/generate-defines.test.ts @@ -153,6 +153,149 @@ 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('emits DEBUGGER_ENABLED even when full Modbus is enabled (always-on serial debugger)', () => { + const out = generateDefinesContent({ + ...EMPTY_INPUTS, + boardRuntime: 'arduino-cli', + vppModbusState: { + serial: { baud_rate: '9600' }, + modbus_rtu: { enabled: true, serial_port: 'Serial', rtu_slave_id: 1 }, + }, + defaultSerial: 'Serial', + }) + 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') + }) + + // 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') + }) + + // 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, + 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('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') + }) + + 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 +501,13 @@ describe('generateDefinesContent — full output snapshot', () => { '//Program MD5', '#define PROGRAM_MD5 "ffffffffffffffffffffffffffffffff"', '', + '//Debugger', + '#define DEBUGGER_ENABLED', + '#define DEBUG_IFACE Serial', + '#define DEBUG_BAUD 115200', + '#define DEBUG_SLAVE 1', + '', + '', '//IO Config', '#define PINMASK_DIN ', '#define PINMASK_AIN 4', diff --git a/src/backend/shared/compile/__tests__/modbus-defines.test.ts b/src/backend/shared/compile/__tests__/modbus-defines.test.ts index a16fe762e..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,114 @@ -import { generateModbusDefines } 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 + * 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, + ) + }) + + // 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', () => { + expect(resolveDebugBaud({})).toBe(DEFAULT_DEBUG_BAUD) + }) +}) + +/** + * 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', () => { @@ -22,6 +132,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 +140,58 @@ 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') + 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', () => { + 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') + // 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', () => { + 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 ecddca33e..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, type VppModbusScreenState } from './modbus-defines' +import { generateModbusDefines, resolveDebugBaud, resolveDebugSlave, type VppModbusScreenState } from './modbus-defines' export type { VppModbusScreenState } from './modbus-defines' @@ -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,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 } = input + const { + boardEntry, + devicePinMapping, + stProgramFileContent, + buildMD5Hash, + boardRuntime, + vppModbusState, + defaultSerial, + } = input let DEFINES_CONTENT = '' @@ -155,13 +167,41 @@ export function generateDefinesContent(input: GenerateDefinesInput): string { DEFINES_CONTENT += '#define MODBUS_ENABLED\n' DEFINES_CONTENT += `\n\n` } 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' } } + // 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` + // 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` + // 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` + } + // 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/compile/steps/modbus-defines.ts b/src/backend/shared/compile/steps/modbus-defines.ts index 693eb0b96..7921c5022 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 @@ -50,6 +76,79 @@ 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. + * + * 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. 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) 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) return DEFAULT_DEBUG_BAUD + } + + 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 @@ -88,7 +187,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 +208,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 +221,28 @@ 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}`) + // 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}`) } @@ -135,34 +250,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}`) - - 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}`) - - 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}"`) + // 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 + + 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'}`) + + 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') 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..79772b12d --- /dev/null +++ b/src/backend/shared/debug/__tests__/modbus-pdu-plc-control.test.ts @@ -0,0 +1,150 @@ +/** + * 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('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 + // 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 1bb6c2864..5daee4a1a 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,195 @@ 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) + // `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', () => { + 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/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/modbus-pdu.ts b/src/backend/shared/debug/modbus-pdu.ts index 1c7d60e69..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,8 +44,16 @@ */ import { detectTargetEndian, type TargetEndian } from '../../../frontend/utils/endian' -import { ModbusDebugResponse, ModbusFunctionCode } from '../simulator/types' -import type { DebugSetResult, DebugTransportResult, Md5ProbeResult } from './types' +import { ModbusDebugResponse, ModbusFunctionCode, PlcRuntimeState } from '../simulator/types' +import type { + DebugBoardIdResult, + DebugSetResult, + DebugStatusResult, + DebugTransportResult, + DebugVersionResult, + Md5ProbeResult, + PlcControlResult, +} from './types' // --------------------------------------------------------------------------- // Uint8Array helpers — host-endian-agnostic, no typed-array views on wire data. @@ -135,6 +146,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 +280,114 @@ export function parseSetVariableResponse(data: Uint8Array): DebugSetResult { return { success: true } } +/** + * Parse a status response (FC 0x46). + * 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) { + 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)` } + } + + const running = readU8(data, 2) + return { + success: true, + 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) } : {}), + } +} + +/** + * 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. @@ -255,3 +395,60 @@ export function parseSetVariableResponse(data: Uint8Array): DebugSetResult { 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 8b2ebe373..1674ed6b9 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 * @@ -5,11 +7,15 @@ * 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 @@ -23,6 +29,47 @@ 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 + /** 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 +} + +/** 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 @@ -50,3 +97,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..4192c0df3 100644 --- a/src/backend/shared/debug/websocket-debug-transport.ts +++ b/src/backend/shared/debug/websocket-debug-transport.ts @@ -31,7 +31,7 @@ import { parseGetMd5Response, parseSetVariableResponse, } from './modbus-pdu' -import type { DebugSetResult, DebugTransport, DebugTransportResult, Md5ProbeResult } from './types' +import type { DebugSetResult, DebugTransport, DebugTransportResult, DeviceDebugChannel, Md5ProbeResult } from './types' const REQUEST_TIMEOUT_MS = 5000 const CONNECT_TIMEOUT_MS = 5000 @@ -67,7 +67,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..20f47ab24 --- /dev/null +++ b/src/backend/shared/hardware/__tests__/connect-resolve-regression.test.ts @@ -0,0 +1,359 @@ +/** + * 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('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. + 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/__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..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 // --------------------------------------------------------------------------- @@ -199,14 +238,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 +265,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] @@ -279,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__/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..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') { @@ -551,6 +551,219 @@ 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() + // `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 () => { + 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 +897,71 @@ 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('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') + const result = await client.getBoardId() + expect(result.error).toBe('non-error string') + }) }) // ----------------------------------------------------------------------- @@ -775,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/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 0e628d582..8de172813 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 @@ -462,4 +463,131 @@ 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 { + 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, + // 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) } + } + } + + 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) } + } + } + + /** + * 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 a2ac377f6..e6609f8f8 100644 --- a/src/backend/shared/simulator/types.ts +++ b/src/backend/shared/simulator/types.ts @@ -4,10 +4,35 @@ 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, + /** 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..6a14c8240 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -8,15 +8,18 @@ import { MagnifierIcon } from '../../../../../../assets/icons/interface/Magnifie 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 +27,15 @@ 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 +62,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 +381,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,90 +628,100 @@ const Board = memo(function () { Search -
- + {connectionStatus === 'connected' && ( -
- ● Connected + <> {plcStatus && ( | PLC: {plcStatus} )} -
- )} - {connectionStatus === 'error' && ( - ● Connection failed + + )} -
+ ) : capabilities.hasLocalSerialPorts ? ( -
- - - {availableCommunicationPorts.map((port) => { - const displayName = port.name?.trim() || port.address - return ( - - - {displayName} - - - ) - })} - - - +
+ - - - + + + ) : null} {!isOpenPLCRuntimeTarget(currentBoardInfo) && !isSimulatorTarget(currentBoardInfo) && (
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/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..f50a79eac 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,68 @@ 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. + // + // 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 + // 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 +230,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 +244,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 +273,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 +341,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 +364,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 device.connect(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 +398,9 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa }, [ compiler, - projectData, projectMeta, deviceDefinitions, + currentBoardInfo, isSimulatorBoard, simulator, debugSession, @@ -341,6 +417,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 +508,107 @@ 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 +632,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 +651,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 +673,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 +681,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 +708,13 @@ 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) // 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 +725,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 +755,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 +779,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 +794,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 +822,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 +877,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,7 +903,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa canEdit, executeSave, addLog, - resolveDebugConfigWithUx, + currentBoardInfo, ]) // --------------------------------------------------------------------------- @@ -902,8 +967,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 +976,13 @@ 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__/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/__tests__/use-device-connect.test.ts b/src/frontend/hooks/__tests__/use-device-connect.test.ts new file mode 100644 index 000000000..f72b09517 --- /dev/null +++ b/src/frontend/hooks/__tests__/use-device-connect.test.ts @@ -0,0 +1,282 @@ +import { renderHook } from '@testing-library/react' + +// `mock*`-prefixed refs are hoisted into the jest.mock factories below. +const mockOpenModal = 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 }, + 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') + }) + + /** + * 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/__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..f6ba9e5af --- /dev/null +++ b/src/frontend/hooks/use-device-connect.ts @@ -0,0 +1,157 @@ +/** + * 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) + + // 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', + } + + 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)) + } + + // 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 + } + } + + 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() + }, + }) + } + } 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]) + + 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/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 f309f5fa1..aff9c47ea 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 }, @@ -60,164 +59,157 @@ 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) - 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 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) + } - 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 } + 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(debugConfig) - 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 } - } - - // Store debug artifacts in workspace - wsActions.setDebugVariableIndexes(indexMap) - wsActions.setDebugVariableTree(treeMap) - wsActions.setFbDebugInstances(fbDebugInstancesMap) + debugTreesRef.current = pouTrees - // Set default selected instance for each FB type - fbDebugInstancesMap.forEach((instanceList: FbInstanceInfo[], fbTypeName: string) => { - if (instanceList.length > 0) { - wsActions.setFbSelectedInstance(fbTypeName, instanceList[0].key) - } - }) + // Derive the composite-key → packed-address map from the tree leaves. + const indexMap = deriveVariableIndexMap(treeMap, debugMap) - // Set target IP for non-simulator connections - if (debugConfig.connectionType !== 'simulator' && debugConfig.connectionParams.ipAddress) { - wsActions.setDebuggerTargetIp(debugConfig.connectionParams.ipAddress) - } + // Build FB instance map + const fbDebugInstancesMap = buildFbInstanceMap(project.data.pous, instances) - // 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. - wsActions.setDebugConnectionType(debugConfig.connectionType) - - 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], - ) - const stopSession = useCallback(async () => { - // If simulator is running, stop it - if (simulator.isRunning()) { - await simulator.stop() + // 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) + + // 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(), + 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]) - // Disconnect debugger + /** + * 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 () => { 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..cbc0b7843 100644 --- a/src/frontend/store/__tests__/device-slice.test.ts +++ b/src/frontend/store/__tests__/device-slice.test.ts @@ -155,6 +155,103 @@ 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('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') + store.getState().deviceActions.clearDeviceConnection() + expect(store.getState().deviceConnection).toEqual({ + status: 'disconnected', + port: null, + transport: null, + debugTransport: null, + }) + }) }) // ----------------------------------------------------------------------- @@ -173,7 +270,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 +282,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 +401,18 @@ 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, + }) + }) }) // ----------------------------------------------------------------------- @@ -1255,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/store/__tests__/device-types.test.ts b/src/frontend/store/__tests__/device-types.test.ts index 41de632aa..dbad28dc3 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/__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/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..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, @@ -63,6 +65,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 +85,39 @@ 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: DeviceLinkTransport | null + /** + * 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: DebugMedium | null +} + // --------------------------------------------------------------------------- // Device state // --------------------------------------------------------------------------- @@ -91,6 +133,7 @@ export type DeviceState = { updated: boolean } runtimeConnection: RuntimeConnection + deviceConnection: DeviceConnection } // --------------------------------------------------------------------------- @@ -150,6 +193,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 +203,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/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/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/frontend/utils/__tests__/serial-port-label.test.ts b/src/frontend/utils/__tests__/serial-port-label.test.ts new file mode 100644 index 000000000..0e9e5323a --- /dev/null +++ b/src/frontend/utils/__tests__/serial-port-label.test.ts @@ -0,0 +1,99 @@ +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/frontend/utils/vpp/__tests__/field-options.test.ts b/src/frontend/utils/vpp/__tests__/field-options.test.ts new file mode 100644 index 000000000..5e84ee136 --- /dev/null +++ b/src/frontend/utils/vpp/__tests__/field-options.test.ts @@ -0,0 +1,53 @@ +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/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 7679f1ef6..30afb8761 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,26 @@ 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, + planBaudAttempts, + QUICK_BOARD_ID_PROBE, + SPECULATIVE_BOARD_ID_PROBE, +} from '../../../backend/editor/hardware/device-probe' +import { + describeLinkCandidate, + 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 { 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 +77,31 @@ 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() +} + +/** + * 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 @@ -63,15 +113,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 @@ -639,10 +703,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 }) @@ -667,6 +735,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' } @@ -676,24 +745,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 { @@ -1020,11 +1072,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) @@ -1632,129 +1690,161 @@ class MainProcessBridge implements MainIpcModule { } } - 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 - match?: boolean - targetMd5?: string - targetEndian?: 'le' | 'be' - error?: string - }> => { - let client: ModbusTcpClient | ModbusRtuClient | null = null - let wsClient: WebSocketDebugTransport | null = null + /** + * 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, expectedMd5: string): Promise => { 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 - } + 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, + error: error instanceof Error ? error.message : 'Unknown error during MD5 verification', + } + } + } - const { md5: targetMd5, targetEndian } = await wsClient.getMd5Hash() + /** + * 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`) - const match = targetMd5.toLowerCase() === expectedMd5.toLowerCase() + // 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) - if (!this.debuggerWebSocketClient) { - this.debuggerWebSocketClient = wsClient - this.debuggerTargetIp = connectionParams.ipAddress - this.debuggerJwtToken = connectionParams.jwtToken - this.debuggerConnectionType = 'websocket' - } + const link = this.requireControl('run/stop') + if ('error' in link) return { success: false, error: link.error } - 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' } - } + 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', + } + } + } - // 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 } - } + /** + * 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 } - client = new ModbusRtuClient({ - port: connectionParams.port, - baudRate: connectionParams.baudRate, - slaveId: connectionParams.slaveId, - timeout: 5000, - }) - } + const status = 'status' in result ? (result.status ?? '') : '' + if (status.includes('ERROR_SWITCH_STOP')) return { success: false, refusedBySwitch: true } - await client.connect() - const { md5: targetMd5, targetEndian } = await client.getMd5Hash() + // 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 } + } - const match = targetMd5.toLowerCase() === expectedMd5.toLowerCase() + /** 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) { + return { success: false, error: getErrorMessage(error) } + } + } - if (connectionType === 'tcp') { - client.disconnect() - } else { - this.debuggerModbusClient = client - this.debuggerConnectionType = 'rtu' - this.debuggerRtuPort = connectionParams.port! - this.debuggerRtuBaudRate = connectionParams.baudRate! - this.debuggerRtuSlaveId = connectionParams.slaveId! - } + 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' } + } - return { success: true, match, targetMd5, targetEndian } + // 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. + try { + 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) { - client?.disconnect() - wsClient?.disconnect() - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error during MD5 verification', - } + return { success: false, error: getErrorMessage(error), needsReconnect: true } } } @@ -1790,289 +1880,532 @@ 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, + }) + } - if (this.debuggerConnectionType === 'websocket') { - if (!this.debuggerWebSocketClient) { - if (this.debuggerReconnecting) { - return { success: false, error: 'Reconnection in progress', needsReconnect: true } - } + /** + * 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) } + } + } - 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 } - } - } + /** + * 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 }) + } - try { - const result = await this.debuggerWebSocketClient.getVariablesList(variableIndexes) + // =================================================================== + // The device link — "stay connected", over serial or Modbus TCP + // =================================================================== + + /** Push a link state change to the renderer. */ + private emitDeviceLinkStatus(status: DeviceLinkStatus): void { + // `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) + } - 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[], + 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) return + const params = { + connectionType: config.connectionType, + port: config.connectionParams.port, + 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)) return + ;(isGuess ? speculative : declared).push({ + transport: kind, + // 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) + if ('error' in built) throw new Error(built.error) + return built.client + }, + }) } - if (!this.debuggerModbusClient) { - if (this.debuggerReconnecting) { - return { success: false, error: 'Reconnection in progress', needsReconnect: true } - } - - 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 } - } - - 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 } + 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) } } - try { - const result = await this.debuggerModbusClient.getVariablesList(variableIndexes) + // 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 - if (result.success && result.data) { - return { - success: true, - tick: result.tick, - lastIndex: result.lastIndex, - data: Array.from(result.data), - } - } + return [...declared, ...speculative] + } - 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 } - } + /** Consume the classification the last verified candidate produced. */ + private takeDeviceLinkProbe(): DeviceProbeOutcome | null { + const probe = this.deviceLinkProbe + this.deviceLinkProbe = null + return probe } - handleDebuggerConnect = async ( + /** + * 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, - connectionType: 'tcp' | 'rtu' | 'websocket' | 'simulator', - connectionParams: { - ipAddress?: string - port?: string - baudRate?: number - slaveId?: number - jwtToken?: string - }, + params: { address: string; debug: DebugConnectionConfig }, ): Promise<{ success: boolean; error?: string }> => { - try { - if (connectionType === 'simulator') { - if (this.debuggerModbusClient) { - this.debuggerModbusClient.disconnect() - this.debuggerModbusClient = null - } + 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 }) + } - 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 - } + /** 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 }) + } + + /** + * 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 }), + } + } + // 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, + descriptor: `${candidate.transport} ${candidate.descriptor}`, + create: candidate.create, + } + } - if (!connectionParams.ipAddress || !connectionParams.jwtToken) { - return { success: false, error: 'IP address and JWT token are required for WebSocket connection' } + /** + * 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.) + // + // 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 + const result = await classifyDeviceLink(client, { boardIdProbe }) + this.deviceLinkProbe = result + 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) + return true + } - if (!this.debuggerWebSocketClient || this.debuggerConnectionType !== 'websocket') { - if (this.debuggerWebSocketClient) { - this.debuggerWebSocketClient.disconnect() - this.debuggerWebSocketClient = null - } + /** + * 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 + } - this.debuggerWebSocketClient = new WebSocketDebugTransport({ - host: connectionParams.ipAddress, - port: 8443, - token: connectionParams.jwtToken, - rejectUnauthorized: false, - }) - await this.debuggerWebSocketClient.connect() - } + /** + * 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, + port: string | null | undefined, + ): Promise<{ released: boolean }> => { + return { released: this.deviceSession.releaseSerialPort(port) } + } - this.debuggerTargetIp = connectionParams.ipAddress - this.debuggerJwtToken = connectionParams.jwtToken - } else if (connectionType === 'tcp') { - 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) { - 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 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.debuggerModbusClient && - this.debuggerConnectionType === 'rtu' && - this.debuggerRtuPort === connectionParams.port && - this.debuggerRtuBaudRate === connectionParams.baudRate && - this.debuggerRtuSlaveId === connectionParams.slaveId - ) { - this.debuggerReconnecting = false - return { success: true } - } + /** + * 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) + 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 + } - if (this.debuggerModbusClient) { - this.debuggerModbusClient.disconnect() - this.debuggerModbusClient = null - } + /** + * 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) + } + } - 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 - } + /** + * 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() - this.debuggerConnectionType = connectionType - this.debuggerReconnecting = false + private traceChannelUse(what: string, family: 'control' | 'debug'): void { + const link = this.deviceSession.getLink() + 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})`) + } - 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) } + 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 } } - 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 }) + /** + * 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 + + /** + * 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' + + /** + * 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 + // 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)' + }`, + ) + + 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.' } + } + + 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' } + + // 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) => `${describeLinkCandidate(attempt)}: ${attempt.error}`).join('; ') + 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() + this.deviceLinkProbe = null + this.tracedChannelUses.clear() + return { success: true } } handleDebuggerSetVariable = async ( @@ -2083,35 +2416,20 @@ 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' } - } - try { - const result = await this.debuggerModbusClient.setVariable(variableIndex, force, buffer) - 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) } @@ -2143,6 +2461,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') } @@ -2371,6 +2690,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, @@ -2379,17 +2708,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..dca64d16d 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,62 @@ 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') + }) + + 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', + }) + }) +}) + // --------------------------------------------------------------------------- // 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 +272,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 +350,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 +366,7 @@ describe('onDisconnected', () => { adapter.onDisconnected(cb3) unsub2() - await adapter.connect(tcpConfig) + await adapter.connect() await adapter.disconnect() expect(cb1).toHaveBeenCalledTimes(1) @@ -372,12 +394,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..def534176 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,14 @@ 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), + 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 }) @@ -73,4 +78,77 @@ 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') + }) + + /** + * 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/adapters/editor/debugger-adapter.ts b/src/middleware/adapters/editor/debugger-adapter.ts index 169a0f16f..b4e76c776 100644 --- a/src/middleware/adapters/editor/debugger-adapter.ts +++ b/src/middleware/adapters/editor/debugger-adapter.ts @@ -10,28 +10,25 @@ * 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, - Unsubscribe, -} from '../../shared/ports/types' +import type { DebugSetResult, DebugVariableResult, Md5VerifyResult, Unsubscribe } from '../../shared/ports/types' export function createEditorDebuggerAdapter(): DebuggerPort { let connected = false 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 +61,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..85185b214 100644 --- a/src/middleware/adapters/editor/device-adapter.ts +++ b/src/middleware/adapters/editor/device-adapter.ts @@ -14,8 +14,8 @@ * 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 +38,41 @@ 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..fad89c06d 100644 --- a/src/middleware/shared/ports/device-port.ts +++ b/src/middleware/shared/ports/device-port.ts @@ -21,7 +21,58 @@ * - getDeviceStatus() */ -import type { BoardInfo, CommunicationPort } from './types' +import type { BoardInfo, CommunicationPort, DebugConnectionConfig, DebugMedium, DeviceLinkTransport } 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?: DeviceLinkTransport + /** + * Medium the DEBUG channel uses — the same as `transport` when one channel serves + * 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?: 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 + * 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 +110,76 @@ 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/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/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 f795de683..eb644ceb4 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 @@ -896,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 { @@ -1018,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: { 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..9b97fb113 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,12 @@ export const RUNTIME_V3_CAPABILITIES: TargetCapabilities = { arduinoApiCompletions: false, hasRuntimeStats: false, isInProcessSimulator: 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, } @@ -64,6 +71,7 @@ export const RUNTIME_V4_CAPABILITIES: TargetCapabilities = { arduinoApiCompletions: false, hasRuntimeStats: true, isInProcessSimulator: false, + plcStateControl: true, directUsbUpload: false, } @@ -83,5 +91,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..4671405bd 100644 --- a/src/middleware/shared/utils/target-capabilities/types.ts +++ b/src/middleware/shared/utils/target-capabilities/types.ts @@ -93,6 +93,19 @@ 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 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 * doesn't require a separate "Connect" step. Arduino-CLI + the * in-process Simulator. Runtime v3 / v4 require an established