11 KiB
S7 Wide Types + Timer/Counter — Design
Status: Approved 2026-06-17. Branch
feat/stillpending-phase-4d-s7-wide-typesoff master12e114b3. Next step: writing-plans → subagent-driven-development → finish (merge to master + push).
Goal
Close the stillpending.md §2 driver-layer lines:
- "S7 — Int64 / UInt64 / Float64(LReal) / String / DateTime read+write not implemented" (
S7Driver.cs:350UnimplementedDataTypes). - "S7 — Timer/Counter address areas unsupported" (
S7Driver.cs:306RejectUnsupportedTagAddresses).
Scope (confirmed via AskUserQuestion): all five wide types + Timer/Counter read in one phase.
Key discovery — the author/compose surfaces are already wired
The slice is almost entirely driver-internal. Already in place:
S7DataType(S7DriverOptions.cs:124) already enumerates all 12 members incl. Int64/UInt64/Float64/String/DateTime.S7TagConfigModel(AdminUI typed editor) reads/writesdataType+stringLengthagainst the full enum — noUnimplementedDataTypesgate in the UI.S7EquipmentTagParseralready parsesdataType+stringLength(+arrayCount) from the equipment-tagTagConfigblob.DriverDataTypealready hasInt64/UInt64members (added in stillpending Phase 4) — so S7's lossyInt64/UInt64 → Int32node mapping can be corrected.S7.CliRead/Write/Subscribe--data-typealready accepts the full enum; only the help text says "not yet implemented".
What actually blocks these types today: two init guards that reject them, the codec (ReinterpretRawValue/BoxValueForWrite) that throws NotSupportedException for them, and one lossy node mapping.
Library facts (verified against S7netplus 0.20.0)
Plc.ReadBytesAsync(DataType, db, startByteAdr, count, ct)/Plc.WriteBytesAsync(...)exist (the 4c array path already usesReadBytesAsync).- Pure static byte-array codecs exist in
S7.Net.Types:S7String,S7WString,DateTime(DT, 8-byte BCD),DateTimeLong(DTL, 12-byte),Timer,Counter,LReal— each withFromByteArray/ToByteArray. These take/returnbyte[], so decode stays unit-testable without a live PLC. - The classic S7 string address suffix (
Plc.ReadAsync("DB1.DBD0")) decodes only 1/2/4-byte sizes (bool/byte/ushort/uint). There is no 8-byte/string/datetime string-suffix path — wide types must use the byte-buffer path.
Approaches considered
- A — Byte-buffer path + pure S7.Net.Types decoders (CHOSEN). Narrow types (Bool…Float32) keep the proven string
ReadAsync/WriteAsyncpath unchanged. Wide/structured/Timer/Counter route throughReadBytesAsync/WriteBytesAsync(the 4c mechanism) feeding a pureDecodeScalarBlock/EncodeScalarBlock:BinaryPrimitivesfor the 8-byte numerics, S7.Net's pure staticFromByteArray/ToByteArrayfor String/DateTime/Timer/Counter. Mirrors 4c exactly; fully unit-testable; minimal churn to working code. - B — Migrate everything to the buffer path. One uniform codec, but rewrites the proven narrow path for zero functional gain. Rejected (needless regression surface).
- C — Lean on S7.Net's typed string
Read(VarType). Re-introduces S7.Net's own address re-parsing (the seam 4c deliberately owns) and isn't unit-testable. Rejected in favor of A's pure functions.
Architecture (Approach A)
Codec dispatch
In ReadOneAsync/WriteOneAsync, branch on the tag's DataType:
- Narrow (Bool, Byte, Int16, UInt16, Int32, UInt32, Float32) → existing string
plc.ReadAsync(tag.Address)/plc.WriteAsync(tag.Address, boxed)path, unchanged. - Wide/structured/Timer/Counter → new
ReadScalarBlockAsync/WriteScalarBlockAsync(resolve(area, db, byteOffset)from the parsed address, callReadBytesAsync/WriteBytesAsyncforwidthbytes) → pureDecodeScalarBlock(tag, addr, block)/EncodeScalarBlock(tag, value).
width(DataType):
| DataType | Width | Decode / Encode |
|---|---|---|
| Int64 | 8 | BinaryPrimitives.Read/WriteInt64BigEndian |
| UInt64 | 8 | BinaryPrimitives.Read/WriteUInt64BigEndian |
| Float64 (LReal) | 8 | BinaryPrimitives.Read/WriteDoubleBigEndian |
| String | StringLength + 2 |
S7.Net.Types.S7String.FromByteArray/ToByteArray (classic 1-byte-char STRING) |
| DateTime | 8 | S7.Net.Types.DateTime.FromByteArray/ToByteArray (DATE_AND_TIME/DT, BCD) |
Timer (T{n}) |
2 | S7.Net.Types.Timer.FromByteArray → double seconds (read-only) |
Counter (C{n}) |
2 | S7.Net.Types.Counter.FromByteArray → int count (read-only) |
The pure DecodeScalarBlock/EncodeScalarBlock keep the same factoring rationale as the existing ReinterpretRawValue/BoxValueForWrite/DecodeArrayBlock — exercised in unit tests against known byte blocks. Verify exact S7.Net.Types method signatures at implementation (the implementer confirms against the 0.20.0 DLL).
Addressing convention (wide/structured types)
Byte-anchored. Wide and structured types are addressed by their start byte via the byte suffix: DB{n}.DBB{offset}, MB{offset}, IB{offset}, QB{offset}. The B suffix names the start byte; the actual width comes from the DataType (there is no native 8-byte S7 address suffix, and DBD is reserved for 4-byte Real/DInt). Timer/Counter keep T{n} / C{n}.
A wide/structured type configured with a non-B suffix (e.g. DBW/DBD) is a fail-fast init error with a clear message.
Timer / Counter semantics
- Decode is driven by the area (
S7Area.Timer/Counter), read viaReadBytesAsync(area, 0, number, 2). - Timer →
doubleseconds → nodeDriverDataType.Float64; require the tagDataTypebeFloat64(else config error). - Counter →
intcount → nodeDriverDataType.Int32; require the tagDataTypebeInt32(else config error). - Read-only in this phase — writing a live timer/counter preset is rare and higher-risk (named follow-up). A write to a Timer/Counter tag returns
BadNotWritable.
Init guards (preserve fail-fast)
- Empty
UnimplementedDataTypes(all five now implemented). - Remove the Timer/Counter rejection from
RejectUnsupportedTagAddresses. - Add a guard rejecting at init: (a)
isArray:true+ wideDataType(wide-type arrays stay out of scope), (b) wide/structuredDataType+ non-Baddress suffix, (c) Timer/Counter + incompatibleDataType. Each with a clear remediation message — fail-fast-at-init promise holds (noBadNotSupported-on-every-read leakage).
Node mapping fix
MapDataType: Int64 → DriverDataType.Int64, UInt64 → DriverDataType.UInt64 (today both lossily map to Int32 for values > 2³¹−1). Float64/String/DateTime already map correctly.
CLI + docs
S7.CliReadCommand/WriteCommand/SubscribeCommand: drop the "Int64, UInt64, Float64, String, DateTime are not yet implemented" help notes.docs/v2/driver-specs.md §5+docs/drivers/S7.md: document the byte-anchored addressing for wide types, the supported type table, Timer/Counter read, and the named deferrals.- Clear the
stillpending.md§2 lines via the plan record only (do not stagestillpending.md).
Scope boundaries (named deferrals — not silent)
- Wide-type arrays (
Int64[],String[],DateTime[], …) —DecodeArrayBlockkeeps its existing deferred-throw; the new init guard rejects them up-front. S7WString(2-byte UTF-16 chars) — classic 1-byteSTRINGonly this phase.DTL(12-byteDateTimeLong) —DATE_AND_TIME(8-byte DT) only this phase.- Timer/Counter writes — read-only this phase.
Testing
- xUnit + Shouldly, offline (no live PLC), in
ZB.MOM.WW.OtOpcUa.Driver.S7.Testsmirroring the existingDecodeArrayBlock/ReinterpretRawValue/BoxValueForWritetests:DecodeScalarBlockround-trips for Int64/UInt64/LReal (known big-endian byte blocks), String (header + chars), DateTime (BCD block), Timer (seconds), Counter (count).EncodeScalarBlockround-trips for Int64/UInt64/LReal/String/DateTime (and a decode∘encode identity).MapDataType→ Int64/UInt64 (not Int32).- Init guards: wide-type-array rejected, wide-type-non-Byte-address rejected, Timer/Counter incompatible-DataType rejected, narrow types + Timer/Counter accepted.
S7.Cli.Testshelp-text assertions if any pin the removed notes.- No bUnit — the AdminUI editor already surfaces the enum (Razor unchanged); covered by the existing model tests.
Live /run
Feasible against the rig's S7 sim (10.100.0.35:1102, S7_SIM_ENDPOINT) — best-effort read of an LReal/Int64 equipment tag over the wire (mirroring 4c's Modbus headline), and a round-trip write where the sim permits. If the sim lacks populated 8-byte DBs, fall back to a unit-proven gate + note (as 4c did for the non-Modbus drivers). Local docker-dev rig; login disabled; agent-driven.
Task slicing (subagent-driven)
All edits are in the S7 driver + its tests/CLI/docs (disjoint from other drivers). Within S7Driver.cs the codec tasks serialize.
- T1 — Init guards (empty
UnimplementedDataTypes, drop Timer/Counter address reject, add wide-array / non-Byte-address / Timer-Counter-DataType guards) +MapDataTypeInt64/UInt64 fix.standard. - T2 — 8-byte numerics (Int64/UInt64/LReal) scalar read+write: codec dispatch +
ReadScalarBlockAsync/WriteScalarBlockAsync+ pureDecodeScalarBlock/EncodeScalarBlock(numeric cases) + tests.high-risk(introduces the dispatch seam). - T3 — String read+write (
S7String+StringLength) + tests.standard. - T4 — DateTime read+write (
DATE_AND_TIME/DT) + tests.standard. - T5 — Timer/Counter read (decode by area, read-only) + tests.
standard. - T6 — CLI help +
docs/v2/driver-specs.md+docs/drivers/S7.md+ plan-record §2 clear.small. - T7 — Full build + S7 + S7.Cli test run + final integration review.
standard. - T8 — Live
/runacceptance (S7 sim best-effort) + finish branch (merge to master + push).standard.
Dependency graph: T1 → T2 → {T3, T4, T5} (T3/T4/T5 all follow T2's dispatch seam and share S7Driver.cs, so serialize) → T6 → T7 → T8.
Hard rules (carried from prior phases)
Stage by explicit path, never git add .; never stage sql_login.txt / src/Server/.../pki/ / pending.md / current.md / docker-dev/docker-compose.yml / stillpending.md; never echo/commit secrets; no force-push; no --no-verify; NO EF migration; NO Commons wire/proto contract change (the codec + EnsureVariable array params from 4c are unchanged; S7DataType/DriverDataType already carry every member needed); NO bUnit; dangerouslyDisableSandbox for all build/test/rig commands.