# FOCAS Driver — Implementation Plan > Source of gap analysis: [featuregaps.md → FOCAS](../featuregaps.md#focas-fanuc-cnc) > > 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](#skip-rated-items-for-context) 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 1–3 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 2–10 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 14–18** — 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 42–43** — 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 100–116** — 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 105–108) 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 14–18; **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 42–43 | | `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 100–116; **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 14–18 ("OtOpcUa is **read-only** against FOCAS… Writes return `BadNotWritable` by design.") - `docs/drivers/FOCAS-Test-Fixture.md` lines 42–43 ("`IWritable` intentionally returns `BadNotWritable` — OtOpcUa is read-only against FOCAS.") - `docs/Driver.FOCAS.Cli.md` lines 100–116 (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.