From e5b1a5574ac106af570158917d2df1c8b6ecf9d6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 12:19:45 -0400 Subject: [PATCH] docs(plan): implementation plan + tasks for FOCAS/Wonderware/AbCip backlog phase --- ...06-18-focas-figure-ww-poison-abcip-gate.md | 142 ++++++++++++++++++ ...-figure-ww-poison-abcip-gate.md.tasks.json | 12 ++ 2 files changed, 154 insertions(+) create mode 100644 docs/plans/2026-06-18-focas-figure-ww-poison-abcip-gate.md create mode 100644 docs/plans/2026-06-18-focas-figure-ww-poison-abcip-gate.md.tasks.json diff --git a/docs/plans/2026-06-18-focas-figure-ww-poison-abcip-gate.md b/docs/plans/2026-06-18-focas-figure-ww-poison-abcip-gate.md new file mode 100644 index 00000000..9144fab3 --- /dev/null +++ b/docs/plans/2026-06-18-focas-figure-ww-poison-abcip-gate.md @@ -0,0 +1,142 @@ +# 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>>`. 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(, 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` 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();` (match the field/property names actually in `WireFocasClient`). Rewrite the now-inaccurate `` 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 == : 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 `` + 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 `/// Per-event status parallel to Events: 0=Ack, 1=Retry, 2=Permanent. Empty ⇒ older sidecar; fall back to PerEventOk.` `[Key(4)] public byte[] PerEventStatus { get; set; } = Array.Empty();`. **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 <= 0` ⇒ **Permanent (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 `` (`: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 (`.Status.Code`, `.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). diff --git a/docs/plans/2026-06-18-focas-figure-ww-poison-abcip-gate.md.tasks.json b/docs/plans/2026-06-18-focas-figure-ww-poison-abcip-gate.md.tasks.json new file mode 100644 index 00000000..8dab4264 --- /dev/null +++ b/docs/plans/2026-06-18-focas-figure-ww-poison-abcip-gate.md.tasks.json @@ -0,0 +1,12 @@ +{ + "planPath": "docs/plans/2026-06-18-focas-figure-ww-poison-abcip-gate.md", + "executionState": "PENDING", + "tasks": [ + {"id": 1, "subject": "Task 1: FOCAS cnc_getfigure wire command + sim handler", "classification": "standard", "status": "pending", "parallelizableWith": [2, 3]}, + {"id": 2, "subject": "Task 2: Wonderware poison-event per-event status (IPC + classifier + client)", "classification": "standard", "status": "pending", "parallelizableWith": [1, 3]}, + {"id": 3, "subject": "Task 3: AbCip nested-UDT Emulate-tier skip-gated test + docs", "classification": "small", "status": "pending", "parallelizableWith": [1, 2]}, + {"id": 4, "subject": "Task 4: FOCAS integration test + Component A live /run", "classification": "small", "status": "pending", "blockedBy": [1]}, + {"id": 5, "subject": "Task 5: Reconcile backlog + memory + finish (merge + push)", "classification": "small", "status": "pending", "blockedBy": [1, 2, 3, 4]} + ], + "lastUpdated": "2026-06-18" +}