#152 left a hook for structured logging when an auto-prohibition first
fires; this commit completes the wiring.
Changes:
- ModbusDriver constructor takes an optional ILogger<ModbusDriver> (defaults
to NullLogger). Existing standalone callers stay compile-clean.
- RecordAutoProhibition logs LogWarning on first-fire only (re-fires of the
same range stay quiet via the existing isNew de-dupe). Format includes
DriverInstanceId, UnitId, Region, Start, End, Span — log aggregators can
filter / count by any field.
- New LogProhibitionCleared helper called by both StraightReprobeAsync (when
the re-probe succeeds on a single-register range) and BisectAndReprobeAsync
(per-half clearing + a single combined line when both halves succeed).
- ModbusDriverFactoryExtensions.Register accepts an optional ILoggerFactory.
Captured at registration time and used in the factory closure to construct
a per-driver logger. Server bootstrap code that already has an ILoggerFactory
in DI threads it through with a single argument addition; old call sites
(Register(registry)) keep working with a null logger.
Tests (2 new ModbusLoggerInjectionTests):
- First_Failure_Emits_Single_Warning_Subsequent_Refire_Stays_Quiet — pins
the de-dupe behaviour. First scan logs one warning with the expected
structured fields; second scan with the same prohibition stays silent.
- Reprobe_Clearing_Prohibition_Emits_Information_Log — protected register
unlocked between record and re-probe; re-probe success emits an info log
containing "cleared".
CapturingLogger test harness is purpose-built (xUnit doesn't ship a logger
mock by default and adding Moq is overkill for two tests).
240 + 2 = 242 unit tests green.
Auto-prohibited ranges (#148) were previously visible only through an
internal AutoProhibitedRangeCount accessor used by tests. Production
operators had no way to see what the planner had learned without pulling
logs or inspecting driver state.
Changes:
- New public record `ModbusAutoProhibition(UnitId, Region, StartAddress,
EndAddress, LastProbedUtc, BisectionPending)` — operator-facing snapshot
shape. Lives in the addressing assembly's logical namespace alongside
the other public types.
- `ModbusDriver.GetAutoProhibitedRanges()` returns
`IReadOnlyList<ModbusAutoProhibition>` — a copy of the live prohibition
map. Lock-protected snapshot so consumers don't race with the re-probe
loop.
- RecordAutoProhibition tracks first-fire vs re-fire via the dictionary
insert path, leaving a hook to add structured logging once an ILogger
is plumbed through (currently elided to keep the constructor minimal
for testability — a future change can wire ILogger and emit a single
warning per first-fire).
Tests (1 new, additive to the 6 in ModbusCoalescingAutoRecoveryTests):
- GetAutoProhibitedRanges_Surfaces_Operator_Visible_Snapshot — confirms
the snapshot shape: empty before any failure, populated with correct
UnitId/Region/Start/End/BisectionPending after a failed coalesced read,
LastProbedUtc within the recent past.
Docs:
- docs/v2/modbus-addressing.md — new "Coalescing auto-recovery" subsection
consolidates the #148/#150/#151/#152 surface in one place. Documents
the diagnostic accessor + flags the in-process consumption pattern
(Server health endpoints today; Admin UI when an RPC channel exists).
239 + 1 = 240 unit tests green.
Caveat: the Admin UI surfacing (table render, "clear all prohibitions"
button) is intentionally NOT shipped here. Admin can't reach a live
ModbusDriver instance without a driver-diagnostics RPC channel that
doesn't exist yet — that's a larger architectural piece. For now the
data is queryable in-process by the Server's health endpoints; once an
RPC channel lands, Admin can wire the existing GetAutoProhibitedRanges
into a Blazor table without further driver changes.
Pre-#150 a coalesced read failure recorded the FULL failed range as
permanently prohibited. Healthy registers around the actual protected
register stayed in per-tag mode forever (until ReinitializeAsync). The
re-probe loop shipped in #151 retried the whole range as a single block,
which would either succeed (clearing everything) or fail (changing
nothing).
Post-#150 the re-probe loop bisects multi-register prohibitions:
- _autoProhibited refactored from Dictionary<key, DateTime> to
Dictionary<key, ProhibitionState> where ProhibitionState carries
LastProbedUtc + SplitPending. Multi-register prohibitions enter with
SplitPending=true; single-register prohibitions enter with
SplitPending=false (already minimal).
- ReprobeLoopAsync delegates the per-pass work to
RunReprobeOnceForTestAsync (also exposed for synchronous test driving).
Each entry routes to BisectAndReprobeAsync (split-pending + multi-reg)
or StraightReprobeAsync (single-reg / non-split-pending).
- Bisection: split (start, end) at mid = (start+end)/2. Try (start, mid)
and (mid+1, end) as separate coalesced reads. Each FAILED half re-enters
the prohibition map with SplitPending = (its end > its start). SUCCEEDED
halves vanish, freeing the planner to coalesce across them on the next
scan.
- Convergence: log2(span) re-probe ticks pin the prohibition to the
actual single offending register(s). For a 100-register block with one
protected address that's ~7 ticks.
Tests (3 new ModbusCoalescingBisectionTests):
- Bisection_Narrows_Multi_Register_Prohibition_Per_Reprobe — 11 tags
100..110 with protected address 105. After 4 re-probe passes the
prohibition collapses from (100..110) → (100..105) → (103..105) →
(105..105).
- Bisection_Clears_When_Both_Halves_Are_Healthy — transient failure
scenario; protection lifted before re-probe; both bisection halves
succeed and the parent vanishes entirely.
- Bisection_Splits_Into_Two_When_Both_Halves_Still_Fail — TwoHoleTransport
with protected addresses 102 + 108 in the same coalesced range. After
bisection both halves still fail (each contains one of the protected
addresses); the prohibition map grows to 2 entries.
236 + 3 = 239 unit tests green. Solution build clean.
#148 introduced auto-prohibited coalesced ranges that persist for the
driver lifetime. Long-running deployments with transient PLC permission
changes (firmware update unlocking a previously-protected register,
operator reconfiguring the device) had no recovery short of operator
restart.
Adds an opt-in background loop that re-probes each prohibition periodically:
- ModbusDriverOptions.AutoProhibitReprobeInterval (TimeSpan?, default null
= disabled). Set to e.g. TimeSpan.FromHours(1) to opt in.
- _autoProhibited refactored from HashSet<key> to Dictionary<key, DateTime>
so each entry tracks its last failure / last re-probe timestamp.
- ReprobeLoopAsync runs on the same Task.Run pattern as ProbeLoopAsync;
cancelled by ShutdownAsync. Each tick snapshots the prohibition set
and issues a one-shot coalesced read per range. Successful re-probes
drop the prohibition; failed ones bump the timestamp + leave the
prohibition in place.
- Communication failures during re-probe (transport-level) are treated
the same as PLC-exception failures — the prohibition stays, but isn't
upgraded to "permanent" since transports recover. The driver-instance
health surface picks up the failure separately.
- ShutdownAsync explicitly clears the prohibition set so a manual restart
via ReinitializeAsync starts with a clean slate (matches the old
"restart to clear" semantics).
- Factory DTO + JSON binding extended with AutoProhibitReprobeMs field.
Tests (2 new, additive to the 3 in ModbusCoalescingAutoRecoveryTests):
- Reprobe_Clears_Prohibition_When_Range_Becomes_Healthy — protected
register at 102 records prohibition; clearing the simulated protection
+ invoking the re-probe drops the prohibition.
- Reprobe_Leaves_Prohibition_When_Range_Is_Still_Bad — re-probe on a
still-failing range keeps the prohibition in place.
Tests use a new internal RunReprobeOnceForTestAsync helper to fire one
re-probe pass synchronously, so the suite doesn't have to wait on the
background timer (the loop's timer behaviour is exercised implicitly via
the InitializeAsync wire-up + the synchronous helper sharing the actual
re-probe code path).
234 + 2 = 236 unit tests green.
Pre-#148 behaviour: a coalesced FC03/FC04 read that crossed a write-only or
PLC-fault register marked every member tag Bad until the operator manually
flagged the offending tag with CoalesceProhibited. Healthy tags around the
hole stayed broken indefinitely.
Post-#148: two-stage recovery, no operator intervention needed.
1. Same-scan fallback: when a coalesced read fails with a Modbus exception
(IllegalDataAddress, SlaveDeviceFailure, etc.), the planner does NOT
mark members handled. The per-tag fallback in the same scan reads each
member individually — non-protected members surface Good values
immediately, and only the actual protected register stays Bad.
2. Cross-scan prohibition: the failed range (Unit, Region, Start, End) is
recorded in a per-driver `_autoProhibited` set. On subsequent scans the
planner checks each candidate merge against the set and refuses to
re-form any block that overlaps a known-bad range. Net effect: after one
scan with a failure, the protected range goes "per-tag mode" indefinitely
while ranges around it keep coalescing normally.
Communication failures (timeouts, socket drops) are NOT auto-prohibited —
they're transport-level, not structural. The same coalesced read can succeed
once the transport recovers; recording it as "permanently bad" would defeat
coalescing for the whole driver instance.
Auto-prohibition state lives for the driver lifetime and clears on
ReinitializeAsync (operator restart). A periodic re-probe is a follow-up if
deployments need it without a restart.
Implementation:
- Added `_autoProhibited` HashSet<(byte, ModbusRegion, ushort, ushort)> +
`_autoProhibitedLock` on ModbusDriver.
- `RangeIsAutoProhibited(unit, region, start, end)` overlap check called
from the planner when forming blocks.
- `RecordAutoProhibition(...)` called from the catch (ModbusException)
branch.
- The catch (Exception) branch (non-Modbus failures) keeps the pre-#148
"mark all Bad in this scan, don't auto-prohibit" behaviour.
- Internal `AutoProhibitedRangeCount` accessor for tests.
Tests (3 new ModbusCoalescingAutoRecoveryTests):
- First_Failure_Falls_Back_To_PerTag_Same_Scan — three tags around a
protected register at 102: T100 + T104 surface Good values via the
per-tag fallback in the SAME scan; T102 surfaces the exception.
- Second_Scan_Skips_Coalesced_Read_Of_Prohibited_Range — confirms scan 2
doesn't re-attempt the failed merge (no FC03 with quantity > 1 at the
prohibited start).
- Tags_Outside_Prohibited_Range_Still_Coalesce — separate cluster at HR
200..202 keeps coalescing normally even after the 100..104 cluster is
prohibited.
234/234 unit tests green.
Follow-ups intentionally NOT shipped (smaller, independent changes):
- Bisection-style range narrowing — currently the prohibition range is the
full failed block; the planner doesn't try to find the exact protected
register. Operator-visible diagnostic + prohibition stays correct.
- Periodic re-probe to clear stale prohibitions.
- Surface auto-prohibited ranges through GetHostStatuses or a new
diagnostic so the Admin UI can show what's been auto-isolated.
Web verification (2026-04-25) against current vendor docs surfaced concrete
grammar conflicts in the v1 suffix grammar shipped in #137. Hard cutover
before the Admin UI rolls out widely so users don't paste `:I` from a
Wonderware spreadsheet and silently get wrong-typed reads.
Sources:
- Wonderware DASMBTCP user guide
https://cdn.logic-control.com/media/DASMBTCP.pdf
- Ignition Modbus addressing (8.1)
https://www.docs.inductiveautomation.com/docs/8.1/ignition-modules/opc-ua/opc-ua-drivers/modbus/modbus-addressing
Type-code changes:
| Code | Pre-#146 | Post-#146 | Vendor reference |
|--------|----------|------------|------------------------------|
| `:S` | (n/a) | Int16 | Wonderware DASMBTCP `S` |
| `:US` | (n/a) | UInt16 | Ignition `HRUS` |
| `:I` | Int16 | **Int32** | Wonderware `I` + Ignition `HRI` |
| `:UI` | UInt16 | **UInt32** | Ignition `HRUI` |
| `:I_64` | (n/a) | Int64 | Ignition `HRI_64` |
| `:UI_64` | (n/a) | UInt64 | Ignition `HRUI_64` |
| `:BCD_32`| (n/a) | BCD32 | Ignition `HRBCD_32` |
Codes REMOVED (no clear vendor precedent + conflict with the new mapping):
`:DI`, `:L`, `:UDI`, `:UL`, `:LI`, `:ULI`, `:LBCD`. Pre-#146 configs that
use them get an "Unknown type code" diagnostic at parse time so users get
a fast surface-level error rather than silent wrong-typed reads.
Codes UNCHANGED (already vendor-aligned): `:BOOL`, `:F`, `:D`, `:BCD`,
`:STR<n>`. Modicon 5/6-digit + mnemonic regions (HR/IR/C/DI) + bit suffix
`.N` are also unchanged.
Defaults:
- Coils / DiscreteInputs → `BOOL` (unchanged)
- HoldingRegisters / InputRegisters with no explicit type → Int16 (matches
Ignition's bare `HR` default)
Byte-order mnemonics (`:ABCD` / `:CDAB` / `:BADC` / `:DCBA`) are kept but
documented as OtOpcUa-specific — they aren't in any major vendor's per-tag
address string. Ignition uses a `-R` suffix per prefix; Wonderware
configures word-order at the topic level.
Tests:
- 12 Type_Codes_Parse rows updated to assert the new mappings.
- New Removed_Aliases_Are_Rejected (×7) confirms each pre-#146 alias now
fails fast with "Unknown type code".
- Worked_Example_Int16_Array uses the new `:S` code.
- New Worked_Example_Int32_Array_Via_I_Code documents the `:I = Int32`
vendor-alignment intent so a future "fix" doesn't accidentally regress.
- Unknown_Type_Code_Rejected_With_Catalog updated to match the new error
message ("Valid: BOOL, S, US, I, ...").
Docs:
- docs/v2/modbus-addressing.md — table replaced with the post-#146 codes,
each row cites its Wonderware / Ignition reference. New "Codes removed
in #146" subsection documents the cutover.
- docs/Driver.Modbus.Cli.md — example grammar list updated; explicit
type-code reminder appended.
114 addressing tests + 231 driver tests still green. Solution build clean.
Adds a coalescing read planner that merges nearby tags into single FC03/FC04
PDUs, opt-in via ModbusDriverOptions.MaxReadGap. Default 0 = no coalescing
(every tag gets its own PDU — preserves pre-#143 wire output).
Worked example with MaxReadGap=10:
T1 @ HR 100 (Int16, 1 reg)
T2 @ HR 102 (Int16, 1 reg, gap 1 → joins block)
T3 @ HR 110 (Float32, 2 regs, gap 7 → joins block)
T4 @ HR 200 (Int16, 1 reg, gap 89 → splits, separate read)
→ 2 PDUs total: FC03 start=100 quantity=12 + FC03 start=200 quantity=1.
Planner:
- Eligible tags: known + register region (HR/IR) + scalar + not String /
BitInRegister / array + not CoalesceProhibited.
- Groups by (UnitId, Region) — never coalesces across slaves or regions.
- Sorts by start address; merges when (next.start - last.end - 1) ≤ MaxReadGap
AND the resulting span ≤ MaxRegistersPerRead. Otherwise opens a new block.
- Single-tag blocks are deferred to the per-tag path so WriteOnChange cache
semantics stay correct without duplication.
- Per-block failure marks every member tag Bad and degrades health — same
semantics the per-tag path has, but at the block granularity.
Per-tag escape hatch ModbusTagDefinition.CoalesceProhibited (bool, default
false) — when true, the tag is read in isolation regardless of MaxReadGap.
For PLCs with protected register holes between adjacent tags.
Tests (7 new ModbusCoalescingTests):
- MaxReadGap=0 keeps the per-tag behavior (2 reads for 2 tags).
- MaxReadGap=2 merges 3 tags within 5 registers into 1 read of qty=5.
- MaxReadGap=10 splits T1+T2 from T3 when the gap exceeds the threshold.
- CoalesceProhibited tag reads alone even when neighbours are eligible.
- Coalescing never crosses UnitId boundaries (multi-slave gateway safety).
- MaxRegistersPerRead caps a would-be block; planner falls back to separate
reads when the merged span would exceed the cap.
- Per-tag values surface independently after coalescing (slice-math sanity).
Existing 220 unit tests still green; total 224 pass with the new file (tests
are additive, no regressions).
Follow-up: auto-split-on-protected-hole isn't shipped — a coalesced read
that hits an Illegal Data Address right now marks every member Bad until
the operator sets CoalesceProhibited on the offending tag. Tracked
implicitly by #138's e2e drill against a pymodbus profile with a protected
hole mid-block.
Lifts the previous "one driver = one slave" assumption so a single Modbus
driver instance can front N RTU slaves behind one Ethernet gateway (Anybus,
ProSoft, Lantronix style). Each tag carries an optional UnitId that drives
the MBAP unit-id byte per-PDU, and the IPerCallHostResolver contract surfaces
per-slave host strings so per-PLC circuit breakers fire per-slave (matches
the AB CIP template documented in docs/v2/multi-host-dispatch.md).
Changes:
- ModbusTagDefinition gains optional UnitId (byte?). Null = use driver-level
ModbusDriverOptions.UnitId (preserves single-slave deployments verbatim).
- ResolveUnitId(tag) helper computed once per ReadOneAsync / WriteOneAsync
call; passed through ReadRegisterBlockAsync / ReadBitBlockAsync /
ReadRegisterBlockChunkedAsync / ReadBitBlockChunkedAsync explicitly. The
probe loop continues using driver-level UnitId (the probe is a
connection-health check, not slave-specific).
- ModbusDriver implements IPerCallHostResolver. ResolveHost(fullReference)
returns "host:port/unitN" — distinct strings per slave so the resilience
pipeline keys breakers on the right granularity. Unknown references fall
back to the bare HostName (single-slave behaviour).
- BitInRegister RMW path also threads the per-tag UnitId through both the
read and write halves so a multi-slave deployment stays correct under bit-
level writes.
- Factory DTO + JSON binding extended with the per-tag UnitId field.
Tests (4 new ModbusMultiUnitTests):
- Per-tag UnitId routes to the correct slave in the MBAP header (driver-level
UnitId=99 must NOT appear when both tags override).
- Tag without override falls back to driver-level UnitId.
- IPerCallHostResolver returns distinct "host:port/unitN" strings per slave.
- Unknown reference returns the bare HostName fallback.
Existing 220 unit tests + 107 addressing tests still green. Per-PLC breaker
isolation under simulated dead slaves is verifiable via the existing AB CIP
test infra; live coverage lands as an integration test in the #138 docs/e2e
refresh.
Promotes DirectLogicAddress + MelsecAddress from "utility helpers an engineer
calls manually" to "first-class branch of ModbusAddressParser." Users can now
paste DL205-native (V2000, Y0, C100, X17, SP10) and MELSEC-native (D100, M50,
X20 hex/octal, Y0) addresses directly into TagConfig and the parser handles
the PLC-native → Modbus PDU translation.
Changes:
- Both helper files moved into the shared Driver.Modbus.Addressing assembly
(same namespace, zero-churn for callers). Required because the parser
needs to call them and the dependency direction is parser→helpers, not
the other way.
- New ModbusFamily enum (Generic / DL205 / MELSEC) on
ModbusDriverOptions.Family. Generic preserves pre-#144 behaviour exactly.
- ModbusDriverOptions.MelsecSubFamily picks the X/Y notation (Q_L_iQR hex
vs F_iQF octal). Default Q_L_iQR.
- ModbusAddressParser.Parse now takes optional family + sub-family hints.
When non-Generic, family-native parsing runs FIRST; on miss falls back to
Modicon / mnemonic. Cross-family ambiguity (C100 = Modicon coil under
Generic, DL205 control relay under DL205) is unambiguous within one
driver instance.
- Suffix grammar composes with native addresses: V2000:F:CDAB:5 parses
end-to-end as DL205 V-memory at PDU 1024 + Float32 + word-swap + array of 5.
- Bit suffix composes too: V2000.7 parses as bit 7 of HR[1024].
- Factory DTO fields Family / MelsecSubFamily flow through to BuildTag so
the JSON binding can drive everything per-driver.
Tests: 16 new ModbusFamilyParserTests covering DL205 V/Y/C/X/SP, MELSEC
D/M/X/Y, sub-family hex-vs-octal disambiguation, cross-family C100 ambiguity,
fallback to Modicon when native misses, and grammar composition with bit/
byte-order/array modifiers. Existing 91 parser tests still green; 220 driver
tests still green.
Caveat: bank-base offsets for MELSEC X/Y/M default to 0 in the grammar
string. Sites with non-zero "Modbus Device Assignment Parameter" bases must
use the structured tag form to override — addressed in the docs refresh
(#138).
Two driver-side filters that ≥5 of 6 surveyed vendors expose:
1. Per-tag Deadband (double?, on ModbusTagDefinition) — when set, the
PollGroupEngine onChange callback suppresses publishes whose distance
from the last-published value is below the threshold. Reduces wire
traffic to OPC UA clients on noisy analog signals (flow meters,
temperatures). Numeric scalar types only — Bool / BitInRegister / String
/ array tags publish unconditionally.
2. WriteOnChangeOnly (bool, on ModbusDriverOptions) — when true, the driver
short-circuits writes whose value matches the most recent successful
write to that tag. Saves PLC bandwidth on clients that re-publish the
same setpoint every scan. Cache invalidates on any read that returns a
different value, so HMI-side changes don't get masked.
Both default off so existing deployments see no behaviour change.
Implementation:
- ShouldPublish guard wraps the existing OnDataChange invocation. First sample
always passes through (no baseline); subsequent samples compare via
Convert.ToDouble for the cross-numeric-type math.
- IsRedundantWrite check at the top of WriteAsync; on success the cache is
populated. Object.Equals handles boxed-numeric equality; arrays are
excluded (reference-equality would never match anyway).
- ReadAsync invalidates the WriteOnChangeOnly cache when the new value
differs from the cached last-written value.
Tests (5 new ModbusSubscribeOptionsTests):
- Deadband suppresses sub-threshold changes (100 → 102 → 106 → 107 with
deadband=5 publishes 100 and 106 only).
- Deadband=null still publishes every change.
- WriteOnChangeOnly suppresses 3 identical 42 writes (only first hits wire).
- WriteOnChangeOnly default false hits the wire every time.
- Read-divergence cache invalidation: external panel write to 99, our
client's re-write of 42 must NOT be suppressed.
220/220 unit tests green; existing ProtocolOptions tests hardened against
probe-loop noise by disabling the probe in their fixtures.
Adds ModbusDriverOptions knobs that ≥4 of 6 surveyed vendors expose:
1. MaxCoilsPerRead (ushort, default 2000) — separate from MaxRegistersPerRead
because coil packing (1 bit per coil) and register packing (16 bits each)
have different spec ceilings. Coil-array reads above the cap auto-chunk
the same way register reads have always done. New ReadBitBlockChunkedAsync
re-assembles per-chunk LSB-first bitmaps into one logical bitmap.
2. UseFC15ForSingleCoilWrites (default false) — forces FC15 (Write Multiple
Coils with quantity=1) for single-coil writes instead of the default FC05
(Write Single Coil). Safety / audit PLCs that only accept the multi-write
codes need this.
3. UseFC16ForSingleRegisterWrites (default false) — same idea for FC16 vs
FC06 on single holding-register writes.
4. DisableFC23 (default false) — placeholder no-op for the future block-read
coalescing (#143) work that may opt into FC23 (Read/Write Multiple
Registers). Lets deployments pre-disable FC23 for PLCs that won't accept
it, before we ship the optimisation that emits it.
Defaults preserve the historical wire output bit-for-bit (FC05/FC06 for
singles, no chunking under 2000 coils, no FC23). Factory DTO + JSON-binding
extended with parallel fields.
6 new ModbusProtocolOptionsTests covering: defaults, FC05→FC15 forcing,
FC06→FC16 forcing, MaxCoilsPerRead chunking math (2500 coils / 2000 cap →
2 reads of 2000 + 500). Existing 209 unit tests still green.
Promotes the previously hardcoded transport-layer settings to ModbusDriverOptions
so users can tune them through DriverConfig JSON without recompiling.
Three new option groups:
1. KeepAlive (ModbusKeepAliveOptions): Enabled / Time / Interval / RetryCount.
Defaults preserve the historical PR 53 wire output exactly (Enabled=true,
Time=30s, Interval=10s, RetryCount=3). Set Enabled=false for PLCs that
reject SO_KEEPALIVE.
2. IdleDisconnectTimeout (TimeSpan?): when set, the transport tracks last-PDU-
success and proactively closes + reconnects on the next request after the
threshold. Defends against silent NAT / firewall socket reaping. Default
null = disabled (no behaviour change).
3. Reconnect (ModbusReconnectOptions): InitialDelay / MaxDelay /
BackoffMultiplier for the post-drop reconnect loop. Defaults
(InitialDelay=0, MaxDelay=30s, Multiplier=2.0) preserve the historical
immediate-retry behaviour for the first attempt and add geometric backoff
only if the reconnect itself fails. Capped at 10 attempts before propagating.
ModbusTcpTransport ctor extended with optional keepAlive / idleDisconnect /
reconnect parameters; existing 4-arg call sites continue to compile. Factory
DTO gains parallel KeepAlive / IdleDisconnectMs / Reconnect fields with
default-aware binding.
5 new ModbusConnectionOptionsTests covering the default-preservation contract
(every default field matches pre-#139) and the JSON-binding round-trip for
each knob group. Existing 204 unit tests still green.
Adds the full Wonderware/Kepware/Ignition-style address suffix grammar so
users paste tag spreadsheets without per-tag manual translation:
<region><offset>[.<bit>][:<type>[<len>]][:<order>][:<count>]
Examples that now parse end-to-end:
40001 HoldingRegisters[0], Int16
400001 same, 6-digit form
40001.5 bit 5 of HR[0]
40001:F Float32 (HR[0..1])
40001:F:CDAB word-swapped Float32
40001:STR20 20-char ASCII string
HR1:DI Int32 via mnemonic region
C100 Coils[99] (mnemonic)
40001:F:5 Float32[5] array (3-field shorthand)
40001:I:CDAB:10 Int16[10] word-swapped (4-field strict)
Driver-side plumbing:
- ModbusAddressParser + ParsedModbusAddress in the shared Addressing
assembly. 91 parser tests (every grammar variant + malformed shapes).
- ModbusDataType / ModbusByteOrder moved to shared (with the same namespace
so callers compile unchanged). ModbusByteOrder gains ByteSwap (BADC) and
FullReverse (DCBA) alongside the existing BigEndian (ABCD) and WordSwap
(CDAB).
- NormalizeWordOrder extended to honor all four orders for both 4-byte and
8-byte values. Old WordSwap behavior preserved bit-for-bit.
- ModbusTagDefinition gains optional ArrayCount.
- ReadOneAsync / WriteOneAsync handle array fan-out: one FC03/04 read covers
N consecutive register-typed elements, decoded into a typed array (short[],
float[], etc.). Coil arrays use FC01 reads + FC15 writes (FakeTransport
in tests gains FC15 support to match).
- DriverAttributeInfo IsArray / ArrayDim flow from ArrayCount so the OPC UA
address space surfaces ValueRank=1 + ArrayDimensions to clients.
- ModbusDriverFactoryExtensions gains AddressString DTO field. When
present, the parser drives Region/Address/DataType/ByteOrder/Bit/
StringLength/ArrayCount; structured fields (Writable, WriteIdempotent,
StringByteOrder) still come from the DTO. Existing structured tag rows
keep working unchanged.
Tests: 91 parser unit tests (Driver.Modbus.Addressing.Tests, all green) +
204 driver tests including new ModbusByteOrderTests (BADC/DCBA roundtrips
across Int32/Float32/Float64) and ModbusArrayTests (Int16[5], Float32[3]
CDAB, Coil[10], length-mismatch error, IsArray/ArrayDim discovery).
Solution-wide build clean.
Caveat: grammar names (type codes, byte-order mnemonics, the :count
shorthand) were synthesized from training-era vendor docs. Verify against
current Kepware Modbus Ethernet Driver Help and Ignition Modbus Addressing
manuals before freezing for production deployments — naming may need a
back-compat layer if vendor wording has shifted.
Foundation for the Modbus addressing-grammar work tracked in #137-#145. Adds
ModbusModiconAddress.Parse / TryParse that turns classic Modicon strings
(40001 / 400001 / 30001 / 00001 / 10001) into (Region, ushort PduOffset).
Also extracts ModbusRegion to a new Driver.Modbus.Addressing assembly so the
Admin UI (#145) can reference the addressing surface without taking a dep on
the wire driver. The new assembly intentionally extends the same
ZB.MOM.WW.OtOpcUa.Driver.Modbus namespace as the driver — callers see the
type as if it lived in one place; only the project layout changes. No
existing call site needed editing (zero-churn move).
Behaviour:
- Single leading digit selects region (0=Coils, 1=DiscreteInputs,
3=InputRegisters, 4=HoldingRegisters).
- 5-digit form: trailing 4 digits are 1-based register, supports 1..9999.
- 6-digit form: trailing 5 digits are 1-based register, supports 1..65536
(full PDU address space).
- Strict 5-or-6 length check; whitespace trimmed; clear FormatException
diagnostics for every malformed shape (wrong length, non-digit body,
illegal leading digit, register zero, register overflow).
29/29 new unit tests pass. Full Driver.Modbus suite (182 tests) and the
solution-wide build still green after the ModbusRegion move.
Parent: #209. Adds the server-side wiring so a Config DB `DriverType='Modbus'`
row actually boots a Modbus driver instance + publishes its tags under OPC UA
NodeIds, instead of being silently skipped by DriverInstanceBootstrapper.
Changes:
- `ModbusDriverFactoryExtensions` (new) — mirrors
`GalaxyProxyDriverFactoryExtensions` + `FocasDriverFactoryExtensions`.
`DriverTypeName="Modbus"`, `CreateInstance` deserialises
`ModbusDriverConfigDto` (Host/Port/UnitId/TimeoutMs/Probe/Tags) to a full
`ModbusDriverOptions` and hands back a `ModbusDriver`. Strict enum parsing
(Region / DataType / ByteOrder / StringByteOrder) — unknown values fail
fast with an explicit "expected one of" error rather than at first read.
- `Program.cs` — register the factory after Galaxy + FOCAS.
- `Driver.Modbus.csproj` — add `Core` project reference (the DI-free factory
needs `DriverFactoryRegistry` from `Core.Hosting`). Matches the FOCAS
driver's reference shape.
- `Server.csproj` — add the `Driver.Modbus` ProjectReference so the
Program.cs registration compiles against the same assembly the server
loads at runtime.
- `scripts/smoke/seed-modbus-smoke.sql` (new) — one-cluster smoke seed
modelled on `seed-phase-7-smoke.sql`. Creates a `modbus-smoke` cluster +
`modbus-smoke-node` + Draft generation + Namespace + UnsArea/UnsLine/
Equipment + one Modbus `DriverInstance` pointing at the pymodbus standard
fixture (`127.0.0.1:5020`) + one Tag at `HR[200]:UInt16`, ending in
`EXEC sp_PublishGeneration`. HR[100] is deliberately *not* used because
pymodbus `standard.json` runs an auto-increment action on that register.
Full-solution build: 0 errors, only the pre-existing xUnit1051 warnings.
AB CIP / S7 / AB Legacy factories follow in their own PRs per #211 / #212 /
#213. Live boot verification happens in the exit-gate PR once all four
factories are in place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-tag opt-in for write-retry per docs/v2/plan.md decisions #44, #45, #143.
Default is false — writes never auto-retry unless the driver author has marked
the tag as safe to replay.
Core.Abstractions:
- DriverAttributeInfo gains `bool WriteIdempotent = false` at the end of the
positional record (back-compatible; every existing call site uses the default).
Driver.Modbus:
- ModbusTagDefinition gains `bool WriteIdempotent = false`. Safe candidates
documented in the param XML: holding-register set-points, configuration
registers. Unsafe: edge-triggered coils, counter-increment addresses.
- ModbusDriver.DiscoverAsync propagates t.WriteIdempotent into
DriverAttributeInfo.WriteIdempotent.
Driver.S7:
- S7TagDefinition gains `bool WriteIdempotent = false`. Safe candidates:
DB word/dword set-points, configuration DBs. Unsafe: M/Q bits that drive
edge-triggered program routines.
- S7Driver.DiscoverAsync propagates the flag.
Stream A.5 integration tests (FlakeyDriverIntegrationTests, 4 new) exercise
the invoker + flaky-driver contract the plan enumerates:
- Read with 5 transient failures succeeds on the 6th attempt (RetryCount=10).
- Non-idempotent write with RetryCount=5 configured still fails on the first
failure — no replay (decision #44 guard at the ExecuteWriteAsync surface).
- Idempotent write with 2 transient failures succeeds on the 3rd attempt.
- Two hosts on the same driver have independent breakers — dead-host trips
its breaker but live-host's first call still succeeds.
Propagation tests:
- ModbusDriverTests: SetPoint WriteIdempotent=true flows into
DriverAttributeInfo; PulseCoil default=false.
- S7DiscoveryAndSubscribeTests: same pattern for DBx SetPoint vs M-bit.
Full solution dotnet test: 947 passing (baseline 906, +41 net across Stream A
so far). Pre-existing Client.CLI Subscribe flake unchanged.
Stream A's remaining work (wiring CapabilityInvoker into DriverNodeManager's
OnReadValue / OnWriteValue / History / Subscribe dispatch paths) is the
server-side integration piece + needs DI wiring for the pipeline builder —
lands in the next PR on this branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three substantive issues caught + fixed during the validation pass:
1. pymodbus rejects unknown keys at device-list / setup level. My PR 43 commit had `_layout_note`, `_uint16_layout`, `_bits_layout`, `_write_note` device-level JSON-comment fields that crashed pymodbus startup with `INVALID key in setup`. Removed all device-level _* fields. Inline `_quirk` keys WITHIN individual register entries are tolerated by pymodbus 3.13.0 — kept those in dl205.json since they document the byte math per quirk and the README + git history aren't enough context for a hand-author reading raw integer values. Documented the constraint in the top-level _comment of each profile.
2. pymodbus rejects sweeping `write` ranges that include any cell not assigned a type. My initial standard.json had `write: [[0, 2047]]` but only seeded HR[0..31] + HR[100] + HR[200..209] + bits[1024..1109] — pymodbus blew up on cell 32 (gap between HR[31] and HR[100]). Fixed by listing per-block write ranges that exactly mirror the seeded ranges. Same fix in dl205.json (was `[[0, 16383]]`).
3. pymodbus simulator stores all 4 standard Modbus tables in ONE underlying cell array — each cell can only be typed once (BITS or UINT16, not both). My initial standard.json had `bits[0..31]` AND `uint16[0..31]` overlapping at the same addresses; pymodbus crashed with `ERROR "uint16" <Cell> used`. Fixed by relocating coils to address 1024+, well clear of the uint16 entries at 0..209. Documented the layout constraint in the standard.json top-level _comment.
Substantive driver bug fixed: ModbusTcpTransport.ConnectAsync was using `new TcpClient()` (default constructor — dual-stack, IPv6 first) then `ConnectAsync(host, port)` with the user's hostname. .NET's TcpClient default-resolves "localhost" to ::1 first, fails to connect to pymodbus (which binds 0.0.0.0 IPv4-only), and only then retries IPv4 — the failure surfaces as the entire ConnectAsync timeout (2s by default) before the IPv4 attempt even starts. PR 30's smoke test silently SKIPPED because the fixture's TCP probe hit the same dual-stack ordering and timed out. Both fixed: ModbusSimulatorFixture probe now resolves Dns.GetHostAddresses, prefers AddressFamily.InterNetwork, dials IPv4 explicitly. ModbusTcpTransport does the same — resolves first, prefers IPv4, falls back to whatever Dns returns (handles IPv6-only hosts in the future). This is a real production-readiness fix because most Modbus PLCs are IPv4-only — a generic dual-stack TcpClient would burn the entire connect timeout against any IPv4-only PLC, masquerading as a connection failure when the PLC is actually fine.
Smoke-test address shifted HR[100] -> HR[200]. Standard.json's HR[100] is the auto-incrementing register that drives subscribe-and-receive tests, so write-then-read against it would race the increment. HR[200] is the first cell of a writable scratch range present in BOTH simulator profiles. DL205Profile.cs xml-doc updated to explain the shift; tag name "DL205_Smoke_HReg100" -> "Smoke_HReg200" + smoke test references updated. dl205.json gains a matching scratch HR[200..209] range so the smoke test runs identically against either profile.
Validation matrix:
- standard.json boot: clean (TCP 5020 listening within ~3s of pymodbus.simulator launch).
- dl205.json boot: clean.
- pymodbus client direct FC06 to HR[200]=1234 + FC03 read: round-trip OK.
- raw-bytes PowerShell TcpClient FC06 + 12-byte response: matches FC06 spec (echo of address + value).
- DL205SmokeTest against standard.json: 1/1 pass (was failing as 'BadInternalError' due to the dual-stack timeout + tag-name typo — both fixed).
- DL205SmokeTest against dl205.json: 1/1 pass.
- Modbus.Tests Unit suite: 52/52 pass — dual-stack transport fix is non-breaking.
- Solution build clean.
Memory + future-PR setup: pymodbus install + activation pattern is now bullet-pointed at the top of Pymodbus/README.md so future PRs (the per-quirk DL205_<behavior> tests in PR 44+) don't have to repeat the trial-and-error of getting the simulator + integration tests cooperating. The three bugs above are documented inline in the JSON profiles + ModbusTcpTransport so they don't bite again.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>