Files
lmxopcua/docs/plans/2026-06-16-stillpending-phase-4-driver-datatypes-design.md
T

9.8 KiB
Raw Blame History

Still-Pending Phase 4 (data-type tier) — Driver data-type & robustness coverage — design

Status: approved 2026-06-16. Parent roadmap: docs/plans/2026-06-15-stillpending-backlog-design.md (Phase 4). Source backlog: stillpending.md §2 (driver-layer gaps). Branch feat/stillpending-phase-4-driver-datatypes off master 7eeb9fb0. Phases 03 already shipped.

Goal

Close the cleanly-achievable scalar data-type and robustness gaps in stillpending.md §2 across three disjoint driver projects — no Configuration entity / EF migration, no contract churn, no bUnit. Phase 4's roadmap entry listed nine per-driver sub-areas; grounding (this session) showed the heavy structural items (S7 wide types, cross-driver arrays, UDT member paths, bit-index writes, Galaxy nesting, OpcUaClient event-history) need read-path refactors / contract changes / Phase-6 UI that don't fit a standard tier. This branch is the data-type tier; everything structural is deferred to Phase 4b (recorded below, not dropped).

Grounding that reshaped the scope

  1. DriverDataType.Int64/UInt64 already exist (Core.Abstractions/DriverDataType.cs:17,20) and the live equipment-node path already resolves the strings "Int64"/"UInt64" → correct OPC UA DataTypeIds (OtOpcUaNodeManager.ResolveBuiltInDataType:1364-1365). So the Modbus "node misreported as Int32" gap is purely the driver's MapDataType returning Int32 — a small forward-fix, not a missing enum member.
  2. The array item is mis-scoped for a data-type tier. The live equipment-node materialiser EnsureVariable hard-wires ValueRank = Scalar with no array params (OtOpcUaNodeManager.cs:1327); EquipmentTagPlan carries no array metadata at all; and S7 + AbLegacy have no array read support. Surfacing arrays end-to-end is a coherent cross-cutting feature (plan metadata + EnsureVariable contract change + per-driver read + Phase-6 authoring UI), not a per-driver flag flip. Deferred to its own slice.
  3. S7 wide types are the riskiest, least-verifiable in-tier item. The driver reads via S7.Net address-strings (plc.ReadAsync("DB1.DBW0") → boxed 4-byte); wide types need a byte-buffer + S7.Net.Types decode branch, and the dev sim is python-snap7 with unconfirmed 8-byte/string support (no real PLC). All S7 deferred to Phase 4b (one S7-focused branch with a proper read-path refactor).

In scope — five changes (parallelizable, three disjoint projects)

1. Modbus Int64 / UInt64 node DataType — small — live-verifiable

  • File: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs.
  • MapDataType:1517 currently ModbusDataType.Int64 or ModbusDataType.UInt64 => DriverDataType.Int32. Split: Int64 => DriverDataType.Int64, UInt64 => DriverDataType.UInt64.
  • The wire codec (DecodeRegister/EncodeRegister) already round-trips long/ulongno codec change.
  • Remove the now-stale Driver.Modbus-007 XML-doc caveat (:1496-1506) + the inline comment (:1510-1516).
  • Test (ModbusDataTypeTests): MapDataType(Int64)⇒DriverDataType.Int64, (UInt64)⇒UInt64; a discovery test that an Int64 tag surfaces DriverDataType.Int64 on its DriverAttributeInfo.
  • Live (Modbus sim 10.100.0.35:5020): author an Int64 tag, confirm the node advertises Int64 and a value outside the 32-bit range reads back correctly.

2. FOCAS fail-fast factory — small — unit-only

  • Files: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs (factory iface + UnimplementedFocasClientFactory), FocasDriver.cs (InitializeAsync).
  • Today UnimplementedFocasClientFactory.Create() throws only on the first read (lazy Create() at FocasDriver.cs:1079) — so an unimplemented/none/stub backend reports healthy then faults every read/write/subscribe (operator footgun, §2).
  • Add IFocasClientFactory.EnsureUsable() (default no-op; UnimplementedFocasClientFactory overrides to throw the same clear message without creating a live wire client) and call it early in InitializeAsync so the driver faults at config/init time with the actionable message instead of masquerading healthy.
  • Test (FocasScaffoldingTests): a driver with Backend:"unimplemented" throws/faults at InitializeAsync, not at first read; the wire backend still initialises clean.

3. FOCAS position scaling — standard — unit-only (no CNC)

  • Files: FOCAS device-config record (add PositionDecimalPlaces, default 0), FocasDriver.cs (PublishAxisSnapshot).
  • cnc_rddynamic2 returns scaled integers; the 10^DecimalPlaces divide is never applied, so positions are published in CNC-internal units (§2). Auto-fetching the figure via cnc_getfigure is wire-gated → deferred.
  • Add a per-device PositionDecimalPlaces config knob (default 0 = today's behaviour, fully backward compatible). When > 0, apply value / 10^dp to the four position fields only (Absolute / Machine / Relative / DistanceToGo) at the PublishAxisSnapshot seam, which feeds both the subscribe poll and the cached ReadAsync. No node-type change — axis variables are already DriverDataType.Float64 (FocasDriver.cs:519), so a fractional double is type-coherent. FeedRate / SpindleSpeed / ServoLoad are not position-scaled.
  • Test (FocasReadWriteTests or new): dp=3 → raw 12345 publishes 12.345; dp=012345 unchanged; FeedRate/SpindleSpeed never scaled.

4. Historian Total aggregate — standard — unit-only (sidecar-gated)

  • File: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs.
  • MapAggregate:519 throws NotSupported for HistoryAggregateType.Total (Wonderware AnalogSummary has no Total column). Implement it client-side via the time-integral identity: for a time-weighted average, Total = Average × interval-duration. In ReadProcessedAsync, when the requested aggregate is Total, issue the wire query with the Average column and post-multiply each returned bucket's value by interval.TotalSeconds; quality/status carries from the Average query.
  • Document the approximation (time-weighted Average × seconds) in the method remarks.
  • Test (WonderwareHistorianClientTests): replace ReadProcessedAsync_TotalAggregate_ThrowsNotSupported with one asserting Total = Average × interval-seconds (FakeSidecarServer returns Average values; client scales).

5. Historian poison-event dead-letter — standard — unit-only

  • File: src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs.
  • The sidecar reply carries only a per-event bool (no transient-vs-permanent), so every failure maps to RetryPlease; the drain loop bumps AttemptCount but never caps it → a permanently-malformed event retries forever at the 60 s backoff floor (§2 "finding 002").
  • Add a maxAttempts ctor knob (default e.g. 10; mirrors capacity/deadLetterRetention; Validate/ctor guard warns or rejects ≤ 0). Extend QueueRow + the ReadBatch SELECT to carry AttemptCount (the column already exists — schema :23,689). In the RetryPlease branch, dead-letter the row once AttemptCount + 1 ≥ maxAttempts (reason "max attempts exceeded") instead of bumping again.
  • Test (SqliteStoreAndForwardSinkTests): a writer that always returns RetryPlease → after maxAttempts drains the row moves to dead-letter (DeadLetterDepth +1, QueueDepth 1); below the cap it stays queued.

Out of scope — deferred, recorded as follow-ups (not dropped)

  • Phase 4b (S7): S7 Int64/UInt64/LReal/String/DateTime + Timer/Counter — needs the S7.Net.Types byte-buffer read-path refactor.
  • Dedicated array slice: cross-driver IsArray surfacing + EnsureVariable/EquipmentTagPlan array plumbing + Modbus String/BitInRegister array decode + per-driver array reads + Phase-6 authoring UI.
  • Phase 4b (structural): AbCip/TwinCAT UDT member paths, AbLegacy/TwinCAT bit-index RMW writes, Galaxy nested gobject hierarchy + writer item-handle cache, OpcUaClient ReadEventsAsync, FOCAS cnc_getfigure auto-scale, Historian oversized tie-cluster paging (task #400).

Architecture / risk notes

  • Three disjoint projects (Modbus / FOCAS / Historian+AlarmHistorian) → per-driver independence. T1, T2, T4, T5 dispatch concurrently; T3 follows T2 (both edit FocasDriver.cs).
  • The only shared-infra touch is Core.AlarmHistorian (item 5, the durable store-and-forward path) — additive threshold + ctor knob, no schema change (column pre-exists). Treated as the highest-care item.
  • No actor-model, redundancy, security, or address-space contract changes. No IOpcUaAddressSpaceSink touch (that was the deferred array work). DriverDataType enum unchanged (Int64/UInt64 already present).

Testing & verification

  • TDD red→green, xUnit + Shouldly, per existing driver test patterns (ModbusDataTypeTests, FocasScaffoldingTests/FocasReadWriteTests, WonderwareHistorianClientTests, SqliteStoreAndForwardSinkTests). No bUnit.
  • dotnet build clean (production projects are TreatWarningsAsErrors) + full dotnet test green before merge.
  • Final integration review (the durable-store item + the cross-item docs).
  • Live /run: Modbus Int64 on the docker-dev rig (the one live-verifiable item). FOCAS (no CNC) and Historian (sidecar-gated) are unit-proven; their live gates are honestly recorded as deferred.

Hard constraints (carried from the parent roadmap)

  • NO Configuration entity / EF migration. Stage by path — never git add .. Never stage sql_login.txt, src/Server/.../Host/pki/, pending.md, current.md, docker-dev/docker-compose.yml, stillpending.md. Never echo or commit secrets. No force-push, no --no-verify.
  • Finish = merge to master + push (every phase).