9.8 KiB
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). Branchfeat/stillpending-phase-4-driver-datatypesoff master7eeb9fb0. Phases 0–3 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
DriverDataType.Int64/UInt64already exist (Core.Abstractions/DriverDataType.cs:17,20) and the live equipment-node path already resolves the strings"Int64"/"UInt64"→ correct OPC UADataTypeIds(OtOpcUaNodeManager.ResolveBuiltInDataType:1364-1365). So the Modbus "node misreported as Int32" gap is purely the driver'sMapDataTypereturningInt32— a small forward-fix, not a missing enum member.- The array item is mis-scoped for a data-type tier. The live equipment-node materialiser
EnsureVariablehard-wiresValueRank = Scalarwith no array params (OtOpcUaNodeManager.cs:1327);EquipmentTagPlancarries 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 +EnsureVariablecontract change + per-driver read + Phase-6 authoring UI), not a per-driver flag flip. Deferred to its own slice. - 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.Typesdecode 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:1517currentlyModbusDataType.Int64 or ModbusDataType.UInt64 => DriverDataType.Int32. Split:Int64 => DriverDataType.Int64,UInt64 => DriverDataType.UInt64.- The wire codec (
DecodeRegister/EncodeRegister) already round-tripslong/ulong— no codec change. - Remove the now-stale
Driver.Modbus-007XML-doc caveat (:1496-1506) + the inline comment (:1510-1516). - Test (
ModbusDataTypeTests):MapDataType(Int64)⇒DriverDataType.Int64,(UInt64)⇒UInt64; a discovery test that anInt64tag surfacesDriverDataType.Int64on itsDriverAttributeInfo. - 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 (lazyCreate()atFocasDriver.cs:1079) — so anunimplemented/none/stubbackend reports healthy then faults every read/write/subscribe (operator footgun, §2). - Add
IFocasClientFactory.EnsureUsable()(default no-op;UnimplementedFocasClientFactoryoverrides to throw the same clear message without creating a live wire client) and call it early inInitializeAsyncso the driver faults at config/init time with the actionable message instead of masquerading healthy. - Test (
FocasScaffoldingTests): a driver withBackend:"unimplemented"throws/faults atInitializeAsync, not at first read; thewirebackend still initialises clean.
3. FOCAS position scaling — standard — unit-only (no CNC)
- Files: FOCAS device-config record (add
PositionDecimalPlaces, default0),FocasDriver.cs(PublishAxisSnapshot). cnc_rddynamic2returns scaled integers; the10^DecimalPlacesdivide is never applied, so positions are published in CNC-internal units (§2). Auto-fetching the figure viacnc_getfigureis wire-gated → deferred.- Add a per-device
PositionDecimalPlacesconfig knob (default0= today's behaviour, fully backward compatible). When> 0, applyvalue / 10^dpto the four position fields only (Absolute / Machine / Relative / DistanceToGo) at thePublishAxisSnapshotseam, which feeds both the subscribe poll and the cachedReadAsync. No node-type change — axis variables are alreadyDriverDataType.Float64(FocasDriver.cs:519), so a fractional double is type-coherent. FeedRate / SpindleSpeed / ServoLoad are not position-scaled. - Test (
FocasReadWriteTestsor new):dp=3→ raw12345publishes12.345;dp=0→12345unchanged; 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:519throwsNotSupportedforHistoryAggregateType.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. InReadProcessedAsync, when the requested aggregate isTotal, issue the wire query with theAveragecolumn and post-multiply each returned bucket's value byinterval.TotalSeconds; quality/status carries from the Average query.- Document the approximation (time-weighted Average × seconds) in the method remarks.
- Test (
WonderwareHistorianClientTests): replaceReadProcessedAsync_TotalAggregate_ThrowsNotSupportedwith one assertingTotal = 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 bumpsAttemptCountbut never caps it → a permanently-malformed event retries forever at the 60 s backoff floor (§2 "finding 002"). - Add a
maxAttemptsctor knob (default e.g.10; mirrorscapacity/deadLetterRetention;Validate/ctor guard warns or rejects≤ 0). ExtendQueueRow+ theReadBatchSELECT to carryAttemptCount(the column already exists — schema:23,689). In theRetryPleasebranch, dead-letter the row onceAttemptCount + 1 ≥ maxAttempts(reason"max attempts exceeded") instead of bumping again. - Test (
SqliteStoreAndForwardSinkTests): a writer that always returnsRetryPlease→ aftermaxAttemptsdrains 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.Typesbyte-buffer read-path refactor. - Dedicated array slice: cross-driver
IsArraysurfacing +EnsureVariable/EquipmentTagPlanarray 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, FOCAScnc_getfigureauto-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
IOpcUaAddressSpaceSinktouch (that was the deferred array work).DriverDataTypeenum 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 buildclean (production projects areTreatWarningsAsErrors) + fulldotnet testgreen 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 stagesql_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).