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

18 KiB
Raw Blame History

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). Keep Create()'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 DTO FocasDriverConfigDto/device DTO, add the field there and thread it onto the device options) — add PositionDecimalPlaces (int, default 0).
  • 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 new FocasPositionScalingTests.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 into DeviceState.Options (whatever PublishAxisSnapshot reads as state.Options).
  • In PublishAxisSnapshot, compute var factor = state.Options.PositionDecimalPlaces > 0 ? Math.Pow(10, state.Options.PositionDecimalPlaces) : 1.0; and store snap.AbsolutePosition / factor (etc.) for the four position fields (Absolute / Machine / Relative / DistanceToGo). Leave any FeedRate/SpindleSpeed publishes (and the ServoLoad field) unscaled. Publish as double (already Float64 nodes).
  • Guard a negative config value (treat <0 as 0, or validate in the config parse) so Math.Pow can'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, use MapAggregate(HistoryAggregateType.Average) as the AggregateColumn (i.e. substitute Average for the query).
  • After decoding the reply samples, when the original request was Total, multiply each sample's numeric value by interval.TotalSeconds (re-box at the same value type the Average decode produced; keep StatusCode/timestamps). Factor a small helper if cleaner.
  • Drop the Total throw from MapAggregate (or keep MapAggregate Average-only and never pass it Total). 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 — thread maxAttempts there too; grep new SqliteStoreAndForwardSink( and the AddAlarmHistorian DI 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 DefaultMaxAttempts const (e.g. 10) + int maxAttempts = DefaultMaxAttempts ctor param; store _maxAttempts; guard > 0 (throw ArgumentOutOfRangeException like batchSize/capacity, or clamp with a _logger.Warning). Add the <param> doc.
  • Extend QueueRowrecord struct QueueRow(long RowId, AlarmHistorianEvent? Event, long AttemptCount); add AttemptCount to the ReadBatch SELECT (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 existing PermanentFail arm: 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 + PositionDecimalPlaces knob), 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.md annotations, memory MEMORY.md + project_stillpending_backlog.md.

Steps:

  1. 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 PositionDecimalPlaces config field; Historian Total supported (note the Average×seconds derivation) + the maxAttempts dead-letter cap.
  2. Find the exact doc paths (rg -l "Driver.Modbus-007|PositionDecimalPlaces|AnalogSummary" docs/ + the FOCAS/Modbus/Historian guide docs).
  3. 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"
  1. 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:

  1. dotnet build ZB.MOM.WW.OtOpcUa.slnx — expect 0 errors (production projects are TreatWarningsAsErrors).
  2. 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 full dotnet test ZB.MOM.WW.OtOpcUa.slnx if time allows.)
  3. Final integration reviewer subagent over git diff 57d9f1b3..HEAD: verify (a) no contract/IOpcUaAddressSpaceSink/DriverDataType change leaked in; (b) Modbus codec unchanged (only MapDataType); (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 _queuedRowCount drift) and maxAttempts<=0 is guarded; (f) every commit staged by path, no forbidden file staged.
  4. 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:

  1. 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).
  2. Confirm healthy startup + clean deploy in the logs (applied plan …, OPC UA serving).
  3. 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//uns if authoring through the UI.
  4. 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.
  5. 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 test green + final review SHIP + the Modbus live gate, then finish-a-development-branch → merge to master + push.