When two or more upstream clients send the same FC03/FC04 read while a matching request is already in flight on the same PLC's multiplexed backend socket, attach the late arrivals to the existing InFlightRequest .InterestedParties list instead of opening a second backend round-trip. The single backend response fans out to every attached party with each party's original MBAP TxId restored individually. Zero post-response staleness — coalescing operates entirely within the in-flight window (microseconds to ~10 ms typical); the proxy is NOT a cache layer. Headline mechanism: - New record struct CoalescingKey(UnitId, Fc, StartAddress, Qty) keys the per-PLC InFlightByKeyMap. FC03 and FC04 are separate Modbus tables and never share a key; different unit IDs never coalesce; writes (FC06/FC16) bypass the coalescing path entirely. - InFlightByKeyMap uses a simple lock around a Dictionary; atomic TryAttachOrCreate either appends a new party to the in-flight request's mutable List<InterestedParty> or invokes a factory to build a fresh entry. Per-entry MaxParties cap (default 32) bounds fan-out cost; past the cap, the next arrival opens a new entry. - PlcMultiplexer.OnUpstreamFrameAsync takes the coalescing path for FC03/FC04 when Mbproxy.Resilience.ReadCoalescing.Enabled. The factory closure does the Phase-9 work (allocate TxId, add to CorrelationMap); the channel send happens AFTER returning from TryAttachOrCreate so the map lock is not held across the async send. - Response fan-out in RunBackendReaderAsync removes the entry from InFlightByKeyMap before iterating InterestedParties, ensuring no concurrent attach can mutate the list during iteration. - Cascade + watchdog paths also drain the key map so a stale entry cannot outlive its backend round-trip. Counter accounting balance (per snapshot): CoalescedHitCount + CoalescedMissCount equals total FC03 + FC04 requests since startup. Even with coalescing disabled, every read still bumps Miss so dashboard math stays balanced. New surface (additive only): - src/Mbproxy/Proxy/Multiplexing/CoalescingKey.cs - src/Mbproxy/Proxy/Multiplexing/InFlightByKeyMap.cs - src/Mbproxy/Proxy/Multiplexing/CoalescingLogEvents.cs - ReadCoalescingOptions on ResilienceOptions - CoalescedHitCount / CoalescedMissCount / CoalescedResponseToDeadUpstream counters surfaced on /status.json per PLC and as a compact "Coal" cell on the HTML status page. Phase 9 test patch: TwoUpstreams_ProxyTxIds_AreDistinct_OnTheWire previously read the same register from both clients (which now coalesces). Patched to read two different addresses so the test still proves distinct backend TxIds without violating the coalescing contract. Tests added: 24 new (19 unit + 5 E2E): - CoalescingKeyTests (5) - InFlightByKeyMapTests (6, includes concurrent stress) - ReadCoalescingTests (8, stub-backend with deterministic delay) - ReadCoalescingE2ETests (5, pymodbus simulator; coalescing-active during overlap is proven against the stub, not the sim, due to pymodbus 3.13's known concurrent-frame bug) Total: 325 tests passing (282 unit + 43 E2E). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
26 KiB
mbproxy — design plan
Architectural design for the mbproxy Modbus TCP proxy service: how it fronts ~54 AutomationDirect DirectLOGIC DL205/DL260 controllers, rewrites BCD tags bidirectionally inline, and recovers from listener and backend failures. Settled in a design Q&A on 2026-05-13.
Status: plan; no code yet. Each decision below is load-bearing — change deliberately, not by drift.
Context (what the service does and why it exists) lives in ../CLAUDE.md under "What this is" and "Purpose: bidirectional BCD rewrite". This file is the how. Device quirks the design depends on live in ../DL260/dl205.md.
Runtime shape: .NET 10 Generic Host worker service registered as a Windows Service via Microsoft.Extensions.Hosting.WindowsServices.
Listener topology — per-PLC port (one port → one PLC)
The host opens one TcpListener per PLC on a distinct port. Upstream clients reach a specific PLC by connecting to its assigned proxy port; no protocol-level routing is needed.
Client A ──┐
Client B ──┼──→ proxy:5020 ──→ PLC #1 (10.0.1.1:502)
├──→ proxy:5021 ──→ PLC #2 (10.0.1.2:502)
│ ...
└──→ proxy:5073 ──→ PLC #54 (10.0.1.54:502)
Connection model — single backend socket per PLC, multiplexed via MBAP TxId rewriting
Each PLC has one persistent backend TCP socket, owned by a PlcMultiplexer. Many upstream client connections share that single backend socket; the multiplexer distinguishes their in-flight requests by rewriting the MBAP transaction ID on each request and restoring each client's original TxId on the matching response. Implemented in Phase 09; replaced the prior 1:1 per-upstream-client backend-socket model.
Client A ─┐
Client B ─┼─→ proxy:5020 ─[ PlcMultiplexer ]─→ PLC #1 (10.0.1.1:502)
Client C ─┘ │ (one persistent socket)
▼
CorrelationMap[proxyTxId]
TxIdAllocator (16-bit space)
- Upstream → multiplexer: each accepted upstream socket is wrapped in an
UpstreamPipe(read loop + bounded response channel). The pipe's read loop hands every parsed MBAP frame to the multiplexer'sOnUpstreamFrameAsync, which allocates a free 16-bitproxyTxId, stores anInFlightRequestin aCorrelationMapkeyed by that proxyTxId, BCD-rewrites the request payload, overwrites the MBAP header's TxId field withproxyTxId, and enqueues the frame into the per-PLC outbound channel. - Multiplexer → backend: a single backend writer task drains the outbound channel and sends each frame to the PLC over the shared socket. A single backend reader task reads MBAP frames back, looks each up by
proxyTxIdin the correlation map, BCD-rewrites the response, restores each interested party's original TxId, and routes the frame to that party'sUpstreamPipe._responseChannel. The single-writer / single-reader invariant on the backend socket eliminates the need for socket-level synchronisation. - Per-request timeout watchdog: a periodic task scans the correlation map at a quarter of
Connection.BackendRequestTimeoutMsand times out any in-flight request whose response has not arrived. Timed-out requests get a Modbus exception 0x0B (Gateway Target Device Failed To Respond) delivered to their upstream party and free their allocator slot. Without this watchdog, a single lost or mis-routed response would leak a correlation entry forever and hang the upstream pipe indefinitely.
Operational consequence (replaces the prior 4-client warning). The H2-ECOM100's 4-concurrent-TCP-client cap (see ../DL260/dl205.md → Behavioral Oddities) no longer limits upstream-side connection count — the proxy holds exactly one slot per PLC regardless of how many upstream clients are attached. The wire-rate ceiling is unchanged (the ECOM internally serializes requests at ~2–10 ms per scan); the multiplexer shifts where serialization happens (proxy outbound queue vs PLC accept queue) rather than adding throughput.
⚠ Backend disconnect cascades upstream. When the backend socket dies (PLC reboot, network partition, middlebox idle drop), the multiplexer closes every attached upstream pipe in the same cycle and increments
BackendDisconnectCascadesby the upstream count. Clients reconnect on their own next request and the multiplexer Polly-reconnects to the backend on the first upstream frame.
⚠ pymodbus 3.13.0 simulator quirk (test-only). The pymodbus simulator's
ServerRequestHandlerstores a singlelast_pduper connection and schedules deferred handlers viaasyncio.call_soon. Two MBAP frames arriving in the same recv buffer (as the multiplexer can produce on its shared backend connection) overwritelast_pdubefore the first handler runs, and both responses then carry the later request's TxId. The real DL260 ECOM does not suffer this — it echoes per-request TxIds correctly. Multiplexer correctness under truly concurrent backend traffic is therefore proved against a stub backend inPlcMultiplexerTests; the E2E suite paces requests to keep pymodbus in known-good single-PDU mode. The per-request watchdog is the production defence against any backend (real or simulated) that mis-echoes a TxId.
Configuration — single appsettings.json
All configuration lives in one file, loaded via Microsoft.Extensions.Configuration and bound to typed POCOs. No sidecar YAML/CSV.
{
"Mbproxy": {
"BcdTags": {
"Global": [
{ "Address": 1072, "Width": 16 },
{ "Address": 1080, "Width": 32 }
]
},
"Plcs": [
{
"Name": "Line1-Mixer",
"ListenPort": 5020,
"Host": "10.0.1.1",
"BcdTags": {
"Add": [ { "Address": 1200, "Width": 32 } ],
"Remove": [ 1080 ]
}
},
{ "Name": "Line1-Conveyor", "ListenPort": 5021, "Host": "10.0.1.2" }
// ... 54 PLC rows
],
"AdminPort": 8080,
"Connection": {
"BackendConnectTimeoutMs": 3000,
"BackendRequestTimeoutMs": 3000
},
"Resilience": {
"BackendConnect": { "MaxAttempts": 3, "BackoffMs": [100, 500, 2000] },
"ListenerRecovery": { "InitialBackoffMs": [1000, 2000, 5000, 15000, 30000], "SteadyStateMs": 30000 }
}
}
}
Hybrid tag resolution. For each PLC, the effective BCD tag list is Global ∪ Add − Remove. Remove matches by address; if the same address appears in both Add and Global the Add entry wins (this is how a width override is expressed). Validation at startup must:
- reject duplicate addresses within a single PLC's resolved list
- reject 32-bit entries that would have their high register overlap a separate 16-bit entry
- warn on
Removeentries that don't match any global tag (probably stale config)
Configuration hot-reload
Microsoft.Extensions.Configuration loads appsettings.json with reloadOnChange: true, and all consumers read via IOptionsMonitor<MbproxyOptions> so a save to the config file propagates without restarting the service. Each change kind has explicit reconcile semantics:
| Change in appsettings | Propagation |
|---|---|
BcdTags.Global add/remove/width |
Rewriter dereferences the monitor per-PDU. Next PDU sees the new map; in-flight reads/writes are not retroactively touched. |
Plcs[i].BcdTags.{Add,Remove} |
Same — next-PDU resolution. |
New Plcs[i] entry |
Listener supervisor binds the new port subject to the same eager-then-auto-recover policy. |
Plcs[i] removed |
Supervisor stops the listener and closes all upstream client connections for that PLC. |
Plcs[i].ListenPort or Host changed |
Equivalent to remove + add. |
Connection.Backend*TimeoutMs |
Next backend connect/request uses the new value. In-flight operations keep their already-applied timeout. |
| Invalid reload (schema break, duplicate ports, duplicate addresses in a resolved tag list) | Reload is rejected as a whole; current in-memory config stays in effect; mbproxy.config.reload.rejected is logged at Error. |
Every accepted reload emits mbproxy.config.reload.applied at Information with a summary of which PLCs were added/removed and the size of the tag-list delta.
BCD tag shape
public sealed record BcdTag(ushort Address, byte Width); // Width ∈ { 16, 32 }
- 16-bit BCD — one register holds 4 BCD digits (0–9999). Wire value
0x1234decodes to decimal 1234. - 32-bit BCD — a CDAB-ordered register pair at
AddressandAddress+1. The register atAddressholds the low 4 digits; the register atAddress+1holds the high 4 digits. Decoded decimal =high * 10000 + low. This follows directly from DirectLOGIC's CDAB word order (see../DL260/dl205.md→ Word Order). - Unsigned only. DL205/DL260 BCD is non-negative in the default ladder pattern; the proxy does not implement signed BCD.
- Holding-register and input-register addresses share the same space. The rewriter applies the configured tag list against both FC03 and FC04 reads.
Read coalescing (Phase 10)
After Phase 10, FC03 / FC04 requests are additionally subject to in-flight read coalescing before they reach the backend. When two or more upstream clients send the same (unitId, fc, startAddress, qty) tuple within the in-flight window of an already-routed request, the multiplexer attaches each late arrival to the existing InFlightRequest.InterestedParties list instead of opening a second backend round-trip. The single backend response is fanned out to every attached party with each party's original MBAP TxId restored individually.
Properties:
- Zero post-response staleness. Coalescing operates entirely between "first request sent to backend" and "response received from backend" (microseconds to ~10 ms typical). Once the response is fanned out, the coalescing entry dies. The proxy is NOT a cache layer — the value each upstream sees is the same value an uncoalesced request would have returned within the PLC's scan-time precision.
- Only FC03 / FC04. Writes (FC06 / FC16) are non-idempotent on BCD tags and never coalesced. Different function codes never share a
CoalescingKeyeven at the same address (FC03 and FC04 read different Modbus tables). DifferentunitIdbytes never coalesce (different PLC personalities behind a shared socket). - Bounded fan-out via
MaxParties(default 32 inMbproxy.Resilience.ReadCoalescing.MaxParties). Once an entry hasMaxPartiesinterested clients, the next arrival opens a fresh entry — bounds the response-fanout cost per entry at O(MaxParties) and shields the backend reader task from pathological pile-on. - Hot-reloadable on/off.
Mbproxy.Resilience.ReadCoalescing.Enableddefaults totrue. Flipping it tofalseat runtime leaves running coalesced entries to drain naturally; subsequent FC03/04 requests take the Phase-9 (one round-trip per upstream request) path. - Transparency contract preserved. Each upstream client still sees its own original MBAP TxId on the response. The BCD rewriter runs once on the shared response buffer; per-party copies are only made when fan-out has more than one party.
Counter accounting balance (per snapshot): coalescedHitCount + coalescedMissCount equals the total FC03 + FC04 requests seen since the multiplexer was constructed. Both counters increment regardless of whether the coalescing feature is enabled — coalescedHitCount is 0 when disabled, but every read still increments coalescedMissCount.
Rewriter — function code scope
The rewriter inspects and rewrites payloads only for these function codes; every other FC (coils, discrete inputs, diagnostics, exception responses) passes through byte-for-byte:
| FC | Direction | Action |
|---|---|---|
| 03 | request + response | FC03 requests may be coalesced with peers before reaching the backend (see Phase-10 section above); response re-encodes covered BCD slots from raw nibbles → binary integer |
| 04 | request + response | Same coalescing eligibility as FC03; response re-encoding the same as FC03 (input-register table also surfaces V-memory) |
| 06 | request | Re-encode binary integer → BCD nibbles before forwarding |
| 06 | response | Decode BCD nibbles → binary integer on the echo (clients validate that the echoed value equals the value they sent; without this, NModbus-style clients throw on the round-trip) |
| 16 | request | Per-register over the configured slots, then forward |
Partial-overlap policy. A request that touches only ONE register of a configured 32-bit BCD pair (qty=1 at the low addr, or any read/write of the high addr alone) passes through raw with a mbproxy.rewrite.partial_bcd warning. The proxy never synthesises a Modbus exception for a partial-overlap — that response code is reserved for transport failure.
Failure modes — transparent pass-through with Polly-bounded backend connect
- PLC returns a Modbus exception (codes 01–04) → forward verbatim with the original MBAP transaction ID. The client sees the real DL205/DL260 exception.
- Backend connect refused or initial connect timeout → retry under a Polly resilience pipeline: 3 attempts at 100ms / 500ms / 2000ms backoff (tuned via
Resilience.BackendConnect). If all attempts fail, the multiplexer closes the upstream client connection that triggered the connect. - Backend mid-stream broken socket → the multiplexer's reader/writer task throws; the backend tear-down path cancels both tasks, drains the correlation map, and cascades the disconnect by closing every attached upstream pipe. The next upstream request to any pipe triggers a fresh backend connect through the Polly pipeline.
BackendDisconnectCascadescounter records the upstream-pipe count at each cascade event. - Backend request timeout → the per-request watchdog times out any correlation entry older than
Connection.BackendRequestTimeoutMs, delivers Modbus exception 0x0B (Gateway Target Device Failed To Respond) with the original TxId to the upstream party, and frees the proxy TxId. No mid-request retries — FC06 / FC16 are non-idempotent on BCD tags (a partial-applied multi-register write could leave a 32-bit BCD tag mid-transition), so every in-flight request is one-shot. The client interprets the 0x0B as a transport failure and reconnects through its normal path. - Partial-BCD overlap → forward raw + warn (see Rewriter section).
- One slow PLC does not stall the rest of the fleet. Each PLC has its own
PlcMultiplexer, with its own backend socket, correlation map, and outbound channel; per-PLC failures are local. A slow or dead backend on one PLC only impacts that PLC's clients.
Startup posture — eager, continue on per-port failure
At startup the host attempts to bind all 54 listen sockets up front. Each failure (port already in use, invalid IP, malformed PLC entry) is logged at Error and handed off to the listener supervisor (next section). The service proceeds with whichever PLCs bound on the first attempt; the rest converge in the background. Monitoring should alert on mbproxy.startup.bind.failed so missing PLCs aren't silently dropped, and watch for mbproxy.listener.recovered to confirm late binds eventually succeeded.
Listener auto-recovery (Polly-backed supervisor)
Each PLC's listener runs under a supervisor task that owns its bind lifecycle. If a bind fails at startup, or if a listener faults at runtime (port stolen by another process, transient OS network reset), the supervisor reattempts via a Polly retry pipeline: 5 attempts at 1s / 2s / 5s / 15s / 30s backoff, then steady-state retries every 30s indefinitely (tuned via Resilience.ListenerRecovery). Each attempt logs at Debug; the bind that finally succeeds emits one mbproxy.listener.recovered Information event.
While a supervisor is between attempts, the corresponding PLC is reported as listener.state = recovering on the status page. Hot-reload uses the same supervisor to bring newly-added PLCs online and to tear down removed ones — there is exactly one code path for "bring up a listener" and one for "shut a listener down."
Logging — Serilog, structured, console + rolling file
Serilog wired through the Microsoft.Extensions.Logging bridge:
- Console sink for interactive
--consoleruns. - Rolling-file sink under
%ProgramData%\mbproxy\logs\. - Default level Information. Per-PLC and per-client scopes via
LogContext.PushProperty("Plc", name)/("Client", remoteEp)so log lines are greppable across the fleet.
Stable event names (keep these stable so log queries don't churn):
| Event | Level | Properties |
|---|---|---|
mbproxy.startup.bind |
Info | Plc, Port |
mbproxy.startup.bind.failed |
Error | Plc, Port, Reason |
mbproxy.listener.recovered |
Info | Plc, Port, AttemptCount |
mbproxy.client.connected |
Info | Plc, RemoteEp |
mbproxy.client.disconnected |
Info | Plc, RemoteEp, Reason |
mbproxy.backend.failed |
Warning | Plc, Reason |
mbproxy.rewrite.partial_bcd |
Warning | Plc, Address, ClientStart, ClientQty |
mbproxy.rewrite.invalid_bcd |
Warning | Plc, Address, RawValue, Direction |
mbproxy.exception.passthrough |
Info | Plc, Fc, ExceptionCode |
mbproxy.config.reload.applied |
Info | PlcsAdded, PlcsRemoved, TagDelta |
mbproxy.config.reload.rejected |
Error | Reason |
mbproxy.admin.bind.failed |
Error | Port, Reason |
mbproxy.multiplex.backend.connected |
Info | Plc, Host, Port |
mbproxy.multiplex.backend.disconnected |
Warning | Plc, UpstreamCount, InFlightCount, Reason |
mbproxy.multiplex.saturated |
Error | Plc, RemoteEp (16-bit TxId space full) |
mbproxy.multiplex.request.timeout |
Warning | Plc, ProxyTxId, OriginalTxId, Fc, ElapsedMs |
mbproxy.coalesce.hit |
Debug | Plc, UnitId, Fc, Start, Qty, PartyCount |
mbproxy.coalesce.miss |
Debug | Plc, UnitId, Fc, Start, Qty |
mbproxy.coalesce.dead_upstream |
Debug | Plc, UnitId, Fc, Start, Qty |
Status page — read-only HTTP endpoint
A separate Kestrel-hosted minimal API runs on Mbproxy.AdminPort (default 8080, distinct from the Modbus listen ports). The endpoint set is intentionally narrow — read-only telemetry; no admin actions (kick client, force reload, restart listener) are exposed:
GET /— single self-contained HTML page rendering a table of all configured PLCs with their state and live counters. Auto-refreshes every 5s via a meta-refresh tag (no JS bundle, no external assets).GET /status.json— the same data as JSON for monitoring scrapers.
Authentication is assumed to live at the network layer (trusted internal segment behind a firewall). Surface that assumption in deployment docs when they exist.
Service-wide fields:
| Field | Meaning |
|---|---|
service.uptime |
Seconds since service start |
service.version |
Assembly informational version |
service.config.lastReloadUtc |
Timestamp of last accepted hot-reload (or null) |
service.config.reloadCount |
Number of reloads accepted since start |
service.config.reloadRejectedCount |
Number of reloads rejected since start |
listeners.bound / listeners.configured |
Bound listener count vs configured PLC count |
Per-PLC fields (one row per Plcs[i]):
| Field | Meaning |
|---|---|
name, host, listenPort |
Identity from config |
listener.state |
bound / recovering / stopped |
listener.lastBindError |
Most recent bind failure message (when recovering) |
listener.recoveryAttempts |
Polly retry count since last successful bind |
clients.connected |
Currently connected upstream client count |
clients.remoteEndpoints |
Array of { remote, connectedAtUtc, pdusForwarded } |
pdus.forwarded |
Total PDUs (request+response) forwarded since start |
pdus.byFc |
{ fc03, fc04, fc06, fc16, other } request counts |
pdus.rewrittenSlots |
Count of register slots BCD-rewritten |
pdus.partialBcdWarnings |
Count of partial-overlap pass-throughs |
backend.connects.success / backend.connects.failed |
Polly-final-result counters |
backend.exceptions.byCode |
{ "01": n, "02": n, "03": n, "04": n } |
backend.lastRoundTripMs |
EWMA of recent successful round-trip times |
backend.coalescedHitCount |
FC03/04 requests that attached to an already-in-flight peer (Phase 10) |
backend.coalescedMissCount |
FC03/04 requests that opened a fresh backend round-trip (Phase 10). Hit + Miss = total FC03/04 requests |
backend.coalescedResponseToDeadUpstream |
Coalesced fan-out responses skipped because the attached upstream had already disconnected (Phase 10) |
bytes.upstreamIn / bytes.upstreamOut |
Bytes forwarded each direction |
Counters are System.Threading.Interlocked longs read atomically per request; no locking on the read path.
Test simulator — pymodbus DL260/DL205 server
The pymodbus profile at ../DL260/dl205.json already models the DL205/DL260 quirks (BCD nibbles at known addresses, CDAB-ordered 32-bit values, C-relay/Y-output coil mappings, etc.) as concrete register seeds. The test infrastructure wraps it as a managed lifecycle so every integration / e2e test gets a fresh known-good DL-series target without needing real hardware.
Harness shape (lives under tests/sim/):
- Launcher script —
tests/sim/run-dl205-sim.ps1provisions a Python venv undertests/sim/.venvon first run (python -m venv+pip install pymodbus), then launchespymodbus.serverwith thedl205.jsonprofile on a configurable port. Idempotent: re-runs reuse the venv. - xUnit fixture —
Mbproxy.Tests.Sim.DL205SimulatorFixture : IAsyncLifetimethat:InitializeAsync: spawns the simulator subprocess, pollsTcpClient.ConnectAsyncagainst the port until success or a 10 s deadline, captures stdout/stderr to test output.DisposeAsync: signals graceful shutdown (Ctrl-C on the process group on Windows), thenProcess.Kill(entireProcessTree: true)as a safety net.- Exposes
Host,Port,LogTail(last N lines of sim stderr for diagnosis).
- Test collection —
[CollectionDefinition(nameof(DL205SimulatorCollection))]so the fixture is shared across all integration/e2e classes that opt in (cheap startup, expensive process churn). - Skip policy — if Python or pymodbus isn't available and the auto-provision fails (no network, locked-down CI image, etc.),
InitializeAsyncrecords the reason and tests skip viaAssert.Skip(sim.SkipReason). CI must have Python 3.10+ available; local devs running only the rewriter unit tests need nothing extra. - Alternate profiles — additional scenarios (e.g., a profile that seeds a specific partial-overlap test case, or a profile with strict
type exception: trueto verify the proxy doesn't depend on lax pymodbus behaviour) live alongsidedl205.jsonand are selected viaMODBUS_SIM_PROFILEenv var, matching the pattern already established by../DL260/DL205BcdQuirkTests.cs.
The simulator IS the proxy's end-to-end test bed. A standard e2e test does:
- Start the simulator at
127.0.0.1:<simPort>. - Configure the proxy with one PLC entry
Host=127.0.0.1, Port=<simPort>, ListenPort=<proxyPort>. - Start the proxy (in-process via
WebApplicationFactory-style host construction). - Drive a plain Modbus TCP client (
NModbusorFluentModbus) against127.0.0.1:<proxyPort>. - Assert two directions:
- Read: client sees the BCD-decoded integer (proxy rewrote the response).
- Write: simulator's register state shows the BCD-encoded nibbles (proxy rewrote the request).
Testing
- Unit tests — drive the BCD rewriter with synthetic Modbus PDU byte arrays. No network, no simulator. Cover every FC03/04/06/16 × {single 16-bit, full 32-bit pair, partial-overlap low, partial-overlap high, mixed-with-non-BCD} cell.
- Integration tests — drive the proxy end-to-end against the pymodbus simulator described in the previous section, using a plain Modbus TCP client (
NModbusorFluentModbus) againstproxy:<listenPort>and asserting the decoded value rather than the raw register bytes. - Auto-recovery tests — bind a
TcpListeneron a target port BEFORE starting the proxy, assert that the supervisor entersrecoveringstate, release the port, and assert the next supervisor attempt succeeds andmbproxy.listener.recoveredfires. Also cover the runtime-fault path by forcing the accept loop to throw and asserting the supervisor reattempts. - Hot-reload tests — write a temp
appsettings.json, start the host, mutate the file (add a PLC, remove a PLC, change a global tag width), and assert: (a) supervisor adds/removes the affected listener, (b) the rewriter on the next PDU reflects the new tag map, (c) a malformed reload is rejected without breaking the running config. Cover bothmbproxy.config.reload.appliedandmbproxy.config.reload.rejectedpaths. - Status page tests — start the host, induce known events (connect 2 clients, force a backend exception, trigger a partial-BCD warning), and assert
GET /status.jsonreturns the expected counters. The HTML page is verified separately as a smoke test that the route returns 200 withtext/html.