diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index 61939a2..02b892c 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -18,8 +18,6 @@
-
-
@@ -65,8 +63,7 @@
-
-
+
diff --git a/docs/Driver.FOCAS.Cli.md b/docs/Driver.FOCAS.Cli.md
index e558a53..d5a258f 100644
--- a/docs/Driver.FOCAS.Cli.md
+++ b/docs/Driver.FOCAS.Cli.md
@@ -5,27 +5,22 @@ protocol. Uses the **same** `FocasDriver` the OtOpcUa server does — PMC R/G/F
file registers, axis bits, parameters, and macro variables — all through
`FocasAddressParser` syntax.
-Sixth of the driver test-client CLIs, added alongside the Tier-C isolation
-work tracked in task #220.
+Sixth of the driver test-client CLIs.
## Architecture note
-FOCAS is a Tier-C driver: `Fwlib32.dll` is a proprietary 32-bit Fanuc library
-with a documented habit of crashing its hosting process on network errors.
-The target runtime deployment splits the driver into an in-process
-`FocasProxyDriver` (.NET 10 x64) and an out-of-process `Driver.FOCAS.Host`
-(.NET 4.8 x86 Windows service) that owns the DLL — see
-[v2/implementation/focas-isolation-plan.md](v2/implementation/focas-isolation-plan.md)
-and
-[v2/implementation/phase-6-1-resilience-and-observability.md](v2/implementation/phase-6-1-resilience-and-observability.md)
-for topology + supervisor / respawn / back-pressure design.
+FOCAS is an in-process driver. The pure-managed `WireFocasClient`
+speaks the FOCAS2 binary protocol directly over TCP:8193, removing the
+Tier-C process-isolation split that the historical P/Invoke + out-of-
+process Host arrangement required. The CLI loads `FocasDriver` with
+`WireFocasClientFactory` and talks to the CNC without any native
+components.
-The CLI skips the proxy and loads `FocasDriver` directly (via
-`FwlibFocasClientFactory`, which P/Invokes `Fwlib32.dll` in the CLI's own
-process). There is **no public simulator** for FOCAS; a meaningful probe
-requires a real CNC + a licensed `Fwlib32.dll` on `PATH` (or next to the
-executable). On a dev box without the DLL, every wire call surfaces as
-`BadCommunicationError` — still useful as a "CLI wire-up is correct" signal.
+A dev-friendly mock is available — start
+`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/docker-compose.yml`
+and point `--cnc-host` at `localhost` for end-to-end CLI exercises
+without a real CNC. See
+[drivers/FOCAS-Test-Fixture.md](drivers/FOCAS-Test-Fixture.md).
## Build + run
@@ -152,7 +147,8 @@ fails.
**"Why did this macro flip?"** → `subscribe` to the macro, let the
operator reproduce the cycle, watch the HH:mm:ss.fff timeline.
-**"Is the Fwlib32 DLL wired up?"** → `probe` against any host. A
-`DllNotFoundException` surfacing as `BadCommunicationError` with a
-matching `Last error` line means the driver is loading but the DLL is
-missing; anything else means a transport-layer problem.
+**"Can I reach the CNC on TCP:8193?"** → `probe` against any host. A
+`BadCommunicationError` means the wire client couldn't open a socket
+(firewall / wrong host / FOCAS Ethernet option unlicensed on the CNC).
+`BadDeviceFailure` after a successful connect means the CNC is rejecting
+the session setup — check the CNC's FOCAS option and password settings.
diff --git a/docs/drivers/FOCAS-Test-Fixture.md b/docs/drivers/FOCAS-Test-Fixture.md
index 3ecf97d..c99919f 100644
--- a/docs/drivers/FOCAS-Test-Fixture.md
+++ b/docs/drivers/FOCAS-Test-Fixture.md
@@ -2,132 +2,149 @@
Coverage map + gap inventory for the FANUC FOCAS2 CNC driver.
-**TL;DR: there is no integration fixture.** Every test uses a
-`FakeFocasClient` injected via `IFocasClientFactory`. Fanuc's FOCAS library
-(`Fwlib32.dll`) is closed-source proprietary with no public simulator;
-CNC-side behavior is trusted from field deployments.
+**Status:** as of 2026-04-24, OtOpcUa speaks FOCAS2 directly over TCP
+via the pure-managed [`Focas.Wire`](https://github.com/Ladder99/focas-mock/tree/main/dotnet/Focas.Wire)
+client. Integration tests run the managed driver end-to-end against the
+vendored `focas-mock` Python server (at
+[`tests/.../Docker/focas-mock/`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/VENDORED.md))
+whose native FOCAS Ethernet responder is verified PDU-by-PDU against the
+real `fwlibe64.dll`.
-## What the fixture is
+No shim DLL, no P/Invoke, no licensed binary — any dev box or CI runner
+with Docker can run the full fixture end-to-end.
-Nothing at the integration layer.
-`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/` is unit-only. The driver ships
-as Tier C (process-isolated) per `docs/v2/driver-stability.md` because the
-FANUC DLL has known crash modes; tests can't replicate those in-process.
+Hardware validation against a real CNC is still useful to catch
+series-specific firmware quirks (see [§ Hardware-only gaps](#hardware-only-gaps))
+but the mock's wire responder covers every FOCAS call OtOpcUa issues.
-## What it actually covers (unit only)
+## What the fixture covers
-- `FocasCapabilityTests` — data-type mapping (PMC bit / word / float,
- macro variable types, parameter types)
-- `FocasCapabilityMatrixTests` — per-CNC-series range validation (macro
- / parameter / PMC letter + number) across 16i / 0i-D / 0i-F /
- 30i / PowerMotion. See [`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md)
- for the authoritative matrix. 46 theory cases lock every documented
- range boundary — widening a range without updating the doc fails a
- test.
-- `FocasReadWriteTests` — read + write against the fake, FOCAS native status
- → OPC UA StatusCode mapping
+### Unit layer (no container required)
+
+`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/` uses `FakeFocasClient`
+injected via `IFocasClientFactory`:
+
+- `FocasCapabilityTests` — data-type mapping (PMC bit / byte / word /
+ long / float / double, macro variable types, parameter types)
+- `FocasCapabilityMatrixTests` — per-CNC-series range validation across
+ 16i / 0i-D / 0i-F / 30i / Power Motion, 46 theory cases locking every
+ documented range boundary. See
+ [`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md).
+- `FocasReadWriteTests` — read / write contract against the fake, FOCAS
+ native status → OPC UA `StatusCode` mapping
- `FocasScaffoldingTests` — `IDriver` lifecycle + multi-device routing
-- `FocasPmcBitRmwTests` — PMC bit read-modify-write synchronization (per-byte
- `SemaphoreSlim`, mirrors the AB / Modbus pattern from #181)
-- `FwlibNativeHelperTests` — `Focas32.dll` → `Fwlib32.dll` bridge validation
- + P/Invoke signature validation
+- `FocasPmcBitRmwTests` — PMC bit read-modify-write synchronisation
+- `FocasAlarmProjectionTests` — raise / clear diffing, severity mapping
+- `FocasHandleRecycleTests` — proactive session recycle cadence
Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
-`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
-`IPerCallHostResolver`.
+`ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
+`IPerCallHostResolver`, `IAlarmSource`. `IWritable` intentionally
+returns `BadNotWritable` — OtOpcUa is read-only against FOCAS.
Pre-flight validation runs in `FocasDriver.InitializeAsync` — configs
referencing out-of-range addresses fail at load time with a diagnostic
-message naming the CNC series + documented limit. This closes the
-cheap half of the hardware-free stability gap; Tier-C process
-isolation (task #220) closes the expensive half — see
-[`docs/v2/implementation/focas-isolation-plan.md`](../v2/implementation/focas-isolation-plan.md).
+message naming the CNC series + documented limit.
-## What it does NOT cover
+### Integration layer (mock only, no CNC, no shim)
-### 1. FOCAS wire traffic
+`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/` drives the
+managed `FocasDriver` end-to-end. A single gate:
-No FOCAS TCP frame is sent. `Fwlib32.dll`'s TCP-to-FANUC-gateway exchange is
-closed-source; the driver trusts the P/Invoke layer per #193. Real CNC
-correctness is trusted from field deployments.
+**Docker compose up** — tests skip when the TCP probe to
+`localhost:8193` fails with a pointer to the compose command.
-### 2. Alarm / parameter-change callbacks
+When the mock is up, `WireFocasClient` dials it over TCP exactly like a
+real CNC, and the mock's native FOCAS Ethernet responder replies with
+binary PDUs against the documented command IDs. Covered assertions:
-FOCAS has no push model — the driver polls via the shared `PollGroupEngine`.
-There are no CNC-initiated callbacks to test; the absence is by design.
+- Session open / close (`cnc_allclibhndl3` + `cnc_freelibhndl`)
+- Parameter read-back after `mock_patch` seed → `cnc_rdparam`
+- Macro read-back after seed → `cnc_rdmacro` (scaled-decimal
+ translation verified)
+- PMC range read after seed → `pmc_rdpmcrng`
+- `IAlarmSource` raise + clear transitions after `mock_patch`
+ alarm-list changes → `cnc_rdalmmsg2`
+- Fixed-tree bootstrap: identity / axes / spindle / program / timers /
+ servo meters populate via `cnc_sysinfo`, `cnc_rdaxisname`,
+ `cnc_rdspdlname`, `cnc_rddynamic2`, `cnc_exeprgname2`,
+ `cnc_rdblkcount`, `cnc_rdopmode`, `cnc_rdsvmeter`, `cnc_rdspload`,
+ `cnc_rdspmaxrpm`, `cnc_rdtimer`
+- Per-series profile selection via `mock_load_profile` — tests can
+ pin one profile and assert series-gated capability suppression
-### 3. Macro / ladder variable types
+### E2E script (CLI)
-FANUC has CNC-specific extensions (macro variables `#100-#999`, system
-variables `#1000-#5000`, PMC timers / counters / keep-relays) whose
-per-address semantics differ across 0i-F / 30i / 31i / 32i Series. Driver
-covers the common address shapes; per-model quirks are not stressed.
+`scripts/e2e/test-focas.ps1` drives the Client.CLI against a running
+OtOpcUa server. Accepts:
-### 4. Model-specific behavior
+- `-CncHost` / `-CncPort` for real hardware
+- `-ProfileName ` for the Docker mock
+- `-Series ` for per-series matrix mode
+- `-HandleLeakCycles ` for handle-leak stress
-- Alarm retention across power cycles (model-specific CNC behavior)
-- Parameter range enforcement (CNC rejects out-of-range writes)
-- MTB (machine tool builder) custom screens that expose non-standard data
+## Hardware-only gaps
-### 5. Tier-C process isolation — architecture shipped, Fwlib32 integration hardware-gated
+The mock has parity with the real `fwlibe64.dll` for the calls OtOpcUa
+issues, but a real CNC can still surface things a reference
+implementation can't:
-The Tier-C architecture is now in place as of PRs #169–#173 (FOCAS
-PR A–E, task #220):
+1. **Series-specific firmware quirks** — alarm retention across power
+ cycles, parameter range enforcement by the CNC (not the driver),
+ MTB custom screens, series-specific option bits. Each series has
+ documented behaviours that only a bench CNC exercises.
+2. **Wire-level stress** — burst reads, concurrent device writes,
+ network-partition recovery under load. The mock handles these
+ correctly but production behaviour is the source of truth.
+3. **Transient operational states** — alarm floods, emergency-stop
+ transitions, power-on resync. These are easy to stub but hard to
+ cover comprehensively in the mock.
-- `Driver.FOCAS.Shared` carries MessagePack IPC contracts
-- `Driver.FOCAS.Host` (.NET 4.8 x86 Windows service via NSSM) accepts
- a connection on a strictly-ACL'd named pipe + dispatches frames to
- an `IFocasBackend`
-- `Driver.FOCAS.Ipc.IpcFocasClient` implements the `IFocasClient` DI
- seam by forwarding over IPC — swap the DI registration and the
- driver runs Tier-C with zero other changes
-- `Driver.FOCAS.Supervisor.FocasHostSupervisor` owns the spawn +
- heartbeat + respawn + 3-in-5min crash-loop breaker + sticky alert
-- `Driver.FOCAS.Host.Stability.PostMortemMmf` ↔
- `Driver.FOCAS.Supervisor.PostMortemReader` — ring-buffer of the
- last ~1000 IPC operations survives a Host crash
+Track the close-out under task #54 (live-CNC smoke). When the rig
+lands, the hardware path runs alongside the mock path; the mock
+stays as the CI quality gate.
-The one remaining gap is the production `FwlibHostedBackend`: an
-`IFocasBackend` implementation that wraps the licensed
-`Fwlib32.dll` P/Invoke. That's hardware-gated on task #222 — we
-need a CNC on the bench (or the licensed FANUC developer kit DLL
-with a test harness) to validate it. Until then, the Host ships
-`FakeFocasBackend` + `UnconfiguredFocasBackend`. Setting
-`OTOPCUA_FOCAS_BACKEND=fake` lets operators smoke-test the whole
-Tier-C pipeline end-to-end without any CNC.
+## When to trust each layer
-## When to trust FOCAS tests, when to reach for a rig
+| Question | Unit | Integration (mock) | Real CNC |
+| --- | :---: | :---: | :---: |
+| "Does PMC address `R100.3` route to the right bit?" | ✅ | ✅ | ✅ |
+| "Does the Fanuc status → OPC UA StatusCode map cover every documented code?" | ✅ (contract) | ✅ | ✅ |
+| "Does `FocasDriver.ReadAsync` correctly decode a seeded parameter?" | no | ✅ | ✅ |
+| "Does `IAlarmSource` fire raise + clear events?" | ✅ (Fake) | ✅ (wire) | ✅ |
+| "Does a real read against a 30i Series return correct bytes?" | no | ✅ (via profile) | ✅ (required) |
+| "Do series-specific firmware quirks behave as documented?" | no | no | ✅ (required) |
+| "Does the driver survive real network partitions?" | no | partial (socket kill) | ✅ (required) |
-| Question | Unit tests | Real CNC |
-| --- | --- | --- |
-| "Does PMC address `R100.3` route to the right bit?" | yes | yes |
-| "Does the FANUC status → OPC UA StatusCode map cover every documented code?" | yes (contract) | yes |
-| "Does a real read against a 30i Series return correct bytes?" | no | yes (required) |
-| "Does `Fwlib32.dll` crash on concurrent reads?" | no | yes (stress) |
-| "Do macro variables round-trip across power cycles?" | no | yes (required) |
+## Running the integration fixture
-## Follow-up candidates
+```powershell
+# 1) Start the mock on a chosen profile.
+docker compose -f tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/docker-compose.yml up -d
-1. **Nothing public** — Fanuc's FOCAS Developer Kit ships an emulator DLL
- but it's under NDA + tied to licensed dev-kit installations; can't
- redistribute for CI.
-2. **Lab rig** — used FANUC 0i-F simulator controller (or a retired machine
- tool) on a dedicated network; only path that covers real CNC behavior.
-3. **Process isolation first** — before trusting FOCAS in production at
- scale, shipping the Tier-C out-of-process Host architecture (similar to
- Galaxy) is higher value than a CI simulator.
+# 2) Run the tests. No shim build, no DLL copy — the driver dials the mock directly.
+dotnet test tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/
+```
+
+Or use `scripts/integration/run-focas.ps1` which wraps compose up / test
+/ compose down and accepts `-Profile ` to pin a per-series run.
## Key fixture / config files
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/`
+ — vendored `focas-mock` Python source + Dockerfile
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/docker-compose.yml`
+ — per-series compose profiles
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/FocasSimFixture.cs`
+ — collection fixture + mock admin API client
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/FixedTreePopulatesTests.cs`
+ — fixed-tree end-to-end tests
+- `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendTests.cs`
+ — pure-wire-backend end-to-end tests
- `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs` —
- in-process fake implementing `IFocasClient`
-- `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityMatrixTests.cs`
- — parameterized theories locking the per-series matrix
-- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs` — ctor takes
- `IFocasClientFactory`
+ in-process unit fake
+- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs` — the
+ managed wire client backing production deployments
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs` —
- per-CNC-series range validator (the matrix the doc describes)
+ per-series range validator
- `docs/v2/focas-version-matrix.md` — authoritative range reference
-- `docs/v2/implementation/focas-isolation-plan.md` — Tier-C isolation
- plan (task #220)
-- `docs/v2/driver-stability.md` — Tier C scope + process-isolation rationale
diff --git a/docs/drivers/FOCAS.md b/docs/drivers/FOCAS.md
new file mode 100644
index 0000000..1fa1dbf
--- /dev/null
+++ b/docs/drivers/FOCAS.md
@@ -0,0 +1,238 @@
+# FOCAS Driver
+
+Getting-started guide for the FANUC FOCAS2 driver. This is the short path — for
+the exhaustive per-node mapping read [`docs/v2/driver-specs.md §7`](../v2/driver-specs.md),
+for deployment details read [`docs/v2/focas-deployment.md`](../v2/focas-deployment.md),
+for the test-harness map read [FOCAS-Test-Fixture.md](FOCAS-Test-Fixture.md).
+
+## What it talks to
+
+FANUC CNCs (0i-D / 0i-F / 0i-MF / 0i-TF / 16i / 30i / 31i / 32i / Power Motion i)
+over the proprietary FOCAS2 protocol on TCP port 8193. The wire is spoken
+directly by the pure-managed [`Focas.Wire`](https://github.com/Ladder99/focas-mock)
+client — no Fwlib64.dll, no P/Invoke, no out-of-process isolation needed.
+
+OtOpcUa is **read-only** against FOCAS; all reads go over the native wire
+protocol using the documented command IDs. Writes return
+`BadNotWritable` by design.
+
+## Project split
+
+| Project | Target | Role |
+|---------|--------|------|
+| `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/` | net10.0 | In-process driver — hosts `WireFocasClient` which speaks FOCAS2 over TCP directly |
+
+Previous `Driver.FOCAS.Host` / `Driver.FOCAS.Shared` Tier-C split has been
+retired — the managed wire client removes the native-crash blast radius
+that justified the out-of-process service.
+
+## Minimum deployment
+
+Register the driver instance in the main server's `appsettings.json`. No
+separate service, no DLL deployment, no shared-secret handshake:
+
+```jsonc
+"Drivers": {
+ "focas-cnc-1": {
+ "Type": "FOCAS",
+ "Config": {
+ "Backend": "wire",
+ "Devices": [
+ { "HostAddress": "focas://10.20.30.40:8193", "Series": "ThirtyOne_i" }
+ ],
+ "Tags": [
+ { "Name": "Mode", "DeviceHostAddress": "focas://10.20.30.40:8193",
+ "Address": "PARAM:3402", "DataType": "Int32", "Writable": false },
+ { "Name": "SpndLoad", "DeviceHostAddress": "focas://10.20.30.40:8193",
+ "Address": "MACRO:500", "DataType": "Float64", "Writable": false }
+ ]
+ }
+ }
+}
+```
+
+The main server opens two TCP sockets per configured device and speaks the
+FOCAS2 binary protocol directly. No local privileged components, no
+platform bitness constraint — the driver runs on every host OtOpcUa runs
+on.
+
+## Address forms
+
+| Form | Example | Meaning |
+|------|---------|---------|
+| `X0.0` / `R100` / `R100.3` | PMC bit or byte | Letter + number; optional `.bit` for bit access |
+| `PARAM:1815` / `PARAM:1815/0` | CNC parameter | Number + optional axis index |
+| `MACRO:500` | Custom macro variable | System / user macro variable number |
+
+Addresses are validated against the per-device `Series` at `InitializeAsync` —
+a config referencing a number outside the documented range for that series
+fails at load time with an error message naming the limit. See
+[`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md) for the
+authoritative range table.
+
+## Backend selection
+
+The driver picks its client from `Config.Backend`:
+
+| Value | Client | Use it for |
+|-------|--------|------------|
+| `wire` (default) | `WireFocasClient` | Production — pure-managed FOCAS2 over TCP |
+| `unimplemented` / `none` / `stub` | `UnimplementedFocasClientFactory` | Scaffolding a DriverInstance row before the CNC endpoint is reachable |
+
+Previous backends (`fwlib`, `fwlib32`, `ipc`) have been retired along
+with `Driver.FOCAS.Host` and the Fwlib P/Invoke path. Configs that still
+reference them will throw at startup with a message pointing here.
+
+## Capability surface
+
+| Capability | Wire path | Notes |
+|------------|-----------|-------|
+| `IReadable` | `ReadAsync` → `cnc_rdpmcrng` / `cnc_rdparam` / `cnc_rdmacro` | One TCP request/response per read; `Focas.Wire` serializes requests on socket 2 internally |
+| `IWritable` | returns `BadNotWritable` | OtOpcUa is read-only against FOCAS by design — no `cnc_wrparam` / `pmc_wrpmcrng` / `cnc_wrmacro` path is implemented |
+| `ITagDiscovery` | `DiscoverAsync` | Emits `FOCAS/{device}/{tag}` folders per configured device |
+| `ISubscribable` | polled via shared `PollGroupEngine` | FOCAS has no push model — subscriptions turn into per-tag polling groups |
+| `IHostConnectivityProbe` | periodic `cnc_rdcncstat` | Probe cadence is `Probe.Interval`; transitions fire `OnHostStatusChanged` |
+| `IPerCallHostResolver` | lookup in `_tagsByName` | Each call routes to the device of the referenced tag |
+| `IAlarmSource` | polled `cnc_rdalmmsg2` via `FocasAlarmProjection` | Opt-in — set `AlarmProjection.Enabled=true`; diffs `(AlarmNumber, Type)` between ticks |
+
+Ack is a no-op — FANUC clears alarms on its own once the underlying condition
+resolves, so `AcknowledgeAsync` swallows the batch rather than surfacing
+`BadNotSupported`.
+
+## Fixed node tree
+
+Enable a pre-defined hierarchy of CNC nodes populated automatically from
+`cnc_sysinfo` + `cnc_rdaxisname` + `cnc_rddynamic2` + related FWLIB calls,
+so operators get an out-of-the-box view of identity / axes / program /
+timers without declaring per-address tags.
+
+```jsonc
+"Config": {
+ "Devices": [ ... ],
+ "Tags": [ ... ],
+ "FixedTree": {
+ "Enabled": true,
+ "PollInterval": "00:00:00.250", // fast — per-axis dynamic reads
+ "ProgramPollInterval": "00:00:01", // medium — program + mode changes
+ "TimerPollInterval": "00:00:30" // slow — cumulative counters
+ }
+}
+```
+
+What gets populated (all under `FOCAS/{deviceHostAddress}/`):
+
+| Subtree | Nodes | Source call |
+|---------|-------|-------------|
+| `Identity/` | `SeriesNumber`, `Version`, `MaxAxes`, `CncType`, `MtType`, `AxisCount` | `cnc_sysinfo` once at bootstrap |
+| `Axes/{name}/` | `AbsolutePosition`, `MachinePosition`, `RelativePosition`, `DistanceToGo`, `ServoLoad` — one folder per discovered axis | `cnc_rdaxisname` once + `cnc_rddynamic2` + `cnc_rdsvmeter` per tick |
+| `Axes/FeedRate/Actual`, `Axes/SpindleSpeed/Actual` | Current feed + spindle RPM | `cnc_rddynamic2` |
+| `Spindle/{name}/` | `Load` (percentage), `MaxRpm` — one folder per discovered spindle | `cnc_rdspdlname` once + `cnc_rdspload` + `cnc_rdspmaxrpm` |
+| `Program/` | `Name` (filename), `ONumber`, `Number`, `MainNumber`, `Sequence`, `BlockCount` | `cnc_exeprgname2` + `cnc_rdblkcount` + cached `cnc_rddynamic2` |
+| `OperationMode/` | `Mode` (int), `ModeText` ("AUTO", "MDI", "EDIT", …) | `cnc_rdopmode` |
+| `Timers/` | `PowerOnSeconds`, `OperatingSeconds`, `CuttingSeconds`, `CycleSeconds` | `cnc_rdtimer` × 4 |
+
+### Per-series node suppression
+
+The driver probes each optional call once at bootstrap. If the target CNC
+returns `EW_FUNC` / `EW_NOOPT` / `EW_VERSION` on the wire, the
+corresponding subtree is **not emitted** — the operator doesn't see nodes
+that will only ever return `BadDeviceFailure`. Capability suppression
+covers `Spindle/`, `Program/` + `OperationMode/`, `Timers/`, and
+per-axis `ServoLoad` independently. Identity + `Axes/*` position reads
+(which every Fanuc CNC supports) are always emitted.
+
+Position values are scaled integers (matching FOCAS's convention). The
+managed side exposes them as `Float64` OPC UA nodes; a future
+`cnc_getfigure` integration will add per-axis decimal scaling. Until
+then, treat the raw integer as the value the CNC reports and scale on
+the client side if decimal precision matters.
+
+**Still user-authored**: `PARAM:6711`, `MACRO:500`, `R100` etc. — specific
+numbers whose meaning is MTB-specific. Those go under the device folder
+alongside the fixed subtree.
+
+## Alarm projection
+
+Alarm surfacing is **disabled by default** because the polling cost is wasted
+on sites that don't consume CNC alarms. Opt in per driver instance:
+
+```jsonc
+"Config": {
+ "Devices": [ ... ],
+ "Tags": [ ... ],
+ "AlarmProjection": {
+ "Enabled": true,
+ "PollInterval": "00:00:02"
+ }
+}
+```
+
+Every alarm transition fires `OnAlarmEvent` with:
+
+- `SourceNodeId` = the device host address (FOCAS has no per-node alarm model;
+ the CNC exposes a single flat active-alarm list per session)
+- `ConditionId` = `"{host}#{Type}:{AlarmNumber}"`
+- `AlarmType` = projected from FANUC's `ALM_TYPE_*` (e.g. `Overtravel`, `Servo`,
+ `Parameter`, `MacroAlarm`)
+- `Severity` = Overtravel / Servo / PulseCode → `Critical`; Parameter / Macro
+ → `Medium`; everything else → `High`
+
+Cleared alarms fire a second event with `" (cleared)"` appended to the message
+so downstream consumers can ignore the clear if they only care about raises.
+
+## Handle recycling
+
+FANUC CNCs have a finite FWLIB handle pool (~5–10 concurrent connections) and
+certain series have documented handle-leak bugs that manifest after long uptime.
+The driver can proactively close + reopen each device's session on a cadence to
+release its slot back to the pool:
+
+```jsonc
+"Config": {
+ "Devices": [ ... ],
+ "HandleRecycle": {
+ "Enabled": true,
+ "Interval": "01:00:00"
+ }
+}
+```
+
+Disabled by default — a healthy CNC + driver doesn't need it. Enable when field
+experience shows handle exhaustion. Typical tuning: 30 min for sites running
+multiple OtOpcUa instances against the same CNC (they share the pool); 6 h for a
+single-client deployment. Reads / writes during recycle simply wait for the
+reconnect rather than failing — worst case, an operator sees a brief read
+latency spike once per cadence.
+
+## Testing
+
+- **Unit tests** — `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/` cover the
+ driver surface via `FakeFocasClient`. Includes the alarm-projection raise /
+ clear diffing tests.
+- **Integration tests** — `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/`
+ hold the Docker simulator scaffold (Stream B / C of the simulator plan —
+ `docs/v2/implementation/focas-simulator-plan.md`).
+- **E2E script** — `scripts/e2e/test-focas.ps1` stages Host + Proxy + a real
+ CNC (or the simulator) and exercises connect → read → write → subscribe
+ round-trips. See [`docs/drivers/FOCAS-Test-Fixture.md`](FOCAS-Test-Fixture.md)
+ for the coverage map.
+
+## Troubleshooting
+
+| Symptom | Likely cause | Fix |
+|---------|--------------|-----|
+| `BadCommunicationError` on every read | CNC unreachable on TCP:8193 | Check firewall / LAN reachability; FOCAS Ethernet option must be licensed on the CNC side |
+| Every read returns `BadNotWritable` on writes | Expected — OtOpcUa is read-only against FOCAS | If you actually need writes, open a feature request — the driver's managed wire client doesn't expose the write commands |
+| `BadOutOfRange` on reads for a macro/parameter | Config address outside the declared `Series` range | Check `docs/v2/focas-version-matrix.md` — either fix the address or widen the `Series` |
+| Alarm events never fire | `AlarmProjection.Enabled` left at default (false) | Set it to `true` in the driver config |
+
+## Further reading
+
+- [`docs/v2/driver-specs.md §7`](../v2/driver-specs.md) — full OPC UA node
+ mapping, pre-defined tag set, per-API notes
+- [`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md) —
+ per-series macro / parameter / PMC range table
+- [`docs/v2/implementation/focas-wire-protocol.md`](../v2/implementation/focas-wire-protocol.md)
+ — captured FOCAS2 wire semantics (magic prefix, handshake, command-id table)
+- [upstream `Focas.Wire`](https://github.com/Ladder99/focas-mock/tree/main/dotnet/Focas.Wire)
+ — the managed client implementation OtOpcUa consumes as a NuGet dependency
diff --git a/docs/drivers/README.md b/docs/drivers/README.md
index 74edd2b..3c12e93 100644
--- a/docs/drivers/README.md
+++ b/docs/drivers/README.md
@@ -26,7 +26,7 @@ Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/ZB.M
| AB CIP | `Driver.AbCip` | A | libplctag CIP | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource | ControlLogix / CompactLogix. Tag discovery uses the `@tags` walker to enumerate controller-scoped + program-scoped symbols; UDT member resolution via the UDT template reader |
| AB Legacy | `Driver.AbLegacy` | A | libplctag PCCC | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | SLC 500 / MicroLogix. File-based addressing (`N7:0`, `F8:0`) — no symbol table, tag list is user-authored in the config DB |
| TwinCAT | `Driver.TwinCAT` | B | Beckhoff `TwinCAT.Ads` (`TcAdsClient`) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | The only native-notification driver outside Galaxy — ADS delivers `ValueChangedCallback` events the driver forwards straight to `ISubscribable.OnDataChange` without polling. Symbol tree uploaded via `SymbolLoaderFactory` |
-| FOCAS | `Driver.FOCAS` | C | FANUC FOCAS2 (`Fwlib32.dll` P/Invoke) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | Tier C — FOCAS DLL has crash modes that warrant process isolation. CNC-shaped data model (axes, spindle, PMC, macros, alarms) not a flat tag map |
+| [FOCAS](FOCAS.md) | `Driver.FOCAS` | A | Pure-managed `FocasWireClient` — FOCAS/2 Ethernet binary protocol on TCP:8193, inlined into the driver assembly | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource | Read-only by design (WriteAsync returns `BadNotWritable`). CNC-shaped data model (axes, spindle, PMC, macros, alarms) not a flat tag map. Previously Tier-C (Host + P/Invoke + shim DLL); retired in the 2026-04-24 migration when the managed wire client landed |
| OPC UA Client | `Driver.OpcUaClient` | B | OPCFoundation `Opc.Ua.Client` | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHistoryProvider, IHostConnectivityProbe | Gateway/aggregation driver. Opens a single `Session` against a remote OPC UA server and re-exposes its address space. Owns its own `ApplicationConfiguration` (distinct from `Client.Shared`) because it's always-on with keep-alive + `TransferSubscriptions` across SDK reconnect, not an interactive CLI |
## Per-driver documentation
@@ -35,6 +35,9 @@ Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/ZB.M
- [Galaxy.md](Galaxy.md) — COM bridge, STA pump, IPC, runtime probes
- [Galaxy-Repository.md](Galaxy-Repository.md) — ZB SQL reader, `LocalPlatform` scope filter, change detection
+- **FOCAS** has a short getting-started doc because the Tier-C two-project deployment + backend-selection env var + alarm projection opt-in all need explaining up front:
+ - [FOCAS.md](FOCAS.md) — deployment, config, capability surface, alarm projection, troubleshooting
+
- **All other drivers** share a single per-driver specification in [docs/v2/driver-specs.md](../v2/driver-specs.md) — addressing, data-type maps, connection settings, and quirks live there. That file is the authoritative per-driver reference; this index points at it rather than duplicating.
## Test-fixture coverage maps
diff --git a/docs/v2/focas-version-matrix.md b/docs/v2/focas-version-matrix.md
index ee21a8e..9f76b40 100644
--- a/docs/v2/focas-version-matrix.md
+++ b/docs/v2/focas-version-matrix.md
@@ -6,7 +6,7 @@ enforces at driver init time. Every row cites the Fanuc FOCAS Developer
Kit function whose documented input range determines the ceiling.
**Why this exists** — we have no FOCAS hardware on the bench and no
-working simulator. Fwlib32 returns `EW_NUMBER` / `EW_PARAM` when you
+working simulator. FWLIB (Fwlib64, or Fwlib32 on legacy deployments) returns `EW_NUMBER` / `EW_PARAM` when you
hand it an address outside the controller's supported range; the
driver would map that to a per-read `BadOutOfRange` at steady state.
Catching at `InitializeAsync` with this matrix surfaces operator
@@ -140,6 +140,6 @@ matrix: Macro variable #50000 is outside the documented range
This validation closes the cheap half of the FOCAS hardware-free
stability gap — config errors now fail at load instead of per-read.
The expensive half is Tier-C process isolation so that a crashing
-`Fwlib32.dll` doesn't take the main OPC UA server down with it. See
+`Fwlib64.dll` doesn't take the main OPC UA server down with it. See
[`docs/v2/implementation/focas-isolation-plan.md`](implementation/focas-isolation-plan.md)
for that plan (task #220).
diff --git a/docs/v2/implementation/focas-isolation-plan.md b/docs/v2/implementation/focas-isolation-plan.md
index ecb6eb2..362e753 100644
--- a/docs/v2/implementation/focas-isolation-plan.md
+++ b/docs/v2/implementation/focas-isolation-plan.md
@@ -1,10 +1,21 @@
# FOCAS Tier-C isolation — plan for task #220
-> **Status**: PRs A–E shipped. Architecture is in place; the only
-> remaining FOCAS work is the hardware-dependent production
-> integration of `Fwlib32.dll` into a real `IFocasBackend`
-> (`FwlibHostedBackend`), which needs an actual CNC on the bench
-> and is tracked as a follow-up on #220.
+> **Status**: **FULLY SHIPPED** (code). PRs A–E shipped the architecture; the
+> 2026-04-23 follow-up shipped the production `Fwlib64FocasBackend` wrapping
+> the licensed `Fwlib64.dll`. Only the wire-level live-boot against real
+> hardware remains (task #222 / requires a bench CNC).
+>
+> **Major update 2026-04-23 — Host retargeted to .NET 10 x64 + Fwlib64**:
+> Both `Fwlib32.dll` and `Fwlib64.dll` are licensed for this project. The
+> original plan put the Host on .NET 4.8 x86 because Fwlib32 was assumed.
+> With Fwlib64 available, the Host moves to `net10.0-windows` x64 — same
+> runtime as the rest of the fleet. **Tier-C isolation stays anyway** — the
+> blast-radius argument against a closed-source vendor P/Invoke is independent
+> of bitness. Galaxy (forced x86 by MXAccess COM) is a pure bitness forcing;
+> FOCAS is a pure blast-radius choice. Body of this document still reflects
+> the original x86 assumptions in a few places — read them as historical
+> design context; the current shape is in `docs/drivers/FOCAS-Test-Fixture.md`
+> and `exit-gate-phase-3.md`.
>
> **Pre-reqs shipped**: version matrix + pre-flight validation
> (PR #168 — the cheap half of the hardware-free stability gap).
diff --git a/docs/v2/v2-release-readiness.md b/docs/v2/v2-release-readiness.md
index 08e2e57..dc7bf6e 100644
--- a/docs/v2/v2-release-readiness.md
+++ b/docs/v2/v2-release-readiness.md
@@ -1,7 +1,7 @@
# v2 Release Readiness
-> **Last updated**: 2026-04-19 (all three release blockers CLOSED — Phase 6.3 Streams A/C core shipped)
-> **Status**: **RELEASE-READY (code-path)** for v2 GA — all three code-path release blockers are closed. Remaining work is manual (client interop matrix, deployment checklist signoff, OPC UA CTT pass) + hardening follow-ups; see exit-criteria checklist below.
+> **Last updated**: 2026-04-24 (Phase 5 driver complement closed — AB CIP, AB Legacy, TwinCAT, FOCAS all shipped; FOCAS Tier-C retired for a pure-managed in-process client)
+> **Status**: **RELEASE-READY (code-path)** for v2 GA. All three original code-path release blockers remain closed. Phase 5 is now complete. Remaining work is manual (live-hardware validations, client interop matrix, deployment checklist signoff, OPC UA CTT pass) + hardening follow-ups; see exit-criteria checklist below.
This doc is the single view of where v2 stands against its release criteria. Update it whenever a deferred follow-up closes or a new release blocker is discovered.
@@ -14,23 +14,25 @@ This doc is the single view of where v2 stands against its release criteria. Upd
| Phase 2 — Galaxy driver split (Proxy/Host/Shared) | ✓ | Shipped |
| Phase 3 — OPC UA server + LDAP + security profiles | ✓ | Shipped |
| Phase 4 — Redundancy scaffold (entities + endpoints) | ✓ | Shipped (runtime closes in 6.3) |
-| Phase 5 — Drivers | ⚠ partial | Galaxy / Modbus / S7 / OpcUaClient shipped; AB CIP / AB Legacy / TwinCAT / FOCAS deferred (task #120) |
-| Phase 6.1 — Resilience & Observability | ✓ | **SHIPPED** (PRs #78–83) |
-| Phase 6.2 — Authorization runtime | ◐ core | **SHIPPED (core)** (PRs #84–88); dispatch wiring + Admin UI deferred |
-| Phase 6.3 — Redundancy runtime | ◐ core | **SHIPPED (core)** (PRs #89–90); coordinator + UA-node wiring + Admin UI + interop deferred |
-| Phase 6.4 — Admin UI completion | ◐ data layer | **SHIPPED (data layer)** (PRs #91–92); Blazor UI + OPC 40010 address-space wiring deferred |
+| Phase 5 — Drivers | ✓ | **Shipped** — Galaxy, Modbus (+ DL205/S7/MELSEC profiles), S7 native, OPC UA Client, AB CIP, AB Legacy, TwinCAT ADS, FOCAS (managed wire client) |
+| Phase 6.1 — Resilience & Observability | ✓ | Shipped (PRs #78–83) |
+| Phase 6.2 — Authorization runtime | ◐ core | Core shipped (PRs #84–88, #94 dispatch wiring); finer-grained Browse/Subscribe/Alarm/Call gating + 3-user interop matrix deferred |
+| Phase 6.3 — Redundancy runtime | ◐ core | Core shipped (PRs #89–90, #98–99); peer-probe HostedServices, OPC UA variable-node binding, `sp_PublishGeneration` lease wrap, client interop matrix deferred |
+| Phase 6.4 — Admin UI completion | ◐ data layer + Identification | Data layer + OPC 40010 Identification folder shipped (PRs #91–92, Identification audit close-out 2026-04-23); Blazor UI pieces deferred |
-**Aggregate test counts:** 906 baseline (pre-Phase-6) → **1159 passing** across Phase 6. One pre-existing Client.CLI `SubscribeCommandTests.Execute_PrintsSubscriptionMessage` flake tracked separately.
+**Driver integration-test counts** (end-to-end against live or simulated targets): Modbus 26, FOCAS 9, AbCip 7, OpcUaClient 3, S7 3, AbLegacy 2, TwinCAT 2. Plus Galaxy's separate cross-FX parity/stability suite.
+
+**Aggregate test counts** (2026-04-19 baseline): 1159 passing across the solution. One pre-existing Client.CLI `SubscribeCommandTests.Execute_PrintsSubscriptionMessage` flake tracked separately. Rerun `dotnet test ZB.MOM.WW.OtOpcUa.slnx` after the FOCAS migration commits land to refresh the number.
## Release blockers (must close before v2 GA)
-Ordered by severity + impact on production fitness.
+All code-path release blockers are closed. The remaining items are live-hardware / manual validations listed under exit criteria.
### ~~Security — Phase 6.2 dispatch wiring~~ (task #143 — **CLOSED** 2026-04-19, PR #94)
-**Closed**. `AuthorizationGate` + `NodeScopeResolver` now thread through `OpcUaApplicationHost → OtOpcUaServer → DriverNodeManager`. `OnReadValue` + `OnWriteValue` + all four HistoryRead paths call `gate.IsAllowed(identity, operation, scope)` before the invoker. Production deployments activate enforcement by constructing `OpcUaApplicationHost` with an `AuthorizationGate(StrictMode: true)` + populating the `NodeAcl` table.
+**Closed**. `AuthorizationGate` + `NodeScopeResolver` thread through `OpcUaApplicationHost → OtOpcUaServer → DriverNodeManager`. `OnReadValue` + `OnWriteValue` + all four HistoryRead paths call `gate.IsAllowed(identity, operation, scope)` before the invoker. Production deployments activate enforcement by constructing `OpcUaApplicationHost` with an `AuthorizationGate(StrictMode: true)` + populating the `NodeAcl` table.
-Additional Stream C surfaces (not release-blocking, hardening only):
+Remaining Stream C surfaces (hardening, not release-blocking):
- Browse + TranslateBrowsePathsToNodeIds gating with ancestor-visibility logic per `acl-design.md` §Browse.
- CreateMonitoredItems + TransferSubscriptions gating with per-item `(AuthGenerationId, MembershipVersion)` stamp so revoked grants surface `BadUserAccessDenied` within one publish cycle (decision #153).
@@ -39,42 +41,51 @@ Additional Stream C surfaces (not release-blocking, hardening only):
- Finer-grained scope resolution — current `NodeScopeResolver` returns a flat cluster-level scope. Joining against the live Configuration DB to populate UnsArea / UnsLine / Equipment path is tracked as Stream C.12.
- 3-user integration matrix covering every operation × allow/deny.
-These are additional hardening — the three highest-value surfaces (Read / Write / HistoryRead) are now gated, which covers the base-security gap for v2 GA.
-
### ~~Config fallback — Phase 6.1 Stream D wiring~~ (task #136 — **CLOSED** 2026-04-19, PR #96)
-**Closed**. `SealedBootstrap` consumes `ResilientConfigReader` + `GenerationSealedCache` + `StaleConfigFlag` end-to-end: bootstrap calls go through the timeout → retry → fallback-to-sealed pipeline; every central-DB success writes a fresh sealed snapshot so the next cache-miss has a known-good fallback; `StaleConfigFlag.IsStale` is now consumed by `HealthEndpointsHost.usingStaleConfig` so `/healthz` body reports reality.
+**Closed**. `SealedBootstrap` consumes `ResilientConfigReader` + `GenerationSealedCache` + `StaleConfigFlag` end-to-end; `/healthz` surfaces the stale flag.
-Production activation: Program.cs switches `NodeBootstrap → SealedBootstrap` + constructs `OpcUaApplicationHost` with the `StaleConfigFlag` as an optional ctor parameter.
-
-Remaining follow-ups (hardening, not release-blocking):
+Remaining follow-ups (hardening):
- A `HostedService` that polls `sp_GetCurrentGenerationForCluster` periodically so peer-published generations land in this node's cache without a restart.
-- Richer snapshot payload via `sp_GetGenerationContent` so fallback can serve the full generation content (DriverInstance enumeration, ACL rows, etc.) from the sealed cache alone.
+- Richer snapshot payload via `sp_GetGenerationContent` so fallback can serve full generation content (DriverInstance enumeration, ACL rows, etc.) from the sealed cache alone.
### ~~Redundancy — Phase 6.3 Streams A/C core~~ (tasks #145 + #147 — **CLOSED** 2026-04-19, PRs #98–99)
-**Closed**. The runtime orchestration layer now exists end-to-end:
-
-- `RedundancyCoordinator` reads `ClusterNode` + peer list at startup (Stream A shipped in PR #98). Invariants enforced: 1-2 nodes (decision #83), unique ApplicationUri (#86), ≤1 Primary in Warm/Hot (#84). Startup fails fast on violation; runtime refresh logs + flips `IsTopologyValid=false` so the calculator falls to band 2 without tearing down.
-- `RedundancyStatePublisher` orchestrates topology + apply lease + recovery state + peer reachability through `ServiceLevelCalculator` + emits `OnStateChanged` / `OnServerUriArrayChanged` edge-triggered events (Stream C core shipped in PR #99). The OPC UA `ServiceLevel` Byte variable + `ServerUriArray` String[] variable subscribe to these events.
+**Closed**. `RedundancyCoordinator` + `RedundancyStatePublisher` + `PeerReachabilityTracker` orchestrate topology + apply lease + recovery state + peer reachability through `ServiceLevelCalculator` + emit `OnStateChanged` / `OnServerUriArrayChanged` edge-triggered events.
Remaining Phase 6.3 surfaces (hardening, not release-blocking):
-- `PeerHttpProbeLoop` + `PeerUaProbeLoop` HostedServices that poll the peer + write to `PeerReachabilityTracker` on each tick. Without these the publisher sees `PeerReachability.Unknown` for every peer → Isolated-Primary band (230) even when the peer is up. Safe default (retains authority) but not the full non-transparent-redundancy UX.
-- OPC UA variable-node wiring layer: bind the `ServiceLevel` Byte node + `ServerUriArray` String[] node to the publisher's events via `BaseDataVariable.OnReadValue` / direct value push. Scoped follow-up on the Opc.Ua.Server stack integration.
+- `PeerHttpProbeLoop` + `PeerUaProbeLoop` HostedServices populating `PeerReachabilityTracker` on each tick. Without these the publisher sees `PeerReachability.Unknown` → Isolated-Primary band (230). Safe default but not the full non-transparent-redundancy UX.
+- OPC UA variable-node wiring: bind `ServiceLevel` Byte + `ServerUriArray` String[] to the publisher's events via `BaseDataVariable.OnReadValue` / direct value push.
- `sp_PublishGeneration` wraps its apply in `await using var lease = coordinator.BeginApplyLease(...)` so the `PrimaryMidApply` band (200) fires during actual publishes (task #148 part 2).
-- Client interop matrix validation — Ignition / Kepware / Aveva OI Gateway (Stream F, task #150). Manual + doc-only work; doesn't block code ship.
+- Client interop matrix — Ignition / Kepware / Aveva OI Gateway (Stream F, task #150). Manual + doc-only.
-### Remaining drivers (task #120)
+### ~~Phase 5 driver complement~~ (task #120 — **CLOSED** 2026-04-24)
-AB CIP, AB Legacy, TwinCAT ADS, FOCAS drivers are planned but unshipped. Decision pending on whether these are release-blocking for v2 GA or can slip to a v2.1 follow-up.
+**Closed**. All four deferred drivers shipped:
+
+- **AB CIP** (PRs #202–222) — `Driver.AbCip`, `Driver.AbCip.IntegrationTests` (7 tests), AB CIP Cli. Live-boot verified against a ControlLogix rig.
+- **AB Legacy** (PRs #202, #223) — `Driver.AbLegacy`, `Driver.AbLegacy.IntegrationTests` (2 tests), AB Legacy Cli. PCCC cip-path workaround for SLC/MicroLogix.
+- **TwinCAT ADS** (PRs #205, this branch `task-galaxy-e2e`) — `Driver.TwinCAT`, `Driver.TwinCAT.IntegrationTests` (2 tests), TwinCAT Cli. TCBSD/ESXi fixture for e2e since local Hyper-V / TwinCAT RTIME are mutually exclusive on the dev box.
+- **FOCAS** (PRs #173, #199 + this session's migration) — `Driver.FOCAS` with an **in-process managed `FocasWireClient`** that speaks FOCAS/2 over TCP directly. Tier-C isolation retired — `Driver.FOCAS.Host` + `Driver.FOCAS.Shared` + `FwlibNative` P/Invoke + shim DLL + NSSM service all deleted. `Driver.FOCAS.IntegrationTests` covers 9 scenarios (fixed tree identity/axes/program/timers/spindle + user-authored PARAM/MACRO/PMC reads, Browse, Subscribe, IAlarmSource raise/clear, Probe transitions).
+
+Decision recorded: FOCAS is **read-only** against the CNC by design — writes return `BadNotWritable`. See `docs/drivers/FOCAS.md` + `docs/drivers/FOCAS-Test-Fixture.md` for the deployment + coverage map.
## Nice-to-haves (not release-blocking)
- **Admin UI** — Phase 6.1 Stream E.2/E.3 (`/hosts` column refresh), Phase 6.2 Stream D (`RoleGrantsTab` + `AclsTab` Probe), Phase 6.3 Stream E (`RedundancyTab`), Phase 6.4 Streams A/B UI pieces, Stream C DiffViewer, Stream D `IdentificationFields.razor`. Tasks #134, #144, #149, #153, #155, #156, #157.
- **Background services** — Phase 6.1 Stream B.4 `ScheduledRecycleScheduler` HostedService (task #137), Phase 6.1 Stream A analyzer (task #135 — Roslyn analyzer asserting every capability surface routes through `CapabilityInvoker`).
-- **Multi-host dispatch** — Phase 6.1 Stream A follow-up (task #135). Currently every driver gets a single pipeline keyed on `driver.DriverInstanceId`; multi-host drivers (Modbus with N PLCs) need per-PLC host resolution so failing PLCs trip per-PLC breakers without poisoning siblings. Decision #144 requires this but we haven't wired it yet.
+- **Multi-host dispatch** — Phase 6.1 Stream A follow-up (task #135). Every driver currently gets a single pipeline keyed on `driver.DriverInstanceId`; multi-host drivers (Modbus with N PLCs) need per-PLC host resolution so failing PLCs trip per-PLC breakers without poisoning siblings. Decision #144 requires this but not wired.
+- **Phase 7** — scripting + alarming + historian sink (plan drafted 2026-04-20 in `docs/v2/implementation/phase-7-*.md`). Out of scope for v2 GA.
+
+## Live-hardware validations (task #54 + task family)
+
+The code ships; these tasks remain open as lab/field verification:
+
+- **#54** — FOCAS live-CNC wire-level smoke against a real FANUC control. The mock's wire responder is PDU-verified against `fwlibe64.dll` upstream but OtOpcUa's managed client has not been pointed at a production CNC.
+- **AB CIP live-boot** — already passed on a ControlLogix rig (PR #222). Continue to run ahead of each release.
+- **TwinCAT wire-live** — TCBSD/ESXi fixture covers the common path; production PLC verification remains lab-gated.
## Running the release-readiness check
@@ -82,7 +93,12 @@ AB CIP, AB Legacy, TwinCAT ADS, FOCAS drivers are planned but unshipped. Decisio
pwsh ./scripts/compliance/phase-6-all.ps1
```
-This meta-runner invokes each `phase-6-N-compliance.ps1` script in sequence and reports an aggregate PASS/FAIL. It is the single-command verification that what we claim is shipped still compiles + tests pass + the plan-level invariants are still satisfied.
+This meta-runner invokes each `phase-6-N-compliance.ps1` script in sequence and reports an aggregate PASS/FAIL:
+
+- `phase-6-1-compliance.ps1` — Resilience & Observability
+- `phase-6-2-compliance.ps1` — Authorization runtime
+- `phase-6-3-compliance.ps1` — Redundancy runtime
+- `phase-6-4-compliance.ps1` — Admin UI completion
Exit 0 = every phase passes its compliance checks + no test-count regression.
@@ -92,18 +108,23 @@ v2 GA requires all of the following:
- [ ] All four Phase 6.N compliance scripts exit 0.
- [ ] `dotnet test ZB.MOM.WW.OtOpcUa.slnx` passes with ≤ 1 known-flake failure.
-- [ ] Release blockers listed above all closed (or consciously deferred to v2.1 with a written decision).
+- [x] Release blockers listed above all closed.
+- [x] Phase 5 driver complement shipped (Galaxy, Modbus, S7, OpcUaClient, AbCip, AbLegacy, TwinCAT, FOCAS).
- [ ] Production deployment checklist (separate doc) signed off by Fleet Admin.
- [ ] At least one end-to-end integration run against the live Galaxy on the dev box succeeds.
+- [ ] FOCAS live-CNC wire-level smoke (#54) runs clean against a real FANUC control.
- [ ] OPC UA conformance test (CTT or UA Compliance Test Tool) passes against the live endpoint.
- [ ] Non-transparent redundancy cutover validated with at least one production client (Ignition 8.3 recommended — see decision #85).
## Change log
-- **2026-04-19** — Release blocker #3 **closed** (PRs #98–99). Phase 6.3 Streams A + C core shipped: `ClusterTopologyLoader` + `RedundancyCoordinator` + `RedundancyStatePublisher` + `PeerReachabilityTracker`. Code-path release blockers all closed; remaining Phase 6.3 surfaces (peer-probe HostedServices, OPC UA variable-node binding, sp_PublishGeneration lease wrap, client interop matrix) are hardening follow-ups.
-- **2026-04-19** — Release blocker #2 **closed** (PR #96). `SealedBootstrap` consumes `ResilientConfigReader` + `GenerationSealedCache` + `StaleConfigFlag`; `/healthz` now surfaces the stale flag. Remaining follow-ups (periodic poller + richer snapshot payload) downgraded to hardening.
-- **2026-04-19** — Release blocker #1 **closed** (PR #94). `AuthorizationGate` wired into `DriverNodeManager` Read / Write / HistoryRead dispatch. Remaining Stream C surfaces (Browse / Subscribe / Alarm / Call + finer-grained scope resolution) downgraded to hardening follow-ups — no longer release-blocking.
-- **2026-04-19** — Phase 6.4 data layer merged (PRs #91–92). Phase 6 core complete. Capstone doc created.
+- **2026-04-24** — Phase 5 driver complement closed (task #120 CLOSED). AB CIP, AB Legacy, TwinCAT, FOCAS all shipped. FOCAS migration: retired the Tier-C split (`Driver.FOCAS.Host` + `Driver.FOCAS.Shared` + `FwlibNative` + shim DLL deleted) in favour of a pure-managed in-process `FocasWireClient` inlined into `Driver.FOCAS`; driver is now read-only against the CNC by design. Integration test matrix grew to cover Browse / Subscribe / IAlarmSource / Probe end-to-end.
+- **2026-04-23** — Phase 6.4 audit close-out. IdentificationFolderBuilder + OPC 40010 Identification folder verified against the shipped code.
+- **2026-04-20** — Phase 7 plan drafted (`phase-7-scripting-and-alarming.md`, `phase-7-e2e-smoke.md`). Out of scope for v2 GA.
+- **2026-04-19** — Release blocker #3 closed (PRs #98–99). Phase 6.3 Streams A + C core shipped: `ClusterTopologyLoader` + `RedundancyCoordinator` + `RedundancyStatePublisher` + `PeerReachabilityTracker`. Code-path release blockers all closed; remaining Phase 6.3 surfaces (peer-probe HostedServices, OPC UA variable-node binding, `sp_PublishGeneration` lease wrap, client interop matrix) are hardening follow-ups.
+- **2026-04-19** — Release blocker #2 closed (PR #96). `SealedBootstrap` consumes `ResilientConfigReader` + `GenerationSealedCache` + `StaleConfigFlag`; `/healthz` surfaces the stale flag. Remaining follow-ups (periodic poller + richer snapshot payload) downgraded to hardening.
+- **2026-04-19** — Release blocker #1 closed (PR #94). `AuthorizationGate` wired into `DriverNodeManager` Read / Write / HistoryRead dispatch. Remaining Stream C surfaces (Browse / Subscribe / Alarm / Call + finer-grained scope resolution) downgraded to hardening follow-ups — no longer release-blocking.
+- **2026-04-19** — Phase 6.4 data layer merged (PRs #91–92). Phase 6 core complete.
- **2026-04-19** — Phase 6.3 core merged (PRs #89–90). `ServiceLevelCalculator` + `RecoveryStateManager` + `ApplyLeaseRegistry` land as pure logic; coordinator / UA-node wiring / Admin UI / interop deferred.
- **2026-04-19** — Phase 6.2 core merged (PRs #84–88). `AuthorizationGate` + `TriePermissionEvaluator` + `LdapGroupRoleMapping` land; dispatch wiring + Admin UI deferred.
- **2026-04-19** — Phase 6.1 shipped (PRs #78–83). Polly resilience + Tier A/B/C stability + health endpoints + LiteDB generation-sealed cache + Admin `/hosts` data layer all live.
diff --git a/scripts/e2e/e2e-config.sample.json b/scripts/e2e/e2e-config.sample.json
index a3ce156..f735efe 100644
--- a/scripts/e2e/e2e-config.sample.json
+++ b/scripts/e2e/e2e-config.sample.json
@@ -34,7 +34,7 @@
},
"focas": {
- "$comment": "Gated behind FOCAS_TRUST_WIRE=1 — no public simulator. Point at a real CNC + ensure Fwlib32.dll is on PATH.",
+ "$comment": "Gated behind FOCAS_TRUST_WIRE=1 for real-CNC runs, or pass -ProfileName to run against the focas-mock Docker fixture. Managed wire client — no native dependencies.",
"host": "192.168.1.20",
"port": 8193,
"address": "R100",
@@ -60,6 +60,21 @@
"historyLookbackSec": 3600
},
+ "opcuaclient": {
+ "$comment": "OPC UA Client (gateway) driver. Default opc-plc Docker fixture exposes ns=3;s=FastUInt1 as a ticker. The `bridgeNodeId` is the local mirror of remoteNodeId after the OpcUaClient driver's DiscoverAsync runs — dev-specific. Stages 5/7/8 are opt-in: supply writable* NodeIds to enable reverse-bridge, alarmNodeId to enable alarm, historyNodeId to enable history (opc-plc does not historize by default — a Prosys / UA Expert sample server is needed for stage 8).",
+ "remoteUrl": "opc.tcp://localhost:50000",
+ "remoteNodeId": "ns=3;s=FastUInt1",
+ "bridgeNodeId": "ns=2;s=OpcUaClient/FastUInt1",
+ "bridgeRootNodeId": "ns=2;s=OpcUaClient",
+ "browseDepth": 3,
+ "browseMinNodes": 5,
+ "changeWaitSec": 8,
+ "writableRemoteNodeId": "",
+ "writableBridgeNodeId": "",
+ "alarmNodeId": "",
+ "historyNodeId": ""
+ },
+
"phase7": {
"$comment": "Virtual tags + scripted alarms. The VirtualNodeId must resolve to a server-side virtual tag whose script reads the modbus InputNodeId and writes VT = input * 2. The AlarmNodeId is the ConditionId of a scripted alarm that fires when VT > 100.",
"modbusEndpoint": "127.0.0.1:5502",
diff --git a/scripts/e2e/test-focas.ps1 b/scripts/e2e/test-focas.ps1
index ef6e9a5..2e49b2a 100644
--- a/scripts/e2e/test-focas.ps1
+++ b/scripts/e2e/test-focas.ps1
@@ -1,16 +1,35 @@
-#Requires -Version 7.0
+#Requires -Version 7.0
<#
.SYNOPSIS
End-to-end CLI test for the FOCAS (Fanuc CNC) driver.
.DESCRIPTION
- **Hardware-gated.** There is no public FOCAS simulator; the driver's
- FwlibFocasClient P/Invokes Fanuc's licensed Fwlib32.dll. Against a dev
- box without the DLL on PATH the test will skip with a clear message.
- Against a real CNC with the DLL present it runs probe / driver-loopback /
- server-bridge the same way the other scripts do.
+ Runs the CLI against either the managed wire client (default — Driver.FOCAS.Cli
+ dials the CNC on TCP:8193 directly, no native dependencies) or the focas-mock
+ Docker fixture. Hardware-gated by default because the default CncHost is
+ 127.0.0.1; set FOCAS_TRUST_WIRE=1 once -CncHost points at a real CNC, or pass
+ -ProfileName to run against the Docker sim.
- Set FOCAS_TRUST_WIRE=1 when -CncHost points at a real CNC to un-gate.
+ The script also supports three nice-to-have modes shipped 2026-04-24:
+
+ -Series — per-series matrix mode. Accepts a comma-separated list; the
+ core stages are run once per series, swapping the -Address to
+ the supplied per-series probe. Fails fast if any series's
+ configured address is outside the documented range (the driver
+ itself enforces that at InitializeAsync).
+
+ -ProfileName — for use with the Python Docker simulator (see
+ docs/v2/implementation/focas-simulator-plan.md). Selects a
+ docker-compose profile + matching -Series. When set, the
+ FOCAS_TRUST_WIRE gate is considered satisfied because the sim
+ is a legitimate non-hardware target.
+
+ -HandleLeakCycles — stress stage that opens + closes sessions
+ via the CLI's `probe` command with a short sleep between
+ cycles. Exercises the Tier-C supervisor's handle-recycle path
+ without touching user data. Typical values: 100–1000. A CNC's
+ FWLIB handle pool is finite (~5–10), so this shakes out
+ handle-leak bugs if either side forgets to free.
.PARAMETER CncHost
IP or hostname of the CNC. Default 127.0.0.1 — override for real runs.
@@ -19,7 +38,22 @@
FOCAS TCP port. Default 8193.
.PARAMETER Address
- FOCAS address to exercise. Default R100 (PMC R-file register).
+ FOCAS address to exercise. Default R100 (PMC R-file register). Ignored
+ when -Series is set and the series profile supplies its own probe.
+
+.PARAMETER Series
+ Comma-separated list of CNC series to run the matrix against. Known:
+ ZeroI_D, ZeroI_F, ZeroI_MF, ZeroI_TF, Sixteen_i, Thirty_i, ThirtyOne_i,
+ ThirtyTwo_i, PowerMotion_i. When empty the script runs a single pass
+ without a series constraint.
+
+.PARAMETER ProfileName
+ docker-compose profile name from tests/.../Docker/profiles/. When set,
+ the script assumes the Python simulator is the target + un-gates
+ FOCAS_TRUST_WIRE.
+
+.PARAMETER HandleLeakCycles
+ Run a handle-leak stress stage with open/close cycles. 0 = skip.
.PARAMETER OpcUaUrl
OtOpcUa server endpoint.
@@ -32,6 +66,9 @@ param(
[string]$CncHost = "127.0.0.1",
[int]$CncPort = 8193,
[string]$Address = "R100",
+ [string]$Series = "",
+ [string]$ProfileName = "",
+ [int]$HandleLeakCycles = 0,
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
[Parameter(Mandatory)] [string]$BridgeNodeId
)
@@ -39,11 +76,41 @@ param(
$ErrorActionPreference = "Stop"
. "$PSScriptRoot/_common.ps1"
-if (-not ($env:FOCAS_TRUST_WIRE -eq "1" -or $env:FOCAS_TRUST_WIRE -eq "true")) {
- Write-Skip "FOCAS_TRUST_WIRE not set — no public simulator exists (task #222 tracks the lab rig). Set =1 when -CncHost points at a real CNC with Fwlib32.dll on PATH."
+$simGated = -not [string]::IsNullOrWhiteSpace($ProfileName)
+if (-not $simGated -and -not ($env:FOCAS_TRUST_WIRE -eq "1" -or $env:FOCAS_TRUST_WIRE -eq "true")) {
+ Write-Skip "FOCAS_TRUST_WIRE not set. Pass -ProfileName to run against the Docker mock in tests/.../Driver.FOCAS.IntegrationTests/Docker/, or set FOCAS_TRUST_WIRE=1 when -CncHost points at a real CNC."
exit 0
}
+if ($simGated) {
+ Write-Info "Sim mode — profile '$ProfileName'. FOCAS_TRUST_WIRE gate bypassed."
+}
+
+# Per-series probe addresses — each one is inside the authoritative range for
+# that series (docs/v2/focas-version-matrix.md). Picking one representative per
+# kind (PMC / parameter / macro) is enough to exercise the driver's validator.
+$seriesProbes = @{
+ "ZeroI_D" = "R100"
+ "ZeroI_F" = "R100"
+ "ZeroI_MF" = "R100"
+ "ZeroI_TF" = "R100"
+ "Sixteen_i" = "R100"
+ "Thirty_i" = "R100"
+ "ThirtyOne_i" = "R100"
+ "ThirtyTwo_i" = "R100"
+ "PowerMotion_i"= "R100"
+}
+
+$seriesList = @()
+if (-not [string]::IsNullOrWhiteSpace($Series)) {
+ $seriesList = @($Series.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ })
+ $unknown = @($seriesList | Where-Object { -not $seriesProbes.ContainsKey($_) })
+ if ($unknown.Count -gt 0) {
+ Write-Fail "Unknown -Series entries: $($unknown -join ', '). Known: $($seriesProbes.Keys -join ', ')."
+ exit 2
+ }
+}
+
$focasCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli" `
-ExeName "otopcua-focas-cli"
@@ -51,46 +118,103 @@ $opcUaCli = Get-CliInvocation `
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
-ExeName "otopcua-cli"
-$commonFocas = @("-h", $CncHost, "-p", $CncPort)
-$results = @()
+$allResults = @()
-$results += Test-Probe `
- -Cli $focasCli `
- -ProbeArgs (@("probe") + $commonFocas + @("-a", $Address, "--type", "Int16"))
+function Invoke-FocasCore {
+ param(
+ [string]$Label,
+ [string]$ProbeAddress
+ )
-$writeValue = Get-Random -Minimum 1 -Maximum 9999
-$results += Test-DriverLoopback `
- -Cli $focasCli `
- -WriteArgs (@("write") + $commonFocas + @("-a", $Address, "-t", "Int16", "-v", $writeValue)) `
- -ReadArgs (@("read") + $commonFocas + @("-a", $Address, "-t", "Int16")) `
- -ExpectedValue "$writeValue"
+ Write-Header "FOCAS stages — $Label"
+ $commonFocas = @("-h", $CncHost, "-p", $CncPort)
+ $results = @()
-$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
-$results += Test-ServerBridge `
- -DriverCli $focasCli `
- -DriverWriteArgs (@("write") + $commonFocas + @("-a", $Address, "-t", "Int16", "-v", $bridgeValue)) `
- -OpcUaCli $opcUaCli `
- -OpcUaUrl $OpcUaUrl `
- -OpcUaNodeId $BridgeNodeId `
- -ExpectedValue "$bridgeValue"
+ $results += Test-Probe `
+ -Cli $focasCli `
+ -ProbeArgs (@("probe") + $commonFocas + @("-a", $ProbeAddress, "--type", "Int16"))
-$reverseValue = Get-Random -Minimum 20000 -Maximum 29999
-$results += Test-OpcUaWriteBridge `
- -OpcUaCli $opcUaCli `
- -OpcUaUrl $OpcUaUrl `
- -OpcUaNodeId $BridgeNodeId `
- -DriverCli $focasCli `
- -DriverReadArgs (@("read") + $commonFocas + @("-a", $Address, "-t", "Int16")) `
- -ExpectedValue "$reverseValue"
+ $writeValue = Get-Random -Minimum 1 -Maximum 9999
+ $results += Test-DriverLoopback `
+ -Cli $focasCli `
+ -WriteArgs (@("write") + $commonFocas + @("-a", $ProbeAddress, "-t", "Int16", "-v", $writeValue)) `
+ -ReadArgs (@("read") + $commonFocas + @("-a", $ProbeAddress, "-t", "Int16")) `
+ -ExpectedValue "$writeValue"
-$subValue = Get-Random -Minimum 30000 -Maximum 32766
-$results += Test-SubscribeSeesChange `
- -OpcUaCli $opcUaCli `
- -OpcUaUrl $OpcUaUrl `
- -OpcUaNodeId $BridgeNodeId `
- -DriverCli $focasCli `
- -DriverWriteArgs (@("write") + $commonFocas + @("-a", $Address, "-t", "Int16", "-v", $subValue)) `
- -ExpectedValue "$subValue"
+ $bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
+ $results += Test-ServerBridge `
+ -DriverCli $focasCli `
+ -DriverWriteArgs (@("write") + $commonFocas + @("-a", $ProbeAddress, "-t", "Int16", "-v", $bridgeValue)) `
+ -OpcUaCli $opcUaCli `
+ -OpcUaUrl $OpcUaUrl `
+ -OpcUaNodeId $BridgeNodeId `
+ -ExpectedValue "$bridgeValue"
-Write-Summary -Title "FOCAS e2e" -Results $results
-if ($results | Where-Object { -not $_.Passed }) { exit 1 }
+ $reverseValue = Get-Random -Minimum 20000 -Maximum 29999
+ $results += Test-OpcUaWriteBridge `
+ -OpcUaCli $opcUaCli `
+ -OpcUaUrl $OpcUaUrl `
+ -OpcUaNodeId $BridgeNodeId `
+ -DriverCli $focasCli `
+ -DriverReadArgs (@("read") + $commonFocas + @("-a", $ProbeAddress, "-t", "Int16")) `
+ -ExpectedValue "$reverseValue"
+
+ $subValue = Get-Random -Minimum 30000 -Maximum 32766
+ $results += Test-SubscribeSeesChange `
+ -OpcUaCli $opcUaCli `
+ -OpcUaUrl $OpcUaUrl `
+ -OpcUaNodeId $BridgeNodeId `
+ -DriverCli $focasCli `
+ -DriverWriteArgs (@("write") + $commonFocas + @("-a", $ProbeAddress, "-t", "Int16", "-v", $subValue)) `
+ -ExpectedValue "$subValue"
+
+ return $results
+}
+
+function Invoke-HandleLeakStage {
+ param([int]$Cycles)
+
+ Write-Header "FOCAS handle-leak stress — $Cycles cycles"
+ $commonFocas = @("-h", $CncHost, "-p", $CncPort)
+ $failed = 0
+ for ($i = 1; $i -le $Cycles; $i++) {
+ $probe = Test-Probe `
+ -Cli $focasCli `
+ -ProbeArgs (@("probe") + $commonFocas + @("-a", $Address, "--type", "Int16"))
+ if (-not $probe.Passed) {
+ $failed++
+ # First 3 failures are informative; the rest just tally.
+ if ($failed -le 3) {
+ Write-Fail "cycle $i failed: $($probe.Reason)"
+ }
+ }
+ # Tiny delay so a broken loop can't DDoS the CNC; FWLIB handles take a
+ # few tens of ms to recycle in practice.
+ Start-Sleep -Milliseconds 50
+ }
+ $passed = $Cycles - $failed
+ if ($failed -eq 0) {
+ Write-Pass "handle-leak stress: $passed/$Cycles cycles succeeded"
+ return @{ Passed = $true; Reason = "$passed/$Cycles" }
+ } else {
+ Write-Fail "handle-leak stress: $failed/$Cycles cycles failed"
+ return @{ Passed = $false; Reason = "$failed/$Cycles failed" }
+ }
+}
+
+if ($seriesList.Count -eq 0) {
+ $allResults += Invoke-FocasCore -Label "single" -ProbeAddress $Address
+} else {
+ foreach ($series in $seriesList) {
+ $probeAddr = $seriesProbes[$series]
+ Write-Info "Running matrix pass for series '$series' with address $probeAddr"
+ $allResults += Invoke-FocasCore -Label $series -ProbeAddress $probeAddr
+ }
+}
+
+if ($HandleLeakCycles -gt 0) {
+ $allResults += Invoke-HandleLeakStage -Cycles $HandleLeakCycles
+}
+
+Write-Summary -Title "FOCAS e2e" -Results $allResults
+if ($allResults | Where-Object { -not $_.Passed }) { exit 1 }
diff --git a/scripts/install/Install-FocasHost.ps1 b/scripts/install/Install-FocasHost.ps1
deleted file mode 100644
index b81e376..0000000
--- a/scripts/install/Install-FocasHost.ps1
+++ /dev/null
@@ -1,108 +0,0 @@
-<#
-.SYNOPSIS
- Registers the OtOpcUaFocasHost Windows service. Optional companion to
- Install-Services.ps1 — only run this on nodes where FOCAS driver instances will run
- with Tier-C process isolation enabled.
-
-.DESCRIPTION
- FOCAS PR #220 / Tier-C isolation plan. Wraps OtOpcUa.Driver.FOCAS.Host.exe (net48 x86)
- as a Windows service using NSSM, running under the same service account as the main
- OtOpcUa service so the named-pipe ACL works. Passes the per-process shared secret via
- environment variable at service-start time so it never hits disk.
-
-.PARAMETER InstallRoot
- Where the FOCAS Host binaries live (typically
- C:\Program Files\OtOpcUa\Driver.FOCAS.Host).
-
-.PARAMETER ServiceAccount
- Service account SID or DOMAIN\name. Must match the main OtOpcUa server account so the
- PipeAcl match succeeds.
-
-.PARAMETER FocasSharedSecret
- Per-process secret passed via env var. Generated freshly per install if not supplied.
-
-.PARAMETER FocasBackend
- Backend selector for the Host process. One of:
- fwlib32 (default — real Fanuc Fwlib32.dll integration; requires licensed DLL on PATH)
- fake (in-memory; smoke-test mode)
- unconfigured (safe default returning structured errors; use until hardware is wired)
-
-.PARAMETER FocasPipeName
- Pipe name the Host listens on. Default: OtOpcUaFocas.
-
-.EXAMPLE
- .\Install-FocasHost.ps1 -InstallRoot 'C:\Program Files\OtOpcUa\Driver.FOCAS.Host' `
- -ServiceAccount 'OTOPCUA\svc-otopcua' -FocasBackend fwlib32
-#>
-[CmdletBinding()]
-param(
- [Parameter(Mandatory)] [string]$InstallRoot,
- [Parameter(Mandatory)] [string]$ServiceAccount,
- [string]$FocasSharedSecret,
- [ValidateSet('fwlib32','fake','unconfigured')] [string]$FocasBackend = 'unconfigured',
- [string]$FocasPipeName = 'OtOpcUaFocas',
- [string]$ServiceName = 'OtOpcUaFocasHost',
- [string]$NssmPath = 'C:\Program Files\nssm\nssm.exe'
-)
-
-$ErrorActionPreference = 'Stop'
-
-function Resolve-Sid {
- param([string]$Account)
- if ($Account -match '^S-\d-\d+') { return $Account }
- try {
- $nt = New-Object System.Security.Principal.NTAccount($Account)
- return $nt.Translate([System.Security.Principal.SecurityIdentifier]).Value
- } catch {
- throw "Could not resolve '$Account' to a SID. Pass an explicit SID or check the account name."
- }
-}
-
-if (-not (Test-Path $NssmPath)) {
- throw "nssm.exe not found at '$NssmPath'. Install NSSM or pass -NssmPath."
-}
-
-$hostExe = Join-Path $InstallRoot 'OtOpcUa.Driver.FOCAS.Host.exe'
-if (-not (Test-Path $hostExe)) {
- throw "FOCAS Host binary not found at '$hostExe'. Publish the Driver.FOCAS.Host project first."
-}
-
-if (-not $FocasSharedSecret) {
- $FocasSharedSecret = [System.Guid]::NewGuid().ToString('N')
- Write-Host "Generated FocasSharedSecret — store it alongside the OtOpcUa service config."
-}
-
-$allowedSid = Resolve-Sid $ServiceAccount
-
-# Idempotent install — remove + re-create if present.
-$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
-if ($existing) {
- Write-Host "Removing existing '$ServiceName' service..."
- & $NssmPath stop $ServiceName confirm | Out-Null
- & $NssmPath remove $ServiceName confirm | Out-Null
-}
-
-& $NssmPath install $ServiceName $hostExe | Out-Null
-& $NssmPath set $ServiceName DisplayName 'OT-OPC-UA FOCAS Host (Tier-C isolated Fwlib32)' | Out-Null
-& $NssmPath set $ServiceName Description 'Out-of-process Fwlib32.dll host for OtOpcUa FOCAS driver. Crash-isolated from the main OPC UA server.' | Out-Null
-& $NssmPath set $ServiceName ObjectName $ServiceAccount | Out-Null
-& $NssmPath set $ServiceName Start SERVICE_AUTO_START | Out-Null
-& $NssmPath set $ServiceName AppStdout (Join-Path $env:ProgramData 'OtOpcUa\focas-host-stdout.log') | Out-Null
-& $NssmPath set $ServiceName AppStderr (Join-Path $env:ProgramData 'OtOpcUa\focas-host-stderr.log') | Out-Null
-& $NssmPath set $ServiceName AppRotateFiles 1 | Out-Null
-& $NssmPath set $ServiceName AppRotateBytes 10485760 | Out-Null
-
-& $NssmPath set $ServiceName AppEnvironmentExtra `
- "OTOPCUA_FOCAS_PIPE=$FocasPipeName" `
- "OTOPCUA_ALLOWED_SID=$allowedSid" `
- "OTOPCUA_FOCAS_SECRET=$FocasSharedSecret" `
- "OTOPCUA_FOCAS_BACKEND=$FocasBackend" | Out-Null
-
-& $NssmPath set $ServiceName DependOnService OtOpcUa | Out-Null
-
-Write-Host "Installed '$ServiceName' under '$ServiceAccount' (SID=$allowedSid)."
-Write-Host "Pipe: \\.\pipe\$FocasPipeName Backend: $FocasBackend"
-Write-Host "Start the service with: Start-Service $ServiceName"
-Write-Host ""
-Write-Host "NOTE: the Fwlib32 backend requires the licensed Fwlib32.dll on PATH"
-Write-Host "alongside the Host exe. See docs/v2/focas-deployment.md."
diff --git a/scripts/integration/README.md b/scripts/integration/README.md
new file mode 100644
index 0000000..d8d2bee
--- /dev/null
+++ b/scripts/integration/README.md
@@ -0,0 +1,87 @@
+# Integration runners
+
+Scripts that orchestrate multi-component integration-test loops —
+each one wires up docker fixtures, support binaries, and `dotnet test`
+in sequence so a developer (or a CI agent) can get from "freshly
+cloned repo" to "green integration suite" with one command.
+
+Unlike `scripts/e2e/test-*.ps1` (which drive the built server through
+the CLI for black-box coverage), scripts in this folder operate
+**below** the server layer — they bring up the raw fixtures the
+driver-level `IntegrationTests` projects need.
+
+## Scripts
+
+| Script | Purpose |
+|--------|---------|
+| [`run-focas.ps1`](run-focas.ps1) | FOCAS driver: builds shim DLLs + starts focas-mock docker + copies shim into test bin + runs `WireCompatGatedTests` + `FocasSimSmokeTests` + tears down docker |
+
+## run-focas.ps1
+
+### Prerequisites
+
+- **Windows + PowerShell 7+**
+- **.NET 10 SDK** — `dotnet --version` prints 10.x
+- **Native C compiler** — one of:
+ - Visual Studio Build Tools with the C++ workload (then run from an
+ "x64 Native Tools Command Prompt for VS" shell), or
+ - Zig (`zig.exe` on PATH) as a drop-in alternative
+- **Docker Desktop** running, OR pass `-SkipDocker` and run the mock
+ externally
+
+### One-shot run
+
+```powershell
+cd C:\Users\dohertj2\Desktop\lmxopcua
+pwsh .\scripts\integration\run-focas.ps1
+```
+
+That's the default invocation: thirtyone profile, debug build,
+docker cleans up on exit.
+
+### Development iteration
+
+Re-run the tests without rebuilding the shim or restarting docker:
+
+```powershell
+# First run bootstraps everything + keeps the mock up.
+pwsh .\scripts\integration\run-focas.ps1 -KeepDocker
+
+# Iterate on test bodies without re-doing the slow steps.
+pwsh .\scripts\integration\run-focas.ps1 -SkipShimBuild -SkipDocker
+```
+
+### Per-series runs
+
+```powershell
+pwsh .\scripts\integration\run-focas.ps1 -Profile thirty # 30i series
+pwsh .\scripts\integration\run-focas.ps1 -Profile zerod # 0i-D
+pwsh .\scripts\integration\run-focas.ps1 -Profile powermotion
+```
+
+Full profile list is in
+`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/docker-compose.yml`.
+
+### Exit codes
+
+| Code | Meaning |
+|------|---------|
+| 0 | All tests passed or cleanly skipped |
+| 1 | `dotnet test` reported failures |
+| 2 | The runner itself crashed (missing file, unexpected exception) |
+| 3 | No C compiler detected for shim build |
+| 4 | Docker CLI not on PATH |
+
+### CI integration
+
+Wire this into the project's CI runner (Gitea Actions, Jenkins,
+whatever's hosting this repo) by calling:
+
+```yaml
+- name: FOCAS integration
+ shell: pwsh
+ run: ./scripts/integration/run-focas.ps1
+```
+
+The script is idempotent; a previous run's `docker compose down`
+failure won't block the next one.
diff --git a/scripts/integration/run-focas.ps1 b/scripts/integration/run-focas.ps1
new file mode 100644
index 0000000..716502a
--- /dev/null
+++ b/scripts/integration/run-focas.ps1
@@ -0,0 +1,123 @@
+#Requires -Version 7.0
+<#
+.SYNOPSIS
+ Orchestrates the FOCAS driver integration-test loop: bring up the
+ focas-mock Docker container, run the managed wire-client integration
+ tests, tear down Docker.
+
+.DESCRIPTION
+ The FOCAS integration fixture now needs just two things running
+ together:
+ 1. A single focas-mock container listening on :8193 (one service,
+ no per-series compose profile ceremony — the mock's native
+ FOCAS Ethernet responder handles every call the managed driver
+ issues).
+ 2. The integration-test assembly built. The managed
+ `WireFocasClient` dials the mock directly; there is no shim
+ DLL, no P/Invoke, no test-bin DLL copy step.
+ This script handles both and cleans up on exit.
+
+ Designed to run unattended on a build agent or on a developer box.
+ Exit code matches the test suite (0 = all pass or skip-clean,
+ non-zero when any integration test failed).
+
+.PARAMETER Profile
+ focas-mock profile name to seed at startup (e.g. `ThirtyOne_i`,
+ `Sixteen_i`, `fwlib30i64`). Defaults to `ThirtyOne_i`. The fixture
+ resolves aliases via `FocasCncSeries`, and tests that need per-series
+ state can call `fixture.LoadProfileAsync` directly at test start to
+ override the default.
+
+.PARAMETER SkipDocker
+ Skip docker up/down. Use when the mock is already running from
+ another shell.
+
+.PARAMETER Configuration
+ Build configuration — Debug or Release. Default: Debug.
+
+.PARAMETER KeepDocker
+ Don't tear down the docker stack on exit. Useful for iterating on
+ tests.
+#>
+
+param(
+ [string]$Profile = "ThirtyOne_i",
+ [switch]$SkipDocker,
+ [ValidateSet("Debug", "Release")]
+ [string]$Configuration = "Debug",
+ [switch]$KeepDocker
+)
+
+Set-StrictMode -Version 3.0
+$ErrorActionPreference = "Stop"
+
+$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
+$integTests = Join-Path $repoRoot "tests\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests"
+$dockerYml = Join-Path $integTests "Docker\docker-compose.yml"
+
+function Write-Step { param([string]$Msg) Write-Host ""; Write-Host "=== $Msg ===" -ForegroundColor Cyan }
+function Write-Info { param([string]$Msg) Write-Host "[INFO] $Msg" -ForegroundColor Gray }
+function Write-Fail { param([string]$Msg) Write-Host "[FAIL] $Msg" -ForegroundColor Red }
+
+$cleanupScripts = @()
+trap {
+ Write-Host ""
+ Write-Fail "run-focas.ps1 crashed: $_"
+ foreach ($c in $cleanupScripts) { try { & $c } catch { Write-Host "cleanup failed: $_" -ForegroundColor DarkYellow } }
+ exit 2
+}
+
+Write-Step "Build FOCAS IntegrationTests ($Configuration)"
+dotnet build (Join-Path $integTests "ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.csproj") `
+ --configuration $Configuration --nologo --verbosity minimal
+if ($LASTEXITCODE -ne 0) { throw "dotnet build failed (exit $LASTEXITCODE)" }
+
+if (-not $SkipDocker) {
+ Write-Step "docker compose up"
+ if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
+ Write-Fail "docker CLI not on PATH. Install Docker Desktop or pass -SkipDocker + run the mock externally."
+ exit 4
+ }
+
+ docker compose -f $dockerYml up -d --build --wait 2>&1 | Write-Host
+ if ($LASTEXITCODE -ne 0) { throw "docker compose up failed (exit $LASTEXITCODE)" }
+
+ if (-not $KeepDocker) {
+ $cleanupScripts += {
+ Write-Step "docker compose down"
+ docker compose -f $dockerYml down --remove-orphans 2>&1 | Write-Host
+ }
+ }
+
+ Write-Info "probing localhost:8193..."
+ $tcp = [System.Net.Sockets.TcpClient]::new()
+ try {
+ $ok = $tcp.ConnectAsync("127.0.0.1", 8193).Wait([TimeSpan]::FromSeconds(5))
+ if (-not $ok -or -not $tcp.Connected) {
+ throw "TCP probe to localhost:8193 failed after docker compose --wait succeeded"
+ }
+ }
+ finally { $tcp.Dispose() }
+ Write-Info "mock is accepting connections"
+}
+else {
+ Write-Step "Docker (skipped)"
+}
+
+Write-Step "dotnet test (wire-backend integration)"
+$env:OTOPCUA_FOCAS_SIM_PROFILE = $Profile
+
+dotnet test (Join-Path $integTests "ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.csproj") `
+ --configuration $Configuration --no-build --nologo --verbosity minimal
+$testExit = $LASTEXITCODE
+
+foreach ($c in $cleanupScripts) { & $c }
+
+if ($testExit -ne 0) {
+ Write-Fail "integration tests failed with exit $testExit"
+ exit $testExit
+}
+
+Write-Host ""
+Write-Host "run-focas.ps1 completed successfully." -ForegroundColor Green
+exit 0
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ProbeCommand.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ProbeCommand.cs
index f0e6ef9..e76c799 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ProbeCommand.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ProbeCommand.cs
@@ -5,11 +5,11 @@ using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
///
-/// Probes a Fanuc CNC: opens a FOCAS session + reads one PMC address. No public
-/// simulator exists — this command only produces meaningful results against a real
-/// CNC with Fwlib32.dll present. Against a dev box it surfaces
-/// BadCommunicationError (DLL missing) which is still a useful signal that
-/// the CLI wire-up is correct.
+/// Probes a Fanuc CNC: opens a FOCAS session + reads one PMC address. Uses the managed
+/// WireFocasClient on TCP:8193. Against an unreachable endpoint it surfaces
+/// BadCommunicationError which is still a useful signal that the CLI wire-up is
+/// correct. Also runs cleanly against the focas-mock Docker fixture in
+/// tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/.
///
[Command("probe", Description = "Verify the CNC is reachable + a sample FOCAS read succeeds.")]
public sealed class ProbeCommand : FocasCommandBase
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
index a8413b4..89ea96f 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
@@ -38,10 +38,9 @@ public abstract class FocasCommandBase : DriverCommandBase
///
/// Build a with the CNC target this base collected
- /// + the tag list a subclass supplies. Probe disabled; the default
- /// attempts Fwlib32.dll P/Invoke, which
- /// throws at first call when the DLL is absent —
- /// surfaced through the driver as BadCommunicationError.
+ /// + the tag list a subclass supplies. Probe disabled; the driver's default managed
+ /// wire client opens a TCP:8193 session to the CNC and surfaces unreachable endpoints
+ /// as BadCommunicationError.
///
protected FocasDriverOptions BuildOptions(IReadOnlyList tags) => new()
{
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Program.cs
index b94257d..9ebe0d4 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Program.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Program.cs
@@ -4,9 +4,9 @@ return await new CliApplicationBuilder()
.AddCommandsFromThisAssembly()
.SetExecutableName("otopcua-focas-cli")
.SetDescription(
- "OtOpcUa FOCAS test-client — ad-hoc probe + PMC/param/macro reads/writes + polled " +
- "subscriptions against Fanuc CNCs via the FOCAS/2 protocol. Requires a real CNC + a " +
- "licensed Fwlib32.dll on PATH (or next to the executable) — no public simulator " +
- "exists. Addresses use FocasAddressParser syntax: R100, X0.0, PARAM:1815/0, MACRO:500.")
+ "OtOpcUa FOCAS test-client — ad-hoc probe + PMC/param/macro reads + polled " +
+ "subscriptions against Fanuc CNCs via the FOCAS/2 protocol. Uses the managed " +
+ "WireFocasClient on TCP:8193 directly; no native dependencies. Addresses use " +
+ "FocasAddressParser syntax: R100, X0.0, PARAM:1815/0, MACRO:500.")
.Build()
.RunAsync(args);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.csproj
index 76520db..23d28bf 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.csproj
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.csproj
@@ -22,4 +22,7 @@
+
+
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/FakeFocasBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/FakeFocasBackend.cs
deleted file mode 100644
index 3f4bedf..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/FakeFocasBackend.cs
+++ /dev/null
@@ -1,122 +0,0 @@
-using System.Collections.Generic;
-using System.Threading;
-using System.Threading.Tasks;
-using MessagePack;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
-
-///
-/// In-memory for tests + an operational stub mode when
-/// OTOPCUA_FOCAS_BACKEND=fake. Keeps per-address values keyed by a canonical
-/// string; RMW semantics honor PMC bit-writes against the containing byte so the
-/// PmcBitWriteRequest path can be exercised end-to-end without hardware.
-///
-public sealed class FakeFocasBackend : IFocasBackend
-{
- private readonly object _gate = new();
- private long _nextSessionId;
- private readonly HashSet _openSessions = [];
- private readonly Dictionary _pmcValues = [];
- private readonly Dictionary _paramValues = [];
- private readonly Dictionary _macroValues = [];
-
- public Task OpenSessionAsync(OpenSessionRequest request, CancellationToken ct)
- {
- lock (_gate)
- {
- var id = ++_nextSessionId;
- _openSessions.Add(id);
- return Task.FromResult(new OpenSessionResponse { Success = true, SessionId = id });
- }
- }
-
- public Task CloseSessionAsync(CloseSessionRequest request, CancellationToken ct)
- {
- lock (_gate) { _openSessions.Remove(request.SessionId); }
- return Task.CompletedTask;
- }
-
- public Task ReadAsync(ReadRequest request, CancellationToken ct)
- {
- lock (_gate)
- {
- if (!_openSessions.Contains(request.SessionId))
- return Task.FromResult(new ReadResponse { Success = false, StatusCode = 0x80020000u, Error = "session-not-open" });
-
- var store = StoreFor(request.Address.Kind);
- var key = CanonicalKey(request.Address);
- store.TryGetValue(key, out var value);
- return Task.FromResult(new ReadResponse
- {
- Success = true,
- StatusCode = 0,
- ValueBytes = value ?? MessagePackSerializer.Serialize((int)0),
- ValueTypeCode = request.DataType,
- SourceTimestampUtcUnixMs = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
- });
- }
- }
-
- public Task WriteAsync(WriteRequest request, CancellationToken ct)
- {
- lock (_gate)
- {
- if (!_openSessions.Contains(request.SessionId))
- return Task.FromResult(new WriteResponse { Success = false, StatusCode = 0x80020000u, Error = "session-not-open" });
-
- var store = StoreFor(request.Address.Kind);
- store[CanonicalKey(request.Address)] = request.ValueBytes ?? [];
- return Task.FromResult(new WriteResponse { Success = true, StatusCode = 0 });
- }
- }
-
- public Task PmcBitWriteAsync(PmcBitWriteRequest request, CancellationToken ct)
- {
- lock (_gate)
- {
- if (!_openSessions.Contains(request.SessionId))
- return Task.FromResult(new PmcBitWriteResponse { Success = false, StatusCode = 0x80020000u, Error = "session-not-open" });
- if (request.BitIndex is < 0 or > 7)
- return Task.FromResult(new PmcBitWriteResponse { Success = false, StatusCode = 0x803C0000u, Error = "bit-out-of-range" });
-
- var key = CanonicalKey(request.Address);
- _pmcValues.TryGetValue(key, out var current);
- current ??= MessagePackSerializer.Serialize((byte)0);
- var b = MessagePackSerializer.Deserialize(current);
- var mask = (byte)(1 << request.BitIndex);
- b = request.Value ? (byte)(b | mask) : (byte)(b & ~mask);
- _pmcValues[key] = MessagePackSerializer.Serialize(b);
- return Task.FromResult(new PmcBitWriteResponse { Success = true, StatusCode = 0 });
- }
- }
-
- public Task ProbeAsync(ProbeRequest request, CancellationToken ct)
- {
- lock (_gate)
- {
- return Task.FromResult(new ProbeResponse
- {
- Healthy = _openSessions.Contains(request.SessionId),
- ObservedAtUtcUnixMs = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
- });
- }
- }
-
- private Dictionary StoreFor(int kind) => kind switch
- {
- 0 => _pmcValues,
- 1 => _paramValues,
- 2 => _macroValues,
- _ => _pmcValues,
- };
-
- private static string CanonicalKey(FocasAddressDto addr) =>
- addr.Kind switch
- {
- 0 => $"{addr.PmcLetter}{addr.Number}",
- 1 => $"P{addr.Number}",
- 2 => $"M{addr.Number}",
- _ => $"?{addr.Number}",
- };
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/IFocasBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/IFocasBackend.cs
deleted file mode 100644
index 4176f08..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/IFocasBackend.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-using System.Threading;
-using System.Threading.Tasks;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
-
-///
-/// The Host's view of a FOCAS session. One implementation wraps the real
-/// Fwlib32.dll via P/Invoke (lands with the real Fwlib32 integration follow-up,
-/// since no hardware is available today); a second implementation —
-/// — is used by tests.
-/// Both live on .NET 4.8 x86 so the Host can be deployed in either mode without
-/// changing the pipe server.
-/// Invoked via FwlibFrameHandler in the Ipc namespace.
-///
-public interface IFocasBackend
-{
- Task OpenSessionAsync(OpenSessionRequest request, CancellationToken ct);
- Task CloseSessionAsync(CloseSessionRequest request, CancellationToken ct);
- Task ReadAsync(ReadRequest request, CancellationToken ct);
- Task WriteAsync(WriteRequest request, CancellationToken ct);
- Task PmcBitWriteAsync(PmcBitWriteRequest request, CancellationToken ct);
- Task ProbeAsync(ProbeRequest request, CancellationToken ct);
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/UnconfiguredFocasBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/UnconfiguredFocasBackend.cs
deleted file mode 100644
index 4889739..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/UnconfiguredFocasBackend.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-using System.Threading;
-using System.Threading.Tasks;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
-
-///
-/// Safe default when the deployment hasn't configured a real Fwlib32 backend.
-/// Returns structured failure responses instead of throwing so the Proxy can map the
-/// error to BadDeviceFailure and surface a clear operator message pointing at
-/// docs/v2/focas-deployment.md. Used when OTOPCUA_FOCAS_BACKEND is unset
-/// or set to unconfigured.
-///
-public sealed class UnconfiguredFocasBackend : IFocasBackend
-{
- private const uint BadDeviceFailure = 0x80550000u;
- private const string Reason =
- "FOCAS Host is running without a real Fwlib32 backend. Set OTOPCUA_FOCAS_BACKEND=fwlib32 " +
- "and ensure Fwlib32.dll is on PATH — see docs/v2/focas-deployment.md.";
-
- public Task OpenSessionAsync(OpenSessionRequest request, CancellationToken ct) =>
- Task.FromResult(new OpenSessionResponse { Success = false, Error = Reason, ErrorCode = "NoFwlibBackend" });
-
- public Task CloseSessionAsync(CloseSessionRequest request, CancellationToken ct) => Task.CompletedTask;
-
- public Task ReadAsync(ReadRequest request, CancellationToken ct) =>
- Task.FromResult(new ReadResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason });
-
- public Task WriteAsync(WriteRequest request, CancellationToken ct) =>
- Task.FromResult(new WriteResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason });
-
- public Task PmcBitWriteAsync(PmcBitWriteRequest request, CancellationToken ct) =>
- Task.FromResult(new PmcBitWriteResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason });
-
- public Task ProbeAsync(ProbeRequest request, CancellationToken ct) =>
- Task.FromResult(new ProbeResponse { Healthy = false, Error = Reason, ObservedAtUtcUnixMs = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() });
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/FwlibFrameHandler.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/FwlibFrameHandler.cs
deleted file mode 100644
index d6e8ecf..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/FwlibFrameHandler.cs
+++ /dev/null
@@ -1,111 +0,0 @@
-using System;
-using System.Threading;
-using System.Threading.Tasks;
-using MessagePack;
-using Serilog;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
-
-///
-/// Real FOCAS frame handler. Deserializes each request DTO, delegates to
-/// , re-serializes the response. The backend owns the
-/// Fwlib32 handle + STA thread — the handler is pure dispatch.
-///
-public sealed class FwlibFrameHandler : IFrameHandler
-{
- private readonly IFocasBackend _backend;
- private readonly ILogger _logger;
-
- public FwlibFrameHandler(IFocasBackend backend, ILogger logger)
- {
- _backend = backend ?? throw new ArgumentNullException(nameof(backend));
- _logger = logger ?? throw new ArgumentNullException(nameof(logger));
- }
-
- public async Task HandleAsync(FocasMessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct)
- {
- try
- {
- switch (kind)
- {
- case FocasMessageKind.Heartbeat:
- {
- var hb = MessagePackSerializer.Deserialize(body);
- await writer.WriteAsync(FocasMessageKind.HeartbeatAck,
- new HeartbeatAck
- {
- MonotonicTicks = hb.MonotonicTicks,
- HostUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
- }, ct).ConfigureAwait(false);
- return;
- }
-
- case FocasMessageKind.OpenSessionRequest:
- {
- var req = MessagePackSerializer.Deserialize(body);
- var resp = await _backend.OpenSessionAsync(req, ct).ConfigureAwait(false);
- await writer.WriteAsync(FocasMessageKind.OpenSessionResponse, resp, ct).ConfigureAwait(false);
- return;
- }
-
- case FocasMessageKind.CloseSessionRequest:
- {
- var req = MessagePackSerializer.Deserialize(body);
- await _backend.CloseSessionAsync(req, ct).ConfigureAwait(false);
- return;
- }
-
- case FocasMessageKind.ReadRequest:
- {
- var req = MessagePackSerializer.Deserialize(body);
- var resp = await _backend.ReadAsync(req, ct).ConfigureAwait(false);
- await writer.WriteAsync(FocasMessageKind.ReadResponse, resp, ct).ConfigureAwait(false);
- return;
- }
-
- case FocasMessageKind.WriteRequest:
- {
- var req = MessagePackSerializer.Deserialize(body);
- var resp = await _backend.WriteAsync(req, ct).ConfigureAwait(false);
- await writer.WriteAsync(FocasMessageKind.WriteResponse, resp, ct).ConfigureAwait(false);
- return;
- }
-
- case FocasMessageKind.PmcBitWriteRequest:
- {
- var req = MessagePackSerializer.Deserialize(body);
- var resp = await _backend.PmcBitWriteAsync(req, ct).ConfigureAwait(false);
- await writer.WriteAsync(FocasMessageKind.PmcBitWriteResponse, resp, ct).ConfigureAwait(false);
- return;
- }
-
- case FocasMessageKind.ProbeRequest:
- {
- var req = MessagePackSerializer.Deserialize(body);
- var resp = await _backend.ProbeAsync(req, ct).ConfigureAwait(false);
- await writer.WriteAsync(FocasMessageKind.ProbeResponse, resp, ct).ConfigureAwait(false);
- return;
- }
-
- default:
- await writer.WriteAsync(FocasMessageKind.ErrorResponse,
- new ErrorResponse { Code = "unknown-kind", Message = $"Kind {kind} is not handled by the Host" },
- ct).ConfigureAwait(false);
- return;
- }
- }
- catch (OperationCanceledException) { throw; }
- catch (Exception ex)
- {
- _logger.Error(ex, "FwlibFrameHandler error processing {Kind}", kind);
- await writer.WriteAsync(FocasMessageKind.ErrorResponse,
- new ErrorResponse { Code = "backend-exception", Message = ex.Message },
- ct).ConfigureAwait(false);
- }
- }
-
- public IDisposable AttachConnection(FrameWriter writer) => IFrameHandler.NoopAttachment.Instance;
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/IFrameHandler.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/IFrameHandler.cs
deleted file mode 100644
index 75cef97..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/IFrameHandler.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-using System;
-using System.Threading;
-using System.Threading.Tasks;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
-
-///
-/// Dispatches a single IPC frame to the backend. Implementations own the FOCAS session
-/// state and translate request DTOs into Fwlib32 calls.
-///
-public interface IFrameHandler
-{
- Task HandleAsync(FocasMessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct);
-
- ///
- /// Called once per accepted connection after the Hello handshake. Lets the handler
- /// attach server-pushed event sinks (data-change notifications, runtime-status
- /// changes) to the connection's . Returns an
- /// the pipe server disposes when the connection closes —
- /// backends use it to unsubscribe from their push sources.
- ///
- IDisposable AttachConnection(FrameWriter writer);
-
- public sealed class NoopAttachment : IDisposable
- {
- public static readonly NoopAttachment Instance = new();
- public void Dispose() { }
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/PipeAcl.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/PipeAcl.cs
deleted file mode 100644
index aac29a1..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/PipeAcl.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using System;
-using System.IO.Pipes;
-using System.Security.AccessControl;
-using System.Security.Principal;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
-
-///
-/// Builds the for the FOCAS Host pipe. Same pattern as
-/// Galaxy.Host: only the configured OtOpcUa server principal SID gets
-/// ReadWrite | Synchronize; LocalSystem + Administrators are explicitly denied
-/// so a compromised service account on the same host can't escalate via the pipe.
-///
-public static class PipeAcl
-{
- public static PipeSecurity Create(SecurityIdentifier allowedSid)
- {
- if (allowedSid is null) throw new ArgumentNullException(nameof(allowedSid));
-
- var security = new PipeSecurity();
-
- security.AddAccessRule(new PipeAccessRule(
- allowedSid,
- PipeAccessRights.ReadWrite | PipeAccessRights.Synchronize,
- AccessControlType.Allow));
-
- var localSystem = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
- var admins = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
-
- if (allowedSid != localSystem)
- security.AddAccessRule(new PipeAccessRule(localSystem, PipeAccessRights.FullControl, AccessControlType.Deny));
- if (allowedSid != admins)
- security.AddAccessRule(new PipeAccessRule(admins, PipeAccessRights.FullControl, AccessControlType.Deny));
-
- security.SetOwner(allowedSid);
-
- return security;
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/PipeServer.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/PipeServer.cs
deleted file mode 100644
index 582870e..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/PipeServer.cs
+++ /dev/null
@@ -1,152 +0,0 @@
-using System;
-using System.IO.Pipes;
-using System.Security.Principal;
-using System.Threading;
-using System.Threading.Tasks;
-using MessagePack;
-using Serilog;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
-
-///
-/// Accepts one client connection at a time on the FOCAS Host's named pipe with the
-/// strict ACL from . Verifies the peer SID + per-process shared
-/// secret before any RPC frame is accepted. Mirrors the Galaxy.Host pipe server byte for
-/// byte — different MessageKind enum, same negotiation semantics.
-///
-public sealed class PipeServer : IDisposable
-{
- private readonly string _pipeName;
- private readonly SecurityIdentifier _allowedSid;
- private readonly string _sharedSecret;
- private readonly ILogger _logger;
- private readonly CancellationTokenSource _cts = new();
- private NamedPipeServerStream? _current;
-
- public PipeServer(string pipeName, SecurityIdentifier allowedSid, string sharedSecret, ILogger logger)
- {
- _pipeName = pipeName ?? throw new ArgumentNullException(nameof(pipeName));
- _allowedSid = allowedSid ?? throw new ArgumentNullException(nameof(allowedSid));
- _sharedSecret = sharedSecret ?? throw new ArgumentNullException(nameof(sharedSecret));
- _logger = logger ?? throw new ArgumentNullException(nameof(logger));
- }
-
- public async Task RunOneConnectionAsync(IFrameHandler handler, CancellationToken ct)
- {
- using var linked = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, ct);
- var acl = PipeAcl.Create(_allowedSid);
-
- _current = new NamedPipeServerStream(
- _pipeName,
- PipeDirection.InOut,
- maxNumberOfServerInstances: 1,
- PipeTransmissionMode.Byte,
- PipeOptions.Asynchronous,
- inBufferSize: 64 * 1024,
- outBufferSize: 64 * 1024,
- pipeSecurity: acl);
-
- try
- {
- await _current.WaitForConnectionAsync(linked.Token).ConfigureAwait(false);
-
- if (!VerifyCaller(_current, out var reason))
- {
- _logger.Warning("FOCAS IPC caller rejected: {Reason}", reason);
- _current.Disconnect();
- return;
- }
-
- using var reader = new FrameReader(_current, leaveOpen: true);
- using var writer = new FrameWriter(_current, leaveOpen: true);
-
- var first = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
- if (first is null || first.Value.Kind != FocasMessageKind.Hello)
- {
- _logger.Warning("FOCAS IPC first frame was not Hello; dropping");
- return;
- }
-
- var hello = MessagePackSerializer.Deserialize(first.Value.Body);
- if (!string.Equals(hello.SharedSecret, _sharedSecret, StringComparison.Ordinal))
- {
- await writer.WriteAsync(FocasMessageKind.HelloAck,
- new HelloAck { Accepted = false, RejectReason = "shared-secret-mismatch" },
- linked.Token).ConfigureAwait(false);
- _logger.Warning("FOCAS IPC Hello rejected: shared-secret-mismatch");
- return;
- }
-
- if (hello.ProtocolMajor != Hello.CurrentMajor)
- {
- await writer.WriteAsync(FocasMessageKind.HelloAck,
- new HelloAck
- {
- Accepted = false,
- RejectReason = $"major-version-mismatch-peer={hello.ProtocolMajor}-server={Hello.CurrentMajor}",
- },
- linked.Token).ConfigureAwait(false);
- _logger.Warning("FOCAS IPC Hello rejected: major mismatch peer={Peer} server={Server}",
- hello.ProtocolMajor, Hello.CurrentMajor);
- return;
- }
-
- await writer.WriteAsync(FocasMessageKind.HelloAck,
- new HelloAck { Accepted = true, HostName = Environment.MachineName },
- linked.Token).ConfigureAwait(false);
-
- using var attachment = handler.AttachConnection(writer);
-
- while (!linked.Token.IsCancellationRequested)
- {
- var frame = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
- if (frame is null) break;
-
- await handler.HandleAsync(frame.Value.Kind, frame.Value.Body, writer, linked.Token).ConfigureAwait(false);
- }
- }
- finally
- {
- _current.Dispose();
- _current = null;
- }
- }
-
- public async Task RunAsync(IFrameHandler handler, CancellationToken ct)
- {
- while (!ct.IsCancellationRequested)
- {
- try { await RunOneConnectionAsync(handler, ct).ConfigureAwait(false); }
- catch (OperationCanceledException) { break; }
- catch (Exception ex) { _logger.Error(ex, "FOCAS IPC connection loop error — accepting next"); }
- }
- }
-
- private bool VerifyCaller(NamedPipeServerStream pipe, out string reason)
- {
- try
- {
- pipe.RunAsClient(() =>
- {
- using var wi = WindowsIdentity.GetCurrent();
- if (wi.User is null)
- throw new InvalidOperationException("GetCurrent().User is null — cannot verify caller");
- if (wi.User != _allowedSid)
- throw new UnauthorizedAccessException(
- $"caller SID {wi.User.Value} does not match allowed {_allowedSid.Value}");
- });
- reason = string.Empty;
- return true;
- }
- catch (Exception ex) { reason = ex.Message; return false; }
- }
-
- public void Dispose()
- {
- _cts.Cancel();
- _current?.Dispose();
- _cts.Dispose();
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/StubFrameHandler.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/StubFrameHandler.cs
deleted file mode 100644
index 2c28dac..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/StubFrameHandler.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-using System;
-using System.Threading;
-using System.Threading.Tasks;
-using MessagePack;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
-
-///
-/// Placeholder handler that returns ErrorResponse{Code=not-implemented} for every
-/// FOCAS data-plane request. Exists so PR B can ship the pipe server + ACL + handshake
-/// plumbing before PR C moves the Fwlib32 calls. Heartbeats are handled fully so the
-/// supervisor's liveness detector stays happy.
-///
-public sealed class StubFrameHandler : IFrameHandler
-{
- public Task HandleAsync(FocasMessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct)
- {
- if (kind == FocasMessageKind.Heartbeat)
- {
- var hb = MessagePackSerializer.Deserialize(body);
- return writer.WriteAsync(FocasMessageKind.HeartbeatAck,
- new HeartbeatAck
- {
- MonotonicTicks = hb.MonotonicTicks,
- HostUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
- }, ct);
- }
-
- return writer.WriteAsync(FocasMessageKind.ErrorResponse,
- new ErrorResponse
- {
- Code = "not-implemented",
- Message = $"Kind {kind} is stubbed — Fwlib32 lift lands in PR C",
- },
- ct);
- }
-
- public IDisposable AttachConnection(FrameWriter writer) => IFrameHandler.NoopAttachment.Instance;
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Program.cs
deleted file mode 100644
index 05ab13e..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Program.cs
+++ /dev/null
@@ -1,72 +0,0 @@
-using System;
-using System.Security.Principal;
-using System.Threading;
-using Serilog;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host;
-
-///
-/// Entry point for the OtOpcUaFocasHost Windows service / console host. The
-/// supervisor (Proxy-side) spawns this process per FOCAS driver instance and passes the
-/// pipe name, allowed-SID, and per-process shared secret as environment variables. In
-/// PR B the backend is — PR C swaps in the real
-/// Fwlib32-backed handler once the session state + STA thread move out of the .NET 10
-/// driver.
-///
-public static class Program
-{
- public static int Main(string[] args)
- {
- Log.Logger = new LoggerConfiguration()
- .MinimumLevel.Information()
- .WriteTo.File(
- @"%ProgramData%\OtOpcUa\focas-host-.log".Replace("%ProgramData%", Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData)),
- rollingInterval: RollingInterval.Day)
- .CreateLogger();
-
- try
- {
- var pipeName = Environment.GetEnvironmentVariable("OTOPCUA_FOCAS_PIPE") ?? "OtOpcUaFocas";
- var allowedSidValue = Environment.GetEnvironmentVariable("OTOPCUA_ALLOWED_SID")
- ?? throw new InvalidOperationException(
- "OTOPCUA_ALLOWED_SID not set — the FOCAS Proxy supervisor must pass the server principal SID");
- var sharedSecret = Environment.GetEnvironmentVariable("OTOPCUA_FOCAS_SECRET")
- ?? throw new InvalidOperationException(
- "OTOPCUA_FOCAS_SECRET not set — the FOCAS Proxy supervisor must pass the per-process secret at spawn time");
-
- var allowedSid = new SecurityIdentifier(allowedSidValue);
-
- using var server = new PipeServer(pipeName, allowedSid, sharedSecret, Log.Logger);
- using var cts = new CancellationTokenSource();
- Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
-
- Log.Information("OtOpcUaFocasHost starting — pipe={Pipe} allowedSid={Sid}",
- pipeName, allowedSidValue);
-
- var backendKind = (Environment.GetEnvironmentVariable("OTOPCUA_FOCAS_BACKEND") ?? "unconfigured")
- .ToLowerInvariant();
- IFocasBackend backend = backendKind switch
- {
- "fake" => new FakeFocasBackend(),
- "unconfigured" => new UnconfiguredFocasBackend(),
- "fwlib32" => new UnconfiguredFocasBackend(), // real Fwlib32 backend lands with hardware integration follow-up
- _ => new UnconfiguredFocasBackend(),
- };
- Log.Information("OtOpcUaFocasHost backend={Backend}", backendKind);
-
- var handler = new FwlibFrameHandler(backend, Log.Logger);
- server.RunAsync(handler, cts.Token).GetAwaiter().GetResult();
-
- Log.Information("OtOpcUaFocasHost stopped cleanly");
- return 0;
- }
- catch (Exception ex)
- {
- Log.Fatal(ex, "OtOpcUaFocasHost fatal");
- return 2;
- }
- finally { Log.CloseAndFlush(); }
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Stability/PostMortemMmf.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Stability/PostMortemMmf.cs
deleted file mode 100644
index 90e7e9b..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Stability/PostMortemMmf.cs
+++ /dev/null
@@ -1,133 +0,0 @@
-using System;
-using System.IO;
-using System.IO.MemoryMappedFiles;
-using System.Text;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Stability;
-
-///
-/// Ring-buffer of the last N IPC operations, written into a memory-mapped file. On a
-/// hard crash the Proxy-side supervisor reads the MMF after the corpse is gone to see
-/// what was in flight at the moment the Host died. Single-writer (the Host), multi-reader
-/// (the supervisor) — the file format is identical to the Galaxy Tier-C
-/// PostMortemMmf so a single reader tool can work both.
-///
-///
-/// File layout:
-///
-/// [16-byte header: magic(4) | version(4) | capacity(4) | writeIndex(4)]
-/// [capacity × 256-byte entries: each is [8-byte utcUnixMs | 8-byte opKind | 240-byte UTF-8 message]]
-///
-/// Magic is 'OFPC' (0x4F46_5043) to distinguish a FOCAS file from the Galaxy MMF.
-///
-public sealed class PostMortemMmf : IDisposable
-{
- private const int Magic = 0x4F465043; // 'OFPC'
- private const int Version = 1;
- private const int HeaderBytes = 16;
- public const int EntryBytes = 256;
- private const int MessageOffset = 16;
- private const int MessageCapacity = EntryBytes - MessageOffset;
-
- public int Capacity { get; }
- public string Path { get; }
-
- private readonly MemoryMappedFile _mmf;
- private readonly MemoryMappedViewAccessor _accessor;
- private readonly object _writeGate = new();
-
- public PostMortemMmf(string path, int capacity = 1000)
- {
- if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity));
- Capacity = capacity;
- Path = path;
-
- var fileBytes = HeaderBytes + capacity * EntryBytes;
- Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!);
-
- var fs = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read);
- fs.SetLength(fileBytes);
- _mmf = MemoryMappedFile.CreateFromFile(fs, null, fileBytes,
- MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, leaveOpen: false);
- _accessor = _mmf.CreateViewAccessor(0, fileBytes, MemoryMappedFileAccess.ReadWrite);
-
- if (_accessor.ReadInt32(0) != Magic)
- {
- _accessor.Write(0, Magic);
- _accessor.Write(4, Version);
- _accessor.Write(8, capacity);
- _accessor.Write(12, 0);
- }
- }
-
- public void Write(long opKind, string message)
- {
- lock (_writeGate)
- {
- var idx = _accessor.ReadInt32(12);
- var offset = HeaderBytes + idx * EntryBytes;
-
- _accessor.Write(offset + 0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
- _accessor.Write(offset + 8, opKind);
-
- var msgBytes = Encoding.UTF8.GetBytes(message ?? string.Empty);
- var copy = Math.Min(msgBytes.Length, MessageCapacity - 1);
- _accessor.WriteArray(offset + MessageOffset, msgBytes, 0, copy);
- _accessor.Write(offset + MessageOffset + copy, (byte)0);
-
- var next = (idx + 1) % Capacity;
- _accessor.Write(12, next);
- }
- }
-
- public PostMortemEntry[] ReadAll()
- {
- var magic = _accessor.ReadInt32(0);
- if (magic != Magic) return new PostMortemEntry[0];
-
- var capacity = _accessor.ReadInt32(8);
- var writeIndex = _accessor.ReadInt32(12);
-
- var entries = new PostMortemEntry[capacity];
- var count = 0;
- for (var i = 0; i < capacity; i++)
- {
- var slot = (writeIndex + i) % capacity;
- var offset = HeaderBytes + slot * EntryBytes;
-
- var ts = _accessor.ReadInt64(offset + 0);
- if (ts == 0) continue;
-
- var op = _accessor.ReadInt64(offset + 8);
- var msgBuf = new byte[MessageCapacity];
- _accessor.ReadArray(offset + MessageOffset, msgBuf, 0, MessageCapacity);
- var nulTerm = Array.IndexOf(msgBuf, 0);
- var msg = Encoding.UTF8.GetString(msgBuf, 0, nulTerm < 0 ? MessageCapacity : nulTerm);
-
- entries[count++] = new PostMortemEntry(ts, op, msg);
- }
-
- Array.Resize(ref entries, count);
- return entries;
- }
-
- public void Dispose()
- {
- _accessor.Dispose();
- _mmf.Dispose();
- }
-}
-
-public readonly struct PostMortemEntry
-{
- public long UtcUnixMs { get; }
- public long OpKind { get; }
- public string Message { get; }
-
- public PostMortemEntry(long utcUnixMs, long opKind, string message)
- {
- UtcUnixMs = utcUnixMs;
- OpKind = opKind;
- Message = message;
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.csproj
deleted file mode 100644
index b9682f6..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.csproj
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
- Exe
- net48
-
- x86
- true
- enable
- latest
- true
- true
- $(NoWarn);CS1591
- ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host
- OtOpcUa.Driver.FOCAS.Host
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Addresses.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Addresses.cs
deleted file mode 100644
index ca443a1..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Addresses.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using MessagePack;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
-
-///
-/// Wire shape for a parsed FOCAS address. Mirrors FocasAddress in the driver
-/// package but lives in Shared so the Host (.NET 4.8) can decode without taking a
-/// reference to the .NET 10 driver assembly. The Proxy serializes from its own
-/// FocasAddress; the Host maps back to its local equivalent.
-///
-[MessagePackObject]
-public sealed class FocasAddressDto
-{
- /// 0 = Pmc, 1 = Parameter, 2 = Macro. Matches FocasAreaKind enum order.
- [Key(0)] public int Kind { get; set; }
-
- /// PMC letter — null for Parameter / Macro.
- [Key(1)] public string? PmcLetter { get; set; }
-
- [Key(2)] public int Number { get; set; }
-
- /// Optional bit index (0-7 for PMC, 0-31 for Parameter).
- [Key(3)] public int? BitIndex { get; set; }
-}
-
-///
-/// 0 = Bit, 1 = Byte, 2 = Int16, 3 = Int32, 4 = Float32, 5 = Float64, 6 = String.
-/// Matches FocasDataType enum order so both sides can cast (int).
-///
-public static class FocasDataTypeCode
-{
- public const int Bit = 0;
- public const int Byte = 1;
- public const int Int16 = 2;
- public const int Int32 = 3;
- public const int Float32 = 4;
- public const int Float64 = 5;
- public const int String = 6;
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Framing.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Framing.cs
deleted file mode 100644
index fe2b979..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Framing.cs
+++ /dev/null
@@ -1,57 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
-
-///
-/// Length-prefixed framing. Each IPC frame is:
-/// [4-byte big-endian length][1-byte message kind][MessagePack body].
-/// Length is the body size only; the kind byte is not part of the prefixed length.
-/// Mirrors the Galaxy Tier-C framing so operators see one wire format across hosts.
-///
-public static class Framing
-{
- public const int LengthPrefixSize = 4;
- public const int KindByteSize = 1;
-
- ///
- /// Maximum permitted body length (16 MiB). Protects the receiver from a hostile or
- /// misbehaving peer sending an oversized length prefix.
- ///
- public const int MaxFrameBodyBytes = 16 * 1024 * 1024;
-}
-
-///
-/// Wire identifier for each contract. Values are stable — new contracts append, never
-/// reuse. Ranges kept aligned with Galaxy so an operator reading a hex dump doesn't have
-/// to context-switch between drivers.
-///
-public enum FocasMessageKind : byte
-{
- Hello = 0x01,
- HelloAck = 0x02,
- Heartbeat = 0x03,
- HeartbeatAck = 0x04,
-
- OpenSessionRequest = 0x10,
- OpenSessionResponse = 0x11,
- CloseSessionRequest = 0x12,
-
- ReadRequest = 0x30,
- ReadResponse = 0x31,
- WriteRequest = 0x32,
- WriteResponse = 0x33,
- PmcBitWriteRequest = 0x34,
- PmcBitWriteResponse = 0x35,
-
- SubscribeRequest = 0x40,
- SubscribeResponse = 0x41,
- UnsubscribeRequest = 0x42,
- OnDataChangeNotification = 0x43,
-
- ProbeRequest = 0x70,
- ProbeResponse = 0x71,
- RuntimeStatusChange = 0x72,
-
- RecycleHostRequest = 0xF0,
- RecycleStatusResponse = 0xF1,
-
- ErrorResponse = 0xFE,
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Hello.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Hello.cs
deleted file mode 100644
index 716bb1a..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Hello.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-using MessagePack;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
-
-///
-/// First frame of every FOCAS Proxy -> Host connection. Advertises protocol major/minor
-/// and the per-process shared secret the Proxy passed to the Host at spawn time. Major
-/// mismatch is fatal; minor is advisory.
-///
-[MessagePackObject]
-public sealed class Hello
-{
- public const int CurrentMajor = 1;
- public const int CurrentMinor = 0;
-
- [Key(0)] public int ProtocolMajor { get; set; } = CurrentMajor;
- [Key(1)] public int ProtocolMinor { get; set; } = CurrentMinor;
- [Key(2)] public string PeerName { get; set; } = string.Empty;
-
- ///
- /// Per-process shared secret verified on the Host side against the value passed by the
- /// supervisor at spawn time. Protects against a local attacker connecting to the pipe
- /// after authenticating via the pipe ACL.
- ///
- [Key(3)] public string SharedSecret { get; set; } = string.Empty;
-
- [Key(4)] public string[] Features { get; set; } = System.Array.Empty();
-}
-
-[MessagePackObject]
-public sealed class HelloAck
-{
- [Key(0)] public int ProtocolMajor { get; set; } = Hello.CurrentMajor;
- [Key(1)] public int ProtocolMinor { get; set; } = Hello.CurrentMinor;
-
- /// True if the Host accepted the hello; false + filled if not.
- [Key(2)] public bool Accepted { get; set; }
- [Key(3)] public string? RejectReason { get; set; }
-
- [Key(4)] public string HostName { get; set; } = string.Empty;
-}
-
-[MessagePackObject]
-public sealed class Heartbeat
-{
- [Key(0)] public long MonotonicTicks { get; set; }
-}
-
-[MessagePackObject]
-public sealed class HeartbeatAck
-{
- [Key(0)] public long MonotonicTicks { get; set; }
- [Key(1)] public long HostUtcUnixMs { get; set; }
-}
-
-[MessagePackObject]
-public sealed class ErrorResponse
-{
- /// Stable symbolic code — e.g. InvalidAddress, SessionNotFound, Fwlib32Crashed.
- [Key(0)] public string Code { get; set; } = string.Empty;
-
- [Key(1)] public string Message { get; set; } = string.Empty;
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Probe.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Probe.cs
deleted file mode 100644
index 151f9d7..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Probe.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-using MessagePack;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
-
-/// Lightweight connectivity probe — maps to cnc_rdcncstat on the Host.
-[MessagePackObject]
-public sealed class ProbeRequest
-{
- [Key(0)] public long SessionId { get; set; }
- [Key(1)] public int TimeoutMs { get; set; } = 2000;
-}
-
-[MessagePackObject]
-public sealed class ProbeResponse
-{
- [Key(0)] public bool Healthy { get; set; }
- [Key(1)] public string? Error { get; set; }
- [Key(2)] public long ObservedAtUtcUnixMs { get; set; }
-}
-
-/// Per-host runtime status — fan-out target when the Host observes the CNC going unreachable without the Proxy asking.
-[MessagePackObject]
-public sealed class RuntimeStatusChangeNotification
-{
- [Key(0)] public long SessionId { get; set; }
-
- /// Running | Stopped | Unknown.
- [Key(1)] public string RuntimeStatus { get; set; } = string.Empty;
-
- [Key(2)] public long ObservedAtUtcUnixMs { get; set; }
-}
-
-[MessagePackObject]
-public sealed class RecycleHostRequest
-{
- /// Soft | Hard. Soft drains subscriptions first; Hard kills immediately.
- [Key(0)] public string Kind { get; set; } = "Soft";
- [Key(1)] public string Reason { get; set; } = string.Empty;
-}
-
-[MessagePackObject]
-public sealed class RecycleStatusResponse
-{
- [Key(0)] public bool Accepted { get; set; }
- [Key(1)] public int GraceSeconds { get; set; } = 15;
- [Key(2)] public string? Error { get; set; }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/ReadWrite.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/ReadWrite.cs
deleted file mode 100644
index 3f3e7ae..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/ReadWrite.cs
+++ /dev/null
@@ -1,85 +0,0 @@
-using MessagePack;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
-
-///
-/// Read one FOCAS address. Multi-read is the Proxy's responsibility — it batches
-/// per-tag reads into parallel frames the Host services on its
-/// STA thread. Keeping the IPC read single-address keeps the Host side trivial; FOCAS
-/// itself has no multi-read primitive that spans area kinds.
-///
-[MessagePackObject]
-public sealed class ReadRequest
-{
- [Key(0)] public long SessionId { get; set; }
- [Key(1)] public FocasAddressDto Address { get; set; } = new();
- [Key(2)] public int DataType { get; set; }
- [Key(3)] public int TimeoutMs { get; set; } = 2000;
-}
-
-[MessagePackObject]
-public sealed class ReadResponse
-{
- [Key(0)] public bool Success { get; set; }
- [Key(1)] public string? Error { get; set; }
-
- /// OPC UA status code mapped by the Host via FocasStatusMapper — 0 = Good.
- [Key(2)] public uint StatusCode { get; set; }
-
- /// MessagePack-serialized boxed value. null when is false.
- [Key(3)] public byte[]? ValueBytes { get; set; }
-
- /// Matches so the Proxy knows how to deserialize.
- [Key(4)] public int ValueTypeCode { get; set; }
-
- [Key(5)] public long SourceTimestampUtcUnixMs { get; set; }
-}
-
-[MessagePackObject]
-public sealed class WriteRequest
-{
- [Key(0)] public long SessionId { get; set; }
- [Key(1)] public FocasAddressDto Address { get; set; } = new();
- [Key(2)] public int DataType { get; set; }
- [Key(3)] public byte[]? ValueBytes { get; set; }
- [Key(4)] public int ValueTypeCode { get; set; }
- [Key(5)] public int TimeoutMs { get; set; } = 2000;
-}
-
-[MessagePackObject]
-public sealed class WriteResponse
-{
- [Key(0)] public bool Success { get; set; }
- [Key(1)] public string? Error { get; set; }
-
- /// OPC UA status code — 0 = Good.
- [Key(2)] public uint StatusCode { get; set; }
-}
-
-///
-/// PMC bit read-modify-write. Handled as a first-class operation (not two separate
-/// read+write round-trips) so the critical section stays on the Host — serializing
-/// concurrent bit writers to the same parent byte is Host-side via
-/// SemaphoreSlim keyed on (PmcLetter, Number). Mirrors the in-process
-/// pattern from FocasPmcBitRmw.
-///
-[MessagePackObject]
-public sealed class PmcBitWriteRequest
-{
- [Key(0)] public long SessionId { get; set; }
- [Key(1)] public FocasAddressDto Address { get; set; } = new();
-
- /// The bit index to set/clear. 0-7.
- [Key(2)] public int BitIndex { get; set; }
-
- [Key(3)] public bool Value { get; set; }
- [Key(4)] public int TimeoutMs { get; set; } = 2000;
-}
-
-[MessagePackObject]
-public sealed class PmcBitWriteResponse
-{
- [Key(0)] public bool Success { get; set; }
- [Key(1)] public string? Error { get; set; }
- [Key(2)] public uint StatusCode { get; set; }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Session.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Session.cs
deleted file mode 100644
index fad9126..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Session.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-using MessagePack;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
-
-///
-/// Open a FOCAS session against the CNC at . One session per
-/// configured device. The Host owns the Fwlib32 handle; the Proxy tracks only the
-/// opaque returned on success.
-///
-[MessagePackObject]
-public sealed class OpenSessionRequest
-{
- [Key(0)] public string HostAddress { get; set; } = string.Empty;
- [Key(1)] public int TimeoutMs { get; set; } = 2000;
- [Key(2)] public int CncSeries { get; set; }
-}
-
-[MessagePackObject]
-public sealed class OpenSessionResponse
-{
- [Key(0)] public bool Success { get; set; }
- [Key(1)] public long SessionId { get; set; }
- [Key(2)] public string? Error { get; set; }
- [Key(3)] public string? ErrorCode { get; set; }
-}
-
-[MessagePackObject]
-public sealed class CloseSessionRequest
-{
- [Key(0)] public long SessionId { get; set; }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Subscriptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Subscriptions.cs
deleted file mode 100644
index 9417d2b..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Subscriptions.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-using MessagePack;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
-
-///
-/// Subscribe the Host to polling a set of tags on behalf of the Proxy. FOCAS is
-/// poll-only — there are no CNC-initiated callbacks — so the Host runs the poll loop and
-/// pushes frames whenever a value differs from
-/// the last observation. Delta-only + per-group interval keeps the wire quiet.
-///
-[MessagePackObject]
-public sealed class SubscribeRequest
-{
- [Key(0)] public long SessionId { get; set; }
- [Key(1)] public long SubscriptionId { get; set; }
- [Key(2)] public int IntervalMs { get; set; } = 1000;
- [Key(3)] public SubscribeItem[] Items { get; set; } = System.Array.Empty();
-}
-
-[MessagePackObject]
-public sealed class SubscribeItem
-{
- /// Opaque correlation id the Proxy uses to route notifications back to the right OPC UA MonitoredItem.
- [Key(0)] public long MonitoredItemId { get; set; }
-
- [Key(1)] public FocasAddressDto Address { get; set; } = new();
- [Key(2)] public int DataType { get; set; }
-}
-
-[MessagePackObject]
-public sealed class SubscribeResponse
-{
- [Key(0)] public bool Success { get; set; }
- [Key(1)] public string? Error { get; set; }
-
- /// Items the Host refused (address mismatch, unsupported type). Empty on full success.
- [Key(2)] public long[] RejectedMonitoredItemIds { get; set; } = System.Array.Empty();
-}
-
-[MessagePackObject]
-public sealed class UnsubscribeRequest
-{
- [Key(0)] public long SubscriptionId { get; set; }
-}
-
-[MessagePackObject]
-public sealed class OnDataChangeNotification
-{
- [Key(0)] public long SubscriptionId { get; set; }
- [Key(1)] public DataChange[] Changes { get; set; } = System.Array.Empty();
-}
-
-[MessagePackObject]
-public sealed class DataChange
-{
- [Key(0)] public long MonitoredItemId { get; set; }
- [Key(1)] public uint StatusCode { get; set; }
- [Key(2)] public byte[]? ValueBytes { get; set; }
- [Key(3)] public int ValueTypeCode { get; set; }
- [Key(4)] public long SourceTimestampUtcUnixMs { get; set; }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameReader.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameReader.cs
deleted file mode 100644
index 7cb142d..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameReader.cs
+++ /dev/null
@@ -1,67 +0,0 @@
-using System;
-using System.IO;
-using System.Threading;
-using System.Threading.Tasks;
-using MessagePack;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
-
-///
-/// Reads length-prefixed, kind-tagged frames from a stream. Single-consumer — do not call
-/// from multiple threads against the same instance.
-///
-public sealed class FrameReader : IDisposable
-{
- private readonly Stream _stream;
- private readonly bool _leaveOpen;
-
- public FrameReader(Stream stream, bool leaveOpen = false)
- {
- _stream = stream ?? throw new ArgumentNullException(nameof(stream));
- _leaveOpen = leaveOpen;
- }
-
- public async Task<(FocasMessageKind Kind, byte[] Body)?> ReadFrameAsync(CancellationToken ct)
- {
- var lengthPrefix = new byte[Framing.LengthPrefixSize];
- if (!await ReadExactAsync(lengthPrefix, ct).ConfigureAwait(false))
- return null;
-
- var length = (lengthPrefix[0] << 24) | (lengthPrefix[1] << 16) | (lengthPrefix[2] << 8) | lengthPrefix[3];
- if (length < 0 || length > Framing.MaxFrameBodyBytes)
- throw new InvalidDataException($"IPC frame length {length} out of range.");
-
- var kindByte = _stream.ReadByte();
- if (kindByte < 0) throw new EndOfStreamException("EOF after length prefix, before kind byte.");
-
- var body = new byte[length];
- if (!await ReadExactAsync(body, ct).ConfigureAwait(false))
- throw new EndOfStreamException("EOF mid-frame.");
-
- return ((FocasMessageKind)(byte)kindByte, body);
- }
-
- public static T Deserialize(byte[] body) => MessagePackSerializer.Deserialize(body);
-
- private async Task ReadExactAsync(byte[] buffer, CancellationToken ct)
- {
- var offset = 0;
- while (offset < buffer.Length)
- {
- var read = await _stream.ReadAsync(buffer, offset, buffer.Length - offset, ct).ConfigureAwait(false);
- if (read == 0)
- {
- if (offset == 0) return false;
- throw new EndOfStreamException($"Stream ended after reading {offset} of {buffer.Length} bytes.");
- }
- offset += read;
- }
- return true;
- }
-
- public void Dispose()
- {
- if (!_leaveOpen) _stream.Dispose();
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameWriter.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameWriter.cs
deleted file mode 100644
index 76b0b19..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameWriter.cs
+++ /dev/null
@@ -1,56 +0,0 @@
-using System;
-using System.IO;
-using System.Threading;
-using System.Threading.Tasks;
-using MessagePack;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
-
-///
-/// Writes length-prefixed, kind-tagged MessagePack frames to a stream. Thread-safe via
-/// — multiple producers (e.g. heartbeat + data-plane sharing a
-/// stream) get serialized writes.
-///
-public sealed class FrameWriter : IDisposable
-{
- private readonly Stream _stream;
- private readonly SemaphoreSlim _gate = new(1, 1);
- private readonly bool _leaveOpen;
-
- public FrameWriter(Stream stream, bool leaveOpen = false)
- {
- _stream = stream ?? throw new ArgumentNullException(nameof(stream));
- _leaveOpen = leaveOpen;
- }
-
- public async Task WriteAsync(FocasMessageKind kind, T message, CancellationToken ct)
- {
- var body = MessagePackSerializer.Serialize(message, cancellationToken: ct);
- if (body.Length > Framing.MaxFrameBodyBytes)
- throw new InvalidOperationException(
- $"IPC frame body {body.Length} exceeds {Framing.MaxFrameBodyBytes} byte cap.");
-
- var lengthPrefix = new byte[Framing.LengthPrefixSize];
- lengthPrefix[0] = (byte)((body.Length >> 24) & 0xFF);
- lengthPrefix[1] = (byte)((body.Length >> 16) & 0xFF);
- lengthPrefix[2] = (byte)((body.Length >> 8) & 0xFF);
- lengthPrefix[3] = (byte)( body.Length & 0xFF);
-
- await _gate.WaitAsync(ct).ConfigureAwait(false);
- try
- {
- await _stream.WriteAsync(lengthPrefix, 0, lengthPrefix.Length, ct).ConfigureAwait(false);
- _stream.WriteByte((byte)kind);
- await _stream.WriteAsync(body, 0, body.Length, ct).ConfigureAwait(false);
- await _stream.FlushAsync(ct).ConfigureAwait(false);
- }
- finally { _gate.Release(); }
- }
-
- public void Dispose()
- {
- _gate.Dispose();
- if (!_leaveOpen) _stream.Dispose();
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj
deleted file mode 100644
index a154f19..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
- netstandard2.0
- enable
- latest
- true
- true
- $(NoWarn);CS1591
- ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
index b04c422..3b0100f 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// fail fast.
///
public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
- IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
+ IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
{
private readonly FocasDriverOptions _options;
private readonly string _driverInstanceId;
@@ -24,10 +24,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly PollGroupEngine _poll;
private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary _tagsByName = new(StringComparer.OrdinalIgnoreCase);
+ private FocasAlarmProjection? _alarmProjection;
private DriverHealth _health = new(DriverState.Unknown, null, null);
public event EventHandler? OnDataChange;
public event EventHandler? OnHostStatusChanged;
+ public event EventHandler? OnAlarmEvent;
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
IFocasClientFactory? clientFactory = null)
@@ -35,7 +37,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
- _clientFactory = clientFactory ?? new FwlibFocasClientFactory();
+ _clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
@@ -85,6 +87,30 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
}
}
+
+ if (_options.HandleRecycle.Enabled)
+ {
+ foreach (var state in _devices.Values)
+ {
+ state.RecycleCts = new CancellationTokenSource();
+ var ct = state.RecycleCts.Token;
+ _ = Task.Run(() => RecycleLoopAsync(state, ct), ct);
+ }
+ }
+
+ if (_options.AlarmProjection.Enabled)
+ _alarmProjection = new FocasAlarmProjection(this, _options.AlarmProjection.PollInterval);
+
+ if (_options.FixedTree.Enabled)
+ {
+ foreach (var state in _devices.Values)
+ {
+ state.FixedTreeCts = new CancellationTokenSource();
+ var ct = state.FixedTreeCts.Token;
+ _ = Task.Run(() => FixedTreeLoopAsync(state, ct), ct);
+ }
+ }
+
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
}
catch (Exception ex)
@@ -104,11 +130,22 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
await _poll.DisposeAsync().ConfigureAwait(false);
+ if (_alarmProjection is { } proj)
+ {
+ await proj.DisposeAsync().ConfigureAwait(false);
+ _alarmProjection = null;
+ }
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); } catch { }
state.ProbeCts?.Dispose();
state.ProbeCts = null;
+ try { state.RecycleCts?.Cancel(); } catch { }
+ state.RecycleCts?.Dispose();
+ state.RecycleCts = null;
+ try { state.FixedTreeCts?.Cancel(); } catch { }
+ state.FixedTreeCts?.Dispose();
+ state.FixedTreeCts = null;
state.DisposeClient();
}
_devices.Clear();
@@ -136,6 +173,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
for (var i = 0; i < fullReferences.Count; i++)
{
var reference = fullReferences[i];
+
+ // Fixed-tree T1 — fixed-tree references are synthesized from the cached
+ // dynamic snapshot + sysinfo; no P/Invoke per Read since the poll loop
+ // already fires them on cadence.
+ if (_options.FixedTree.Enabled && TryReadFixedTree(reference, now) is { } fx)
+ {
+ results[i] = fx;
+ continue;
+ }
+
if (!_tagsByName.TryGetValue(reference, out var def))
{
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
@@ -241,6 +288,80 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
var label = device.DeviceName ?? device.HostAddress;
var deviceFolder = root.Folder(device.HostAddress, label);
+
+ // Fixed-tree T1 — Identity + Axes subtrees, populated once per session
+ // from cnc_sysinfo + cnc_rdaxisname at init time and kept in DeviceState.
+ if (_options.FixedTree.Enabled
+ && _devices.TryGetValue(device.HostAddress, out var state)
+ && state.FixedTreeCache is { } cache)
+ {
+ var identity = deviceFolder.Folder("Identity", "Identity");
+ EmitIdentityVariable(identity, device.HostAddress, "SeriesNumber", FocasDriverDataType.String);
+ EmitIdentityVariable(identity, device.HostAddress, "Version", FocasDriverDataType.String);
+ EmitIdentityVariable(identity, device.HostAddress, "MaxAxes", FocasDriverDataType.Int32);
+ EmitIdentityVariable(identity, device.HostAddress, "CncType", FocasDriverDataType.String);
+ EmitIdentityVariable(identity, device.HostAddress, "MtType", FocasDriverDataType.String);
+ EmitIdentityVariable(identity, device.HostAddress, "AxisCount", FocasDriverDataType.Int32);
+
+ var axesFolder = deviceFolder.Folder("Axes", "Axes");
+ foreach (var axis in cache.Axes)
+ {
+ var axisFolder = axesFolder.Folder(axis.Display, axis.Display);
+ EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "AbsolutePosition");
+ EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "MachinePosition");
+ EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "RelativePosition");
+ EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "DistanceToGo");
+ if (cache.Capabilities.ServoLoad)
+ EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "ServoLoad");
+ }
+ EmitAxisVariable(axesFolder, device.HostAddress, "FeedRate", "Actual");
+ EmitAxisVariable(axesFolder, device.HostAddress, "SpindleSpeed", "Actual");
+
+ // Spindle subtree — one folder per discovered spindle, suppressed
+ // entirely on series that don't export cnc_rdspdlname. Per-spindle
+ // Load + MaxRpm each gated on their own capability probe.
+ if (cache.Capabilities.Spindles)
+ {
+ var spindleRoot = deviceFolder.Folder("Spindle", "Spindle");
+ for (var i = 0; i < cache.Spindles.Count; i++)
+ {
+ var s = cache.Spindles[i];
+ var name = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
+ var spindleFolder = spindleRoot.Folder(name, name);
+ if (cache.Capabilities.SpindleLoad)
+ EmitFixedVariable(spindleFolder, device.HostAddress, $"Spindle/{name}", "Load", DriverDataType.Int32);
+ if (cache.Capabilities.SpindleMaxRpm && i < cache.SpindleMaxRpms.Count)
+ EmitFixedVariable(spindleFolder, device.HostAddress, $"Spindle/{name}", "MaxRpm", DriverDataType.Int32);
+ }
+ }
+
+ // Fixed-tree T2 — Program + OperationMode subtrees (gated on capability).
+ if (cache.Capabilities.ProgramInfo)
+ {
+ var program = deviceFolder.Folder("Program", "Program");
+ EmitFixedVariable(program, device.HostAddress, "Program", "Name", DriverDataType.String);
+ EmitFixedVariable(program, device.HostAddress, "Program", "ONumber", DriverDataType.Int32);
+ EmitFixedVariable(program, device.HostAddress, "Program", "Number", DriverDataType.Int32);
+ EmitFixedVariable(program, device.HostAddress, "Program", "MainNumber", DriverDataType.Int32);
+ EmitFixedVariable(program, device.HostAddress, "Program", "Sequence", DriverDataType.Int32);
+ EmitFixedVariable(program, device.HostAddress, "Program", "BlockCount", DriverDataType.Int32);
+
+ var opMode = deviceFolder.Folder("OperationMode", "OperationMode");
+ EmitFixedVariable(opMode, device.HostAddress, "OperationMode", "Mode", DriverDataType.Int32);
+ EmitFixedVariable(opMode, device.HostAddress, "OperationMode", "ModeText", DriverDataType.String);
+ }
+
+ // Fixed-tree T3 — Timers subtree (power-on / operating / cutting / cycle).
+ if (cache.Capabilities.Timers)
+ {
+ var timers = deviceFolder.Folder("Timers", "Timers");
+ EmitFixedVariable(timers, device.HostAddress, "Timers", "PowerOnSeconds", DriverDataType.Float64);
+ EmitFixedVariable(timers, device.HostAddress, "Timers", "OperatingSeconds", DriverDataType.Float64);
+ EmitFixedVariable(timers, device.HostAddress, "Timers", "CuttingSeconds", DriverDataType.Float64);
+ EmitFixedVariable(timers, device.HostAddress, "Timers", "CycleSeconds", DriverDataType.Float64);
+ }
+ }
+
var tagsForDevice = _options.Tags.Where(t =>
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
foreach (var tag in tagsForDevice)
@@ -261,6 +382,72 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return Task.CompletedTask;
}
+ private enum FocasDriverDataType { String, Int32, Float64 }
+
+ private static void EmitIdentityVariable(
+ IAddressSpaceBuilder folder, string deviceHost, string field, FocasDriverDataType type)
+ {
+ var fullName = FixedTreeReference(deviceHost, $"Identity/{field}");
+ folder.Variable(field, field, new DriverAttributeInfo(
+ FullName: fullName,
+ DriverDataType: type switch
+ {
+ FocasDriverDataType.Int32 => DriverDataType.Int32,
+ FocasDriverDataType.Float64 => DriverDataType.Float64,
+ _ => DriverDataType.String,
+ },
+ IsArray: false,
+ ArrayDim: null,
+ SecurityClass: SecurityClassification.ViewOnly,
+ IsHistorized: false,
+ IsAlarm: false,
+ WriteIdempotent: false));
+ }
+
+ private static void EmitAxisVariable(
+ IAddressSpaceBuilder folder, string deviceHost, string axisName, string field)
+ {
+ var fullName = FixedTreeReference(deviceHost, $"Axes/{axisName}/{field}");
+ folder.Variable(field, field, new DriverAttributeInfo(
+ FullName: fullName,
+ DriverDataType: DriverDataType.Float64,
+ IsArray: false,
+ ArrayDim: null,
+ SecurityClass: SecurityClassification.ViewOnly,
+ IsHistorized: false,
+ IsAlarm: false,
+ WriteIdempotent: false));
+ }
+
+ ///
+ /// Emit a variable under a named fixed-tree folder (Program, OperationMode,
+ /// …). Full-reference shape is {deviceHost}/{folderPath}/{field}.
+ ///
+ private static void EmitFixedVariable(
+ IAddressSpaceBuilder folder, string deviceHost, string folderPath,
+ string field, DriverDataType type)
+ {
+ var fullName = FixedTreeReference(deviceHost, $"{folderPath}/{field}");
+ folder.Variable(field, field, new DriverAttributeInfo(
+ FullName: fullName,
+ DriverDataType: type,
+ IsArray: false,
+ ArrayDim: null,
+ SecurityClass: SecurityClassification.ViewOnly,
+ IsHistorized: false,
+ IsAlarm: false,
+ WriteIdempotent: false));
+ }
+
+ ///
+ /// Canonical full-reference shape for a fixed-tree node. Keeps the device
+ /// host as a prefix so multi-device configs don't collide, and the rest is
+ /// the path inside the tree. Matches what poll-loop snapshots publish +
+ /// what looks up.
+ ///
+ internal static string FixedTreeReference(string deviceHost, string path) =>
+ $"{deviceHost}/{path}";
+
// ---- ISubscribable (polling overlay via shared engine) ----
public Task SubscribeAsync(
@@ -298,6 +485,310 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
+ ///
+ /// Per-device fixed-tree poll loop. First tick resolves sysinfo + axis names
+ /// (once) so can render the subtree on its next
+ /// invocation; every tick thereafter fires a cnc_rddynamic2 per axis
+ /// and publishes OnDataChange for the axis positions + feed rate + spindle
+ /// speed.
+ ///
+ private async Task FixedTreeLoopAsync(DeviceState state, CancellationToken ct)
+ {
+ // Bootstrap: identity + axis names + per-optional-API capability probe.
+ // Each optional call is attempted once; failures (EW_FUNC / EW_NOOPT / EW_VERSION)
+ // record the capability as unsupported and suppress the corresponding nodes
+ // in DiscoverAsync + the poll loop.
+ while (!ct.IsCancellationRequested && state.FixedTreeCache is null)
+ {
+ try
+ {
+ var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
+ var sys = await client.GetSysInfoAsync(ct).ConfigureAwait(false);
+ var axes = await client.GetAxisNamesAsync(ct).ConfigureAwait(false);
+
+ // Optional-API probes — each returns empty / throws when unsupported.
+ var spindles = await SafeProbe(() => client.GetSpindleNamesAsync(ct), []);
+ var spindleMaxRpms = await SafeProbe(() => client.GetSpindleMaxRpmsAsync(ct), []);
+ var servoLoads = await SafeProbe(() => client.GetServoLoadsAsync(ct), []);
+ var programInfo = await SafeTryProbe(() => client.GetProgramInfoAsync(ct));
+ var timer = await SafeTryProbe(() => client.GetTimerAsync(FocasTimerKind.PowerOn, ct));
+ var spindleLoad = await SafeProbe(() => client.GetSpindleLoadsAsync(ct), []);
+
+ var caps = new FocasFixedTreeCapabilities(
+ Spindles: spindles.Count > 0,
+ SpindleLoad: spindleLoad.Count > 0,
+ SpindleMaxRpm: spindleMaxRpms.Count > 0,
+ ServoLoad: servoLoads.Count > 0,
+ ProgramInfo: programInfo is not null,
+ Timers: timer is not null);
+
+ state.FixedTreeCache = new FocasFixedTreeCache(
+ sys, [.. axes], [.. spindles], [.. spindleMaxRpms], caps);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
+ catch
+ {
+ try { await Task.Delay(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false); }
+ catch (OperationCanceledException) { return; }
+ }
+ }
+
+ // Prime the spindle-loads cache from bootstrap if supported — avoids a
+ // "tree is there but reads say BadNodeIdUnknown" window on startup.
+ if (state.FixedTreeCache?.Capabilities is { SpindleLoad: true })
+ {
+ try
+ {
+ var client2 = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
+ var loads = await client2.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
+ for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
+ }
+ catch { /* first-tick poll will retry */ }
+ }
+
+ var programPollDue = DateTime.MinValue;
+ var timerPollDue = DateTime.MinValue;
+ while (!ct.IsCancellationRequested)
+ {
+ try
+ {
+ var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
+ var cache = state.FixedTreeCache;
+ if (cache is null) break;
+ FocasDynamicSnapshot? firstAxisSnap = null;
+ for (var i = 0; i < cache.Axes.Count; i++)
+ {
+ var axisIndex = i + 1; // FOCAS uses 1-based axis indexing
+ var axis = cache.Axes[i];
+ var snap = await client.ReadDynamicAsync(axisIndex, ct).ConfigureAwait(false);
+ PublishAxisSnapshot(state, axis, snap);
+ if (i == 0) { firstAxisSnap = snap; PublishRateSnapshot(state, snap); }
+ }
+
+ // Servo loads + spindle loads — both return bulk arrays, so folding
+ // into the axis cadence is cheap. Each is gated by the bootstrap
+ // capability probe — unsupported on this series = silent skip.
+ if (cache.Capabilities.ServoLoad)
+ {
+ try
+ {
+ var loads = await client.GetServoLoadsAsync(ct).ConfigureAwait(false);
+ PublishServoLoads(state, loads);
+ }
+ catch { /* transient — next tick retries */ }
+ }
+ if (cache.Capabilities.SpindleLoad)
+ {
+ try
+ {
+ var loads = await client.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
+ for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
+ }
+ catch { /* transient */ }
+ }
+
+ // Program-info poll runs on its own cadence — much slower than the axis
+ // poll because program / mode transitions are operator-driven.
+ var programInterval = _options.FixedTree.ProgramPollInterval;
+ if (cache.Capabilities.ProgramInfo
+ && programInterval > TimeSpan.Zero && DateTime.UtcNow >= programPollDue)
+ {
+ try
+ {
+ var program = await client.GetProgramInfoAsync(ct).ConfigureAwait(false);
+ state.LastProgramInfo = program;
+ if (firstAxisSnap is { } s) state.LastProgramAxisRef = s;
+ }
+ catch { /* transient — next tick retries */ }
+ programPollDue = DateTime.UtcNow + programInterval;
+ }
+
+ // Timers — slowest cadence. Fires 4 FWLIB calls per tick (one per kind).
+ var timerInterval = _options.FixedTree.TimerPollInterval;
+ if (cache.Capabilities.Timers
+ && timerInterval > TimeSpan.Zero && DateTime.UtcNow >= timerPollDue)
+ {
+ foreach (FocasTimerKind kind in Enum.GetValues())
+ {
+ try
+ {
+ var t = await client.GetTimerAsync(kind, ct).ConfigureAwait(false);
+ state.LastTimers[kind] = t;
+ }
+ catch { /* per-kind failures are non-fatal */ }
+ }
+ timerPollDue = DateTime.UtcNow + timerInterval;
+ }
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
+ catch { /* next tick retries — transient blips are expected */ }
+
+ try { await Task.Delay(_options.FixedTree.PollInterval, ct).ConfigureAwait(false); }
+ catch (OperationCanceledException) { break; }
+ }
+ }
+
+ ///
+ /// Cache a fresh axis snapshot. The poll loop doesn't fire OnDataChange
+ /// directly — subscribers go through the normal SubscribeAsync →
+ /// → path, which hits
+ /// and returns these cached values.
+ ///
+ private static void PublishAxisSnapshot(DeviceState state, FocasAxisName axis, FocasDynamicSnapshot snap)
+ {
+ var host = state.Options.HostAddress;
+ state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/AbsolutePosition")] = snap.AbsolutePosition;
+ state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/MachinePosition")] = snap.MachinePosition;
+ state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/RelativePosition")] = snap.RelativePosition;
+ state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/DistanceToGo")] = snap.DistanceToGo;
+ }
+
+ private static void PublishRateSnapshot(DeviceState state, FocasDynamicSnapshot snap)
+ {
+ var host = state.Options.HostAddress;
+ state.LastFixedSnapshots[FixedTreeReference(host, "Axes/FeedRate/Actual")] = snap.ActualFeedRate;
+ state.LastFixedSnapshots[FixedTreeReference(host, "Axes/SpindleSpeed/Actual")] = snap.ActualSpindleSpeed;
+ }
+
+ ///
+ /// Cache servo-load percentages keyed by axis name. Stored separately from
+ /// LastFixedSnapshots (which is int-typed) so the double-valued load
+ /// values don't need casting on every read.
+ ///
+ private static void PublishServoLoads(DeviceState state, IReadOnlyList loads)
+ {
+ foreach (var load in loads)
+ state.LastServoLoads[load.AxisName] = load.LoadPercent;
+ }
+
+ private static object? TimerValue(DeviceState state, FocasTimerKind kind) =>
+ state.LastTimers.TryGetValue(kind, out var t) ? (object)t.TotalSeconds : null;
+
+ ///
+ /// Call an optional probe that returns a collection; swallow any exception
+ /// and return . Used by bootstrap to capture
+ /// per-series capability without letting one failed probe take down the
+ /// entire bootstrap sequence.
+ ///
+ private static async Task> SafeProbe(
+ Func>> probe, IReadOnlyList fallback)
+ {
+ try { return await probe().ConfigureAwait(false); }
+ catch { return fallback; }
+ }
+
+ ///
+ /// Nullable variant — probe returns a single object or null on failure.
+ ///
+ private static async Task SafeTryProbe(Func> probe) where T : class
+ {
+ try { return await probe().ConfigureAwait(false); }
+ catch { return null; }
+ }
+
+ ///
+ /// Read cached last-fixed-tree snapshots. Returns the projected value when
+ /// the reference looks like a fixed-tree FullName; null when it doesn't
+ /// (callers fall through to the user-authored tag path).
+ ///
+ private DataValueSnapshot? TryReadFixedTree(string reference, DateTime now)
+ {
+ foreach (var state in _devices.Values)
+ {
+ if (!reference.StartsWith(state.Options.HostAddress + "/", StringComparison.OrdinalIgnoreCase)) continue;
+ if (state.LastFixedSnapshots.TryGetValue(reference, out var raw))
+ return new DataValueSnapshot((double)raw, FocasStatusMapper.Good, now, now);
+
+ // Servo-load match: reference shape is "{host}/Axes/{name}/ServoLoad"
+ var suffixFull = reference[(state.Options.HostAddress.Length + 1)..];
+ if (suffixFull.StartsWith("Axes/", StringComparison.Ordinal) && suffixFull.EndsWith("/ServoLoad", StringComparison.Ordinal))
+ {
+ var axisName = suffixFull["Axes/".Length..^"/ServoLoad".Length];
+ if (state.LastServoLoads.TryGetValue(axisName, out var load))
+ return new DataValueSnapshot(load, FocasStatusMapper.Good, now, now);
+ }
+
+ // Spindle matches: "{host}/Spindle/{name}/Load" + "{host}/Spindle/{name}/MaxRpm"
+ if (suffixFull.StartsWith("Spindle/", StringComparison.Ordinal)
+ && state.FixedTreeCache is { } spindleCache)
+ {
+ var tail = suffixFull["Spindle/".Length..];
+ var slash = tail.IndexOf('/');
+ if (slash > 0)
+ {
+ var spindleName = tail[..slash];
+ var field = tail[(slash + 1)..];
+ var idx = -1;
+ for (var i = 0; i < spindleCache.Spindles.Count; i++)
+ {
+ var s = spindleCache.Spindles[i];
+ var display = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
+ if (string.Equals(display, spindleName, StringComparison.OrdinalIgnoreCase)) { idx = i; break; }
+ }
+ if (idx >= 0)
+ {
+ object? value = field switch
+ {
+ "Load" => state.LastSpindleLoads.TryGetValue(idx, out var l) ? (object)l : null,
+ "MaxRpm" => idx < spindleCache.SpindleMaxRpms.Count ? (object)spindleCache.SpindleMaxRpms[idx] : null,
+ _ => null,
+ };
+ if (value is not null)
+ return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
+ }
+ }
+ }
+ // Identity strings + program / op-mode fields aren't cached as doubles —
+ // re-derive from the struct caches.
+ if (state.FixedTreeCache is { } cache)
+ {
+ var suffix = reference[(state.Options.HostAddress.Length + 1)..];
+ var value = suffix switch
+ {
+ "Identity/SeriesNumber" => (object)cache.SysInfo.Series,
+ "Identity/Version" => cache.SysInfo.Version,
+ "Identity/MaxAxes" => cache.SysInfo.MaxAxis,
+ "Identity/CncType" => cache.SysInfo.CncType,
+ "Identity/MtType" => cache.SysInfo.MtType,
+ "Identity/AxisCount" => cache.SysInfo.AxesCount,
+ "Program/Name" => (object?)state.LastProgramInfo?.Name,
+ "Program/ONumber" => state.LastProgramInfo?.ONumber,
+ "Program/BlockCount" => state.LastProgramInfo?.BlockCount,
+ "Program/Number" => state.LastProgramAxisRef?.ProgramNumber,
+ "Program/MainNumber" => state.LastProgramAxisRef?.MainProgramNumber,
+ "Program/Sequence" => state.LastProgramAxisRef?.SequenceNumber,
+ "OperationMode/Mode" => state.LastProgramInfo?.Mode,
+ "OperationMode/ModeText" => state.LastProgramInfo is { } pi
+ ? FocasOpMode.ToText(pi.Mode) : null,
+ "Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn),
+ "Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating),
+ "Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting),
+ "Timers/CycleSeconds" => TimerValue(state, FocasTimerKind.Cycle),
+ _ => null,
+ };
+ if (value is not null)
+ return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
+ }
+ }
+ return null;
+ }
+
+ private async Task RecycleLoopAsync(DeviceState state, CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ try { await Task.Delay(_options.HandleRecycle.Interval, ct).ConfigureAwait(false); }
+ catch (OperationCanceledException) { break; }
+
+ // Close the current handle — the next Read / Write / Probe call triggers
+ // EnsureConnectedAsync, which reopens a fresh one. We don't block here on
+ // reconnect because the goal is just to release the FWLIB handle slot; a
+ // readable tick one probe cycle later is an acceptable cost.
+ try { state.DisposeClient(); }
+ catch { /* already disposed or race — next EnsureConnected recovers */ }
+ }
+ }
+
private void TransitionDeviceState(DeviceState state, HostState newState)
{
HostState old;
@@ -312,6 +803,50 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
}
+ // ---- IAlarmSource ----
+
+ public Task SubscribeAlarmsAsync(
+ IReadOnlyList sourceNodeIds, CancellationToken cancellationToken)
+ {
+ if (_alarmProjection is null)
+ throw new NotSupportedException(
+ "FOCAS alarm projection is disabled — set FocasDriverOptions.AlarmProjection.Enabled=true to opt in.");
+ return _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken);
+ }
+
+ public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
+ _alarmProjection is { } p ? p.UnsubscribeAsync(handle, cancellationToken) : Task.CompletedTask;
+
+ public Task AcknowledgeAsync(
+ IReadOnlyList acknowledgements, CancellationToken cancellationToken) =>
+ _alarmProjection is { } p ? p.AcknowledgeAsync(acknowledgements, cancellationToken) : Task.CompletedTask;
+
+ internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
+
+ ///
+ /// Poll every configured device's active-alarm list in one pass. Used by the alarm
+ /// projection — kept internal rather than public because callers that
+ /// want alarm events should subscribe through IAlarmSource instead.
+ ///
+ internal async Task Alarms)>>
+ ReadActiveAlarmsAcrossDevicesAsync(HashSet? deviceFilter, CancellationToken ct)
+ {
+ var result = new List<(string, IReadOnlyList)>();
+ foreach (var state in _devices.Values)
+ {
+ if (deviceFilter is not null && !deviceFilter.Contains(state.Options.HostAddress)) continue;
+ try
+ {
+ var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
+ var alarms = await client.ReadAlarmsAsync(ct).ConfigureAwait(false);
+ result.Add((state.Options.HostAddress, alarms));
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
+ catch { /* surface a device-local fault on the next tick */ }
+ }
+ return result;
+ }
+
// ---- IPerCallHostResolver ----
public string ResolveHost(string fullReference)
@@ -341,6 +876,33 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
+ ///
+ /// Per-device fixed-tree cache populated once at first successful connect and
+ /// read-only thereafter. Used by to render the
+ /// tree + by for synchronous Identity/* reads.
+ ///
+ internal sealed record FocasFixedTreeCache(
+ FocasSysInfo SysInfo,
+ IReadOnlyList Axes,
+ IReadOnlyList Spindles,
+ IReadOnlyList SpindleMaxRpms,
+ FocasFixedTreeCapabilities Capabilities);
+
+ ///
+ /// Per-device optional-API capability flags — which of the "this may or may not
+ /// exist on this CNC series" calls succeeded at bootstrap. Drives per-series
+ /// node suppression so a 16i that doesn't export cnc_rdspmaxrpm simply
+ /// doesn't get a Spindle/{name}/MaxRpm node (instead of surfacing
+ /// BadDeviceFailure on every read).
+ ///
+ internal sealed record FocasFixedTreeCapabilities(
+ bool Spindles, // cnc_rdspdlname returned 1+ spindle names
+ bool SpindleLoad, // cnc_rdspload bootstrap probe succeeded
+ bool SpindleMaxRpm, // cnc_rdspmaxrpm bootstrap probe succeeded
+ bool ServoLoad, // cnc_rdsvmeter bootstrap probe returned data
+ bool ProgramInfo, // cnc_exeprgname2 + cnc_rdblkcount + cnc_rdopmode work
+ bool Timers); // cnc_rdtimer works for at least PowerOn
+
internal sealed class DeviceState(FocasHostAddress parsedAddress, FocasDeviceOptions options)
{
public FocasHostAddress ParsedAddress { get; } = parsedAddress;
@@ -351,6 +913,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public HostState HostState { get; set; } = HostState.Unknown;
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
public CancellationTokenSource? ProbeCts { get; set; }
+ public CancellationTokenSource? RecycleCts { get; set; }
+ public CancellationTokenSource? FixedTreeCts { get; set; }
+ public FocasFixedTreeCache? FixedTreeCache { get; set; }
+ public Dictionary LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
+ public FocasProgramInfo? LastProgramInfo { get; set; }
+ /// Cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence.
+ public FocasDynamicSnapshot? LastProgramAxisRef { get; set; }
+ public Dictionary LastTimers { get; } = [];
+ public Dictionary LastServoLoads { get; } = new(StringComparer.OrdinalIgnoreCase);
+ public Dictionary LastSpindleLoads { get; } = [];
public void DisposeClient()
{
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs
index 7d4bdef..cd18733 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverFactoryExtensions.cs
@@ -1,32 +1,28 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
///
-/// Static factory registration helper for . Server's Program.cs
-/// calls once at startup; the bootstrapper (task #248) then
-/// materialises FOCAS DriverInstance rows from the central config DB into live driver
-/// instances. Mirrors GalaxyProxyDriverFactoryExtensions; no dependency on
-/// Microsoft.Extensions.DependencyInjection so the driver project stays DI-free.
+/// Static factory registration helper for . Server's
+/// Program.cs calls once at startup; the bootstrapper
+/// then materialises FOCAS DriverInstance rows from the central config DB
+/// into live driver instances.
///
///
/// The DriverConfig JSON selects the backend:
///
-/// - "Backend": "ipc" (default) — wires
-/// against a named-pipe talking to a separate
-/// Driver.FOCAS.Host process (Tier-C isolation). Requires PipeName +
-/// SharedSecret.
-/// - "Backend": "fwlib" — direct in-process Fwlib32.dll P/Invoke via
-/// . Use only when the main server is licensed
-/// for FOCAS and you accept the native-crash blast-radius trade-off.
-/// - "Backend": "unimplemented" — returns the no-op factory; useful for
-/// scaffolding DriverInstance rows before the Host is deployed so the server boots.
+/// - "Backend": "wire" (default) — pure-managed FOCAS2 wire
+/// client () speaking directly to
+/// the CNC on TCP:8193.
+/// - "Backend": "unimplemented" / "none" / "stub"
+/// — returns the no-op factory; useful for scaffolding DriverInstance
+/// rows before the CNC endpoint is reachable.
///
-/// Devices / Tags / Probe / Timeout / Series come from the same JSON and feed directly
-/// into .
+/// Devices / Tags / Probe / Timeout / Series come from the same JSON and
+/// feed directly into .
///
public static class FocasDriverFactoryExtensions
{
@@ -92,45 +88,19 @@ public static class FocasDriverFactoryExtensions
internal static IFocasClientFactory BuildClientFactory(
FocasDriverConfigDto dto, string driverInstanceId)
{
- var backend = (dto.Backend ?? "ipc").Trim().ToLowerInvariant();
+ var backend = (dto.Backend ?? "wire").Trim().ToLowerInvariant();
return backend switch
{
- "ipc" => BuildIpcFactory(dto, driverInstanceId),
- "fwlib" or "fwlib32" => new FwlibFocasClientFactory(),
+ "wire" => new WireFocasClientFactory(),
"unimplemented" or "none" or "stub" => new UnimplementedFocasClientFactory(),
_ => throw new InvalidOperationException(
$"FOCAS driver config for '{driverInstanceId}' has unknown Backend '{dto.Backend}'. " +
- "Expected one of: ipc, fwlib, unimplemented."),
+ "Expected one of: wire, unimplemented. " +
+ "(The legacy 'ipc' / 'fwlib' backends were retired in the Wire migration — " +
+ "see docs/drivers/FOCAS.md.)"),
};
}
- private static IpcFocasClientFactory BuildIpcFactory(
- FocasDriverConfigDto dto, string driverInstanceId)
- {
- var pipeName = dto.PipeName
- ?? throw new InvalidOperationException(
- $"FOCAS driver config for '{driverInstanceId}' missing required PipeName (Tier-C ipc backend)");
- var sharedSecret = dto.SharedSecret
- ?? throw new InvalidOperationException(
- $"FOCAS driver config for '{driverInstanceId}' missing required SharedSecret (Tier-C ipc backend)");
- var connectTimeout = TimeSpan.FromMilliseconds(dto.ConnectTimeoutMs ?? 10_000);
- var series = ParseSeries(dto.Series);
-
- // Each IFocasClientFactory.Create() call opens a fresh pipe to the Host — matches the
- // driver's one-client-per-device invariant. FocasIpcClient.ConnectAsync is awaited
- // synchronously via GetAwaiter().GetResult() because IFocasClientFactory.Create is a
- // sync contract; the blocking call lands inside FocasDriver.EnsureConnectedAsync,
- // which immediately awaits IFocasClient.ConnectAsync afterwards so the perceived
- // latency is identical to a fully-async factory.
- return new IpcFocasClientFactory(
- ipcClientFactory: () => FocasIpcClient.ConnectAsync(
- pipeName: pipeName,
- sharedSecret: sharedSecret,
- connectTimeout: connectTimeout,
- ct: CancellationToken.None).GetAwaiter().GetResult(),
- series: series);
- }
-
private static FocasCncSeries ParseSeries(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return FocasCncSeries.Unknown;
@@ -162,9 +132,6 @@ public static class FocasDriverFactoryExtensions
internal sealed class FocasDriverConfigDto
{
public string? Backend { get; init; }
- public string? PipeName { get; init; }
- public string? SharedSecret { get; init; }
- public int? ConnectTimeoutMs { get; init; }
public string? Series { get; init; }
public int? TimeoutMs { get; init; }
public List? Devices { get; init; }
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs
index ce8a042..09b240f 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverOptions.cs
@@ -11,6 +11,77 @@ public sealed class FocasDriverOptions
public IReadOnlyList Tags { get; init; } = [];
public FocasProbeOptions Probe { get; init; } = new();
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
+ public FocasAlarmProjectionOptions AlarmProjection { get; init; } = new();
+ public FocasHandleRecycleOptions HandleRecycle { get; init; } = new();
+ public FocasFixedTreeOptions FixedTree { get; init; } = new();
+}
+
+///
+/// Fixed-node tree exposed by FOCAS per docs/v2/driver-specs.md §7 —
+/// Identity/, Axes/{name}/, etc. populated from
+/// cnc_sysinfo / cnc_rdaxisname / cnc_rddynamic2. Disabled by
+/// default so existing configs that only use user-authored tags don't grow new
+/// nodes on upgrade.
+///
+public sealed class FocasFixedTreeOptions
+{
+ /// Enable the fixed-node tree for every configured device.
+ public bool Enabled { get; init; } = false;
+
+ ///
+ /// Poll cadence for cnc_rddynamic2. Each tick calls the API once per
+ /// configured axis + publishes OnDataChange for the axis subtree. Real CNCs
+ /// serve ~100ms loops comfortably; the default is conservative.
+ ///
+ public TimeSpan PollInterval { get; init; } = TimeSpan.FromMilliseconds(250);
+
+ ///
+ /// Poll cadence for program + operation-mode info. Slower than the axis
+ /// poll because program / mode transitions happen on operator timescales.
+ /// Zero / negative disables the program poll entirely.
+ ///
+ public TimeSpan ProgramPollInterval { get; init; } = TimeSpan.FromSeconds(1);
+
+ ///
+ /// Poll cadence for timers (power-on / operating / cutting / cycle).
+ /// These change at human timescales — default is 30s. Zero / negative
+ /// disables the timer poll entirely.
+ ///
+ public TimeSpan TimerPollInterval { get; init; } = TimeSpan.FromSeconds(30);
+}
+
+///
+/// Proactive session-recycle cadence. Fanuc CNCs have a finite FWLIB handle pool
+/// (~5–10 concurrent connections) and certain series have documented handle-leak bugs
+/// that manifest after long uptime. When is true the
+/// driver closes + reopens each device's session on the cadence,
+/// forcing FWLIB to release its handle slot back to the pool. Reads / writes during
+/// recycle wait for the reconnect rather than failing — worst case an operator sees a
+/// brief read latency spike once per cadence.
+///
+///
+/// Disabled by default because a healthy CNC + driver doesn't need it. Enable when
+/// field experience shows handle exhaustion against a specific series / firmware.
+/// Typical tuning: 30 min for sites running multiple OtOpcUa instances against the
+/// same CNC (they share the pool); 6 h for a single-client deployment.
+///
+public sealed class FocasHandleRecycleOptions
+{
+ public bool Enabled { get; init; } = false;
+ public TimeSpan Interval { get; init; } = TimeSpan.FromHours(1);
+}
+
+///
+/// Controls the CNC active-alarm polling projection that surfaces FOCAS alarms via
+/// IAlarmSource. Disabled by default — operators opt in by setting
+/// in appsettings.json.
+///
+public sealed class FocasAlarmProjectionOptions
+{
+ public bool Enabled { get; init; } = false;
+
+ /// Poll cadence. One cnc_rdalmmsg2 call per device per tick.
+ public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(2);
}
///
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs
deleted file mode 100644
index 63a55fd..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs
+++ /dev/null
@@ -1,328 +0,0 @@
-using System.Buffers.Binary;
-using System.Collections.Concurrent;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
-
-///
-/// implementation backed by Fanuc's licensed
-/// Fwlib32.dll via P/Invoke. The DLL is NOT shipped with
-/// OtOpcUa; the deployment places it next to the server executable or on PATH
-/// (per Fanuc licensing — see docs/v2/focas-deployment.md).
-///
-///
-/// Construction is licence-safe — .NET P/Invoke is lazy, so instantiating this class
-/// does NOT load Fwlib32.dll. The DLL only loads on the first wire call (Connect /
-/// Read / Write / Probe). When missing, those calls throw
-/// which the driver surfaces as BadCommunicationError through the normal exception
-/// mapping.
-///
-/// Session-scoped handle — cnc_allclibhndl3 opens one FWLIB handle per CNC;
-/// all PMC / parameter / macro reads on that device go through the same handle. Dispose
-/// calls cnc_freelibhndl.
-///
-internal sealed class FwlibFocasClient : IFocasClient
-{
- private ushort _handle;
- private bool _connected;
-
- // Per-PMC-byte RMW lock registry. Bit writes to the same byte get serialised so two
- // concurrent bit updates don't lose one another's modification. Key = "{addrType}:{byteAddr}".
- private readonly ConcurrentDictionary _rmwLocks = new();
-
- private SemaphoreSlim GetRmwLock(short addrType, int byteAddr) =>
- _rmwLocks.GetOrAdd($"{addrType}:{byteAddr}", _ => new SemaphoreSlim(1, 1));
-
- public bool IsConnected => _connected;
-
- public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
- {
- if (_connected) return Task.CompletedTask;
-
- var timeoutMs = (int)Math.Max(1, timeout.TotalMilliseconds);
- var ret = FwlibNative.AllcLibHndl3(address.Host, (ushort)address.Port, timeoutMs, out var handle);
- if (ret != 0)
- throw new InvalidOperationException(
- $"FWLIB cnc_allclibhndl3 failed with EW_{ret} connecting to {address}.");
- _handle = handle;
- _connected = true;
- return Task.CompletedTask;
- }
-
- public Task<(object? value, uint status)> ReadAsync(
- FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
- {
- if (!_connected) return Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadCommunicationError));
- cancellationToken.ThrowIfCancellationRequested();
-
- return address.Kind switch
- {
- FocasAreaKind.Pmc => Task.FromResult(ReadPmc(address, type)),
- FocasAreaKind.Parameter => Task.FromResult(ReadParameter(address, type)),
- FocasAreaKind.Macro => Task.FromResult(ReadMacro(address)),
- _ => Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported)),
- };
- }
-
- public async Task WriteAsync(
- FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
- {
- if (!_connected) return FocasStatusMapper.BadCommunicationError;
- cancellationToken.ThrowIfCancellationRequested();
-
- return address.Kind switch
- {
- FocasAreaKind.Pmc when type == FocasDataType.Bit && address.BitIndex is int =>
- await WritePmcBitAsync(address, Convert.ToBoolean(value), cancellationToken).ConfigureAwait(false),
- FocasAreaKind.Pmc => WritePmc(address, type, value),
- FocasAreaKind.Parameter => WriteParameter(address, type, value),
- FocasAreaKind.Macro => WriteMacro(address, value),
- _ => FocasStatusMapper.BadNotSupported,
- };
- }
-
- ///
- /// Read-modify-write one bit within a PMC byte. Acquires a per-byte semaphore so
- /// concurrent bit writes against the same byte serialise and neither loses its update.
- ///
- private async Task WritePmcBitAsync(
- FocasAddress address, bool newValue, CancellationToken cancellationToken)
- {
- var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "") ?? (short)0;
- var bit = address.BitIndex ?? 0;
- if (bit is < 0 or > 7)
- throw new InvalidOperationException(
- $"PMC bit index {bit} out of range (0-7) for {address.Canonical}.");
-
- var rmwLock = GetRmwLock(addrType, address.Number);
- await rmwLock.WaitAsync(cancellationToken).ConfigureAwait(false);
- try
- {
- // Read the parent byte.
- var readBuf = new FwlibNative.IODBPMC { Data = new byte[40] };
- var readRet = FwlibNative.PmcRdPmcRng(
- _handle, addrType, FocasPmcDataType.Byte,
- (ushort)address.Number, (ushort)address.Number, 8 + 1, ref readBuf);
- if (readRet != 0) return FocasStatusMapper.MapFocasReturn(readRet);
-
- var current = readBuf.Data[0];
- var updated = newValue
- ? (byte)(current | (1 << bit))
- : (byte)(current & ~(1 << bit));
-
- // Write the updated byte.
- var writeBuf = new FwlibNative.IODBPMC
- {
- TypeA = addrType,
- TypeD = FocasPmcDataType.Byte,
- DatanoS = (ushort)address.Number,
- DatanoE = (ushort)address.Number,
- Data = new byte[40],
- };
- writeBuf.Data[0] = updated;
-
- var writeRet = FwlibNative.PmcWrPmcRng(_handle, 8 + 1, ref writeBuf);
- return writeRet == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(writeRet);
- }
- finally
- {
- rmwLock.Release();
- }
- }
-
- public Task ProbeAsync(CancellationToken cancellationToken)
- {
- if (!_connected) return Task.FromResult(false);
- var buf = new FwlibNative.ODBST();
- var ret = FwlibNative.StatInfo(_handle, ref buf);
- return Task.FromResult(ret == 0);
- }
-
- // ---- PMC ----
-
- private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)
- {
- var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "")
- ?? throw new InvalidOperationException($"Unknown PMC letter '{address.PmcLetter}'.");
- var dataType = FocasPmcDataType.FromFocasDataType(type);
- var length = PmcReadLength(type);
-
- var buf = new FwlibNative.IODBPMC { Data = new byte[40] };
- var ret = FwlibNative.PmcRdPmcRng(
- _handle, addrType, dataType,
- (ushort)address.Number, (ushort)address.Number, (ushort)length, ref buf);
- if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
-
- var value = type switch
- {
- FocasDataType.Bit => ExtractBit(buf.Data[0], address.BitIndex ?? 0),
- FocasDataType.Byte => (object)(sbyte)buf.Data[0],
- FocasDataType.Int16 => (object)BinaryPrimitives.ReadInt16LittleEndian(buf.Data),
- FocasDataType.Int32 => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
- FocasDataType.Float32 => (object)BinaryPrimitives.ReadSingleLittleEndian(buf.Data),
- FocasDataType.Float64 => (object)BinaryPrimitives.ReadDoubleLittleEndian(buf.Data),
- _ => (object)buf.Data[0],
- };
- return (value, FocasStatusMapper.Good);
- }
-
- private uint WritePmc(FocasAddress address, FocasDataType type, object? value)
- {
- var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "") ?? (short)0;
- var dataType = FocasPmcDataType.FromFocasDataType(type);
- var length = PmcWriteLength(type);
-
- var buf = new FwlibNative.IODBPMC
- {
- TypeA = addrType,
- TypeD = dataType,
- DatanoS = (ushort)address.Number,
- DatanoE = (ushort)address.Number,
- Data = new byte[40],
- };
- EncodePmcValue(buf.Data, type, value, address.BitIndex);
-
- var ret = FwlibNative.PmcWrPmcRng(_handle, (ushort)length, ref buf);
- return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
- }
-
- private (object? value, uint status) ReadParameter(FocasAddress address, FocasDataType type)
- {
- var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
- var length = ParamReadLength(type);
- var ret = FwlibNative.RdParam(_handle, (ushort)address.Number, axis: 0, (short)length, ref buf);
- if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
-
- var value = type switch
- {
- FocasDataType.Bit when address.BitIndex is int bit => ExtractBit(buf.Data[0], bit),
- FocasDataType.Byte => (object)(sbyte)buf.Data[0],
- FocasDataType.Int16 => (object)BinaryPrimitives.ReadInt16LittleEndian(buf.Data),
- FocasDataType.Int32 => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
- _ => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
- };
- return (value, FocasStatusMapper.Good);
- }
-
- private uint WriteParameter(FocasAddress address, FocasDataType type, object? value)
- {
- var buf = new FwlibNative.IODBPSD
- {
- Datano = (short)address.Number,
- Type = 0,
- Data = new byte[32],
- };
- var length = ParamReadLength(type);
- EncodeParamValue(buf.Data, type, value);
- var ret = FwlibNative.WrParam(_handle, (short)length, ref buf);
- return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
- }
-
- private (object? value, uint status) ReadMacro(FocasAddress address)
- {
- var buf = new FwlibNative.ODBM();
- var ret = FwlibNative.RdMacro(_handle, (short)address.Number, length: 8, ref buf);
- if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
-
- // Macro value = mcr_val / 10^dec_val. Convert to double so callers get the correct
- // scaled value regardless of the decimal-point count the CNC reports.
- var scaled = buf.McrVal / Math.Pow(10.0, buf.DecVal);
- return (scaled, FocasStatusMapper.Good);
- }
-
- private uint WriteMacro(FocasAddress address, object? value)
- {
- // Write as integer + 0 decimal places — callers that need decimal precision can extend
- // this via a future WriteMacroScaled overload. Consistent with what most HMIs do today.
- var intValue = Convert.ToInt32(value);
- var ret = FwlibNative.WrMacro(_handle, (short)address.Number, length: 8, intValue, decimalPointCount: 0);
- return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
- }
-
- public void Dispose()
- {
- if (_connected)
- {
- try { FwlibNative.FreeLibHndl(_handle); } catch { }
- _connected = false;
- }
- }
-
- // ---- helpers ----
-
- private static int PmcReadLength(FocasDataType type) => type switch
- {
- FocasDataType.Bit or FocasDataType.Byte => 8 + 1, // 8-byte header + 1 byte payload
- FocasDataType.Int16 => 8 + 2,
- FocasDataType.Int32 => 8 + 4,
- FocasDataType.Float32 => 8 + 4,
- FocasDataType.Float64 => 8 + 8,
- _ => 8 + 1,
- };
-
- private static int PmcWriteLength(FocasDataType type) => PmcReadLength(type);
- private static int ParamReadLength(FocasDataType type) => type switch
- {
- FocasDataType.Bit or FocasDataType.Byte => 4 + 1,
- FocasDataType.Int16 => 4 + 2,
- FocasDataType.Int32 => 4 + 4,
- _ => 4 + 4,
- };
-
- private static bool ExtractBit(byte word, int bit) => (word & (1 << bit)) != 0;
-
- internal static void EncodePmcValue(byte[] data, FocasDataType type, object? value, int? bitIndex)
- {
- switch (type)
- {
- case FocasDataType.Bit:
- // PMC Bit writes with a non-null bitIndex go through WritePmcBitAsync's RMW path
- // upstream. This branch only fires when a caller passes Bit with no bitIndex —
- // treat the value as a whole-byte boolean (non-zero / zero).
- data[0] = Convert.ToBoolean(value) ? (byte)1 : (byte)0;
- break;
- case FocasDataType.Byte:
- data[0] = (byte)(sbyte)Convert.ToSByte(value);
- break;
- case FocasDataType.Int16:
- BinaryPrimitives.WriteInt16LittleEndian(data, Convert.ToInt16(value));
- break;
- case FocasDataType.Int32:
- BinaryPrimitives.WriteInt32LittleEndian(data, Convert.ToInt32(value));
- break;
- case FocasDataType.Float32:
- BinaryPrimitives.WriteSingleLittleEndian(data, Convert.ToSingle(value));
- break;
- case FocasDataType.Float64:
- BinaryPrimitives.WriteDoubleLittleEndian(data, Convert.ToDouble(value));
- break;
- default:
- throw new NotSupportedException($"FocasDataType {type} not writable via PMC.");
- }
- _ = bitIndex; // bit-in-byte handled above
- }
-
- internal static void EncodeParamValue(byte[] data, FocasDataType type, object? value)
- {
- switch (type)
- {
- case FocasDataType.Byte:
- data[0] = (byte)(sbyte)Convert.ToSByte(value);
- break;
- case FocasDataType.Int16:
- BinaryPrimitives.WriteInt16LittleEndian(data, Convert.ToInt16(value));
- break;
- case FocasDataType.Int32:
- BinaryPrimitives.WriteInt32LittleEndian(data, Convert.ToInt32(value));
- break;
- default:
- BinaryPrimitives.WriteInt32LittleEndian(data, Convert.ToInt32(value));
- break;
- }
- }
-}
-
-/// Default — produces a fresh per device.
-public sealed class FwlibFocasClientFactory : IFocasClientFactory
-{
- public IFocasClient Create() => new FwlibFocasClient();
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs
deleted file mode 100644
index 08c2761..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs
+++ /dev/null
@@ -1,190 +0,0 @@
-using System.Runtime.InteropServices;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
-
-///
-/// P/Invoke surface for Fanuc FWLIB (Fwlib32.dll). Declarations extracted from
-/// fwlib32.h in the strangesast/fwlib repo; the licensed DLL itself is NOT shipped
-/// with OtOpcUa — the deployment places Fwlib32.dll next to the server executable
-/// or on PATH.
-///
-///
-/// Deliberately narrow — only the calls actually makes.
-/// FOCAS has 800+ functions in fwlib32.h; pulling in every one would bloat the
-/// P/Invoke surface + signal more coverage than this driver provides. Expand as capabilities
-/// are added.
-///
-internal static class FwlibNative
-{
- private const string Library = "Fwlib32.dll";
-
- // ---- Handle lifetime ----
-
- /// Open an Ethernet FWLIB handle. Returns EW_OK (0) on success; handle written out.
- [DllImport(Library, EntryPoint = "cnc_allclibhndl3", CharSet = CharSet.Ansi, ExactSpelling = true)]
- public static extern short AllcLibHndl3(
- [MarshalAs(UnmanagedType.LPStr)] string ipaddr,
- ushort port,
- int timeout,
- out ushort handle);
-
- [DllImport(Library, EntryPoint = "cnc_freelibhndl", ExactSpelling = true)]
- public static extern short FreeLibHndl(ushort handle);
-
- // ---- PMC ----
-
- /// PMC range read. is the ADR_* enum; is 0 byte / 1 word / 2 long.
- [DllImport(Library, EntryPoint = "pmc_rdpmcrng", ExactSpelling = true)]
- public static extern short PmcRdPmcRng(
- ushort handle,
- short addrType,
- short dataType,
- ushort startNumber,
- ushort endNumber,
- ushort length,
- ref IODBPMC buffer);
-
- [DllImport(Library, EntryPoint = "pmc_wrpmcrng", ExactSpelling = true)]
- public static extern short PmcWrPmcRng(
- ushort handle,
- ushort length,
- ref IODBPMC buffer);
-
- // ---- Parameters ----
-
- [DllImport(Library, EntryPoint = "cnc_rdparam", ExactSpelling = true)]
- public static extern short RdParam(
- ushort handle,
- ushort number,
- short axis,
- short length,
- ref IODBPSD buffer);
-
- [DllImport(Library, EntryPoint = "cnc_wrparam", ExactSpelling = true)]
- public static extern short WrParam(
- ushort handle,
- short length,
- ref IODBPSD buffer);
-
- // ---- Macro variables ----
-
- [DllImport(Library, EntryPoint = "cnc_rdmacro", ExactSpelling = true)]
- public static extern short RdMacro(
- ushort handle,
- short number,
- short length,
- ref ODBM buffer);
-
- [DllImport(Library, EntryPoint = "cnc_wrmacro", ExactSpelling = true)]
- public static extern short WrMacro(
- ushort handle,
- short number,
- short length,
- int macroValue,
- short decimalPointCount);
-
- // ---- Status ----
-
- [DllImport(Library, EntryPoint = "cnc_statinfo", ExactSpelling = true)]
- public static extern short StatInfo(ushort handle, ref ODBST buffer);
-
- // ---- Structs ----
-
- ///
- /// IODBPMC — PMC range I/O buffer. 8-byte header + 40-byte union. We marshal the union
- /// as a fixed byte buffer + interpret per on the managed side.
- ///
- [StructLayout(LayoutKind.Sequential, Pack = 1)]
- public struct IODBPMC
- {
- public short TypeA;
- public short TypeD;
- public ushort DatanoS;
- public ushort DatanoE;
- // 40-byte union: cdata[5] / idata[5] / ldata[5] / fdata[5] / dbdata[5] — dbdata is the widest.
- [MarshalAs(UnmanagedType.ByValArray, SizeConst = 40)]
- public byte[] Data;
- }
-
- ///
- /// IODBPSD — CNC parameter I/O buffer. Axis-aware; for non-axis parameters pass axis=0.
- /// Union payload is bytes / shorts / longs — we marshal 32 bytes as the widest slot.
- ///
- [StructLayout(LayoutKind.Sequential, Pack = 1)]
- public struct IODBPSD
- {
- public short Datano;
- public short Type; // axis index (0 for non-axis)
- [MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)]
- public byte[] Data;
- }
-
- /// ODBM — macro variable read buffer. Value = McrVal / 10^DecVal.
- [StructLayout(LayoutKind.Sequential, Pack = 1)]
- public struct ODBM
- {
- public short Datano;
- public short Dummy;
- public int McrVal; // long in C; 32-bit signed
- public short DecVal; // decimal-point count
- }
-
- /// ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.
- [StructLayout(LayoutKind.Sequential, Pack = 1)]
- public struct ODBST
- {
- public short Dummy;
- public short TmMode;
- public short Aut;
- public short Run;
- public short Motion;
- public short Mstb;
- public short Emergency;
- public short Alarm;
- public short Edit;
- }
-}
-
-///
-/// PMC address-letter → FOCAS ADR_* numeric code. Per Fanuc FOCAS/2 spec the codes
-/// are: G=0, F=1, Y=2, X=3, A=4, R=5, T=6, K=7, C=8, D=9, E=10. Exposed internally +
-/// tested so the FwlibFocasClient translation is verifiable without the DLL loaded.
-///
-internal static class FocasPmcAddrType
-{
- public static short? FromLetter(string letter) => letter.ToUpperInvariant() switch
- {
- "G" => 0,
- "F" => 1,
- "Y" => 2,
- "X" => 3,
- "A" => 4,
- "R" => 5,
- "T" => 6,
- "K" => 7,
- "C" => 8,
- "D" => 9,
- "E" => 10,
- _ => null,
- };
-}
-
-/// PMC data-type numeric codes per FOCAS/2: 0 = byte, 1 = word, 2 = long, 4 = float, 5 = double.
-internal static class FocasPmcDataType
-{
- public const short Byte = 0;
- public const short Word = 1;
- public const short Long = 2;
- public const short Float = 4;
- public const short Double = 5;
-
- public static short FromFocasDataType(FocasDataType t) => t switch
- {
- FocasDataType.Bit or FocasDataType.Byte => Byte,
- FocasDataType.Int16 => Word,
- FocasDataType.Int32 => Long,
- FocasDataType.Float32 => Float,
- FocasDataType.Float64 => Double,
- _ => Byte,
- };
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
index 4c7733a..7e4d7fc 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/IFocasClient.cs
@@ -5,15 +5,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// configured device; lifetime matches the device.
///
///
-/// No default wire implementation ships with this assembly. FWLIB
-/// (Fwlib32.dll) is Fanuc-proprietary and requires a valid customer license — it
-/// cannot legally be redistributed. The deployment team supplies an
-/// that wraps the licensed Fwlib32.dll via
-/// P/Invoke and registers it at server startup.
+/// The default implementation is — a pure-managed
+/// FOCAS/2 Ethernet client that speaks the wire protocol directly on TCP:8193. No
+/// P/Invoke, no native DLLs, no out-of-process isolation.
///
-/// The default throws with a pointer at
-/// the deployment docs so misconfigured servers fail fast with a clear error rather than
-/// mysteriously hanging.
+/// is a scaffolding backend that
+/// throws on — selected by
+/// "Backend": "unimplemented" so a DriverInstance row can be seeded before the CNC
+/// endpoint is reachable without silently reading stale data.
///
public interface IFocasClient : IDisposable
{
@@ -48,8 +47,208 @@ public interface IFocasClient : IDisposable
/// responds with any valid status.
///
Task ProbeAsync(CancellationToken cancellationToken);
+
+ ///
+ /// Read active alarm messages from the CNC via cnc_rdalmmsg2. Returns
+ /// zero-or-more active alarms. Null / empty list means "no alarms currently
+ /// active". IAlarmSource projection polls this at a configurable interval +
+ /// emits transitions (raise / clear) on the driver's OnAlarmEvent.
+ ///
+ Task> ReadAlarmsAsync(CancellationToken cancellationToken);
+
+ // ---- Fixed-tree T1 (identity + axis discovery + fast-poll dynamic bundle) ----
+
+ ///
+ /// Read CNC identity via cnc_sysinfo. Populates the Identity/*
+ /// subtree of the fixed-node surface. Callable once at session open; the
+ /// values don't change across the session.
+ ///
+ Task GetSysInfoAsync(CancellationToken cancellationToken);
+
+ ///
+ /// Read the CNC's configured axis names via cnc_rdaxisname. The driver
+ /// uses these to build the Axes/{name}/ subtree and to index
+ /// calls.
+ ///
+ Task> GetAxisNamesAsync(CancellationToken cancellationToken);
+
+ ///
+ /// Read the CNC's configured spindle names via cnc_rdspdlname. Drives
+ /// the Spindle/{name}/ subtree.
+ ///
+ Task> GetSpindleNamesAsync(CancellationToken cancellationToken);
+
+ ///
+ /// Read the fast-poll dynamic bundle for one axis via cnc_rddynamic2.
+ /// Returns the current position quadruple (absolute / machine / relative /
+ /// distance-to-go) plus actual feed rate + actual spindle speed + alarm
+ /// flags + program / sequence numbers — one network round-trip per call.
+ ///
+ Task ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken);
+
+ // ---- Fixed-tree T2 (program + operation mode) ----
+
+ ///
+ /// Aggregate program + operation-mode snapshot. One wire round-trip per
+ /// underlying FWLIB call — cnc_rdblkcount, cnc_exeprgname2,
+ /// cnc_rdopmode. The driver polls this on a slower cadence than
+ /// since program / mode transitions happen
+ /// on human-operator timescales.
+ ///
+ Task GetProgramInfoAsync(CancellationToken cancellationToken);
+
+ // ---- Fixed-tree T3 (timers) ----
+
+ ///
+ /// Read one CNC cumulative timer. Kind selects PowerOn / Operating / Cutting /
+ /// Cycle. Values are seconds — the managed side already converted the native
+ /// minute+msec representation so downstream nodes display uniform units.
+ ///
+ Task GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken);
+
+ // ---- Fixed-tree T3.5 (servo meters) ----
+
+ ///
+ /// Read the servo-load meter percentages across all configured axes.
+ /// Values are percentages (scaled by 10^Dec). Empty list on a
+ /// disconnected session or unsupported CNC.
+ ///
+ Task> GetServoLoadsAsync(CancellationToken cancellationToken);
+
+ // ---- Fixed-tree T3.6 (spindle meters) ----
+
+ ///
+ /// Read per-spindle load percentages. Result list index corresponds to
+ /// spindle index from . Empty list on a
+ /// disconnected session or when the CNC doesn't support the call (older
+ /// series like 16i may return EW_FUNC).
+ ///
+ Task> GetSpindleLoadsAsync(CancellationToken cancellationToken);
+
+ ///
+ /// Read per-spindle maximum RPM values. Static configuration, fetched once at
+ /// bootstrap. Index alignment as per .
+ ///
+ Task> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken);
}
+/// One servo-meter entry — one axis's current load percentage.
+public sealed record FocasServoLoad(string AxisName, double LoadPercent);
+
+/// Which cumulative counter reads.
+public enum FocasTimerKind
+{
+ /// Machine power-on hours — resets never.
+ PowerOn = 0,
+ /// Cycle operating time — resets when the operator clears the counter.
+ Operating = 1,
+ /// Cutting time — only counts while in cutting feed.
+ Cutting = 2,
+ /// Cycle time since the last program start.
+ Cycle = 3,
+}
+
+/// One cumulative timer reading. is the canonical unit.
+public sealed record FocasTimer(FocasTimerKind Kind, int Minutes, int Milliseconds)
+{
+ /// Cumulative time in seconds — Minutes * 60 + Milliseconds / 1000.
+ public double TotalSeconds => Minutes * 60.0 + Milliseconds / 1000.0;
+}
+
+///
+/// CNC identity snapshot from cnc_sysinfo. Strings are trimmed ASCII.
+///
+public sealed record FocasSysInfo(
+ int AddInfo,
+ int MaxAxis,
+ string CncType, // "M" (mill) / "T" (lathe)
+ string MtType,
+ string Series, // e.g. "30i"
+ string Version, // e.g. "A1.0"
+ int AxesCount);
+
+/// One configured axis name (e.g. "X", "X1").
+public sealed record FocasAxisName(string Name, string Suffix)
+{
+ ///
+ /// Display name — name + suffix concatenated, trimmed. Empty suffix yields
+ /// just the name (the common case on single-channel CNCs).
+ ///
+ public string Display => string.IsNullOrEmpty(Suffix) ? Name : $"{Name}{Suffix}";
+}
+
+/// One configured spindle name (e.g. "S1").
+public sealed record FocasSpindleName(string Name, string Suffix1, string Suffix2, string Suffix3)
+{
+ public string Display
+ {
+ get
+ {
+ var s = Name + Suffix1 + Suffix2 + Suffix3;
+ return s.TrimEnd('\0', ' ');
+ }
+ }
+}
+
+///
+/// Fast-poll bundle for one axis. Position values are scaled integers; the caller
+/// divides by 10^DecimalPlaces to get the decimal value. DecimalPlaces is
+/// currently left to the caller to supply (via device config or a future
+/// cnc_getfigure path once that export lands).
+///
+///
+/// Program + operation-mode snapshot. Name is the currently-executing
+/// program filename (e.g. "O0001.NC"); ONumber is its Fanuc O-number (1-9999).
+/// Mode is the numeric code from cnc_rdopmode — see .
+///
+public sealed record FocasProgramInfo(
+ string Name,
+ int ONumber,
+ int BlockCount,
+ int Mode);
+
+/// Human-readable text for the integer.
+public static class FocasOpMode
+{
+ public static string ToText(int mode) => mode switch
+ {
+ 0 => "MDI",
+ 1 => "AUTO",
+ 2 => "TJOG",
+ 3 => "EDIT",
+ 4 => "HANDLE",
+ 5 => "JOG",
+ 6 => "TEACH_IN_HANDLE",
+ 7 => "REFERENCE",
+ 8 => "REMOTE",
+ 9 => "TEST",
+ _ => $"Mode{mode}",
+ };
+}
+
+public sealed record FocasDynamicSnapshot(
+ int AxisIndex,
+ int AlarmFlags,
+ int ProgramNumber,
+ int MainProgramNumber,
+ int SequenceNumber,
+ int ActualFeedRate,
+ int ActualSpindleSpeed,
+ int AbsolutePosition,
+ int MachinePosition,
+ int RelativePosition,
+ int DistanceToGo);
+
+///
+/// One active alarm surfaced by . Shape
+/// mirrors ODBALMMSG2 but normalises the message bytes to a .NET string.
+///
+public sealed record FocasActiveAlarm(
+ int AlarmNumber,
+ short Type,
+ short Axis,
+ string Message);
+
/// Factory for s. One client per configured device.
public interface IFocasClientFactory
{
@@ -57,14 +256,32 @@ public interface IFocasClientFactory
}
///
-/// Default factory that throws at construction time — the deployment must register a real
-/// factory. Keeps the driver assembly licence-clean while still allowing the skeleton to
-/// compile + the abstraction tests to run.
+/// Scaffolding factory — throws on so a DriverInstance row can be
+/// seeded ahead of the CNC endpoint being reachable without silently reading stale data.
+/// Select via "Backend": "unimplemented" in driver config. Flip to
+/// "Backend": "wire" once the CNC is provisioned.
///
public sealed class UnimplementedFocasClientFactory : IFocasClientFactory
{
public IFocasClient Create() => throw new NotSupportedException(
- "FOCAS driver has no wire client configured. Register a real IFocasClientFactory at " +
- "server startup wrapping the licensed Fwlib32.dll — see docs/v2/focas-deployment.md. " +
- "Fanuc licensing forbids shipping Fwlib32.dll in the OtOpcUa package.");
+ "FOCAS driver backend is 'unimplemented'. Switch to 'Backend: \"wire\"' in driver config " +
+ "once the CNC is provisioned — see docs/drivers/FOCAS.md.");
+}
+
+///
+/// Well-known FOCAS alarm types from fwlib32.h ALM_TYPE_*. Narrow subset —
+/// the full list is ~15 types per model; these cover the universally-present categories.
+///
+public static class FocasAlarmType
+{
+ /// Pass to -equivalent to mean "any type".
+ public const int All = -1;
+ public const int Parameter = 0; // ALM_P
+ public const int PulseCode = 1; // ALM_Y (servo)
+ public const int Overtravel = 2; // ALM_O
+ public const int Overheat = 3; // ALM_H
+ public const int Servo = 4; // ALM_S
+ public const int DataIo = 5; // ALM_T
+ public const int MemoryCheck = 6; // ALM_M
+ public const int MacroAlarm = 13; // ALM_MC — used by #3006 etc.
}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/FocasIpcClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/FocasIpcClient.cs
deleted file mode 100644
index 51ede9b..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/FocasIpcClient.cs
+++ /dev/null
@@ -1,120 +0,0 @@
-using System.IO;
-using System.IO.Pipes;
-using MessagePack;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
-
-///
-/// Proxy-side IPC channel to a running Driver.FOCAS.Host. Owns the pipe connection
-/// and serializes request/response round-trips through a single call gate so
-/// concurrent callers don't interleave frames. One instance per FOCAS Host session.
-///
-public sealed class FocasIpcClient : IAsyncDisposable
-{
- private readonly Stream _stream;
- private readonly FrameReader _reader;
- private readonly FrameWriter _writer;
- private readonly SemaphoreSlim _callGate = new(1, 1);
-
- private FocasIpcClient(Stream stream)
- {
- _stream = stream;
- _reader = new FrameReader(stream, leaveOpen: true);
- _writer = new FrameWriter(stream, leaveOpen: true);
- }
-
- /// Named-pipe factory: connects, sends Hello, awaits HelloAck.
- public static async Task ConnectAsync(
- string pipeName, string sharedSecret, TimeSpan connectTimeout, CancellationToken ct)
- {
- var stream = new NamedPipeClientStream(
- serverName: ".",
- pipeName: pipeName,
- direction: PipeDirection.InOut,
- options: PipeOptions.Asynchronous);
-
- await stream.ConnectAsync((int)connectTimeout.TotalMilliseconds, ct);
- return await HandshakeAsync(stream, sharedSecret, ct).ConfigureAwait(false);
- }
-
- ///
- /// Stream factory — used by tests that wire the Proxy against an in-memory stream
- /// pair instead of a real pipe. is owned by the caller
- /// until .
- ///
- public static Task ConnectAsync(Stream stream, string sharedSecret, CancellationToken ct)
- => HandshakeAsync(stream, sharedSecret, ct);
-
- private static async Task HandshakeAsync(Stream stream, string sharedSecret, CancellationToken ct)
- {
- var client = new FocasIpcClient(stream);
- try
- {
- await client._writer.WriteAsync(FocasMessageKind.Hello,
- new Hello { PeerName = "FOCAS.Proxy", SharedSecret = sharedSecret }, ct).ConfigureAwait(false);
-
- var ack = await client._reader.ReadFrameAsync(ct).ConfigureAwait(false);
- if (ack is null || ack.Value.Kind != FocasMessageKind.HelloAck)
- throw new InvalidOperationException("Did not receive HelloAck from FOCAS.Host");
-
- var ackMsg = FrameReader.Deserialize(ack.Value.Body);
- if (!ackMsg.Accepted)
- throw new UnauthorizedAccessException($"FOCAS.Host rejected Hello: {ackMsg.RejectReason}");
-
- return client;
- }
- catch
- {
- await client.DisposeAsync().ConfigureAwait(false);
- throw;
- }
- }
-
- public async Task CallAsync(
- FocasMessageKind requestKind, TReq request, FocasMessageKind expectedResponseKind, CancellationToken ct)
- {
- await _callGate.WaitAsync(ct).ConfigureAwait(false);
- try
- {
- await _writer.WriteAsync(requestKind, request, ct).ConfigureAwait(false);
-
- var frame = await _reader.ReadFrameAsync(ct).ConfigureAwait(false);
- if (frame is null) throw new EndOfStreamException("FOCAS IPC peer closed before response");
-
- if (frame.Value.Kind == FocasMessageKind.ErrorResponse)
- {
- var err = MessagePackSerializer.Deserialize(frame.Value.Body);
- throw new FocasIpcException(err.Code, err.Message);
- }
-
- if (frame.Value.Kind != expectedResponseKind)
- throw new InvalidOperationException(
- $"Expected {expectedResponseKind}, got {frame.Value.Kind}");
-
- return MessagePackSerializer.Deserialize(frame.Value.Body);
- }
- finally { _callGate.Release(); }
- }
-
- public async Task SendOneWayAsync(FocasMessageKind requestKind, TReq request, CancellationToken ct)
- {
- await _callGate.WaitAsync(ct).ConfigureAwait(false);
- try { await _writer.WriteAsync(requestKind, request, ct).ConfigureAwait(false); }
- finally { _callGate.Release(); }
- }
-
- public async ValueTask DisposeAsync()
- {
- _callGate.Dispose();
- _reader.Dispose();
- _writer.Dispose();
- await _stream.DisposeAsync().ConfigureAwait(false);
- }
-}
-
-public sealed class FocasIpcException(string code, string message) : Exception($"[{code}] {message}")
-{
- public string Code { get; } = code;
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/IpcFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/IpcFocasClient.cs
deleted file mode 100644
index 01c227c..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/IpcFocasClient.cs
+++ /dev/null
@@ -1,199 +0,0 @@
-using MessagePack;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
-
-///
-/// implementation that forwards every operation over a
-/// to a Driver.FOCAS.Host process. Keeps the
-/// Fwlib32.dll P/Invoke out of the main server process so a native crash
-/// blast-radius stops at the Host boundary.
-///
-///
-/// Session lifecycle: sends OpenSessionRequest and
-/// caches the returned SessionId. Subsequent /
-/// / calls thread that session id
-/// onto each request DTO. sends CloseSessionRequest +
-/// disposes the underlying pipe.
-///
-public sealed class IpcFocasClient : IFocasClient
-{
- private readonly FocasIpcClient _ipc;
- private readonly FocasCncSeries _series;
- private long _sessionId;
- private bool _connected;
-
- public IpcFocasClient(FocasIpcClient ipc, FocasCncSeries series = FocasCncSeries.Unknown)
- {
- _ipc = ipc ?? throw new ArgumentNullException(nameof(ipc));
- _series = series;
- }
-
- public bool IsConnected => _connected;
-
- public async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
- {
- if (_connected) return;
-
- var resp = await _ipc.CallAsync(
- FocasMessageKind.OpenSessionRequest,
- new OpenSessionRequest
- {
- HostAddress = $"{address.Host}:{address.Port}",
- TimeoutMs = (int)Math.Max(1, timeout.TotalMilliseconds),
- CncSeries = (int)_series,
- },
- FocasMessageKind.OpenSessionResponse,
- cancellationToken).ConfigureAwait(false);
-
- if (!resp.Success)
- throw new InvalidOperationException(
- $"FOCAS Host rejected OpenSession for {address}: {resp.ErrorCode ?? "?"} — {resp.Error}");
-
- _sessionId = resp.SessionId;
- _connected = true;
- }
-
- public async Task<(object? value, uint status)> ReadAsync(
- FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
- {
- if (!_connected) return (null, FocasStatusMapper.BadCommunicationError);
-
- var resp = await _ipc.CallAsync(
- FocasMessageKind.ReadRequest,
- new ReadRequest
- {
- SessionId = _sessionId,
- Address = ToDto(address),
- DataType = (int)type,
- },
- FocasMessageKind.ReadResponse,
- cancellationToken).ConfigureAwait(false);
-
- if (!resp.Success) return (null, resp.StatusCode);
-
- var value = DecodeValue(resp.ValueBytes, resp.ValueTypeCode);
- return (value, resp.StatusCode);
- }
-
- public async Task WriteAsync(
- FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
- {
- if (!_connected) return FocasStatusMapper.BadCommunicationError;
-
- // PMC bit writes get the first-class RMW frame so the critical section stays on the Host.
- if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit)
- {
- var bitResp = await _ipc.CallAsync(
- FocasMessageKind.PmcBitWriteRequest,
- new PmcBitWriteRequest
- {
- SessionId = _sessionId,
- Address = ToDto(address),
- BitIndex = bit,
- Value = Convert.ToBoolean(value),
- },
- FocasMessageKind.PmcBitWriteResponse,
- cancellationToken).ConfigureAwait(false);
- return bitResp.StatusCode;
- }
-
- var resp = await _ipc.CallAsync(
- FocasMessageKind.WriteRequest,
- new WriteRequest
- {
- SessionId = _sessionId,
- Address = ToDto(address),
- DataType = (int)type,
- ValueTypeCode = (int)type,
- ValueBytes = EncodeValue(value, type),
- },
- FocasMessageKind.WriteResponse,
- cancellationToken).ConfigureAwait(false);
-
- return resp.StatusCode;
- }
-
- public async Task ProbeAsync(CancellationToken cancellationToken)
- {
- if (!_connected) return false;
- try
- {
- var resp = await _ipc.CallAsync(
- FocasMessageKind.ProbeRequest,
- new ProbeRequest { SessionId = _sessionId },
- FocasMessageKind.ProbeResponse,
- cancellationToken).ConfigureAwait(false);
- return resp.Healthy;
- }
- catch { return false; }
- }
-
- public void Dispose()
- {
- if (_connected)
- {
- try
- {
- _ipc.SendOneWayAsync(FocasMessageKind.CloseSessionRequest,
- new CloseSessionRequest { SessionId = _sessionId }, CancellationToken.None)
- .GetAwaiter().GetResult();
- }
- catch { /* best effort */ }
- _connected = false;
- }
- _ipc.DisposeAsync().AsTask().GetAwaiter().GetResult();
- }
-
- private static FocasAddressDto ToDto(FocasAddress addr) => new()
- {
- Kind = (int)addr.Kind,
- PmcLetter = addr.PmcLetter,
- Number = addr.Number,
- BitIndex = addr.BitIndex,
- };
-
- private static byte[]? EncodeValue(object? value, FocasDataType type)
- {
- if (value is null) return null;
- return type switch
- {
- FocasDataType.Bit => MessagePackSerializer.Serialize(Convert.ToBoolean(value)),
- FocasDataType.Byte => MessagePackSerializer.Serialize(Convert.ToByte(value)),
- FocasDataType.Int16 => MessagePackSerializer.Serialize(Convert.ToInt16(value)),
- FocasDataType.Int32 => MessagePackSerializer.Serialize(Convert.ToInt32(value)),
- FocasDataType.Float32 => MessagePackSerializer.Serialize(Convert.ToSingle(value)),
- FocasDataType.Float64 => MessagePackSerializer.Serialize(Convert.ToDouble(value)),
- FocasDataType.String => MessagePackSerializer.Serialize(Convert.ToString(value) ?? string.Empty),
- _ => MessagePackSerializer.Serialize(Convert.ToInt32(value)),
- };
- }
-
- private static object? DecodeValue(byte[]? bytes, int typeCode)
- {
- if (bytes is null) return null;
- return typeCode switch
- {
- FocasDataTypeCode.Bit => MessagePackSerializer.Deserialize(bytes),
- FocasDataTypeCode.Byte => MessagePackSerializer.Deserialize(bytes),
- FocasDataTypeCode.Int16 => MessagePackSerializer.Deserialize(bytes),
- FocasDataTypeCode.Int32 => MessagePackSerializer.Deserialize(bytes),
- FocasDataTypeCode.Float32 => MessagePackSerializer.Deserialize(bytes),
- FocasDataTypeCode.Float64 => MessagePackSerializer.Deserialize(bytes),
- FocasDataTypeCode.String => MessagePackSerializer.Deserialize(bytes),
- _ => MessagePackSerializer.Deserialize(bytes),
- };
- }
-}
-
-///
-/// Factory producing s. One pipe connection per
-/// IFocasClient — matches the driver's one-client-per-device invariant. The
-/// deployment wires this into the DI container in place of
-/// .
-///
-public sealed class IpcFocasClientFactory(Func ipcClientFactory, FocasCncSeries series = FocasCncSeries.Unknown)
- : IFocasClientFactory
-{
- public IFocasClient Create() => new IpcFocasClient(ipcClientFactory(), series);
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/Backoff.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/Backoff.cs
deleted file mode 100644
index 8ffa193..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/Backoff.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
-
-///
-/// Respawn-with-backoff schedule for the FOCAS Host process. Matches Galaxy Tier-C:
-/// 5s → 15s → 60s cap. A sustained stable run (default 2 min) resets the index so a
-/// one-off crash after hours of steady-state doesn't start from the top of the ladder.
-///
-public sealed class Backoff
-{
- public static TimeSpan[] DefaultSequence { get; } =
- [TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(60)];
-
- public TimeSpan StableRunThreshold { get; init; } = TimeSpan.FromMinutes(2);
-
- private readonly TimeSpan[] _sequence;
- private int _index;
-
- public Backoff(TimeSpan[]? sequence = null) => _sequence = sequence ?? DefaultSequence;
-
- public TimeSpan Next()
- {
- var delay = _sequence[Math.Min(_index, _sequence.Length - 1)];
- _index++;
- return delay;
- }
-
- public void RecordStableRun() => _index = 0;
-
- public int AttemptIndex => _index;
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/CircuitBreaker.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/CircuitBreaker.cs
deleted file mode 100644
index eeb1a7b..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/CircuitBreaker.cs
+++ /dev/null
@@ -1,69 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
-
-///
-/// Crash-loop circuit breaker for the FOCAS Host. Matches Galaxy Tier-C defaults:
-/// 3 crashes within 5 minutes opens the breaker; cooldown escalates 1h → 4h → manual
-/// reset. A sticky alert stays live until the operator explicitly clears it so
-/// recurring crashes can't silently burn through the cooldown ladder overnight.
-///
-public sealed class CircuitBreaker
-{
- public int CrashesAllowedPerWindow { get; init; } = 3;
- public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(5);
-
- public TimeSpan[] CooldownEscalation { get; init; } =
- [TimeSpan.FromHours(1), TimeSpan.FromHours(4), TimeSpan.MaxValue];
-
- private readonly List _crashesUtc = [];
- private DateTime? _openSinceUtc;
- private int _escalationLevel;
- public bool StickyAlertActive { get; private set; }
-
- ///
- /// Records a crash + returns true if the supervisor may respawn. On
- /// false, is how long to wait before
- /// trying again (TimeSpan.MaxValue means manual reset required).
- ///
- public bool TryRecordCrash(DateTime utcNow, out TimeSpan cooldownRemaining)
- {
- if (_openSinceUtc is { } openedAt)
- {
- var cooldown = CooldownEscalation[Math.Min(_escalationLevel, CooldownEscalation.Length - 1)];
- if (cooldown == TimeSpan.MaxValue)
- {
- cooldownRemaining = TimeSpan.MaxValue;
- return false;
- }
- if (utcNow - openedAt < cooldown)
- {
- cooldownRemaining = cooldown - (utcNow - openedAt);
- return false;
- }
-
- _openSinceUtc = null;
- _escalationLevel++;
- }
-
- _crashesUtc.RemoveAll(t => utcNow - t > Window);
- _crashesUtc.Add(utcNow);
-
- if (_crashesUtc.Count > CrashesAllowedPerWindow)
- {
- _openSinceUtc = utcNow;
- StickyAlertActive = true;
- cooldownRemaining = CooldownEscalation[Math.Min(_escalationLevel, CooldownEscalation.Length - 1)];
- return false;
- }
-
- cooldownRemaining = TimeSpan.Zero;
- return true;
- }
-
- public void ManualReset()
- {
- _crashesUtc.Clear();
- _openSinceUtc = null;
- _escalationLevel = 0;
- StickyAlertActive = false;
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/FocasHostSupervisor.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/FocasHostSupervisor.cs
deleted file mode 100644
index fc58d38..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/FocasHostSupervisor.cs
+++ /dev/null
@@ -1,159 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
-
-///
-/// Ties + +
-/// + into one object the
-/// driver asks for IFocasClients. On a detected crash (process exit or
-/// heartbeat loss) the supervisor fans out BadCommunicationError to all
-/// subscribers via the callback, then respawns with
-/// backoff unless the breaker is open.
-///
-///
-/// The supervisor itself is I/O-free — it doesn't know how to spawn processes, probe
-/// pipes, or send heartbeats. Production wires the concrete
-/// over FocasIpcClient + Process;
-/// tests drive the same state machine with a deterministic launcher stub.
-///
-public sealed class FocasHostSupervisor : IDisposable
-{
- private readonly IHostProcessLauncher _launcher;
- private readonly Backoff _backoff;
- private readonly CircuitBreaker _breaker;
- private readonly Func _clock;
- private IFocasClient? _current;
- private DateTime _currentStartedUtc;
- private bool _disposed;
-
- public FocasHostSupervisor(
- IHostProcessLauncher launcher,
- Backoff? backoff = null,
- CircuitBreaker? breaker = null,
- Func? clock = null)
- {
- _launcher = launcher ?? throw new ArgumentNullException(nameof(launcher));
- _backoff = backoff ?? new Backoff();
- _breaker = breaker ?? new CircuitBreaker();
- _clock = clock ?? (() => DateTime.UtcNow);
- }
-
- /// Raised with a short reason string whenever the Host goes unavailable (crash / heartbeat loss / breaker-open).
- public event Action? OnUnavailable;
-
- /// Crash count observed in the current process lifetime. Exposed for /hosts Admin telemetry.
- public int ObservedCrashes { get; private set; }
-
- /// true if the crash-loop breaker has latched a sticky alert that needs operator reset.
- public bool StickyAlertActive => _breaker.StickyAlertActive;
-
- public int BackoffAttempt => _backoff.AttemptIndex;
-
- ///
- /// Returns the current live client. If none, tries to launch — applying the
- /// backoff schedule between attempts and stopping once the breaker opens.
- ///
- public async Task GetOrLaunchAsync(CancellationToken ct)
- {
- ThrowIfDisposed();
- if (_current is not null && _launcher.IsProcessAlive) return _current;
-
- return await LaunchWithBackoffAsync(ct).ConfigureAwait(false);
- }
-
- ///
- /// Called by the heartbeat task each time a miss threshold is crossed.
- /// Treated as a crash: fan out Bad status + attempt respawn.
- ///
- public async Task NotifyHostDeadAsync(string reason, CancellationToken ct)
- {
- ThrowIfDisposed();
- OnUnavailable?.Invoke(reason);
- ObservedCrashes++;
- try { await _launcher.TerminateAsync(ct).ConfigureAwait(false); }
- catch { /* best effort */ }
- _current?.Dispose();
- _current = null;
-
- if (!_breaker.TryRecordCrash(_clock(), out var cooldown))
- {
- OnUnavailable?.Invoke(cooldown == TimeSpan.MaxValue
- ? "circuit-breaker-open-manual-reset-required"
- : $"circuit-breaker-open-cooldown-{cooldown:g}");
- return;
- }
- // Successful crash recording — do not respawn synchronously; GetOrLaunchAsync will
- // pick up the attempt on the next call. Keeps the fan-out fast.
- }
-
- /// Operator action — clear the sticky alert + reset the breaker.
- public void AcknowledgeAndReset()
- {
- _breaker.ManualReset();
- _backoff.RecordStableRun();
- }
-
- private async Task LaunchWithBackoffAsync(CancellationToken ct)
- {
- while (true)
- {
- if (_breaker.StickyAlertActive)
- {
- if (!_breaker.TryRecordCrash(_clock(), out var cooldown) && cooldown == TimeSpan.MaxValue)
- throw new InvalidOperationException(
- "FOCAS Host circuit breaker is open and awaiting manual reset. " +
- "See Admin /hosts; call AcknowledgeAndReset after investigating the Host log.");
- }
-
- try
- {
- _current = await _launcher.LaunchAsync(ct).ConfigureAwait(false);
- _currentStartedUtc = _clock();
-
- // If the launch sequence itself takes long enough to count as a stable run,
- // reset the backoff ladder immediately.
- if (_clock() - _currentStartedUtc >= _backoff.StableRunThreshold)
- _backoff.RecordStableRun();
-
- return _current;
- }
- catch (Exception ex) when (ex is not OperationCanceledException)
- {
- OnUnavailable?.Invoke($"launch-failed: {ex.Message}");
- ObservedCrashes++;
- if (!_breaker.TryRecordCrash(_clock(), out var cooldown))
- {
- var hint = cooldown == TimeSpan.MaxValue
- ? "manual reset required"
- : $"cooldown {cooldown:g}";
- throw new InvalidOperationException(
- $"FOCAS Host circuit breaker opened after {ObservedCrashes} crashes — {hint}.", ex);
- }
-
- var delay = _backoff.Next();
- await Task.Delay(delay, ct).ConfigureAwait(false);
- }
- }
- }
-
- /// Called from the heartbeat loop after a successful ack run — relaxes the backoff ladder.
- public void NotifyStableRun()
- {
- if (_current is null) return;
- if (_clock() - _currentStartedUtc >= _backoff.StableRunThreshold)
- _backoff.RecordStableRun();
- }
-
- public void Dispose()
- {
- if (_disposed) return;
- _disposed = true;
- try { _launcher.TerminateAsync(CancellationToken.None).GetAwaiter().GetResult(); }
- catch { /* best effort */ }
- _current?.Dispose();
- _current = null;
- }
-
- private void ThrowIfDisposed()
- {
- if (_disposed) throw new ObjectDisposedException(nameof(FocasHostSupervisor));
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/HeartbeatMonitor.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/HeartbeatMonitor.cs
deleted file mode 100644
index 5b43309..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/HeartbeatMonitor.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
-
-///
-/// Tracks missed heartbeats from the FOCAS Host. 2s cadence + 3 consecutive misses =
-/// host declared dead (~6s detection). Same defaults as Galaxy Tier-C so operators
-/// see the same cadence across hosts on the /hosts Admin page.
-///
-public sealed class HeartbeatMonitor
-{
- public int MissesUntilDead { get; init; } = 3;
-
- public TimeSpan Cadence { get; init; } = TimeSpan.FromSeconds(2);
-
- public int ConsecutiveMisses { get; private set; }
- public DateTime? LastAckUtc { get; private set; }
-
- public void RecordAck(DateTime utcNow)
- {
- ConsecutiveMisses = 0;
- LastAckUtc = utcNow;
- }
-
- /// Records a missed heartbeat; returns true when the death threshold is crossed.
- public bool RecordMiss()
- {
- ConsecutiveMisses++;
- return ConsecutiveMisses >= MissesUntilDead;
- }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/IHostProcessLauncher.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/IHostProcessLauncher.cs
deleted file mode 100644
index 50c4024..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/IHostProcessLauncher.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
-
-///
-/// Abstraction over the act of spawning a FOCAS Host process and obtaining an
-/// connected to it. Production wires this to a real
-/// Process.Start + FocasIpcClient.ConnectAsync; tests use a fake that
-/// exposes deterministic failure modes so the supervisor logic can be stressed
-/// without spawning actual exes.
-///
-public interface IHostProcessLauncher
-{
- ///
- /// Spawn a new Host process (if one isn't already running) and return a live
- /// client session. Throws on unrecoverable errors; transient errors (e.g. Host
- /// not ready yet) should throw so the supervisor
- /// applies the backoff ladder.
- ///
- Task LaunchAsync(CancellationToken ct);
-
- ///
- /// Terminate the Host process if one is running. Called on Dispose and after a
- /// heartbeat loss is detected.
- ///
- Task TerminateAsync(CancellationToken ct);
-
- ///
- /// true when the most recently spawned Host process is still alive.
- /// Supervisor polls this at heartbeat cadence; going false without a
- /// clean shutdown counts as a crash.
- ///
- bool IsProcessAlive { get; }
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/PostMortemReader.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/PostMortemReader.cs
deleted file mode 100644
index f5236dc..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/PostMortemReader.cs
+++ /dev/null
@@ -1,57 +0,0 @@
-using System.IO.MemoryMappedFiles;
-using System.Text;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
-
-///
-/// Proxy-side reader for the Host's post-mortem MMF. After a Host crash the supervisor
-/// opens the file (which persists beyond the process lifetime) and enumerates the last
-/// few thousand IPC operations that were in flight. Format matches
-/// Driver.FOCAS.Host.Stability.PostMortemMmf — magic 'OFPC' / 256-byte entries.
-///
-public sealed class PostMortemReader
-{
- private const int Magic = 0x4F465043; // 'OFPC'
- private const int HeaderBytes = 16;
- private const int EntryBytes = 256;
- private const int MessageOffset = 16;
- private const int MessageCapacity = EntryBytes - MessageOffset;
-
- public string Path { get; }
-
- public PostMortemReader(string path) => Path = path;
-
- public PostMortemEntry[] ReadAll()
- {
- if (!File.Exists(Path)) return [];
-
- using var mmf = MemoryMappedFile.CreateFromFile(Path, FileMode.Open, null, 0, MemoryMappedFileAccess.Read);
- using var accessor = mmf.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read);
-
- if (accessor.ReadInt32(0) != Magic) return [];
-
- var capacity = accessor.ReadInt32(8);
- var writeIndex = accessor.ReadInt32(12);
- var entries = new PostMortemEntry[capacity];
- var count = 0;
-
- for (var i = 0; i < capacity; i++)
- {
- var slot = (writeIndex + i) % capacity;
- var offset = HeaderBytes + slot * EntryBytes;
- var ts = accessor.ReadInt64(offset + 0);
- if (ts == 0) continue;
- var op = accessor.ReadInt64(offset + 8);
- var msgBuf = new byte[MessageCapacity];
- accessor.ReadArray(offset + MessageOffset, msgBuf, 0, MessageCapacity);
- var nulTerm = Array.IndexOf(msgBuf, 0);
- var msg = Encoding.UTF8.GetString(msgBuf, 0, nulTerm < 0 ? MessageCapacity : nulTerm);
- entries[count++] = new PostMortemEntry(ts, op, msg);
- }
-
- Array.Resize(ref entries, count);
- return entries;
- }
-}
-
-public readonly record struct PostMortemEntry(long UtcUnixMs, long OpKind, string Message);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/ProcessHostLauncher.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/ProcessHostLauncher.cs
deleted file mode 100644
index f31145c..0000000
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/ProcessHostLauncher.cs
+++ /dev/null
@@ -1,113 +0,0 @@
-using System.Diagnostics;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
-
-///
-/// Production . Spawns OtOpcUa.Driver.FOCAS.Host.exe
-/// with the pipe name / allowed-SID / per-spawn shared secret in the environment, waits for
-/// the pipe to come up, then connects a and wraps it in an
-/// . On best-effort kills the
-/// process and closes the IPC stream.
-///
-public sealed class ProcessHostLauncher : IHostProcessLauncher
-{
- private readonly ProcessHostLauncherOptions _options;
- private Process? _process;
- private FocasIpcClient? _ipc;
-
- public ProcessHostLauncher(ProcessHostLauncherOptions options)
- {
- _options = options ?? throw new ArgumentNullException(nameof(options));
- }
-
- public bool IsProcessAlive => _process is { HasExited: false };
-
- public async Task LaunchAsync(CancellationToken ct)
- {
- await TerminateAsync(ct).ConfigureAwait(false);
-
- var secret = _options.SharedSecret ?? Guid.NewGuid().ToString("N");
-
- var psi = new ProcessStartInfo
- {
- FileName = _options.HostExePath,
- Arguments = _options.Arguments ?? string.Empty,
- UseShellExecute = false,
- CreateNoWindow = true,
- };
- psi.Environment["OTOPCUA_FOCAS_PIPE"] = _options.PipeName;
- psi.Environment["OTOPCUA_ALLOWED_SID"] = _options.AllowedSid;
- psi.Environment["OTOPCUA_FOCAS_SECRET"] = secret;
- psi.Environment["OTOPCUA_FOCAS_BACKEND"] = _options.Backend;
-
- _process = Process.Start(psi)
- ?? throw new InvalidOperationException($"Failed to start {_options.HostExePath}");
-
- // Poll for pipe readiness up to the configured connect timeout.
- var deadline = DateTime.UtcNow + _options.ConnectTimeout;
- while (true)
- {
- ct.ThrowIfCancellationRequested();
- if (_process.HasExited)
- throw new InvalidOperationException(
- $"FOCAS Host exited before pipe was ready (ExitCode={_process.ExitCode}).");
-
- try
- {
- _ipc = await FocasIpcClient.ConnectAsync(
- _options.PipeName, secret, TimeSpan.FromSeconds(1), ct).ConfigureAwait(false);
- break;
- }
- catch (TimeoutException)
- {
- if (DateTime.UtcNow >= deadline)
- throw new TimeoutException(
- $"FOCAS Host pipe {_options.PipeName} did not come up within {_options.ConnectTimeout:g}.");
- await Task.Delay(TimeSpan.FromMilliseconds(250), ct).ConfigureAwait(false);
- }
- }
-
- return new IpcFocasClient(_ipc, _options.Series);
- }
-
- public async Task TerminateAsync(CancellationToken ct)
- {
- if (_ipc is not null)
- {
- try { await _ipc.DisposeAsync().ConfigureAwait(false); }
- catch { /* best effort */ }
- _ipc = null;
- }
-
- if (_process is not null)
- {
- try
- {
- if (!_process.HasExited)
- {
- _process.Kill(entireProcessTree: true);
- await _process.WaitForExitAsync(ct).ConfigureAwait(false);
- }
- }
- catch { /* best effort */ }
- finally
- {
- _process.Dispose();
- _process = null;
- }
- }
- }
-}
-
-public sealed record ProcessHostLauncherOptions(
- string HostExePath,
- string PipeName,
- string AllowedSid)
-{
- public string? SharedSecret { get; init; }
- public string? Arguments { get; init; }
- public string Backend { get; init; } = "fwlib32";
- public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(15);
- public FocasCncSeries Series { get; init; } = FocasCncSeries.Unknown;
-}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasConstants.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasConstants.cs
new file mode 100644
index 0000000..b82824d
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasConstants.cs
@@ -0,0 +1,120 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
+
+///
+/// PMC address-letter → FOCAS ADR_* numeric code. Values are the FOCAS/2 wire
+/// constants passed as the area argument on pmc_rdpmcrng
+/// (G=0, F=1, Y=2, X=3, A=4, R=5, T=6, K=7, C=8, D=9, E=10).
+///
+public enum FocasPmcArea : short
+{
+ G = 0,
+ F = 1,
+ Y = 2,
+ X = 3,
+ A = 4,
+ R = 5,
+ T = 6,
+ K = 7,
+ C = 8,
+ D = 9,
+ E = 10,
+}
+
+///
+/// PMC data-type numeric codes per FOCAS/2: Byte=0, Word=1, Long=2,
+/// Real=4, Double=5. Passed as the data_type argument on
+/// pmc_rdpmcrng.
+///
+public enum FocasPmcDataType : short
+{
+ Byte = 0,
+ Word = 1,
+ Long = 2,
+ Real = 4,
+ Double = 5,
+}
+
+///
+/// CNC operation mode as reported by cnc_rdopmode. Values are the FOCAS-defined
+/// mode codes; see for the canonical
+/// operator-facing labels.
+///
+public enum FocasOperationMode : short
+{
+ Mdi = 0,
+ Auto = 1,
+ TJog = 2,
+ Edit = 3,
+ Handle = 4,
+ Jog = 5,
+ TeachInHandle = 6,
+ Reference = 7,
+ Remote = 8,
+ Test = 9,
+}
+
+/// Extension helpers over .
+public static class FocasOperationModeExtensions
+{
+ ///
+ /// Canonical operator-facing label for an operation mode (e.g. "AUTO",
+ /// "EDIT"). Unknown codes fall back to the raw numeric value as a string
+ /// so the UI still shows something interpretable.
+ ///
+ public static string ToText(this FocasOperationMode mode) => mode switch
+ {
+ FocasOperationMode.Mdi => "MDI",
+ FocasOperationMode.Auto => "AUTO",
+ FocasOperationMode.TJog => "T-JOG",
+ FocasOperationMode.Edit => "EDIT",
+ FocasOperationMode.Handle => "HANDLE",
+ FocasOperationMode.Jog => "JOG",
+ FocasOperationMode.TeachInHandle => "TEACH-IN-HANDLE",
+ FocasOperationMode.Reference => "REFERENCE",
+ FocasOperationMode.Remote => "REMOTE",
+ FocasOperationMode.Test => "TEST",
+ _ => ((short)mode).ToString(),
+ };
+}
+
+///
+/// Letter → lookup. Used by to
+/// translate a parsed into the wire code expected by
+/// pmc_rdpmcrng.
+///
+internal static class FocasPmcAreaLookup
+{
+ public static FocasPmcArea? FromLetter(string letter) => letter.ToUpperInvariant() switch
+ {
+ "G" => FocasPmcArea.G,
+ "F" => FocasPmcArea.F,
+ "Y" => FocasPmcArea.Y,
+ "X" => FocasPmcArea.X,
+ "A" => FocasPmcArea.A,
+ "R" => FocasPmcArea.R,
+ "T" => FocasPmcArea.T,
+ "K" => FocasPmcArea.K,
+ "C" => FocasPmcArea.C,
+ "D" => FocasPmcArea.D,
+ "E" => FocasPmcArea.E,
+ _ => null,
+ };
+}
+
+///
+/// → mapping for wire PMC
+/// reads. Bit reads collapse to byte — the caller extracts the bit from the returned
+/// value.
+///
+internal static class FocasPmcDataTypeLookup
+{
+ public static FocasPmcDataType FromFocasDataType(FocasDataType t) => t switch
+ {
+ FocasDataType.Bit or FocasDataType.Byte => FocasPmcDataType.Byte,
+ FocasDataType.Int16 => FocasPmcDataType.Word,
+ FocasDataType.Int32 => FocasPmcDataType.Long,
+ FocasDataType.Float32 => FocasPmcDataType.Real,
+ FocasDataType.Float64 => FocasPmcDataType.Double,
+ _ => FocasPmcDataType.Byte,
+ };
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs
new file mode 100644
index 0000000..1d636d1
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs
@@ -0,0 +1,883 @@
+using System.Buffers.Binary;
+using System.Net.Sockets;
+using Microsoft.Extensions.Logging;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
+
+///
+/// Pure-managed read-only FOCAS/2 Ethernet wire client. Speaks the proprietary Fanuc
+/// binary protocol on TCP:8193 directly — no P/Invoke, no Fwlib64.dll, no
+/// out-of-process Host. One instance owns two TCP sockets for the duration of a CNC
+/// session; runs the
+/// two-socket initiate handshake and a setup request, subsequent reads reuse
+/// socket 2 serialised through an internal semaphore.
+///
+///
+/// Read surface. Covers every FOCAS call OtOpcUa's managed driver issues:
+/// sysinfo, status, axis + spindle names, the cnc_rddynamic2 fast-poll bundle,
+/// parameters (typed + raw-bytes overloads), macros, PMC ranges, alarms, operation mode,
+/// executing program, block count, timers, and servo / spindle meters. Writes are
+/// intentionally out of scope.
+/// Concurrency. Callers may issue reads concurrently from multiple threads
+/// — socket 2 is guarded by a so at most one
+/// request/response pair is in flight at a time.
+/// and share a second semaphore to stop the two racing.
+/// Transient failures. When cancellation or a socket-level error happens
+/// mid-request the client closes both sockets and throws
+/// with
+/// set — the caller must reconnect before issuing the next request. The transport is
+/// left deliberately torn down rather than half-open so a truncated response never
+/// desynchronises the next caller's read.
+///
+public sealed class FocasWireClient : IAsyncDisposable, IDisposable
+{
+ private readonly ILogger? _logger;
+ private readonly SemaphoreSlim _requestGate = new(1, 1);
+ private readonly SemaphoreSlim _lifetimeGate = new(1, 1);
+ private TcpClient? _socket1;
+ private TcpClient? _socket2;
+ private NetworkStream? _stream1;
+ private NetworkStream? _stream2;
+ private bool _connected;
+ private bool _disposed;
+ private FocasResult? _sysInfo;
+
+ ///
+ /// Construct a disconnected client. Optional receives
+ /// Debug-level entries per response block (command ID, RC, payload length).
+ ///
+ public FocasWireClient(ILogger? logger = null)
+ {
+ _logger = logger;
+ }
+
+ ///
+ /// Default PathId applied when no per-call override is supplied. Relevant for
+ /// multi-path CNCs; single-path controllers leave this at the default of 1.
+ ///
+ public ushort PathId { get; set; } = 1;
+
+ /// True when the two-socket handshake has completed and the transport is live.
+ public bool IsConnected => _connected;
+
+ ///
+ /// Open the FOCAS session using an integer-seconds timeout. Idempotent — a second
+ /// call while already connected is a no-op. Sub-second timeouts require the
+ /// overload.
+ ///
+ public Task ConnectAsync(
+ string host,
+ int port,
+ int timeoutSeconds = 10,
+ CancellationToken cancellationToken = default)
+ => ConnectCoreAsync(
+ host,
+ port,
+ timeoutSeconds > 0 ? TimeSpan.FromSeconds(timeoutSeconds) : null,
+ cancellationToken);
+
+ ///
+ /// Open the FOCAS session with a timeout. Pass
+ /// to disable the timeout entirely (rely on the caller's
+ /// instead). Idempotent.
+ ///
+ public Task ConnectAsync(
+ string host,
+ int port,
+ TimeSpan timeout,
+ CancellationToken cancellationToken = default)
+ => ConnectCoreAsync(host, port, timeout == TimeSpan.Zero ? null : timeout, cancellationToken);
+
+ private async Task ConnectCoreAsync(
+ string host,
+ int port,
+ TimeSpan? timeoutValue,
+ CancellationToken cancellationToken)
+ {
+ await _lifetimeGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ ThrowIfDisposed();
+ if (_connected) return;
+
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ if (timeoutValue is { } value) timeout.CancelAfter(value);
+
+ try
+ {
+ _socket1 = await ConnectSocketAsync(host, port, timeout.Token).ConfigureAwait(false);
+ _stream1 = _socket1.GetStream();
+ await SendPduAsync(_stream1, FocasWireProtocol.TypeInitiate, FocasWireProtocol.BuildInitiateBody(1), timeout.Token).ConfigureAwait(false);
+ await ReadExpectedPduAsync(_stream1, FocasWireProtocol.TypeInitiate, timeout.Token).ConfigureAwait(false);
+
+ _socket2 = await ConnectSocketAsync(host, port, timeout.Token).ConfigureAwait(false);
+ _stream2 = _socket2.GetStream();
+ await SendPduAsync(_stream2, FocasWireProtocol.TypeInitiate, FocasWireProtocol.BuildInitiateBody(2), timeout.Token).ConfigureAwait(false);
+ await ReadExpectedPduAsync(_stream2, FocasWireProtocol.TypeInitiate, timeout.Token).ConfigureAwait(false);
+
+ _connected = true;
+ // Cache the sysinfo payload from the setup exchange so later
+ // ReadSysInfoAsync calls are a lookup rather than a wire hit.
+ var sysInfoBlock = await SendSingleRequestAsync(timeout.Token, new RequestBlock(0x0018, PathId: PathId)).ConfigureAwait(false);
+ _sysInfo = ToResult(sysInfoBlock, ParseSysInfo);
+ // Kick the cached path/session metadata request the DLL sends
+ // right after initiate. The result is ignored; the CNC uses it to
+ // populate internal state the subsequent reads depend on.
+ await SendRequestAsync(timeout.Token, new RequestBlock(0x000e, 0x26f0, 0x26f0, PathId: PathId)).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (IsTransientException(ex))
+ {
+ CloseTransport();
+ throw new FocasWireException("FOCAS wire connect failed.", ex, isTransient: true);
+ }
+ }
+ finally
+ {
+ _lifetimeGate.Release();
+ }
+ }
+
+ ///
+ /// Synchronous dispose — sends the close PDU when connected and tears down both
+ /// sockets. Idempotent. Callers on an async context should prefer
+ /// .
+ ///
+ public void Dispose()
+ {
+ _lifetimeGate.Wait();
+ try
+ {
+ if (_disposed) return;
+
+ _disposed = true;
+ if (_stream2 is not null && _connected)
+ {
+ try
+ {
+ SendPdu(_stream2, FocasWireProtocol.TypeClose, ReadOnlySpan.Empty);
+ _ = FocasWireProtocol.ReadPdu(_stream2);
+ }
+ catch
+ {
+ // Close best-effort — don't let teardown failure hide a caller's real error.
+ }
+ }
+ CloseTransport();
+ }
+ finally
+ {
+ _lifetimeGate.Release();
+ }
+ }
+
+ ///
+ /// Async dispose — sends the close PDU when connected and tears down both sockets.
+ /// Idempotent.
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ await _lifetimeGate.WaitAsync(CancellationToken.None).ConfigureAwait(false);
+ try
+ {
+ if (_disposed) return;
+
+ _disposed = true;
+ if (_stream2 is not null && _connected)
+ {
+ try
+ {
+ await SendPduAsync(_stream2, FocasWireProtocol.TypeClose, ReadOnlyMemory.Empty, CancellationToken.None).ConfigureAwait(false);
+ await FocasWireProtocol.ReadPduAsync(_stream2, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch
+ {
+ // Close best-effort — don't let teardown failure hide a caller's real error.
+ }
+ }
+ CloseTransport();
+ }
+ finally
+ {
+ _lifetimeGate.Release();
+ }
+ }
+
+ ///
+ /// Read CNC identity via cnc_sysinfo. Cached from the connect-time exchange
+ /// unless a per-call override is supplied.
+ ///
+ public async Task> ReadSysInfoAsync(
+ CancellationToken cancellationToken = default,
+ TimeSpan? timeout = null,
+ ushort? pathId = null)
+ {
+ if (pathId is null && _sysInfo is { } cached) return cached;
+
+ using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
+ return await ReadSingleAsync(0x0018, ParseSysInfo, EffectivePathId(pathId), cancellationToken: callTimeout.Token).ConfigureAwait(false);
+ }
+
+ /// Read CNC status bits via cnc_statinfo (3 command blocks aggregated into one ).
+ public async Task> ReadStatusAsync(
+ CancellationToken cancellationToken = default,
+ TimeSpan? timeout = null,
+ ushort? pathId = null)
+ {
+ using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
+ var requestPathId = EffectivePathId(pathId);
+ var blocks = await SendRequestAsync(
+ callTimeout.Token,
+ new RequestBlock(0x0019, PathId: requestPathId),
+ new RequestBlock(0x00e1, PathId: requestPathId),
+ new RequestBlock(0x0098, PathId: requestPathId)).ConfigureAwait(false);
+
+ var rc = AggregateRc(blocks);
+ if (rc != 0) return new FocasResult(rc, null);
+
+ var primary = FindPayload(blocks, 0x0019);
+ RequireLength(primary, 14, "cnc_statinfo");
+ var tmModePayload = FindPayload(blocks, 0x0098);
+ var tmMode = tmModePayload.Length >= 2 ? ReadInt16(tmModePayload, 0) : (short)0;
+
+ return new FocasResult(
+ rc,
+ new WireStatus(
+ Auto: ReadInt16(primary, 0),
+ Run: ReadInt16(primary, 2),
+ Motion: ReadInt16(primary, 4),
+ Mstb: ReadInt16(primary, 6),
+ Emergency: ReadInt16(primary, 8),
+ Alarm: ReadInt16(primary, 10),
+ Edit: ReadInt16(primary, 12),
+ TmMode: tmMode));
+ }
+
+ /// Read configured axis names via cnc_rdaxisname (command 0x0089).
+ public async Task>> ReadAxisNamesAsync(
+ short maxCount = 32,
+ CancellationToken cancellationToken = default,
+ TimeSpan? timeout = null,
+ ushort? pathId = null)
+ {
+ using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
+ var block = await SendSingleRequestAsync(callTimeout.Token, new RequestBlock(0x0089, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
+ return ToResult(block, payload => ReadNameRecords(payload, maxCount, (index, name) => new WireAxisRecord(index, name)));
+ }
+
+ /// Read configured spindle names via cnc_rdspdlname (command 0x008a).
+ public async Task>> ReadSpindleNamesAsync(
+ short maxCount = 8,
+ CancellationToken cancellationToken = default,
+ TimeSpan? timeout = null,
+ ushort? pathId = null)
+ {
+ using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
+ var block = await SendSingleRequestAsync(callTimeout.Token, new RequestBlock(0x008a, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
+ return ToResult(block, payload => ReadNameRecords(payload, maxCount, (index, name) => new WireSpindleRecord(index, name)));
+ }
+
+ ///
+ /// Fast-poll bundle for one axis via cnc_rddynamic2. Sends 9 request blocks in
+ /// one PDU and aggregates the replies — alarm flags, program/sequence numbers, feed
+ /// and spindle actuals, plus the four-slot position quadruple.
+ ///
+ public async Task> ReadDynamic2Async(
+ short axis = 1,
+ CancellationToken cancellationToken = default,
+ TimeSpan? timeout = null,
+ ushort? pathId = null)
+ {
+ using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
+ var requestPathId = EffectivePathId(pathId);
+ var blocks = await SendRequestAsync(
+ callTimeout.Token,
+ new RequestBlock(0x001a, PathId: requestPathId),
+ new RequestBlock(0x001c, PathId: requestPathId),
+ new RequestBlock(0x001d, PathId: requestPathId),
+ new RequestBlock(0x0024, PathId: requestPathId),
+ new RequestBlock(0x0025, PathId: requestPathId),
+ new RequestBlock(0x0026, 4, axis, PathId: requestPathId),
+ new RequestBlock(0x0026, 1, axis, PathId: requestPathId),
+ new RequestBlock(0x0026, 6, axis, PathId: requestPathId),
+ new RequestBlock(0x0026, 7, axis, PathId: requestPathId)).ConfigureAwait(false);
+
+ var rc = AggregateRc(blocks);
+ if (rc != 0) return new FocasResult(rc, null);
+
+ var programPayload = FindPayload(blocks, 0x001c);
+ return new FocasResult(
+ rc,
+ new WireDynamic(
+ ReadFirstInt32(blocks, 0x001a),
+ programPayload.Length >= 4 ? ReadInt32(programPayload, 0) : 0,
+ programPayload.Length >= 8 ? ReadInt32(programPayload, 4) : 0,
+ ReadFirstInt32(blocks, 0x001d),
+ ReadFirstInt32(blocks, 0x0024),
+ ReadFirstInt32(blocks, 0x0025),
+ new WireAxisPosition(
+ ReadSelectorPosition(blocks, 0x0026, 0),
+ ReadSelectorPosition(blocks, 0x0026, 1),
+ ReadSelectorPosition(blocks, 0x0026, 2),
+ ReadSelectorPosition(blocks, 0x0026, 3))));
+ }
+
+ /// Read servo-meter load percentages via cnc_rdsvmeter (command 0x0056).
+ public async Task>> ReadServoMeterAsync(
+ short maxCount = 32,
+ CancellationToken cancellationToken = default,
+ TimeSpan? timeout = null,
+ ushort? pathId = null)
+ {
+ using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
+ var requestPathId = EffectivePathId(pathId);
+ var blocks = await SendRequestAsync(
+ callTimeout.Token,
+ new RequestBlock(0x0056, 1, PathId: requestPathId),
+ new RequestBlock(0x0089, PathId: requestPathId)).ConfigureAwait(false);
+
+ var rc = AggregateRc(blocks);
+ if (rc != 0) return new FocasResult>(rc, null);
+
+ var payload = FindPayload(blocks, 0x0056);
+ var result = new List();
+ for (var offset = 0; offset + 12 <= payload.Length && result.Count < maxCount; offset += 12)
+ {
+ var name = FocasWireProtocol.ReadNameRecord(payload.AsSpan(offset + 8, 4));
+ result.Add(new WireServoMeter(
+ (short)(result.Count + 1),
+ name,
+ ReadInt32(payload, offset),
+ ReadInt16(payload, offset + 4),
+ ReadInt16(payload, offset + 6)));
+ }
+
+ return new FocasResult>(rc, result);
+ }
+
+ /// Read per-spindle load percentages via cnc_rdspload (command 0x0040 with arg1=0).
+ public Task>> ReadSpindleLoadAsync(
+ short spindleSelector = -1,
+ CancellationToken cancellationToken = default,
+ TimeSpan? timeout = null,
+ ushort? pathId = null)
+ => ReadSpindleMetricAsync(0, spindleSelector, cancellationToken, timeout, pathId);
+
+ /// Read per-spindle maximum RPMs via cnc_rdspmaxrpm (command 0x0040 with arg1=1).
+ public Task>> ReadSpindleMaxRpmAsync(
+ short spindleSelector = -1,
+ CancellationToken cancellationToken = default,
+ TimeSpan? timeout = null,
+ ushort? pathId = null)
+ => ReadSpindleMetricAsync(1, spindleSelector, cancellationToken, timeout, pathId);
+
+ ///
+ /// Raw-bytes parameter read via cnc_rdparam. Caller marshals the returned
+ /// payload to the type declared in the per-series parameter catalog.
+ /// selects an axis-scoped parameter; 0 means global.
+ ///
+ public async Task> ReadParameterBytesAsync(
+ short dataNumber,
+ short axis = 0,
+ CancellationToken cancellationToken = default,
+ TimeSpan? timeout = null,
+ ushort? pathId = null)
+ {
+ using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
+ var secondArg = axis == 0 ? dataNumber : axis;
+ var block = await SendSingleRequestAsync(callTimeout.Token, new RequestBlock(0x000e, dataNumber, secondArg, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
+ return ToResult(block, payload => payload);
+ }
+
+ /// Typed Int32 parameter read — convenience over .
+ public async Task> ReadParameterAsync(
+ short dataNumber,
+ short type = 0,
+ CancellationToken cancellationToken = default,
+ TimeSpan? timeout = null,
+ ushort? pathId = null)
+ {
+ var result = await ReadParameterBytesAsync(dataNumber, cancellationToken: cancellationToken, timeout: timeout, pathId: pathId).ConfigureAwait(false);
+ if (!result.IsOk || result.Value is null) return new FocasResult(result.Rc, null);
+ return new FocasResult(
+ result.Rc,
+ new WireParameter(dataNumber, type, result.Value.Length >= 4 ? ReadInt32(result.Value, 0) : 0));
+ }
+
+ /// Typed 8-bit parameter read.
+ public async Task> ReadParameterByteAsync(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
+ {
+ var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
+ return !result.IsOk || result.Value is null
+ ? new FocasResult(result.Rc, default)
+ : new FocasResult(result.Rc, result.Value.Length >= 1 ? result.Value[0] : default);
+ }
+
+ /// Typed 16-bit parameter read.
+ public async Task> ReadParameterInt16Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
+ {
+ var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
+ return !result.IsOk || result.Value is null
+ ? new FocasResult(result.Rc, default)
+ : new FocasResult(result.Rc, result.Value.Length >= 2 ? ReadInt16(result.Value, 0) : default);
+ }
+
+ /// Typed 32-bit parameter read.
+ public async Task> ReadParameterInt32Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
+ {
+ var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
+ return !result.IsOk || result.Value is null
+ ? new FocasResult(result.Rc, default)
+ : new FocasResult(result.Rc, result.Value.Length >= 4 ? ReadInt32(result.Value, 0) : default);
+ }
+
+ /// Typed IEEE-754 single-precision parameter read.
+ public async Task> ReadParameterFloat32Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
+ {
+ var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
+ return !result.IsOk || result.Value is null || result.Value.Length < 4
+ ? new FocasResult(result.Rc, default)
+ : new FocasResult(result.Rc, BitConverter.Int32BitsToSingle(ReadInt32(result.Value, 0)));
+ }
+
+ /// Typed IEEE-754 double-precision parameter read.
+ public async Task> ReadParameterFloat64Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
+ {
+ var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
+ return !result.IsOk || result.Value is null || result.Value.Length < 8
+ ? new FocasResult(result.Rc, default)
+ : new FocasResult(result.Rc, BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64BigEndian(result.Value.AsSpan(0, 8))));
+ }
+
+ /// Read a single macro variable via cnc_rdmacro (command 0x0015).
+ public Task> ReadMacroAsync(
+ short number,
+ CancellationToken cancellationToken = default,
+ TimeSpan? timeout = null,
+ ushort? pathId = null)
+ => ReadSingleWithTimeoutAsync(
+ 0x0015,
+ payload => new WireMacro(number, payload.Length >= 4 ? ReadInt32(payload, 0) : 0, payload.Length >= 6 ? ReadInt16(payload, 4) : (short)0),
+ cancellationToken, timeout, EffectivePathId(pathId), number, number);
+
+ ///
+ /// Read a PMC range via pmc_rdpmcrng. is the numeric
+ /// address-letter code (see );
+ /// is the width code (see ). Payload is decoded into
+ /// — one entry per slot of the requested width.
+ ///
+ public async Task> ReadPmcRangeAsync(
+ short area,
+ short dataType,
+ ushort start,
+ ushort end,
+ CancellationToken cancellationToken = default,
+ TimeSpan? timeout = null,
+ ushort? pathId = null)
+ {
+ if (end < start)
+ throw new ArgumentOutOfRangeException(nameof(end), "PMC end address must be greater than or equal to start.");
+
+ using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
+ var block = await SendSingleRequestAsync(
+ callTimeout.Token,
+ new RequestBlock(0x8001, start, end, area, dataType, RequestClass: 2, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
+
+ return ToResult(block, payload =>
+ {
+ var width = dataType switch
+ {
+ 1 => 2,
+ 2 or 4 => 4,
+ 5 => 8,
+ _ => 1,
+ };
+
+ var values = new List();
+ for (var offset = 0; offset + width <= payload.Length; offset += width)
+ {
+ values.Add(width switch
+ {
+ 1 => payload[offset],
+ 2 => ReadInt16(payload, offset),
+ 4 => ReadInt32(payload, offset),
+ 8 => BinaryPrimitives.ReadInt64BigEndian(payload.AsSpan(offset, 8)),
+ _ => 0,
+ });
+ }
+
+ return new WirePmcRange(area, dataType, start, end, values);
+ });
+ }
+
+ /// Typed overload for .
+ public Task> ReadPmcRangeAsync(
+ FocasPmcArea area,
+ FocasPmcDataType dataType,
+ ushort start,
+ ushort end,
+ CancellationToken cancellationToken = default,
+ TimeSpan? timeout = null,
+ ushort? pathId = null)
+ => ReadPmcRangeAsync((short)area, (short)dataType, start, end, cancellationToken, timeout, pathId);
+
+ ///
+ /// Read active alarms via cnc_rdalmmsg2 (command 0x0023). Parses both
+ /// the 76-byte vendor ODBALMMSG2_data layout and the 80-byte legacy wire
+ /// shape so the same managed surface works across firmware revisions.
+ ///
+ public async Task>> ReadAlarmsAsync(
+ short type = -1,
+ short count = 32,
+ CancellationToken cancellationToken = default,
+ TimeSpan? timeout = null,
+ ushort? pathId = null)
+ {
+ using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
+ var block = await SendSingleRequestAsync(
+ callTimeout.Token,
+ new RequestBlock(0x0023, type, count, 2, 0x40, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
+
+ return ToResult(block, payload => ParseAlarms(payload, count));
+ }
+
+ /// Read operation mode via cnc_rdopmode, returned as the typed .
+ public Task> ReadOperationModeAsync(
+ CancellationToken cancellationToken = default,
+ TimeSpan? timeout = null,
+ ushort? pathId = null)
+ => ReadSingleWithTimeoutAsync(
+ 0x0057,
+ payload => (FocasOperationMode)(payload.Length >= 2 ? ReadInt16(payload, 0) : (short)0),
+ cancellationToken, timeout, EffectivePathId(pathId));
+
+ ///
+ /// Raw-code variant of — returns the underlying
+ /// FOCAS short so callers storing the raw mode code (e.g. OtOpcUa's
+ /// FocasProgramInfo.Mode int field) don't have to cast the enum.
+ ///
+ public Task> ReadOperationModeCodeAsync(
+ CancellationToken cancellationToken = default,
+ TimeSpan? timeout = null,
+ ushort? pathId = null)
+ => ReadSingleWithTimeoutAsync(
+ 0x0057,
+ payload => payload.Length >= 2 ? ReadInt16(payload, 0) : (short)0,
+ cancellationToken, timeout, EffectivePathId(pathId));
+
+ /// Read the currently-executing program name + O-number via cnc_exeprgname2 (command 0x00fc).
+ public Task> ReadExecutingProgramNameAsync(
+ CancellationToken cancellationToken = default,
+ TimeSpan? timeout = null,
+ ushort? pathId = null)
+ => ReadSingleWithTimeoutAsync(0x00fc, ParseProgramName, cancellationToken, timeout, EffectivePathId(pathId));
+
+ /// Read the executed block count via cnc_rdblkcount.
+ public Task> ReadBlockCountAsync(
+ CancellationToken cancellationToken = default,
+ TimeSpan? timeout = null,
+ ushort? pathId = null)
+ => ReadSingleWithTimeoutAsync(
+ 0x0035,
+ payload => payload.Length >= 4 ? ReadInt32(payload, 0) : 0,
+ cancellationToken, timeout, EffectivePathId(pathId));
+
+ ///
+ /// Read one cumulative timer via cnc_rdtimer. selects
+ /// PowerOn / Operating / Cutting / Cycle per the FOCAS spec (0..3).
+ ///
+ public Task> ReadTimerAsync(
+ short type,
+ CancellationToken cancellationToken = default,
+ TimeSpan? timeout = null,
+ ushort? pathId = null)
+ => ReadSingleWithTimeoutAsync(
+ 0x0120,
+ payload => new WireTimer(type, payload.Length >= 4 ? ReadInt32(payload, 0) : 0, payload.Length >= 8 ? ReadInt32(payload, 4) : 0),
+ cancellationToken, timeout, EffectivePathId(pathId), type);
+
+ // ---- internal plumbing ------------------------------------------------------------
+
+ private async Task>> ReadSpindleMetricAsync(
+ int metric, short spindleSelector, CancellationToken cancellationToken, TimeSpan? timeout, ushort? pathId)
+ {
+ using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
+ var block = await SendSingleRequestAsync(
+ callTimeout.Token,
+ new RequestBlock(0x0040, metric, spindleSelector, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
+
+ return ToResult>(block, payload =>
+ {
+ var values = new List();
+ for (var offset = 0; offset + 8 <= payload.Length; offset += 8)
+ values.Add(new WireSpindleMetric((short)(values.Count + 1), ReadInt32(payload, offset)));
+ return values;
+ });
+ }
+
+ private async Task> ReadSingleAsync(
+ ushort command,
+ Func parser,
+ ushort? pathId = null,
+ int arg1 = 0,
+ int arg2 = 0,
+ int arg3 = 0,
+ int arg4 = 0,
+ CancellationToken cancellationToken = default)
+ {
+ var block = await SendSingleRequestAsync(cancellationToken, new RequestBlock(command, arg1, arg2, arg3, arg4, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
+ return ToResult(block, parser);
+ }
+
+ private async Task> ReadSingleWithTimeoutAsync(
+ ushort command,
+ Func parser,
+ CancellationToken cancellationToken,
+ TimeSpan? timeout,
+ ushort pathId,
+ int arg1 = 0,
+ int arg2 = 0,
+ int arg3 = 0,
+ int arg4 = 0)
+ {
+ using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
+ return await ReadSingleAsync(command, parser, pathId, arg1, arg2, arg3, arg4, callTimeout.Token).ConfigureAwait(false);
+ }
+
+ private async Task SendSingleRequestAsync(CancellationToken cancellationToken, RequestBlock block)
+ {
+ var blocks = await SendRequestAsync(cancellationToken, block).ConfigureAwait(false);
+ return blocks.Count == 0 ? new ResponseBlock(block.Command, 0, Array.Empty()) : blocks[0];
+ }
+
+ private async Task> SendRequestAsync(CancellationToken cancellationToken, params RequestBlock[] blocks)
+ {
+ EnsureConnected();
+ await _requestGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ var requestStarted = false;
+ try
+ {
+ var body = FocasWireProtocol.BuildRequestBody(blocks);
+ requestStarted = true;
+ await SendPduAsync(_stream2!, FocasWireProtocol.TypeData, body, cancellationToken).ConfigureAwait(false);
+ var response = await ReadExpectedPduAsync(_stream2!, FocasWireProtocol.TypeData, cancellationToken).ConfigureAwait(false);
+ var responseBlocks = FocasWireProtocol.ParseResponseBlocks(response.Body);
+ foreach (var block in responseBlocks)
+ _logger?.LogDebug("FOCAS response command=0x{Command:x4} rc={Rc} payloadLength={PayloadLength}", block.Command, block.Rc, block.Payload.Length);
+ return responseBlocks;
+ }
+ catch (Exception ex) when (requestStarted && IsTransientException(ex))
+ {
+ // A cancelled or failed mid-request write leaves the wire in an undefined state —
+ // tear the connection down so the next caller reconnects cleanly instead of
+ // consuming a stale response.
+ CloseTransport();
+ throw new FocasWireException("FOCAS wire request failed; connection was closed to avoid response desynchronization.", ex, isTransient: true);
+ }
+ finally
+ {
+ _requestGate.Release();
+ }
+ }
+
+ private static async Task ConnectSocketAsync(string host, int port, CancellationToken cancellationToken)
+ {
+ var socket = new TcpClient { NoDelay = true };
+ try
+ {
+ await WithCancellation(socket.ConnectAsync(host, port), cancellationToken).ConfigureAwait(false);
+ return socket;
+ }
+ catch
+ {
+ socket.Dispose();
+ throw;
+ }
+ }
+
+ private static async Task SendPduAsync(NetworkStream stream, byte type, ReadOnlyMemory body, CancellationToken cancellationToken)
+ {
+ var pdu = FocasWireProtocol.BuildPdu(type, FocasWireProtocol.DirectionRequest, body.Span);
+ await stream.WriteAsync(pdu, 0, pdu.Length, cancellationToken).ConfigureAwait(false);
+ await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ private static void SendPdu(NetworkStream stream, byte type, ReadOnlySpan body)
+ {
+ var pdu = FocasWireProtocol.BuildPdu(type, FocasWireProtocol.DirectionRequest, body);
+ stream.Write(pdu, 0, pdu.Length);
+ stream.Flush();
+ }
+
+ private void ThrowIfDisposed()
+ {
+ if (_disposed) throw new ObjectDisposedException(nameof(FocasWireClient));
+ }
+
+ private static async Task WithCancellation(Task task, CancellationToken cancellationToken)
+ {
+ if (!cancellationToken.CanBeCanceled)
+ {
+ await task.ConfigureAwait(false);
+ return;
+ }
+
+ var cancellation = new TaskCompletionSource();
+ using var registration = cancellationToken.Register(static state => ((TaskCompletionSource)state!).TrySetResult(true), cancellation);
+ if (task != await Task.WhenAny(task, cancellation.Task).ConfigureAwait(false))
+ throw new OperationCanceledException(cancellationToken);
+ await task.ConfigureAwait(false);
+ }
+
+ private static CancellationTokenSource CreateCallTimeout(CancellationToken cancellationToken, TimeSpan? timeout)
+ {
+ var source = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ if (timeout is { } value) source.CancelAfter(value);
+ return source;
+ }
+
+ private static async Task ReadExpectedPduAsync(NetworkStream stream, byte expectedType, CancellationToken cancellationToken)
+ {
+ var pdu = await FocasWireProtocol.ReadPduAsync(stream, cancellationToken).ConfigureAwait(false);
+ if (pdu.Type != expectedType || pdu.Direction != FocasWireProtocol.DirectionResponse)
+ throw new FocasWireException($"Unexpected FOCAS PDU type 0x{pdu.Type:x2}, direction 0x{pdu.Direction:x2}.", rc: null);
+ return pdu;
+ }
+
+ private void EnsureConnected()
+ {
+ ThrowIfDisposed();
+ if (!_connected || _stream2 is null)
+ throw new FocasWireException("FOCAS wire client is not connected.", rc: null, isTransient: true);
+ }
+
+ private void CloseTransport()
+ {
+ _connected = false;
+ _sysInfo = null;
+ _stream1?.Dispose();
+ _stream2?.Dispose();
+ _socket1?.Dispose();
+ _socket2?.Dispose();
+ _stream1 = null;
+ _stream2 = null;
+ _socket1 = null;
+ _socket2 = null;
+ }
+
+ private ushort EffectivePathId(ushort? pathId) => pathId ?? PathId;
+
+ private static FocasResult ToResult(ResponseBlock block, Func parser)
+ => block.Rc != 0
+ ? new FocasResult(block.Rc, default)
+ : new FocasResult(block.Rc, parser(block.Payload));
+
+ private static short AggregateRc(IReadOnlyList blocks)
+ => blocks.FirstOrDefault(block => block.Rc != 0)?.Rc ?? 0;
+
+ private static byte[] FindPayload(IReadOnlyList blocks, ushort command)
+ => blocks.FirstOrDefault(block => block.Command == command)?.Payload ?? Array.Empty();
+
+ private static int ReadFirstInt32(IReadOnlyList blocks, ushort command)
+ {
+ var payload = FindPayload(blocks, command);
+ return payload.Length >= 4 ? ReadInt32(payload, 0) : 0;
+ }
+
+ private static int ReadSelectorPosition(IReadOnlyList blocks, ushort command, int selectorIndex)
+ {
+ var seen = 0;
+ foreach (var block in blocks)
+ {
+ if (block.Command != command) continue;
+ if (seen == selectorIndex)
+ return block.Payload.Length >= 4 ? ReadInt32(block.Payload, 0) : 0;
+ seen++;
+ }
+ return 0;
+ }
+
+ private static WireSysInfo ParseSysInfo(byte[] payload)
+ {
+ RequireLength(payload, 16, "cnc_sysinfo");
+ return new WireSysInfo(
+ ReadInt16(payload, 0),
+ ReadInt16(payload, 2),
+ FocasWireProtocol.ReadAscii(payload.AsSpan(4, 2)),
+ FocasWireProtocol.ReadAscii(payload.AsSpan(6, 2)),
+ FocasWireProtocol.ReadAscii(payload.AsSpan(8, 4)),
+ FocasWireProtocol.ReadAscii(payload.AsSpan(12, 4)),
+ payload.Length >= 18 ? FocasWireProtocol.ReadAscii(payload.AsSpan(16, 2)) : string.Empty);
+ }
+
+ private static WireProgramName ParseProgramName(byte[] payload)
+ {
+ var nameLength = payload.Length >= 40 ? 36 : payload.Length;
+ var name = FocasWireProtocol.ReadAscii(payload.AsSpan(0, nameLength));
+ var number = payload.Length >= 40 ? ReadInt32(payload, 36) : (int?)null;
+ return new WireProgramName(name, number);
+ }
+
+ private static IReadOnlyList ParseAlarms(byte[] payload, short count)
+ => payload.Length % 76 == 0
+ ? ParseVendorAlarms(payload, count)
+ : ParseLegacyWireAlarms(payload, count);
+
+ private static IReadOnlyList ParseVendorAlarms(byte[] payload, short count)
+ {
+ var alarms = new List();
+ for (var offset = 0; offset + 76 <= payload.Length && alarms.Count < count; offset += 76)
+ {
+ var messageLength = ReadInt16(payload, offset + 10);
+ alarms.Add(new WireAlarm(
+ ReadInt32(payload, offset),
+ ReadInt16(payload, offset + 4),
+ ReadInt16(payload, offset + 6),
+ messageLength,
+ FocasWireProtocol.ReadAscii(payload.AsSpan(offset + 12, 64))));
+ }
+ return alarms;
+ }
+
+ private static IReadOnlyList ParseLegacyWireAlarms(byte[] payload, short count)
+ {
+ var alarms = new List();
+ for (var offset = 0; offset + 80 <= payload.Length && alarms.Count < count; offset += 80)
+ {
+ alarms.Add(new WireAlarm(
+ ReadInt32(payload, offset),
+ (short)ReadInt32(payload, offset + 4),
+ (short)ReadInt32(payload, offset + 8),
+ (short)ReadInt32(payload, offset + 12),
+ FocasWireProtocol.ReadAscii(payload.AsSpan(offset + 16, 64))));
+ }
+ return alarms;
+ }
+
+ private static IReadOnlyList ReadNameRecords(byte[] payload, short maxCount, Func factory)
+ {
+ var names = new List();
+ for (var offset = 0; offset + 4 <= payload.Length && offset / 4 < maxCount; offset += 4)
+ {
+ var name = FocasWireProtocol.ReadNameRecord(payload.AsSpan(offset, 4));
+ if (!string.IsNullOrWhiteSpace(name))
+ names.Add(factory((short)((offset / 4) + 1), name));
+ }
+ return names;
+ }
+
+ private static void RequireLength(byte[] payload, int length, string call)
+ {
+ if (payload.Length < length)
+ throw new FocasWireException($"{call} returned {payload.Length} bytes; expected at least {length}.", rc: null);
+ }
+
+ private static bool IsTransientException(Exception exception)
+ => exception is IOException or SocketException or TimeoutException or OperationCanceledException
+ || exception.InnerException is IOException or SocketException or TimeoutException or OperationCanceledException;
+
+ private static short ReadInt16(byte[] bytes, int offset)
+ => BinaryPrimitives.ReadInt16BigEndian(bytes.AsSpan(offset, 2));
+
+ private static int ReadInt32(byte[] bytes, int offset)
+ => BinaryPrimitives.ReadInt32BigEndian(bytes.AsSpan(offset, 4));
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireException.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireException.cs
new file mode 100644
index 0000000..9b18ca7
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireException.cs
@@ -0,0 +1,51 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
+
+///
+/// Thrown by the wire client when a FOCAS request fails — either at the protocol layer
+/// (invalid PDU magic, desynchronised response framing, connection dropped mid-request)
+/// or when the CNC returns a non-zero EW_* return code.
+///
+///
+/// Callers distinguish the two classes via : true
+/// when the transport is gone (socket closed, timeout, cancellation mid-write) and the
+/// wire client has already torn the sockets down, so a reconnect is required before any
+/// further call. false for protocol-level errors where the connection is still
+/// usable.
+/// carries the wire-level FOCAS return code when the exception
+/// came from a parsed response block. Null when the failure happened before a response
+/// was received (e.g. connect-time handshake errors).
+///
+public class FocasWireException : Exception
+{
+ /// FOCAS EW_* return code from the response block, when available.
+ public short? Rc { get; }
+
+ ///
+ /// True when the transport was closed as a side effect of this failure — the caller
+ /// must reconnect before issuing the next request.
+ ///
+ public bool IsTransient { get; }
+
+ public FocasWireException(string message)
+ : base(message)
+ {
+ }
+
+ public FocasWireException(string message, short? rc, bool isTransient = false)
+ : base(message)
+ {
+ Rc = rc;
+ IsTransient = isTransient;
+ }
+
+ public FocasWireException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+
+ public FocasWireException(string message, Exception innerException, bool isTransient)
+ : base(message, innerException)
+ {
+ IsTransient = isTransient;
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs
new file mode 100644
index 0000000..abdc1d7
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs
@@ -0,0 +1,131 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
+
+///
+/// Return envelope over a parsed wire response. carries the FOCAS
+/// EW_* code from the response block — 0 / means the
+/// call succeeded and is populated; non-zero means the CNC rejected
+/// the call and is default. Callers use the RC to distinguish
+/// "feature missing on this series" (EW_FUNC / EW_NOOPT) from genuine
+/// failures.
+///
+public readonly record struct FocasResult(short Rc, T? Value)
+{
+ /// True when is zero (EW_OK).
+ public bool IsOk => Rc == 0;
+}
+
+/// CNC identity payload returned by cnc_sysinfo.
+public sealed record WireSysInfo(
+ short AddInfo,
+ short MaxAxis,
+ string CncType,
+ string MachineType,
+ string Series,
+ string Version,
+ string Axes);
+
+/// Coarse CNC state bits returned by cnc_statinfo — the seven-word status block plus TM mode.
+public sealed record WireStatus(
+ short Auto,
+ short Run,
+ short Motion,
+ short Mstb,
+ short Emergency,
+ short Alarm,
+ short Edit,
+ short TmMode);
+
+/// Four-slot position quadruple for one axis: absolute / machine / relative / distance-to-go.
+public sealed record WireAxisPosition(
+ int Absolute,
+ int Machine,
+ int Relative,
+ int Distance);
+
+///
+/// Fast-poll bundle for one axis from cnc_rddynamic2 — alarm flags, active program
+/// numbers, sequence number, actual feed rate, actual spindle speed, and the position
+/// quadruple.
+///
+public sealed record WireDynamic(
+ int Alarm,
+ int ProgramNumber,
+ int MainProgramNumber,
+ int SequenceNumber,
+ int FeedRate,
+ int SpindleSpeed,
+ WireAxisPosition Axis);
+
+/// One servo-meter entry from cnc_rdsvmeter — per-axis load percentage (scale by 10^).
+public sealed record WireServoMeter(
+ short Index,
+ string Name,
+ int Value,
+ short Decimal,
+ short Unit);
+
+/// One spindle metric slot from cnc_rdspload / cnc_rdspmaxrpm.
+public sealed record WireSpindleMetric(
+ short Index,
+ int Value);
+
+///
+/// One axis-name slot from cnc_rdaxisname. is the 1-based
+/// axis index (preserved even when the name is empty so callers can pass it to
+/// cnc_rddynamic2).
+///
+public readonly record struct WireAxisRecord(short Index, string Name);
+
+/// One spindle-name slot from cnc_rdspdlname.
+public readonly record struct WireSpindleRecord(short Index, string Name);
+
+/// Parameter value returned by cnc_rdparam, interpreted as a scalar Int32.
+public sealed record WireParameter(
+ short DataNumber,
+ short Type,
+ int Value);
+
+///
+/// Macro variable from cnc_rdmacro. Scaled decimal: the callable value is
+/// Value / 10^Decimal.
+///
+public sealed record WireMacro(
+ short Number,
+ int Value,
+ short Decimal);
+
+/// PMC range read-back from pmc_rdpmcrng: one or more values of the requested width.
+public sealed record WirePmcRange(
+ short Area,
+ short DataType,
+ ushort Start,
+ ushort End,
+ IReadOnlyList Values);
+
+///
+/// One active alarm from cnc_rdalmmsg2. Mirrors the vendor ODBALMMSG2
+/// layout; is populated when the wire responder carries it
+/// (currently null for both the 76-byte vendor shape and the 80-byte legacy
+/// shape).
+///
+public sealed record WireAlarm(
+ int AlarmNumber,
+ short Type,
+ short Axis,
+ short MessageLength,
+ string Message,
+ int? AlarmGroup = null);
+
+///
+/// Executing-program identity from cnc_exeprgname2: the NUL-terminated name and
+/// the trailing 32-bit O-number (null when the wire responder omits the trailing int).
+///
+public sealed record WireProgramName(
+ string Name,
+ int? ONumber);
+
+/// One cumulative timer reading from cnc_rdtimer (minutes + fractional milliseconds).
+public sealed record WireTimer(
+ short Type,
+ int Minutes,
+ int Milliseconds);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireProtocol.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireProtocol.cs
new file mode 100644
index 0000000..c24b154
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireProtocol.cs
@@ -0,0 +1,250 @@
+using System.Buffers.Binary;
+using System.Net.Sockets;
+using System.Text;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
+
+///
+/// Framing primitives for the FOCAS/2 Ethernet wire protocol — magic-prefixed PDU
+/// header + request/response block envelopes. Read-only subset: every call OtOpcUa
+/// issues maps to one of the command IDs documented in
+/// docs/v2/implementation/focas-wire-protocol.md.
+///
+///
+/// All multi-byte integer fields are big-endian on the wire. The 10-byte header is
+/// a0 a0 a0 a0 magic + 2-byte version + type byte + direction byte + 2-byte body
+/// length. Version 1 is the only version this implementation supports.
+/// Type 0x01 is the initiate handshake, 0x02 is the session close,
+/// 0x21 is a request/response data PDU carrying one or more request blocks.
+///
+internal static class FocasWireProtocol
+{
+ public const ushort Version = 1;
+ public const byte DirectionRequest = 0x01;
+ public const byte DirectionResponse = 0x02;
+ public const byte TypeInitiate = 0x01;
+ public const byte TypeClose = 0x02;
+ public const byte TypeData = 0x21;
+
+ private static readonly byte[] Magic = [0xa0, 0xa0, 0xa0, 0xa0];
+
+ /// Assemble a full PDU (10-byte header + body) for transmission.
+ public static byte[] BuildPdu(byte type, byte direction, ReadOnlySpan body)
+ {
+ if (body.Length > ushort.MaxValue)
+ throw new ArgumentOutOfRangeException(nameof(body), "FOCAS PDU body is limited to 65535 bytes.");
+
+ var bytes = new byte[10 + body.Length];
+ Magic.CopyTo(bytes, 0);
+ BinaryPrimitives.WriteUInt16BigEndian(bytes.AsSpan(4, 2), Version);
+ bytes[6] = type;
+ bytes[7] = direction;
+ BinaryPrimitives.WriteUInt16BigEndian(bytes.AsSpan(8, 2), (ushort)body.Length);
+ body.CopyTo(bytes.AsSpan(10));
+ return bytes;
+ }
+
+ ///
+ /// Initiate-body shape — just the 2-byte socket index (1 or 2). cnc_allclibhndl3
+ /// opens two TCP sockets in sequence and each sends its own initiate PDU carrying its
+ /// index.
+ ///
+ public static byte[] BuildInitiateBody(ushort socketIndex)
+ {
+ var body = new byte[2];
+ BinaryPrimitives.WriteUInt16BigEndian(body, socketIndex);
+ return body;
+ }
+
+ /// Assemble a type-0x21 body carrying one or more request blocks.
+ public static byte[] BuildRequestBody(IReadOnlyList blocks)
+ {
+ if (blocks.Count > ushort.MaxValue)
+ throw new ArgumentOutOfRangeException(nameof(blocks), "Too many request blocks.");
+
+ var blockBytes = blocks.Select(BuildRequestBlock).ToArray();
+ var bodyLength = 2 + blockBytes.Sum(block => block.Length);
+ if (bodyLength > ushort.MaxValue)
+ throw new ArgumentOutOfRangeException(nameof(blocks), "FOCAS request body is too large.");
+
+ var body = new byte[bodyLength];
+ BinaryPrimitives.WriteUInt16BigEndian(body.AsSpan(0, 2), (ushort)blocks.Count);
+ var offset = 2;
+ foreach (var block in blockBytes)
+ {
+ block.CopyTo(body.AsSpan(offset));
+ offset += block.Length;
+ }
+ return body;
+ }
+
+ /// Async read of one full PDU off a stream. Throws on invalid magic / version / truncation.
+ public static async Task ReadPduAsync(NetworkStream stream, CancellationToken cancellationToken)
+ {
+ var header = new byte[10];
+ await ReadExactlyAsync(stream, header, cancellationToken).ConfigureAwait(false);
+
+ if (!header.AsSpan(0, 4).SequenceEqual(Magic))
+ throw new FocasWireException("Invalid FOCAS PDU magic.");
+
+ var version = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4, 2));
+ if (version != Version)
+ throw new FocasWireException($"Unsupported FOCAS PDU version {version}.");
+
+ var bodyLength = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(8, 2));
+ var body = new byte[bodyLength];
+ if (bodyLength > 0)
+ await ReadExactlyAsync(stream, body, cancellationToken).ConfigureAwait(false);
+
+ return new Pdu(header[6], header[7], body);
+ }
+
+ /// Synchronous counterpart to — used by 's sync dispose.
+ public static Pdu ReadPdu(NetworkStream stream)
+ {
+ var header = new byte[10];
+ ReadExactly(stream, header);
+
+ if (!header.AsSpan(0, 4).SequenceEqual(Magic))
+ throw new FocasWireException("Invalid FOCAS PDU magic.");
+
+ var version = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4, 2));
+ if (version != Version)
+ throw new FocasWireException($"Unsupported FOCAS PDU version {version}.");
+
+ var bodyLength = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(8, 2));
+ var body = new byte[bodyLength];
+ if (bodyLength > 0)
+ ReadExactly(stream, body);
+
+ return new Pdu(header[6], header[7], body);
+ }
+
+ private static async Task ReadExactlyAsync(NetworkStream stream, byte[] buffer, CancellationToken cancellationToken)
+ {
+ var offset = 0;
+ while (offset < buffer.Length)
+ {
+ var read = await stream.ReadAsync(buffer, offset, buffer.Length - offset, cancellationToken).ConfigureAwait(false);
+ if (read == 0)
+ throw new EndOfStreamException("FOCAS socket closed before the expected number of bytes were read.");
+ offset += read;
+ }
+ }
+
+ private static void ReadExactly(NetworkStream stream, byte[] buffer)
+ {
+ var offset = 0;
+ while (offset < buffer.Length)
+ {
+ var read = stream.Read(buffer, offset, buffer.Length - offset);
+ if (read == 0)
+ throw new EndOfStreamException("FOCAS socket closed before the expected number of bytes were read.");
+ offset += read;
+ }
+ }
+
+ ///
+ /// Unpack a type-0x21 response body into its constituent response blocks. Each
+ /// block carries the command ID, the FOCAS EW_* return code, and the payload
+ /// bytes.
+ ///
+ public static IReadOnlyList ParseResponseBlocks(ReadOnlySpan body)
+ {
+ if (body.Length < 2)
+ return Array.Empty();
+
+ var count = BinaryPrimitives.ReadUInt16BigEndian(body.Slice(0, 2));
+ var blocks = new List(count);
+ var offset = 2;
+ for (var index = 0; index < count; index++)
+ {
+ if (offset + 2 > body.Length)
+ throw new FocasWireException("Truncated FOCAS response block length.");
+
+ var blockLength = BinaryPrimitives.ReadUInt16BigEndian(body.Slice(offset, 2));
+ if (blockLength < 0x10 || offset + blockLength > body.Length)
+ throw new FocasWireException($"Invalid FOCAS response block length {blockLength}.");
+
+ var block = body.Slice(offset, blockLength);
+ var command = BinaryPrimitives.ReadUInt16BigEndian(block.Slice(6, 2));
+ var payloadLength = BinaryPrimitives.ReadUInt16BigEndian(block.Slice(14, 2));
+ if (0x10 + payloadLength > blockLength)
+ throw new FocasWireException("Invalid FOCAS response payload length.");
+
+ var rc = BinaryPrimitives.ReadInt16BigEndian(block.Slice(8, 2));
+ blocks.Add(new ResponseBlock(command, rc, block.Slice(16, payloadLength).ToArray()));
+ offset += blockLength;
+ }
+
+ return blocks;
+ }
+
+ /// Read an ASCII string out of a payload span, stopping at the first NUL and trimming trailing spaces.
+ public static string ReadAscii(ReadOnlySpan bytes)
+ {
+ var end = bytes.IndexOf((byte)0);
+ if (end >= 0) bytes = bytes.Slice(0, end);
+ return Encoding.ASCII.GetString(bytes.ToArray()).TrimEnd(' ', '\0');
+ }
+
+ ///
+ /// Read an axis/spindle name record — the first 2 bytes of a 2-byte (axis) or 4-byte
+ /// (spindle) slot. Trailing spaces and NULs are stripped so "X " becomes
+ /// "X".
+ ///
+ public static string ReadNameRecord(ReadOnlySpan bytes)
+ {
+ if (bytes.Length < 2) return string.Empty;
+ var buffer = bytes.Slice(0, Math.Min(2, bytes.Length)).ToArray();
+ return Encoding.ASCII.GetString(buffer).TrimEnd(' ', '\0');
+ }
+
+ private static byte[] BuildRequestBlock(RequestBlock request)
+ {
+ var extra = request.ExtraPayload ?? Array.Empty();
+ if (extra.Length > ushort.MaxValue)
+ throw new ArgumentOutOfRangeException(nameof(request), "FOCAS request extra payload is too large.");
+
+ var blockLength = 0x1c + extra.Length;
+ if (blockLength > ushort.MaxValue)
+ throw new ArgumentOutOfRangeException(nameof(request), "FOCAS request block is too large.");
+
+ var block = new byte[blockLength];
+ BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(0, 2), (ushort)blockLength);
+ BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(2, 2), request.RequestClass);
+ BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(4, 2), request.PathId);
+ BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(6, 2), request.Command);
+ BinaryPrimitives.WriteInt32BigEndian(block.AsSpan(8, 4), request.Arg1);
+ BinaryPrimitives.WriteInt32BigEndian(block.AsSpan(12, 4), request.Arg2);
+ BinaryPrimitives.WriteInt32BigEndian(block.AsSpan(16, 4), request.Arg3);
+ BinaryPrimitives.WriteInt32BigEndian(block.AsSpan(20, 4), request.Arg4);
+ BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(24, 2), request.Arg5);
+ BinaryPrimitives.WriteUInt16BigEndian(block.AsSpan(26, 2), (ushort)extra.Length);
+ extra.CopyTo(block.AsSpan(28));
+ return block;
+ }
+}
+
+/// One raw PDU off the wire — header bytes plus the body.
+internal sealed record Pdu(byte Type, byte Direction, byte[] Body);
+
+///
+/// One request block within a type-0x21 PDU body. is the
+/// FOCAS command ID (e.g. 0x0018 for sysinfo); ..
+/// are the command-specific scalar arguments; carries the
+/// optional extra bytes for writes.
+///
+internal sealed record RequestBlock(
+ ushort Command,
+ int Arg1 = 0,
+ int Arg2 = 0,
+ int Arg3 = 0,
+ int Arg4 = 0,
+ ushort Arg5 = 0,
+ ushort RequestClass = 1,
+ ushort PathId = 1,
+ byte[]? ExtraPayload = null);
+
+/// One response block — command ID + FOCAS return code + payload bytes.
+internal sealed record ResponseBlock(ushort Command, short Rc, byte[] Payload);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs
new file mode 100644
index 0000000..c7273ee
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs
@@ -0,0 +1,333 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
+
+///
+/// implementation backed by the in-tree managed
+/// . No P/Invoke, no Fwlib64.dll, no out-of-process
+/// Host — the wire client dials the CNC on TCP:8193 directly and speaks the FOCAS/2
+/// Ethernet binary protocol.
+///
+///
+/// OtOpcUa is read-only against FOCAS. returns
+/// for every address — the managed wire
+/// client intentionally does not expose cnc_wrparam / pmc_wrpmcrng /
+/// cnc_wrmacro.
+///
+public sealed class WireFocasClient : IFocasClient
+{
+ private readonly FocasWireClient _wire = new();
+ private FocasHostAddress? _address;
+
+ public bool IsConnected => _wire.IsConnected;
+
+ public async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ if (_wire.IsConnected) return;
+ _address = address;
+ // FocasWireClient.ConnectAsync interprets TimeSpan.Zero as "no timeout" — clamp the
+ // driver's default TimeSpan to at least 1s so a caller passing TimeSpan.Zero gets a
+ // sane fail-fast instead of hanging indefinitely.
+ var effective = timeout <= TimeSpan.Zero ? TimeSpan.FromSeconds(1) : timeout;
+ await _wire.ConnectAsync(address.Host, address.Port, effective, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task<(object? value, uint status)> ReadAsync(
+ FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
+ {
+ if (!_wire.IsConnected) return (null, FocasStatusMapper.BadCommunicationError);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ return address.Kind switch
+ {
+ FocasAreaKind.Pmc => await ReadPmcAsync(address, type, cancellationToken).ConfigureAwait(false),
+ FocasAreaKind.Parameter => await ReadParameterAsync(address, type, cancellationToken).ConfigureAwait(false),
+ FocasAreaKind.Macro => await ReadMacroAsync(address, cancellationToken).ConfigureAwait(false),
+ _ => (null, FocasStatusMapper.BadNotSupported),
+ };
+ }
+
+ public Task WriteAsync(
+ FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
+ => Task.FromResult(FocasStatusMapper.BadNotWritable);
+
+ public async Task ProbeAsync(CancellationToken cancellationToken)
+ {
+ if (!_wire.IsConnected) return false;
+ try
+ {
+ var result = await _wire.ReadStatusAsync(cancellationToken).ConfigureAwait(false);
+ return result.IsOk;
+ }
+ catch (FocasWireException)
+ {
+ return false;
+ }
+ }
+
+ public async Task> ReadAlarmsAsync(CancellationToken cancellationToken)
+ {
+ if (!_wire.IsConnected) return [];
+ try
+ {
+ var result = await _wire.ReadAlarmsAsync(FocasAlarmType.All, 32, cancellationToken).ConfigureAwait(false);
+ if (!result.IsOk || result.Value is null) return [];
+ return result.Value.Select(Map).ToList();
+ }
+ catch (FocasWireException)
+ {
+ return [];
+ }
+
+ static FocasActiveAlarm Map(WireAlarm a) => new(
+ AlarmNumber: a.AlarmNumber,
+ Type: a.Type,
+ Axis: a.Axis,
+ Message: a.Message ?? string.Empty);
+ }
+
+ public async Task GetSysInfoAsync(CancellationToken cancellationToken)
+ {
+ RequireConnected();
+ var result = await _wire.ReadSysInfoAsync(cancellationToken).ConfigureAwait(false);
+ ThrowIfRcNonZero(result.Rc, "cnc_sysinfo", result.IsOk);
+ var info = result.Value!;
+ // Fanuc right-pads the ASCII axis count with spaces; fall back to MaxAxis if the
+ // text field isn't interpretable as an integer.
+ var axesCount = int.TryParse(info.Axes?.Trim(), out var parsed) ? parsed : info.MaxAxis;
+ return new FocasSysInfo(
+ AddInfo: info.AddInfo,
+ MaxAxis: info.MaxAxis,
+ CncType: info.CncType ?? string.Empty,
+ MtType: info.MachineType ?? string.Empty,
+ Series: info.Series ?? string.Empty,
+ Version: info.Version ?? string.Empty,
+ AxesCount: axesCount);
+ }
+
+ public async Task> GetAxisNamesAsync(CancellationToken cancellationToken)
+ {
+ if (!_wire.IsConnected) return [];
+ var result = await _wire.ReadAxisNamesAsync(32, cancellationToken).ConfigureAwait(false);
+ if (!result.IsOk || result.Value is null) return [];
+ return result.Value.Select(SplitAxis).Where(n => n.Name.Length > 0).ToList();
+
+ // FocasWireClient returns axis records as a single Name string (e.g. "X" or "X1").
+ // IFocasClient wants Name + Suffix split — the first char is the axis letter, the
+ // rest is the multi-channel suffix.
+ static FocasAxisName SplitAxis(WireAxisRecord r)
+ {
+ var n = r.Name ?? string.Empty;
+ return n.Length == 0
+ ? new FocasAxisName(string.Empty, string.Empty)
+ : new FocasAxisName(n[..1], n.Length > 1 ? n[1..] : string.Empty);
+ }
+ }
+
+ public async Task> GetSpindleNamesAsync(CancellationToken cancellationToken)
+ {
+ if (!_wire.IsConnected) return [];
+ var result = await _wire.ReadSpindleNamesAsync(8, cancellationToken).ConfigureAwait(false);
+ if (!result.IsOk || result.Value is null) return [];
+ return result.Value.Select(SplitSpindle).Where(n => n.Name.Length > 0).ToList();
+
+ static FocasSpindleName SplitSpindle(WireSpindleRecord r)
+ {
+ var n = r.Name ?? string.Empty;
+ return new FocasSpindleName(
+ Name: n.Length > 0 ? n[..1] : string.Empty,
+ Suffix1: n.Length > 1 ? n[1..2] : string.Empty,
+ Suffix2: n.Length > 2 ? n[2..3] : string.Empty,
+ Suffix3: n.Length > 3 ? n[3..4] : string.Empty);
+ }
+ }
+
+ public async Task ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken)
+ {
+ RequireConnected();
+ var result = await _wire.ReadDynamic2Async((short)axisIndex, cancellationToken).ConfigureAwait(false);
+ ThrowIfRcNonZero(result.Rc, "cnc_rddynamic2", result.IsOk);
+ var d = result.Value!;
+ var pos = d.Axis ?? new WireAxisPosition(0, 0, 0, 0);
+ return new FocasDynamicSnapshot(
+ AxisIndex: axisIndex,
+ AlarmFlags: d.Alarm,
+ ProgramNumber: d.ProgramNumber,
+ MainProgramNumber: d.MainProgramNumber,
+ SequenceNumber: d.SequenceNumber,
+ ActualFeedRate: d.FeedRate,
+ ActualSpindleSpeed: d.SpindleSpeed,
+ AbsolutePosition: pos.Absolute,
+ MachinePosition: pos.Machine,
+ RelativePosition: pos.Relative,
+ DistanceToGo: pos.Distance);
+ }
+
+ public async Task GetProgramInfoAsync(CancellationToken cancellationToken)
+ {
+ RequireConnected();
+ var nameResult = await _wire.ReadExecutingProgramNameAsync(cancellationToken).ConfigureAwait(false);
+ var blkResult = await _wire.ReadBlockCountAsync(cancellationToken).ConfigureAwait(false);
+ // Use the raw short variant — FocasProgramInfo.Mode stores the integer code so the
+ // managed ToText path in FocasOpMode can map it for display.
+ var modeResult = await _wire.ReadOperationModeCodeAsync(cancellationToken).ConfigureAwait(false);
+
+ var wireName = nameResult.Value;
+ return new FocasProgramInfo(
+ Name: wireName?.Name ?? string.Empty,
+ ONumber: wireName?.ONumber ?? 0,
+ BlockCount: blkResult.IsOk ? blkResult.Value : 0,
+ Mode: modeResult.IsOk ? modeResult.Value : 0);
+ }
+
+ public async Task GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken)
+ {
+ RequireConnected();
+ var result = await _wire.ReadTimerAsync((short)kind, cancellationToken).ConfigureAwait(false);
+ ThrowIfRcNonZero(result.Rc, $"cnc_rdtimer kind={kind}", result.IsOk);
+ var t = result.Value!;
+ return new FocasTimer(kind, t.Minutes, t.Milliseconds);
+ }
+
+ public async Task> GetServoLoadsAsync(CancellationToken cancellationToken)
+ {
+ if (!_wire.IsConnected) return [];
+ var result = await _wire.ReadServoMeterAsync(32, cancellationToken).ConfigureAwait(false);
+ if (!result.IsOk || result.Value is null) return [];
+ return result.Value
+ .Select(m => new FocasServoLoad(m.Name ?? string.Empty, m.Value / Math.Pow(10.0, m.Decimal)))
+ .Where(s => s.AxisName.Length > 0)
+ .ToList();
+ }
+
+ public Task> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
+ ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleLoadAsync(sel, ct), cancellationToken);
+
+ public Task> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) =>
+ ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMaxRpmAsync(sel, ct), cancellationToken);
+
+ private static async Task> ReadSpindleMetricAsync(
+ Func>>> call,
+ CancellationToken cancellationToken)
+ {
+ var result = await call(-1, cancellationToken).ConfigureAwait(false);
+ if (!result.IsOk || result.Value is null) return [];
+ var list = new List();
+ foreach (var m in result.Value)
+ {
+ // Fanuc pads unused spindle slots with 0 — stop at the first trailing zero so the
+ // list length matches the configured spindle count.
+ if (m.Value == 0 && list.Count > 0) break;
+ list.Add(m.Value);
+ }
+ return list;
+ }
+
+ public void Dispose() => _wire.Dispose();
+
+ // ---- PMC / Parameter / Macro read paths ------------------------------------------
+
+ private async Task<(object? value, uint status)> ReadPmcAsync(
+ FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
+ {
+ var area = FocasPmcAreaLookup.FromLetter(address.PmcLetter ?? string.Empty);
+ if (area is null) return (null, FocasStatusMapper.BadNodeIdUnknown);
+ var dataType = FocasPmcDataTypeLookup.FromFocasDataType(type);
+ var start = (ushort)address.Number;
+ var end = start;
+
+ try
+ {
+ var result = await _wire.ReadPmcRangeAsync(area.Value, dataType, start, end, cancellationToken)
+ .ConfigureAwait(false);
+ if (!result.IsOk || result.Value is null)
+ return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
+ var values = result.Value.Values;
+ if (values.Count == 0) return (null, FocasStatusMapper.BadOutOfRange);
+ var raw = values[0];
+ var mapped = type switch
+ {
+ FocasDataType.Bit => (object)(((long)raw >> (address.BitIndex ?? 0) & 1L) != 0),
+ FocasDataType.Byte => (object)(sbyte)(raw & 0xFFL),
+ FocasDataType.Int16 => (object)(short)raw,
+ FocasDataType.Int32 => (object)(int)raw,
+ FocasDataType.Float32 => (object)BitConverter.Int32BitsToSingle((int)raw),
+ FocasDataType.Float64 => (object)BitConverter.Int64BitsToDouble(raw),
+ _ => (object)raw,
+ };
+ return (mapped, FocasStatusMapper.Good);
+ }
+ catch (FocasWireException ex)
+ {
+ return (null, ex.Rc is short rc ? FocasStatusMapper.MapFocasReturn(rc) : FocasStatusMapper.BadCommunicationError);
+ }
+ }
+
+ private async Task<(object? value, uint status)> ReadParameterAsync(
+ FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
+ {
+ try
+ {
+ switch (type)
+ {
+ case FocasDataType.Byte:
+ var b = await _wire.ReadParameterByteAsync((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
+ return b.IsOk ? ((object)(sbyte)b.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(b.Rc));
+ case FocasDataType.Int16:
+ var s = await _wire.ReadParameterInt16Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
+ return s.IsOk ? ((object)s.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(s.Rc));
+ case FocasDataType.Float32:
+ var f = await _wire.ReadParameterFloat32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
+ return f.IsOk ? ((object)f.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(f.Rc));
+ case FocasDataType.Float64:
+ var d = await _wire.ReadParameterFloat64Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
+ return d.IsOk ? ((object)d.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(d.Rc));
+ case FocasDataType.Bit when address.BitIndex is int bit:
+ var bi = await _wire.ReadParameterInt32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
+ if (!bi.IsOk) return (null, FocasStatusMapper.MapFocasReturn(bi.Rc));
+ return ((object)((bi.Value >> bit & 1) != 0), FocasStatusMapper.Good);
+ default:
+ var i = await _wire.ReadParameterInt32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
+ return i.IsOk ? ((object)i.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(i.Rc));
+ }
+ }
+ catch (FocasWireException ex)
+ {
+ return (null, ex.Rc is short rc ? FocasStatusMapper.MapFocasReturn(rc) : FocasStatusMapper.BadCommunicationError);
+ }
+ }
+
+ private async Task<(object? value, uint status)> ReadMacroAsync(
+ FocasAddress address, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var result = await _wire.ReadMacroAsync((short)address.Number, cancellationToken).ConfigureAwait(false);
+ if (!result.IsOk || result.Value is null)
+ return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
+ var m = result.Value;
+ // Macro value is scaled-decimal: the real value is Value / 10^Decimal.
+ var scaled = m.Value / Math.Pow(10.0, m.Decimal);
+ return ((object)scaled, FocasStatusMapper.Good);
+ }
+ catch (FocasWireException ex)
+ {
+ return (null, ex.Rc is short rc ? FocasStatusMapper.MapFocasReturn(rc) : FocasStatusMapper.BadCommunicationError);
+ }
+ }
+
+ private void RequireConnected()
+ {
+ if (!_wire.IsConnected)
+ throw new InvalidOperationException("FOCAS wire session not connected.");
+ }
+
+ private static void ThrowIfRcNonZero(short rc, string call, bool isOk)
+ {
+ if (!isOk) throw new InvalidOperationException($"{call} failed EW_{rc}.");
+ }
+}
+
+/// Factory producing instances — one per configured device.
+public sealed class WireFocasClientFactory : IFocasClientFactory
+{
+ public IFocasClient Create() => new WireFocasClient();
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj
index b63b6c0..ce08b38 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj
@@ -15,20 +15,15 @@
-
-
+
+
+
+
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/FwlibFrameHandlerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/FwlibFrameHandlerTests.cs
deleted file mode 100644
index a47a9f5..0000000
--- a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/FwlibFrameHandlerTests.cs
+++ /dev/null
@@ -1,200 +0,0 @@
-using System;
-using System.IO;
-using System.Threading;
-using System.Threading.Tasks;
-using MessagePack;
-using Serilog;
-using Shouldly;
-using Xunit;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests
-{
- ///
- /// Validates that correctly dispatches each
- /// to the corresponding
- /// method and serializes the response into the expected response kind. Uses
- /// so no hardware is needed.
- ///
- [Trait("Category", "Unit")]
- public sealed class FwlibFrameHandlerTests
- {
- private static async Task RoundTripAsync(
- IFrameHandler handler, FocasMessageKind reqKind, TReq req, FocasMessageKind expectedRespKind,
- Action assertResponse)
- {
- using var buffer = new MemoryStream();
- using var writer = new FrameWriter(buffer, leaveOpen: true);
- await handler.HandleAsync(reqKind, MessagePackSerializer.Serialize(req), writer, CancellationToken.None);
-
- buffer.Position = 0;
- using var reader = new FrameReader(buffer, leaveOpen: true);
- var frame = await reader.ReadFrameAsync(CancellationToken.None);
- frame.HasValue.ShouldBeTrue();
- frame!.Value.Kind.ShouldBe(expectedRespKind);
- assertResponse(MessagePackSerializer.Deserialize(frame.Value.Body));
- }
-
- private static FwlibFrameHandler BuildHandler() =>
- new(new FakeFocasBackend(), new LoggerConfiguration().CreateLogger());
-
- [Fact]
- public async Task OpenSession_returns_a_new_session_id()
- {
- long sessionId = 0;
- await RoundTripAsync(
- BuildHandler(),
- FocasMessageKind.OpenSessionRequest,
- new OpenSessionRequest { HostAddress = "h:8193" },
- FocasMessageKind.OpenSessionResponse,
- resp => { resp.Success.ShouldBeTrue(); resp.SessionId.ShouldBeGreaterThan(0L); sessionId = resp.SessionId; });
- sessionId.ShouldBeGreaterThan(0L);
- }
-
- [Fact]
- public async Task Read_without_open_session_returns_internal_error()
- {
- await RoundTripAsync(
- BuildHandler(),
- FocasMessageKind.ReadRequest,
- new ReadRequest
- {
- SessionId = 999,
- Address = new FocasAddressDto { Kind = 0, PmcLetter = "R", Number = 100 },
- DataType = FocasDataTypeCode.Int32,
- },
- FocasMessageKind.ReadResponse,
- resp => { resp.Success.ShouldBeFalse(); resp.Error.ShouldContain("session-not-open"); });
- }
-
- [Fact]
- public async Task Full_open_write_read_round_trip_preserves_value()
- {
- var handler = BuildHandler();
-
- // Open.
- using var buffer = new MemoryStream();
- using var writer = new FrameWriter(buffer, leaveOpen: true);
- await handler.HandleAsync(FocasMessageKind.OpenSessionRequest,
- MessagePackSerializer.Serialize(new OpenSessionRequest { HostAddress = "h:8193" }), writer, CancellationToken.None);
-
- buffer.Position = 0;
- using var reader = new FrameReader(buffer, leaveOpen: true);
- var openFrame = await reader.ReadFrameAsync(CancellationToken.None);
- var openResp = MessagePackSerializer.Deserialize(openFrame!.Value.Body);
- var sessionId = openResp.SessionId;
-
- // Write 42 at MACRO:500 as Int32.
- buffer.Position = 0;
- buffer.SetLength(0);
- await handler.HandleAsync(FocasMessageKind.WriteRequest,
- MessagePackSerializer.Serialize(new WriteRequest
- {
- SessionId = sessionId,
- Address = new FocasAddressDto { Kind = 2, Number = 500 },
- DataType = FocasDataTypeCode.Int32,
- ValueTypeCode = FocasDataTypeCode.Int32,
- ValueBytes = MessagePackSerializer.Serialize((int)42),
- }), writer, CancellationToken.None);
-
- // Read back.
- buffer.Position = 0;
- buffer.SetLength(0);
- await handler.HandleAsync(FocasMessageKind.ReadRequest,
- MessagePackSerializer.Serialize(new ReadRequest
- {
- SessionId = sessionId,
- Address = new FocasAddressDto { Kind = 2, Number = 500 },
- DataType = FocasDataTypeCode.Int32,
- }), writer, CancellationToken.None);
-
- buffer.Position = 0;
- var readFrame = await reader.ReadFrameAsync(CancellationToken.None);
- readFrame.HasValue.ShouldBeTrue();
- readFrame!.Value.Kind.ShouldBe(FocasMessageKind.ReadResponse);
- // With buffer reuse there may be multiple queued frames; we want the last one.
- var lastResp = MessagePackSerializer.Deserialize(readFrame.Value.Body);
- // If the Write frame is first, drain it.
- if (lastResp.ValueBytes is null)
- {
- var next = await reader.ReadFrameAsync(CancellationToken.None);
- lastResp = MessagePackSerializer.Deserialize(next!.Value.Body);
- }
- lastResp.Success.ShouldBeTrue();
- MessagePackSerializer.Deserialize(lastResp.ValueBytes!).ShouldBe(42);
- }
-
- [Fact]
- public async Task PmcBitWrite_sets_specified_bit()
- {
- var handler = BuildHandler();
- using var buffer = new MemoryStream();
- using var writer = new FrameWriter(buffer, leaveOpen: true);
-
- await handler.HandleAsync(FocasMessageKind.OpenSessionRequest,
- MessagePackSerializer.Serialize(new OpenSessionRequest { HostAddress = "h:8193" }), writer, CancellationToken.None);
- buffer.Position = 0;
- using var reader = new FrameReader(buffer, leaveOpen: true);
- var openFrame = await reader.ReadFrameAsync(CancellationToken.None);
- var sessionId = MessagePackSerializer.Deserialize(openFrame!.Value.Body).SessionId;
-
- buffer.Position = 0; buffer.SetLength(0);
- await handler.HandleAsync(FocasMessageKind.PmcBitWriteRequest,
- MessagePackSerializer.Serialize(new PmcBitWriteRequest
- {
- SessionId = sessionId,
- Address = new FocasAddressDto { Kind = 0, PmcLetter = "R", Number = 100 },
- BitIndex = 3,
- Value = true,
- }), writer, CancellationToken.None);
-
- buffer.Position = 0;
- var resp = MessagePackSerializer.Deserialize(
- (await reader.ReadFrameAsync(CancellationToken.None))!.Value.Body);
- resp.Success.ShouldBeTrue();
- resp.StatusCode.ShouldBe(0u);
- }
-
- [Fact]
- public async Task Probe_reports_healthy_when_session_open()
- {
- var handler = BuildHandler();
- using var buffer = new MemoryStream();
- using var writer = new FrameWriter(buffer, leaveOpen: true);
- await handler.HandleAsync(FocasMessageKind.OpenSessionRequest,
- MessagePackSerializer.Serialize(new OpenSessionRequest { HostAddress = "h:8193" }), writer, CancellationToken.None);
- buffer.Position = 0;
- using var reader = new FrameReader(buffer, leaveOpen: true);
- var sessionId = MessagePackSerializer.Deserialize(
- (await reader.ReadFrameAsync(CancellationToken.None))!.Value.Body).SessionId;
-
- buffer.Position = 0; buffer.SetLength(0);
- await handler.HandleAsync(FocasMessageKind.ProbeRequest,
- MessagePackSerializer.Serialize(new ProbeRequest { SessionId = sessionId }), writer, CancellationToken.None);
- buffer.Position = 0;
- var resp = MessagePackSerializer.Deserialize(
- (await reader.ReadFrameAsync(CancellationToken.None))!.Value.Body);
- resp.Healthy.ShouldBeTrue();
- }
-
- [Fact]
- public async Task Unconfigured_backend_returns_pointed_error_message()
- {
- var handler = new FwlibFrameHandler(new UnconfiguredFocasBackend(), new LoggerConfiguration().CreateLogger());
- await RoundTripAsync(
- handler,
- FocasMessageKind.OpenSessionRequest,
- new OpenSessionRequest { HostAddress = "h:8193" },
- FocasMessageKind.OpenSessionResponse,
- resp =>
- {
- resp.Success.ShouldBeFalse();
- resp.Error.ShouldContain("Fwlib32");
- resp.ErrorCode.ShouldBe("NoFwlibBackend");
- });
- }
- }
-}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/IpcHandshakeIntegrationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/IpcHandshakeIntegrationTests.cs
deleted file mode 100644
index b520c2a..0000000
--- a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/IpcHandshakeIntegrationTests.cs
+++ /dev/null
@@ -1,157 +0,0 @@
-using System;
-using System.IO;
-using System.IO.Pipes;
-using System.Security.Principal;
-using System.Threading;
-using System.Threading.Tasks;
-using MessagePack;
-using Serilog;
-using Serilog.Core;
-using Shouldly;
-using Xunit;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests
-{
- ///
- /// Direct FOCAS Host IPC handshake test. Drives through a
- /// hand-rolled pipe client built on /
- /// from FOCAS.Shared. Skipped on Administrator shells because PipeAcl denies
- /// the BuiltinAdministrators group.
- ///
- [Trait("Category", "Integration")]
- public sealed class IpcHandshakeIntegrationTests
- {
- private static bool IsAdministrator()
- {
- using var identity = WindowsIdentity.GetCurrent();
- return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
- }
-
- private static async Task<(NamedPipeClientStream Stream, FrameReader Reader, FrameWriter Writer)>
- ConnectAndHelloAsync(string pipeName, string secret, CancellationToken ct)
- {
- var stream = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
- await stream.ConnectAsync(5_000, ct);
-
- var reader = new FrameReader(stream, leaveOpen: true);
- var writer = new FrameWriter(stream, leaveOpen: true);
- await writer.WriteAsync(FocasMessageKind.Hello,
- new Hello { PeerName = "test-client", SharedSecret = secret }, ct);
-
- var ack = await reader.ReadFrameAsync(ct);
- if (ack is null) throw new EndOfStreamException("no HelloAck");
- if (ack.Value.Kind != FocasMessageKind.HelloAck)
- throw new InvalidOperationException("unexpected first frame kind " + ack.Value.Kind);
- var ackMsg = MessagePackSerializer.Deserialize(ack.Value.Body);
- if (!ackMsg.Accepted) throw new UnauthorizedAccessException(ackMsg.RejectReason);
-
- return (stream, reader, writer);
- }
-
- [Fact]
- public async Task Handshake_with_correct_secret_succeeds_and_heartbeat_round_trips()
- {
- if (IsAdministrator()) return;
-
- using var identity = WindowsIdentity.GetCurrent();
- var sid = identity.User!;
- var pipe = $"OtOpcUaFocasTest-{Guid.NewGuid():N}";
- const string secret = "test-secret-2026";
- Logger log = new LoggerConfiguration().CreateLogger();
- using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
-
- var server = new PipeServer(pipe, sid, secret, log);
- var serverTask = Task.Run(() => server.RunOneConnectionAsync(new StubFrameHandler(), cts.Token));
-
- var (stream, reader, writer) = await ConnectAndHelloAsync(pipe, secret, cts.Token);
- using (stream)
- using (reader)
- using (writer)
- {
- await writer.WriteAsync(FocasMessageKind.Heartbeat,
- new Heartbeat { MonotonicTicks = 42 }, cts.Token);
-
- var hbAck = await reader.ReadFrameAsync(cts.Token);
- hbAck.HasValue.ShouldBeTrue();
- hbAck!.Value.Kind.ShouldBe(FocasMessageKind.HeartbeatAck);
- MessagePackSerializer.Deserialize(hbAck.Value.Body).MonotonicTicks.ShouldBe(42L);
- }
-
- cts.Cancel();
- try { await serverTask; } catch { }
- server.Dispose();
- }
-
- [Fact]
- public async Task Handshake_with_wrong_secret_is_rejected()
- {
- if (IsAdministrator()) return;
-
- using var identity = WindowsIdentity.GetCurrent();
- var sid = identity.User!;
- var pipe = $"OtOpcUaFocasTest-{Guid.NewGuid():N}";
- Logger log = new LoggerConfiguration().CreateLogger();
- using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
-
- var server = new PipeServer(pipe, sid, "real-secret", log);
- var serverTask = Task.Run(() => server.RunOneConnectionAsync(new StubFrameHandler(), cts.Token));
-
- await Should.ThrowAsync(async () =>
- {
- var (s, r, w) = await ConnectAndHelloAsync(pipe, "wrong-secret", cts.Token);
- s.Dispose();
- r.Dispose();
- w.Dispose();
- });
-
- cts.Cancel();
- try { await serverTask; } catch { }
- server.Dispose();
- }
-
- [Fact]
- public async Task Stub_handler_returns_not_implemented_for_data_plane_request()
- {
- if (IsAdministrator()) return;
-
- using var identity = WindowsIdentity.GetCurrent();
- var sid = identity.User!;
- var pipe = $"OtOpcUaFocasTest-{Guid.NewGuid():N}";
- const string secret = "stub-test";
- Logger log = new LoggerConfiguration().CreateLogger();
- using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
-
- var server = new PipeServer(pipe, sid, secret, log);
- var serverTask = Task.Run(() => server.RunOneConnectionAsync(new StubFrameHandler(), cts.Token));
-
- var (stream, reader, writer) = await ConnectAndHelloAsync(pipe, secret, cts.Token);
- using (stream)
- using (reader)
- using (writer)
- {
- await writer.WriteAsync(FocasMessageKind.ReadRequest,
- new ReadRequest
- {
- SessionId = 1,
- Address = new FocasAddressDto { Kind = 0, PmcLetter = "R", Number = 100 },
- DataType = FocasDataTypeCode.Int32,
- },
- cts.Token);
-
- var resp = await reader.ReadFrameAsync(cts.Token);
- resp.HasValue.ShouldBeTrue();
- resp!.Value.Kind.ShouldBe(FocasMessageKind.ErrorResponse);
- var err = MessagePackSerializer.Deserialize(resp.Value.Body);
- err.Code.ShouldBe("not-implemented");
- err.Message.ShouldContain("PR C");
- }
-
- cts.Cancel();
- try { await serverTask; } catch { }
- server.Dispose();
- }
- }
-}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/PostMortemMmfTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/PostMortemMmfTests.cs
deleted file mode 100644
index 45908b2..0000000
--- a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/PostMortemMmfTests.cs
+++ /dev/null
@@ -1,86 +0,0 @@
-using System;
-using System.IO;
-using Shouldly;
-using Xunit;
-using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Stability;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests
-{
- [Trait("Category", "Unit")]
- public sealed class PostMortemMmfTests : IDisposable
- {
- private readonly string _tempPath;
-
- public PostMortemMmfTests()
- {
- _tempPath = Path.Combine(Path.GetTempPath(), $"focas-mmf-{Guid.NewGuid():N}.bin");
- }
-
- public void Dispose()
- {
- if (File.Exists(_tempPath)) File.Delete(_tempPath);
- }
-
- [Fact]
- public void Write_and_read_preserve_order_and_content()
- {
- using (var mmf = new PostMortemMmf(_tempPath, capacity: 10))
- {
- mmf.Write(opKind: 1, "read R100");
- mmf.Write(opKind: 2, "write MACRO:500 = 3.14");
- mmf.Write(opKind: 3, "probe ok");
- }
-
- // Reopen (simulating a reader after the writer crashed).
- using var reader = new PostMortemMmf(_tempPath, capacity: 10);
- var entries = reader.ReadAll();
- entries.Length.ShouldBe(3);
- entries[0].OpKind.ShouldBe(1L);
- entries[0].Message.ShouldBe("read R100");
- entries[1].OpKind.ShouldBe(2L);
- entries[2].Message.ShouldBe("probe ok");
- }
-
- [Fact]
- public void Ring_buffer_wraps_at_capacity()
- {
- using var mmf = new PostMortemMmf(_tempPath, capacity: 3);
- for (var i = 0; i < 10; i++) mmf.Write(i, $"op-{i}");
-
- var entries = mmf.ReadAll();
- entries.Length.ShouldBe(3);
- // Oldest surviving entry is op-7 (entries 7,8,9 survive in FIFO order).
- entries[0].Message.ShouldBe("op-7");
- entries[1].Message.ShouldBe("op-8");
- entries[2].Message.ShouldBe("op-9");
- }
-
- [Fact]
- public void Truncated_message_is_null_terminated_and_does_not_overflow()
- {
- using var mmf = new PostMortemMmf(_tempPath, capacity: 4);
- var big = new string('x', 500); // longer than the 240-byte message capacity
- mmf.Write(42, big);
-
- var entries = mmf.ReadAll();
- entries.Length.ShouldBe(1);
- entries[0].Message.Length.ShouldBeLessThanOrEqualTo(240);
- entries[0].OpKind.ShouldBe(42L);
- }
-
- [Fact]
- public void Reopening_with_existing_data_preserves_entries()
- {
- using (var first = new PostMortemMmf(_tempPath, capacity: 5))
- {
- first.Write(1, "first-run-1");
- first.Write(2, "first-run-2");
- }
-
- using var second = new PostMortemMmf(_tempPath, capacity: 5);
- var entries = second.ReadAll();
- entries.Length.ShouldBe(2);
- entries[0].Message.ShouldBe("first-run-1");
- }
- }
-}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests.csproj
deleted file mode 100644
index 74b5ccf..0000000
--- a/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests.csproj
+++ /dev/null
@@ -1,33 +0,0 @@
-