Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c3dac15
feat(debugger): always-on serial debugger decoupled from full Modbus
marconetsf Jul 3, 2026
232da71
feat(vpp): resolve VPP select options dynamically from board data
marconetsf Jul 9, 2026
631c895
feat(compile): generate defines for the serial/network/modbus split
marconetsf Jul 9, 2026
cfd7e94
feat(firmware): keep the debugger serial up on Modbus TCP-only builds
marconetsf Jul 9, 2026
74ab1b2
feat(debugger): fall back to the serial channel when no channel matches
marconetsf Jul 9, 2026
4b3c138
feat(firmware): run Modbus RTU on a secondary serial while the debugg…
marconetsf Jul 9, 2026
86940ef
refactor(firmware): split ModbusSlave into per-entity modules
marconetsf Jul 10, 2026
c582131
docs(firmware): add ARCHITECTURE.md for the Modbus slave modules
marconetsf Jul 10, 2026
231e11d
feat(editor): one device connection over serial or Modbus TCP, with r…
marconetsf Aug 4, 2026
8ac7775
style: prettier + import sort over the connection surface
marconetsf Aug 4, 2026
0aa0c8a
fix(editor): align the debugger's baud with the firmware, and sweep o…
marconetsf Aug 4, 2026
ac16ea7
fix(editor): keep the baud out of a candidate's descriptor, and dial …
marconetsf Aug 4, 2026
474c149
fix(compile): name precompiled objects `<source>.cpp.o` so esp8266 li…
marconetsf Aug 4, 2026
976ed4c
fix(editor): stop the liveness poll from killing a busy debug session…
marconetsf Aug 4, 2026
2596f1e
fix(editor): a firmware that reports no unique id is still a firmware
marconetsf Aug 4, 2026
579cc05
fix(connection): reconnect after upload through the device port
marconetsf Aug 4, 2026
1c8ee64
refactor(connection): trace the outcome, not every step towards it
marconetsf Aug 4, 2026
46b2b12
Merge branch 'development' into feat/baremetal-connection
marconetsf Aug 5, 2026
87001fd
fix(runtime): address review findings on the device-connection PR
thiagoralves Aug 5, 2026
58bdcda
refactor(debug): one published medium drives the debug poll on both p…
thiagoralves Aug 5, 2026
7fd4fd4
Merge branch 'development' into feat/baremetal-connection
thiagoralves Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions resources/sources/Baremetal/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -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`).
49 changes: 46 additions & 3 deletions resources/sources/Baremetal/Baremetal.ino
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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++)
Expand All @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
Loading
Loading