Files
lmxopcua/docs/plans/2026-06-18-focas-figure-ww-poison-abcip-gate.md
T

15 KiB

FOCAS cnc_getfigure + Wonderware poison-event status + AbCip nested-UDT live-gate — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task.

Goal: Close three backlog items — FOCAS cnc_getfigure wire command (#3), Wonderware poison-event per-event status (#5), AbCip nested-UDT Emulate-tier live-gate (#6).

Architecture: Three disjoint-project components. A: a new FOCAS/2 wire command in FocasWireClient + matching focas_mock handler, wiring the WireFocasClient stub. B: an additive [Key(4)] PerEventStatus field on the in-repo Wonderware sidecar IPC reply + a sidecar structural-malformed classifier + client consumption. C: a skip-gated Emulate-tier AbCip nested-UDT integration test + docs.

Tech Stack: .NET 10 (FOCAS, AbCip) / .NET 4.8 sidecar (Wonderware), MessagePack IPC, python focas-mock, xUnit + Shouldly.

Design: docs/plans/2026-06-18-focas-figure-ww-poison-abcip-gate-design.md (committed a687821f).

Standing constraints: NO Commons/proto change · NO Core.Abstractions/breaking-interface change (the sidecar IAlarmEventWriter is NOT touched) · NO EF migration · NO bUnit · stage by explicit path (never git add .) · never stage sql_login.txt/pki//pending.md/current.md/stillpending.md/docker-dev/docker-compose.yml · never echo/commit secrets · no force-push / no --no-verify · dangerouslyDisableSandbox:true for all build/test/rig commands · finish = merge to master + push.


Task 1: FOCAS cnc_getfigure wire command + sim handler

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 2, Task 3

Files:

  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs (add ReadPositionFiguresAsync, mirror ReadServoMeterAsync at :351-386)
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs:287-297 (replace the empty-list stub)
  • Modify: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/server.py (add _wire_position_figures + a _wire_payload branch for the chosen command code)
  • Modify: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/src/focas_mock/data_store.py (add a default position_figures state key)

Context: WireFocasClient is the production path to real CNCs. FocasDriver.cs:662 wraps the fetch in SafeProbe(…, []) and AxisFactor uses a figure only when present & ≥ 0 — so an empty/failed read degrades to today's config-knob behavior (cannot regress). The .NET FocasWireClient ↔ python focas_mock wire protocol is co-designed & sim-validated (bench-CNC validation is gated for the whole backend — out of scope). IFocasClient.GetPositionFiguresAsync already exists — no interface change.

Steps:

  1. FocasWireClient.ReadPositionFiguresAsync(CancellationToken, TimeSpan? timeout, ushort? pathId)Task<FocasResult<IReadOnlyList<int>>>. Pick a wire command id unused by both the client and the mock's _wire_payload (e.g. 0x00D3); add an XML-doc note that the code is sim-consistent and bench-CNC-unvalidated. Send new RequestBlock(<code>, PathId: requestPathId); on rc != 0 return empty. Parse the response payload as consecutive big-endian short dec values (payload.Length / 2 axes) into a List<int> via the existing ReadInt16 helper. Keep it self-delimiting (no axis-name pairing needed).
  2. WireFocasClient.GetPositionFiguresAsync — replace the stub body: var r = await _client.ReadPositionFiguresAsync(cancellationToken).ConfigureAwait(false); return r.Rc == 0 && r.Value is not null ? r.Value : Array.Empty<int>(); (match the field/property names actually in WireFocasClient). Rewrite the now-inaccurate <returns> doc to describe the live fetch + graceful-empty fallback. Never throws.
  3. focas_mock data_store.py — add a position_figures key to the default state: a per-axis decimal-place map (or list parallel to axis_names), defaulting every axis to 0 (→ scale factor 1.0 → no change to existing integration assertions). Make it mock_patch-targetable.
  4. focas_mock server.py — add def _wire_position_figures(self) -> bytes: returning b"".join(self._u16(dec) for each axis in axis_names order) reading from position_figures (default 0). Add a branch in _wire_payload: if command == <code>: return self._wire_position_figures().
  5. Build: dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS — clean.
  6. Commit (stage by explicit path): the two .cs files + the two .py files.

Acceptance: stub gone; client method follows the ReadServoMeterAsync pattern; mock returns per-axis figures defaulting to 0; FOCAS project builds clean. (Integration proof is Task 4.)


Task 2: Wonderware poison-event per-event status (sidecar IPC + classifier + client)

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 1, Task 3

Files:

  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/Ipc/Contracts.cs (add [Key(4)] byte[] PerEventStatus to WriteAlarmEventsReply, ~:216-228)
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/Contracts.cs (mirror the same [Key(4)] addition, ~:252-266)
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware/Ipc/HistorianFrameHandler.cs:162-199 (classifier + populate both fields)
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianClient.cs:301-369 (consume PerEventStatus; rewrite stale <remarks> + inline comments)
  • Test: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/ (new file, e.g. HistorianEventClassifierTests.cs) — sidecar classifier unit test (project has InternalsVisibleTo)
  • Test: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs (client mapping + back-compat via FakeSidecarServer.OnWriteAlarmEvents)

Context: Both IPC ends are in-repo (MessagePack/TCP — not Commons). HistorianWriteOutcome already has PermanentFail; the sink (SqliteStoreAndForwardSink.cs:456-465) already dead-letters it. The sidecar IAlarmEventWriter.WriteAsync returns only bool[]do NOT change that interface (its only real impl is the infra-gated AahClientManagedAlarmEventWriter). AlarmHistorianEventDto fields: EventId, SourceName (req), ConditionId?, AlarmType (req), Message?, Severity, EventTimeUtcTicks, AckComment?.

Steps:

  1. DTO (both Contracts.cs, identical): add /// <summary>Per-event status parallel to Events: 0=Ack, 1=Retry, 2=Permanent. Empty ⇒ older sidecar; fall back to PerEventOk.</summary> [Key(4)] public byte[] PerEventStatus { get; set; } = Array.Empty<byte>();. Keep PerEventOk [Key(3)]. Define the 3 byte constants once on each side (e.g. const byte StatusAck=0, StatusRetry=1, StatusPermanent=2; local to the consumer, or a tiny shared-by-copy comment — do NOT add a Core.Abstractions enum).
  2. Sidecar HistorianFrameHandler.HandleWriteAlarmEventsAsync: add a pure internal static byte[] ClassifyAndSplit(AlarmHistorianEventDto[] events, out List<(int index, AlarmHistorianEventDto evt)> writable) (or equivalent): an event with string.IsNullOrWhiteSpace(SourceName) OR string.IsNullOrWhiteSpace(AlarmType) OR EventTimeUtcTicks <= 0Permanent (2) and excluded from the writer batch; others ⇒ writer-bound. Call _alarmWriter.WriteAsync(writableEvents); map each writer true→Ack(0), false→Retry(1) back into its original index. Set reply.PerEventStatus (full length) AND reply.PerEventOk (Ack→true else false) AND reply.Success = true. In the no-writer + catch branches, set PerEventStatus to all-Retry(1) (matching the existing all-false PerEventOk).
  3. Client WriteBatchAsync: after the reply.Success guard, if reply.PerEventStatus is { Length: > 0 } st && st.Length == batch.Count, map 0→Ack, 1→RetryPlease, 2→PermanentFail (unknown→RetryPlease); else keep the existing PerEventOk loop. Rewrite the stale <remarks> (:301-311) + the two inline "PermanentFail is never emitted" comments to describe the new path + the documented SDK-semantic boundary.
  4. Sidecar test: ClassifyAndSplit — malformed (empty SourceName / empty AlarmType / EventTimeUtcTicks ≤ 0) ⇒ Permanent + excluded; valid ⇒ writer-bound; verify index mapping with a mixed batch.
  5. Client test: (a) OnWriteAlarmEvents returns PerEventStatus=[2]WriteBatchAsync yields PermanentFail; (b) empty PerEventStatus + PerEventOk=[false]RetryPlease (back-compat); (c) PerEventStatus=[0]Ack.
  6. Build + test: dotnet build the two src projects (note: sidecar targets net48 — build via the solution or the specific TFM) + dotnet test the two test projects (filter to the new tests).
  7. Commit (explicit paths): 4 src files + 2 test files.

Acceptance: additive [Key(4)] on both ends; sidecar classifies structurally-malformed events as Permanent and excludes them; client emits PermanentFail from PerEventStatus and falls back to PerEventOk; stale comments gone; tests green. No IAlarmEventWriter / Commons / Core.Abstractions change.


Task 3: AbCip nested-UDT Emulate-tier skip-gated test + docs

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 1, Task 2

Files:

  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateNestedUdtTests.cs
  • Modify: docs/drivers/AbCip.md (nested-struct = Emulate-tier verified note, ~:136)

Context: Decode/threading already shipped (3d8ce4e8/d203f31c); offline unit tests pin it (301/301). ab_server can't serve CIP Template Object (class 0x6C) → a local gate is impossible; the honest close is an Emulate-tier skip-gated smoke. Mirror Emulate/AbCipEmulateUdtReadTests.cs exactly (same [AbServerFact] + AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate) + AB_SERVER_ENDPOINT env, Emulate = "emulate").

Steps:

  1. Read Emulate/AbCipEmulateUdtReadTests.cs + AbServerProfileGate.cs + AbServerFixture.cs for the exact collection/attribute/skip pattern.
  2. Write AbCipEmulateNestedUdtTests with one [AbServerFact] test: SkipUnless(Emulate), read AB_SERVER_ENDPOINT, init an AbCipDriver with EnableControllerBrowse=true, run DiscoverAsync against a documented nested-UDT Emulate project (parent UDT with a nested struct member), assert the nested atomic leaves are addressable (<Tag>.Status.Code, <Tag>.Status.Running) and the nested sub-folder materializes. Document the assumed Emulate project shape in an XML/comment header (matching the AbCipEmulateUdtReadTests convention).
  3. docs/drivers/AbCip.md: add/extend the nested-struct paragraph — support shipped (3d8ce4e8/d203f31c), live-verified Emulate-tier only because ab_server lacks CIP Template Object; reference AbCipEmulateNestedUdtTests + the offline unit coverage.
  4. Build + skip-check: dotnet build the AbCip.IntegrationTests project (clean); run the new test by name and confirm it skips cleanly on this Mac (no Emulate): dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --filter FullyQualifiedName~Nested_struct -- … → 0 failed, 1 skipped.
  5. Commit (explicit paths): the new test file + docs/drivers/AbCip.md.

Acceptance: the test compiles & skips locally (proving it's wired); docs describe the Emulate-tier gate honestly. No runtime change.


Task 4: FOCAS integration test + Component A live /run

Classification: small Estimated implement time: ~4 min Parallelizable with: none (after Task 1)

Files:

  • Modify: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs (new skip-gated test)

Steps:

  1. Read Series/WireBackendTests.cs + Series/WireBackendCoverageTests.cs + FocasSimFixture.cs for the [Collection(FocasSimCollection.Name)] + if (_fx.SkipReason is not null) Assert.Skip(...) + mock_patch/LoadProfileAsync pattern.
  2. Add Position_figures_scale_axis_via_wire_backend: load a profile, mock_patch axis_names ["X"], a dynamic pos.absolute = 12345, and position_figures so X = 3 decimal places; init the wire-backed FocasDriver (FixedTree enabled, probe disabled); WaitFor the AbsolutePosition snapshot; assert it equals 12.345 (raw ÷ 10^3) — i.e. the auto figure won, not the config knob.
  3. Live /run (Component A): bring the focas-mock up locally and run the FOCAS integration suite:
    docker compose -f tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/docker-compose.yml up -d --build
    dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests --filter FullyQualifiedName~Position_figures_scale
    
    Confirm the new test executes (not skipped) and passes. Tear the mock down after.
  4. Commit (explicit path): the integration test file.

Acceptance: the new integration test runs against the live focas-mock and proves the scaled position end-to-end.


Task 5: Reconcile backlog + memory + finish (merge + push)

Classification: small Estimated implement time: ~4 min Parallelizable with: none (last)

Files:

  • Modify (NEVER staged): stillpending.md (mark #3/#5/#6)
  • Modify: memory project_stillpending_backlog.md + MEMORY.md index line (under the memory dir)

Steps:

  1. Full solution build: dotnet build ZB.MOM.WW.OtOpcUa.slnx — 0 errors.
  2. Targeted tests green: Driver.FOCAS.Tests + Wonderware.Tests + Wonderware.Client.Tests + Core.AlarmHistorian.Tests + AbCip.IntegrationTests (new test skips). Report counts.
  3. stillpending.md (never staged): #3 FOCAS — wire cnc_getfigure shipped + focas-mock-proven (real-Fanuc bench-CNC-gated, graceful fallback); #5 Wonderware — structurally-malformed poison events now PermanentFail → immediate dead-letter (additive PerEventStatus; SDK-semantic boundary noted as follow-up); #6 AbCip — live-gate formalized as the Emulate-tier skip-gated AbCipEmulateNestedUdtTests.
  4. Memory: update project_stillpending_backlog.md (new top marker for this 3-item phase) + the MEMORY.md index line (keep ≤ ~200 chars; trim if over the soft cap).
  5. Finish (superpowers-extended-cc:finishing-a-development-branch): verify tests → ff-merge feat/focas-figure-ww-poison-abcip-gate → master → push to origin → delete branch → confirm local master = origin/master.

Acceptance: build clean, tests green, backlog + memory reconciled, merged to master + pushed.


Dependency graph

{T1 ∥ T2 ∥ T3}T4 (needs T1) → T5 (last). T1/T2/T3 touch disjoint projects → dispatch implementers concurrently with worktree isolation (shared-tree git-race lesson).