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(addReadPositionFiguresAsync, mirrorReadServoMeterAsyncat :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_payloadbranch 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 defaultposition_figuresstate 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:
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. Sendnew RequestBlock(<code>, PathId: requestPathId); onrc != 0return empty. Parse the response payload as consecutive big-endianshort decvalues (payload.Length / 2axes) into aList<int>via the existingReadInt16helper. Keep it self-delimiting (no axis-name pairing needed).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 inWireFocasClient). Rewrite the now-inaccurate<returns>doc to describe the live fetch + graceful-empty fallback. Never throws.- focas_mock
data_store.py— add aposition_figureskey to the default state: a per-axis decimal-place map (or list parallel toaxis_names), defaulting every axis to 0 (→ scale factor 1.0 → no change to existing integration assertions). Make itmock_patch-targetable. - focas_mock
server.py— adddef _wire_position_figures(self) -> bytes:returningb"".join(self._u16(dec) for each axis in axis_names order)reading fromposition_figures(default 0). Add a branch in_wire_payload:if command == <code>: return self._wire_position_figures(). - Build:
dotnet build src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS— clean. - Commit (stage by explicit path): the two
.csfiles + the two.pyfiles.
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[] PerEventStatustoWriteAlarmEventsReply, ~: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(consumePerEventStatus; 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 hasInternalsVisibleTo) - Test:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/WonderwareHistorianClientTests.cs(client mapping + back-compat viaFakeSidecarServer.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:
- 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>();. KeepPerEventOk [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). - Sidecar
HistorianFrameHandler.HandleWriteAlarmEventsAsync: add a pureinternal static byte[] ClassifyAndSplit(AlarmHistorianEventDto[] events, out List<(int index, AlarmHistorianEventDto evt)> writable)(or equivalent): an event withstring.IsNullOrWhiteSpace(SourceName)ORstring.IsNullOrWhiteSpace(AlarmType)OREventTimeUtcTicks <= 0⇒ Permanent (2) and excluded from the writer batch; others ⇒ writer-bound. Call_alarmWriter.WriteAsync(writableEvents); map each writertrue→Ack(0),false→Retry(1) back into its original index. Setreply.PerEventStatus(full length) ANDreply.PerEventOk(Ack→true else false) ANDreply.Success = true. In the no-writer + catch branches, setPerEventStatusto all-Retry(1) (matching the existing all-false PerEventOk). - Client
WriteBatchAsync: after thereply.Successguard, ifreply.PerEventStatus is { Length: > 0 } st && st.Length == batch.Count, map0→Ack, 1→RetryPlease, 2→PermanentFail(unknown→RetryPlease); else keep the existingPerEventOkloop. Rewrite the stale<remarks>(:301-311) + the two inline "PermanentFail is never emitted" comments to describe the new path + the documented SDK-semantic boundary. - Sidecar test:
ClassifyAndSplit— malformed (empty SourceName / empty AlarmType / EventTimeUtcTicks ≤ 0) ⇒ Permanent + excluded; valid ⇒ writer-bound; verify index mapping with a mixed batch. - Client test: (a)
OnWriteAlarmEventsreturnsPerEventStatus=[2]⇒WriteBatchAsyncyieldsPermanentFail; (b) emptyPerEventStatus+PerEventOk=[false]⇒RetryPlease(back-compat); (c)PerEventStatus=[0]⇒Ack. - Build + test:
dotnet buildthe two src projects (note: sidecar targets net48 — build via the solution or the specific TFM) +dotnet testthe two test projects (filter to the new tests). - 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:
- Read
Emulate/AbCipEmulateUdtReadTests.cs+AbServerProfileGate.cs+AbServerFixture.csfor the exact collection/attribute/skip pattern. - Write
AbCipEmulateNestedUdtTestswith one[AbServerFact]test:SkipUnless(Emulate), readAB_SERVER_ENDPOINT, init anAbCipDriverwithEnableControllerBrowse=true, runDiscoverAsyncagainst 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 theAbCipEmulateUdtReadTestsconvention). docs/drivers/AbCip.md: add/extend the nested-struct paragraph — support shipped (3d8ce4e8/d203f31c), live-verified Emulate-tier only becauseab_serverlacks CIP Template Object; referenceAbCipEmulateNestedUdtTests+ the offline unit coverage.- Build + skip-check:
dotnet buildthe 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. - 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:
- Read
Series/WireBackendTests.cs+Series/WireBackendCoverageTests.cs+FocasSimFixture.csfor the[Collection(FocasSimCollection.Name)]+if (_fx.SkipReason is not null) Assert.Skip(...)+mock_patch/LoadProfileAsyncpattern. - Add
Position_figures_scale_axis_via_wire_backend: load a profile,mock_patchaxis_names["X"], a dynamicpos.absolute = 12345, andposition_figuresso X = 3 decimal places; init the wire-backedFocasDriver(FixedTree enabled, probe disabled);WaitFortheAbsolutePositionsnapshot; assert it equals 12.345 (raw ÷ 10^3) — i.e. the auto figure won, not the config knob. - Live
/run(Component A): bring the focas-mock up locally and run the FOCAS integration suite:Confirm the new test executes (not skipped) and passes. Tear the mock down after.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 - 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.mdindex line (under the memory dir)
Steps:
- Full solution build:
dotnet build ZB.MOM.WW.OtOpcUa.slnx— 0 errors. - Targeted tests green: Driver.FOCAS.Tests + Wonderware.Tests + Wonderware.Client.Tests + Core.AlarmHistorian.Tests + AbCip.IntegrationTests (new test skips). Report counts.
stillpending.md(never staged): #3 FOCAS — wirecnc_getfigureshipped + focas-mock-proven (real-Fanuc bench-CNC-gated, graceful fallback); #5 Wonderware — structurally-malformed poison events nowPermanentFail→ immediate dead-letter (additivePerEventStatus; SDK-semantic boundary noted as follow-up); #6 AbCip — live-gate formalized as the Emulate-tier skip-gatedAbCipEmulateNestedUdtTests.- Memory: update
project_stillpending_backlog.md(new top marker for this 3-item phase) + theMEMORY.mdindex line (keep ≤ ~200 chars; trim if over the soft cap). - 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 → confirmlocal 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).