Merge commit '7feffc96' into remed/lane-Modbus

This commit is contained in:
Joseph Doherty
2026-10-22 16:19:17 -04:00
105 changed files with 11332 additions and 2594 deletions
+50 -8
View File
@@ -88,6 +88,9 @@ After the handshake, Test Connect reads 2 bytes from `MB0`. A CPU with PUT/GET d
default) completes the handshake but denies every read, so the probe fails with an actionable "enable
PUT/GET communication" message instead of a false green tick. A CPU that merely rejects that address (no
`MB0`) still passes — the connection is fine; any other read fault fails the probe with its message.
Both are recognised in the bare-`Exception` shape S7.Net's read path really raises. The handshake and
test read are bounded by the config's `timeoutMs` (the driver's own per-operation timeout), capped by the
Test Connect timeout — before R07:H7 the config value was ignored.
## Address forms
@@ -103,9 +106,18 @@ Addresses use Siemens TIA-Portal / STEP 7 Classic syntax, parsed by
| Timer | `T0` / `T15` | Timer area (read-only — see Wide types section) |
| Counter | `C0` / `C10` | Counter area (read-only — see Wide types section) |
Parsing is strict and runs once at `InitializeAsync` so a config typo fails fast
at load instead of surfacing as `BadInternalError` on every read. Bit offsets
must be 0-7, byte offsets non-negative, DB numbers >= 1.
Parsing is strict. Bit offsets must be 0-7, byte offsets non-negative, DB numbers >= 1.
**Tag shape rules are judged per tag, at every gate** (`S7TagShapeRules`, called by
`S7TagDefinitionFactory.FromTagConfig`): the AdminUI editor refuses the tag, the deploy gate
(`Inspect`) warns, and the driver's init **skips** it (reported through `ISkippedTagReporter`; it reads
`BadNodeIdUnknown`) while every other tag keeps working. Refused: an address that does not parse, a
wide/structured type that is an array or is not byte-addressed (`DBB`/`MB`/`IB`/`QB`), a narrow type at the
wrong access width (`Int32` on `DBW0`, `Bool` on `DBB0`...), a Timer not typed `Float64` or a Counter not typed
`Int32`, a writable or array Timer/Counter, a writable `Bool` array, and a `Bool` array that does not start
at bit 0 (a Bool array reads bit 0 of each byte, so `DB1.DBX0.3` used to silently return bit 0 of bytes
0..n). These used to be init guards that THREW, so one typo faulted every tag on the PLC and the actor
retried the same init every 10 s forever (R07:H3).
## Data types
@@ -277,7 +289,7 @@ AdminUI `/hosts` page (Gitea #640). See `docs/Telemetry.md` ("Driver diagnostics
| `IWritable` | `WriteAsync` → `S7.Net.Plc.WriteAsync` | Read-only tags (`Writable=false`) return `BadNotWritable` |
| `ITagDiscovery` | `DiscoverAsync` | Emits a flat `S7/` folder of the configured tags — no live browse |
| `ISubscribable` | per-tag poll loop with capped exponential backoff | S7 has no push model; floor is 100 ms (the CPU services the comms mailbox once per scan) |
| `IHostConnectivityProbe` | periodic `S7.Net.Plc.ReadStatusAsync` (CPU-status PDU) | `host:port` host key; `Running`/`Stopped` transitions raise `OnHostStatusChanged` |
| `IHostConnectivityProbe` | periodic `S7.Net.Plc.ReadStatusAsync` (CPU-status PDU) | `host:port` host key; `Running`/`Stopped` transitions raise `OnHostStatusChanged`. A tick that finds the gate held past the probe deadline (a long read pass) gives **no verdict** — it no longer reports `Stopped` for a connection that is busy, not lost (R07:H7) |
### Single-connection policy
@@ -302,8 +314,9 @@ Portal under *Protection & Security* for the CPU.
| Tag not in config | `BadNodeIdUnknown` | unchanged |
| Read-only tag written | `BadNotWritable` | unchanged |
| Unimplemented data type | `BadNotSupported` | unchanged |
| PUT/GET denied | `BadNotSupported` | `Faulted` (config alert) |
| CPU / hardware fault | `BadDeviceFailure` | `Degraded` |
| PUT/GET denied | `BadNotSupported` | `Faulted` (config alert) — also counts as *answered*, advances `LastSuccessfulRead` (no watchdog reconnect loop) |
| CPU refusal of the item (object does not exist, address out of range, type inconsistent, hardware fault) | `BadDeviceFailure` | unchanged — counts as *answered*, advances `LastSuccessfulRead`; one Warning per tag |
| Other device-layer fault (`PlcException` that is neither a refusal nor connection-fatal) | `BadDeviceFailure` | `Degraded` |
| Socket / timeout | `BadCommunicationError` | `Degraded` |
| Reconnect suppressed (backoff window) | `BadCommunicationError` | `Degraded` |
| Array write of the wrong shape / length / element type | `BadTypeMismatch` | unchanged |
@@ -314,7 +327,36 @@ accumulate their outcomes and write health once (`Healthy < Degraded < Faulted`)
`Faulted` item is not erased by a later `Degraded` or successful sibling. The write-side `Faulted` is
**sticky against reads**: a clean read pass (reads often work against a write-protected CPU) leaves
`Faulted` in place and only advances `LastSuccessfulRead`; the next write that succeeds clears it. A
transient `Degraded` write failure is still cleared by a clean read, as before.
transient `Degraded` write failure is cleared by a clean read, or by the next write batch the CPU
answers without a failure.
**A refusal is an answer (R07:H4).** S7.Net's read paths raise the CPU's item error as a bare
`Exception` ("Received error from PLC: Object does not exist." — live-probed on the snap7 fixture), its
write paths as a `PlcException(WriteData)`; both are classified over the whole exception chain, and the
access-denied text inside either (S7.Net's exact "Accessing object not allowed", and never when the chain
also carries a transport fault) is the `Faulted` alert — which is ALSO an answer: it advances
`LastSuccessfulRead`, so a factory-default CPU with PUT/GET disabled stays `Faulted` instead of being
reconnected by the comms-loss watchdog every 90 s. A multi-var chunk that falls back per tag memoises the
members refused on their own, like a refused span. A refusal is deterministic, so it neither
degrades health nor reopens the connection, and because it proves the CPU is reachable it advances
`LastSuccessfulRead` and restores `Healthy` after an earlier comms fault. Before this, a driver whose
every tag was refused (a wrong DB number) sat `Degraded` with a frozen timestamp and the comms-loss
watchdog reconnected it every 90 s against a perfectly reachable PLC.
**A dead handle ends the pass's wire traffic (R07:P1).** Once a read pass or write batch marks the
connection dead (a wire op blew its deadline on a frozen-but-established peer, a socket dropped), the
remaining spans / multi-var chunks / tags / writes of that pass are stamped `BadCommunicationError`
without I/O; the next call reopens. A multi-var chunk that dies on a transport loss no longer falls back
to one doomed per-tag read per member. Without this an op that ignores its cancellation token would cost
one full deadline per remaining item under the gate, and `Degraded` (and so the comms-loss watchdog's
clock) only became visible after the whole pass.
**A refused span falls back per member (R07:P2).** A grouped block read covers the union of its members'
ranges, so one member the CPU refuses (running past the DB's real end, an over-declared `stringLength`)
used to make every neighbour in the block Bad on every poll. When the CPU refuses a multi-member span the
driver re-reads its members one at a time for that pass and remembers which tags must not be grouped —
the members refused on their own, or every member when none was — so later passes group only the rest.
The memo lasts until the next initialize. A span failure that is not a refusal still stamps every member.
**Connect backoff.** A dead PLC costs one connect attempt per backoff window (1 s doubling to a 30 s
cap), not one per read / poll tick / probe tick: inside the window `EnsureConnectedAsync` fails fast
@@ -359,7 +401,7 @@ normal dev (the S7 sim is on the shared Docker host but currently offline).
**Array writes (S7-016)** — a writable `Byte` / `Int16` / `UInt16` / `Int32` / `UInt32` / `Float32` array
is written as ONE contiguous `WriteBytesAsync` of `count x element-bytes` (the inverse of the read
decode), and the value must be an array of exactly `arrayLength` elements (else `BadTypeMismatch`, no
health impact). **Writable `Bool` arrays are refused at init** (`NotSupportedException`): the array
health impact). **Writable `Bool` arrays are refused per tag** (see the tag shape rules above): the array
read is byte-granular, so a matching write would overwrite the 7 neighbouring bits of every byte it
touches, and a safe bit-packed write needs read-modify-write. Mark Bool arrays `writable: false`. A `null` array element is rejected (`BadTypeMismatch`, nothing written)
rather than coerced to 0. An array write larger than one PDU is split by S7.Net into several writes and is