Files
lmxopcua/docs/plans/focas-plan.md
Joseph Doherty 2d07d716dc Recover stashed driver-gaps work from pre-v2-mxgw-merge working tree
Captures uncommitted work that lived in the working tree on
v2-mxgw-integration but was orthogonal to the migration. Stashed
during the v2-mxgw merge to master (2026-04-30) and replanted here on
a feature branch off master so it's git-visible rather than living in
the stash list.

Two distinct buckets:

1. Tracked fixture/config refinements (10 files, ~36 lines):
   - scripts/e2e/test-opcuaclient.ps1
   - src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json
   - 5 docker-compose.yml under tests/.../IntegrationTests/Docker/
     (AbCip, Modbus, OpcUaClient, S7)
   - 4 fixture .cs files (AbServerFixture, ModbusSimulatorFixture,
     OpcPlcFixture, Snap7ServerFixture)

2. Untracked driver-gaps queue artifacts (~8000 lines):
   - docs/plans/{abcip,ablegacy,focas,opcuaclient,s7,twincat}-plan.md
     — per-driver gap plans
   - docs/featuregaps.md — cross-cutting analysis
   - docs/v2/focas-deployment.md, docs/v2/implementation/focas-simulator-plan.md
   - followup.md — auto/driver-gaps queue follow-ups
   - scripts/queue/ — PR-queue automation tooling (12 files including
     pr-manifest.yaml at 1473 lines)

This commit is a snapshot for recoverability — review and split into
focused PRs (or discard) before merging anywhere downstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:28:01 -04:00

44 KiB
Raw Blame History

FOCAS Driver — Implementation Plan

Source of gap analysis: featuregaps.md → FOCAS

Covers Build = Yes items only.

Summary

The FOCAS driver today is a pure-managed, read-only FOCAS/2 wire client (src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/) backing a fixed-tree projection plus user-authored PARAM: / MACRO: / PMC tags. It exposes a thin set of calls (cnc_sysinfo, cnc_rdcncstat, cnc_rdaxisname, cnc_rdspdlname, cnc_rddynamic2, cnc_rdsvmeter, cnc_rdspload, cnc_rdspmaxrpm, cnc_exeprgname2, cnc_rdblkcount, cnc_rdopmode, cnc_rdtimer, cnc_rdparam, cnc_rdmacro, pmc_rdpmcrng, cnc_rdalmmsg2).

The featuregaps table marks 18 items as Build = Yes. They cluster into five distinct workstreams:

  1. Phase 1 — fixed-tree expansion (#6, #7, #8, #10, #11, #12, #13, #14, #18, #20, #24, #27). These are mostly new wire calls plumbed into the existing FixedTree* poll cadences; no architectural change.
  2. Phase 2 — addressing additions (#4, #14 DIAG scheme, #15, #16). New FocasAreaKind values, new capability-matrix entries, multi-path PathId. Touches the parser + matrix + wire envelope; mostly additive.
  3. Phase 3 — alarm history (#17). Extends the existing FocasAlarmProjection with a one-shot history pull on connect plus periodic delta polls.
  4. Phase 4 — write path (#1, #3). The biggest behavioural change in the driver's lifetime: removes the BadNotWritable short-circuit, adds cnc_wrparam / pmc_wrpmcrng / cnc_wrmacro plus FOCAS password handling. Material risk surface — see Risks.
  5. Phase 5 — derived telemetry (#24 cycle-delta computation). Optional companion to #24 raw cycle time; computes "last completed cycle" from the existing cumulative Cycle timer.

DIAG (#14) is in Phase 2 (addressing) rather than Phase 1 because it needs a new address scheme, but the fixed-tree status flag projection (#12) is the cheapest item and should land first as a vertical slice.

The remaining 9 items in the featuregaps table (HSSB, Series 15 / 35i, tool-offset write, program upload/download, DPRNT, deep servo info, acceleration/jerk, operator preset commands, NTP) are scoped out as Build = No; they appear in Skip-rated items for context only.

Phased delivery

Phase Scope Gaps closed Approx PRs Risk
1 Fixed-tree expansion (read-only) 12, 13, 7, 8, 10, 11, 20, 18, 6, 24, 27, 14 (read-only piece) 6 Low
2 Addressing additions 4, 15, 16, 14 (DIAG: scheme) 4 Medium (multi-path)
3 Alarm history 17 1 Low
4 Write path + password 1, 3 4 High (read-only design choice removed)
5 Cycle-delta derived telemetry 24 (delta companion) 1 Low

Phases 13 are mutually independent and can ship in any order. Phase 4 deliberately follows Phase 2 so writes ride on top of the multi-path addressing already in place. Phase 5 tags onto the cycle-time node from Phase 1.

Per-PR detail

Phase 1 — fixed-tree expansion

Common shape: each PR adds one or more wire calls in Wire/FocasWireClient.cs, surfaces them on IFocasClient, plumbs them into FocasDriver's FixedTreeLoopAsync cadences (axis 250 ms / program 1 s / timer 30 s) and the TryReadFixedTree synthesizer, then adds fakes + assertions.

PR F1-a — ODBST status flags as fixed-tree nodes (#12)

  • Scope: project the 9 fields of cnc_rdcncstat (tmmode, aut, run, motion, mstb, emergency, alarm, edit, dummy) under Status/ per device. We already issue this call in ProbeAsync; this PR keeps the boolean probe but additionally caches the full struct on every poll tick.
  • Files: Wire/FocasWireClient.cs (extend ReadStatusAsync to return the whole WireStatus rather than only IsOk), IFocasClient.cs (new GetStatusAsync), FocasDriver.cs (new Status/* branch in TryReadFixedTree, status cache on DeviceState).
  • Tests: tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFixedTreeStatusTests.cs (new) — FakeFocasClient returns canned ODBST, assert each field maps to the expected Status/* browse name. Integration: extend FocasSimFixture to seed the simulator's status response and assert via the OPC UA client.
  • Docs / fixture / e2e: extend docs/drivers/FOCAS.md fixed-tree table with the 9 Status/* nodes; mention the boolean-probe → full-struct change in docs/drivers/FOCAS-Test-Fixture.md integration bullet list; teach focas-mock (under tests/.../IntegrationTests/Docker/focas-mock/) the cnc_rdcncstat payload shape per docs/v2/implementation/focas-wire-protocol.md (add ODBST struct entry); extend FocasSimFixture with a helper to patch the canned status payload; new Series/StatusFlagsPopulateTests.cs integration test.
  • Effort: small; one wire call already exists.
  • Risk: Low.

PR F1-b — parts count + cycle time (#13, #24 raw)

  • Scope: surface cnc_rdparam(6711) (parts produced), 6712 (parts required), 6713 (parts total since power-on) under Production/, plus Production/CycleTimeSeconds (already exposed as Timers/CycleSeconds — promote to the Production/ group too with the same backing). The existing cnc_rdtimer call is sufficient.
  • Files: FocasDriver.cs (Production/* branch, parameter-cached reads on the timer poll cadence), IFocasClient.cs (no new call — rides on ReadParameterInt32Async).
  • Tests: FakeFocasClient returns canned parameter values; assert Production/PartsTotal equals the canned value.
  • Docs / fixture / e2e: add Production/* rows to the fixed-tree table in docs/drivers/FOCAS.md; add Production: example to docs/Driver.FOCAS.Cli.md (a read -a PARAM:6711 snippet); the parts-count parameters (6711/6712/6713) are already in the simulator profile range, so only the dl205-style profile JSON under tests/.../Docker/focas-mock/profiles/ needs seeded values added; extend FocasSimFixture with a SeedPartsCount helper; integration test under Series/ProductionPopulatesTests.cs.
  • Effort: small.
  • Risk: Low.

PR F1-c — modal G/M/T codes (#7) + override values (#11)

  • Scope: add cnc_modal (command id TBD per fwlib32.h — the wire protocol uses the same numeric command convention seen in FocasWireClient; capture during simulator iteration). Project: Modal/G_Group{n} (groups 1..21), Modal/MCode, Modal/SCode, Modal/TCode, Modal/BCode. Adds Override/Feed, Override/Rapid, Override/Spindle, Override/Jog from cnc_rdparam(...) — the override percent registers live at known parameter numbers; numbers are MTB-specific so pull defaults from docs/v2/focas-version-matrix.md and let operators override per device.
  • Files: Wire/FocasWireClient.cs (new ReadModalAsync), new Wire/FocasWireModels.cs records WireModal / WireModalGroup, IFocasClient.cs (new GetModalAsync), FocasDriver.cs (new poll-medium branches under the program-poll cadence).
  • Tests: FocasModalTests.cs (unit), simulator handler returns canned modal payload, integration asserts Modal/G_Group1 text.
  • Docs / fixture / e2e: add Modal/* and Override/* sections to the fixed-tree table in docs/drivers/FOCAS.md, including the G-group decode table for groups 01/03/06/07/14; add a MODAL: address example row to docs/Driver.FOCAS.Cli.md (new read -a MODAL:G1 style — note: this PR does NOT add a new address scheme, the modal data is fixed-tree only, so the CLI example reads via read -n "ns=2;s=Modal/G_Group1" over the OPC UA endpoint); document MTB-specific override register defaults in docs/v2/focas-version-matrix.md (new Override registers per series table); capture the cnc_modal command id resolved during simulator iteration into docs/v2/implementation/focas-wire-protocol.md (new struct entry — promote out of the open-questions list); update docs/v2/implementation/focas-simulator-plan.md Stream C protocol-surface table with the new cnc_modal handler; extend focas-mock with a cnc_modal command-id handler + canned modal payload per profile; integration test reading G54/G90 modal state via Series/ModalPopulatesTests.cs.
  • Effort: medium — cnc_modal returns a multi-group struct; encoding needs care.
  • Risk: Medium — modal-group numbering varies by series; treat the raw integer as the value the CNC reports and surface a string decode table only for the universally-present groups (G-group 01 motion, 03 absolute/incremental, 06 input units, 07 cutter comp, 14 work coordinate). Document MTB-specific groups as raw int.

PR F1-d — tool number / tool life (#8) + work coordinate offsets (#10)

  • Scope: add cnc_rdtofs / cnc_rdtlife* / cnc_rdzofs. Project Tooling/CurrentTool, Tooling/CurrentOffset, Tooling/Life/{group}/Remaining, Tooling/Life/{group}/Total, Offsets/G54..G59[+ extended]/{X,Y,Z}.
  • Files: new wire calls in Wire/FocasWireClient.cs (ReadToolOffsetAsync, ReadToolLifeAsync, ReadWorkOffsetAsync), Wire/FocasWireModels.cs (records), IFocasClient.cs, FocasDriver.cs (new Tooling/ and Offsets/ branches; both poll on the slow timer cadence — these change at setup time, not per-cycle), capability matrix per-call suppression like the existing Spindle/ gating.
  • Tests: unit + simulator. Tool-life is the largest payload; assert array projection rather than per-tool nodes (one ValueRank=1 array per group keeps the address-space size bounded on machines with 500+ tool slots).
  • Docs / fixture / e2e: add Tooling/* and Offsets/* sections to the fixed-tree table in docs/drivers/FOCAS.md, including the ValueRank=1 array note for tool-life groups; add a per-series capability-suppression row to docs/v2/focas-version-matrix.md (which series support cnc_rdtlife* vs not); document the three new structs (ODBTOFS, ODBTLIFE5, IODBZOR) in docs/v2/implementation/focas-wire-protocol.md; add cnc_rdtofs / cnc_rdtlife* / cnc_rdzofs rows to the protocol surface table in docs/v2/implementation/focas-simulator-plan.md; extend focas-mock with three new command-id handlers + per-profile seed data (tool table + work-offset table); add a tools_per_series matrix to the focas-mock per-series profile JSON so 0i-D's small tool table differs from 30i's; new Series/ToolingPopulatesTests.cs and Series/OffsetsPopulatesTests.cs integration tests; update docs/drivers/FOCAS-Test-Fixture.md coverage map with the three new wire calls.
  • Effort: large — three new calls, each with its own struct; tool-life is variable-length.
  • Risk: Medium — payload shapes are series-specific; keep the capability matrix as the authoritative gate.

PR F1-e — operator messages (#18) + currently-executing block text (#20)

  • Scope: cnc_rdopmsg3 (gives all four FANUC opmsg classes in one call), cnc_rdactpt (current block text). Project Messages/External (variable, last-N strings), Program/CurrentBlock (single string).
  • Files: Wire/FocasWireClient.cs (ReadOperatorMessagesAsync, ReadCurrentBlockAsync), IFocasClient.cs, FocasDriver.cs (new branches under program-poll cadence).
  • Tests: simulator returns canned ASCII; assert string round-trip is trim-stable (FANUC right-pads with \0 or space).
  • Docs / fixture / e2e: add Messages/External and Program/CurrentBlock rows to the fixed-tree table in docs/drivers/FOCAS.md, including the ring-buffer / last-N semantics for opmsg; document the OPMSG3 and ODBACT2 payload shapes in docs/v2/implementation/focas-wire-protocol.md; add cnc_rdopmsg3 / cnc_rdactpt rows to the protocol surface table in docs/v2/implementation/focas-simulator-plan.md; extend focas-mock with the two new command-id handlers (per-profile canned message text + canned current-block text); add a mock_patch_opmsg admin endpoint hook on FocasSimFixture for tests that need to push a canned message; integration test Series/OperatorMessagesPopulateTests.cs asserts trim-stable round-trip and last-N retention.
  • Effort: medium.
  • Risk: Low — ASCII-only payloads.

PR F1-f — cnc_getfigure decimal scaling (#6) + connection statistics (#27)

  • Scope: cnc_getfigure returns per-axis decimal-place counts; cache the result at bootstrap and divide each AbsolutePosition / MachinePosition / RelativePosition / DistanceToGo / ActualFeedRate value before publishing. Existing nodes already carry Float64; the change is invisible to clients except that values become real-world units. Adds Diagnostics/ subtree: Diagnostics/ReadCount, Diagnostics/ReadFailureCount, Diagnostics/LastErrorMessage, Diagnostics/LastSuccessfulRead, Diagnostics/ReconnectCount — driven by counters already maintained on DeviceState.
  • Files: Wire/FocasWireClient.cs (new ReadFigureAsync), IFocasClient.cs, FocasDriver.cs (cache decimal places per axis, multiply on the read path, expose counters under Diagnostics/).
  • Tests: assert that with a canned cnc_getfigure returning 3, an AbsolutePosition of 12345 becomes 12.345. Connection-stat tests assert counters increment under known conditions.
  • Docs / fixture / e2e: significant docs/drivers/FOCAS.md change — add a "Decimal-place scaling" subsection explaining the FixedTree.ApplyFigureScaling flag (default true on new installs, false on migrations) and the unit-correctness semantics it enforces; add Diagnostics/* rows to the fixed-tree table; add a Diagnostics-counters subsection to docs/v2/focas-deployment.md for operator dashboards; document cnc_getfigure (ODBAXDP / ODBAXIS) struct in docs/v2/implementation/focas-wire-protocol.md; add cnc_getfigure to the protocol surface in docs/v2/implementation/focas-simulator-plan.md; extend focas-mock with the per-axis decimal-place command handler + a decimal_places field on each profile JSON; update docs/drivers/FOCAS-Test-Fixture.md "When to trust each layer" table with a "Are axis values reported in real-world units?" row; add an opt-in -CheckDecimalScaling switch to scripts/e2e/test-focas.ps1 that asserts AbsolutePosition is scaled when the flag is on; integration test Series/DecimalScalingTests.cs and Series/DiagnosticsCountersTests.cs.
  • Effort: medium — touches every axis read.
  • Risk: Medium — this is a behavioural change for any existing consumer that was already dividing client-side. Surface as a FixedTree.ApplyFigureScaling opt-in flag (default true on new installs, false when migrating); document in docs/drivers/FOCAS.md.

Phase 2 — addressing additions

PR F2-a — DIAG: address scheme (#14)

  • Scope: new FocasAreaKind.Diagnostic parsed from DIAG:nnn / DIAG:nnn/axis, dispatched to cnc_rddiag (or cnc_rddiagdgn for series that support it).
  • Files: FocasAddress.cs (new prefix branch), FocasCapabilityMatrix.cs (new DiagnosticRange per series), Wire/FocasWireClient.cs (ReadDiagnosticAsync), WireFocasClient.ReadAsync (new dispatch branch).
  • Tests: parser unit tests, capability matrix unit tests, simulator read-round-trip.
  • Docs / fixture / e2e: add a DIAG: row to the address-syntax table in docs/Driver.FOCAS.Cli.md with read -a DIAG:301 and DIAG:301/0 (axis-scoped) examples; add a DIAG: row to the addressing table in docs/drivers/FOCAS.md; add per-series DiagnosticRange columns to docs/v2/focas-version-matrix.md; document the ODBDGN struct in docs/v2/implementation/focas-wire-protocol.md; add cnc_rddiag / cnc_rddiagdgn to the protocol surface in docs/v2/implementation/focas-simulator-plan.md; extend focas-mock with the diagnostic-range command handler + per-profile seeded diagnostic numbers; integration test Series/DiagAddressTests.cs round-trips a seeded diagnostic number; update docs/drivers/FOCAS-Test-Fixture.md capability list with the new Diagnostic FocasAreaKind.
  • Effort: medium.
  • Risk: Low — additive.

PR F2-b — Multi-path / multi-channel CNC (#4)

  • Scope: 30i/31i/32i can host 210 paths; today every request block is built with PathId = 1 (Wire/FocasWireProtocol.cs:216). Add optional Path segment to FocasAddress (e.g. PARAM:1815@2, R100@3.0, MACRO:500@2); thread it into the RequestBlock.PathId field. Fixed-tree gets a Paths/{n}/ folder pivot.
  • Files: FocasAddress.cs (new Path field + parser), IFocasClient.cs (every read call gains an optional pathId parameter, defaulting to 1 for backward compatibility), Wire/FocasWireClient.cs (thread the param through every RequestBlock constructor), FocasDriver.cs (per-device PathCount discovery via cnc_rdpathnum; iterate fixed-tree per path).
  • Tests: unit on the parser; simulator with two paths configured; assert that a PARAM:1815@2 read targets path 2.
  • Docs / fixture / e2e: significant docs/drivers/FOCAS.md update — new "Multi-path / multi-channel CNC" subsection explaining the @N suffix syntax, Paths/{n}/ browse pivot, and per-path capability gating; add @N to every address row in the addressing table in docs/Driver.FOCAS.Cli.md; document cnc_rdpathnum (ODBPATHNUM struct) in docs/v2/implementation/focas-wire-protocol.md, and update the RequestBlock.PathId discussion (was hard-coded to 1 — now a parameter); add cnc_rdpathnum to the protocol surface and the per-profile path_count field to the profile schema in docs/v2/implementation/focas-simulator-plan.md; extend focas-mock with per-path state isolation (separate PMC / param / macro tables per path_id) and a new multi_path profile (e.g. thirtyone_i_dual_path); add a -Paths switch to scripts/e2e/test-focas.ps1 that runs the matrix once per declared path; document the new compose profile in docs/drivers/FOCAS-Test-Fixture.md; new Series/MultiPathTests.cs integration test asserting independent per-path reads.
  • Effort: large — touches every wire call's RequestBlock shape.
  • Risk: Medium — backward compatibility for existing single-path configs. Default PathId = 1 everywhere; only deviate when the address explicitly carries a @N suffix or when the fixed-tree loop is iterating discovered paths.

PR F2-c — PMC F/G letters for 16i (#15)

  • Scope: capability matrix bug — PmcLetters(Sixteen_i) currently returns {X, Y, R, D}; real 16i ladders use F/G for handshakes. Widen the set; verify the address pmc_rdpmcrng numeric letter codes match.
  • Files: FocasCapabilityMatrix.cs (one-line fix to the 16i case), tests/.../FocasCapabilityMatrixTests.cs (assert F0.0 and G50.5 parse against Sixteen_i).
  • Docs / fixture / e2e: update the 16i row of the PMC-letters column in docs/v2/focas-version-matrix.md (the row currently lists X/Y/R/D — add F/G); add a one-line "fixed in v…" callout to the changelog section of the same doc; no simulator change required (the 16i profile JSON in tests/.../Docker/focas-mock/profiles/sixteen_i.json already has F/G ranges declared from Stream B); add F0.0 / G50.5 probes to the 16i row of the per-series matrix in scripts/e2e/test-focas.ps1; no fixture-doc change needed.
  • Effort: trivial.
  • Risk: Low — correctness fix.

PR F2-d — Bulk PMC range read (#16)

  • Scope: today the driver issues one pmc_rdpmcrng per tag (one TCP RTT each). The wire call already supports a range [start, end]; the missing piece is coalescing on the read side. Add a coalescer: group same-letter contiguous (or near-contiguous within a small gap budget) PMC bytes from the request batch into one wire call per group, then slice client-side. Reuse the Modbus coalescing infrastructure pattern (per-group-id ProhibitedRanges) where it applies.
  • Files: new Wire/FocasPmcCoalescer.cs, hook into FocasDriver.ReadAsync between the per-tag path and the wire call layer. Surface coalesce stats on the Diagnostics/ subtree (PR F1-f).
  • Tests: unit — given a request batch of R100..R110, assert that the coalescer issues one call covering 100..110 and slices the result. Integration — assert observed wire-call count drops with coalescing on.
  • Docs / fixture / e2e: add a "PMC range coalescing" subsection to docs/drivers/FOCAS.md (wire-call reduction, gap budget, per-series byte cap); document the new Diagnostics/CoalesceStats/* counters added on top of PR F1-f's diagnostics tree; add a PMC-byte-cap column to docs/v2/focas-version-matrix.md; no new wire calls (pmc_rdpmcrng is already in the surface), but document the supported max-bytes-per-call in docs/v2/implementation/focas-wire-protocol.md; extend focas-mock with a request-counter admin endpoint so integration tests can assert the call-count reduction (counter visible via FocasSimFixture.GetWireCallCountAsync); update docs/v2/implementation/focas-simulator-plan.md Stream B validation harness with the request-counter handler; integration test Series/PmcCoalescingTests.cs asserts an R100..R110 batch produces exactly 1 wire call against the mock.
  • Effort: medium.
  • Risk: Medium — the FANUC max-bytes-per-pmc_rdpmcrng ceiling is series-specific; cap conservatively (≤ 256 bytes per range) and let operators raise it via config if their CNC accepts more.

Phase 3 — alarm history

PR F3-a — cnc_rdalmhistry extension to alarm projection (#17)

  • Scope: extend FocasAlarmProjection with two modes — ActiveOnly (today's behaviour) and ActivePlusHistory. In the latter, on connect (and on a configurable cadence — default 5 min, since the CNC ring buffer changes only on alarm raise/clear) issue cnc_rdalmhistry for the most-recent N entries; project as historic events through IAlarmSource with OccurrenceTime from the CNC's timestamp field.
  • Files: new Wire/FocasWireClient.ReadAlarmHistoryAsync, new IFocasClient.ReadAlarmHistoryAsync, FocasAlarmProjection.cs (mode switch + history poll loop), FocasDriverOptions.cs (AlarmProjection.Mode enum + HistoryPollInterval + HistoryDepth).
  • Tests: simulator returns canned history payload; assert events fire with the timestamps from the canned data and don't re-fire on every poll.
  • Docs / fixture / e2e: add an "Alarm history" subsection to docs/drivers/FOCAS.md documenting the ActiveOnly vs ActivePlusHistory mode switch, the HistoryDepth cap, and the dedup key; add a configuration-knob row to docs/v2/focas-deployment.md for operator dashboards; document ODBALMHIS struct in docs/v2/implementation/focas-wire-protocol.md; add cnc_rdalmhistry to the protocol surface in docs/v2/implementation/focas-simulator-plan.md; extend focas-mock with a ring-buffer alarm history (per profile) + mock_patch_alarmhistory admin endpoint; expose a SeedAlarmHistoryAsync helper on FocasSimFixture; add Series/AlarmHistoryProjectionTests.cs asserting historic events fire once and active events still fire raise/clear; update docs/drivers/FOCAS-Test-Fixture.md integration bullet list with cnc_rdalmhistry.
  • Effort: medium.
  • Risk: Medium — duplicate-event suppression; key history events on (timestamp, alarmNumber, type) to deduplicate against the active list.

Phase 4 — write path

This phase is the major behavioural change. The driver's read-only contract has been the documented design choice in docs/drivers/FOCAS.md:14-18 and is reinforced by tests (FocasReadWriteTests.WriteAsync_ReturnsBadNotWritable). Removing it deserves a deliberate decision-record entry in the v2 decisions log before any code lands.

PR F4-a — write infrastructure + per-tag opt-in (no wire calls yet)

  • Scope: drop the BadNotWritable short-circuit in WireFocasClient.WriteAsync and replace with a kind-based dispatch that returns BadNotWritable only for kinds the wire client doesn't yet implement. Honour FocasTagDefinition.Writable (already present, default true — flip default to false per #1's safer posture). Plumb WriteIdempotent through Polly retry.
  • Files: WireFocasClient.cs, FocasDriverOptions.cs, FocasDriver.cs, docs/drivers/FOCAS.md (rewrite the read-only paragraph), new docs/v2/decisions.md entry.
  • Tests: assert that with Writable=false the path still returns BadNotWritable; with Writable=true and an unimplemented kind the write returns BadNotSupported (distinct from the per-tag policy denial).
  • Docs / fixture / e2e: this is the heaviest doc PR in the plan.
    • docs/drivers/FOCAS.md lines 1418 — revoke the unconditional "OtOpcUa is read-only against FOCAS… Writes return BadNotWritable by design" callout. Replace with a "Writes (opt-in, off by default)" subsection that names Writes.Enabled, the per-tag Writable flag (default flipped to false), and links to the Phase 4 decision-record entry.
    • docs/drivers/FOCAS-Test-Fixture.md lines 4243 — revoke the "IWritable intentionally returns BadNotWritable — OtOpcUa is read-only against FOCAS" callout. Replace with a qualified "default behaviour" note plus a pointer to the new write-enabled test profile.
    • docs/Driver.FOCAS.Cli.md lines 100116 — the existing write section already documents the CLI shape; expand the "Writes are non-idempotent by default" warning with a server-side note that the OtOpcUa endpoint enforces the Writes.Enabled flag and rejects writes when off, and that the CLI itself talks to the driver directly so its writes are not gated by the server flag (operator must consciously use the right tool).
    • New docs/v2/decisions.md entry "FOCAS write-path opt-in" capturing the design-choice reversal.
    • Update docs/featuregaps.md row for #1 / #3 — flip Build = Yes annotation to "shipping behind flag".
    • Simulator: no new commands; existing read commands gain a "writes when not unlocked" branch wired up here for symmetry even though no write commands ship yet (returns BadNotSupported until F4-b lands).
    • E2E: add -Write switch (no-op stage in this PR; populated by F4-b) to scripts/e2e/test-focas.ps1.
  • Effort: medium.
  • Risk: High — design-choice reversal. Mitigation: ship behind a driver-level Writes.Enabled flag (default false); operators must explicitly enable in appsettings.json.

PR F4-b — cnc_wrmacro + cnc_wrparam

  • Scope: implement macro and parameter writes. Both have well-defined payload shapes mirroring their read counterparts (IODBPSD for parameters, ODBM for macros).
  • Files: Wire/FocasWireClient.cs (new WriteParameterAsync, WriteMacroAsync), WireFocasClient.WriteAsync (dispatch).
  • Tests: simulator extension — accept writes and reflect them on subsequent reads. ACL tests in tests/ZB.MOM.WW.OtOpcUa.IntegrationTests to verify the server-layer enforcement (per the memory entry: ACL decisions happen in DriverNodeManager, never in driver-level code).
  • Docs / fixture / e2e:
    • docs/drivers/FOCAS.md — extend the "Writes" subsection (introduced in F4-a) with the two new write kinds, the Writes.AllowParameter and Writes.AllowMacro granular flags, and a security note: parameter writes require LDAP group WriteConfigure, macro writes require WriteOperate (cross-link to docs/Security.md).
    • docs/v2/focas-deployment.md — significant addition: a "Write safety" section covering operator pre-checks (CNC in MDI mode, parameter-write switch enabled), audit-log expectations, and the LDAP group requirements.
    • docs/Driver.FOCAS.Cli.md — populate the existing write examples for PARAM: and MACRO: (already present at lines 105108) with a "Server-enforced ACL" note linking to docs/Security.md.
    • Document IODBPSD (write side) and ODBM (write side) in docs/v2/implementation/focas-wire-protocol.md (the read-side structs are already there — flag the byte layout symmetry).
    • docs/v2/implementation/focas-simulator-plan.md — add cnc_wrparam / cnc_wrmacro to the protocol surface table and update Stream C status accordingly.
    • Extend focas-mock with cnc_wrparam / cnc_wrmacro handlers that mutate the per-profile state and return EW_PASSWD when the unlock state is off (sets up F4-d's test path); add mock_get_last_write admin endpoint for audit-log assertions.
    • New Series/ParameterWriteTests.cs and Series/MacroWriteTests.cs integration tests; ACL test under tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/Authz/FocasWriteAclTests.cs asserting WriteConfigure is required for PARAM: writes and WriteOperate for MACRO: writes.
    • scripts/e2e/test-focas.ps1 — populate the -Write stage from F4-a with macro and parameter round-trip writes against the Docker mock.
  • Effort: medium.
  • Risk: High — a misdirected parameter write can put the CNC into a bad state. Surface a Writes.AllowParameter flag separate from Writes.Enabled so operators can grant macro writes without parameter writes.

PR F4-c — pmc_wrpmcrng

  • Scope: PMC range writes. Read-modify-write semantics for bit-level writes (the wire call is byte-addressed). Existing tests (FocasPmcBitRmwTests.cs) prove the read-modify-write pattern shape that the write path needs.
  • Files: Wire/FocasWireClient.cs (new WritePmcRangeAsync), bit-level RMW helper in WireFocasClient.
  • Tests: simulator round-trip on byte writes; bit-level write asserts the unrelated bits in the same byte are preserved.
  • Docs / fixture / e2e:
    • docs/drivers/FOCAS.md — extend the "Writes" subsection with PMC writes; loud safety callout block ("PMC is ladder working memory — a mistargeted bit can move motion"); document the read-modify-write semantics for bit-level writes; document the new Writes.AllowPmc granular flag.
    • docs/v2/focas-deployment.md — extend the "Write safety" section with PMC-specific pre-checks (e-stop, jog mode); add an ops-runbook bullet on auditing PMC writes from the Diagnostics/CoalesceStats/ (extended) tree.
    • docs/Driver.FOCAS.Cli.md — the existing write example write -h … -a G50.3 -t Bit -v on (line 107) is already PMC-bit; update its surrounding warning to call out RMW behaviour.
    • Document the pmc_wrpmcrng request frame in docs/v2/implementation/focas-wire-protocol.md (the read frame is already there — flag the inverted shape).
    • docs/v2/implementation/focas-simulator-plan.md — add pmc_wrpmcrng to the protocol surface table.
    • Extend focas-mock with pmc_wrpmcrng handler that mutates per-profile PMC tables; assert byte-aligned writes preserve untouched bytes (mirrors the driver's RMW contract).
    • New Series/PmcRangeWriteTests.cs and Series/PmcBitRmwIntegrationTests.cs integration tests; ACL test under tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/Authz/FocasPmcWriteAclTests.cs asserting WriteOperate is required.
    • scripts/e2e/test-focas.ps1 — extend the -Write stage with a PMC bit round-trip.
  • Effort: medium.
  • Risk: High — PMC is the ladder logic's working memory; a mistargeted write can move motion. Document loudly.

PR F4-d — FOCAS password / unlock parameter (#3)

  • Scope: some controllers gate cnc_wrparam and certain reads behind a connection-level password. Add Password to FocasDeviceOptions; emit the FOCAS password block during connect (cnc_wrunlockparam per FOCAS docs — confirm the exact command id during simulator iteration). On any read/write returning EW_PASSWD re-issue the password and retry once.
  • Files: Wire/FocasWireClient.cs (UnlockAsync), FocasDriverOptions.cs (Password field, treated as a secret — redact in logs), FocasDriver.cs (call on connect).
  • Tests: simulator extension — emit EW_PASSWD on writes when not unlocked; assert the unlock+retry path.
  • Docs / fixture / e2e:
    • docs/drivers/FOCAS.md — new "FOCAS password" subsection under Writes describing the optional Password device-option, when the CNC requires it (16i + some 30i firmwares with parameter- protect on), and the redaction guarantee.
    • Security-note in docs/v2/focas-deployment.md — significant addition: a "FOCAS password handling" subsection covering storage in appsettings.json (and the dev redaction pattern at .local/), the no-log invariant, and a runbook for password rotation. Cross-link to docs/Security.md.
    • docs/Driver.FOCAS.Cli.md — add a --cnc-password flag row to the "Common flags" table with the redaction note.
    • Document cnc_wrunlockparam (or the resolved command id) in docs/v2/implementation/focas-wire-protocol.md; resolve the open question raised by F4-d into the doc.
    • docs/v2/implementation/focas-simulator-plan.md — add cnc_wrunlockparam to the protocol surface; document the per-profile unlock_password field on the JSON profile schema.
    • Extend focas-mock with locked-state semantics on parameter writes (already half-stubbed in F4-b's EW_PASSWD branch); add cnc_wrunlockparam handler; add mock_set_password admin endpoint so integration tests can pin the unlock value.
    • New Series/PasswordUnlockTests.cs integration test asserts a write returning EW_PASSWD triggers exactly one unlock retry, and the second write succeeds.
    • scripts/e2e/test-focas.ps1 — add -CncPassword parameter, threaded through to the CLI for the -Write stage.
  • Effort: small — once Phase 4-a/b are in.
  • Risk: Medium — password storage. Use the existing appsettings.json redaction pattern (memory entry: dohertj2 AppData path); never log the password value.

Phase 5 — derived telemetry

PR F5-a — Cycle time per part / last cycle delta (#24 derivation)

  • Scope: with Production/CycleTimeSeconds in place from F1-b and the parts-count from cnc_rdparam, compute "last completed cycle" as the delta in Timers/CycleSeconds between successive parts-count increments. Project Production/LastCycleSeconds, Production/LastCycleStartUtc.
  • Files: FocasDriver.cs only — pure derivation in the program-poll cadence handler.
  • Tests: simulate a parts-count increment from 5→6; assert LastCycleSeconds equals the cycle-timer delta over the same window.
  • Docs / fixture / e2e: add Production/LastCycleSeconds and Production/LastCycleStartUtc rows to the fixed-tree table in docs/drivers/FOCAS.md with the rollover / counter-reset behaviour documented; add a Derived telemetry callout in docs/v2/focas-deployment.md explaining the derivation is client-visible only (no new wire calls); no docs/v2/implementation/focas-wire-protocol.md change (pure derivation); no focas-mock change beyond FocasSimFixture's existing parameter-patch / timer-patch helpers — add a SimulateCycleCompletionAsync convenience helper that increments parts-count and advances the cycle timer atomically; new Series/CycleDeltaTests.cs integration test simulates a 5→6 parts-count transition; no scripts/e2e/test-focas.ps1 change.
  • Effort: small.
  • Risk: Low — pure derivation.

Documentation, fixture, and e2e impact

Consolidated view of every doc, fixture, and e2e artefact this plan touches. FOCAS has the largest doc surface of any driver in the v2 roadmap because Phase 4 reverses a long-standing read-only design choice that is referenced from at least three user-facing docs and one test-fixture doc.

Docs touched (per file, with the heaviest PR called out)

Doc Touched by Heaviest change
docs/drivers/FOCAS.md F1-a, F1-b, F1-c, F1-d, F1-e, F1-f, F2-a, F2-b, F2-d, F3-a, F4-a, F4-b, F4-c, F4-d, F5-a F4-a revokes the read-only callout at lines 1418; F2-b adds the multi-path subsection
docs/drivers/FOCAS-Test-Fixture.md F1-a, F1-d, F1-f, F2-a, F2-b, F3-a, F4-a F4-a revokes the "IWritable intentionally returns BadNotWritable" callout at lines 4243
docs/Driver.FOCAS.Cli.md F1-b, F1-c, F2-a, F2-b, F4-a, F4-b, F4-c, F4-d F4-a qualifies the read-only stance at lines 100116; F4-d adds --cnc-password flag
docs/v2/focas-deployment.md F1-f, F3-a, F4-a, F4-b, F4-c, F4-d F4-b adds "Write safety" section; F4-d adds "FOCAS password handling" section
docs/v2/focas-version-matrix.md F1-c, F1-d, F2-a, F2-c, F2-d F1-d adds capability-suppression rows for tooling/offsets
docs/v2/implementation/focas-wire-protocol.md F1-a, F1-c, F1-d, F1-e, F1-f, F2-a, F2-b, F2-d, F3-a, F4-b, F4-c, F4-d F1-d documents three new structs (ODBTOFS, ODBTLIFE5, IODBZOR); F4-d resolves the cnc_wrunlockparam open question
docs/v2/implementation/focas-simulator-plan.md F1-c, F1-d, F1-e, F1-f, F2-a, F2-b, F2-d, F3-a, F4-a, F4-b, F4-c, F4-d Each PR appends to the protocol surface table; F4-* close out Stream C status
docs/v2/decisions.md (new entry) F4-a Net-new decision-record for the read-only reversal
docs/featuregaps.md F4-a Updates Build = Yes annotation for #1 / #3 with "shipping behind flag"

Fixture (focas-mock) extensions

The vendored Python focas-mock simulator under tests/.../IntegrationTests/Docker/focas-mock/ gains the following new command-id handlers and per-profile state:

PR Mock extension
F1-a cnc_rdcncstat full-struct response
F1-b Seeded values for parameters 6711/6712/6713 in every profile JSON
F1-c New cnc_modal handler + canned modal payload per profile
F1-d cnc_rdtofs / cnc_rdtlife* / cnc_rdzofs handlers + per-profile tool/offset tables, plus a tools_per_series profile knob
F1-e cnc_rdopmsg3 / cnc_rdactpt handlers + mock_patch_opmsg admin endpoint
F1-f cnc_getfigure handler + per-profile decimal_places field
F2-a cnc_rddiag / cnc_rddiagdgn handlers + per-profile diagnostic numbers
F2-b Per-path state isolation; new path_count profile field; new thirtyone_i_dual_path compose profile
F2-c No mock change (16i profile already declares F/G ranges)
F2-d Wire-call counter admin endpoint
F3-a Ring-buffer alarm history + mock_patch_alarmhistory admin endpoint
F4-a Stub branch returning BadNotSupported for write commands
F4-b cnc_wrparam / cnc_wrmacro handlers (with EW_PASSWD when locked); mock_get_last_write admin endpoint
F4-c pmc_wrpmcrng handler with byte-aligned write semantics
F4-d cnc_wrunlockparam handler; mock_set_password admin endpoint; locked-state on the param-write path
F5-a SimulateCycleCompletionAsync helper on FocasSimFixture (no new mock command)

FocasSimFixture (in tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/FocasSimFixture.cs) gains corresponding admin-API client helpers for each new endpoint.

Integration tests (per phase)

Phase New / extended integration tests under tests/.../FOCAS.IntegrationTests/Series/
Phase 1 StatusFlagsPopulateTests.cs, ProductionPopulatesTests.cs, ModalPopulatesTests.cs, ToolingPopulatesTests.cs, OffsetsPopulatesTests.cs, OperatorMessagesPopulateTests.cs, DecimalScalingTests.cs, DiagnosticsCountersTests.cs
Phase 2 DiagAddressTests.cs, MultiPathTests.cs, PmcCoalescingTests.cs (plus a 16i row in FocasCapabilityMatrixTests.cs for F2-c)
Phase 3 AlarmHistoryProjectionTests.cs
Phase 4 ParameterWriteTests.cs, MacroWriteTests.cs, PmcRangeWriteTests.cs, PmcBitRmwIntegrationTests.cs, PasswordUnlockTests.cs plus ACL tests under tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/Authz/FocasWriteAclTests.cs and FocasPmcWriteAclTests.cs
Phase 5 CycleDeltaTests.cs

E2E script (scripts/e2e/test-focas.ps1) updates

PR Change
F1-f New -CheckDecimalScaling switch
F2-b New -Paths switch (matrix mode iterates per declared path)
F2-c Adds F0.0 / G50.5 probes to the 16i row of the per-series matrix
F4-a Adds -Write switch (no-op stage in F4-a; populated by F4-b/c)
F4-b Populates -Write stage with macro + parameter round-trip writes
F4-c Extends -Write stage with PMC bit round-trip
F4-d Adds -CncPassword parameter, threaded through to the CLI

scripts/integration/run-focas.ps1 does not change shape across the plan — it remains the compose up/test/compose down wrapper. New profiles registered by F2-b are automatically picked up via the existing -Profile switch.

Read-only callouts requiring revocation in Phase 4

For reviewer benefit, the explicit read-only callouts that F4-a must revoke or qualify in the same PR that flips the design choice:

  • docs/drivers/FOCAS.md lines 1418 ("OtOpcUa is read-only against FOCAS… Writes return BadNotWritable by design.")
  • docs/drivers/FOCAS-Test-Fixture.md lines 4243 ("IWritable intentionally returns BadNotWritable — OtOpcUa is read-only against FOCAS.")
  • docs/Driver.FOCAS.Cli.md lines 100116 (write section is already documented but predates the server-side flag; needs a server-enforced-ACL note)
  • docs/featuregaps.md (FOCAS row entries for #1 and #3 carry the same read-only-by-design framing — flip annotation)

Skip-rated items (for context)

These appear in the featuregaps recommendations table as Build = No; recapped here so reviewers can confirm the scope decision rather than re-deriving it from featuregaps.md:

  • #2 HSSB transport — PCI hardware, declining install base, reopens the Fwlib distribution problem the wire client deliberately closed.
  • #5 Series 15 / Power Mate D-H / Series 35i — very legacy; small install base. Capability matrix already accepts Unknown as a permissive escape hatch.
  • #9 Tool-offset write — write-heavy; defer alongside the general write decision (F4 covers reads via tool-life only).
  • #19 Program list / upload / download / delete — DNC product territory; significant scope; out of OtOpcUa's MES focus.
  • #21 DPRNT TCP listener — significant scope; modern OPC UA alarms / events supersede it.
  • #22 Servo / spindle deep info (cnc_rdsvinfo / cnc_rdspinfo) — specialty; load-percent already covers most needs.
  • #23 Per-axis acceleration / jerk / feed-per-rev — niche advanced telemetry.
  • #25 Operator write commands (preset, cnc_setpath, cnc_wrabsmac) — read-only-by-design covers it; parameter / PMC / macro writes from Phase 4 are the supervisory writes operators actually need.
  • #26 CNC time / date sync — rare ask; commonly handled by CNC NTP.

Open questions

  • Modal command id (PR F1-c): cnc_modal numeric command code is not in the existing wire-protocol notes (docs/v2/implementation/focas-wire-protocol.md). Capture during the simulator iteration loop; if the simulator can't yet emit the shape, gate F1-c behind a bench-CNC trace per the diminishing-returns checkpoint.
  • Override parameter numbers (PR F1-c): feedrate / rapid / spindle override register numbers are MTB-specific. Default to the documented Fanuc factory numbers and let operators override per device (Devices[].OverrideRegisters map).
  • Multi-path discovery (PR F2-b): does the simulator support multi-path responses today? If not, F2-b lands gated behind the OTOPCUA_FOCAS_SIM_WIRE_COMPAT=1 flag the wire-protocol doc describes.
  • Decimal-scaling migration (PR F1-f): existing Float64 axis nodes are scaled integers today. Decision: ship F1-f with scaling-on default, add a one-release deprecation window with the flag default-off so existing dashboards don't silently scale by 10^N when the driver is upgraded. Need explicit operator opt-in.
  • Write security posture (Phase 4): should writes require LDAP group WriteConfigure (parameters) vs WriteOperate (macros / PMC)? Per the memory entry on ACL-at-server-layer, the driver only reports SecurityClassification; the server enforces. Need the driver to surface the right classification per address kind: Configure for PARAM:, Operate for MACRO: and PMC writes.
  • Phase 4 rollout: ship behind a feature flag in appsettings.json (Drivers.{name}.Config.Writes.Enabled) with false default for at least one release before flipping the default. Update docs/drivers/FOCAS.md and docs/featuregaps.md in the same PR that flips the default.
  • Cycle-delta edge cases (PR F5-a): parts-count rollover; counter reset by the operator. Default behaviour: emit the delta only when the counter strictly increments by 1; on any other transition emit Production/LastCycleSeconds as null with BadOutOfRange and let the operator interpret.