18 KiB
Phase 4 (data-type tier) — Driver data-type & robustness coverage — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task.
Goal: Close five cleanly-achievable scalar data-type & robustness gaps from stillpending.md §2 across three disjoint driver projects (Modbus, FOCAS, Historian/AlarmHistorian).
Architecture: Per-driver-independent changes; no contract churn, no EF migration, no IOpcUaAddressSpaceSink touch. DriverDataType.Int64/UInt64 already exist + already resolve correctly on the equipment-node path. S7 wide types and all array work are deferred (see design doc).
Tech Stack: .NET 10, xUnit + Shouldly (TDD red→green, no bUnit), Akka not touched, SQLite store-and-forward (item 5).
Design: docs/plans/2026-06-16-stillpending-phase-4-driver-datatypes-design.md (branch feat/stillpending-phase-4-driver-datatypes off master 7eeb9fb0).
Hard rules: 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/commit secrets; no force-push; no --no-verify; NO Configuration entity / EF migration.
Task 0: Feature branch — DONE
Branch feat/stillpending-phase-4-driver-datatypes created off master 7eeb9fb0; design doc committed 57d9f1b3. No action.
Task 1: Modbus Int64 / UInt64 node DataType
Classification: small Estimated implement time: ~3 min Parallelizable with: Task 2, Task 4, Task 5
Files:
- Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs(MapDataType~1508-1523; stale doc ~1496-1506) - Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDataTypeTests.cs
Context: DriverDataType.Int64/UInt64 already exist and OtOpcUaNodeManager.ResolveBuiltInDataType already maps "Int64"/"UInt64". The wire codec round-trips long/ulong correctly — only MapDataType lies (returns Int32).
Step 1 — Failing test. In ModbusDataTypeTests.cs add:
[Theory]
[InlineData(ModbusDataType.Int64, DriverDataType.Int64)]
[InlineData(ModbusDataType.UInt64, DriverDataType.UInt64)]
public void MapDataType_64bit_surfaces_correct_DriverDataType(ModbusDataType wire, DriverDataType expected)
=> ModbusDriver.MapDataType(wire).ShouldBe(expected);
(If MapDataType is private, either make it internal + ensure [InternalsVisibleTo] to the test project already exists — it does for DecodeRegister/EncodeRegister tests — or assert via a DiscoverAsync recording-builder test that an Int64 tag's DriverAttributeInfo.DriverDataType == DriverDataType.Int64. Prefer matching the existing visibility pattern in this file.)
Step 2 — Run, expect FAIL (Int64 currently maps to Int32):
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter MapDataType_64bit
Step 3 — Implement. In MapDataType replace the combined line:
ModbusDataType.Int64 or ModbusDataType.UInt64 => DriverDataType.Int32,
with:
ModbusDataType.Int64 => DriverDataType.Int64,
ModbusDataType.UInt64 => DriverDataType.UInt64,
Remove the stale Driver.Modbus-007 XML-doc caveat block (~1496-1506) and the inline // Driver.Modbus-007 … comment (~1510-1516). Leave Bcd16/Bcd32 => Int32 and the _ => Int32 default unchanged.
Step 4 — Run, expect PASS + the full Modbus suite green:
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests
Step 5 — Commit:
git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs \
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDataTypeTests.cs
git commit -m "fix(modbus): surface Int64/UInt64 node DataType (Driver.Modbus-007)"
Task 2: FOCAS fail-fast factory
Classification: small
Estimated implement time: ~4 min
Parallelizable with: Task 1, Task 4, Task 5 (NOT Task 3 — both edit FocasDriver.cs)
Files:
- Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs(IFocasClientFactory,UnimplementedFocasClientFactory,WireFocasClientFactory) - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs(InitializeAsync) - Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasScaffoldingTests.cs
Context: UnimplementedFocasClientFactory.Create() throws only on first read (lazy Create() at FocasDriver.cs:1079), so an unimplemented/none/stub backend reports healthy then faults every read. Make it fail at init.
Step 1 — Failing test. In FocasScaffoldingTests.cs add a test asserting that a driver built on Backend:"unimplemented" throws (or transitions the driver Faulted, matching how this suite already asserts init failures) from InitializeAsync, BEFORE any ReadAsync. Mirror the existing scaffolding-test setup. Assert the exception/health message contains "unimplemented".
Step 2 — Run, expect FAIL (today init succeeds; the throw only happens on first read):
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests --filter Scaffolding
Step 3 — Implement.
- Add to
IFocasClientFactory:void EnsureUsable();(a config-time usability probe that must NOT create a live wire client). WireFocasClientFactory.EnsureUsable()→ no-op ({ }).UnimplementedFocasClientFactory.EnsureUsable()→throw new NotSupportedException(<same message as Create()>).- In
FocasDriver.InitializeAsync, after the factory is in hand and before the probe/poll loops start, call_clientFactory.EnsureUsable();(let it propagate so the driver faults at init with the actionable message). KeepCreate()'s own throw as the lazy backstop.
Step 4 — Run, expect PASS + full FOCAS suite green:
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests
Step 5 — Commit:
git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs \
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs \
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasScaffoldingTests.cs
git commit -m "fix(focas): fail-fast at init on unimplemented backend (operator footgun)"
Task 3: FOCAS position scaling (config DecimalPlaces)
Classification: standard
Estimated implement time: ~5 min
Parallelizable with: Task 1, Task 4, Task 5 (NOT Task 2 — both edit FocasDriver.cs; sequence after Task 2)
Files:
- Modify: the FOCAS device-config record (find it:
rg "record FocasDeviceOptions" src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/; if config flows via a separate DTOFocasDriverConfigDto/device DTO, add the field there and thread it onto the device options) — addPositionDecimalPlaces(int, default0). - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs(PublishAxisSnapshot~817-820) - Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReadWriteTests.cs(or a newFocasPositionScalingTests.cs)
Context: cnc_rddynamic2 returns scaled integers; the 10^dp divide is never applied. Axis variables are already DriverDataType.Float64 (FocasDriver.cs:519) → no node-type change. Scale only the four position fields; FeedRate/SpindleSpeed/ServoLoad are untouched. Default 0 = today's behaviour (backward compatible).
Step 1 — Failing test. Assert: with the device configured PositionDecimalPlaces:3, a snapshot with AbsolutePosition=12345 publishes 12.345d (read it back via ReadAsync of the AbsolutePosition fixed-tree ref, mirroring FocasReadWriteTests setup which drives the poll loop / cache). Add a second case: PositionDecimalPlaces:0 publishes 12345d unchanged; and FeedRate is never scaled.
Step 2 — Run, expect FAIL.
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests --filter Scaling
Step 3 — Implement.
- Add
PositionDecimalPlaces(default 0) to the device options/DTO and thread it intoDeviceState.Options(whateverPublishAxisSnapshotreads asstate.Options). - In
PublishAxisSnapshot, computevar factor = state.Options.PositionDecimalPlaces > 0 ? Math.Pow(10, state.Options.PositionDecimalPlaces) : 1.0;and storesnap.AbsolutePosition / factor(etc.) for the four position fields (Absolute / Machine / Relative / DistanceToGo). Leave any FeedRate/SpindleSpeed publishes (and theServoLoadfield) unscaled. Publish asdouble(already Float64 nodes). - Guard a negative config value (treat
<0as0, or validate in the config parse) soMath.Powcan't misbehave.
Step 4 — Run, expect PASS + full FOCAS suite green:
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests
Step 5 — Commit:
git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ \
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/
git commit -m "feat(focas): scale axis positions by 10^PositionDecimalPlaces (config-supplied)"
(Stage only the precise FOCAS files you edited — list them explicitly, do not blanket-add the dir if other work is present.)
Task 4: Historian Total aggregate (client-side)
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 1, Task 2, Task 3, Task 5
Files:
- Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs(ReadProcessedAsync~102-130,MapAggregate~513-522) - Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs
Context: MapAggregate throws for Total (Wonderware AnalogSummary has no Total column). For a time-weighted average, Total = Average × interval-duration. Compute client-side.
Step 1 — Replace the throws-test. Remove/repurpose ReadProcessedAsync_TotalAggregate_ThrowsNotSupported. Add ReadProcessedAsync_TotalAggregate_ReturnsAverageTimesIntervalSeconds: FakeSidecarServer OnReadProcessed/OnAnalogSummary returns Average buckets (e.g. value 2.0); request Total with interval = TimeSpan.FromMinutes(1) (60 s) → client returns 120.0 for that bucket; quality carries through.
Step 2 — Run, expect FAIL.
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests --filter TotalAggregate
Step 3 — Implement. In ReadProcessedAsync:
- Detect
aggregate == HistoryAggregateType.Total. For the wire request, useMapAggregate(HistoryAggregateType.Average)as theAggregateColumn(i.e. substitute Average for the query). - After decoding the reply samples, when the original request was
Total, multiply each sample's numeric value byinterval.TotalSeconds(re-box at the same value type the Average decode produced; keepStatusCode/timestamps). Factor a small helper if cleaner. - Drop the
Totalthrow fromMapAggregate(or keepMapAggregateAverage-only and never pass itTotal). Add a<remarks>note: "Total is derived client-side as time-weighted Average × interval-seconds; Wonderware AnalogSummary exposes no Total column."
Step 4 — Run, expect PASS + full suite green:
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests
Step 5 — Commit:
git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs \
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs
git commit -m "feat(historian): support Total aggregate (client-side Average x interval-seconds)"
Task 5: Historian poison-event dead-letter cap
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 1, Task 2, Task 3, Task 4
Files:
- Modify:
src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs(ctor ~119-135,ReadBatch/QueueRow~555-586, drain outcome loop ~448-462) - Test:
tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/SqliteStoreAndForwardSinkTests.cs - (If a wiring/options site passes the other knobs —
capacity/deadLetterRetention— threadmaxAttemptsthere too; grepnew SqliteStoreAndForwardSink(and theAddAlarmHistorianDI to find call sites.)
Context: All failures map to RetryPlease; the drain bumps AttemptCount but never caps it → poison events retry forever. The AttemptCount column already exists (schema :23,689); QueueRow just doesn't surface it.
Step 1 — Failing test. In SqliteStoreAndForwardSinkTests.cs: a writer stub that always returns RetryPlease; enqueue one event; drain maxAttempts times (construct the sink with a small maxAttempts, e.g. 3). Assert after the 3rd drain the row is dead-lettered (HistorianSinkStatus.DeadLetterDepth == 1, QueueDepth == 0); assert before the cap it stays queued.
Step 2 — Run, expect FAIL (today it never dead-letters):
dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests --filter MaxAttempts
Step 3 — Implement.
- Add
DefaultMaxAttemptsconst (e.g.10) +int maxAttempts = DefaultMaxAttemptsctor param; store_maxAttempts; guard> 0(throwArgumentOutOfRangeExceptionlikebatchSize/capacity, or clamp with a_logger.Warning). Add the<param>doc. - Extend
QueueRow→record struct QueueRow(long RowId, AlarmHistorianEvent? Event, long AttemptCount); addAttemptCountto theReadBatchSELECT (SELECT RowId, PayloadJson, AttemptCount …) and read it (reader.GetInt64(2)). - In the drain outcome loop,
case HistorianWriteOutcome.RetryPlease:→if (liveRows[i].AttemptCount + 1 >= _maxAttempts) DeadLetterRow(conn, tx, rowId, $"max attempts ({_maxAttempts}) exceeded"); else BumpAttempt(conn, tx, rowId, "retry-please");(and count a dead-lettered row as leaving the queue, mirroring the existingPermanentFailarm:rowsLeavingQueue++).
Step 4 — Run, expect PASS + full suite green:
dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests
Step 5 — Commit:
git add src/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs \
tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/SqliteStoreAndForwardSinkTests.cs
git commit -m "fix(historian): dead-letter poison events after maxAttempts (finding 002)"
Task 6: Docs + bookkeeping
Classification: small Estimated implement time: ~4 min Parallelizable with: none (run after Tasks 1-5 land)
Files:
- Modify:
docs/drivers/FOCAS.md(fail-fast backend +PositionDecimalPlacesknob), the Modbus driver doc (Int64/UInt64 now surfaced),docs/Historian.md(Total aggregate now supported; poison-event dead-letter cap). - Modify (disk-only, do NOT commit):
pending.md,stillpending.mdannotations, memoryMEMORY.md+project_stillpending_backlog.md.
Steps:
- Update each driver doc: Modbus Int64/UInt64 node-type now correct (remove the Driver.Modbus-007 caveat if mentioned); FOCAS unimplemented backend now fails at init + the
PositionDecimalPlacesconfig field; HistorianTotalsupported (note the Average×seconds derivation) + themaxAttemptsdead-letter cap. - Find the exact doc paths (
rg -l "Driver.Modbus-007|PositionDecimalPlaces|AnalogSummary" docs/+ the FOCAS/Modbus/Historian guide docs). - Commit ONLY the
docs/**files by path:
git add docs/drivers/FOCAS.md docs/Historian.md <modbus-doc-path>
git commit -m "docs(phase4): Int64/UInt64 modbus, FOCAS fail-fast+scaling, Historian Total+dead-letter"
- Update
pending.md/stillpending.md/ memory on disk (NOT staged) at the integration step.
Task 7: Full build + test + final integration review
Classification: high-risk (final gate) Estimated implement time: ~6 min Parallelizable with: none
Steps:
dotnet build ZB.MOM.WW.OtOpcUa.slnx— expect 0 errors (production projects areTreatWarningsAsErrors).dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests tests/Core/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests— all green. (Then a fulldotnet test ZB.MOM.WW.OtOpcUa.slnxif time allows.)- Final integration reviewer subagent over
git diff 57d9f1b3..HEAD: verify (a) no contract/IOpcUaAddressSpaceSink/DriverDataTypechange leaked in; (b) Modbus codec unchanged (onlyMapDataType); (c) FOCAS scaling applies to positions only + default 0 is byte-identical to old behaviour; (d) Historian Total quality/timestamp carry-through is correct; (e) the dead-letter cap counts the row as leaving the queue (no_queuedRowCountdrift) andmaxAttempts<=0is guarded; (f) every commit staged by path, no forbidden file staged. - Address any blocking findings, re-review, then proceed.
Task 8: Live /run — Modbus Int64 (the one live-verifiable item)
Classification: high-risk (acceptance gate, agent-driven on docker-dev — login disabled) Estimated implement time: ~8 min Parallelizable with: none
Steps:
- Rebuild the docker-dev central nodes onto this branch:
docker compose -f docker-dev/docker-compose.yml up -d --build central-1 central-2(do NOT stage the compose file). - Confirm healthy startup + clean deploy in the logs (
applied plan …, OPC UA serving). - Author (or seed) a Modbus equipment tag of type Int64 bound to the modbus sim (
10.100.0.35:5020), deploy, and via Client.CLI confirm the node advertises Int64 and a value outside the 32-bit range reads back correctly (browse the node's DataType + read). Drive the AdminUI//unsif authoring through the UI. - FOCAS (no CNC) and Historian (sidecar-gated on
10.100.0.48) live gates: record honestly as unit-proven / deferred — do not fabricate a pass. - Capture the evidence in the completion report.
Execution notes (subagent-driven)
- Dispatch order: Tasks 1, 2, 4, 5 are mutually parallelizable (disjoint projects) — dispatch their implementers concurrently. Task 3 follows Task 2 (both edit
FocasDriver.cs). Task 6 after 1-5; Task 7 then Task 8. - Review chain by classification: small → code-reviewer (Sonnet/Haiku); standard → spec ∥ code reviewers; Task 7 → final integration reviewer. Implementer model: opus for standard (3,4,5), sonnet for small (1,2,6).
- Done = build clean +
dotnet testgreen + final review SHIP + the Modbus live gate, then finish-a-development-branch → merge to master + push.