Compare commits

...

8 Commits

Author SHA1 Message Date
Joseph Doherty
96940aeb24 Modbus exception-injection profile — closes the end-to-end test gap for exception codes 0x01/0x03/0x04/0x05/0x06/0x0A/0x0B. pymodbus simulator naturally emits only 0x02 (Illegal Data Address on reads outside configured ranges) + 0x03 (Illegal Data Value on over-length); the driver's MapModbusExceptionToStatus table translates eight codes, but only 0x02 had integration-level coverage (via DL205's unmapped-register test). Unit tests lock the translation function in isolation but an integration test was missing for everything else. This PR lands wire-level coverage for the remaining seven codes without depending on device-specific quirks to naturally produce them.
New exception_injector.py — standalone pure-Python-stdlib Modbus/TCP server shipped alongside the pymodbus image. Speaks the wire protocol directly (MBAP header parse + FC 01/02/03/04/05/06/15/16 dispatch + store-backed happy-path reads/writes + spec-enforced length caps) and looks up each (fc, starting-address) against a rules list loaded from JSON; a matching rule makes the server respond [fc|0x80, exception_code] instead of the normal response. Zero runtime dependencies outside the stdlib — the Dockerfile just COPY's the script into /fixtures/ alongside the pymodbus profile JSONs, no new pip install needed. ~200 lines. New exception_injection.json profile carries rules for every exception code on FC03 (addresses 1000-1007, one per code), FC06 (2000-2001 for CPU-PROGRAM-mode and busy), and FC16 (3000 for server failure). New exception_injection compose profile binds :5020 like every other service + runs python /fixtures/exception_injector.py --config /fixtures/exception_injection.json.

New ExceptionInjectionTests.cs in Modbus.IntegrationTests — 11 tests. Eight FC03-read theories exercise every exception code 0x01/0x02/0x03/0x04/0x05/0x06/0x0A/0x0B asserting the driver's expected OPC UA StatusCode mapping (BadNotSupported/BadOutOfRange/BadOutOfRange/BadDeviceFailure/BadDeviceFailure/BadDeviceFailure/BadCommunicationError/BadCommunicationError). Two FC06-write theories cover the write path for 0x04 (Server Failure, CPU in PROGRAM mode) + 0x06 (Server Busy). One sanity-check read at address 5 confirms the injector isn't globally broken + non-injected reads round-trip cleanly with Value=5/StatusCode=Good. All tests follow the MODBUS_SIM_PROFILE=exception_injection skip guard so they no-op on a fresh clone without Docker running.

Docker/README.md gains an §Exception injection section explaining what pymodbus can and cannot emit, what the injector does, where the rules live, and how to append new ones. docs/drivers/Modbus-Test-Fixture.md follow-up item #2 (extend pymodbus profiles to inject exceptions) gets a shipped strikethrough with the new coverage inventory; the unit-level section adds ExceptionInjectionTests next to DL205ExceptionCodeTests so the split-of-responsibilities is explicit (DL205 test = natural out-of-range via dl205 profile, ExceptionInjectionTests = every other code via the injector).

Test baselines: Modbus unit 182/182 green (unchanged); Modbus integration with exception_injection profile live 11/11 new tests green. Existing DL205/S7/Mitsubishi integration tests unaffected since they skip on MODBUS_SIM_PROFILE mismatch.

Found + fixed during validation: a stale native pymodbus simulator from April 18 was still listening on port 5020 on IPv6 localhost (Windows was load-balancing between it + the Docker IPv4 forward, making injected exceptions intermittently come back as pymodbus's default 0x02). Killed the leftover. Documented the debugging path in the commit as a note for anyone who hits the same "my tests see exception 0x02 but the injector log has no request" symptom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:11:32 -04:00
340f580be0 Merge pull request 'FOCAS Tier-C PR E — ops glue: ProcessHostLauncher + post-mortem MMF + NSSM scripts' (#173) from focas-tier-c-pr-e-ops-glue into v2 2026-04-20 14:26:35 -04:00
Joseph Doherty
8d88ffa14d FOCAS Tier-C PR E — ops glue: ProcessHostLauncher + post-mortem MMF + NSSM install scripts + doc close-out. Final of the 5 PRs for #220. With this landing, the Tier-C architecture is fully shipped; the only remaining FOCAS work is the hardware-dependent FwlibHostedBackend (real Fwlib32.dll P/Invoke, gated on #222 lab rig).
Production IHostProcessLauncher (ProcessHostLauncher.cs): Process.Start spawns OtOpcUa.Driver.FOCAS.Host.exe with OTOPCUA_FOCAS_PIPE / OTOPCUA_ALLOWED_SID / OTOPCUA_FOCAS_SECRET / OTOPCUA_FOCAS_BACKEND in the environment (supervisor-owned, never disk), polls FocasIpcClient.ConnectAsync at 250ms cadence until the pipe is up or the Host exits or the ConnectTimeout deadline passes, then wraps the connected client in an IpcFocasClient. TerminateAsync kills the entire process tree + disposes the IPC stream. ProcessHostLauncherOptions carries HostExePath + PipeName + AllowedSid plus optional SharedSecret (auto-generated from a GUID when omitted so install scripts don't have to), Arguments, Backend (fwlib32/fake/unconfigured default-unconfigured), ConnectTimeout (15s), and Series for CNC pre-flight.

Post-mortem MMF (Host/Stability/PostMortemMmf.cs + Proxy/Supervisor/PostMortemReader.cs): ring-buffer of the last ~1000 IPC operations written by the Host into a memory-mapped file. On a Host crash the supervisor reads the MMF — which survives process death — to see what was in flight. File format: 16-byte header [magic 'OFPC' (0x4F465043) | version | capacity | writeIndex] + N × 256-byte entries [8-byte UTC unix ms | 8-byte opKind | 240-byte UTF-8 message + null terminator]. Magic distinguishes FOCAS MMFs from the Galaxy MMFs that ship the same format shape. Writer is single-producer (Host) with a lock_writeGate; reader is multi-consumer (Proxy + any diagnostic tool) using a separate MemoryMappedFile handle.

NSSM install wrappers (scripts/install/Install-FocasHost.ps1 + Uninstall-FocasHost.ps1): idempotent service registration for OtOpcUaFocasHost. Resolves SID from the ServiceAccount, generates a fresh shared secret per install if not supplied, stages OTOPCUA_FOCAS_PIPE/SID/SECRET/BACKEND in AppEnvironmentExtra so they never hit disk, rotates 10MB stdout/stderr logs under %ProgramData%\OtOpcUa, DependOnService=OtOpcUa so startup order is deterministic. Backend selector defaults to unconfigured so a fresh install doesn't accidentally load a half-configured Fwlib32.dll on first start.

Tests (7 new, 2 files): PostMortemMmfTests.cs in FOCAS.Host.Tests — round-trip write+read preserves order + content, ring-buffer wraps at capacity (writes 10 entries to a 3-slot buffer, asserts only op-7/8/9 survive in FIFO order), message truncation at the 240-byte cap is null-terminated + non-overflowing, reopening an existing file preserves entries. PostMortemReaderCompatibilityTests.cs in FOCAS.Tests — hand-writes a file in the exact host format (magic/entry layout) + asserts the Proxy reader decodes with correct ring-walk ordering when writeIndex != 0, empty-return on missing file + magic mismatch. Keeps the two codebases in format-lockstep without the net10 test project referencing the net48 Host assembly.

Docs updated: docs/v2/implementation/focas-isolation-plan.md promoted from DRAFT to PRs A-E shipped status with per-PR citations + post-ship test counts (189 + 24 + 13 = 226 FOCAS-family tests green). docs/drivers/FOCAS-Test-Fixture.md §5 updated from "architecture scoped but not implemented" to listing the shipped components with the FwlibHostedBackend gap explicitly labeled as hardware-gated. Install-FocasHost.ps1 documents the OTOPCUA_FOCAS_BACKEND selector + points at docs/v2/focas-deployment.md for Fwlib32.dll licensing.

What ISN'T in this PR: (1) the real FwlibHostedBackend implementing IFocasBackend with the P/Invoke — requires either a CNC on the bench or a licensed FANUC developer kit to validate, tracked under #220 as a single follow-up task; (2) Admin /hosts surface integration for FOCAS runtime status — Galaxy Tier-C already has the shape, FOCAS can slot in when someone wires ObservedCrashes/StickyAlertActive/BackoffAttempt to the FleetStatusHub; (3) a full integration test that actually spawns a real FOCAS Host process — ProcessHostLauncher is tested via its contract + the MMF is tested via round-trip, but no test spins up the real exe (the Galaxy Tier-C tests do this, but the FOCAS equivalent adds no new coverage over what's already in place).

Total FOCAS-family tests green after this PR: 189 driver + 24 Shared + 13 Host = 226.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:24:13 -04:00
446a5c022c Merge pull request 'FOCAS Tier-C PR D — supervisor + backoff + crash-loop breaker' (#172) from focas-tier-c-pr-d-supervisor into v2 2026-04-20 14:19:32 -04:00
Joseph Doherty
5033609944 FOCAS Tier-C PR D — supervisor + backoff + crash-loop breaker + heartbeat monitor. Fourth of 5 PRs for #220. Ships the resilience harness that sits between the driver's IFocasClient requests and the Tier-C Host process, so a crashing Fwlib32.dll takes down only the Host (not the main server), gets respawned on a backoff ladder, and opens a circuit with a sticky operator alert when the crash rate is pathological. Same shape as Galaxy Tier-C so the Admin /hosts surface has a single mental model. New Supervisor/ namespace in Driver.FOCAS (.NET 10, Proxy-side): Backoff with the 5s→15s→60s default ladder + StableRunThreshold that resets the index after a 2-min clean run (so a one-off crash after hours of steady-state doesn't restart from the top); CircuitBreaker with 3-crashes-in-5-min threshold + escalating 1h→4h→manual-reset cooldown ladder + StickyAlertActive flag that persists across cooldowns until AcknowledgeAndReset is called; HeartbeatMonitor tracking ConsecutiveMisses against the 3-misses-kill threshold + LastAckUtc for telemetry; IHostProcessLauncher abstraction over "spawn Host process + produce an IFocasClient connected to it" so the supervisor stays I/O-free and fully testable with a fake launcher that can be told to throw on specific attempts (production wiring over Process.Start + FocasIpcClient.ConnectAsync is the PR E ops-glue concern); FocasHostSupervisor orchestrating them — GetOrLaunchAsync cycles through backoff until either a client is returned or the breaker opens (surfaced as InvalidOperationException so the driver maps to BadDeviceFailure), NotifyHostDeadAsync fans out the unavailable event + terminates the current launcher + records the crash without blocking (so heartbeat-loss detection can short-circuit subscriber fan-out and let the next GetOrLaunchAsync handle the respawn), AcknowledgeAndReset is the operator-clear path, OnUnavailable event for Admin /hosts wiring + ObservedCrashes + BackoffAttempt + StickyAlertActive for telemetry. 14 new unit tests across SupervisorTests.cs: Backoff (default sequence, clamping, RecordStableRun resets), CircuitBreaker (below threshold allowed, opens at threshold, escalates cooldown after second open, ManualReset clears state), HeartbeatMonitor (3 consecutive misses declares dead, ack resets counter), FocasHostSupervisor (first-launch success, retry-with-backoff after transient failure, repeated failures open breaker + surface InvalidOperationException, NotifyHostDeadAsync terminates + fan-outs + increments crash count, AcknowledgeAndReset clears sticky, Dispose terminates). Full FOCAS driver tests now 186/186 green (172 + 14 new). No changes to IFocasClient DI contract; existing FakeFocasClient-based tests unaffected. PR E wires the real Process-based IHostProcessLauncher + NSSM install scripts + MMF post-mortem + docs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:17:23 -04:00
9034294b77 Merge pull request 'FOCAS Tier-C PR C — IPC path end-to-end' (#171) from focas-tier-c-pr-c-ipc-proxy into v2 2026-04-20 14:13:33 -04:00
Joseph Doherty
3892555631 FOCAS Tier-C PR C — IPC path end-to-end: Proxy IpcFocasClient + Host FwlibFrameHandler + IFocasBackend abstraction. Third of 5 PRs for #220. Ships the wire path from IFocasClient calls in the .NET 10 driver, over a named-pipe (or in-memory stream) to the .NET 4.8 Host's FwlibFrameHandler, dispatched to an IFocasBackend. Keeps the existing IFocasClient DI seam intact so existing unit tests are unaffected (172/172 still pass). Proxy side adds Ipc/FocasIpcClient (owns one pipe stream + call gate so concurrent callers don't interleave frames, supports both real NamedPipeClientStream and arbitrary Stream for in-memory test loopback) and Ipc/IpcFocasClient (implements IFocasClient by forwarding every call as an IPC frame — Connect sends OpenSessionRequest and caches the SessionId; Read sends ReadRequest and decodes the typed value via FocasDataTypeCode; Write sends WriteRequest for non-bit data or PmcBitWriteRequest when it's a PMC bit so the RMW critical section stays on the Host; Probe sends ProbeRequest; Dispose best-effort sends CloseSessionRequest); plus FocasIpcException surfacing Host-side ErrorResponse frames as typed exceptions. Host side adds Backend/IFocasBackend (the Host's view of one FOCAS session — Open/Close/Read/Write/PmcBitWrite/Probe) with two implementations: FakeFocasBackend (in-memory, per-address value store, honors bit-write RMW semantics against the containing byte — used by tests and as an OTOPCUA_FOCAS_BACKEND=fake operational stub) and UnconfiguredFocasBackend (structured failure pointing at docs/v2/focas-deployment.md — the safe default when OTOPCUA_FOCAS_BACKEND is unset or hardware isn't configured). Ipc/FwlibFrameHandler replaces StubFrameHandler: deserializes each request DTO, delegates to the IFocasBackend, re-serializes into the matching response kind. Catches backend exceptions and surfaces them as ErrorResponse{backend-exception} rather than tearing down the pipe. Program.cs now picks the backend from OTOPCUA_FOCAS_BACKEND env var (fake/unconfigured/fwlib32; fwlib32 still maps to Unconfigured because the real Fwlib32 P/Invoke integration is a hardware-dependent follow-up — #220 captures it). Tests: 7 new IPC round-trip tests on the Proxy side (IpcFocasClient vs. an IpcLoopback fake server: connect happy path, connect rejection, read decode, write round-trip, PMC bit write routes to first-class RMW frame, probe, ErrorResponse surfaces as typed exception) + 6 new Host-side tests on FwlibFrameHandler (OpenSession allocates id, read-without-session fails, full open/write/read round-trip preserves value, PmcBitWrite sets the specified bit, Probe reports healthy with open session, UnconfiguredBackend returns pointed-at-docs error with ErrorCode=NoFwlibBackend). Existing 165 FOCAS unit tests + 24 Shared tests + 3 Host handshake tests all unchanged. Total post-PR: 172+24+9 = 205 FOCAS-family tests green. What's NOT in this PR: the actual Fwlib32.dll P/Invoke integration inside the Host (FwlibHostedBackend) lands as a hardware-dependent follow-up since no CNC is available for validation; supervisor + respawn + crash-loop breaker comes in PR D; MMF + NSSM install scripts in PR E.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:10:52 -04:00
3609a5c676 Merge pull request 'FOCAS Tier-C PR B � Driver.FOCAS.Host net48 x86 skeleton' (#170) from focas-tier-c-pr-b-host into v2 2026-04-20 14:02:56 -04:00
33 changed files with 2894 additions and 44 deletions

View File

@@ -69,14 +69,32 @@ covers the common address shapes; per-model quirks are not stressed.
- Parameter range enforcement (CNC rejects out-of-range writes)
- MTB (machine tool builder) custom screens that expose non-standard data
### 5. Tier-C process isolation behavior
### 5. Tier-C process isolation — architecture shipped, Fwlib32 integration hardware-gated
Per driver-stability.md, FOCAS should run process-isolated because
`Fwlib32.dll` has documented crash modes. The test suite runs in-process +
only exercises the happy path + mapped error codes — a native access
violation from the DLL would take the test host down. The process-isolation
path (similar to Galaxy's out-of-process Host) has been scoped but not
implemented.
The Tier-C architecture is now in place as of PRs #169#173 (FOCAS
PR AE, task #220):
- `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
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 FOCAS tests, when to reach for a rig

View File

@@ -34,7 +34,8 @@ shaped (neither is a Modbus-side concept).
- `DL205SmokeTests` — FC16 write → FC03 read round-trip on holding register
- `DL205CoilMappingTests` — Y-output / C-relay / X-input address mapping
(octal → Modbus offset)
- `DL205ExceptionCodeTests` — Modbus exception → OPC UA StatusCode mapping
- `DL205ExceptionCodeTests` — Modbus exception 0x02 → OPC UA `BadOutOfRange` against the dl205 profile (natural out-of-range path)
- `ExceptionInjectionTests` — every other exception code in the mapping table (0x01 / 0x03 / 0x04 / 0x05 / 0x06 / 0x0A / 0x0B) against the `exception_injection` profile on both read + write paths
- `DL205FloatCdabQuirkTests` — CDAB word-swap float encoding
- `DL205StringQuirkTests` — packed-string V-memory layout
- `DL205VMemoryQuirkTests` — V-memory octal addressing
@@ -103,8 +104,13 @@ Not a Modbus concept. Driver doesn't implement `IAlarmSource` or
1. Add `MODBUS_SIM_ENDPOINT` override documentation to
`docs/v2/test-data-sources.md` so operators can point the suite at a lab rig.
2. Extend `pymodbus` profiles to inject exception responses — a JSON flag per
register saying "next read returns exception 0x04."
2. ~~Extend `pymodbus` profiles to inject exception responses~~**shipped**
via the `exception_injection` compose profile + standalone
`exception_injector.py` server. Rules in
`Docker/profiles/exception_injection.json` map `(fc, address)` to an
exception code; `ExceptionInjectionTests` exercises every code in
`MapModbusExceptionToStatus` (0x01 / 0x02 / 0x03 / 0x04 / 0x05 / 0x06 /
0x0A / 0x0B) end-to-end on both read (FC03) and write (FC06) paths.
3. Add an FX5U profile once a lab rig is available; the scaffolding is in place.
## Key fixture / config files

View File

@@ -1,12 +1,13 @@
# FOCAS Tier-C isolation — plan for task #220
> **Status**: DRAFT — not yet started. Tracks the multi-PR work to
> move `Fwlib32.dll` behind an out-of-process host, mirroring the
> Galaxy Tier-C split in [`phase-2-galaxy-out-of-process.md`](phase-2-galaxy-out-of-process.md).
> **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.
>
> **Pre-reqs shipped** (this PR): version matrix + pre-flight
> validation + unit tests. Those close the cheap half of the
> hardware-free stability gap. Tier-C closes the expensive half.
> **Pre-reqs shipped**: version matrix + pre-flight validation
> (PR #168 — the cheap half of the hardware-free stability gap).
## Why isolate
@@ -79,32 +80,41 @@ its own timer + pushes change notifications so the Proxy doesn't
round-trip per poll. Matches `Driver.Galaxy.Host` subscription
forwarding.
## PR sequence (proposed)
## PR sequence — shipped
1. **PR A — shared contracts**
Create `Driver.FOCAS.Shared` with the MessagePack DTOs. No
behaviour change. ~200 LOC + round-trip tests for each DTO.
2. **PR B — Host project skeleton**
Create `Driver.FOCAS.Host` .NET 4.8 x86 project, NSSM wrapper,
pipe server scaffold with the same ACL + caller-SID + shared
secret plumbing as Galaxy.Host. No Fwlib32 wiring yet — returns
`NotImplemented` for everything. ~400 LOC.
3. **PR C — Move Fwlib32 calls into Host**
Move `FocasNativeSession`, `FocasTagReader`, `FocasTagWriter`,
`FocasPmcBitRmw` + the STA thread into the Host. Proxy forwards
over IPC. This is the biggest PR — probably 800-1500 LOC of
move-with-translation. Existing unit tests keep passing because
`IFocasTagFactory` is the DI seam the tests inject against.
4. **PR D — Supervisor + respawn**
Proxy-side heartbeat + respawn + crash-loop circuit breaker +
BackPressure fan-out on Host death. ~500 LOC + chaos tests.
5. **PR E — Post-mortem MMF + operational glue**
MMF writer in Host, reader in Proxy. Install scripts for the
new `OtOpcUaFocasHost` Windows service. Docs. ~300 LOC.
1. **PR A (#169) — shared contracts**
`Driver.FOCAS.Shared` netstandard2.0 with MessagePack DTOs for every
IPC surface (Hello/Heartbeat/OpenSession/Read/Write/PmcBitWrite/
Subscribe/Probe/RuntimeStatus/Recycle/ErrorResponse) + FrameReader/
FrameWriter + 24 round-trip tests.
2. **PR B (#170) — Host project skeleton**
`Driver.FOCAS.Host` net48 x86 Windows Service entry point,
`PipeAcl` + `PipeServer` + `IFrameHandler` + `StubFrameHandler`.
ACL denies LocalSystem/Administrators; Hello verifies
shared-secret + protocol major. 3 handshake tests.
3. **PR C (#171) — IPC path end-to-end**
Proxy `Ipc/FocasIpcClient` + `Ipc/IpcFocasClient` (implements
IFocasClient via IPC). Host `Backend/IFocasBackend` +
`FakeFocasBackend` + `UnconfiguredFocasBackend` +
`Ipc/FwlibFrameHandler` replacing the stub. 13 new round-trip
tests via in-memory loopback.
4. **PR D (#172) — Supervisor + respawn**
`Supervisor/Backoff` (5s→15s→60s) + `CircuitBreaker` (3-in-5min →
1h→4h→manual) + `HeartbeatMonitor` + `IHostProcessLauncher` +
`FocasHostSupervisor`. 14 tests.
5. **PR E — Ops glue** ✅ (this PR)
`ProcessHostLauncher` (real Process.Start + FocasIpcClient
connect), `Host/Stability/PostMortemMmf` (magic 'OFPC') +
Proxy `Supervisor/PostMortemReader`, `scripts/install/
Install-FocasHost.ps1` + `Uninstall-FocasHost.ps1` NSSM wrappers.
7 tests (4 MMF round-trip + 3 reader format compatibility).
Total estimate: 2200-3200 LOC across 5 PRs. Consistent with Galaxy
Tier-C but narrower since FOCAS has no Historian + no alarm
history.
**Post-shipment totals: 189 FOCAS driver tests + 24 Shared tests + 13 Host tests = 226 FOCAS-family tests green.**
What remains is hardware-dependent: wiring `Fwlib32.dll` P/Invoke
into a real `FwlibHostedBackend` implementation of `IFocasBackend`
+ validating against a live CNC. The architecture is all the
plumbing that work needs.
## Testing without hardware

View File

@@ -0,0 +1,108 @@
<#
.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,27 @@
<#
.SYNOPSIS
Removes the OtOpcUaFocasHost Windows service.
.DESCRIPTION
Companion to Install-FocasHost.ps1. Stops + unregisters the service via NSSM.
Idempotent — succeeds silently if the service doesn't exist.
.EXAMPLE
.\Uninstall-FocasHost.ps1
#>
[CmdletBinding()]
param(
[string]$ServiceName = 'OtOpcUaFocasHost',
[string]$NssmPath = 'C:\Program Files\nssm\nssm.exe'
)
$ErrorActionPreference = 'Stop'
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if (-not $svc) { Write-Host "Service '$ServiceName' not present — nothing to do."; return }
if (-not (Test-Path $NssmPath)) { throw "nssm.exe not found at '$NssmPath'." }
& $NssmPath stop $ServiceName confirm | Out-Null
& $NssmPath remove $ServiceName confirm | Out-Null
Write-Host "Removed '$ServiceName'."

View File

@@ -0,0 +1,122 @@
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

@@ -0,0 +1,24 @@
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

@@ -0,0 +1,37 @@
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

@@ -0,0 +1,111 @@
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

@@ -2,6 +2,7 @@ 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;
@@ -44,9 +45,18 @@ public static class Program
Log.Information("OtOpcUaFocasHost starting — pipe={Pipe} allowedSid={Sid}",
pipeName, allowedSidValue);
var handler = new StubFrameHandler();
Log.Warning("OtOpcUaFocasHost backend=stub — Fwlib32 lift lands in PR C");
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");

View File

@@ -0,0 +1,133 @@
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

@@ -0,0 +1,120 @@
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

@@ -0,0 +1,199 @@
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

@@ -0,0 +1,30 @@
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

@@ -0,0 +1,69 @@
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

@@ -0,0 +1,159 @@
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

@@ -0,0 +1,29 @@
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

@@ -0,0 +1,32 @@
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

@@ -0,0 +1,57 @@
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

@@ -0,0 +1,113 @@
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

@@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
</ItemGroup>
<!--

View File

@@ -0,0 +1,200 @@
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

@@ -0,0 +1,86 @@
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

@@ -0,0 +1,265 @@
using MessagePack;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.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.Tests;
/// <summary>
/// End-to-end IPC round-trips over an in-memory loopback: <c>IpcFocasClient</c> talks
/// to a test fake that plays the Host's role by reading frames, dispatching on kind,
/// and responding with canned DTOs. Validates that every <see cref="IFocasClient"/>
/// method translates to the right wire frame + decodes the response correctly.
/// </summary>
[Trait("Category", "Unit")]
public sealed class IpcFocasClientTests
{
private const string Secret = "test-secret";
private static async Task ServerLoopAsync(Stream serverSide, Func<FocasMessageKind, byte[], FrameWriter, Task> dispatch, CancellationToken ct)
{
using var reader = new FrameReader(serverSide, leaveOpen: true);
using var writer = new FrameWriter(serverSide, leaveOpen: true);
// Hello handshake.
var first = await reader.ReadFrameAsync(ct);
if (first is null) return;
var hello = MessagePackSerializer.Deserialize<Hello>(first.Value.Body);
var accepted = hello.SharedSecret == Secret;
await writer.WriteAsync(FocasMessageKind.HelloAck,
new HelloAck { Accepted = accepted, RejectReason = accepted ? null : "wrong-secret" }, ct);
if (!accepted) return;
while (!ct.IsCancellationRequested)
{
var frame = await reader.ReadFrameAsync(ct);
if (frame is null) return;
await dispatch(frame.Value.Kind, frame.Value.Body, writer);
}
}
[Fact]
public async Task Connect_sends_OpenSessionRequest_and_caches_session_id()
{
await using var loop = new IpcLoopback();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
OpenSessionRequest? received = null;
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
{
if (kind == FocasMessageKind.OpenSessionRequest)
{
received = MessagePackSerializer.Deserialize<OpenSessionRequest>(body);
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
new OpenSessionResponse { Success = true, SessionId = 42 }, cts.Token);
}
}, cts.Token));
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
var client = new IpcFocasClient(ipc, FocasCncSeries.Thirty_i);
await client.ConnectAsync(new FocasHostAddress("192.168.1.50", 8193), TimeSpan.FromSeconds(2), cts.Token);
client.IsConnected.ShouldBeTrue();
received.ShouldNotBeNull();
received!.HostAddress.ShouldBe("192.168.1.50:8193");
received.CncSeries.ShouldBe((int)FocasCncSeries.Thirty_i);
cts.Cancel();
try { await server; } catch { }
}
[Fact]
public async Task Connect_throws_when_host_rejects()
{
await using var loop = new IpcLoopback();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
{
if (kind == FocasMessageKind.OpenSessionRequest)
{
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
new OpenSessionResponse { Success = false, Error = "unreachable", ErrorCode = "EW_SOCKET" }, cts.Token);
}
}, cts.Token));
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
var client = new IpcFocasClient(ipc);
await Should.ThrowAsync<InvalidOperationException>(async () =>
await client.ConnectAsync(new FocasHostAddress("10.0.0.1", 8193), TimeSpan.FromSeconds(1), cts.Token));
cts.Cancel();
try { await server; } catch { }
}
[Fact]
public async Task Read_sends_ReadRequest_and_decodes_response()
{
await using var loop = new IpcLoopback();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
ReadRequest? received = null;
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
{
switch (kind)
{
case FocasMessageKind.OpenSessionRequest:
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token);
break;
case FocasMessageKind.ReadRequest:
received = MessagePackSerializer.Deserialize<ReadRequest>(body);
await writer.WriteAsync(FocasMessageKind.ReadResponse,
new ReadResponse
{
Success = true,
StatusCode = 0,
ValueBytes = MessagePackSerializer.Serialize((int)12345),
ValueTypeCode = FocasDataTypeCode.Int32,
}, cts.Token);
break;
}
}, cts.Token));
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
var client = new IpcFocasClient(ipc);
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token);
var addr = new FocasAddress(FocasAreaKind.Parameter, null, 1815, null);
var (value, status) = await client.ReadAsync(addr, FocasDataType.Int32, cts.Token);
status.ShouldBe(0u);
value.ShouldBe(12345);
received!.Address.Number.ShouldBe(1815);
cts.Cancel();
try { await server; } catch { }
}
[Fact]
public async Task Write_sends_WriteRequest_and_returns_status()
{
await using var loop = new IpcLoopback();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
{
switch (kind)
{
case FocasMessageKind.OpenSessionRequest:
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token);
break;
case FocasMessageKind.WriteRequest:
var req = MessagePackSerializer.Deserialize<WriteRequest>(body);
MessagePackSerializer.Deserialize<double>(req.ValueBytes!).ShouldBe(3.14);
await writer.WriteAsync(FocasMessageKind.WriteResponse,
new WriteResponse { Success = true, StatusCode = 0 }, cts.Token);
break;
}
}, cts.Token));
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
var client = new IpcFocasClient(ipc);
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token);
var status = await client.WriteAsync(new FocasAddress(FocasAreaKind.Macro, null, 500, null),
FocasDataType.Float64, 3.14, cts.Token);
status.ShouldBe(0u);
cts.Cancel();
try { await server; } catch { }
}
[Fact]
public async Task Write_pmc_bit_sends_first_class_RMW_frame()
{
await using var loop = new IpcLoopback();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
PmcBitWriteRequest? received = null;
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
{
switch (kind)
{
case FocasMessageKind.OpenSessionRequest:
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token);
break;
case FocasMessageKind.PmcBitWriteRequest:
received = MessagePackSerializer.Deserialize<PmcBitWriteRequest>(body);
await writer.WriteAsync(FocasMessageKind.PmcBitWriteResponse,
new PmcBitWriteResponse { Success = true, StatusCode = 0 }, cts.Token);
break;
}
}, cts.Token));
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
var client = new IpcFocasClient(ipc);
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token);
var addr = new FocasAddress(FocasAreaKind.Pmc, "R", 100, BitIndex: 5);
var status = await client.WriteAsync(addr, FocasDataType.Bit, true, cts.Token);
status.ShouldBe(0u);
received.ShouldNotBeNull();
received!.BitIndex.ShouldBe(5);
received.Value.ShouldBeTrue();
received.Address.PmcLetter.ShouldBe("R");
cts.Cancel();
try { await server; } catch { }
}
[Fact]
public async Task Probe_round_trips_health_from_host()
{
await using var loop = new IpcLoopback();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
{
switch (kind)
{
case FocasMessageKind.OpenSessionRequest:
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token);
break;
case FocasMessageKind.ProbeRequest:
await writer.WriteAsync(FocasMessageKind.ProbeResponse,
new ProbeResponse { Healthy = true, ObservedAtUtcUnixMs = 1_700_000_000_000 }, cts.Token);
break;
}
}, cts.Token));
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
var client = new IpcFocasClient(ipc);
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token);
(await client.ProbeAsync(cts.Token)).ShouldBeTrue();
cts.Cancel();
try { await server; } catch { }
}
[Fact]
public async Task Error_response_from_host_surfaces_as_FocasIpcException()
{
await using var loop = new IpcLoopback();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
{
await writer.WriteAsync(FocasMessageKind.ErrorResponse,
new ErrorResponse { Code = "backend-exception", Message = "simulated" }, cts.Token);
}, cts.Token));
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
var client = new IpcFocasClient(ipc);
var ex = await Should.ThrowAsync<FocasIpcException>(async () =>
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token));
ex.Code.ShouldBe("backend-exception");
cts.Cancel();
try { await server; } catch { }
}
}

View File

@@ -0,0 +1,72 @@
using System.IO.Pipelines;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Bidirectional in-memory stream pair for IPC tests. Two <c>System.IO.Pipelines.Pipe</c>
/// instances — one per direction — exposed as <see cref="System.IO.Stream"/> endpoints
/// via <c>PipeReader.AsStream</c> / <c>PipeWriter.AsStream</c>. Lets the test set up a
/// <c>FocasIpcClient</c> on one end and a minimal fake server loop on the other without
/// standing up a real named pipe.
/// </summary>
internal sealed class IpcLoopback : IAsyncDisposable
{
public Stream ClientSide { get; }
public Stream ServerSide { get; }
public IpcLoopback()
{
var clientToServer = new Pipe();
var serverToClient = new Pipe();
ClientSide = new DuplexPipeStream(serverToClient.Reader.AsStream(), clientToServer.Writer.AsStream());
ServerSide = new DuplexPipeStream(clientToServer.Reader.AsStream(), serverToClient.Writer.AsStream());
}
public async ValueTask DisposeAsync()
{
await ClientSide.DisposeAsync();
await ServerSide.DisposeAsync();
}
private sealed class DuplexPipeStream(Stream read, Stream write) : Stream
{
public override bool CanRead => true;
public override bool CanWrite => true;
public override bool CanSeek => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override int Read(byte[] buffer, int offset, int count) => read.Read(buffer, offset, count);
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
read.ReadAsync(buffer, offset, count, ct);
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct = default) =>
read.ReadAsync(buffer, ct);
public override void Write(byte[] buffer, int offset, int count) => write.Write(buffer, offset, count);
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
write.WriteAsync(buffer, offset, count, ct);
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken ct = default) =>
write.WriteAsync(buffer, ct);
public override void Flush() => write.Flush();
public override Task FlushAsync(CancellationToken ct) => write.FlushAsync(ct);
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (disposing)
{
read.Dispose();
write.Dispose();
}
base.Dispose(disposing);
}
}
}

View File

@@ -0,0 +1,84 @@
using System.IO.MemoryMappedFiles;
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// The Proxy-side <see cref="PostMortemReader"/> must read the Host's MMF format
/// (magic 'OFPC', 256-byte entries). This test writes a hand-crafted file that mimics
/// the Host's layout exactly + asserts the reader decodes it correctly. Keeps the two
/// codebases in lockstep on the wire format without needing to reference the net48
/// Host assembly from the net10 test project.
/// </summary>
[Trait("Category", "Unit")]
public sealed class PostMortemReaderCompatibilityTests : IDisposable
{
private readonly string _tempPath = Path.Combine(Path.GetTempPath(), $"focas-mmf-compat-{Guid.NewGuid():N}.bin");
public void Dispose()
{
if (File.Exists(_tempPath)) File.Delete(_tempPath);
}
[Fact]
public void Reader_parses_host_format_and_returns_entries_in_oldest_first_order()
{
const int magic = 0x4F465043;
const int capacity = 5;
const int headerBytes = 16;
const int entryBytes = 256;
const int messageOffset = 16;
var fileBytes = headerBytes + capacity * entryBytes;
using (var fs = new FileStream(_tempPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Read))
{
fs.SetLength(fileBytes);
using var mmf = MemoryMappedFile.CreateFromFile(fs, null, fileBytes,
MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, leaveOpen: false);
using var acc = mmf.CreateViewAccessor(0, fileBytes, MemoryMappedFileAccess.ReadWrite);
acc.Write(0, magic);
acc.Write(4, 1);
acc.Write(8, capacity);
acc.Write(12, 2); // writeIndex — next write would land at slot 2
void WriteEntry(int slot, long ts, long op, string msg)
{
var offset = headerBytes + slot * entryBytes;
acc.Write(offset + 0, ts);
acc.Write(offset + 8, op);
var bytes = Encoding.UTF8.GetBytes(msg);
acc.WriteArray(offset + messageOffset, bytes, 0, bytes.Length);
acc.Write(offset + messageOffset + bytes.Length, (byte)0);
}
WriteEntry(0, 100, 1, "op-a");
WriteEntry(1, 200, 2, "op-b");
// Slots 2,3 unwritten (ts=0) — reader must skip.
WriteEntry(4, 50, 9, "old-wrapped");
}
var entries = new PostMortemReader(_tempPath).ReadAll();
entries.Length.ShouldBe(3);
// writeIndex=2 means the ring walk starts at slot 2, so iteration order is 2→3→4→0→1.
// Slots 2 and 3 are empty; 4 yields "old-wrapped"; then 0="op-a", 1="op-b".
entries[0].Message.ShouldBe("old-wrapped");
entries[1].Message.ShouldBe("op-a");
entries[2].Message.ShouldBe("op-b");
}
[Fact]
public void Reader_returns_empty_when_file_missing()
{
new PostMortemReader(_tempPath + "-does-not-exist").ReadAll().ShouldBeEmpty();
}
[Fact]
public void Reader_returns_empty_when_magic_mismatches()
{
File.WriteAllBytes(_tempPath, new byte[1024]);
new PostMortemReader(_tempPath).ReadAll().ShouldBeEmpty();
}
}

View File

@@ -0,0 +1,249 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class BackoffTests
{
[Fact]
public void Default_sequence_is_5s_15s_60s_then_clamped()
{
var b = new Backoff();
b.Next().ShouldBe(TimeSpan.FromSeconds(5));
b.Next().ShouldBe(TimeSpan.FromSeconds(15));
b.Next().ShouldBe(TimeSpan.FromSeconds(60));
b.Next().ShouldBe(TimeSpan.FromSeconds(60));
b.Next().ShouldBe(TimeSpan.FromSeconds(60));
}
[Fact]
public void RecordStableRun_resets_the_ladder_to_the_start()
{
var b = new Backoff();
b.Next(); b.Next();
b.AttemptIndex.ShouldBe(2);
b.RecordStableRun();
b.AttemptIndex.ShouldBe(0);
b.Next().ShouldBe(TimeSpan.FromSeconds(5));
}
}
[Trait("Category", "Unit")]
public sealed class CircuitBreakerTests
{
[Fact]
public void Allows_crashes_below_threshold()
{
var b = new CircuitBreaker();
var now = DateTime.UtcNow;
b.TryRecordCrash(now, out _).ShouldBeTrue();
b.TryRecordCrash(now.AddSeconds(1), out _).ShouldBeTrue();
b.TryRecordCrash(now.AddSeconds(2), out _).ShouldBeTrue();
b.StickyAlertActive.ShouldBeFalse();
}
[Fact]
public void Opens_when_exceeding_threshold_in_window()
{
var b = new CircuitBreaker();
var now = DateTime.UtcNow;
b.TryRecordCrash(now, out _);
b.TryRecordCrash(now.AddSeconds(1), out _);
b.TryRecordCrash(now.AddSeconds(2), out _);
b.TryRecordCrash(now.AddSeconds(3), out var cooldown).ShouldBeFalse();
cooldown.ShouldBe(TimeSpan.FromHours(1));
b.StickyAlertActive.ShouldBeTrue();
}
[Fact]
public void Escalates_cooldown_after_second_open()
{
var b = new CircuitBreaker();
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
// First burst — 4 crashes opens breaker with 1h cooldown.
for (var i = 0; i < 4; i++) b.TryRecordCrash(t0.AddSeconds(i), out _);
b.StickyAlertActive.ShouldBeTrue();
// Wait past cooldown. The first crash after cooldown-elapsed resets _openSinceUtc and
// bumps escalation level; the next 3 crashes then re-open with the escalated 4h cooldown.
b.TryRecordCrash(t0.AddHours(1).AddMinutes(1), out _);
var t1 = t0.AddHours(1).AddMinutes(1).AddSeconds(1);
b.TryRecordCrash(t1, out _);
b.TryRecordCrash(t1.AddSeconds(1), out _);
b.TryRecordCrash(t1.AddSeconds(2), out var cooldown).ShouldBeFalse();
cooldown.ShouldBe(TimeSpan.FromHours(4));
}
[Fact]
public void ManualReset_clears_everything()
{
var b = new CircuitBreaker();
var now = DateTime.UtcNow;
for (var i = 0; i < 5; i++) b.TryRecordCrash(now.AddSeconds(i), out _);
b.StickyAlertActive.ShouldBeTrue();
b.ManualReset();
b.StickyAlertActive.ShouldBeFalse();
b.TryRecordCrash(now.AddSeconds(10), out _).ShouldBeTrue();
}
}
[Trait("Category", "Unit")]
public sealed class HeartbeatMonitorTests
{
[Fact]
public void Three_consecutive_misses_declares_dead()
{
var m = new HeartbeatMonitor();
m.RecordMiss().ShouldBeFalse();
m.RecordMiss().ShouldBeFalse();
m.RecordMiss().ShouldBeTrue();
}
[Fact]
public void Ack_resets_the_miss_counter()
{
var m = new HeartbeatMonitor();
m.RecordMiss(); m.RecordMiss();
m.ConsecutiveMisses.ShouldBe(2);
m.RecordAck(DateTime.UtcNow);
m.ConsecutiveMisses.ShouldBe(0);
}
}
[Trait("Category", "Unit")]
public sealed class FocasHostSupervisorTests
{
private sealed class FakeLauncher : IHostProcessLauncher
{
public int LaunchAttempts { get; private set; }
public int Terminations { get; private set; }
public Queue<Func<IFocasClient>> Plan { get; } = new();
public bool IsProcessAlive { get; set; }
public Task<IFocasClient> LaunchAsync(CancellationToken ct)
{
LaunchAttempts++;
if (Plan.Count == 0) throw new InvalidOperationException("FakeLauncher plan exhausted");
var next = Plan.Dequeue()();
IsProcessAlive = true;
return Task.FromResult(next);
}
public Task TerminateAsync(CancellationToken ct)
{
Terminations++;
IsProcessAlive = false;
return Task.CompletedTask;
}
}
private sealed class StubFocasClient : IFocasClient
{
public bool IsConnected => true;
public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct) => Task.CompletedTask;
public Task<(object? value, uint status)> ReadAsync(FocasAddress a, FocasDataType t, CancellationToken ct) =>
Task.FromResult<(object?, uint)>((0, 0));
public Task<uint> WriteAsync(FocasAddress a, FocasDataType t, object? v, CancellationToken ct) => Task.FromResult(0u);
public Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(true);
public void Dispose() { }
}
[Fact]
public async Task GetOrLaunch_returns_client_on_first_success()
{
var launcher = new FakeLauncher();
launcher.Plan.Enqueue(() => new StubFocasClient());
var supervisor = new FocasHostSupervisor(launcher);
var client = await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
client.ShouldNotBeNull();
launcher.LaunchAttempts.ShouldBe(1);
}
[Fact]
public async Task GetOrLaunch_retries_after_transient_failure_with_backoff()
{
var launcher = new FakeLauncher();
launcher.Plan.Enqueue(() => throw new TimeoutException("pipe not ready"));
launcher.Plan.Enqueue(() => new StubFocasClient());
var backoff = new Backoff([TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(20)]);
var supervisor = new FocasHostSupervisor(launcher, backoff);
var unavailableMessages = new List<string>();
supervisor.OnUnavailable += m => unavailableMessages.Add(m);
var client = await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
client.ShouldNotBeNull();
launcher.LaunchAttempts.ShouldBe(2);
unavailableMessages.Count.ShouldBe(1);
unavailableMessages[0].ShouldContain("launch-failed");
}
[Fact]
public async Task Repeated_launch_failures_open_breaker_and_surface_InvalidOperation()
{
var launcher = new FakeLauncher();
for (var i = 0; i < 10; i++)
launcher.Plan.Enqueue(() => throw new InvalidOperationException("simulated host refused"));
var supervisor = new FocasHostSupervisor(
launcher,
backoff: new Backoff([TimeSpan.FromMilliseconds(1)]),
breaker: new CircuitBreaker { CrashesAllowedPerWindow = 2, Window = TimeSpan.FromMinutes(5) });
var ex = await Should.ThrowAsync<InvalidOperationException>(async () =>
await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken));
ex.Message.ShouldContain("circuit breaker");
supervisor.StickyAlertActive.ShouldBeTrue();
}
[Fact]
public async Task NotifyHostDeadAsync_terminates_current_and_fans_out_unavailable()
{
var launcher = new FakeLauncher();
launcher.Plan.Enqueue(() => new StubFocasClient());
var supervisor = new FocasHostSupervisor(launcher);
var messages = new List<string>();
supervisor.OnUnavailable += m => messages.Add(m);
await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
await supervisor.NotifyHostDeadAsync("heartbeat-loss", TestContext.Current.CancellationToken);
launcher.Terminations.ShouldBe(1);
messages.ShouldContain("heartbeat-loss");
supervisor.ObservedCrashes.ShouldBe(1);
}
[Fact]
public async Task AcknowledgeAndReset_clears_sticky_alert()
{
var launcher = new FakeLauncher();
for (var i = 0; i < 10; i++)
launcher.Plan.Enqueue(() => throw new InvalidOperationException("refused"));
var supervisor = new FocasHostSupervisor(
launcher,
backoff: new Backoff([TimeSpan.FromMilliseconds(1)]),
breaker: new CircuitBreaker { CrashesAllowedPerWindow = 1 });
try { await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken); } catch { }
supervisor.StickyAlertActive.ShouldBeTrue();
supervisor.AcknowledgeAndReset();
supervisor.StickyAlertActive.ShouldBeFalse();
}
[Fact]
public async Task Dispose_terminates_host_process()
{
var launcher = new FakeLauncher();
launcher.Plan.Enqueue(() => new StubFocasClient());
var supervisor = new FocasHostSupervisor(launcher);
await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
supervisor.Dispose();
launcher.Terminations.ShouldBe(1);
}
}

View File

@@ -15,6 +15,12 @@ RUN pip install --no-cache-dir "pymodbus[simulator]==3.13.0"
WORKDIR /fixtures
COPY profiles/ /fixtures/
# Standalone exception-injection server (pure Python stdlib — no pymodbus
# dependency). Speaks raw Modbus/TCP and emits arbitrary exception codes
# per rules in exception_injection.json. Drives the `exception_injection`
# compose profile. See Docker/README.md §exception injection.
COPY exception_injector.py /fixtures/
EXPOSE 5020
# Default to the standard profile; docker-compose.yml overrides per service.

View File

@@ -9,9 +9,10 @@ nothing else.
| File | Purpose |
|---|---|
| [`Dockerfile`](Dockerfile) | `python:3.12-slim-bookworm` + `pymodbus[simulator]==3.13.0` + the four profile JSONs |
| [`docker-compose.yml`](docker-compose.yml) | One service per profile (`standard` / `dl205` / `mitsubishi` / `s7_1500`); all bind `:5020` so only one runs at a time |
| [`Dockerfile`](Dockerfile) | `python:3.12-slim-bookworm` + `pymodbus[simulator]==3.13.0` + every profile JSON + `exception_injector.py` |
| [`docker-compose.yml`](docker-compose.yml) | One service per profile (`standard` / `dl205` / `mitsubishi` / `s7_1500` / `exception_injection`); all bind `:5020` so only one runs at a time |
| [`profiles/*.json`](profiles/) | Same seed-register definitions the native launcher uses — canonical source |
| [`exception_injector.py`](exception_injector.py) | Pure-stdlib Modbus/TCP server that emits arbitrary exception codes per rule — used by the `exception_injection` profile |
## Run
@@ -29,6 +30,10 @@ docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\
# Siemens S7-1500 MB_SERVER quirks
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile s7_1500 up
# Exception-injection — end-to-end coverage of every Modbus exception code
# (01/02/03/04/05/06/0A/0B), not just the 02 + 03 pymodbus emits naturally
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile exception_injection up
```
Detached + stop:
@@ -61,6 +66,36 @@ dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests
records a `SkipReason` when unreachable, so tests stay green on a fresh
clone without Docker running.
## Exception injection
pymodbus's simulator naturally emits only Modbus exception codes `0x02`
(Illegal Data Address, on reads outside its configured ranges) and
`0x03` (Illegal Data Value, on over-length requests). The driver's
`MapModbusExceptionToStatus` table translates eight codes: `0x01`,
`0x02`, `0x03`, `0x04`, `0x05`, `0x06`, `0x0A`, `0x0B`. Unit tests
lock the translation function; the integration side previously only
proved the wire-to-status path for `0x02`.
The `exception_injection` profile runs
[`exception_injector.py`](exception_injector.py) — a tiny standalone
Modbus/TCP server written against the Python stdlib (zero
dependencies outside what's in the base image). It speaks the wire
protocol directly (FC 01/02/03/04/05/06/15/16) and looks up each
incoming `(fc, address)` against the rules in
[`profiles/exception_injection.json`](profiles/exception_injection.json);
a matching rule makes the server reply with
`[fc | 0x80, exception_code]` instead of the normal response.
Current rules (see the JSON file for the canonical list):
- `FC03 @1000..1007` — one per exception code (`0x01`/`0x02`/`0x03`/`0x04`/`0x05`/`0x06`/`0x0A`/`0x0B`)
- `FC06 @2000..2001``0x04` Server Failure, `0x06` Server Busy (write-path coverage)
- `FC16 @3000``0x04` Server Failure (multi-register write path)
Adding more coverage is append-only: drop a new `{fc, address,
exception, description}` entry into the JSON, restart the service,
add an `[InlineData]` row in `ExceptionInjectionTests`.
## References
- [`docs/drivers/Modbus-Test-Fixture.md`](../../../docs/drivers/Modbus-Test-Fixture.md) — coverage map + gap inventory

View File

@@ -77,3 +77,24 @@ services:
"--modbus_device", "dev",
"--json_file", "/fixtures/s7_1500.json"
]
# Exception-injection profile. Runs the standalone pure-stdlib Modbus/TCP
# server shipped as exception_injector.py instead of the pymodbus
# simulator — pymodbus naturally emits only exception codes 02 + 03, and
# this profile extends integration coverage to the other codes the
# driver's MapModbusExceptionToStatus table handles (01, 04, 05, 06,
# 0A, 0B). Rules are driven by exception_injection.json.
exception_injection:
profiles: ["exception_injection"]
image: otopcua-pymodbus:3.13.0
build:
context: .
dockerfile: Dockerfile
container_name: otopcua-modbus-exception-injector
restart: "no"
ports:
- "5020:5020"
command: [
"python", "/fixtures/exception_injector.py",
"--config", "/fixtures/exception_injection.json"
]

View File

@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""
Minimal Modbus/TCP server that supports per-address + per-function-code
exception injection — the missing piece of the pymodbus simulator, which
only naturally emits exception code 02 (Illegal Data Address) via its
"invalid" list and 03 (Illegal Data Value) via spec-enforced length caps.
Integration tests against this fixture drive the driver's
`MapModbusExceptionToStatus` end-to-end over the wire for codes 01, 04,
05, 06, 0A, 0B — the ones the pymodbus simulator can't be configured to
return.
Wire protocol — straight Modbus/TCP (spec chapter 7.1):
MBAP header (7 bytes): [tx_id:u16 BE][proto=0:u16][length:u16][unit_id:u8]
then length-1 bytes of PDU. Length covers unit_id + PDU.
Supported function codes (enough for the driver's RMW + read paths):
01 Read Coils, 02 Read Discrete Inputs,
03 Read Holding Registers, 04 Read Input Registers,
05 Write Single Coil, 06 Write Single Register,
15 Write Multiple Coils, 16 Write Multiple Registers.
Config JSON schema (see exception_injection.json):
{
"listen": { "host": "0.0.0.0", "port": 5020 },
"seeds": { "hr": { "<addr>": <uint16>, ... },
"ir": { "<addr>": <uint16>, ... },
"co": { "<addr>": <0|1>, ... },
"di": { "<addr>": <0|1>, ... } },
"rules": [ { "fc": <int>, "address": <int>, "exception": <int>,
"description": "..." }, ... ]
}
Rules match on (fc, starting address). A matching rule wins and the server
responds with the PDU `[fc | 0x80, exception_code]`.
Zero runtime dependencies outside the Python stdlib so the Docker image
stays tiny.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import struct
import sys
from dataclasses import dataclass
log = logging.getLogger("exception_injector")
@dataclass(frozen=True)
class Rule:
fc: int
address: int
exception: int
description: str = ""
class Store:
"""In-memory data store backing non-injected reads + writes."""
def __init__(self, seeds: dict[str, dict[str, int]]) -> None:
self.hr: dict[int, int] = {int(k): int(v) for k, v in seeds.get("hr", {}).items()}
self.ir: dict[int, int] = {int(k): int(v) for k, v in seeds.get("ir", {}).items()}
self.co: dict[int, int] = {int(k): int(v) for k, v in seeds.get("co", {}).items()}
self.di: dict[int, int] = {int(k): int(v) for k, v in seeds.get("di", {}).items()}
def read_bits(self, table: dict[int, int], addr: int, count: int) -> bytes:
"""Pack `count` bits LSB-first into the Modbus bit response body."""
bits = [table.get(addr + i, 0) & 1 for i in range(count)]
out = bytearray((count + 7) // 8)
for i, b in enumerate(bits):
if b:
out[i // 8] |= 1 << (i % 8)
return bytes(out)
def read_regs(self, table: dict[int, int], addr: int, count: int) -> bytes:
"""Pack `count` uint16 BE into the Modbus register response body."""
return b"".join(struct.pack(">H", table.get(addr + i, 0) & 0xFFFF) for i in range(count))
class Server:
EXC_ILLEGAL_FUNCTION = 0x01
EXC_ILLEGAL_DATA_ADDRESS = 0x02
EXC_ILLEGAL_DATA_VALUE = 0x03
def __init__(self, store: Store, rules: list[Rule]) -> None:
self._store = store
# Index rules by (fc, address) for O(1) lookup.
self._rules: dict[tuple[int, int], Rule] = {(r.fc, r.address): r for r in rules}
def lookup_rule(self, fc: int, address: int) -> Rule | None:
return self._rules.get((fc, address))
def exception_pdu(self, fc: int, code: int) -> bytes:
return bytes([fc | 0x80, code & 0xFF])
def handle_pdu(self, pdu: bytes) -> bytes:
if not pdu:
return self.exception_pdu(0, self.EXC_ILLEGAL_FUNCTION)
fc = pdu[0]
# Reads: FC 01/02/03/04 — [fc u8][addr u16][quantity u16]
if fc in (0x01, 0x02, 0x03, 0x04):
if len(pdu) != 5:
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
addr, count = struct.unpack(">HH", pdu[1:5])
rule = self.lookup_rule(fc, addr)
if rule is not None:
log.info("inject fc=%d addr=%d -> exception 0x%02X (%s)",
fc, addr, rule.exception, rule.description)
return self.exception_pdu(fc, rule.exception)
# Spec caps — FC01/02 allow 1..2000 bits; FC03/04 allow 1..125 regs.
if fc in (0x01, 0x02):
if not 1 <= count <= 2000:
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
body = self._store.read_bits(
self._store.co if fc == 0x01 else self._store.di, addr, count)
return bytes([fc, len(body)]) + body
if not 1 <= count <= 125:
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
body = self._store.read_regs(
self._store.hr if fc == 0x03 else self._store.ir, addr, count)
return bytes([fc, len(body)]) + body
# FC05 — [fc u8][addr u16][value u16] where value is 0xFF00=ON or 0x0000=OFF.
if fc == 0x05:
if len(pdu) != 5:
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
addr, value = struct.unpack(">HH", pdu[1:5])
rule = self.lookup_rule(fc, addr)
if rule is not None:
return self.exception_pdu(fc, rule.exception)
if value == 0xFF00:
self._store.co[addr] = 1
elif value == 0x0000:
self._store.co[addr] = 0
else:
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
return pdu # FC05 echoes the request on success.
# FC06 — [fc u8][addr u16][value u16].
if fc == 0x06:
if len(pdu) != 5:
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
addr, value = struct.unpack(">HH", pdu[1:5])
rule = self.lookup_rule(fc, addr)
if rule is not None:
return self.exception_pdu(fc, rule.exception)
self._store.hr[addr] = value
return pdu # FC06 echoes on success.
# FC15 — [fc u8][addr u16][count u16][byte_count u8][values...]
if fc == 0x0F:
if len(pdu) < 6:
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
addr, count = struct.unpack(">HH", pdu[1:5])
rule = self.lookup_rule(fc, addr)
if rule is not None:
return self.exception_pdu(fc, rule.exception)
# Happy-path ignore-the-data, ack with standard response.
return struct.pack(">BHH", fc, addr, count)
# FC16 — [fc u8][addr u16][count u16][byte_count u8][u16 values...]
if fc == 0x10:
if len(pdu) < 6:
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
addr, count = struct.unpack(">HH", pdu[1:5])
rule = self.lookup_rule(fc, addr)
if rule is not None:
return self.exception_pdu(fc, rule.exception)
byte_count = pdu[5]
data = pdu[6:6 + byte_count]
for i in range(count):
self._store.hr[addr + i] = struct.unpack(">H", data[i * 2:i * 2 + 2])[0]
return struct.pack(">BHH", fc, addr, count)
return self.exception_pdu(fc, self.EXC_ILLEGAL_FUNCTION)
async def handle_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
peer = writer.get_extra_info("peername")
log.info("client connected from %s", peer)
try:
while True:
hdr = await reader.readexactly(7)
tx_id, proto, length, unit_id = struct.unpack(">HHHB", hdr)
if length < 1:
return
pdu = await reader.readexactly(length - 1)
resp = self.handle_pdu(pdu)
out = struct.pack(">HHHB", tx_id, proto, len(resp) + 1, unit_id) + resp
writer.write(out)
await writer.drain()
except asyncio.IncompleteReadError:
log.info("client %s disconnected", peer)
except Exception: # pylint: disable=broad-except
log.exception("unexpected error serving %s", peer)
finally:
try:
writer.close()
await writer.wait_closed()
except Exception: # pylint: disable=broad-except
pass
def load_config(path: str) -> tuple[Store, list[Rule], str, int]:
with open(path, "r", encoding="utf-8") as fh:
raw = json.load(fh)
listen = raw.get("listen", {})
host = listen.get("host", "0.0.0.0")
port = int(listen.get("port", 5020))
store = Store(raw.get("seeds", {}))
rules = [
Rule(
fc=int(r["fc"]),
address=int(r["address"]),
exception=int(r["exception"]),
description=str(r.get("description", "")),
)
for r in raw.get("rules", [])
]
return store, rules, host, port
async def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", required=True, help="Path to exception-injection JSON config.")
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s - %(message)s")
store, rules, host, port = load_config(args.config)
server = Server(store, rules)
listener = await asyncio.start_server(server.handle_connection, host, port)
log.info("exception-injector listening on %s:%d with %d rule(s)", host, port, len(rules))
for r in rules:
log.info(" rule: fc=%d addr=%d -> exception 0x%02X (%s)",
r.fc, r.address, r.exception, r.description)
async with listener:
await listener.serve_forever()
return 0
if __name__ == "__main__":
try:
sys.exit(asyncio.run(main(sys.argv[1:])))
except KeyboardInterrupt:
sys.exit(0)

View File

@@ -0,0 +1,34 @@
{
"_comment": "Modbus exception-injection profile — feeds exception_injector.py (not pymodbus). Rules match by (fc, address). HR[0-31] are address-as-value for the happy-path reads; HR[1000..1010] + coils[2000..2010] carry per-exception-code rules. Every code in the driver's MapModbusExceptionToStatus table that pymodbus can't naturally emit has a dedicated slot. See Docker/README.md §exception injection.",
"listen": { "host": "0.0.0.0", "port": 5020 },
"seeds": {
"hr": {
"0": 0, "1": 1, "2": 2, "3": 3,
"4": 4, "5": 5, "6": 6, "7": 7,
"8": 8, "9": 9, "10": 10, "11": 11,
"12": 12, "13": 13, "14": 14, "15": 15,
"16": 16, "17": 17, "18": 18, "19": 19,
"20": 20, "21": 21, "22": 22, "23": 23,
"24": 24, "25": 25, "26": 26, "27": 27,
"28": 28, "29": 29, "30": 30, "31": 31
}
},
"rules": [
{ "fc": 3, "address": 1000, "exception": 1, "description": "FC03 @1000 -> Illegal Function (0x01)" },
{ "fc": 3, "address": 1001, "exception": 2, "description": "FC03 @1001 -> Illegal Data Address (0x02)" },
{ "fc": 3, "address": 1002, "exception": 3, "description": "FC03 @1002 -> Illegal Data Value (0x03)" },
{ "fc": 3, "address": 1003, "exception": 4, "description": "FC03 @1003 -> Server Failure (0x04)" },
{ "fc": 3, "address": 1004, "exception": 5, "description": "FC03 @1004 -> Acknowledge (0x05)" },
{ "fc": 3, "address": 1005, "exception": 6, "description": "FC03 @1005 -> Server Busy (0x06)" },
{ "fc": 3, "address": 1006, "exception": 10, "description": "FC03 @1006 -> Gateway Path Unavailable (0x0A)" },
{ "fc": 3, "address": 1007, "exception": 11, "description": "FC03 @1007 -> Gateway Target No Response (0x0B)" },
{ "fc": 6, "address": 2000, "exception": 4, "description": "FC06 @2000 -> Server Failure (0x04, e.g. CPU in PROGRAM mode)" },
{ "fc": 6, "address": 2001, "exception": 6, "description": "FC06 @2001 -> Server Busy (0x06)" },
{ "fc": 16, "address": 3000, "exception": 4, "description": "FC16 @3000 -> Server Failure (0x04)" }
]
}

View File

@@ -0,0 +1,122 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
/// <summary>
/// End-to-end verification that the driver's <c>MapModbusExceptionToStatus</c>
/// translation is wire-correct for every exception code in the mapping table —
/// not just 0x02, which is the only code the pymodbus simulator naturally emits.
/// Drives the standalone <c>exception_injector.py</c> server (<c>exception_injection</c>
/// compose profile) at each of the rule addresses in
/// <c>Docker/profiles/exception_injection.json</c> and asserts the driver surfaces
/// the expected OPC UA StatusCode.
/// </summary>
/// <remarks>
/// Why integration coverage on top of the unit tests: the unit tests prove the
/// translation function is correct; these prove the driver wires it through on
/// the read + write paths unchanged, after the MBAP header + PDU round-trip
/// (where a subtle framing bug could swallow or misclassify the exception).
/// </remarks>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "ExceptionInjection")]
public sealed class ExceptionInjectionTests(ModbusSimulatorFixture sim)
{
private const uint StatusGood = 0u;
private const uint StatusBadOutOfRange = 0x803C0000u;
private const uint StatusBadNotSupported = 0x803D0000u;
private const uint StatusBadDeviceFailure = 0x80550000u;
private const uint StatusBadCommunicationError = 0x80050000u;
private void SkipUnlessInjectorLive()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var profile = Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE");
if (!string.Equals(profile, "exception_injection", StringComparison.OrdinalIgnoreCase))
Assert.Skip("MODBUS_SIM_PROFILE != exception_injection — skipping. " +
"Start the fixture with --profile exception_injection.");
}
private async Task<IReadOnlyList<DataValueSnapshot>> ReadSingleAsync(int address, string tagName)
{
var opts = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags =
[
new ModbusTagDefinition(tagName,
ModbusRegion.HoldingRegisters, Address: (ushort)address,
DataType: ModbusDataType.UInt16, Writable: false),
],
Probe = new ModbusProbeOptions { Enabled = false },
};
await using var driver = new ModbusDriver(opts, driverInstanceId: "modbus-exc");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
return await driver.ReadAsync([tagName], TestContext.Current.CancellationToken);
}
[Theory]
[InlineData(1000, StatusBadNotSupported, "exc 0x01 (Illegal Function) -> BadNotSupported")]
[InlineData(1001, StatusBadOutOfRange, "exc 0x02 (Illegal Data Address) -> BadOutOfRange")]
[InlineData(1002, StatusBadOutOfRange, "exc 0x03 (Illegal Data Value) -> BadOutOfRange")]
[InlineData(1003, StatusBadDeviceFailure, "exc 0x04 (Server Failure) -> BadDeviceFailure")]
[InlineData(1004, StatusBadDeviceFailure, "exc 0x05 (Acknowledge / long op) -> BadDeviceFailure")]
[InlineData(1005, StatusBadDeviceFailure, "exc 0x06 (Server Busy) -> BadDeviceFailure")]
[InlineData(1006, StatusBadCommunicationError, "exc 0x0A (Gateway Path Unavailable) -> BadCommunicationError")]
[InlineData(1007, StatusBadCommunicationError, "exc 0x0B (Gateway Target No Response) -> BadCommunicationError")]
public async Task FC03_read_at_injection_address_surfaces_expected_status(
int address, uint expectedStatus, string scenario)
{
SkipUnlessInjectorLive();
var results = await ReadSingleAsync(address, $"Injected_{address}");
results[0].StatusCode.ShouldBe(expectedStatus, scenario);
}
[Fact]
public async Task FC03_read_at_non_injected_address_returns_Good()
{
// Sanity: HR[0..31] are seeded with address-as-value in the profile. A read at
// one of those addresses must come back Good (0) — otherwise the injector is
// misbehaving and every other assertion in this class is uninformative.
SkipUnlessInjectorLive();
var results = await ReadSingleAsync(address: 5, tagName: "Healthy_5");
results[0].StatusCode.ShouldBe(StatusGood);
results[0].Value.ShouldBe((ushort)5);
}
[Theory]
[InlineData(2000, StatusBadDeviceFailure, "exc 0x04 on FC06 -> BadDeviceFailure (CPU in PROGRAM mode)")]
[InlineData(2001, StatusBadDeviceFailure, "exc 0x06 on FC06 -> BadDeviceFailure (Server Busy)")]
public async Task FC06_write_at_injection_address_surfaces_expected_status(
int address, uint expectedStatus, string scenario)
{
SkipUnlessInjectorLive();
var tag = $"InjectedWrite_{address}";
var opts = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags =
[
new ModbusTagDefinition(tag,
ModbusRegion.HoldingRegisters, Address: (ushort)address,
DataType: ModbusDataType.UInt16, Writable: true),
],
Probe = new ModbusProbeOptions { Enabled = false },
};
await using var driver = new ModbusDriver(opts, driverInstanceId: "modbus-exc-write");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var writes = await driver.WriteAsync(
[new WriteRequest(tag, (ushort)42)],
TestContext.Current.CancellationToken);
writes[0].StatusCode.ShouldBe(expectedStatus, scenario);
}
}