FOCAS — retire Tier-C split, inline managed wire client, make read-only

Migration closes the FOCAS Tier-C architecture. OtOpcUa previously had
`Driver.FOCAS.Host` (NSSM-wrapped Windows service loading Fwlib64.dll via
P/Invoke) + `Driver.FOCAS.Shared` (MessagePack IPC contracts) + a C shim
DLL stand-in for unit tests. All of it is deleted; the driver is now a
single in-process managed assembly talking the FOCAS/2 Ethernet binary
protocol directly on TCP:8193.

Architecture

- Pure-managed `FocasWireClient` inlined at `src/.../Driver.FOCAS/Wire/`
  (owner-imported — see Wire/FocasWireClient.cs for the full surface).
  Opens two TCP sockets, runs the initiate handshake, serialises requests
  on socket 2 through a semaphore, closes cleanly with PDU + socket
  teardown. Both sync `IDisposable` and async `IAsyncDisposable`.
- `WireFocasClient` (same folder) adapts the wire client to OtOpcUa's
  `IFocasClient` surface — fixed-tree reads, PARAM/MACRO/PMC addresses,
  alarms. Writes return `BadNotWritable` by design — OtOpcUa is read-only
  against FOCAS.
- `FocasDriverFactoryExtensions` now accepts `"Backend": "wire"` (default)
  and `"Backend": "unimplemented"`. Legacy `ipc` and `fwlib` backends are
  rejected at startup with a diagnostic pointing at the migration doc.

Deletions

- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/` — whole project + Ipc/,
  Backend/, Stability/, Program.cs.
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/` — Contracts/, FrameReader,
  FrameWriter, whole project.
- `tests/...Driver.FOCAS.Host.Tests/` + `.Shared.Tests/` — whole projects.
- `src/.../Driver.FOCAS/FwlibNative.cs` + `FwlibFocasClient.cs` — 21
  P/Invokes + 7 `Pack=1` marshalling structs + the Fwlib-backed
  `IFocasClient` implementation.
- `src/.../Driver.FOCAS/Ipc/` + `Supervisor/` — IPC client wrapper +
  Host-process supervisor (backoff, circuit breaker, heartbeat, post-
  mortem reader, process launcher).
- `scripts/install/Install-FocasHost.ps1` — NSSM service installer.
- `tests/.../Driver.FOCAS.Tests/{IpcFocasClientTests, IpcLoopback,
  FwlibNativeHelperTests, PostMortemReaderCompatibilityTests,
  SupervisorTests, FocasDriverFactoryExtensionsTests}.cs` — tests that
  exercised the retired surfaces.
- `tests/.../Driver.FOCAS.IntegrationTests/Shim/` — the zig-built C shim
  DLL that masqueraded as Fwlib64.dll.

Solution changes

- `ZB.MOM.WW.OtOpcUa.slnx` drops the 4 retired project refs.
- `src/.../Driver.FOCAS.csproj` drops the Shared ProjectReference, adds
  `Microsoft.Extensions.Logging.Abstractions` for the optional `ILogger`
  hook in `FocasWireClient`.
- `src/.../Driver.FOCAS.Cli.csproj` drops the six `<Content Include>`
  entries that copied `vendor/fanuc/*.dll` into the CLI bin. CLI now uses
  `WireFocasClient` directly.
- `FocasDriver` default factory flips to `Wire.WireFocasClientFactory`.

Integration tests

- New `tests/.../Driver.FOCAS.IntegrationTests/` project covering fixed-
  tree reads (identity, axes, dynamic, program, operation mode, timers,
  spindle load + max RPM, servo meters), user-authored PARAM / MACRO /
  PMC reads, `DiscoverAsync` emission, `SubscribeAsync` + `OnDataChange`,
  `IAlarmSource` raise/clear transitions, and `ProbeAsync` /
  `OnHostStatusChanged`. 9 e2e tests against the focas-mock fixture
  (Docker container with the vendored Python mock's native FOCAS/2
  Ethernet responder).
- `scripts/integration/run-focas.ps1` orchestrates compose up → tests →
  compose down. Dropped the shim-build stage + DLL-copy step + the split
  testhost workaround (the latter only existed because of native-DLL
  lifecycle bugs the shim tripped).
- Docker compose collapses from 11 per-series services to one `focas-sim`
  service. Tests seed per-series state via `mock_load_profile` at test
  start.
- Vendored focas-mock snapshot refreshed to pick up upstream's native
  FOCAS/2 Ethernet responder (was 660 lines, now 1018) — the
  pre-refresh snapshot only spoke the JSON admin protocol.

Tests

- 145/145 unit tests in `Driver.FOCAS.Tests` pass (was 208 pre-deletion;
  63 removed tests exercised the retired IPC/shim/supervisor/Fwlib
  surfaces).
- 9/9 integration tests pass against the refreshed mock.
- `FocasScaffoldingTests.Unimplemented_factory_throws_on_Create…` updated
  to assert the new diagnostic message pointing at
  `docs/drivers/FOCAS.md` rather than the now-gone `Fwlib64.dll`.

Docs

- `docs/drivers/FOCAS.md` rewritten for the managed wire topology —
  deployment collapses to one `"Backend": "wire"` config block, no
  separate service, no DLL deployment, no pipe ACL.
- `docs/drivers/FOCAS-Test-Fixture.md` updated — single TCP probe skip
  gate instead of TCP + shim probe; fewer moving parts.
- `docs/drivers/README.md` row for FOCAS reflects the Tier-A managed
  topology (previously listed Tier-C + `Fwlib64.dll` P/Invoke).
- `docs/Driver.FOCAS.Cli.md` drops the Tier-C architecture-note section.
- `docs/v2/implementation/focas-isolation-plan.md` marked historical —
  the plan it documents was executed then superseded by the wire client.
- `docs/v2/v2-release-readiness.md` re-audited 2026-04-24. Phase 5
  driver complement closed. FOCAS change-log entry added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-24 14:10:59 -04:00
parent 404b54add0
commit 4b0664bd55
105 changed files with 19530 additions and 4873 deletions

View File

@@ -18,8 +18,6 @@
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
@@ -65,8 +63,7 @@
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj"/>

View File

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

View File

@@ -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 <compose-profile>` for the Docker mock
- `-Series <csv>` for per-series matrix mode
- `-HandleLeakCycles <N>` 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 AE, 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 <name>` 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

238
docs/drivers/FOCAS.md Normal file
View File

@@ -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 (~510 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

View File

@@ -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

View File

@@ -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).

View File

@@ -1,10 +1,21 @@
# FOCAS Tier-C isolation — plan for task #220
> **Status**: PRs AE 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 AE 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).

View File

@@ -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 #7883) |
| Phase 6.2 — Authorization runtime | ◐ core | **SHIPPED (core)** (PRs #8488); dispatch wiring + Admin UI deferred |
| Phase 6.3 — Redundancy runtime | ◐ core | **SHIPPED (core)** (PRs #8990); coordinator + UA-node wiring + Admin UI + interop deferred |
| Phase 6.4 — Admin UI completion | ◐ data layer | **SHIPPED (data layer)** (PRs #9192); 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 #7883) |
| Phase 6.2 — Authorization runtime | ◐ core | Core shipped (PRs #8488, #94 dispatch wiring); finer-grained Browse/Subscribe/Alarm/Call gating + 3-user interop matrix deferred |
| Phase 6.3 — Redundancy runtime | ◐ core | Core shipped (PRs #8990, #9899); 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 #9192, 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 #9899)
**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 #202222) — `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 #9899). 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 #9192). 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 #9899). 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 #9192). Phase 6 core complete.
- **2026-04-19** — Phase 6.3 core merged (PRs #8990). `ServiceLevelCalculator` + `RecoveryStateManager` + `ApplyLeaseRegistry` land as pure logic; coordinator / UA-node wiring / Admin UI / interop deferred.
- **2026-04-19** — Phase 6.2 core merged (PRs #8488). `AuthorizationGate` + `TriePermissionEvaluator` + `LdapGroupRoleMapping` land; dispatch wiring + Admin UI deferred.
- **2026-04-19** — Phase 6.1 shipped (PRs #7883). Polly resilience + Tier A/B/C stability + health endpoints + LiteDB generation-sealed cache + Admin `/hosts` data layer all live.

View File

@@ -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",

View File

@@ -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 <int> — stress stage that opens + closes <N> 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: 1001000. A CNC's
FWLIB handle pool is finite (~510), 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 <N> 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 <profile> 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 }

View File

@@ -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."

View File

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

View File

@@ -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

View File

@@ -5,11 +5,11 @@ using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
/// <summary>
/// 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
/// <c>BadCommunicationError</c> (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
/// <c>WireFocasClient</c> on TCP:8193. Against an unreachable endpoint it surfaces
/// <c>BadCommunicationError</c> which is still a useful signal that the CLI wire-up is
/// correct. Also runs cleanly against the focas-mock Docker fixture in
/// <c>tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/</c>.
/// </summary>
[Command("probe", Description = "Verify the CNC is reachable + a sample FOCAS read succeeds.")]
public sealed class ProbeCommand : FocasCommandBase

View File

@@ -38,10 +38,9 @@ public abstract class FocasCommandBase : DriverCommandBase
/// <summary>
/// Build a <see cref="FocasDriverOptions"/> with the CNC target this base collected
/// + the tag list a subclass supplies. Probe disabled; the default
/// <see cref="FwlibFocasClientFactory"/> attempts <c>Fwlib32.dll</c> P/Invoke, which
/// throws <see cref="DllNotFoundException"/> at first call when the DLL is absent —
/// surfaced through the driver as <c>BadCommunicationError</c>.
/// + 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 <c>BadCommunicationError</c>.
/// </summary>
protected FocasDriverOptions BuildOptions(IReadOnlyList<FocasTagDefinition> tags) => new()
{

View File

@@ -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);

View File

@@ -22,4 +22,7 @@
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
</ItemGroup>
<!-- CLI runs the managed WireFocasClient and talks to the CNC over TCP:8193
directly — no Fwlib64.dll copy step needed. -->
</Project>

View File

@@ -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;
/// <summary>
/// In-memory <see cref="IFocasBackend"/> for tests + an operational stub mode when
/// <c>OTOPCUA_FOCAS_BACKEND=fake</c>. Keeps per-address values keyed by a canonical
/// string; RMW semantics honor PMC bit-writes against the containing byte so the
/// <c>PmcBitWriteRequest</c> path can be exercised end-to-end without hardware.
/// </summary>
public sealed class FakeFocasBackend : IFocasBackend
{
private readonly object _gate = new();
private long _nextSessionId;
private readonly HashSet<long> _openSessions = [];
private readonly Dictionary<string, byte[]> _pmcValues = [];
private readonly Dictionary<string, byte[]> _paramValues = [];
private readonly Dictionary<string, byte[]> _macroValues = [];
public Task<OpenSessionResponse> 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<ReadResponse> 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<WriteResponse> 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<PmcBitWriteResponse> 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<byte>(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<ProbeResponse> ProbeAsync(ProbeRequest request, CancellationToken ct)
{
lock (_gate)
{
return Task.FromResult(new ProbeResponse
{
Healthy = _openSessions.Contains(request.SessionId),
ObservedAtUtcUnixMs = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
});
}
}
private Dictionary<string, byte[]> 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}",
};
}

View File

@@ -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;
/// <summary>
/// The Host's view of a FOCAS session. One implementation wraps the real
/// <c>Fwlib32.dll</c> via P/Invoke (lands with the real Fwlib32 integration follow-up,
/// since no hardware is available today); a second implementation —
/// <see cref="FakeFocasBackend"/> — 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 <c>FwlibFrameHandler</c> in the Ipc namespace.
/// </summary>
public interface IFocasBackend
{
Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest request, CancellationToken ct);
Task CloseSessionAsync(CloseSessionRequest request, CancellationToken ct);
Task<ReadResponse> ReadAsync(ReadRequest request, CancellationToken ct);
Task<WriteResponse> WriteAsync(WriteRequest request, CancellationToken ct);
Task<PmcBitWriteResponse> PmcBitWriteAsync(PmcBitWriteRequest request, CancellationToken ct);
Task<ProbeResponse> ProbeAsync(ProbeRequest request, CancellationToken ct);
}

View File

@@ -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;
/// <summary>
/// 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 <c>BadDeviceFailure</c> and surface a clear operator message pointing at
/// <c>docs/v2/focas-deployment.md</c>. Used when <c>OTOPCUA_FOCAS_BACKEND</c> is unset
/// or set to <c>unconfigured</c>.
/// </summary>
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<OpenSessionResponse> 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<ReadResponse> ReadAsync(ReadRequest request, CancellationToken ct) =>
Task.FromResult(new ReadResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason });
public Task<WriteResponse> WriteAsync(WriteRequest request, CancellationToken ct) =>
Task.FromResult(new WriteResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason });
public Task<PmcBitWriteResponse> PmcBitWriteAsync(PmcBitWriteRequest request, CancellationToken ct) =>
Task.FromResult(new PmcBitWriteResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason });
public Task<ProbeResponse> ProbeAsync(ProbeRequest request, CancellationToken ct) =>
Task.FromResult(new ProbeResponse { Healthy = false, Error = Reason, ObservedAtUtcUnixMs = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() });
}

View File

@@ -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;
/// <summary>
/// Real FOCAS frame handler. Deserializes each request DTO, delegates to
/// <see cref="IFocasBackend"/>, re-serializes the response. The backend owns the
/// Fwlib32 handle + STA thread — the handler is pure dispatch.
/// </summary>
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<Heartbeat>(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<OpenSessionRequest>(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<CloseSessionRequest>(body);
await _backend.CloseSessionAsync(req, ct).ConfigureAwait(false);
return;
}
case FocasMessageKind.ReadRequest:
{
var req = MessagePackSerializer.Deserialize<ReadRequest>(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<WriteRequest>(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<PmcBitWriteRequest>(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<ProbeRequest>(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;
}

View File

@@ -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;
/// <summary>
/// Dispatches a single IPC frame to the backend. Implementations own the FOCAS session
/// state and translate request DTOs into Fwlib32 calls.
/// </summary>
public interface IFrameHandler
{
Task HandleAsync(FocasMessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct);
/// <summary>
/// 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 <paramref name="writer"/>. Returns an
/// <see cref="IDisposable"/> the pipe server disposes when the connection closes —
/// backends use it to unsubscribe from their push sources.
/// </summary>
IDisposable AttachConnection(FrameWriter writer);
public sealed class NoopAttachment : IDisposable
{
public static readonly NoopAttachment Instance = new();
public void Dispose() { }
}
}

View File

@@ -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;
/// <summary>
/// Builds the <see cref="PipeSecurity"/> for the FOCAS Host pipe. Same pattern as
/// Galaxy.Host: only the configured OtOpcUa server principal SID gets
/// <c>ReadWrite | Synchronize</c>; LocalSystem + Administrators are explicitly denied
/// so a compromised service account on the same host can't escalate via the pipe.
/// </summary>
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;
}
}

View File

@@ -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;
/// <summary>
/// Accepts one client connection at a time on the FOCAS Host's named pipe with the
/// strict ACL from <see cref="PipeAcl"/>. 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.
/// </summary>
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<Hello>(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();
}
}

View File

@@ -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;
/// <summary>
/// Placeholder handler that returns <c>ErrorResponse{Code=not-implemented}</c> 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.
/// </summary>
public sealed class StubFrameHandler : IFrameHandler
{
public Task HandleAsync(FocasMessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct)
{
if (kind == FocasMessageKind.Heartbeat)
{
var hb = MessagePackSerializer.Deserialize<Heartbeat>(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;
}

View File

@@ -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;
/// <summary>
/// Entry point for the <c>OtOpcUaFocasHost</c> 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 <see cref="StubFrameHandler"/> — PR C swaps in the real
/// Fwlib32-backed handler once the session state + STA thread move out of the .NET 10
/// driver.
/// </summary>
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(); }
}
}

View File

@@ -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;
/// <summary>
/// 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
/// <c>PostMortemMmf</c> so a single reader tool can work both.
/// </summary>
/// <remarks>
/// File layout:
/// <code>
/// [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]]
/// </code>
/// Magic is 'OFPC' (0x4F46_5043) to distinguish a FOCAS file from the Galaxy MMF.
/// </remarks>
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<byte>(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;
}
}

View File

@@ -1,40 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
<!-- Fwlib32.dll is 32-bit only — x86 target is mandatory. Matches the Galaxy.Host
bitness constraint but for a different native library. -->
<PlatformTarget>x86</PlatformTarget>
<Prefer32Bit>true</Prefer32Bit>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host</RootNamespace>
<AssemblyName>OtOpcUa.Driver.FOCAS.Host</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.IO.Pipes.AccessControl" Version="5.0.0"/>
<PackageReference Include="System.Memory" Version="4.5.5"/>
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.5.4"/>
<PackageReference Include="Serilog" Version="4.2.0"/>
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -1,39 +0,0 @@
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
/// <summary>
/// Wire shape for a parsed FOCAS address. Mirrors <c>FocasAddress</c> 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
/// <c>FocasAddress</c>; the Host maps back to its local equivalent.
/// </summary>
[MessagePackObject]
public sealed class FocasAddressDto
{
/// <summary>0 = Pmc, 1 = Parameter, 2 = Macro. Matches <c>FocasAreaKind</c> enum order.</summary>
[Key(0)] public int Kind { get; set; }
/// <summary>PMC letter — null for Parameter / Macro.</summary>
[Key(1)] public string? PmcLetter { get; set; }
[Key(2)] public int Number { get; set; }
/// <summary>Optional bit index (0-7 for PMC, 0-31 for Parameter).</summary>
[Key(3)] public int? BitIndex { get; set; }
}
/// <summary>
/// 0 = Bit, 1 = Byte, 2 = Int16, 3 = Int32, 4 = Float32, 5 = Float64, 6 = String.
/// Matches <c>FocasDataType</c> enum order so both sides can cast <c>(int)</c>.
/// </summary>
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;
}

View File

@@ -1,57 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
/// <summary>
/// Length-prefixed framing. Each IPC frame is:
/// <c>[4-byte big-endian length][1-byte message kind][MessagePack body]</c>.
/// 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.
/// </summary>
public static class Framing
{
public const int LengthPrefixSize = 4;
public const int KindByteSize = 1;
/// <summary>
/// Maximum permitted body length (16 MiB). Protects the receiver from a hostile or
/// misbehaving peer sending an oversized length prefix.
/// </summary>
public const int MaxFrameBodyBytes = 16 * 1024 * 1024;
}
/// <summary>
/// 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.
/// </summary>
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,
}

View File

@@ -1,63 +0,0 @@
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
/// <summary>
/// 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.
/// </summary>
[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;
/// <summary>
/// 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.
/// </summary>
[Key(3)] public string SharedSecret { get; set; } = string.Empty;
[Key(4)] public string[] Features { get; set; } = System.Array.Empty<string>();
}
[MessagePackObject]
public sealed class HelloAck
{
[Key(0)] public int ProtocolMajor { get; set; } = Hello.CurrentMajor;
[Key(1)] public int ProtocolMinor { get; set; } = Hello.CurrentMinor;
/// <summary>True if the Host accepted the hello; false + <see cref="RejectReason"/> filled if not.</summary>
[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
{
/// <summary>Stable symbolic code — e.g. <c>InvalidAddress</c>, <c>SessionNotFound</c>, <c>Fwlib32Crashed</c>.</summary>
[Key(0)] public string Code { get; set; } = string.Empty;
[Key(1)] public string Message { get; set; } = string.Empty;
}

View File

@@ -1,47 +0,0 @@
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
/// <summary>Lightweight connectivity probe — maps to <c>cnc_rdcncstat</c> on the Host.</summary>
[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; }
}
/// <summary>Per-host runtime status — fan-out target when the Host observes the CNC going unreachable without the Proxy asking.</summary>
[MessagePackObject]
public sealed class RuntimeStatusChangeNotification
{
[Key(0)] public long SessionId { get; set; }
/// <summary>Running | Stopped | Unknown.</summary>
[Key(1)] public string RuntimeStatus { get; set; } = string.Empty;
[Key(2)] public long ObservedAtUtcUnixMs { get; set; }
}
[MessagePackObject]
public sealed class RecycleHostRequest
{
/// <summary>Soft | Hard. Soft drains subscriptions first; Hard kills immediately.</summary>
[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; }
}

View File

@@ -1,85 +0,0 @@
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
/// <summary>
/// Read one FOCAS address. Multi-read is the Proxy's responsibility — it batches
/// per-tag reads into parallel <see cref="ReadRequest"/> 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.
/// </summary>
[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; }
/// <summary>OPC UA status code mapped by the Host via <c>FocasStatusMapper</c> — 0 = Good.</summary>
[Key(2)] public uint StatusCode { get; set; }
/// <summary>MessagePack-serialized boxed value. <c>null</c> when <see cref="Success"/> is false.</summary>
[Key(3)] public byte[]? ValueBytes { get; set; }
/// <summary>Matches <see cref="FocasDataTypeCode"/> so the Proxy knows how to deserialize.</summary>
[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; }
/// <summary>OPC UA status code — 0 = Good.</summary>
[Key(2)] public uint StatusCode { get; set; }
}
/// <summary>
/// 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
/// <c>SemaphoreSlim</c> keyed on <c>(PmcLetter, Number)</c>. Mirrors the in-process
/// pattern from <c>FocasPmcBitRmw</c>.
/// </summary>
[MessagePackObject]
public sealed class PmcBitWriteRequest
{
[Key(0)] public long SessionId { get; set; }
[Key(1)] public FocasAddressDto Address { get; set; } = new();
/// <summary>The bit index to set/clear. 0-7.</summary>
[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; }
}

View File

@@ -1,31 +0,0 @@
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
/// <summary>
/// Open a FOCAS session against the CNC at <see cref="HostAddress"/>. One session per
/// configured device. The Host owns the Fwlib32 handle; the Proxy tracks only the
/// opaque <see cref="OpenSessionResponse.SessionId"/> returned on success.
/// </summary>
[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; }
}

View File

@@ -1,61 +0,0 @@
using MessagePack;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
/// <summary>
/// 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 <see cref="OnDataChangeNotification"/> frames whenever a value differs from
/// the last observation. Delta-only + per-group interval keeps the wire quiet.
/// </summary>
[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<SubscribeItem>();
}
[MessagePackObject]
public sealed class SubscribeItem
{
/// <summary>Opaque correlation id the Proxy uses to route notifications back to the right OPC UA MonitoredItem.</summary>
[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; }
/// <summary>Items the Host refused (address mismatch, unsupported type). Empty on full success.</summary>
[Key(2)] public long[] RejectedMonitoredItemIds { get; set; } = System.Array.Empty<long>();
}
[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<DataChange>();
}
[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; }
}

View File

@@ -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;
/// <summary>
/// Reads length-prefixed, kind-tagged frames from a stream. Single-consumer — do not call
/// <see cref="ReadFrameAsync"/> from multiple threads against the same instance.
/// </summary>
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<T>(byte[] body) => MessagePackSerializer.Deserialize<T>(body);
private async Task<bool> 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();
}
}

View File

@@ -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;
/// <summary>
/// Writes length-prefixed, kind-tagged MessagePack frames to a stream. Thread-safe via
/// <see cref="SemaphoreSlim"/> — multiple producers (e.g. heartbeat + data-plane sharing a
/// stream) get serialized writes.
/// </summary>
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<T>(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();
}
}

View File

@@ -1,23 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- MessagePack for IPC. Netstandard 2.0 consumable by both .NET 10 (Proxy) + .NET 4.8 (Host). -->
<PackageReference Include="MessagePack" Version="2.5.187"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// fail fast.
/// </remarks>
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<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private FocasAlarmProjection? _alarmProjection;
private DriverHealth _health = new(DriverState.Unknown, null, null);
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
public event EventHandler<AlarmEventArgs>? 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));
}
/// <summary>
/// Emit a variable under a named fixed-tree folder (Program, OperationMode,
/// …). Full-reference shape is <c>{deviceHost}/{folderPath}/{field}</c>.
/// </summary>
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));
}
/// <summary>
/// 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 <see cref="ReadAsync"/> looks up.
/// </summary>
internal static string FixedTreeReference(string deviceHost, string path) =>
$"{deviceHost}/{path}";
// ---- ISubscribable (polling overlay via shared engine) ----
public Task<ISubscriptionHandle> SubscribeAsync(
@@ -298,6 +485,310 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
/// <summary>
/// Per-device fixed-tree poll loop. First tick resolves sysinfo + axis names
/// (once) so <see cref="DiscoverAsync"/> can render the subtree on its next
/// invocation; every tick thereafter fires a <c>cnc_rddynamic2</c> per axis
/// and publishes OnDataChange for the axis positions + feed rate + spindle
/// speed.
/// </summary>
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<FocasTimerKind>())
{
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; }
}
}
/// <summary>
/// Cache a fresh axis snapshot. The poll loop doesn't fire <c>OnDataChange</c>
/// directly — subscribers go through the normal <c>SubscribeAsync</c> →
/// <see cref="PollGroupEngine"/> → <see cref="ReadAsync"/> path, which hits
/// <see cref="TryReadFixedTree"/> and returns these cached values.
/// </summary>
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;
}
/// <summary>
/// Cache servo-load percentages keyed by axis name. Stored separately from
/// <c>LastFixedSnapshots</c> (which is int-typed) so the double-valued load
/// values don't need casting on every read.
/// </summary>
private static void PublishServoLoads(DeviceState state, IReadOnlyList<FocasServoLoad> 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;
/// <summary>
/// Call an optional probe that returns a collection; swallow any exception
/// and return <paramref name="fallback"/>. Used by bootstrap to capture
/// per-series capability without letting one failed probe take down the
/// entire bootstrap sequence.
/// </summary>
private static async Task<IReadOnlyList<T>> SafeProbe<T>(
Func<Task<IReadOnlyList<T>>> probe, IReadOnlyList<T> fallback)
{
try { return await probe().ConfigureAwait(false); }
catch { return fallback; }
}
/// <summary>
/// Nullable variant — probe returns a single object or null on failure.
/// </summary>
private static async Task<T?> SafeTryProbe<T>(Func<Task<T>> probe) where T : class
{
try { return await probe().ConfigureAwait(false); }
catch { return null; }
}
/// <summary>
/// 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).
/// </summary>
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<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> 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<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
_alarmProjection is { } p ? p.AcknowledgeAsync(acknowledgements, cancellationToken) : Task.CompletedTask;
internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
/// <summary>
/// Poll every configured device's active-alarm list in one pass. Used by the alarm
/// projection — kept <c>internal</c> rather than <c>public</c> because callers that
/// want alarm events should subscribe through <c>IAlarmSource</c> instead.
/// </summary>
internal async Task<IReadOnlyList<(string HostAddress, IReadOnlyList<FocasActiveAlarm> Alarms)>>
ReadActiveAlarmsAcrossDevicesAsync(HashSet<string>? deviceFilter, CancellationToken ct)
{
var result = new List<(string, IReadOnlyList<FocasActiveAlarm>)>();
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);
/// <summary>
/// Per-device fixed-tree cache populated once at first successful connect and
/// read-only thereafter. Used by <see cref="DiscoverAsync"/> to render the
/// tree + by <see cref="TryReadFixedTree"/> for synchronous Identity/* reads.
/// </summary>
internal sealed record FocasFixedTreeCache(
FocasSysInfo SysInfo,
IReadOnlyList<FocasAxisName> Axes,
IReadOnlyList<FocasSpindleName> Spindles,
IReadOnlyList<int> SpindleMaxRpms,
FocasFixedTreeCapabilities Capabilities);
/// <summary>
/// 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 <c>cnc_rdspmaxrpm</c> simply
/// doesn't get a <c>Spindle/{name}/MaxRpm</c> node (instead of surfacing
/// <c>BadDeviceFailure</c> on every read).
/// </summary>
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<string, int> LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
public FocasProgramInfo? LastProgramInfo { get; set; }
/// <summary>Cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence.</summary>
public FocasDynamicSnapshot? LastProgramAxisRef { get; set; }
public Dictionary<FocasTimerKind, FocasTimer> LastTimers { get; } = [];
public Dictionary<string, double> LastServoLoads { get; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<int, int> LastSpindleLoads { get; } = [];
public void DisposeClient()
{

View File

@@ -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;
/// <summary>
/// Static factory registration helper for <see cref="FocasDriver"/>. Server's Program.cs
/// calls <see cref="Register"/> once at startup; the bootstrapper (task #248) then
/// materialises FOCAS DriverInstance rows from the central config DB into live driver
/// instances. Mirrors <c>GalaxyProxyDriverFactoryExtensions</c>; no dependency on
/// Microsoft.Extensions.DependencyInjection so the driver project stays DI-free.
/// Static factory registration helper for <see cref="FocasDriver"/>. Server's
/// Program.cs calls <see cref="Register"/> once at startup; the bootstrapper
/// then materialises FOCAS DriverInstance rows from the central config DB
/// into live driver instances.
/// </summary>
/// <remarks>
/// The DriverConfig JSON selects the <see cref="IFocasClientFactory"/> backend:
/// <list type="bullet">
/// <item><c>"Backend": "ipc"</c> (default) — wires <see cref="IpcFocasClientFactory"/>
/// against a named-pipe <see cref="FocasIpcClient"/> talking to a separate
/// <c>Driver.FOCAS.Host</c> process (Tier-C isolation). Requires <c>PipeName</c> +
/// <c>SharedSecret</c>.</item>
/// <item><c>"Backend": "fwlib"</c> — direct in-process Fwlib32.dll P/Invoke via
/// <see cref="FwlibFocasClientFactory"/>. Use only when the main server is licensed
/// for FOCAS and you accept the native-crash blast-radius trade-off.</item>
/// <item><c>"Backend": "unimplemented"</c> — returns the no-op factory; useful for
/// scaffolding DriverInstance rows before the Host is deployed so the server boots.</item>
/// <item><c>"Backend": "wire"</c> (default) — pure-managed FOCAS2 wire
/// client (<see cref="WireFocasClientFactory"/>) speaking directly to
/// the CNC on TCP:8193.</item>
/// <item><c>"Backend": "unimplemented"</c> / <c>"none"</c> / <c>"stub"</c>
/// — returns the no-op factory; useful for scaffolding DriverInstance
/// rows before the CNC endpoint is reachable.</item>
/// </list>
/// Devices / Tags / Probe / Timeout / Series come from the same JSON and feed directly
/// into <see cref="FocasDriverOptions"/>.
/// Devices / Tags / Probe / Timeout / Series come from the same JSON and
/// feed directly into <see cref="FocasDriverOptions"/>.
/// </remarks>
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<FocasDeviceDto>? Devices { get; init; }

View File

@@ -11,6 +11,77 @@ public sealed class FocasDriverOptions
public IReadOnlyList<FocasTagDefinition> 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();
}
/// <summary>
/// Fixed-node tree exposed by FOCAS per <c>docs/v2/driver-specs.md §7</c> —
/// <c>Identity/</c>, <c>Axes/{name}/</c>, etc. populated from
/// <c>cnc_sysinfo</c> / <c>cnc_rdaxisname</c> / <c>cnc_rddynamic2</c>. Disabled by
/// default so existing configs that only use user-authored tags don't grow new
/// nodes on upgrade.
/// </summary>
public sealed class FocasFixedTreeOptions
{
/// <summary>Enable the fixed-node tree for every configured device.</summary>
public bool Enabled { get; init; } = false;
/// <summary>
/// Poll cadence for <c>cnc_rddynamic2</c>. 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.
/// </summary>
public TimeSpan PollInterval { get; init; } = TimeSpan.FromMilliseconds(250);
/// <summary>
/// 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.
/// </summary>
public TimeSpan ProgramPollInterval { get; init; } = TimeSpan.FromSeconds(1);
/// <summary>
/// Poll cadence for timers (power-on / operating / cutting / cycle).
/// These change at human timescales — default is 30s. Zero / negative
/// disables the timer poll entirely.
/// </summary>
public TimeSpan TimerPollInterval { get; init; } = TimeSpan.FromSeconds(30);
}
/// <summary>
/// Proactive session-recycle cadence. Fanuc CNCs have a finite FWLIB handle pool
/// (~510 concurrent connections) and certain series have documented handle-leak bugs
/// that manifest after long uptime. When <see cref="Enabled"/> is <c>true</c> the
/// driver closes + reopens each device's session on the <see cref="Interval"/> 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class FocasHandleRecycleOptions
{
public bool Enabled { get; init; } = false;
public TimeSpan Interval { get; init; } = TimeSpan.FromHours(1);
}
/// <summary>
/// Controls the CNC active-alarm polling projection that surfaces FOCAS alarms via
/// <c>IAlarmSource</c>. Disabled by default — operators opt in by setting
/// <see cref="Enabled"/> in <c>appsettings.json</c>.
/// </summary>
public sealed class FocasAlarmProjectionOptions
{
public bool Enabled { get; init; } = false;
/// <summary>Poll cadence. One <c>cnc_rdalmmsg2</c> call per device per tick.</summary>
public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(2);
}
/// <summary>

View File

@@ -1,328 +0,0 @@
using System.Buffers.Binary;
using System.Collections.Concurrent;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// <see cref="IFocasClient"/> implementation backed by Fanuc's licensed
/// <c>Fwlib32.dll</c> via <see cref="FwlibNative"/> P/Invoke. The DLL is NOT shipped with
/// OtOpcUa; the deployment places it next to the server executable or on <c>PATH</c>
/// (per Fanuc licensing — see <c>docs/v2/focas-deployment.md</c>).
/// </summary>
/// <remarks>
/// <para>Construction is licence-safe — .NET P/Invoke is lazy, so instantiating this class
/// does NOT load <c>Fwlib32.dll</c>. The DLL only loads on the first wire call (Connect /
/// Read / Write / Probe). When missing, those calls throw <see cref="DllNotFoundException"/>
/// which the driver surfaces as <c>BadCommunicationError</c> through the normal exception
/// mapping.</para>
///
/// <para>Session-scoped handle — <c>cnc_allclibhndl3</c> opens one FWLIB handle per CNC;
/// all PMC / parameter / macro reads on that device go through the same handle. Dispose
/// calls <c>cnc_freelibhndl</c>.</para>
/// </remarks>
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<string, SemaphoreSlim> _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<uint> 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,
};
}
/// <summary>
/// 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.
/// </summary>
private async Task<uint> 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<bool> 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;
}
}
}
/// <summary>Default <see cref="IFocasClientFactory"/> — produces a fresh <see cref="FwlibFocasClient"/> per device.</summary>
public sealed class FwlibFocasClientFactory : IFocasClientFactory
{
public IFocasClient Create() => new FwlibFocasClient();
}

View File

@@ -1,190 +0,0 @@
using System.Runtime.InteropServices;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// P/Invoke surface for Fanuc FWLIB (<c>Fwlib32.dll</c>). Declarations extracted from
/// <c>fwlib32.h</c> in the strangesast/fwlib repo; the licensed DLL itself is NOT shipped
/// with OtOpcUa — the deployment places <c>Fwlib32.dll</c> next to the server executable
/// or on <c>PATH</c>.
/// </summary>
/// <remarks>
/// Deliberately narrow — only the calls <see cref="FwlibFocasClient"/> actually makes.
/// FOCAS has 800+ functions in <c>fwlib32.h</c>; pulling in every one would bloat the
/// P/Invoke surface + signal more coverage than this driver provides. Expand as capabilities
/// are added.
/// </remarks>
internal static class FwlibNative
{
private const string Library = "Fwlib32.dll";
// ---- Handle lifetime ----
/// <summary>Open an Ethernet FWLIB handle. Returns EW_OK (0) on success; handle written out.</summary>
[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 ----
/// <summary>PMC range read. <paramref name="addrType"/> is the ADR_* enum; <paramref name="dataType"/> is 0 byte / 1 word / 2 long.</summary>
[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 ----
/// <summary>
/// IODBPMC — PMC range I/O buffer. 8-byte header + 40-byte union. We marshal the union
/// as a fixed byte buffer + interpret per <see cref="FocasDataType"/> on the managed side.
/// </summary>
[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;
}
/// <summary>
/// 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.
/// </summary>
[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;
}
/// <summary>ODBM — macro variable read buffer. Value = <c>McrVal / 10^DecVal</c>.</summary>
[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
}
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
[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;
}
}
/// <summary>
/// PMC address-letter → FOCAS <c>ADR_*</c> 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.
/// </summary>
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,
};
}
/// <summary>PMC data-type numeric codes per FOCAS/2: 0 = byte, 1 = word, 2 = long, 4 = float, 5 = double.</summary>
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,
};
}

View File

@@ -5,15 +5,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// configured device; lifetime matches the device.
/// </summary>
/// <remarks>
/// <para><b>No default wire implementation ships with this assembly.</b> FWLIB
/// (<c>Fwlib32.dll</c>) is Fanuc-proprietary and requires a valid customer license — it
/// cannot legally be redistributed. The deployment team supplies an
/// <see cref="IFocasClientFactory"/> that wraps the licensed <c>Fwlib32.dll</c> via
/// P/Invoke and registers it at server startup.</para>
/// <para>The default implementation is <see cref="Wire.WireFocasClient"/> — 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.</para>
///
/// <para>The default <see cref="UnimplementedFocasClientFactory"/> throws with a pointer at
/// the deployment docs so misconfigured servers fail fast with a clear error rather than
/// mysteriously hanging.</para>
/// <para><see cref="UnimplementedFocasClientFactory"/> is a scaffolding backend that
/// throws on <see cref="IFocasClientFactory.Create"/> — selected by
/// <c>"Backend": "unimplemented"</c> so a DriverInstance row can be seeded before the CNC
/// endpoint is reachable without silently reading stale data.</para>
/// </remarks>
public interface IFocasClient : IDisposable
{
@@ -48,8 +47,208 @@ public interface IFocasClient : IDisposable
/// responds with any valid status.
/// </summary>
Task<bool> ProbeAsync(CancellationToken cancellationToken);
/// <summary>
/// Read active alarm messages from the CNC via <c>cnc_rdalmmsg2</c>. 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 <c>OnAlarmEvent</c>.
/// </summary>
Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken);
// ---- Fixed-tree T1 (identity + axis discovery + fast-poll dynamic bundle) ----
/// <summary>
/// Read CNC identity via <c>cnc_sysinfo</c>. Populates the <c>Identity/*</c>
/// subtree of the fixed-node surface. Callable once at session open; the
/// values don't change across the session.
/// </summary>
Task<FocasSysInfo> GetSysInfoAsync(CancellationToken cancellationToken);
/// <summary>
/// Read the CNC's configured axis names via <c>cnc_rdaxisname</c>. The driver
/// uses these to build the <c>Axes/{name}/</c> subtree and to index
/// <see cref="ReadDynamicAsync"/> calls.
/// </summary>
Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken cancellationToken);
/// <summary>
/// Read the CNC's configured spindle names via <c>cnc_rdspdlname</c>. Drives
/// the <c>Spindle/{name}/</c> subtree.
/// </summary>
Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken cancellationToken);
/// <summary>
/// Read the fast-poll dynamic bundle for one axis via <c>cnc_rddynamic2</c>.
/// 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.
/// </summary>
Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken);
// ---- Fixed-tree T2 (program + operation mode) ----
/// <summary>
/// Aggregate program + operation-mode snapshot. One wire round-trip per
/// underlying FWLIB call — <c>cnc_rdblkcount</c>, <c>cnc_exeprgname2</c>,
/// <c>cnc_rdopmode</c>. The driver polls this on a slower cadence than
/// <see cref="ReadDynamicAsync"/> since program / mode transitions happen
/// on human-operator timescales.
/// </summary>
Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken);
// ---- Fixed-tree T3 (timers) ----
/// <summary>
/// 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.
/// </summary>
Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken);
// ---- Fixed-tree T3.5 (servo meters) ----
/// <summary>
/// Read the servo-load meter percentages across all configured axes.
/// Values are percentages (scaled by <c>10^Dec</c>). Empty list on a
/// disconnected session or unsupported CNC.
/// </summary>
Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken cancellationToken);
// ---- Fixed-tree T3.6 (spindle meters) ----
/// <summary>
/// Read per-spindle load percentages. Result list index corresponds to
/// spindle index from <see cref="GetSpindleNamesAsync"/>. Empty list on a
/// disconnected session or when the CNC doesn't support the call (older
/// series like 16i may return EW_FUNC).
/// </summary>
Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken);
/// <summary>
/// Read per-spindle maximum RPM values. Static configuration, fetched once at
/// bootstrap. Index alignment as per <see cref="GetSpindleLoadsAsync"/>.
/// </summary>
Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken);
}
/// <summary>One servo-meter entry — one axis's current load percentage.</summary>
public sealed record FocasServoLoad(string AxisName, double LoadPercent);
/// <summary>Which cumulative counter <see cref="IFocasClient.GetTimerAsync"/> reads.</summary>
public enum FocasTimerKind
{
/// <summary>Machine power-on hours — resets never.</summary>
PowerOn = 0,
/// <summary>Cycle operating time — resets when the operator clears the counter.</summary>
Operating = 1,
/// <summary>Cutting time — only counts while in cutting feed.</summary>
Cutting = 2,
/// <summary>Cycle time since the last program start.</summary>
Cycle = 3,
}
/// <summary>One cumulative timer reading. <see cref="TotalSeconds"/> is the canonical unit.</summary>
public sealed record FocasTimer(FocasTimerKind Kind, int Minutes, int Milliseconds)
{
/// <summary>Cumulative time in seconds — <c>Minutes * 60 + Milliseconds / 1000</c>.</summary>
public double TotalSeconds => Minutes * 60.0 + Milliseconds / 1000.0;
}
/// <summary>
/// CNC identity snapshot from <c>cnc_sysinfo</c>. Strings are trimmed ASCII.
/// </summary>
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);
/// <summary>One configured axis name (e.g. "X", "X1").</summary>
public sealed record FocasAxisName(string Name, string Suffix)
{
/// <summary>
/// Display name — name + suffix concatenated, trimmed. Empty suffix yields
/// just the name (the common case on single-channel CNCs).
/// </summary>
public string Display => string.IsNullOrEmpty(Suffix) ? Name : $"{Name}{Suffix}";
}
/// <summary>One configured spindle name (e.g. "S1").</summary>
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', ' ');
}
}
}
/// <summary>
/// Fast-poll bundle for one axis. Position values are scaled integers; the caller
/// divides by <c>10^DecimalPlaces</c> to get the decimal value. DecimalPlaces is
/// currently left to the caller to supply (via device config or a future
/// <c>cnc_getfigure</c> path once that export lands).
/// </summary>
/// <summary>
/// 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 <c>cnc_rdopmode</c> — see <see cref="FocasOpMode"/>.
/// </summary>
public sealed record FocasProgramInfo(
string Name,
int ONumber,
int BlockCount,
int Mode);
/// <summary>Human-readable text for the <see cref="FocasProgramInfo.Mode"/> integer.</summary>
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);
/// <summary>
/// One active alarm surfaced by <see cref="IFocasClient.ReadAlarmsAsync"/>. Shape
/// mirrors <c>ODBALMMSG2</c> but normalises the message bytes to a .NET string.
/// </summary>
public sealed record FocasActiveAlarm(
int AlarmNumber,
short Type,
short Axis,
string Message);
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
public interface IFocasClientFactory
{
@@ -57,14 +256,32 @@ public interface IFocasClientFactory
}
/// <summary>
/// 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 <see cref="Create"/> so a DriverInstance row can be
/// seeded ahead of the CNC endpoint being reachable without silently reading stale data.
/// Select via <c>"Backend": "unimplemented"</c> in driver config. Flip to
/// <c>"Backend": "wire"</c> once the CNC is provisioned.
/// </summary>
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.");
}
/// <summary>
/// Well-known FOCAS alarm types from <c>fwlib32.h</c> <c>ALM_TYPE_*</c>. Narrow subset —
/// the full list is ~15 types per model; these cover the universally-present categories.
/// </summary>
public static class FocasAlarmType
{
/// <summary>Pass to <see cref="IFocasClient.ReadAlarmsAsync"/>-equivalent to mean "any type".</summary>
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.
}

View File

@@ -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;
/// <summary>
/// Proxy-side IPC channel to a running <c>Driver.FOCAS.Host</c>. 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.
/// </summary>
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);
}
/// <summary>Named-pipe factory: connects, sends Hello, awaits HelloAck.</summary>
public static async Task<FocasIpcClient> 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);
}
/// <summary>
/// Stream factory — used by tests that wire the Proxy against an in-memory stream
/// pair instead of a real pipe. <paramref name="stream"/> is owned by the caller
/// until <see cref="DisposeAsync"/>.
/// </summary>
public static Task<FocasIpcClient> ConnectAsync(Stream stream, string sharedSecret, CancellationToken ct)
=> HandshakeAsync(stream, sharedSecret, ct);
private static async Task<FocasIpcClient> 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<HelloAck>(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<TResp> CallAsync<TReq, TResp>(
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<ErrorResponse>(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<TResp>(frame.Value.Body);
}
finally { _callGate.Release(); }
}
public async Task SendOneWayAsync<TReq>(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;
}

View File

@@ -1,199 +0,0 @@
using MessagePack;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
/// <summary>
/// <see cref="IFocasClient"/> implementation that forwards every operation over a
/// <see cref="FocasIpcClient"/> to a <c>Driver.FOCAS.Host</c> process. Keeps the
/// <c>Fwlib32.dll</c> P/Invoke out of the main server process so a native crash
/// blast-radius stops at the Host boundary.
/// </summary>
/// <remarks>
/// Session lifecycle: <see cref="ConnectAsync"/> sends <c>OpenSessionRequest</c> and
/// caches the returned <c>SessionId</c>. Subsequent <see cref="ReadAsync"/> /
/// <see cref="WriteAsync"/> / <see cref="ProbeAsync"/> calls thread that session id
/// onto each request DTO. <see cref="Dispose"/> sends <c>CloseSessionRequest</c> +
/// disposes the underlying pipe.
/// </remarks>
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<OpenSessionRequest, OpenSessionResponse>(
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<ReadRequest, ReadResponse>(
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<uint> 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<PmcBitWriteRequest, PmcBitWriteResponse>(
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<WriteRequest, WriteResponse>(
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<bool> ProbeAsync(CancellationToken cancellationToken)
{
if (!_connected) return false;
try
{
var resp = await _ipc.CallAsync<ProbeRequest, ProbeResponse>(
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<bool>(bytes),
FocasDataTypeCode.Byte => MessagePackSerializer.Deserialize<byte>(bytes),
FocasDataTypeCode.Int16 => MessagePackSerializer.Deserialize<short>(bytes),
FocasDataTypeCode.Int32 => MessagePackSerializer.Deserialize<int>(bytes),
FocasDataTypeCode.Float32 => MessagePackSerializer.Deserialize<float>(bytes),
FocasDataTypeCode.Float64 => MessagePackSerializer.Deserialize<double>(bytes),
FocasDataTypeCode.String => MessagePackSerializer.Deserialize<string>(bytes),
_ => MessagePackSerializer.Deserialize<int>(bytes),
};
}
}
/// <summary>
/// Factory producing <see cref="IpcFocasClient"/>s. One pipe connection per
/// <c>IFocasClient</c> — matches the driver's one-client-per-device invariant. The
/// deployment wires this into the DI container in place of
/// <see cref="UnimplementedFocasClientFactory"/>.
/// </summary>
public sealed class IpcFocasClientFactory(Func<FocasIpcClient> ipcClientFactory, FocasCncSeries series = FocasCncSeries.Unknown)
: IFocasClientFactory
{
public IFocasClient Create() => new IpcFocasClient(ipcClientFactory(), series);
}

View File

@@ -1,30 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
/// <summary>
/// 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.
/// </summary>
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;
}

View File

@@ -1,69 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
/// <summary>
/// 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.
/// </summary>
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<DateTime> _crashesUtc = [];
private DateTime? _openSinceUtc;
private int _escalationLevel;
public bool StickyAlertActive { get; private set; }
/// <summary>
/// Records a crash + returns <c>true</c> if the supervisor may respawn. On
/// <c>false</c>, <paramref name="cooldownRemaining"/> is how long to wait before
/// trying again (<c>TimeSpan.MaxValue</c> means manual reset required).
/// </summary>
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;
}
}

View File

@@ -1,159 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
/// <summary>
/// Ties <see cref="IHostProcessLauncher"/> + <see cref="Backoff"/> +
/// <see cref="CircuitBreaker"/> + <see cref="HeartbeatMonitor"/> into one object the
/// driver asks for <c>IFocasClient</c>s. On a detected crash (process exit or
/// heartbeat loss) the supervisor fans out <c>BadCommunicationError</c> to all
/// subscribers via the <see cref="OnUnavailable"/> callback, then respawns with
/// backoff unless the breaker is open.
/// </summary>
/// <remarks>
/// The supervisor itself is I/O-free — it doesn't know how to spawn processes, probe
/// pipes, or send heartbeats. Production wires the concrete
/// <see cref="IHostProcessLauncher"/> over <c>FocasIpcClient</c> + <c>Process</c>;
/// tests drive the same state machine with a deterministic launcher stub.
/// </remarks>
public sealed class FocasHostSupervisor : IDisposable
{
private readonly IHostProcessLauncher _launcher;
private readonly Backoff _backoff;
private readonly CircuitBreaker _breaker;
private readonly Func<DateTime> _clock;
private IFocasClient? _current;
private DateTime _currentStartedUtc;
private bool _disposed;
public FocasHostSupervisor(
IHostProcessLauncher launcher,
Backoff? backoff = null,
CircuitBreaker? breaker = null,
Func<DateTime>? clock = null)
{
_launcher = launcher ?? throw new ArgumentNullException(nameof(launcher));
_backoff = backoff ?? new Backoff();
_breaker = breaker ?? new CircuitBreaker();
_clock = clock ?? (() => DateTime.UtcNow);
}
/// <summary>Raised with a short reason string whenever the Host goes unavailable (crash / heartbeat loss / breaker-open).</summary>
public event Action<string>? OnUnavailable;
/// <summary>Crash count observed in the current process lifetime. Exposed for /hosts Admin telemetry.</summary>
public int ObservedCrashes { get; private set; }
/// <summary><c>true</c> if the crash-loop breaker has latched a sticky alert that needs operator reset.</summary>
public bool StickyAlertActive => _breaker.StickyAlertActive;
public int BackoffAttempt => _backoff.AttemptIndex;
/// <summary>
/// Returns the current live client. If none, tries to launch — applying the
/// backoff schedule between attempts and stopping once the breaker opens.
/// </summary>
public async Task<IFocasClient> GetOrLaunchAsync(CancellationToken ct)
{
ThrowIfDisposed();
if (_current is not null && _launcher.IsProcessAlive) return _current;
return await LaunchWithBackoffAsync(ct).ConfigureAwait(false);
}
/// <summary>
/// Called by the heartbeat task each time a miss threshold is crossed.
/// Treated as a crash: fan out Bad status + attempt respawn.
/// </summary>
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.
}
/// <summary>Operator action — clear the sticky alert + reset the breaker.</summary>
public void AcknowledgeAndReset()
{
_breaker.ManualReset();
_backoff.RecordStableRun();
}
private async Task<IFocasClient> 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);
}
}
}
/// <summary>Called from the heartbeat loop after a successful ack run — relaxes the backoff ladder.</summary>
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));
}
}

View File

@@ -1,29 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>Records a missed heartbeat; returns <c>true</c> when the death threshold is crossed.</summary>
public bool RecordMiss()
{
ConsecutiveMisses++;
return ConsecutiveMisses >= MissesUntilDead;
}
}

View File

@@ -1,32 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
/// <summary>
/// Abstraction over the act of spawning a FOCAS Host process and obtaining an
/// <see cref="IFocasClient"/> connected to it. Production wires this to a real
/// <c>Process.Start</c> + <c>FocasIpcClient.ConnectAsync</c>; tests use a fake that
/// exposes deterministic failure modes so the supervisor logic can be stressed
/// without spawning actual exes.
/// </summary>
public interface IHostProcessLauncher
{
/// <summary>
/// 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 <see cref="TimeoutException"/> so the supervisor
/// applies the backoff ladder.
/// </summary>
Task<IFocasClient> LaunchAsync(CancellationToken ct);
/// <summary>
/// Terminate the Host process if one is running. Called on Dispose and after a
/// heartbeat loss is detected.
/// </summary>
Task TerminateAsync(CancellationToken ct);
/// <summary>
/// <c>true</c> when the most recently spawned Host process is still alive.
/// Supervisor polls this at heartbeat cadence; going <c>false</c> without a
/// clean shutdown counts as a crash.
/// </summary>
bool IsProcessAlive { get; }
}

View File

@@ -1,57 +0,0 @@
using System.IO.MemoryMappedFiles;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
/// <summary>
/// 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
/// <c>Driver.FOCAS.Host.Stability.PostMortemMmf</c> — magic 'OFPC' / 256-byte entries.
/// </summary>
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<byte>(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);

View File

@@ -1,113 +0,0 @@
using System.Diagnostics;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
/// <summary>
/// Production <see cref="IHostProcessLauncher"/>. Spawns <c>OtOpcUa.Driver.FOCAS.Host.exe</c>
/// with the pipe name / allowed-SID / per-spawn shared secret in the environment, waits for
/// the pipe to come up, then connects a <see cref="FocasIpcClient"/> and wraps it in an
/// <see cref="IpcFocasClient"/>. On <see cref="TerminateAsync"/> best-effort kills the
/// process and closes the IPC stream.
/// </summary>
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<IFocasClient> 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;
}

View File

@@ -0,0 +1,120 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
/// <summary>
/// PMC address-letter → FOCAS <c>ADR_*</c> numeric code. Values are the FOCAS/2 wire
/// constants passed as the <c>area</c> argument on <c>pmc_rdpmcrng</c>
/// (G=0, F=1, Y=2, X=3, A=4, R=5, T=6, K=7, C=8, D=9, E=10).
/// </summary>
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,
}
/// <summary>
/// PMC data-type numeric codes per FOCAS/2: <c>Byte=0</c>, <c>Word=1</c>, <c>Long=2</c>,
/// <c>Real=4</c>, <c>Double=5</c>. Passed as the <c>data_type</c> argument on
/// <c>pmc_rdpmcrng</c>.
/// </summary>
public enum FocasPmcDataType : short
{
Byte = 0,
Word = 1,
Long = 2,
Real = 4,
Double = 5,
}
/// <summary>
/// CNC operation mode as reported by <c>cnc_rdopmode</c>. Values are the FOCAS-defined
/// mode codes; see <see cref="FocasOperationModeExtensions.ToText"/> for the canonical
/// operator-facing labels.
/// </summary>
public enum FocasOperationMode : short
{
Mdi = 0,
Auto = 1,
TJog = 2,
Edit = 3,
Handle = 4,
Jog = 5,
TeachInHandle = 6,
Reference = 7,
Remote = 8,
Test = 9,
}
/// <summary>Extension helpers over <see cref="FocasOperationMode"/>.</summary>
public static class FocasOperationModeExtensions
{
/// <summary>
/// Canonical operator-facing label for an operation mode (e.g. <c>"AUTO"</c>,
/// <c>"EDIT"</c>). Unknown codes fall back to the raw numeric value as a string
/// so the UI still shows something interpretable.
/// </summary>
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(),
};
}
/// <summary>
/// Letter → <see cref="FocasPmcArea"/> lookup. Used by <see cref="WireFocasClient"/> to
/// translate a parsed <see cref="FocasAddress.PmcLetter"/> into the wire code expected by
/// <c>pmc_rdpmcrng</c>.
/// </summary>
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,
};
}
/// <summary>
/// <see cref="FocasDataType"/> → <see cref="FocasPmcDataType"/> mapping for wire PMC
/// reads. Bit reads collapse to byte — the caller extracts the bit from the returned
/// value.
/// </summary>
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,
};
}

View File

@@ -0,0 +1,883 @@
using System.Buffers.Binary;
using System.Net.Sockets;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
/// <summary>
/// Pure-managed read-only FOCAS/2 Ethernet wire client. Speaks the proprietary Fanuc
/// binary protocol on TCP:8193 directly — no P/Invoke, no <c>Fwlib64.dll</c>, no
/// out-of-process Host. One instance owns two TCP sockets for the duration of a CNC
/// session; <see cref="ConnectAsync(string, int, int, CancellationToken)"/> runs the
/// two-socket initiate handshake and a setup request, subsequent reads reuse
/// <c>socket 2</c> serialised through an internal semaphore.
/// </summary>
/// <remarks>
/// <para><b>Read surface.</b> Covers every FOCAS call OtOpcUa's managed driver issues:
/// sysinfo, status, axis + spindle names, the <c>cnc_rddynamic2</c> 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.</para>
/// <para><b>Concurrency.</b> Callers may issue reads concurrently from multiple threads
/// — <c>socket 2</c> is guarded by a <see cref="SemaphoreSlim"/> so at most one
/// request/response pair is in flight at a time. <see cref="ConnectAsync(string, int, int, CancellationToken)"/>
/// and <see cref="DisposeAsync"/> share a second semaphore to stop the two racing.</para>
/// <para><b>Transient failures.</b> When cancellation or a socket-level error happens
/// mid-request the client closes both sockets and throws
/// <see cref="FocasWireException"/> with <see cref="FocasWireException.IsTransient"/>
/// 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.</para>
/// </remarks>
public sealed class FocasWireClient : IAsyncDisposable, IDisposable
{
private readonly ILogger<FocasWireClient>? _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<WireSysInfo>? _sysInfo;
/// <summary>
/// Construct a disconnected client. Optional <paramref name="logger"/> receives
/// <c>Debug</c>-level entries per response block (command ID, RC, payload length).
/// </summary>
public FocasWireClient(ILogger<FocasWireClient>? logger = null)
{
_logger = logger;
}
/// <summary>
/// Default <c>PathId</c> applied when no per-call override is supplied. Relevant for
/// multi-path CNCs; single-path controllers leave this at the default of <c>1</c>.
/// </summary>
public ushort PathId { get; set; } = 1;
/// <summary>True when the two-socket handshake has completed and the transport is live.</summary>
public bool IsConnected => _connected;
/// <summary>
/// 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
/// <see cref="ConnectAsync(string, int, TimeSpan, CancellationToken)"/> overload.
/// </summary>
public Task ConnectAsync(
string host,
int port,
int timeoutSeconds = 10,
CancellationToken cancellationToken = default)
=> ConnectCoreAsync(
host,
port,
timeoutSeconds > 0 ? TimeSpan.FromSeconds(timeoutSeconds) : null,
cancellationToken);
/// <summary>
/// Open the FOCAS session with a <see cref="TimeSpan"/> timeout. Pass
/// <see cref="TimeSpan.Zero"/> to disable the timeout entirely (rely on the caller's
/// <paramref name="cancellationToken"/> instead). Idempotent.
/// </summary>
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();
}
}
/// <summary>
/// Synchronous dispose — sends the close PDU when connected and tears down both
/// sockets. Idempotent. Callers on an async context should prefer
/// <see cref="DisposeAsync"/>.
/// </summary>
public void Dispose()
{
_lifetimeGate.Wait();
try
{
if (_disposed) return;
_disposed = true;
if (_stream2 is not null && _connected)
{
try
{
SendPdu(_stream2, FocasWireProtocol.TypeClose, ReadOnlySpan<byte>.Empty);
_ = FocasWireProtocol.ReadPdu(_stream2);
}
catch
{
// Close best-effort — don't let teardown failure hide a caller's real error.
}
}
CloseTransport();
}
finally
{
_lifetimeGate.Release();
}
}
/// <summary>
/// Async dispose — sends the close PDU when connected and tears down both sockets.
/// Idempotent.
/// </summary>
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<byte>.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();
}
}
/// <summary>
/// Read CNC identity via <c>cnc_sysinfo</c>. Cached from the connect-time exchange
/// unless a per-call <paramref name="pathId"/> override is supplied.
/// </summary>
public async Task<FocasResult<WireSysInfo>> 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);
}
/// <summary>Read CNC status bits via <c>cnc_statinfo</c> (3 command blocks aggregated into one <see cref="WireStatus"/>).</summary>
public async Task<FocasResult<WireStatus>> 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<WireStatus>(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<WireStatus>(
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));
}
/// <summary>Read configured axis names via <c>cnc_rdaxisname</c> (command <c>0x0089</c>).</summary>
public async Task<FocasResult<IReadOnlyList<WireAxisRecord>>> 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)));
}
/// <summary>Read configured spindle names via <c>cnc_rdspdlname</c> (command <c>0x008a</c>).</summary>
public async Task<FocasResult<IReadOnlyList<WireSpindleRecord>>> 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)));
}
/// <summary>
/// Fast-poll bundle for one axis via <c>cnc_rddynamic2</c>. 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.
/// </summary>
public async Task<FocasResult<WireDynamic>> 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<WireDynamic>(rc, null);
var programPayload = FindPayload(blocks, 0x001c);
return new FocasResult<WireDynamic>(
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))));
}
/// <summary>Read servo-meter load percentages via <c>cnc_rdsvmeter</c> (command <c>0x0056</c>).</summary>
public async Task<FocasResult<IReadOnlyList<WireServoMeter>>> 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<IReadOnlyList<WireServoMeter>>(rc, null);
var payload = FindPayload(blocks, 0x0056);
var result = new List<WireServoMeter>();
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<IReadOnlyList<WireServoMeter>>(rc, result);
}
/// <summary>Read per-spindle load percentages via <c>cnc_rdspload</c> (command <c>0x0040</c> with arg1=0).</summary>
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleLoadAsync(
short spindleSelector = -1,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
=> ReadSpindleMetricAsync(0, spindleSelector, cancellationToken, timeout, pathId);
/// <summary>Read per-spindle maximum RPMs via <c>cnc_rdspmaxrpm</c> (command <c>0x0040</c> with arg1=1).</summary>
public Task<FocasResult<IReadOnlyList<WireSpindleMetric>>> ReadSpindleMaxRpmAsync(
short spindleSelector = -1,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
=> ReadSpindleMetricAsync(1, spindleSelector, cancellationToken, timeout, pathId);
/// <summary>
/// Raw-bytes parameter read via <c>cnc_rdparam</c>. Caller marshals the returned
/// payload to the type declared in the per-series parameter catalog. <paramref name="axis"/>
/// selects an axis-scoped parameter; <c>0</c> means global.
/// </summary>
public async Task<FocasResult<byte[]>> 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);
}
/// <summary>Typed Int32 parameter read — convenience over <see cref="ReadParameterBytesAsync"/>.</summary>
public async Task<FocasResult<WireParameter>> 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<WireParameter>(result.Rc, null);
return new FocasResult<WireParameter>(
result.Rc,
new WireParameter(dataNumber, type, result.Value.Length >= 4 ? ReadInt32(result.Value, 0) : 0));
}
/// <summary>Typed 8-bit parameter read.</summary>
public async Task<FocasResult<byte>> 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<byte>(result.Rc, default)
: new FocasResult<byte>(result.Rc, result.Value.Length >= 1 ? result.Value[0] : default);
}
/// <summary>Typed 16-bit parameter read.</summary>
public async Task<FocasResult<short>> 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<short>(result.Rc, default)
: new FocasResult<short>(result.Rc, result.Value.Length >= 2 ? ReadInt16(result.Value, 0) : default);
}
/// <summary>Typed 32-bit parameter read.</summary>
public async Task<FocasResult<int>> 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<int>(result.Rc, default)
: new FocasResult<int>(result.Rc, result.Value.Length >= 4 ? ReadInt32(result.Value, 0) : default);
}
/// <summary>Typed IEEE-754 single-precision parameter read.</summary>
public async Task<FocasResult<float>> 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<float>(result.Rc, default)
: new FocasResult<float>(result.Rc, BitConverter.Int32BitsToSingle(ReadInt32(result.Value, 0)));
}
/// <summary>Typed IEEE-754 double-precision parameter read.</summary>
public async Task<FocasResult<double>> 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<double>(result.Rc, default)
: new FocasResult<double>(result.Rc, BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64BigEndian(result.Value.AsSpan(0, 8))));
}
/// <summary>Read a single macro variable via <c>cnc_rdmacro</c> (command <c>0x0015</c>).</summary>
public Task<FocasResult<WireMacro>> 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);
/// <summary>
/// Read a PMC range via <c>pmc_rdpmcrng</c>. <paramref name="area"/> is the numeric
/// address-letter code (see <see cref="FocasPmcArea"/>); <paramref name="dataType"/>
/// is the width code (see <see cref="FocasPmcDataType"/>). Payload is decoded into
/// <see cref="WirePmcRange.Values"/> — one entry per slot of the requested width.
/// </summary>
public async Task<FocasResult<WirePmcRange>> 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<long>();
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);
});
}
/// <summary>Typed overload for <see cref="ReadPmcRangeAsync(short, short, ushort, ushort, CancellationToken, TimeSpan?, ushort?)"/>.</summary>
public Task<FocasResult<WirePmcRange>> 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);
/// <summary>
/// Read active alarms via <c>cnc_rdalmmsg2</c> (command <c>0x0023</c>). Parses both
/// the 76-byte vendor <c>ODBALMMSG2_data</c> layout and the 80-byte legacy wire
/// shape so the same managed surface works across firmware revisions.
/// </summary>
public async Task<FocasResult<IReadOnlyList<WireAlarm>>> 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));
}
/// <summary>Read operation mode via <c>cnc_rdopmode</c>, returned as the typed <see cref="FocasOperationMode"/>.</summary>
public Task<FocasResult<FocasOperationMode>> 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));
/// <summary>
/// Raw-code variant of <see cref="ReadOperationModeAsync"/> — returns the underlying
/// FOCAS <c>short</c> so callers storing the raw mode code (e.g. OtOpcUa's
/// <c>FocasProgramInfo.Mode</c> int field) don't have to cast the enum.
/// </summary>
public Task<FocasResult<short>> 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));
/// <summary>Read the currently-executing program name + O-number via <c>cnc_exeprgname2</c> (command <c>0x00fc</c>).</summary>
public Task<FocasResult<WireProgramName>> ReadExecutingProgramNameAsync(
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
=> ReadSingleWithTimeoutAsync(0x00fc, ParseProgramName, cancellationToken, timeout, EffectivePathId(pathId));
/// <summary>Read the executed block count via <c>cnc_rdblkcount</c>.</summary>
public Task<FocasResult<int>> ReadBlockCountAsync(
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
=> ReadSingleWithTimeoutAsync(
0x0035,
payload => payload.Length >= 4 ? ReadInt32(payload, 0) : 0,
cancellationToken, timeout, EffectivePathId(pathId));
/// <summary>
/// Read one cumulative timer via <c>cnc_rdtimer</c>. <paramref name="type"/> selects
/// PowerOn / Operating / Cutting / Cycle per the FOCAS spec (0..3).
/// </summary>
public Task<FocasResult<WireTimer>> 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<FocasResult<IReadOnlyList<WireSpindleMetric>>> 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<IReadOnlyList<WireSpindleMetric>>(block, payload =>
{
var values = new List<WireSpindleMetric>();
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<FocasResult<T>> ReadSingleAsync<T>(
ushort command,
Func<byte[], T> 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<FocasResult<T>> ReadSingleWithTimeoutAsync<T>(
ushort command,
Func<byte[], T> 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<ResponseBlock> SendSingleRequestAsync(CancellationToken cancellationToken, RequestBlock block)
{
var blocks = await SendRequestAsync(cancellationToken, block).ConfigureAwait(false);
return blocks.Count == 0 ? new ResponseBlock(block.Command, 0, Array.Empty<byte>()) : blocks[0];
}
private async Task<IReadOnlyList<ResponseBlock>> 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<TcpClient> 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<byte> 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<byte> 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<bool>();
using var registration = cancellationToken.Register(static state => ((TaskCompletionSource<bool>)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<Pdu> 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<T> ToResult<T>(ResponseBlock block, Func<byte[], T> parser)
=> block.Rc != 0
? new FocasResult<T>(block.Rc, default)
: new FocasResult<T>(block.Rc, parser(block.Payload));
private static short AggregateRc(IReadOnlyList<ResponseBlock> blocks)
=> blocks.FirstOrDefault(block => block.Rc != 0)?.Rc ?? 0;
private static byte[] FindPayload(IReadOnlyList<ResponseBlock> blocks, ushort command)
=> blocks.FirstOrDefault(block => block.Command == command)?.Payload ?? Array.Empty<byte>();
private static int ReadFirstInt32(IReadOnlyList<ResponseBlock> blocks, ushort command)
{
var payload = FindPayload(blocks, command);
return payload.Length >= 4 ? ReadInt32(payload, 0) : 0;
}
private static int ReadSelectorPosition(IReadOnlyList<ResponseBlock> 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<WireAlarm> ParseAlarms(byte[] payload, short count)
=> payload.Length % 76 == 0
? ParseVendorAlarms(payload, count)
: ParseLegacyWireAlarms(payload, count);
private static IReadOnlyList<WireAlarm> ParseVendorAlarms(byte[] payload, short count)
{
var alarms = new List<WireAlarm>();
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<WireAlarm> ParseLegacyWireAlarms(byte[] payload, short count)
{
var alarms = new List<WireAlarm>();
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<T> ReadNameRecords<T>(byte[] payload, short maxCount, Func<short, string, T> factory)
{
var names = new List<T>();
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));
}

View File

@@ -0,0 +1,51 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
/// <summary>
/// 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 <c>EW_*</c> return code.
/// </summary>
/// <remarks>
/// <para>Callers distinguish the two classes via <see cref="IsTransient"/>: <c>true</c>
/// 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. <c>false</c> for protocol-level errors where the connection is still
/// usable.</para>
/// <para><see cref="Rc"/> 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).</para>
/// </remarks>
public class FocasWireException : Exception
{
/// <summary>FOCAS <c>EW_*</c> return code from the response block, when available.</summary>
public short? Rc { get; }
/// <summary>
/// True when the transport was closed as a side effect of this failure — the caller
/// must reconnect before issuing the next request.
/// </summary>
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;
}
}

View File

@@ -0,0 +1,131 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
/// <summary>
/// Return envelope over a parsed wire response. <see cref="Rc"/> carries the FOCAS
/// <c>EW_*</c> code from the response block — <c>0</c> / <see cref="IsOk"/> means the
/// call succeeded and <see cref="Value"/> is populated; non-zero means the CNC rejected
/// the call and <see cref="Value"/> is <c>default</c>. Callers use the RC to distinguish
/// "feature missing on this series" (<c>EW_FUNC</c> / <c>EW_NOOPT</c>) from genuine
/// failures.
/// </summary>
public readonly record struct FocasResult<T>(short Rc, T? Value)
{
/// <summary>True when <see cref="Rc"/> is zero (<c>EW_OK</c>).</summary>
public bool IsOk => Rc == 0;
}
/// <summary>CNC identity payload returned by <c>cnc_sysinfo</c>.</summary>
public sealed record WireSysInfo(
short AddInfo,
short MaxAxis,
string CncType,
string MachineType,
string Series,
string Version,
string Axes);
/// <summary>Coarse CNC state bits returned by <c>cnc_statinfo</c> — the seven-word status block plus TM mode.</summary>
public sealed record WireStatus(
short Auto,
short Run,
short Motion,
short Mstb,
short Emergency,
short Alarm,
short Edit,
short TmMode);
/// <summary>Four-slot position quadruple for one axis: absolute / machine / relative / distance-to-go.</summary>
public sealed record WireAxisPosition(
int Absolute,
int Machine,
int Relative,
int Distance);
/// <summary>
/// Fast-poll bundle for one axis from <c>cnc_rddynamic2</c> — alarm flags, active program
/// numbers, sequence number, actual feed rate, actual spindle speed, and the position
/// quadruple.
/// </summary>
public sealed record WireDynamic(
int Alarm,
int ProgramNumber,
int MainProgramNumber,
int SequenceNumber,
int FeedRate,
int SpindleSpeed,
WireAxisPosition Axis);
/// <summary>One servo-meter entry from <c>cnc_rdsvmeter</c> — per-axis load percentage (scale by 10^<see cref="Decimal"/>).</summary>
public sealed record WireServoMeter(
short Index,
string Name,
int Value,
short Decimal,
short Unit);
/// <summary>One spindle metric slot from <c>cnc_rdspload</c> / <c>cnc_rdspmaxrpm</c>.</summary>
public sealed record WireSpindleMetric(
short Index,
int Value);
/// <summary>
/// One axis-name slot from <c>cnc_rdaxisname</c>. <see cref="Index"/> is the 1-based
/// axis index (preserved even when the name is empty so callers can pass it to
/// <c>cnc_rddynamic2</c>).
/// </summary>
public readonly record struct WireAxisRecord(short Index, string Name);
/// <summary>One spindle-name slot from <c>cnc_rdspdlname</c>.</summary>
public readonly record struct WireSpindleRecord(short Index, string Name);
/// <summary>Parameter value returned by <c>cnc_rdparam</c>, interpreted as a scalar Int32.</summary>
public sealed record WireParameter(
short DataNumber,
short Type,
int Value);
/// <summary>
/// Macro variable from <c>cnc_rdmacro</c>. Scaled decimal: the callable value is
/// <c>Value / 10^Decimal</c>.
/// </summary>
public sealed record WireMacro(
short Number,
int Value,
short Decimal);
/// <summary>PMC range read-back from <c>pmc_rdpmcrng</c>: one or more values of the requested width.</summary>
public sealed record WirePmcRange(
short Area,
short DataType,
ushort Start,
ushort End,
IReadOnlyList<long> Values);
/// <summary>
/// One active alarm from <c>cnc_rdalmmsg2</c>. Mirrors the vendor <c>ODBALMMSG2</c>
/// layout; <see cref="AlarmGroup"/> is populated when the wire responder carries it
/// (currently <c>null</c> for both the 76-byte vendor shape and the 80-byte legacy
/// shape).
/// </summary>
public sealed record WireAlarm(
int AlarmNumber,
short Type,
short Axis,
short MessageLength,
string Message,
int? AlarmGroup = null);
/// <summary>
/// Executing-program identity from <c>cnc_exeprgname2</c>: the NUL-terminated name and
/// the trailing 32-bit O-number (null when the wire responder omits the trailing int).
/// </summary>
public sealed record WireProgramName(
string Name,
int? ONumber);
/// <summary>One cumulative timer reading from <c>cnc_rdtimer</c> (minutes + fractional milliseconds).</summary>
public sealed record WireTimer(
short Type,
int Minutes,
int Milliseconds);

View File

@@ -0,0 +1,250 @@
using System.Buffers.Binary;
using System.Net.Sockets;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
/// <summary>
/// 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
/// <c>docs/v2/implementation/focas-wire-protocol.md</c>.
/// </summary>
/// <remarks>
/// <para>All multi-byte integer fields are big-endian on the wire. The 10-byte header is
/// <c>a0 a0 a0 a0</c> magic + 2-byte version + type byte + direction byte + 2-byte body
/// length. Version 1 is the only version this implementation supports.</para>
/// <para>Type <c>0x01</c> is the initiate handshake, <c>0x02</c> is the session close,
/// <c>0x21</c> is a request/response data PDU carrying one or more request blocks.</para>
/// </remarks>
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];
/// <summary>Assemble a full PDU (10-byte header + body) for transmission.</summary>
public static byte[] BuildPdu(byte type, byte direction, ReadOnlySpan<byte> 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;
}
/// <summary>
/// Initiate-body shape — just the 2-byte socket index (1 or 2). <c>cnc_allclibhndl3</c>
/// opens two TCP sockets in sequence and each sends its own initiate PDU carrying its
/// index.
/// </summary>
public static byte[] BuildInitiateBody(ushort socketIndex)
{
var body = new byte[2];
BinaryPrimitives.WriteUInt16BigEndian(body, socketIndex);
return body;
}
/// <summary>Assemble a type-<c>0x21</c> body carrying one or more request blocks.</summary>
public static byte[] BuildRequestBody(IReadOnlyList<RequestBlock> 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;
}
/// <summary>Async read of one full PDU off a stream. Throws <see cref="FocasWireException"/> on invalid magic / version / truncation.</summary>
public static async Task<Pdu> 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);
}
/// <summary>Synchronous counterpart to <see cref="ReadPduAsync"/> — used by <see cref="FocasWireClient"/>'s sync dispose.</summary>
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;
}
}
/// <summary>
/// Unpack a type-<c>0x21</c> response body into its constituent response blocks. Each
/// block carries the command ID, the FOCAS <c>EW_*</c> return code, and the payload
/// bytes.
/// </summary>
public static IReadOnlyList<ResponseBlock> ParseResponseBlocks(ReadOnlySpan<byte> body)
{
if (body.Length < 2)
return Array.Empty<ResponseBlock>();
var count = BinaryPrimitives.ReadUInt16BigEndian(body.Slice(0, 2));
var blocks = new List<ResponseBlock>(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;
}
/// <summary>Read an ASCII string out of a payload span, stopping at the first NUL and trimming trailing spaces.</summary>
public static string ReadAscii(ReadOnlySpan<byte> bytes)
{
var end = bytes.IndexOf((byte)0);
if (end >= 0) bytes = bytes.Slice(0, end);
return Encoding.ASCII.GetString(bytes.ToArray()).TrimEnd(' ', '\0');
}
/// <summary>
/// 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 <c>"X "</c> becomes
/// <c>"X"</c>.
/// </summary>
public static string ReadNameRecord(ReadOnlySpan<byte> 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<byte>();
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;
}
}
/// <summary>One raw PDU off the wire — header bytes plus the body.</summary>
internal sealed record Pdu(byte Type, byte Direction, byte[] Body);
/// <summary>
/// One request block within a type-<c>0x21</c> PDU body. <see cref="Command"/> is the
/// FOCAS command ID (e.g. <c>0x0018</c> for sysinfo); <see cref="Arg1"/>..<see cref="Arg5"/>
/// are the command-specific scalar arguments; <see cref="ExtraPayload"/> carries the
/// optional extra bytes for writes.
/// </summary>
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);
/// <summary>One response block — command ID + FOCAS return code + payload bytes.</summary>
internal sealed record ResponseBlock(ushort Command, short Rc, byte[] Payload);

View File

@@ -0,0 +1,333 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
/// <summary>
/// <see cref="IFocasClient"/> implementation backed by the in-tree managed
/// <see cref="FocasWireClient"/>. No P/Invoke, no <c>Fwlib64.dll</c>, no out-of-process
/// Host — the wire client dials the CNC on TCP:8193 directly and speaks the FOCAS/2
/// Ethernet binary protocol.
/// </summary>
/// <remarks>
/// OtOpcUa is read-only against FOCAS. <see cref="WriteAsync"/> returns
/// <see cref="FocasStatusMapper.BadNotWritable"/> for every address — the managed wire
/// client intentionally does not expose <c>cnc_wrparam</c> / <c>pmc_wrpmcrng</c> /
/// <c>cnc_wrmacro</c>.
/// </remarks>
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<uint> WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
=> Task.FromResult(FocasStatusMapper.BadNotWritable);
public async Task<bool> 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<IReadOnlyList<FocasActiveAlarm>> 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<FocasSysInfo> 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<IReadOnlyList<FocasAxisName>> 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<IReadOnlyList<FocasSpindleName>> 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<FocasDynamicSnapshot> 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<FocasProgramInfo> 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<FocasTimer> 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<IReadOnlyList<FocasServoLoad>> 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<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken) =>
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleLoadAsync(sel, ct), cancellationToken);
public Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken) =>
ReadSpindleMetricAsync((sel, ct) => _wire.ReadSpindleMaxRpmAsync(sel, ct), cancellationToken);
private static async Task<IReadOnlyList<int>> ReadSpindleMetricAsync(
Func<short, CancellationToken, Task<FocasResult<IReadOnlyList<WireSpindleMetric>>>> call,
CancellationToken cancellationToken)
{
var result = await call(-1, cancellationToken).ConfigureAwait(false);
if (!result.IsOk || result.Value is null) return [];
var list = new List<int>();
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}.");
}
}
/// <summary>Factory producing <see cref="WireFocasClient"/> instances — one per configured device.</summary>
public sealed class WireFocasClientFactory : IFocasClientFactory
{
public IFocasClient Create() => new WireFocasClient();
}

View File

@@ -15,20 +15,15 @@
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
</ItemGroup>
<!--
No NuGet reference to a FOCAS library — FWLIB is Fanuc-proprietary and the licensed
Fwlib32.dll cannot be redistributed. The deployment side supplies an IFocasClient
implementation that P/Invokes against whatever Fwlib32.dll the customer has licensed.
Driver.FOCAS.IntegrationTests in a separate repo can wire in the real binary.
Follow-up task #193 tracks the real-client reference implementation that customers may
drop in privately.
-->
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests"/>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests"/>
</ItemGroup>
</Project>

View File

@@ -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
{
/// <summary>
/// Validates that <see cref="FwlibFrameHandler"/> correctly dispatches each
/// <see cref="FocasMessageKind"/> to the corresponding <see cref="IFocasBackend"/>
/// method and serializes the response into the expected response kind. Uses
/// <see cref="FakeFocasBackend"/> so no hardware is needed.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FwlibFrameHandlerTests
{
private static async Task RoundTripAsync<TReq, TResp>(
IFrameHandler handler, FocasMessageKind reqKind, TReq req, FocasMessageKind expectedRespKind,
Action<TResp> 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<TResp>(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<OpenSessionRequest, OpenSessionResponse>(
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<ReadRequest, ReadResponse>(
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<OpenSessionResponse>(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<ReadResponse>(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<ReadResponse>(next!.Value.Body);
}
lastResp.Success.ShouldBeTrue();
MessagePackSerializer.Deserialize<int>(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<OpenSessionResponse>(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<PmcBitWriteResponse>(
(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<OpenSessionResponse>(
(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<ProbeResponse>(
(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<OpenSessionRequest, OpenSessionResponse>(
handler,
FocasMessageKind.OpenSessionRequest,
new OpenSessionRequest { HostAddress = "h:8193" },
FocasMessageKind.OpenSessionResponse,
resp =>
{
resp.Success.ShouldBeFalse();
resp.Error.ShouldContain("Fwlib32");
resp.ErrorCode.ShouldBe("NoFwlibBackend");
});
}
}
}

View File

@@ -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
{
/// <summary>
/// Direct FOCAS Host IPC handshake test. Drives <see cref="PipeServer"/> through a
/// hand-rolled pipe client built on <see cref="FrameReader"/> / <see cref="FrameWriter"/>
/// from FOCAS.Shared. Skipped on Administrator shells because <c>PipeAcl</c> denies
/// the BuiltinAdministrators group.
/// </summary>
[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<HelloAck>(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<HeartbeatAck>(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<UnauthorizedAccessException>(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<ErrorResponse>(resp.Value.Body);
err.Code.ShouldBe("not-implemented");
err.Message.ShouldContain("PR C");
}
cts.Cancel();
try { await serverTask; } catch { }
server.Dispose();
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -1,33 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<PlatformTarget>x86</PlatformTarget>
<Prefer32Bit>true</Prefer32Bit>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit" Version="2.9.2"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,94 @@
# FOCAS Docker simulator — focas-mock + shim DLL
Hardware-free FOCAS fixture for OtOpcUa's integration test matrix. Runs
the vendored [`focas-mock`](focas-mock/VENDORED.md) Python server under
Docker and pairs it with the [shim DLL](../Shim/VENDORED.md) that
masquerades as `Fwlib64.dll` inside the .NET test process.
## Architecture
```
┌────────────────────────────┐ cnc_allclibhndl3 / cnc_rdparam / ...
│ xunit test process │ (P/Invoke, __stdcall)
│ ├── Driver.FOCAS │
│ │ └── FwlibNative.cs ─┼─┐
│ └── FocasSimFixture │ │ resolves to...
└────────────────────────────┘ │
┌────────────────────────────┐
│ Fwlib64.dll (shim) │ JSON over TCP
│ tests/.../Shim/focas_ │──────────────────────┐
│ shim.c compiled here │ │
└────────────────────────────┘ │
┌─────────────────────────────┐
│ focas-mock (Docker) │
│ python:3.11-slim │
│ profile-aware responses │
│ mock_load_profile / │
│ mock_patch admin methods │
└─────────────────────────────┘
```
The shim bridges the binary ABI (C `__stdcall` exports with FOCAS struct
shapes) to the mock's newline-delimited JSON protocol. OtOpcUa's
`FocasSimFixture` seeds per-test state by sending `mock_load_profile` +
`mock_patch` admin calls on the same socket. Tests assert the managed
driver sees the seeded values through its normal P/Invoke path.
## Running
Pick one compose profile (they all publish 8193 — only one at a time):
```powershell
docker compose -f Docker/docker-compose.yml --profile thirtyone up -d
dotnet test tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests
docker compose -f Docker/docker-compose.yml --profile thirtyone down
```
Available profiles + their focas-mock target:
| compose --profile | focas-mock profile | Covers |
|---|---|---|
| `thirtyone` / `thirty` / `thirtytwo` | `fwlib30i64` | 30i / 31i / 32i series |
| `sixteen` | `FWLIB64` | 16i / 18i / 21i legacy family |
| `zerod` / `zerof` / `zeromf` / `zerotf` | `fwlib0iD64` | 0i-D / 0i-F / 0i-MF / 0i-TF |
| `powermotion` | `fwlib0DN64` | Power Motion i |
| `ethernet` | `fwlibe64` | Ethernet-variant DLL |
| `ncguide` | `fwlibNCG64` | NC Guide PC simulator |
## What this covers — and what it doesn't
**Covered:**
- All 10 FOCAS functions `FwlibNative.cs` P/Invokes
- Read-after-write round-trip for parameters, macros, PMC ranges
- PMC bit read-modify-write path (via the `pmc_wrpmcrng` seam)
- `IAlarmSource` raise + clear transitions (via `mock_schedule_alarms`)
- Per-series profile selection — tests can pin one and assert series-gated
behaviour
**Not covered** (still hardware-gated):
- Real FOCAS2 TCP wire protocol (this is a JSON mock; the shim hides
the real protocol entirely)
- CNC-specific firmware quirks (position scaling across power cycles,
edit-mode session locks, MTB custom screens)
- Concurrent-read behaviour on the real `Fwlib64.dll` — the shim is
single-threaded per connection
See [`docs/drivers/FOCAS-Test-Fixture.md`](../../../docs/drivers/FOCAS-Test-Fixture.md)
for the full coverage map.
## Skip behaviour
`FocasSimFixture` probes the mock at collection init time:
- Mock unreachable → tests skip with the compose-up command to run
- Mock reachable but shim DLL not loaded → tests skip with a pointer
at `Shim/build.ps1`
- Both available → tests run
This lets the same test assembly be green on a fresh CI box without
docker, green on a dev box with just the docker compose up, and
exercise the full wire path when the shim is built.

View File

@@ -0,0 +1,34 @@
# FOCAS simulator — focas-mock JSON/TCP + native FOCAS2 Ethernet server.
#
# The image is built from the vendored focas-mock snapshot at ./focas-mock/
# (see focas-mock/VENDORED.md for refresh procedure).
#
# Usage:
# docker compose -f Docker/docker-compose.yml up -d --wait
# docker compose -f Docker/docker-compose.yml down
#
# One service, one container — the mock's native FOCAS Ethernet responder
# auto-detects the binary PDU prefix (`a0 a0 a0 a0`) on the same TCP port
# that serves JSON admin commands. Tests that need per-series behaviour
# call `mock_load_profile` via the fixture's admin API at test start.
# The pre-wire-client era had one compose profile per CNC series; that
# ceremony is gone because the managed wire client doesn't depend on a
# per-series shim DLL.
services:
focas-sim:
image: otopcua-focas-sim:latest
build:
context: ./focas-mock
dockerfile: Dockerfile
container_name: otopcua-focas-sim
ports:
- "8193:8193"
restart: "no"
command: ["--profile", "FWLIB64"]
healthcheck:
test: ["CMD-SHELL", "python -c \"import socket; s=socket.create_connection(('127.0.0.1',8193),timeout=2); s.close()\" || exit 1"]
interval: 10s
timeout: 3s
retries: 3
start_period: 5s

View File

@@ -0,0 +1,13 @@
FROM python:3.11-slim
WORKDIR /app
COPY pyproject.toml README.md LICENSE ./
COPY src ./src
RUN pip install --no-cache-dir .
EXPOSE 8193
ENTRYPOINT ["focas-mock", "serve", "--host", "0.0.0.0", "--port", "8193"]
CMD ["--profile", "FWLIB64"]

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,191 @@
# focas-mock
`focas-mock` is a Python TCP mock server for testing higher-level FOCAS clients without a real FANUC control.
The project is built from two inputs:
- The 64-bit FANUC-related DLLs downloaded from [Ladder99/fanuc-cnc-api](https://github.com/Ladder99/fanuc-cnc-api)
- The vendor `fwlib.cs` interop file, used as the callable surface reference
The DLLs are not reimplemented at the binary ABI level. Instead, this project extracts their export tables, builds per-version capability profiles, exposes a JSON-over-TCP mock API, and implements the targeted native FOCAS Ethernet wire protocol used by OtOpcUa fixed-tree tests.
## What is included
- Vendored 64-bit DLLs under `vendor/fanuc-cnc-api/64bit/`
- A profile extractor that inspects PE exports with `pefile`
- A Windows P/Invoke shim source under `shim/` for clients that load `FWLIB64.dll` directly
- Built-in profiles for:
- `FWLIB64`
- `fwlib0DN64`
- `fwlib0iD64`
- `fwlib30i64`
- `fwlibe64`
- `fwlibNCG64`
- A stateful mock server with:
- version/profile switching
- forced error injection
- runtime state patching
- built-in default mock data
- auto-detected native FOCAS Ethernet PDU handling for the targeted API subset
## Quick start
Install in editable mode:
```powershell
python -m pip install -e .
```
List the generated profiles:
```powershell
focas-mock list-profiles
```
Start the mock server with the 30i profile:
```powershell
focas-mock serve --profile fwlib30i64 --host 127.0.0.1 --port 8193
```
Start with a JSON patch file that overrides the default data:
```powershell
focas-mock serve --profile fwlib30i64 --data examples/mock-30i.json
```
## Protocol
The server accepts two protocols on the same port:
- newline-delimited JSON for fixture control and shim tests
- native FOCAS Ethernet binary PDUs from the real `fwlibe64.dll`
JSON requests are one object per line:
```json
{"id":1,"method":"cnc_allclibhndl3","params":{"ipaddr":"127.0.0.1","port":8193,"timeout":10}}
```
Example response:
```json
{"id":1,"method":"cnc_allclibhndl3","rc":0,"message":"EW_OK","result":{"FlibHndl":1,"profile":"fwlib30i64"}}
```
Supported admin methods:
- `mock_get_state`
- `mock_patch`
- `mock_reset`
- `mock_load_profile`
- `mock_list_methods`
- `mock_schedule_alarms`
Example patch request:
```json
{"id":2,"method":"mock_patch","params":{"state":{"parameters":{"6711":{"type":"long","value":1234,"decimal":0}}}}}
```
Native FOCAS Ethernet clients do not use the JSON request format. Seed profile
and fixture state with JSON first, then point `cnc_allclibhndl3` at the same
host and port. Wire-level details are documented in
`docs/FOCAS_WIRE_PROTOCOL.md`.
For clients that should avoid FANUC DLL loading entirely, `dotnet/Focas.Wire`
contains a native C# read-only TCP client for the verified wire subset. It does
not expose write APIs; use the JSON control channel to preset fixture state.
Example test setup over TCP:
```json
{"id":1,"method":"mock_load_profile","params":{"profile":"FWLIB64"}}
{"id":2,"method":"mock_patch","params":{"state":{"pmc":{"R":{"100":{"type":"byte","value":1}}},"parameters":{"6711":{"type":"long","value":1234,"decimal":0}},"macros":{"500":{"value":42000,"decimal":3}},"statinfo":{"run":3,"aut":1,"emergency":0},"alarms":[{"alm_no":100,"type":1,"axis":0,"msg":"TEST ALARM"}]}}}
{"id":3,"method":"cnc_allclibhndl3","params":{"ipaddr":"127.0.0.1","port":8193,"timeout":10}}
{"id":4,"method":"pmc_rdpmcrng","params":{"FlibHndl":1,"area":"R","data_type":"byte","start":100,"end":100}}
```
## Regenerating profiles
The built-in JSON profiles are generated from the vendored binaries:
```powershell
python -m focas_mock.cli extract-profiles
```
By default this reads:
- `vendor/fanuc-cnc-api/64bit/*.dll`
- `upstream/fwlib.cs`
and writes:
- `src/focas_mock/builtin_profiles/*.json`
## Testing Direct P/Invoke Clients
If a client directly P/Invokes FANUC's 64-bit DLLs, point it at the shim DLLs built from `shim/` instead of the real vendor DLLs. The shim exports the small FOCAS surface used by the client and forwards calls to this Python server over JSON/TCP.
```powershell
focas-mock serve --profile FWLIB64 --host 127.0.0.1 --port 8193
.\shim\build.ps1
$env:FOCAS_MOCK_HOST = "127.0.0.1"
$env:FOCAS_MOCK_PORT = "8193"
```
Before running the client, seed profile/state with `mock_load_profile` and `mock_patch` as shown above.
Detailed documentation for the supported FOCAS subset is in `docs/USED_FOCAS_API.md`.
Native Ethernet wire notes are in `docs/FOCAS_WIRE_PROTOCOL.md`.
OtOpcUa-specific setup notes are in `docs/OTOPCUA_DOTNET_INTEGRATION.md`.
## Implemented mock calls
The server currently implements a practical subset of the surface observed in the exported DLLs and the C# wrapper:
- `cnc_allclibhndl`
- `cnc_allclibhndl2`
- `cnc_allclibhndl3`
- `cnc_freelibhndl`
- `cnc_sysinfo`
- `cnc_statinfo`
- `cnc_rddynamic2`
- `cnc_actf`
- `cnc_acts`
- `cnc_acts2`
- `cnc_getpath`
- `cnc_setpath`
- `cnc_rdaxisname`
- `cnc_rdspdlname`
- `cnc_rdparam`
- `cnc_wrparam`
- `cnc_rdmacro`
- `cnc_wrmacro`
- `cnc_rdalmmsg2`
- `pmc_rdpmcrng`
- `pmc_wrpmcrng`
- `cnc_rdopmsg`
- `cnc_rdopmode`
- `cnc_rdprgnum`
- `cnc_exeprgname2`
- `cnc_rdexecprog`
- `cnc_rdseqnum`
- `cnc_rdblkcount`
- `cnc_rdproginfo`
- `cnc_rdprogdir3`
- `cnc_rdtimer`
- `cnc_rdspmeter`
- `cnc_rdsvmeter`
- `cnc_rdspload`
- `cnc_rdspgear`
- `cnc_rdspmaxrpm`
- `cnc_rddiagnum`
- `cnc_rddiaginfo`
- `cnc_diagnoss`
## Limitations
- This is not a binary-compatible replacement for FANUC's DLLs.
- Native FOCAS Ethernet support is intentionally scoped to the targeted API subset documented in `docs/FOCAS_WIRE_PROTOCOL.md`.
- The per-version profiles are grounded in exported symbol tables plus the published interop wrapper, while some defaults such as axis-count hints are inferred from filename families and documented as heuristics.

View File

@@ -0,0 +1,45 @@
# focas-mock — vendored snapshot
Source: `C:\Users\dohertj2\Desktop\focas` (sibling project in this dev environment).
**Snapshot date:** 2026-04-24 (second refresh — pulled the native FOCAS2 Ethernet responder work in).
## Why vendored
OtOpcUa's FOCAS integration fixture runs against the Python mock server.
The upstream lives in its own repo; this directory is a verbatim
snapshot so CI can build the Docker image without network access to the
source repo and so OtOpcUa's test matrix pins a known-good revision.
The managed `WireFocasClient` speaks the mock's native FOCAS2 Ethernet
binary protocol directly — there's no longer a companion shim DLL.
## What's here
| Path | Purpose |
|------|---------|
| `src/focas_mock/` | Python package — TCP JSON/line-delimited mock server with 6 Fanuc CNC profiles |
| `pyproject.toml` | Package metadata; installs `focas-mock` CLI |
| `Dockerfile` | `python:3.11-slim` image built by the parent `docker-compose.yml` |
| `README.md` | Upstream README |
| `LICENSE` | MIT — permissive, vendoring allowed |
## Refreshing the snapshot
When upstream ships changes worth pulling:
```powershell
$src = "C:\Users\dohertj2\Desktop\focas"
$dest = "$PWD"
Remove-Item -Recurse -Force "$dest\src" 2>$null
Copy-Item -Recurse "$src\src" "$dest\src"
Copy-Item "$src\pyproject.toml" "$dest\"
Copy-Item "$src\README.md" "$dest\"
Copy-Item "$src\LICENSE" "$dest\"
Copy-Item "$src\Dockerfile" "$dest\"
```
Update the snapshot date at the top of this file afterward. No other
files belong here — the Docker build context is just the Python package
and its metadata.

View File

@@ -0,0 +1,24 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "focas-mock"
version = "0.1.0"
description = "Mock FOCAS server with version-aware profiles derived from FANUC 64-bit DLL exports."
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
dependencies = ["pefile>=2024.8.26"]
[project.scripts]
focas-mock = "focas_mock.cli:main"
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
focas_mock = ["builtin_profiles/*.json"]

View File

@@ -0,0 +1,185 @@
Metadata-Version: 2.4
Name: focas-mock
Version: 0.1.0
Summary: Mock FOCAS server with version-aware profiles derived from FANUC 64-bit DLL exports.
License-Expression: MIT
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pefile>=2024.8.26
Dynamic: license-file
# focas-mock
`focas-mock` is a Python TCP mock server for testing higher-level FOCAS clients without a real FANUC control.
The project is built from two inputs:
- The 64-bit FANUC-related DLLs downloaded from [Ladder99/fanuc-cnc-api](https://github.com/Ladder99/fanuc-cnc-api)
- The vendor `fwlib.cs` interop file, used as the callable surface reference
The DLLs are not reimplemented at the binary ABI level. Instead, this project extracts their export tables, builds per-version capability profiles, and exposes a JSON-over-TCP mock API whose method names match common FOCAS entry points such as `cnc_allclibhndl3`, `cnc_sysinfo`, `cnc_statinfo`, and `cnc_rddynamic2`.
## What is included
- Vendored 64-bit DLLs under `vendor/fanuc-cnc-api/64bit/`
- A profile extractor that inspects PE exports with `pefile`
- A Windows P/Invoke shim source under `shim/` for clients that load `FWLIB64.dll` directly
- Built-in profiles for:
- `FWLIB64`
- `fwlib0DN64`
- `fwlib0iD64`
- `fwlib30i64`
- `fwlibe64`
- `fwlibNCG64`
- A stateful mock server with:
- version/profile switching
- forced error injection
- runtime state patching
- built-in default mock data
## Quick start
Install in editable mode:
```powershell
python -m pip install -e .
```
List the generated profiles:
```powershell
focas-mock list-profiles
```
Start the mock server with the 30i profile:
```powershell
focas-mock serve --profile fwlib30i64 --host 127.0.0.1 --port 8193
```
Start with a JSON patch file that overrides the default data:
```powershell
focas-mock serve --profile fwlib30i64 --data examples/mock-30i.json
```
## Protocol
The server speaks newline-delimited JSON. Each request is one JSON object per line:
```json
{"id":1,"method":"cnc_allclibhndl3","params":{"ipaddr":"127.0.0.1","port":8193,"timeout":10}}
```
Example response:
```json
{"id":1,"method":"cnc_allclibhndl3","rc":0,"message":"EW_OK","result":{"FlibHndl":1,"profile":"fwlib30i64"}}
```
Supported admin methods:
- `mock_get_state`
- `mock_patch`
- `mock_reset`
- `mock_load_profile`
- `mock_list_methods`
Example patch request:
```json
{"id":2,"method":"mock_patch","params":{"state":{"parameters":{"6711":{"type":"long","value":1234,"decimal":0}}}}}
```
Example test setup over TCP:
```json
{"id":1,"method":"mock_load_profile","params":{"profile":"FWLIB64"}}
{"id":2,"method":"mock_patch","params":{"state":{"pmc":{"R":{"100":{"type":"byte","value":1}}},"parameters":{"6711":{"type":"long","value":1234,"decimal":0}},"macros":{"500":{"value":42000,"decimal":3}},"statinfo":{"run":3,"aut":1,"emergency":0},"alarms":[{"alm_no":100,"type":1,"axis":0,"msg":"TEST ALARM"}]}}}
{"id":3,"method":"cnc_allclibhndl3","params":{"ipaddr":"127.0.0.1","port":8193,"timeout":10}}
{"id":4,"method":"pmc_rdpmcrng","params":{"FlibHndl":1,"area":"R","data_type":"byte","start":100,"end":100}}
```
## Regenerating profiles
The built-in JSON profiles are generated from the vendored binaries:
```powershell
python -m focas_mock.cli extract-profiles
```
By default this reads:
- `vendor/fanuc-cnc-api/64bit/*.dll`
- `upstream/fwlib.cs`
and writes:
- `src/focas_mock/builtin_profiles/*.json`
## Testing Direct P/Invoke Clients
If a client directly P/Invokes FANUC's 64-bit DLLs, point it at the shim DLLs built from `shim/` instead of the real vendor DLLs. The shim exports the small FOCAS surface used by the client and forwards calls to this Python server over JSON/TCP.
```powershell
focas-mock serve --profile FWLIB64 --host 127.0.0.1 --port 8193
.\shim\build.ps1
$env:FOCAS_MOCK_HOST = "127.0.0.1"
$env:FOCAS_MOCK_PORT = "8193"
```
Before running the client, seed profile/state with `mock_load_profile` and `mock_patch` as shown above.
Detailed documentation for the supported FOCAS subset is in `docs/USED_FOCAS_API.md`.
OtOpcUa-specific setup notes are in `docs/OTOPCUA_DOTNET_INTEGRATION.md`.
## Implemented mock calls
The server currently implements a practical subset of the surface observed in the exported DLLs and the C# wrapper:
- `cnc_allclibhndl`
- `cnc_allclibhndl2`
- `cnc_allclibhndl3`
- `cnc_freelibhndl`
- `cnc_sysinfo`
- `cnc_statinfo`
- `cnc_rddynamic2`
- `cnc_actf`
- `cnc_acts`
- `cnc_acts2`
- `cnc_getpath`
- `cnc_setpath`
- `cnc_rdaxisname`
- `cnc_rdspdlname`
- `cnc_rdparam`
- `cnc_wrparam`
- `cnc_rdmacro`
- `cnc_wrmacro`
- `cnc_rdalmmsg2`
- `pmc_rdpmcrng`
- `pmc_wrpmcrng`
- `cnc_rdopmsg`
- `cnc_rdopmode`
- `cnc_rdprgnum`
- `cnc_exeprgname2`
- `cnc_rdexecprog`
- `cnc_rdseqnum`
- `cnc_rdblkcount`
- `cnc_rdproginfo`
- `cnc_rdprogdir3`
- `cnc_rdtimer`
- `cnc_rdspmeter`
- `cnc_rdsvmeter`
- `cnc_rdspload`
- `cnc_rdspgear`
- `cnc_rdspmaxrpm`
- `cnc_rddiagnum`
- `cnc_rddiaginfo`
- `cnc_diagnoss`
## Limitations
- This is not a binary-compatible replacement for FANUC's DLLs.
- This is not a reverse-engineered implementation of FANUC's wire protocol.
- The per-version profiles are grounded in exported symbol tables plus the published interop wrapper, while some defaults such as axis-count hints are inferred from filename families and documented as heuristics.

View File

@@ -0,0 +1,25 @@
LICENSE
README.md
pyproject.toml
src/focas_mock/__init__.py
src/focas_mock/cli.py
src/focas_mock/constants.py
src/focas_mock/data_store.py
src/focas_mock/defaults.py
src/focas_mock/export_introspection.py
src/focas_mock/profiles.py
src/focas_mock/server.py
src/focas_mock.egg-info/PKG-INFO
src/focas_mock.egg-info/SOURCES.txt
src/focas_mock.egg-info/dependency_links.txt
src/focas_mock.egg-info/entry_points.txt
src/focas_mock.egg-info/requires.txt
src/focas_mock.egg-info/top_level.txt
src/focas_mock/builtin_profiles/FWLIB64.json
src/focas_mock/builtin_profiles/fwlib0DN64.json
src/focas_mock/builtin_profiles/fwlib0iD64.json
src/focas_mock/builtin_profiles/fwlib30i64.json
src/focas_mock/builtin_profiles/fwlibNCG64.json
src/focas_mock/builtin_profiles/fwlibe64.json
tests/test_profiles.py
tests/test_server.py

View File

@@ -0,0 +1,2 @@
[console_scripts]
focas-mock = focas_mock.cli:main

View File

@@ -0,0 +1,5 @@
from .profiles import list_profiles, load_profile
from .server import FocasMockServer
__all__ = ["FocasMockServer", "list_profiles", "load_profile"]

View File

@@ -0,0 +1,80 @@
from __future__ import annotations
import argparse
import asyncio
import json
from pathlib import Path
from .data_store import MockDataStore
from .export_introspection import write_profiles
from .profiles import list_profiles, load_profile
from .server import FocasMockServer
def _default_root() -> Path:
return Path(__file__).resolve().parents[2]
def build_parser() -> argparse.ArgumentParser:
root = _default_root()
parser = argparse.ArgumentParser(prog="focas-mock")
sub = parser.add_subparsers(dest="command", required=True)
serve = sub.add_parser("serve", help="Start the mock server.")
serve.add_argument("--host", default="127.0.0.1")
serve.add_argument("--port", type=int, default=8193)
serve.add_argument("--profile", default="fwlib30i64")
serve.add_argument("--data", help="Optional JSON patch file.")
sub.add_parser("list-profiles", help="List built-in profiles.")
dump_profile = sub.add_parser("dump-profile", help="Print one built-in profile as JSON.")
dump_profile.add_argument("profile")
extract = sub.add_parser("extract-profiles", help="Regenerate JSON profiles from vendored DLLs.")
extract.add_argument("--dll-dir", default=str(root / "vendor" / "fanuc-cnc-api" / "64bit"))
extract.add_argument("--fwlib", default=str(root / "upstream" / "fwlib.cs"))
extract.add_argument("--out-dir", default=str(root / "src" / "focas_mock" / "builtin_profiles"))
return parser
async def _run_server(args: argparse.Namespace) -> None:
profile = load_profile(args.profile)
store = MockDataStore(profile)
if args.data:
store.load_patch_file(args.data)
server = FocasMockServer(args.host, args.port, profile, store)
await server.start()
print(f"focas-mock listening on {server.host}:{server.port} with profile {profile['profile_name']}")
try:
await server.serve_forever()
finally:
await server.close()
def main(argv: list[str] | None = None) -> None:
parser = build_parser()
args = parser.parse_args(argv)
if args.command == "list-profiles":
for profile in list_profiles():
print(profile)
return
if args.command == "dump-profile":
print(json.dumps(load_profile(args.profile), indent=2))
return
if args.command == "extract-profiles":
written = write_profiles(args.dll_dir, args.fwlib, args.out_dir)
for path in written:
print(path)
return
if args.command == "serve":
asyncio.run(_run_server(args))
return
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,120 @@
from __future__ import annotations
EW_PROTOCOL = -17
EW_SOCKET = -16
EW_NODLL = -15
EW_BUS = -11
EW_SYSTEM2 = -10
EW_HSSB = -9
EW_HANDLE = -8
EW_VERSION = -7
EW_UNEXP = -6
EW_SYSTEM = -5
EW_PARITY = -4
EW_MMCSYS = -3
EW_RESET = -2
EW_BUSY = -1
EW_OK = 0
EW_FUNC = 1
EW_LENGTH = 2
EW_NUMBER = 3
EW_ATTRIB = 4
EW_DATA = 5
EW_NOOPT = 6
EW_PROT = 7
EW_OVRFLOW = 8
EW_PARAM = 9
EW_BUFFER = 10
EW_PATH = 11
EW_MODE = 12
EW_REJECT = 13
EW_DTSRVR = 14
EW_ALARM = 15
EW_STOP = 16
EW_PASSWD = 17
RC_LABELS = {
EW_PROTOCOL: "EW_PROTOCOL",
EW_SOCKET: "EW_SOCKET",
EW_NODLL: "EW_NODLL",
EW_BUS: "EW_BUS",
EW_SYSTEM2: "EW_SYSTEM2",
EW_HSSB: "EW_HSSB",
EW_HANDLE: "EW_HANDLE",
EW_VERSION: "EW_VERSION",
EW_UNEXP: "EW_UNEXP",
EW_SYSTEM: "EW_SYSTEM",
EW_PARITY: "EW_PARITY",
EW_MMCSYS: "EW_MMCSYS",
EW_RESET: "EW_RESET",
EW_BUSY: "EW_BUSY",
EW_OK: "EW_OK",
EW_FUNC: "EW_FUNC",
EW_LENGTH: "EW_LENGTH",
EW_NUMBER: "EW_NUMBER",
EW_ATTRIB: "EW_ATTRIB",
EW_DATA: "EW_DATA",
EW_NOOPT: "EW_NOOPT",
EW_PROT: "EW_PROT",
EW_OVRFLOW: "EW_OVRFLOW",
EW_PARAM: "EW_PARAM",
EW_BUFFER: "EW_BUFFER",
EW_PATH: "EW_PATH",
EW_MODE: "EW_MODE",
EW_REJECT: "EW_REJECT",
EW_DTSRVR: "EW_DTSRVR",
EW_ALARM: "EW_ALARM",
EW_STOP: "EW_STOP",
EW_PASSWD: "EW_PASSWD",
}
IMPLEMENTED_FOCAS_METHODS = [
"cnc_allclibhndl",
"cnc_allclibhndl2",
"cnc_allclibhndl3",
"cnc_freelibhndl",
"cnc_sysinfo",
"cnc_statinfo",
"cnc_rddynamic2",
"cnc_actf",
"cnc_acts",
"cnc_acts2",
"cnc_getpath",
"cnc_setpath",
"cnc_rdaxisname",
"cnc_rdspdlname",
"cnc_rdparam",
"cnc_wrparam",
"cnc_rdmacro",
"cnc_wrmacro",
"cnc_rdalmmsg2",
"pmc_rdpmcrng",
"pmc_wrpmcrng",
"cnc_rdopmsg",
"cnc_rdopmode",
"cnc_rdprgnum",
"cnc_exeprgname2",
"cnc_rdexecprog",
"cnc_rdseqnum",
"cnc_rdblkcount",
"cnc_rdproginfo",
"cnc_rdprogdir3",
"cnc_rdtimer",
"cnc_rdspmeter",
"cnc_rdsvmeter",
"cnc_rdspload",
"cnc_rdspgear",
"cnc_rdspmaxrpm",
"cnc_rddiagnum",
"cnc_rddiaginfo",
"cnc_diagnoss",
]
ADMIN_METHODS = [
"mock_get_state",
"mock_patch",
"mock_reset",
"mock_load_profile",
"mock_list_methods",
"mock_schedule_alarms",
]

View File

@@ -0,0 +1,59 @@
from __future__ import annotations
import json
from copy import deepcopy
from pathlib import Path
from typing import Any, Mapping
from .defaults import make_default_state
def _deep_merge(target: dict[str, Any], patch: Mapping[str, Any]) -> dict[str, Any]:
for key, value in patch.items():
if isinstance(value, Mapping) and isinstance(target.get(key), dict):
_deep_merge(target[key], value)
else:
target[key] = deepcopy(value)
return target
class MockDataStore:
def __init__(self, profile: Mapping[str, Any]) -> None:
self.profile = dict(profile)
self._defaults = make_default_state(profile)
self._state = deepcopy(self._defaults)
@property
def state(self) -> dict[str, Any]:
return self._state
def snapshot(self) -> dict[str, Any]:
return deepcopy(self._state)
def reset(self) -> dict[str, Any]:
self._state = deepcopy(self._defaults)
return self.snapshot()
def merge_patch(self, patch: Mapping[str, Any]) -> dict[str, Any]:
_deep_merge(self._state, patch)
return self.snapshot()
def load_patch_file(self, path: str | Path) -> dict[str, Any]:
patch = json.loads(Path(path).read_text(encoding="utf-8"))
return self.merge_patch(patch)
def consume_forced_error(self, method: str) -> tuple[int, str] | None:
entry = self._state.get("forced_errors", {}).get(method)
if not entry:
return None
if isinstance(entry, int):
return entry, f"forced error for {method}"
rc = int(entry.get("rc", 0))
count = int(entry.get("count", 1))
message = str(entry.get("message", f"forced error for {method}"))
if count <= 1:
self._state["forced_errors"].pop(method, None)
else:
entry["count"] = count - 1
return rc, message

View File

@@ -0,0 +1,142 @@
from __future__ import annotations
from typing import Any, Mapping
def _family_config(profile_name: str) -> tuple[str, int, int, int]:
lowered = profile_name.lower()
if "30i" in lowered or "ncg" in lowered:
return ("30i", 32, 2, 4)
if "0id" in lowered or "0dn" in lowered:
return ("0i-D", 24, 1, 2)
if "fwlibe64" in lowered:
return ("e64", 8, 1, 2)
return ("generic", 8, 1, 2)
def make_default_state(profile: Mapping[str, Any]) -> dict[str, Any]:
profile_name = str(profile.get("profile_name", "FWLIB64"))
family, default_axes, max_path, max_spindles = _family_config(profile_name)
max_axis = int(profile.get("max_axis_hint") or default_axes)
axis_names = [
"X",
"Y",
"Z",
"A",
"B",
"C",
"U",
"V",
"W",
"P",
"Q",
"R",
]
while len(axis_names) < max_axis:
axis_names.append(f"A{len(axis_names) + 1}")
spindle_names = [f"S{i}" for i in range(1, max_spindles + 1)]
programs = [
{"number": 1, "comment": "MAIN", "length": 128},
{"number": 100, "comment": "TOOLCHANGE", "length": 84},
]
return {
"sysinfo": {
"addinfo": 0,
"max_axis": max_axis,
"cnc_type": family[:2].ljust(2),
"mt_type": "M ",
"series": family[:4].ljust(4),
"version": "A1.0",
"axes": str(max_axis).rjust(2, "0"),
},
"statinfo": {
"aut": 1,
"run": 3,
"motion": 1,
"mstb": 0,
"emergency": 0,
"alarm": 0,
"edit": 0,
},
"paths": {"current": 1, "max": max_path},
"dynamic": {
"alarm": 0,
"prgnum": 1,
"prgmnum": 1,
"seqnum": 120,
"actf": 1500,
"acts": 3200,
"axes": {
axis_names[0]: {"absolute": 123456, "machine": 123450, "relative": 6, "distance": 0},
axis_names[1]: {"absolute": -22000, "machine": -22010, "relative": 10, "distance": 0},
axis_names[2]: {"absolute": 8000, "machine": 7990, "relative": 10, "distance": 0},
},
},
"actf": 1500,
"acts": 3200,
"acts2": [3200] + [0] * (max_spindles - 1),
"axis_names": axis_names[:max_axis],
"spindle_names": spindle_names,
"parameters": {
"6711": {"type": "long", "value": 1, "decimal": 0, "description": "example parameter"},
"6712": {"type": "long", "value": 500, "decimal": 0, "description": "example parameter"},
},
"pmc": {
"R": {
"100": {"type": "byte", "value": 0},
"101": {"type": "byte", "value": 1},
"102": {"type": "byte", "value": 0},
},
},
"macros": {
"100": {"value": 12345, "decimal": 3},
"101": {"value": 98765, "decimal": 3},
},
"alarms": [
{"alm_no": 0, "type": 0, "axis": 0, "msg": ""},
],
"operator_messages": [
{"number": 200, "type": 0, "char_num": 12, "data": "READY"},
],
"program": {
"current": 1,
"main": 1,
"sequence": 120,
"block_count": 42,
"executing": "%\nO0001\nG90 G54 G00 X0 Y0\nM30\n%",
"executing_path": "//CNC_MEM/USER/PATH1/O0001",
"directory": programs,
},
"spindle": {
"meter": [
{"name": spindle_names[0], "value": 56, "unit": "%"},
{"name": spindle_names[1], "value": 0, "unit": "%"},
],
"servo_meter": [
{"name": axis_names[0], "value": 14, "unit": "%"},
{"name": axis_names[1], "value": 8, "unit": "%"},
{"name": axis_names[2], "value": 5, "unit": "%"},
],
"load": [
{"name": spindle_names[0], "load": 56, "speed": 3200},
{"name": spindle_names[1], "load": 0, "speed": 0},
],
"gear": [1] * max_spindles,
"max_rpm": [6000] * max_spindles,
},
"timers": {
"power_on": 86400,
"operating": 7200,
"cutting": 3600,
"cycle": 95,
},
"operation_mode": {"mode": 1, "name": "MEM"},
"diagnostics": {
"300": {"type": "long", "value": 14, "description": "servo load X"},
"301": {"type": "long", "value": 8, "description": "servo load Y"},
"302": {"type": "long", "value": 5, "description": "servo load Z"},
},
"forced_errors": {},
}

View File

@@ -0,0 +1,82 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from .constants import IMPLEMENTED_FOCAS_METHODS
def parse_fwlib_imports(fwlib_cs_path: str | Path) -> list[str]:
text = Path(fwlib_cs_path).read_text(encoding="utf-8", errors="ignore")
imports = re.findall(r"extern short\s+([A-Za-z0-9_]+)\s*\(", text)
return sorted(set(imports))
def extract_dll_exports(dll_path: str | Path) -> list[str]:
import pefile
pe = pefile.PE(str(dll_path))
exports = [entry.name.decode("ascii", "ignore") for entry in pe.DIRECTORY_ENTRY_EXPORT.symbols if entry.name]
return sorted(set(exports))
def _infer_metadata(dll_name: str) -> tuple[str, int, int]:
lowered = dll_name.lower()
if lowered == "fwlib64.dll":
return ("generic-ethernet", 8, 1)
if lowered == "fwlibe64.dll":
return ("embedded-ethernet", 8, 1)
if "30i" in lowered:
return ("30i/31i/32i", 32, 2)
if "ncg" in lowered:
return ("ncguide-family", 32, 2)
if "0id" in lowered:
return ("0i-d-family", 24, 1)
if "0dn" in lowered:
return ("0-dn-family", 24, 1)
return ("unknown", 8, 1)
def build_profile(dll_path: str | Path, fwlib_cs_path: str | Path) -> dict[str, Any]:
dll_path = Path(dll_path)
exports = extract_dll_exports(dll_path)
wrapper_imports = set(parse_fwlib_imports(fwlib_cs_path))
series_hint, max_axis_hint, max_path_hint = _infer_metadata(dll_path.name)
export_set = set(exports)
connection_methods = sorted(symbol for symbol in exports if symbol.startswith("cnc_allclibhndl"))
mock_methods = sorted(set(IMPLEMENTED_FOCAS_METHODS) & export_set)
wrapper_supported = sorted(wrapper_imports & export_set)
return {
"profile_name": dll_path.stem,
"dll_name": dll_path.name,
"series_hint": series_hint,
"max_axis_hint": max_axis_hint,
"max_path_hint": max_path_hint,
"export_count": len(exports),
"connection_methods": connection_methods,
"mock_methods": mock_methods,
"wrapper_supported_count": len(wrapper_supported),
"wrapper_supported_methods": wrapper_supported,
"exports": exports,
"notes": [
"Exports extracted directly from the 64-bit DLL PE export directory.",
"Wrapper-supported methods are intersected with upstream fwlib.cs extern declarations.",
"Axis and path hints are filename-family heuristics, not protocol-level proofs.",
],
}
def write_profiles(dll_dir: str | Path, fwlib_cs_path: str | Path, out_dir: str | Path) -> list[Path]:
dll_dir = Path(dll_dir)
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
written: list[Path] = []
for dll_path in sorted(dll_dir.glob("*.dll")):
profile = build_profile(dll_path, fwlib_cs_path)
out_path = out_dir / f"{dll_path.stem}.json"
out_path.write_text(json.dumps(profile, indent=2), encoding="utf-8")
written.append(out_path)
return written

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
PROFILE_DIR = Path(__file__).resolve().parent / "builtin_profiles"
PROFILE_ALIASES = {
"ZeroI_D": "fwlib0iD64",
"ZeroI_F": "fwlib0iD64",
"ZeroI_MF": "fwlib0iD64",
"ZeroI_TF": "fwlib0iD64",
"Sixteen_i": "FWLIB64",
"Thirty_i": "fwlib30i64",
"ThirtyOne_i": "fwlib30i64",
"ThirtyTwo_i": "fwlib30i64",
"PowerMotion_i": "fwlib0DN64",
}
def list_profiles() -> list[str]:
return sorted(path.stem for path in PROFILE_DIR.glob("*.json"))
def resolve_profile_name(profile_name: str) -> str:
return PROFILE_ALIASES.get(profile_name, profile_name)
def load_profile(profile_name: str) -> dict[str, Any]:
profile_name = resolve_profile_name(profile_name)
candidates = [
PROFILE_DIR / f"{profile_name}.json",
PROFILE_DIR / f"{Path(profile_name).stem}.json",
]
for candidate in candidates:
if candidate.exists():
return json.loads(candidate.read_text(encoding="utf-8"))
available = ", ".join(list_profiles())
raise FileNotFoundError(f"Unknown profile '{profile_name}'. Available profiles: {available}")

View File

@@ -0,0 +1,192 @@
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests;
/// <summary>
/// Fixture for the focas-mock simulator. Probes the Docker mock at
/// collection init; if reachable, exposes helpers that drive the mock's
/// admin surface (<c>mock_load_profile</c>, <c>mock_patch</c>,
/// <c>mock_reset</c>, <c>mock_schedule_alarms</c>) so tests can seed
/// deterministic state before exercising the managed driver.
/// </summary>
/// <remarks>
/// Single skip gate: <see cref="SkipReason"/> is non-null when the
/// <c>localhost:8193</c> TCP probe fails. Tests call
/// <c>Assert.Skip</c>.
/// </remarks>
public sealed class FocasSimFixture : IAsyncDisposable
{
private const string EndpointEnvVar = "OTOPCUA_FOCAS_SIM_ENDPOINT";
private const string ProfileEnvVar = "OTOPCUA_FOCAS_SIM_PROFILE";
private const string DefaultHost = "localhost";
private const int DefaultPort = 8193;
public string Host { get; }
public int Port { get; }
/// <summary>focas-mock profile stem the fixture should load (e.g. <c>fwlib30i64</c>,
/// <c>ThirtyOne_i</c> — both resolve via the mock's alias table). Null when unset.</summary>
public string? ExpectedProfile { get; }
/// <summary>When the <see cref="ExpectedProfile"/> maps to a concrete
/// <see cref="FocasCncSeries"/>, this is it. Null otherwise.</summary>
public FocasCncSeries? ExpectedSeries { get; }
/// <summary>Non-null when the mock probe failed — tests skip with this reason.</summary>
public string? SkipReason { get; }
public FocasSimFixture()
{
var endpoint = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? $"{DefaultHost}:{DefaultPort}";
(Host, Port) = ParseEndpoint(endpoint);
ExpectedProfile = Environment.GetEnvironmentVariable(ProfileEnvVar);
ExpectedSeries = ParseSeries(ExpectedProfile);
try
{
using var client = new TcpClient(AddressFamily.InterNetwork);
var addresses = System.Net.Dns.GetHostAddresses(Host);
var ip = addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork)
?? System.Net.IPAddress.Loopback;
var task = client.ConnectAsync(ip, Port);
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
{
SkipReason = $"focas-mock at {Host}:{Port} did not accept a TCP connection within 2s. " +
$"Start it (`docker compose -f Docker/docker-compose.yml up -d`) " +
$"or override {EndpointEnvVar}.";
}
}
catch (Exception ex)
{
SkipReason = $"focas-mock at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
$"Start it or override {EndpointEnvVar}.";
}
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
// ---- Admin API helpers ----
/// <summary>
/// Load a focas-mock profile. Accepts either the raw DLL-stem name
/// (<c>fwlib30i64</c>) or the OtOpcUa-style alias (<c>ThirtyOne_i</c>);
/// focas-mock's <c>PROFILE_ALIASES</c> resolves both.
/// </summary>
public Task<JsonElement> LoadProfileAsync(string profileName, CancellationToken ct = default) =>
SendAdminAsync("mock_load_profile", new { profile = profileName }, ct);
/// <summary>Deep-merge <paramref name="state"/> into the mock's current state.</summary>
public Task<JsonElement> PatchStateAsync(object state, CancellationToken ct = default) =>
SendAdminAsync("mock_patch", new { state }, ct);
/// <summary>Reset the mock to the selected profile's default state.</summary>
public Task<JsonElement> ResetAsync(CancellationToken ct = default) =>
SendAdminAsync("mock_reset", new { }, ct);
/// <summary>Install a time-scheduled alarm raise / clear sequence.</summary>
public Task<JsonElement> ScheduleAlarmsAsync(IEnumerable<object> sequence, CancellationToken ct = default) =>
SendAdminAsync("mock_schedule_alarms", new { sequence }, ct);
/// <summary>Low-level JSON round-trip. One TCP connection per call — matches
/// how the shim talks to the mock; simpler than pooling.</summary>
public async Task<JsonElement> SendAdminAsync(string method, object @params, CancellationToken ct = default)
{
using var client = new TcpClient();
await client.ConnectAsync(Host, Port, ct).ConfigureAwait(false);
using var stream = client.GetStream();
var request = JsonSerializer.SerializeToUtf8Bytes(new
{
id = Interlocked.Increment(ref _nextId),
method,
@params,
});
await stream.WriteAsync(request, ct).ConfigureAwait(false);
await stream.WriteAsync(new byte[] { (byte)'\n' }, ct).ConfigureAwait(false);
var buffer = new byte[65536];
var len = 0;
while (len < buffer.Length)
{
var read = await stream.ReadAsync(buffer.AsMemory(len), ct).ConfigureAwait(false);
if (read == 0) break;
len += read;
// focas-mock replies with a single newline-terminated JSON object.
if (Array.IndexOf(buffer, (byte)'\n', 0, len) >= 0) break;
}
var newline = Array.IndexOf(buffer, (byte)'\n', 0, len);
var jsonLen = newline >= 0 ? newline : len;
var text = Encoding.UTF8.GetString(buffer, 0, jsonLen);
using var doc = JsonDocument.Parse(text);
var rc = doc.RootElement.GetProperty("rc").GetInt32();
if (rc != 0)
{
var message = doc.RootElement.TryGetProperty("message", out var m) ? m.GetString() : "?";
throw new InvalidOperationException($"focas-mock {method} returned rc={rc} ({message}).");
}
// Return the "result" subtree cloned — document is disposed on exit.
return doc.RootElement.GetProperty("result").Clone();
}
private static int _nextId;
// ---- Parsing ----
private static (string Host, int Port) ParseEndpoint(string endpoint)
{
const string focasScheme = "focas://";
var body = endpoint.StartsWith(focasScheme, StringComparison.OrdinalIgnoreCase)
? endpoint[focasScheme.Length..]
: endpoint;
var slash = body.IndexOf('/');
if (slash >= 0) body = body[..slash];
var colon = body.LastIndexOf(':');
if (colon < 0) return (body, DefaultPort);
var host = body[..colon];
return int.TryParse(body[(colon + 1)..], out var p) ? (host, p) : (host, DefaultPort);
}
/// <summary>
/// Map either a focas-mock DLL-stem profile (<c>fwlib30i64</c>) or a
/// OtOpcUa-style alias (<c>ThirtyOne_i</c>) to the matching
/// <see cref="FocasCncSeries"/>. Keeps tests able to assert
/// series-gated behaviour regardless of how the profile was pinned.
/// </summary>
private static FocasCncSeries? ParseSeries(string? profile)
{
if (string.IsNullOrWhiteSpace(profile)) return null;
var trimmed = profile.Trim();
// Try the OtOpcUa alias set first — it's a superset of human-readable names.
// The docker-compose profile names (thirtyone / zerod / ...) are accepted too so
// run-focas.ps1's -Profile argument threads straight through.
var aliasMapped = trimmed switch
{
"ThirtyOne_i" or "Thirty_i" or "ThirtyTwo_i"
or "thirtyone_i" or "thirty_i" or "thirtytwo_i"
or "thirtyone" or "thirty" or "thirtytwo"
or "fwlib30i64" => "ThirtyOne_i",
"Sixteen_i" or "sixteen_i" or "sixteen" or "FWLIB64" => "Sixteen_i",
"Zero_i_D" or "Zero_i_F" or "Zero_i_MF" or "Zero_i_TF"
or "zero_i_d" or "zero_i_f" or "zero_i_mf" or "zero_i_tf"
or "zerod" or "zerof" or "zeromf" or "zerotf"
or "fwlib0iD64" => "Zero_i_D",
"PowerMotion_i" or "powermotion_i" or "powermotion"
or "fwlib0DN64" => "PowerMotion_i",
_ => null,
};
return aliasMapped is not null && Enum.TryParse<FocasCncSeries>(aliasMapped, out var parsed)
? parsed : null;
}
}
[Xunit.CollectionDefinition(Name)]
public sealed class FocasSimCollection : Xunit.ICollectionFixture<FocasSimFixture>
{
public const string Name = "FocasSim";
}

View File

@@ -0,0 +1,263 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series;
/// <summary>
/// End-to-end coverage for the driver capabilities that aren't part of
/// the fixed-tree path: user-authored <c>PARAM:</c> / <c>MACRO:</c> / PMC
/// reads, <c>DiscoverAsync</c> emission, <c>SubscribeAsync</c> +
/// <c>OnDataChange</c>, <c>IAlarmSource</c> raise/clear, and
/// <c>IHostConnectivityProbe</c> transitions. All via the managed
/// <see cref="WireFocasClient"/> against the running focas-mock.
/// </summary>
[Collection(FocasSimCollection.Name)]
public sealed class WireBackendCoverageTests
{
private readonly FocasSimFixture _fx;
public WireBackendCoverageTests(FocasSimFixture fx) => _fx = fx;
private const string DeviceHost = "focas://127.0.0.1:8193";
[Fact]
public async Task User_tag_reads_route_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
parameters = new Dictionary<string, object>
{
["6711"] = new { type = "long", value = 1234, @decimal = 0 },
},
macros = new Dictionary<string, object>
{
["500"] = new { value = 42000, @decimal = 3 },
},
pmc = new { R = new Dictionary<string, object>
{
["100"] = new { type = "byte", value = 7 },
}},
}, ct);
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
Tags =
[
new FocasTagDefinition("Param6711", DeviceHost, "PARAM:6711", FocasDataType.Int32, Writable: false),
new FocasTagDefinition("Macro500", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false),
new FocasTagDefinition("R100", DeviceHost, "R100", FocasDataType.Byte, Writable: false),
],
Probe = new FocasProbeOptions { Enabled = false },
}, driverInstanceId: "wire-usertags", clientFactory: new WireFocasClientFactory());
await using (drv)
{
await drv.InitializeAsync("{}", ct);
var snaps = await drv.ReadAsync(["Param6711", "Macro500", "R100"], ct);
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
Convert.ToInt32(snaps[0].Value).ShouldBe(1234);
Convert.ToDouble(snaps[1].Value).ShouldBe(42.0, tolerance: 0.001);
Convert.ToInt32(snaps[2].Value).ShouldBe(7);
}
}
[Fact]
public async Task Discover_emits_device_folder_and_tag_variables()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost, DeviceName: "Lathe-1")],
Tags =
[
new FocasTagDefinition("Run", DeviceHost, "R100", FocasDataType.Byte, Writable: false),
new FocasTagDefinition("Speed", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false),
],
Probe = new FocasProbeOptions { Enabled = false },
}, driverInstanceId: "wire-discover", clientFactory: new WireFocasClientFactory());
await using (drv)
{
await drv.InitializeAsync("{}", ct);
var builder = new RecordingBuilder();
await drv.DiscoverAsync(builder, ct);
builder.Folders.ShouldContain(f => f.BrowseName == "FOCAS");
builder.Folders.ShouldContain(f => f.BrowseName == DeviceHost && f.DisplayName == "Lathe-1");
builder.Variables.ShouldContain(v => v.BrowseName == "Run");
builder.Variables.ShouldContain(v => v.BrowseName == "Speed");
}
}
[Fact]
public async Task Subscribe_fires_OnDataChange_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
pmc = new { R = new Dictionary<string, object>
{
["100"] = new { type = "byte", value = 1 },
}},
}, ct);
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
Tags = [new FocasTagDefinition("Run", DeviceHost, "R100", FocasDataType.Byte, Writable: false)],
Probe = new FocasProbeOptions { Enabled = false },
}, driverInstanceId: "wire-subscribe", clientFactory: new WireFocasClientFactory());
await using (drv)
{
await drv.InitializeAsync("{}", ct);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Run"], TimeSpan.FromMilliseconds(150), ct);
await WaitFor(() => events.Count >= 1, TimeSpan.FromSeconds(3));
Convert.ToInt32(events.First().Snapshot.Value).ShouldBe(1);
// Flip the PMC byte — next poll tick should emit a fresh OnDataChange.
var before = events.Count;
await _fx.PatchStateAsync(new
{
pmc = new { R = new Dictionary<string, object>
{
["100"] = new { type = "byte", value = 99 },
}},
}, ct);
await WaitFor(() => events.Any(e => Convert.ToInt32(e.Snapshot.Value) == 99),
TimeSpan.FromSeconds(3));
await drv.UnsubscribeAsync(handle, ct);
events.Count.ShouldBeGreaterThan(before);
}
}
[Fact]
public async Task Alarm_raise_then_clear_emits_both_events_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
// Start with no active alarms.
await _fx.PatchStateAsync(new { alarms = Array.Empty<object>() }, ct);
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
AlarmProjection = new FocasAlarmProjectionOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(200),
},
}, driverInstanceId: "wire-alarms", clientFactory: new WireFocasClientFactory());
await using (drv)
{
await drv.InitializeAsync("{}", ct);
var events = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
var sub = await drv.SubscribeAlarmsAsync([], ct);
// Raise one alarm.
await _fx.PatchStateAsync(new
{
alarms = new[]
{
new { alm_no = 500, type = 2, axis = 1, msg = "TEST OVERTRAVEL" },
},
}, ct);
await WaitFor(() => events.Any(e => e.Message.Contains("OVERTRAVEL")), TimeSpan.FromSeconds(5));
// Clear.
await _fx.PatchStateAsync(new { alarms = Array.Empty<object>() }, ct);
await WaitFor(() => events.Any(e => e.Message.Contains("cleared")), TimeSpan.FromSeconds(5));
await drv.UnsubscribeAlarmsAsync(sub, ct);
events.ShouldContain(e => e.AlarmType == "Overtravel" && e.Severity == AlarmSeverity.Critical);
events.ShouldContain(e => e.Message.Contains("cleared"));
events[0].SourceNodeId.ShouldBe(DeviceHost);
}
}
[Fact]
public async Task Probe_transitions_to_Running_against_live_mock()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
Probe = new FocasProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(150),
Timeout = TimeSpan.FromSeconds(1),
},
}, driverInstanceId: "wire-probe", clientFactory: new WireFocasClientFactory());
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await using (drv)
{
await drv.InitializeAsync("{}", ct);
await WaitFor(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(5));
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
}
}
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (pred()) return;
await Task.Delay(50);
}
}
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
}
}

View File

@@ -0,0 +1,280 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series;
/// <summary>
/// Dual-run companion to <see cref="FixedTreePopulatesTests"/> — exercises the same
/// fixed-tree scenarios through the pure-managed <see cref="WireFocasClient"/>
/// instead of the shim/P-Invoke path. Proves both backends observe identical
/// state against the same focas-mock instance.
/// </summary>
/// <remarks>
/// Scheduled for removal in Wire migration phase 3 (task #104) once the shim is
/// deleted — at that point only this class survives and becomes the canonical
/// fixed-tree integration test.
/// </remarks>
[Collection(FocasSimCollection.Name)]
public sealed class WireBackendTests
{
private readonly FocasSimFixture _fx;
public WireBackendTests(FocasSimFixture fx) => _fx = fx;
private const string DeviceHost = "focas://127.0.0.1:8193";
[Fact]
public async Task Identity_axes_and_dynamic_populate_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
sysinfo = new
{
addinfo = 0, max_axis = 8, cnc_type = "M ", mt_type = "M ",
series = "30i ", version = "A1.0", axes = "3 ",
},
axis_names = new[] { "X", "Y", "Z" },
rddynamic2 = new
{
axis = 1, alarm = 0, prgnum = 1, prgmnum = 1, seqnum = 42,
actf = 1500, acts = 3200,
pos = new { absolute = 123456, machine = 123450, relative = 6, distance = 0 },
},
}, ct);
var driver = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(100),
},
}, driverInstanceId: "focas-wire-identity", clientFactory: new WireFocasClientFactory());
await using (driver)
{
await driver.InitializeAsync("{}", ct);
await WaitFor(() =>
driver.GetDeviceState(DeviceHost) is { FixedTreeCache: not null }, TimeSpan.FromSeconds(5));
var state = driver.GetDeviceState(DeviceHost);
state.ShouldNotBeNull();
state.FixedTreeCache.ShouldNotBeNull();
state.FixedTreeCache.SysInfo.Series.ShouldStartWith("30i");
state.FixedTreeCache.Axes.Count.ShouldBe(3);
state.FixedTreeCache.Axes[0].Display.ShouldBe("X");
await WaitFor(() =>
state.LastFixedSnapshots.ContainsKey($"{DeviceHost}/Axes/X/AbsolutePosition"),
TimeSpan.FromSeconds(3));
state.LastFixedSnapshots[$"{DeviceHost}/Axes/X/AbsolutePosition"].ShouldBe(123456);
}
}
[Fact]
public async Task Program_and_operation_mode_populate_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
sysinfo = new
{
addinfo = 0, max_axis = 8, cnc_type = "M ", mt_type = "M ",
series = "30i ", version = "A1.0", axes = "1 ",
},
axis_names = new[] { "X" },
rddynamic2 = new
{
axis = 1, alarm = 0, prgnum = 42, prgmnum = 42, seqnum = 100,
actf = 0, acts = 0,
pos = new { absolute = 0, machine = 0, relative = 0, distance = 0 },
},
program = new
{
current = 42, main = 42, sequence = 100, block_count = 17,
executing_path = "O0042.NC",
},
operation_mode = new { mode = 3 },
}, ct);
var driver = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(100),
ProgramPollInterval = TimeSpan.FromMilliseconds(200),
},
}, driverInstanceId: "focas-wire-program", clientFactory: new WireFocasClientFactory());
await using (driver)
{
await driver.InitializeAsync("{}", ct);
await WaitFor(() =>
driver.GetDeviceState(DeviceHost) is { LastProgramInfo: not null },
TimeSpan.FromSeconds(5));
var snapshots = await driver.ReadAsync(
[$"{DeviceHost}/Program/Name",
$"{DeviceHost}/Program/ONumber",
$"{DeviceHost}/Program/BlockCount",
$"{DeviceHost}/OperationMode/Mode"], ct);
snapshots.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
snapshots[0].Value!.ToString().ShouldStartWith("O0042");
Convert.ToInt32(snapshots[1].Value).ShouldBe(42);
Convert.ToInt32(snapshots[2].Value).ShouldBe(17);
Convert.ToInt32(snapshots[3].Value).ShouldBe(3);
}
}
[Fact]
public async Task Timers_populate_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
axis_names = new[] { "X" },
timers = new
{
power_on = 3600,
operating = 7200,
cutting = 1800,
cycle = 120,
},
}, ct);
var driver = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(100),
TimerPollInterval = TimeSpan.FromMilliseconds(200),
ProgramPollInterval = TimeSpan.Zero,
},
}, driverInstanceId: "focas-wire-timers", clientFactory: new WireFocasClientFactory());
await using (driver)
{
await driver.InitializeAsync("{}", ct);
await WaitFor(() =>
{
var state = driver.GetDeviceState(DeviceHost);
return state is not null && state.LastTimers.Count == 4;
}, TimeSpan.FromSeconds(5));
var snapshots = await driver.ReadAsync(
[$"{DeviceHost}/Timers/PowerOnSeconds",
$"{DeviceHost}/Timers/OperatingSeconds",
$"{DeviceHost}/Timers/CuttingSeconds",
$"{DeviceHost}/Timers/CycleSeconds"], ct);
snapshots.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
Convert.ToDouble(snapshots[0].Value).ShouldBe(3600.0, tolerance: 1);
Convert.ToDouble(snapshots[1].Value).ShouldBe(7200.0, tolerance: 1);
Convert.ToDouble(snapshots[2].Value).ShouldBe(1800.0, tolerance: 1);
Convert.ToDouble(snapshots[3].Value).ShouldBe(120.0, tolerance: 1);
}
}
[Fact]
public async Task Spindle_load_and_max_rpm_populate_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
axis_names = new[] { "X" },
spindle_names = new[] { "S1", "S2" },
spindle = new
{
load = new object[]
{
new { name = "S1", load = 56, speed = 3200 },
new { name = "S2", load = 12, speed = 1800 },
},
max_rpm = new[] { 6000, 4500 },
},
rddynamic2 = new
{
axis = 1, alarm = 0, prgnum = 1, prgmnum = 1, seqnum = 1,
actf = 0, acts = 0,
pos = new { absolute = 0, machine = 0, relative = 0, distance = 0 },
},
}, ct);
var driver = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(100),
ProgramPollInterval = TimeSpan.Zero,
TimerPollInterval = TimeSpan.Zero,
},
}, driverInstanceId: "focas-wire-spindle", clientFactory: new WireFocasClientFactory());
await using (driver)
{
await driver.InitializeAsync("{}", ct);
await WaitFor(() =>
{
var state = driver.GetDeviceState(DeviceHost);
return state?.FixedTreeCache is { Capabilities.SpindleLoad: true, Capabilities.SpindleMaxRpm: true }
&& state.LastSpindleLoads.Count >= 2;
}, TimeSpan.FromSeconds(5));
var snapshots = await driver.ReadAsync(
[$"{DeviceHost}/Spindle/S1/Load",
$"{DeviceHost}/Spindle/S1/MaxRpm",
$"{DeviceHost}/Spindle/S2/Load",
$"{DeviceHost}/Spindle/S2/MaxRpm"], ct);
snapshots.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
Convert.ToInt32(snapshots[0].Value).ShouldBe(56);
Convert.ToInt32(snapshots[1].Value).ShouldBe(6000);
Convert.ToInt32(snapshots[2].Value).ShouldBe(12);
Convert.ToInt32(snapshots[3].Value).ShouldBe(4500);
}
}
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (pred()) return;
await Task.Delay(50);
}
}
}

View File

@@ -6,7 +6,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests</RootNamespace>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests</RootNamespace>
</PropertyGroup>
<ItemGroup>
@@ -20,12 +20,14 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
<!-- Docker/ (the Python simulator + profiles) is part of the project so the
file tree stays discoverable, but it doesn't need to be copied to bin/;
tests run it via docker compose, not via the test-output dir. -->
<None Include="Docker\**\*" Pack="false" CopyToOutputDirectory="Never"/>
</ItemGroup>
</Project>

View File

@@ -1,280 +0,0 @@
using MessagePack;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests;
/// <summary>
/// MessagePack round-trip coverage for every FOCAS IPC contract. Ensures
/// <c>[Key]</c>-tagged fields survive serialize -> deserialize without loss so the
/// wire format stays stable across Proxy (.NET 10) and Host (.NET 4.8) processes.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ContractRoundTripTests
{
private static T RoundTrip<T>(T value)
{
var bytes = MessagePackSerializer.Serialize(value);
return MessagePackSerializer.Deserialize<T>(bytes);
}
[Fact]
public void Hello_round_trips()
{
var original = new Hello
{
ProtocolMajor = 1,
ProtocolMinor = 2,
PeerName = "OtOpcUa.Server",
SharedSecret = "abc-123",
Features = ["bulk-read", "pmc-rmw"],
};
var decoded = RoundTrip(original);
decoded.ProtocolMajor.ShouldBe(1);
decoded.ProtocolMinor.ShouldBe(2);
decoded.PeerName.ShouldBe("OtOpcUa.Server");
decoded.SharedSecret.ShouldBe("abc-123");
decoded.Features.ShouldBe(["bulk-read", "pmc-rmw"]);
}
[Fact]
public void HelloAck_rejected_carries_reason()
{
var original = new HelloAck { Accepted = false, RejectReason = "bad secret" };
var decoded = RoundTrip(original);
decoded.Accepted.ShouldBeFalse();
decoded.RejectReason.ShouldBe("bad secret");
}
[Fact]
public void Heartbeat_and_ack_preserve_ticks()
{
var hb = RoundTrip(new Heartbeat { MonotonicTicks = 987654321 });
hb.MonotonicTicks.ShouldBe(987654321);
var ack = RoundTrip(new HeartbeatAck { MonotonicTicks = 987654321, HostUtcUnixMs = 1_700_000_000_000 });
ack.MonotonicTicks.ShouldBe(987654321);
ack.HostUtcUnixMs.ShouldBe(1_700_000_000_000);
}
[Fact]
public void ErrorResponse_preserves_code_and_message()
{
var decoded = RoundTrip(new ErrorResponse { Code = "Fwlib32Crashed", Message = "EW_UNEXPECTED" });
decoded.Code.ShouldBe("Fwlib32Crashed");
decoded.Message.ShouldBe("EW_UNEXPECTED");
}
[Fact]
public void OpenSessionRequest_preserves_series_and_timeout()
{
var decoded = RoundTrip(new OpenSessionRequest
{
HostAddress = "192.168.1.50:8193",
TimeoutMs = 3500,
CncSeries = 5,
});
decoded.HostAddress.ShouldBe("192.168.1.50:8193");
decoded.TimeoutMs.ShouldBe(3500);
decoded.CncSeries.ShouldBe(5);
}
[Fact]
public void OpenSessionResponse_failure_carries_error_code()
{
var decoded = RoundTrip(new OpenSessionResponse
{
Success = false,
SessionId = 0,
Error = "unreachable",
ErrorCode = "EW_SOCKET",
});
decoded.Success.ShouldBeFalse();
decoded.Error.ShouldBe("unreachable");
decoded.ErrorCode.ShouldBe("EW_SOCKET");
}
[Fact]
public void FocasAddressDto_carries_pmc_with_bit_index()
{
var decoded = RoundTrip(new FocasAddressDto
{
Kind = 0,
PmcLetter = "R",
Number = 100,
BitIndex = 3,
});
decoded.Kind.ShouldBe(0);
decoded.PmcLetter.ShouldBe("R");
decoded.Number.ShouldBe(100);
decoded.BitIndex.ShouldBe(3);
}
[Fact]
public void FocasAddressDto_macro_omits_letter_and_bit()
{
var decoded = RoundTrip(new FocasAddressDto { Kind = 2, Number = 500 });
decoded.Kind.ShouldBe(2);
decoded.PmcLetter.ShouldBeNull();
decoded.Number.ShouldBe(500);
decoded.BitIndex.ShouldBeNull();
}
[Fact]
public void ReadRequest_and_response_round_trip()
{
var req = RoundTrip(new ReadRequest
{
SessionId = 42,
Address = new FocasAddressDto { Kind = 1, Number = 1815 },
DataType = FocasDataTypeCode.Int32,
TimeoutMs = 1500,
});
req.SessionId.ShouldBe(42);
req.Address.Number.ShouldBe(1815);
req.DataType.ShouldBe(FocasDataTypeCode.Int32);
var resp = RoundTrip(new ReadResponse
{
Success = true,
StatusCode = 0,
ValueBytes = MessagePackSerializer.Serialize((int)12345),
ValueTypeCode = FocasDataTypeCode.Int32,
SourceTimestampUtcUnixMs = 1_700_000_000_000,
});
resp.Success.ShouldBeTrue();
resp.StatusCode.ShouldBe(0u);
MessagePackSerializer.Deserialize<int>(resp.ValueBytes!).ShouldBe(12345);
resp.ValueTypeCode.ShouldBe(FocasDataTypeCode.Int32);
}
[Fact]
public void WriteRequest_and_response_round_trip()
{
var req = RoundTrip(new WriteRequest
{
SessionId = 1,
Address = new FocasAddressDto { Kind = 2, Number = 500 },
DataType = FocasDataTypeCode.Float64,
ValueBytes = MessagePackSerializer.Serialize(3.14159),
ValueTypeCode = FocasDataTypeCode.Float64,
});
MessagePackSerializer.Deserialize<double>(req.ValueBytes!).ShouldBe(3.14159);
var resp = RoundTrip(new WriteResponse { Success = true, StatusCode = 0 });
resp.Success.ShouldBeTrue();
resp.StatusCode.ShouldBe(0u);
}
[Fact]
public void PmcBitWriteRequest_preserves_bit_and_value()
{
var req = RoundTrip(new PmcBitWriteRequest
{
SessionId = 7,
Address = new FocasAddressDto { Kind = 0, PmcLetter = "Y", Number = 12 },
BitIndex = 5,
Value = true,
});
req.BitIndex.ShouldBe(5);
req.Value.ShouldBeTrue();
}
[Fact]
public void SubscribeRequest_round_trips_multiple_items()
{
var original = new SubscribeRequest
{
SessionId = 1,
SubscriptionId = 100,
IntervalMs = 250,
Items =
[
new() { MonitoredItemId = 1, Address = new() { Kind = 0, PmcLetter = "R", Number = 100 }, DataType = FocasDataTypeCode.Bit },
new() { MonitoredItemId = 2, Address = new() { Kind = 2, Number = 500 }, DataType = FocasDataTypeCode.Float64 },
],
};
var decoded = RoundTrip(original);
decoded.Items.Length.ShouldBe(2);
decoded.Items[0].MonitoredItemId.ShouldBe(1);
decoded.Items[0].Address.PmcLetter.ShouldBe("R");
decoded.Items[1].DataType.ShouldBe(FocasDataTypeCode.Float64);
}
[Fact]
public void SubscribeResponse_rejected_items_survive()
{
var decoded = RoundTrip(new SubscribeResponse
{
Success = true,
RejectedMonitoredItemIds = [2, 7],
});
decoded.RejectedMonitoredItemIds.ShouldBe([2, 7]);
}
[Fact]
public void UnsubscribeRequest_round_trips()
{
var decoded = RoundTrip(new UnsubscribeRequest { SubscriptionId = 42 });
decoded.SubscriptionId.ShouldBe(42);
}
[Fact]
public void OnDataChangeNotification_round_trips()
{
var original = new OnDataChangeNotification
{
SubscriptionId = 100,
Changes =
[
new()
{
MonitoredItemId = 1,
StatusCode = 0,
ValueBytes = MessagePackSerializer.Serialize(true),
ValueTypeCode = FocasDataTypeCode.Bit,
SourceTimestampUtcUnixMs = 1_700_000_000_000,
},
],
};
var decoded = RoundTrip(original);
decoded.Changes.Length.ShouldBe(1);
MessagePackSerializer.Deserialize<bool>(decoded.Changes[0].ValueBytes!).ShouldBeTrue();
}
[Fact]
public void ProbeRequest_and_response_round_trip()
{
var req = RoundTrip(new ProbeRequest { SessionId = 1, TimeoutMs = 500 });
req.TimeoutMs.ShouldBe(500);
var resp = RoundTrip(new ProbeResponse { Healthy = true, ObservedAtUtcUnixMs = 1_700_000_000_000 });
resp.Healthy.ShouldBeTrue();
resp.ObservedAtUtcUnixMs.ShouldBe(1_700_000_000_000);
}
[Fact]
public void RuntimeStatusChangeNotification_round_trips()
{
var decoded = RoundTrip(new RuntimeStatusChangeNotification
{
SessionId = 5,
RuntimeStatus = "Stopped",
ObservedAtUtcUnixMs = 1_700_000_000_000,
});
decoded.RuntimeStatus.ShouldBe("Stopped");
}
[Fact]
public void RecycleHostRequest_and_response_round_trip()
{
var req = RoundTrip(new RecycleHostRequest { Kind = "Hard", Reason = "wedge-detected" });
req.Kind.ShouldBe("Hard");
req.Reason.ShouldBe("wedge-detected");
var resp = RoundTrip(new RecycleStatusResponse { Accepted = true, GraceSeconds = 20 });
resp.Accepted.ShouldBeTrue();
resp.GraceSeconds.ShouldBe(20);
}
}

View File

@@ -1,107 +0,0 @@
using System.IO;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests;
[Trait("Category", "Unit")]
public sealed class FramingTests
{
[Fact]
public async Task FrameWriter_round_trips_single_frame_through_FrameReader()
{
var buffer = new MemoryStream();
using (var writer = new FrameWriter(buffer, leaveOpen: true))
{
await writer.WriteAsync(FocasMessageKind.Hello,
new Hello { PeerName = "proxy", SharedSecret = "s3cr3t" }, TestContext.Current.CancellationToken);
}
buffer.Position = 0;
using var reader = new FrameReader(buffer, leaveOpen: true);
var frame = await reader.ReadFrameAsync(TestContext.Current.CancellationToken);
frame.ShouldNotBeNull();
frame!.Value.Kind.ShouldBe(FocasMessageKind.Hello);
var hello = FrameReader.Deserialize<Hello>(frame.Value.Body);
hello.PeerName.ShouldBe("proxy");
hello.SharedSecret.ShouldBe("s3cr3t");
}
[Fact]
public async Task FrameReader_returns_null_on_clean_EOF_at_frame_boundary()
{
using var empty = new MemoryStream();
using var reader = new FrameReader(empty, leaveOpen: true);
var frame = await reader.ReadFrameAsync(TestContext.Current.CancellationToken);
frame.ShouldBeNull();
}
[Fact]
public async Task FrameReader_throws_on_oversized_length_prefix()
{
var hostile = new byte[] { 0x7F, 0xFF, 0xFF, 0xFF, 0x01 }; // length > 16 MiB
using var stream = new MemoryStream(hostile);
using var reader = new FrameReader(stream, leaveOpen: true);
await Should.ThrowAsync<InvalidDataException>(async () =>
await reader.ReadFrameAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task FrameReader_throws_on_mid_frame_eof()
{
var buffer = new MemoryStream();
using (var writer = new FrameWriter(buffer, leaveOpen: true))
{
await writer.WriteAsync(FocasMessageKind.Hello, new Hello { PeerName = "x" },
TestContext.Current.CancellationToken);
}
// Truncate so body is incomplete.
var truncated = buffer.ToArray()[..(buffer.ToArray().Length - 2)];
using var partial = new MemoryStream(truncated);
using var reader = new FrameReader(partial, leaveOpen: true);
await Should.ThrowAsync<EndOfStreamException>(async () =>
await reader.ReadFrameAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task FrameWriter_serializes_concurrent_writes()
{
var buffer = new MemoryStream();
using var writer = new FrameWriter(buffer, leaveOpen: true);
var tasks = Enumerable.Range(0, 20).Select(i => writer.WriteAsync(
FocasMessageKind.Heartbeat,
new Heartbeat { MonotonicTicks = i },
TestContext.Current.CancellationToken)).ToArray();
await Task.WhenAll(tasks);
buffer.Position = 0;
using var reader = new FrameReader(buffer, leaveOpen: true);
var seen = new List<long>();
while (await reader.ReadFrameAsync(TestContext.Current.CancellationToken) is { } frame)
{
frame.Kind.ShouldBe(FocasMessageKind.Heartbeat);
seen.Add(FrameReader.Deserialize<Heartbeat>(frame.Body).MonotonicTicks);
}
seen.Count.ShouldBe(20);
seen.OrderBy(x => x).ShouldBe(Enumerable.Range(0, 20).Select(x => (long)x));
}
[Fact]
public void MessageKind_values_are_stable()
{
// Guardrail — if someone reorders/renumbers, the wire format breaks for deployed peers.
((byte)FocasMessageKind.Hello).ShouldBe((byte)0x01);
((byte)FocasMessageKind.Heartbeat).ShouldBe((byte)0x03);
((byte)FocasMessageKind.OpenSessionRequest).ShouldBe((byte)0x10);
((byte)FocasMessageKind.ReadRequest).ShouldBe((byte)0x30);
((byte)FocasMessageKind.WriteRequest).ShouldBe((byte)0x32);
((byte)FocasMessageKind.PmcBitWriteRequest).ShouldBe((byte)0x34);
((byte)FocasMessageKind.SubscribeRequest).ShouldBe((byte)0x40);
((byte)FocasMessageKind.OnDataChangeNotification).ShouldBe((byte)0x43);
((byte)FocasMessageKind.ProbeRequest).ShouldBe((byte)0x70);
((byte)FocasMessageKind.ErrorResponse).ShouldBe((byte)0xFE);
}
}

View File

@@ -48,6 +48,52 @@ internal class FakeFocasClient : IFocasClient
public virtual Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult);
public List<FocasActiveAlarm> Alarms { get; } = [];
public virtual Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<FocasActiveAlarm>>([.. Alarms]);
// ---- Fixed-tree T1 ----
public FocasSysInfo SysInfo { get; set; } = new(0, 3, "M", "M", "30i", "A1.0", 3);
public List<FocasAxisName> AxisNames { get; } = [new("X", ""), new("Y", ""), new("Z", "")];
public List<FocasSpindleName> SpindleNames { get; } = [new("S", "1", "", "")];
public Dictionary<int, FocasDynamicSnapshot> DynamicByAxis { get; } = [];
public virtual Task<FocasSysInfo> GetSysInfoAsync(CancellationToken ct) => Task.FromResult(SysInfo);
public virtual Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<FocasAxisName>>([.. AxisNames]);
public virtual Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<FocasSpindleName>>([.. SpindleNames]);
public virtual Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken ct)
{
if (!DynamicByAxis.TryGetValue(axisIndex, out var snap))
snap = new FocasDynamicSnapshot(axisIndex, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
return Task.FromResult(snap);
}
public FocasProgramInfo ProgramInfo { get; set; } = new("O0001", 1, 0, 1);
public virtual Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) =>
Task.FromResult(ProgramInfo);
public Dictionary<FocasTimerKind, FocasTimer> Timers { get; } = [];
public virtual Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken ct)
{
if (!Timers.TryGetValue(kind, out var t))
t = new FocasTimer(kind, 0, 0);
return Task.FromResult(t);
}
public List<FocasServoLoad> ServoLoads { get; } = [];
public virtual Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<FocasServoLoad>>([.. ServoLoads]);
public List<int> SpindleLoads { get; } = [];
public List<int> SpindleMaxRpms { get; } = [];
public virtual Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<int>>([.. SpindleLoads]);
public virtual Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<int>>([.. SpindleMaxRpms]);
public virtual void Dispose()
{
DisposeCount++;

View File

@@ -1,162 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Task #220 — covers the DriverConfig JSON contract that
/// <see cref="FocasDriverFactoryExtensions.CreateInstance"/> parses when the bootstrap
/// pipeline (task #248) materialises FOCAS DriverInstance rows. Pure unit tests, no pipe
/// or CNC required.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasDriverFactoryExtensionsTests
{
[Fact]
public void Register_adds_FOCAS_entry_to_registry()
{
var registry = new DriverFactoryRegistry();
FocasDriverFactoryExtensions.Register(registry);
registry.TryGet("FOCAS").ShouldNotBeNull();
}
[Fact]
public void Register_is_case_insensitive_via_registry()
{
var registry = new DriverFactoryRegistry();
FocasDriverFactoryExtensions.Register(registry);
registry.TryGet("focas").ShouldNotBeNull();
registry.TryGet("Focas").ShouldNotBeNull();
}
[Fact]
public void CreateInstance_with_ipc_backend_and_valid_config_returns_FocasDriver()
{
const string json = """
{
"Backend": "ipc",
"PipeName": "OtOpcUaFocasHost",
"SharedSecret": "secret-for-test",
"ConnectTimeoutMs": 5000,
"Series": "Thirty_i",
"TimeoutMs": 3000,
"Devices": [
{ "HostAddress": "focas://10.0.0.5:8193", "DeviceName": "Lathe1" }
],
"Tags": [
{ "Name": "Override", "DeviceHostAddress": "focas://10.0.0.5:8193",
"Address": "R100", "DataType": "Int32", "Writable": true }
]
}
""";
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-0", json);
driver.ShouldNotBeNull();
driver.DriverInstanceId.ShouldBe("focas-0");
driver.DriverType.ShouldBe("FOCAS");
}
[Fact]
public void CreateInstance_defaults_Backend_to_ipc_when_unspecified()
{
// No "Backend" key → defaults to ipc → requires PipeName + SharedSecret.
const string json = """
{ "PipeName": "p", "SharedSecret": "s" }
""";
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-default", json);
driver.DriverType.ShouldBe("FOCAS");
}
[Fact]
public void CreateInstance_ipc_backend_missing_PipeName_throws()
{
const string json = """{ "Backend": "ipc", "SharedSecret": "s" }""";
Should.Throw<InvalidOperationException>(
() => FocasDriverFactoryExtensions.CreateInstance("focas-missing-pipe", json))
.Message.ShouldContain("PipeName");
}
[Fact]
public void CreateInstance_ipc_backend_missing_SharedSecret_throws()
{
const string json = """{ "Backend": "ipc", "PipeName": "p" }""";
Should.Throw<InvalidOperationException>(
() => FocasDriverFactoryExtensions.CreateInstance("focas-missing-secret", json))
.Message.ShouldContain("SharedSecret");
}
[Fact]
public void CreateInstance_fwlib_backend_does_not_require_pipe_fields()
{
// Direct in-process Fwlib32 path. No pipe config needed; driver connects the DLL
// natively on first use.
const string json = """{ "Backend": "fwlib" }""";
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-fwlib", json);
driver.DriverInstanceId.ShouldBe("focas-fwlib");
}
[Fact]
public void CreateInstance_unimplemented_backend_yields_driver_that_fails_fast_on_use()
{
// Useful for staging DriverInstance rows in the config DB before the Host is
// actually deployed — the server boots but reads/writes surface clear errors.
const string json = """{ "Backend": "unimplemented" }""";
var driver = FocasDriverFactoryExtensions.CreateInstance("focas-unimpl", json);
driver.DriverInstanceId.ShouldBe("focas-unimpl");
}
[Fact]
public void CreateInstance_unknown_backend_throws_with_expected_list()
{
const string json = """{ "Backend": "gibberish", "PipeName": "p", "SharedSecret": "s" }""";
Should.Throw<InvalidOperationException>(
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-backend", json))
.Message.ShouldContain("gibberish");
}
[Fact]
public void CreateInstance_rejects_unknown_Series()
{
const string json = """
{ "Backend": "fwlib", "Series": "NotARealSeries" }
""";
Should.Throw<InvalidOperationException>(
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-series", json))
.Message.ShouldContain("NotARealSeries");
}
[Fact]
public void CreateInstance_rejects_tag_with_missing_DataType()
{
const string json = """
{
"Backend": "fwlib",
"Devices": [{ "HostAddress": "focas://1.1.1.1:8193" }],
"Tags": [{ "Name": "Broken", "DeviceHostAddress": "focas://1.1.1.1:8193", "Address": "R1" }]
}
""";
Should.Throw<InvalidOperationException>(
() => FocasDriverFactoryExtensions.CreateInstance("focas-bad-tag", json))
.Message.ShouldContain("DataType");
}
[Fact]
public void CreateInstance_null_or_whitespace_args_rejected()
{
Should.Throw<ArgumentException>(
() => FocasDriverFactoryExtensions.CreateInstance("", "{}"));
Should.Throw<ArgumentException>(
() => FocasDriverFactoryExtensions.CreateInstance("id", ""));
}
[Fact]
public void Register_twice_throws()
{
var registry = new DriverFactoryRegistry();
FocasDriverFactoryExtensions.Register(registry);
Should.Throw<InvalidOperationException>(
() => FocasDriverFactoryExtensions.Register(registry));
}
}

View File

@@ -219,11 +219,11 @@ public sealed class FocasScaffoldingTests
// ---- UnimplementedFocasClientFactory ----
[Fact]
public void Default_factory_throws_on_Create_with_deployment_pointer()
public void Unimplemented_factory_throws_on_Create_with_config_pointer()
{
var factory = new UnimplementedFocasClientFactory();
var ex = Should.Throw<NotSupportedException>(() => factory.Create());
ex.Message.ShouldContain("Fwlib32.dll");
ex.Message.ShouldContain("licensed");
ex.Message.ShouldContain("wire");
ex.Message.ShouldContain("docs/drivers/FOCAS.md");
}
}

Some files were not shown because too many files have changed in this diff Show More