Replaces the per-tag Plc.ReadAsync loop in S7Driver.ReadAsync with a
batched ReadMultipleVarsAsync path. Scalar fixed-width tags (Bool, Byte,
Int16/UInt16, Int32/UInt32, Float32, Float64) are bin-packed into ≤18-item
batches at the default 240-byte PDU using S7.Net.Types.DataItem; arrays,
strings, dates, 64-bit ints, and UDT-shaped types stay on the legacy
ReadOneAsync path. On batch-level failure each tag in the batch falls
back to ReadOneAsync so good tags still produce values and the offender
gets its per-item StatusCode (BadDeviceFailure / BadCommunicationError).
100 scalar reads now coalesce into ≤6 PDU round-trips instead of 100.
Closes#292
Add CPU-aware overload S7AddressParser.Parse(string, CpuType?) that
accepts the V area letter for S7-200 / S7-200 Smart / LOGO! 0BA8 and
maps it to DataBlock DB1. V is rejected on S7-300/400/1200/1500 and on
the legacy CPU-agnostic Parse(string) overload. Width suffixes mirror
M/I/Q (VB/VW/VD/V0.0). S7Driver passes _options.CpuType so live tag
config picks up family-aware parsing.
Tests cover S7200/S7200Smart/Logo0BA8 positive cases, modern-family
rejection, and CPU-agnostic rejection.
Closes#291
- S7TagDefinition gets optional ElementCount; >1 marks the tag as a 1-D array.
- ReadOneAsync / WriteOneAsync: one byte-range Read/WriteBytesAsync covering
N × elementBytes, sliced/packed client-side via the existing big-endian scalar
codecs and S7DateTimeCodec.
- DiscoverAsync surfaces IsArray=true and ArrayDim=ElementCount → ValueRank=1.
- Init-time validation (now ahead of TCP open) caps ElementCount at 8000 and
rejects unsupported element types: STRING/WSTRING/CHAR/WCHAR (variable-width)
and BOOL (packed-bit layout) — both follow-ups.
- Supported element types: Byte, Int16/UInt16, Int32/UInt32, Int64/UInt64,
Float32, Float64, Date, Time, TimeOfDay.
Closes#290
Adds S7DateTimeCodec static class implementing the six Siemens S7 date/time
wire formats:
- DTL (12 bytes): UInt16 BE year + month/day/dow/h/m/s + UInt32 BE nanos
- DATE_AND_TIME (8 bytes BCD): yy/mm/dd/hh/mm/ss + 3-digit BCD ms + dow
- S5TIME (16 bits): 2-bit timebase + 3-digit BCD count → TimeSpan
- TIME (Int32 ms BE, signed) → TimeSpan, allows negative durations
- TOD (UInt32 ms BE, 0..86399999) → TimeSpan since midnight
- DATE (UInt16 BE days since 1990-01-01) → DateTime
Mirrors the S7StringCodec pattern from PR-S7-A2 — codecs operate on raw byte
spans so each format can be locked with golden-byte unit tests without a
live PLC. New S7DataType members (Dtl, DateAndTime, S5Time, Time, TimeOfDay,
Date) are wired into S7Driver.ReadOneAsync/WriteOneAsync via byte-level
ReadBytesAsync/WriteBytesAsync calls — S7.Net's string-keyed Read/Write
overloads have no syntax for these widths.
Uninitialized PLC buffers (all-zero year+month for DTL/DT) reject as
InvalidDataException → BadOutOfRange to operators, rather than decoding as
year-0001 garbage.
S5TIME / TIME / TOD surface as Int32 ms (DriverDataType has no Duration);
DTL / DT / DATE surface as DriverDataType.DateTime.
Test coverage: 30 new golden-vector + round-trip + rejection tests,
including the all-zero buffer rejection paths and BCD-nibble validation.
Build clean, 115/115 S7 tests pass.
Closes#289
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the NotSupportedException cliff for S7 string-shaped types.
- S7DataType gains WString, Char, WChar members alongside the existing
String entry.
- New S7StringCodec encodes/decodes the four wire formats:
STRING : 2-byte header (max-len + actual-len bytes) + N ASCII bytes
-> total 2 + max_len.
WSTRING : 4-byte header (max-len + actual-len UInt16 BE) + N×2
UTF-16BE bytes -> total 4 + 2 × max_len.
CHAR : 1 ASCII byte (rejects non-ASCII on encode).
WCHAR : 2 UTF-16BE bytes.
Header-bug clamp: actualLen > maxLen is silently clamped on read so
firmware quirks don't walk past the wire buffer; rejected on write
to avoid silent truncation.
- S7Driver.ReadOneAsync / WriteOneAsync issue ReadBytesAsync /
WriteBytesAsync against the parsed Area / DbNumber / ByteOffset and
honour S7TagDefinition.StringLength (default 254 = S7 STRING max).
- MapDataType returns DriverDataType.String for the three new enum
members so OPC UA discovery surfaces them as scalar strings.
Tests: 21 new cases on S7StringCodec covering golden-byte vectors,
encode/decode round-trips, the firmware-bug header-clamp, ASCII-only
guard on CHAR, and the StringLength default. 85/85 passing.
Closes#288
Closes the NotSupportedException cliff for S7 Float64/Int64/UInt64.
- S7Size enum gains LWord (8 bytes); parser accepts DBLD/DBL on data
blocks and LD on M/I/Q (e.g. DB1.DBLD0, DB1.DBL8, MLD0, ILD8, QLD16).
- S7Driver.ReadOneAsync / WriteOneAsync issue ReadBytesAsync /
WriteBytesAsync for 64-bit types and convert big-endian via
System.Buffers.Binary.BinaryPrimitives. S7's wire format is BE.
- Internal MapArea(S7Area) helper translates to S7.Net DataType.
- MapDataType now surfaces native DriverDataType for Int16/UInt16/
UInt32/Int64/UInt64 instead of collapsing them all to Int32.
Tests: parser theories cover DBLD/DBL/MLD/ILD/QLD; discovery test
asserts the 64-bit DriverDataType mapping. 64/64 passing.
Closes#287
S7 integration — AbCip/Modbus already have real-simulator integration suites; S7 had zero wire-level coverage despite being a Tier-A driver (all unit tests mocked IS7Client). Picked python-snap7's `snap7.server.Server` over raw Snap7 C library because `pip install` beats per-OS binary-pin maintenance, the package ships a Python __main__ shim that mirrors our existing pymodbus serve.ps1 + *.json pattern structurally, and the python-snap7 project is actively maintained. New project `tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/` with four moving parts: (a) `Snap7ServerFixture` — collection-scoped TCP probe on `localhost:1102` that sets `SkipReason` when the simulator's not running, matching the `ModbusSimulatorFixture` shape one directory over (same S7_SIM_ENDPOINT env var override convention for pointing at a real S7 CPU on port 102); (b) `PythonSnap7/` — `serve.ps1` wrapper + `server.py` shim + `s7_1500.json` seed profile + `README.md` documenting install / run / known limitations; (c) `S7_1500/S7_1500Profile.cs` — driver-side `S7DriverOptions` whose tag addresses map 1:1 to the JSON profile's seed offsets (DB1.DBW0 u16, DB1.DBW10 i16, DB1.DBD20 i32, DB1.DBD30 f32, DB1.DBX50.3 bool, DB1.DBW100 scratch); (d) `S7_1500SmokeTests` — three tests proving typed reads + write-then-read round-trip work through real S7netplus + real ISO-on-TCP + real snap7 server. Picked port 1102 default instead of S7-standard 102 because 102 is privileged on Linux + triggers Windows Firewall prompt; S7netplus 0.20 has a 5-arg `Plc(CpuType, host, port, rack, slot)` ctor that lets the driver honour `S7DriverOptions.Port`, but the existing driver code called the 4-arg overload + silently hardcoded 102. One-line driver fix (S7Driver.cs:87) threads `_options.Port` through — the S7 unit suite (58/58) still passes unchanged because every unit test uses a fake IS7Client that never sees the real ctor. Server seed-type matrix in `server.py` covers u8 / i8 / u16 / i16 / u32 / i32 / f32 / bool-with-bit / ascii (S7 STRING with max_len header). register_area takes the SrvArea enum value, not the string name — a 15-minute debug after the first test run caught that; documented inline.
Per-driver test-fixture coverage docs — eight new files in `docs/drivers/` laying out what each driver's harness actually benchmarks vs. what's trusted from field deployments. Pattern mirrors the AbServer-Test-Fixture.md doc that shipped earlier in this arc: TL;DR → What the fixture is → What it actually covers → What it does NOT cover → When-to-trust table → Follow-up candidates → Key files. Ugly truth the survey made visible: Galaxy + Modbus + (now) S7 + AB CIP have real wire-level coverage; AB Legacy / TwinCAT / FOCAS / OpcUaClient are still contract-only because their libraries ship no fake + no open-source simulator exists (AB Legacy PCCC), no public simulator exists (FOCAS), the vendor SDK has no in-process fake (TwinCAT/ADS.NET), or the test wiring just hasn't happened yet (OpcUaClient could trivially loopback against this repo's own server — flagged as #215). Each doc names the specific follow-up route: Snap7 server for S7 (done), TwinCAT 3 developer-runtime auto-restart for TwinCAT, Tier-C out-of-process Host for FOCAS, lab rigs for AB Legacy + hardware-gated bits of the others. `docs/drivers/README.md` gains a coverage-map section linking all eight. Tracking tasks #215-#222 filed for each PR-able follow-up.
Build clean (driver + integration project + docs); S7.Tests 58/58 (unchanged); S7.IntegrationTests 3/3 (new, verified end-to-end against a live python-snap7 server: `driver_reads_seeded_u16_through_real_S7comm`, `driver_reads_seeded_typed_batch`, `driver_write_then_read_round_trip_on_scratch_word`). Next fixture follow-up is #215 (OpcUaClient loopback against own server) — highest ROI of the remaining set, zero external deps.
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>