Files
lmxopcua/docs/plans/2026-06-17-stillpending-phase-4d-s7-wide-types-design.md
T

11 KiB

S7 Wide Types + Timer/Counter — Design

Status: Approved 2026-06-17. Branch feat/stillpending-phase-4d-s7-wide-types off master 12e114b3. 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:350 UnimplementedDataTypes).
  • "S7 — Timer/Counter address areas unsupported" (S7Driver.cs:306 RejectUnsupportedTagAddresses).

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/writes dataType + stringLength against the full enum — no UnimplementedDataTypes gate in the UI.
  • S7EquipmentTagParser already parses dataType + stringLength (+ arrayCount) from the equipment-tag TagConfig blob.
  • DriverDataType already has Int64/UInt64 members (added in stillpending Phase 4) — so S7's lossy Int64/UInt64 → Int32 node mapping can be corrected.
  • S7.Cli Read/Write/Subscribe --data-type already 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 uses ReadBytesAsync).
  • 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 with FromByteArray/ToByteArray. These take/return byte[], 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/WriteAsync path unchanged. Wide/structured/Timer/Counter route through ReadBytesAsync/WriteBytesAsync (the 4c mechanism) feeding a pure DecodeScalarBlock/EncodeScalarBlock: BinaryPrimitives for the 8-byte numerics, S7.Net's pure static FromByteArray/ToByteArray for 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, call ReadBytesAsync/WriteBytesAsync for width bytes) → pure DecodeScalarBlock(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.FromByteArraydouble seconds (read-only)
Counter (C{n}) 2 S7.Net.Types.Counter.FromByteArrayint 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 via ReadBytesAsync(area, 0, number, 2).
  • Timerdouble seconds → node DriverDataType.Float64; require the tag DataType be Float64 (else config error).
  • Counterint count → node DriverDataType.Int32; require the tag DataType be Int32 (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 + wide DataType (wide-type arrays stay out of scope), (b) wide/structured DataType + non-B address suffix, (c) Timer/Counter + incompatible DataType. Each with a clear remediation message — fail-fast-at-init promise holds (no BadNotSupported-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.Cli ReadCommand/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 stage stillpending.md).

Scope boundaries (named deferrals — not silent)

  • Wide-type arrays (Int64[], String[], DateTime[], …) — DecodeArrayBlock keeps its existing deferred-throw; the new init guard rejects them up-front.
  • S7WString (2-byte UTF-16 chars) — classic 1-byte STRING only this phase.
  • DTL (12-byte DateTimeLong) — 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.Tests mirroring the existing DecodeArrayBlock/ReinterpretRawValue/BoxValueForWrite tests:
    • DecodeScalarBlock round-trips for Int64/UInt64/LReal (known big-endian byte blocks), String (header + chars), DateTime (BCD block), Timer (seconds), Counter (count).
    • EncodeScalarBlock round-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.Tests help-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) + MapDataType Int64/UInt64 fix. standard.
  • T2 — 8-byte numerics (Int64/UInt64/LReal) scalar read+write: codec dispatch + ReadScalarBlockAsync/WriteScalarBlockAsync + pure DecodeScalarBlock/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 /run acceptance (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.