# 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.
