Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e4aae350b | |||
| 8de152df4f | |||
| 3b0e093002 | |||
| 0b7653d3b2 | |||
| dfd027ebca | |||
| 5ea57d2d70 | |||
| 858f300a61 | |||
| 366212417c | |||
| ad7d811f69 | |||
| 4cf0b4eb73 | |||
| 4bffe879c5 | |||
| 55f4044a69 | |||
| 6cf20131fe | |||
| 850b816873 | |||
| 501d8f494b | |||
| fb760bc465 | |||
| 75c07149d4 | |||
| d11d160395 | |||
| e5d1c9c9b9 | |||
| bd6568bcbd | |||
| a52086efc5 | |||
| ec1a5905bf | |||
| 69e1d320ac | |||
| 8be82e02c2 | |||
| d11dd0520b | |||
| fb6dd3478d | |||
| 1be0fb5a29 | |||
| ded292ecd7 | |||
| 6a6b0f56f2 | |||
| e8b8541554 | |||
| a23de2a7e4 | |||
| de77d42eab | |||
| 96918b148c | |||
| 69e0d02c72 | |||
| 4b0664bd55 | |||
| 404b54add0 |
@@ -13,13 +13,12 @@
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||
@@ -50,6 +49,7 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests.csproj"/>
|
||||
@@ -65,8 +65,7 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj"/>
|
||||
|
||||
+17
-21
@@ -5,27 +5,22 @@ protocol. Uses the **same** `FocasDriver` the OtOpcUa server does — PMC R/G/F
|
||||
file registers, axis bits, parameters, and macro variables — all through
|
||||
`FocasAddressParser` syntax.
|
||||
|
||||
Sixth of the driver test-client CLIs, added alongside the Tier-C isolation
|
||||
work tracked in task #220.
|
||||
Sixth of the driver test-client CLIs.
|
||||
|
||||
## Architecture note
|
||||
|
||||
FOCAS is a Tier-C driver: `Fwlib32.dll` is a proprietary 32-bit Fanuc library
|
||||
with a documented habit of crashing its hosting process on network errors.
|
||||
The target runtime deployment splits the driver into an in-process
|
||||
`FocasProxyDriver` (.NET 10 x64) and an out-of-process `Driver.FOCAS.Host`
|
||||
(.NET 4.8 x86 Windows service) that owns the DLL — see
|
||||
[v2/implementation/focas-isolation-plan.md](v2/implementation/focas-isolation-plan.md)
|
||||
and
|
||||
[v2/implementation/phase-6-1-resilience-and-observability.md](v2/implementation/phase-6-1-resilience-and-observability.md)
|
||||
for topology + supervisor / respawn / back-pressure design.
|
||||
FOCAS is an in-process driver. The pure-managed `WireFocasClient`
|
||||
speaks the FOCAS2 binary protocol directly over TCP:8193, removing the
|
||||
Tier-C process-isolation split that the historical P/Invoke + out-of-
|
||||
process Host arrangement required. The CLI loads `FocasDriver` with
|
||||
`WireFocasClientFactory` and talks to the CNC without any native
|
||||
components.
|
||||
|
||||
The CLI skips the proxy and loads `FocasDriver` directly (via
|
||||
`FwlibFocasClientFactory`, which P/Invokes `Fwlib32.dll` in the CLI's own
|
||||
process). There is **no public simulator** for FOCAS; a meaningful probe
|
||||
requires a real CNC + a licensed `Fwlib32.dll` on `PATH` (or next to the
|
||||
executable). On a dev box without the DLL, every wire call surfaces as
|
||||
`BadCommunicationError` — still useful as a "CLI wire-up is correct" signal.
|
||||
A dev-friendly mock is available — start
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/docker-compose.yml`
|
||||
and point `--cnc-host` at `localhost` for end-to-end CLI exercises
|
||||
without a real CNC. See
|
||||
[drivers/FOCAS-Test-Fixture.md](drivers/FOCAS-Test-Fixture.md).
|
||||
|
||||
## Build + run
|
||||
|
||||
@@ -152,7 +147,8 @@ fails.
|
||||
**"Why did this macro flip?"** → `subscribe` to the macro, let the
|
||||
operator reproduce the cycle, watch the HH:mm:ss.fff timeline.
|
||||
|
||||
**"Is the Fwlib32 DLL wired up?"** → `probe` against any host. A
|
||||
`DllNotFoundException` surfacing as `BadCommunicationError` with a
|
||||
matching `Last error` line means the driver is loading but the DLL is
|
||||
missing; anything else means a transport-layer problem.
|
||||
**"Can I reach the CNC on TCP:8193?"** → `probe` against any host. A
|
||||
`BadCommunicationError` means the wire client couldn't open a socket
|
||||
(firewall / wrong host / FOCAS Ethernet option unlicensed on the CNC).
|
||||
`BadDeviceFailure` after a successful connect means the CNC is rejecting
|
||||
the session setup — check the CNC's FOCAS option and password settings.
|
||||
|
||||
@@ -119,3 +119,37 @@ address.
|
||||
**"What's the right byte order for this family?"** → `read` with
|
||||
`--byte-order BigEndian`, then with `--byte-order WordSwap`. The one that
|
||||
gives plausible values is the correct one for that device.
|
||||
|
||||
## v2 addressing grammar
|
||||
|
||||
The driver accepts the industry-standard tag-address grammar so you can
|
||||
paste tag spreadsheets from Wonderware / Kepware / Ignition without
|
||||
per-row manual translation. Full reference + grammar rules:
|
||||
[`docs/v2/modbus-addressing.md`](v2/modbus-addressing.md).
|
||||
|
||||
Quick examples:
|
||||
|
||||
```
|
||||
40001 HoldingRegisters[0], Int16
|
||||
400001 same, 6-digit form
|
||||
40001:F Float32
|
||||
40001:F:CDAB Float32 word-swapped
|
||||
40001:STR20 20-char ASCII string
|
||||
40001:S:5 Int16[5] array (3-field shorthand)
|
||||
40001:F:CDAB:10 Float32[10] with explicit word-swap (4-field strict)
|
||||
40001.5 bit 5 of HR[0]
|
||||
HR1:I Int32 via mnemonic region prefix (matches Wonderware)
|
||||
C100 Coil 100 (mnemonic, 1-based)
|
||||
V2000:F:CDAB DL205 V-memory at PDU 1024 + Float32 + word-swap (Family=DL205)
|
||||
D100:I MELSEC D-register 100, Int32 (Family=MELSEC)
|
||||
```
|
||||
|
||||
**Type-code reminder** (post-#146): `:I` is **Int32** (matches Wonderware
|
||||
DASMBTCP + Ignition `HRI`). The explicit Int16 code is `:S`. Bare HR/IR
|
||||
with no type still defaults to Int16. Pre-#146 codes `:DI` / `:L` /
|
||||
`:UDI` / `:UL` / `:LI` / `:ULI` / `:LBCD` are removed; configs that use
|
||||
them get a clear "Unknown type code" diagnostic at parse time.
|
||||
|
||||
In `DriverConfig` JSON, set the per-tag `addressString` field instead of
|
||||
the structured `region` + `address` + `dataType` fields. Both styles can
|
||||
coexist within one driver instance.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# `otopcua-twincat-cli` — Beckhoff TwinCAT test client
|
||||
|
||||
Ad-hoc probe / read / write / subscribe tool for Beckhoff TwinCAT 2 / TwinCAT 3
|
||||
runtimes via ADS. Uses the **same** `TwinCATDriver` the OtOpcUa server does
|
||||
(`Beckhoff.TwinCAT.Ads` package). Native ADS notifications by default;
|
||||
`--poll-only` falls back to the shared `PollGroupEngine`.
|
||||
Ad-hoc probe / read / write / subscribe / browse tool for Beckhoff TwinCAT 2 /
|
||||
TwinCAT 3 runtimes via ADS. Uses the **same** `TwinCATDriver` the OtOpcUa
|
||||
server does (`Beckhoff.TwinCAT.Ads` package). Native ADS notifications by
|
||||
default; `--poll-only` falls back to the shared `PollGroupEngine`.
|
||||
|
||||
Fifth (final) of the driver test-client CLIs.
|
||||
|
||||
@@ -50,6 +50,13 @@ caller interpret semantics.
|
||||
|
||||
### `probe`
|
||||
|
||||
Per-command flags:
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `-s` / `--symbol` | **required** | Symbol path to probe (e.g. `MAIN.bRunning`) |
|
||||
| `--type` | `DInt` | Declared data type — see the [Data types](#data-types) list |
|
||||
|
||||
```powershell
|
||||
# Local TwinCAT 3, probe a canonical global
|
||||
otopcua-twincat-cli probe -n 127.0.0.1.1.1 -s "TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt"
|
||||
@@ -89,6 +96,14 @@ Structure writes refused — drop to driver config JSON for those.
|
||||
|
||||
### `subscribe`
|
||||
|
||||
Per-command flags:
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `-s` / `--symbol` | **required** | Symbol path — same format as `read` |
|
||||
| `-t` / `--type` | `DInt` | Declared data type |
|
||||
| `-i` / `--interval-ms` | `1000` | Publishing interval in **milliseconds** — native mode passes this as the ADS `NotificationSettings.CycleTime` |
|
||||
|
||||
```powershell
|
||||
# Native ADS notifications (default) — PLC pushes on its own cycle
|
||||
otopcua-twincat-cli subscribe -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -i 500
|
||||
@@ -99,3 +114,23 @@ otopcua-twincat-cli subscribe -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -i 500
|
||||
|
||||
The subscribe banner announces which mechanism is in play — "ADS notification"
|
||||
or "polling" — so it's obvious in screen-recorded bug reports.
|
||||
|
||||
### `browse`
|
||||
|
||||
Walks the controller's symbol table via ADS `SymbolLoaderFactory` (same path
|
||||
`TwinCATDriver.DiscoverAsync` takes when `EnableControllerBrowse = true`).
|
||||
Output filters to symbols whose type maps onto the driver's atomic surface —
|
||||
UDTs / function-block instances don't appear.
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--prefix` | _(none)_ | Case-sensitive instance-path prefix filter (e.g. `GVL_Fixture`) |
|
||||
| `--max` | `500` | Max symbols to print. `0` = unbounded |
|
||||
|
||||
```powershell
|
||||
# Everything under a single GVL
|
||||
otopcua-twincat-cli browse -n 192.168.1.40.1.1 --prefix GVL_Fixture
|
||||
|
||||
# Full dump (beware: flat-mode walks on a real controller can top 10k symbols)
|
||||
otopcua-twincat-cli browse -n 192.168.1.40.1.1 --max 0
|
||||
```
|
||||
|
||||
@@ -83,7 +83,7 @@ The host spins up `StaPump` (the STA thread with message pump), creates the MXAc
|
||||
|
||||
### Pipe security
|
||||
|
||||
`PipeServer` builds a `PipeAcl` from the provided `SecurityIdentifier` + uses `NamedPipeServerStream` with `maxNumberOfServerInstances: 1`. The handshake requires a matching shared secret in the first Hello frame; callers whose SID doesn't match `OTOPCUA_ALLOWED_SID` are rejected before any frame is processed. **By design the pipe ACL denies BUILTIN\Administrators** — live smoke tests must therefore run from a non-elevated shell that matches the allowed principal. The installed dev host (`OtOpcUaGalaxyHost`) runs as `dohertj2` with the secret at `.local/galaxy-host-secret.txt`.
|
||||
`PipeServer` builds a `PipeAcl` from the provided `SecurityIdentifier` + uses `NamedPipeServerStream` with `maxNumberOfServerInstances: 1`. The handshake requires a matching shared secret in the first Hello frame; callers whose SID doesn't match `OTOPCUA_ALLOWED_SID` are rejected before any frame is processed via `NamedPipeServerStream.RunAsClient` + a SID comparison against the configured allow list. The DACL grants `ReadWrite | Synchronize` only to the allowed SID and denies `LocalSystem`. The installed dev host (`OtOpcUaGalaxyHost`) runs as `dohertj2` with the secret at `.local/galaxy-host-secret.txt`.
|
||||
|
||||
### Installation
|
||||
|
||||
|
||||
@@ -2,132 +2,149 @@
|
||||
|
||||
Coverage map + gap inventory for the FANUC FOCAS2 CNC driver.
|
||||
|
||||
**TL;DR: there is no integration fixture.** Every test uses a
|
||||
`FakeFocasClient` injected via `IFocasClientFactory`. Fanuc's FOCAS library
|
||||
(`Fwlib32.dll`) is closed-source proprietary with no public simulator;
|
||||
CNC-side behavior is trusted from field deployments.
|
||||
**Status:** as of 2026-04-24, OtOpcUa speaks FOCAS2 directly over TCP
|
||||
via the pure-managed [`Focas.Wire`](https://github.com/Ladder99/focas-mock/tree/main/dotnet/Focas.Wire)
|
||||
client. Integration tests run the managed driver end-to-end against the
|
||||
vendored `focas-mock` Python server (at
|
||||
[`tests/.../Docker/focas-mock/`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/VENDORED.md))
|
||||
whose native FOCAS Ethernet responder is verified PDU-by-PDU against the
|
||||
real `fwlibe64.dll`.
|
||||
|
||||
## What the fixture is
|
||||
No shim DLL, no P/Invoke, no licensed binary — any dev box or CI runner
|
||||
with Docker can run the full fixture end-to-end.
|
||||
|
||||
Nothing at the integration layer.
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/` is unit-only. The driver ships
|
||||
as Tier C (process-isolated) per `docs/v2/driver-stability.md` because the
|
||||
FANUC DLL has known crash modes; tests can't replicate those in-process.
|
||||
Hardware validation against a real CNC is still useful to catch
|
||||
series-specific firmware quirks (see [§ Hardware-only gaps](#hardware-only-gaps))
|
||||
but the mock's wire responder covers every FOCAS call OtOpcUa issues.
|
||||
|
||||
## What it actually covers (unit only)
|
||||
## What the fixture covers
|
||||
|
||||
- `FocasCapabilityTests` — data-type mapping (PMC bit / word / float,
|
||||
macro variable types, parameter types)
|
||||
- `FocasCapabilityMatrixTests` — per-CNC-series range validation (macro
|
||||
/ parameter / PMC letter + number) across 16i / 0i-D / 0i-F /
|
||||
30i / PowerMotion. See [`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md)
|
||||
for the authoritative matrix. 46 theory cases lock every documented
|
||||
range boundary — widening a range without updating the doc fails a
|
||||
test.
|
||||
- `FocasReadWriteTests` — read + write against the fake, FOCAS native status
|
||||
→ OPC UA StatusCode mapping
|
||||
### Unit layer (no container required)
|
||||
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/` uses `FakeFocasClient`
|
||||
injected via `IFocasClientFactory`:
|
||||
|
||||
- `FocasCapabilityTests` — data-type mapping (PMC bit / byte / word /
|
||||
long / float / double, macro variable types, parameter types)
|
||||
- `FocasCapabilityMatrixTests` — per-CNC-series range validation across
|
||||
16i / 0i-D / 0i-F / 30i / Power Motion, 46 theory cases locking every
|
||||
documented range boundary. See
|
||||
[`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md).
|
||||
- `FocasReadWriteTests` — read / write contract against the fake, FOCAS
|
||||
native status → OPC UA `StatusCode` mapping
|
||||
- `FocasScaffoldingTests` — `IDriver` lifecycle + multi-device routing
|
||||
- `FocasPmcBitRmwTests` — PMC bit read-modify-write synchronization (per-byte
|
||||
`SemaphoreSlim`, mirrors the AB / Modbus pattern from #181)
|
||||
- `FwlibNativeHelperTests` — `Focas32.dll` → `Fwlib32.dll` bridge validation
|
||||
+ P/Invoke signature validation
|
||||
- `FocasPmcBitRmwTests` — PMC bit read-modify-write synchronisation
|
||||
- `FocasAlarmProjectionTests` — raise / clear diffing, severity mapping
|
||||
- `FocasHandleRecycleTests` — proactive session recycle cadence
|
||||
|
||||
Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
|
||||
`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
|
||||
`IPerCallHostResolver`.
|
||||
`ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
|
||||
`IPerCallHostResolver`, `IAlarmSource`. `IWritable` intentionally
|
||||
returns `BadNotWritable` — OtOpcUa is read-only against FOCAS.
|
||||
|
||||
Pre-flight validation runs in `FocasDriver.InitializeAsync` — configs
|
||||
referencing out-of-range addresses fail at load time with a diagnostic
|
||||
message naming the CNC series + documented limit. This closes the
|
||||
cheap half of the hardware-free stability gap; Tier-C process
|
||||
isolation (task #220) closes the expensive half — see
|
||||
[`docs/v2/implementation/focas-isolation-plan.md`](../v2/implementation/focas-isolation-plan.md).
|
||||
message naming the CNC series + documented limit.
|
||||
|
||||
## What it does NOT cover
|
||||
### Integration layer (mock only, no CNC, no shim)
|
||||
|
||||
### 1. FOCAS wire traffic
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/` drives the
|
||||
managed `FocasDriver` end-to-end. A single gate:
|
||||
|
||||
No FOCAS TCP frame is sent. `Fwlib32.dll`'s TCP-to-FANUC-gateway exchange is
|
||||
closed-source; the driver trusts the P/Invoke layer per #193. Real CNC
|
||||
correctness is trusted from field deployments.
|
||||
**Docker compose up** — tests skip when the TCP probe to
|
||||
`localhost:8193` fails with a pointer to the compose command.
|
||||
|
||||
### 2. Alarm / parameter-change callbacks
|
||||
When the mock is up, `WireFocasClient` dials it over TCP exactly like a
|
||||
real CNC, and the mock's native FOCAS Ethernet responder replies with
|
||||
binary PDUs against the documented command IDs. Covered assertions:
|
||||
|
||||
FOCAS has no push model — the driver polls via the shared `PollGroupEngine`.
|
||||
There are no CNC-initiated callbacks to test; the absence is by design.
|
||||
- Session open / close (`cnc_allclibhndl3` + `cnc_freelibhndl`)
|
||||
- Parameter read-back after `mock_patch` seed → `cnc_rdparam`
|
||||
- Macro read-back after seed → `cnc_rdmacro` (scaled-decimal
|
||||
translation verified)
|
||||
- PMC range read after seed → `pmc_rdpmcrng`
|
||||
- `IAlarmSource` raise + clear transitions after `mock_patch`
|
||||
alarm-list changes → `cnc_rdalmmsg2`
|
||||
- Fixed-tree bootstrap: identity / axes / spindle / program / timers /
|
||||
servo meters populate via `cnc_sysinfo`, `cnc_rdaxisname`,
|
||||
`cnc_rdspdlname`, `cnc_rddynamic2`, `cnc_exeprgname2`,
|
||||
`cnc_rdblkcount`, `cnc_rdopmode`, `cnc_rdsvmeter`, `cnc_rdspload`,
|
||||
`cnc_rdspmaxrpm`, `cnc_rdtimer`
|
||||
- Per-series profile selection via `mock_load_profile` — tests can
|
||||
pin one profile and assert series-gated capability suppression
|
||||
|
||||
### 3. Macro / ladder variable types
|
||||
### E2E script (CLI)
|
||||
|
||||
FANUC has CNC-specific extensions (macro variables `#100-#999`, system
|
||||
variables `#1000-#5000`, PMC timers / counters / keep-relays) whose
|
||||
per-address semantics differ across 0i-F / 30i / 31i / 32i Series. Driver
|
||||
covers the common address shapes; per-model quirks are not stressed.
|
||||
`scripts/e2e/test-focas.ps1` drives the Client.CLI against a running
|
||||
OtOpcUa server. Accepts:
|
||||
|
||||
### 4. Model-specific behavior
|
||||
- `-CncHost` / `-CncPort` for real hardware
|
||||
- `-ProfileName <compose-profile>` for the Docker mock
|
||||
- `-Series <csv>` for per-series matrix mode
|
||||
- `-HandleLeakCycles <N>` for handle-leak stress
|
||||
|
||||
- Alarm retention across power cycles (model-specific CNC behavior)
|
||||
- Parameter range enforcement (CNC rejects out-of-range writes)
|
||||
- MTB (machine tool builder) custom screens that expose non-standard data
|
||||
## Hardware-only gaps
|
||||
|
||||
### 5. Tier-C process isolation — architecture shipped, Fwlib32 integration hardware-gated
|
||||
The mock has parity with the real `fwlibe64.dll` for the calls OtOpcUa
|
||||
issues, but a real CNC can still surface things a reference
|
||||
implementation can't:
|
||||
|
||||
The Tier-C architecture is now in place as of PRs #169–#173 (FOCAS
|
||||
PR A–E, task #220):
|
||||
1. **Series-specific firmware quirks** — alarm retention across power
|
||||
cycles, parameter range enforcement by the CNC (not the driver),
|
||||
MTB custom screens, series-specific option bits. Each series has
|
||||
documented behaviours that only a bench CNC exercises.
|
||||
2. **Wire-level stress** — burst reads, concurrent device writes,
|
||||
network-partition recovery under load. The mock handles these
|
||||
correctly but production behaviour is the source of truth.
|
||||
3. **Transient operational states** — alarm floods, emergency-stop
|
||||
transitions, power-on resync. These are easy to stub but hard to
|
||||
cover comprehensively in the mock.
|
||||
|
||||
- `Driver.FOCAS.Shared` carries MessagePack IPC contracts
|
||||
- `Driver.FOCAS.Host` (.NET 4.8 x86 Windows service via NSSM) accepts
|
||||
a connection on a strictly-ACL'd named pipe + dispatches frames to
|
||||
an `IFocasBackend`
|
||||
- `Driver.FOCAS.Ipc.IpcFocasClient` implements the `IFocasClient` DI
|
||||
seam by forwarding over IPC — swap the DI registration and the
|
||||
driver runs Tier-C with zero other changes
|
||||
- `Driver.FOCAS.Supervisor.FocasHostSupervisor` owns the spawn +
|
||||
heartbeat + respawn + 3-in-5min crash-loop breaker + sticky alert
|
||||
- `Driver.FOCAS.Host.Stability.PostMortemMmf` ↔
|
||||
`Driver.FOCAS.Supervisor.PostMortemReader` — ring-buffer of the
|
||||
last ~1000 IPC operations survives a Host crash
|
||||
Track the close-out under task #54 (live-CNC smoke). When the rig
|
||||
lands, the hardware path runs alongside the mock path; the mock
|
||||
stays as the CI quality gate.
|
||||
|
||||
The one remaining gap is the production `FwlibHostedBackend`: an
|
||||
`IFocasBackend` implementation that wraps the licensed
|
||||
`Fwlib32.dll` P/Invoke. That's hardware-gated on task #222 — we
|
||||
need a CNC on the bench (or the licensed FANUC developer kit DLL
|
||||
with a test harness) to validate it. Until then, the Host ships
|
||||
`FakeFocasBackend` + `UnconfiguredFocasBackend`. Setting
|
||||
`OTOPCUA_FOCAS_BACKEND=fake` lets operators smoke-test the whole
|
||||
Tier-C pipeline end-to-end without any CNC.
|
||||
## When to trust each layer
|
||||
|
||||
## When to trust FOCAS tests, when to reach for a rig
|
||||
| Question | Unit | Integration (mock) | Real CNC |
|
||||
| --- | :---: | :---: | :---: |
|
||||
| "Does PMC address `R100.3` route to the right bit?" | ✅ | ✅ | ✅ |
|
||||
| "Does the Fanuc status → OPC UA StatusCode map cover every documented code?" | ✅ (contract) | ✅ | ✅ |
|
||||
| "Does `FocasDriver.ReadAsync` correctly decode a seeded parameter?" | no | ✅ | ✅ |
|
||||
| "Does `IAlarmSource` fire raise + clear events?" | ✅ (Fake) | ✅ (wire) | ✅ |
|
||||
| "Does a real read against a 30i Series return correct bytes?" | no | ✅ (via profile) | ✅ (required) |
|
||||
| "Do series-specific firmware quirks behave as documented?" | no | no | ✅ (required) |
|
||||
| "Does the driver survive real network partitions?" | no | partial (socket kill) | ✅ (required) |
|
||||
|
||||
| Question | Unit tests | Real CNC |
|
||||
| --- | --- | --- |
|
||||
| "Does PMC address `R100.3` route to the right bit?" | yes | yes |
|
||||
| "Does the FANUC status → OPC UA StatusCode map cover every documented code?" | yes (contract) | yes |
|
||||
| "Does a real read against a 30i Series return correct bytes?" | no | yes (required) |
|
||||
| "Does `Fwlib32.dll` crash on concurrent reads?" | no | yes (stress) |
|
||||
| "Do macro variables round-trip across power cycles?" | no | yes (required) |
|
||||
## Running the integration fixture
|
||||
|
||||
## Follow-up candidates
|
||||
```powershell
|
||||
# 1) Start the mock on a chosen profile.
|
||||
docker compose -f tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/docker-compose.yml up -d
|
||||
|
||||
1. **Nothing public** — Fanuc's FOCAS Developer Kit ships an emulator DLL
|
||||
but it's under NDA + tied to licensed dev-kit installations; can't
|
||||
redistribute for CI.
|
||||
2. **Lab rig** — used FANUC 0i-F simulator controller (or a retired machine
|
||||
tool) on a dedicated network; only path that covers real CNC behavior.
|
||||
3. **Process isolation first** — before trusting FOCAS in production at
|
||||
scale, shipping the Tier-C out-of-process Host architecture (similar to
|
||||
Galaxy) is higher value than a CI simulator.
|
||||
# 2) Run the tests. No shim build, no DLL copy — the driver dials the mock directly.
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/
|
||||
```
|
||||
|
||||
Or use `scripts/integration/run-focas.ps1` which wraps compose up / test
|
||||
/ compose down and accepts `-Profile <name>` to pin a per-series run.
|
||||
|
||||
## Key fixture / config files
|
||||
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/focas-mock/`
|
||||
— vendored `focas-mock` Python source + Dockerfile
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/docker-compose.yml`
|
||||
— per-series compose profiles
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/FocasSimFixture.cs`
|
||||
— collection fixture + mock admin API client
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/FixedTreePopulatesTests.cs`
|
||||
— fixed-tree end-to-end tests
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendTests.cs`
|
||||
— pure-wire-backend end-to-end tests
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs` —
|
||||
in-process fake implementing `IFocasClient`
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityMatrixTests.cs`
|
||||
— parameterized theories locking the per-series matrix
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs` — ctor takes
|
||||
`IFocasClientFactory`
|
||||
in-process unit fake
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs` — the
|
||||
managed wire client backing production deployments
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs` —
|
||||
per-CNC-series range validator (the matrix the doc describes)
|
||||
per-series range validator
|
||||
- `docs/v2/focas-version-matrix.md` — authoritative range reference
|
||||
- `docs/v2/implementation/focas-isolation-plan.md` — Tier-C isolation
|
||||
plan (task #220)
|
||||
- `docs/v2/driver-stability.md` — Tier C scope + process-isolation rationale
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# FOCAS Driver
|
||||
|
||||
Getting-started guide for the FANUC FOCAS2 driver. This is the short path — for
|
||||
the exhaustive per-node mapping read [`docs/v2/driver-specs.md §7`](../v2/driver-specs.md),
|
||||
for deployment details read [`docs/v2/focas-deployment.md`](../v2/focas-deployment.md),
|
||||
for the test-harness map read [FOCAS-Test-Fixture.md](FOCAS-Test-Fixture.md).
|
||||
|
||||
## What it talks to
|
||||
|
||||
FANUC CNCs (0i-D / 0i-F / 0i-MF / 0i-TF / 16i / 30i / 31i / 32i / Power Motion i)
|
||||
over the proprietary FOCAS2 protocol on TCP port 8193. The wire is spoken
|
||||
directly by the pure-managed [`Focas.Wire`](https://github.com/Ladder99/focas-mock)
|
||||
client — no Fwlib64.dll, no P/Invoke, no out-of-process isolation needed.
|
||||
|
||||
OtOpcUa is **read-only** against FOCAS; all reads go over the native wire
|
||||
protocol using the documented command IDs. Writes return
|
||||
`BadNotWritable` by design.
|
||||
|
||||
## Project split
|
||||
|
||||
| Project | Target | Role |
|
||||
|---------|--------|------|
|
||||
| `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/` | net10.0 | In-process driver — hosts `WireFocasClient` which speaks FOCAS2 over TCP directly |
|
||||
|
||||
Previous `Driver.FOCAS.Host` / `Driver.FOCAS.Shared` Tier-C split has been
|
||||
retired — the managed wire client removes the native-crash blast radius
|
||||
that justified the out-of-process service.
|
||||
|
||||
## Minimum deployment
|
||||
|
||||
Register the driver instance in the main server's `appsettings.json`. No
|
||||
separate service, no DLL deployment, no shared-secret handshake:
|
||||
|
||||
```jsonc
|
||||
"Drivers": {
|
||||
"focas-cnc-1": {
|
||||
"Type": "FOCAS",
|
||||
"Config": {
|
||||
"Backend": "wire",
|
||||
"Devices": [
|
||||
{ "HostAddress": "focas://10.20.30.40:8193", "Series": "ThirtyOne_i" }
|
||||
],
|
||||
"Tags": [
|
||||
{ "Name": "Mode", "DeviceHostAddress": "focas://10.20.30.40:8193",
|
||||
"Address": "PARAM:3402", "DataType": "Int32", "Writable": false },
|
||||
{ "Name": "SpndLoad", "DeviceHostAddress": "focas://10.20.30.40:8193",
|
||||
"Address": "MACRO:500", "DataType": "Float64", "Writable": false }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The main server opens two TCP sockets per configured device and speaks the
|
||||
FOCAS2 binary protocol directly. No local privileged components, no
|
||||
platform bitness constraint — the driver runs on every host OtOpcUa runs
|
||||
on.
|
||||
|
||||
## Address forms
|
||||
|
||||
| Form | Example | Meaning |
|
||||
|------|---------|---------|
|
||||
| `X0.0` / `R100` / `R100.3` | PMC bit or byte | Letter + number; optional `.bit` for bit access |
|
||||
| `PARAM:1815` / `PARAM:1815/0` | CNC parameter | Number + optional axis index |
|
||||
| `MACRO:500` | Custom macro variable | System / user macro variable number |
|
||||
|
||||
Addresses are validated against the per-device `Series` at `InitializeAsync` —
|
||||
a config referencing a number outside the documented range for that series
|
||||
fails at load time with an error message naming the limit. See
|
||||
[`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md) for the
|
||||
authoritative range table.
|
||||
|
||||
## Backend selection
|
||||
|
||||
The driver picks its client from `Config.Backend`:
|
||||
|
||||
| Value | Client | Use it for |
|
||||
|-------|--------|------------|
|
||||
| `wire` (default) | `WireFocasClient` | Production — pure-managed FOCAS2 over TCP |
|
||||
| `unimplemented` / `none` / `stub` | `UnimplementedFocasClientFactory` | Scaffolding a DriverInstance row before the CNC endpoint is reachable |
|
||||
|
||||
Previous backends (`fwlib`, `fwlib32`, `ipc`) have been retired along
|
||||
with `Driver.FOCAS.Host` and the Fwlib P/Invoke path. Configs that still
|
||||
reference them will throw at startup with a message pointing here.
|
||||
|
||||
## Capability surface
|
||||
|
||||
| Capability | Wire path | Notes |
|
||||
|------------|-----------|-------|
|
||||
| `IReadable` | `ReadAsync` → `cnc_rdpmcrng` / `cnc_rdparam` / `cnc_rdmacro` | One TCP request/response per read; `Focas.Wire` serializes requests on socket 2 internally |
|
||||
| `IWritable` | returns `BadNotWritable` | OtOpcUa is read-only against FOCAS by design — no `cnc_wrparam` / `pmc_wrpmcrng` / `cnc_wrmacro` path is implemented |
|
||||
| `ITagDiscovery` | `DiscoverAsync` | Emits `FOCAS/{device}/{tag}` folders per configured device |
|
||||
| `ISubscribable` | polled via shared `PollGroupEngine` | FOCAS has no push model — subscriptions turn into per-tag polling groups |
|
||||
| `IHostConnectivityProbe` | periodic `cnc_rdcncstat` | Probe cadence is `Probe.Interval`; transitions fire `OnHostStatusChanged` |
|
||||
| `IPerCallHostResolver` | lookup in `_tagsByName` | Each call routes to the device of the referenced tag |
|
||||
| `IAlarmSource` | polled `cnc_rdalmmsg2` via `FocasAlarmProjection` | Opt-in — set `AlarmProjection.Enabled=true`; diffs `(AlarmNumber, Type)` between ticks |
|
||||
|
||||
Ack is a no-op — FANUC clears alarms on its own once the underlying condition
|
||||
resolves, so `AcknowledgeAsync` swallows the batch rather than surfacing
|
||||
`BadNotSupported`.
|
||||
|
||||
## Fixed node tree
|
||||
|
||||
Enable a pre-defined hierarchy of CNC nodes populated automatically from
|
||||
`cnc_sysinfo` + `cnc_rdaxisname` + `cnc_rddynamic2` + related FWLIB calls,
|
||||
so operators get an out-of-the-box view of identity / axes / program /
|
||||
timers without declaring per-address tags.
|
||||
|
||||
```jsonc
|
||||
"Config": {
|
||||
"Devices": [ ... ],
|
||||
"Tags": [ ... ],
|
||||
"FixedTree": {
|
||||
"Enabled": true,
|
||||
"PollInterval": "00:00:00.250", // fast — per-axis dynamic reads
|
||||
"ProgramPollInterval": "00:00:01", // medium — program + mode changes
|
||||
"TimerPollInterval": "00:00:30" // slow — cumulative counters
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
What gets populated (all under `FOCAS/{deviceHostAddress}/`):
|
||||
|
||||
| Subtree | Nodes | Source call |
|
||||
|---------|-------|-------------|
|
||||
| `Identity/` | `SeriesNumber`, `Version`, `MaxAxes`, `CncType`, `MtType`, `AxisCount` | `cnc_sysinfo` once at bootstrap |
|
||||
| `Axes/{name}/` | `AbsolutePosition`, `MachinePosition`, `RelativePosition`, `DistanceToGo`, `ServoLoad` — one folder per discovered axis | `cnc_rdaxisname` once + `cnc_rddynamic2` + `cnc_rdsvmeter` per tick |
|
||||
| `Axes/FeedRate/Actual`, `Axes/SpindleSpeed/Actual` | Current feed + spindle RPM | `cnc_rddynamic2` |
|
||||
| `Spindle/{name}/` | `Load` (percentage), `MaxRpm` — one folder per discovered spindle | `cnc_rdspdlname` once + `cnc_rdspload` + `cnc_rdspmaxrpm` |
|
||||
| `Program/` | `Name` (filename), `ONumber`, `Number`, `MainNumber`, `Sequence`, `BlockCount` | `cnc_exeprgname2` + `cnc_rdblkcount` + cached `cnc_rddynamic2` |
|
||||
| `OperationMode/` | `Mode` (int), `ModeText` ("AUTO", "MDI", "EDIT", …) | `cnc_rdopmode` |
|
||||
| `Timers/` | `PowerOnSeconds`, `OperatingSeconds`, `CuttingSeconds`, `CycleSeconds` | `cnc_rdtimer` × 4 |
|
||||
|
||||
### Per-series node suppression
|
||||
|
||||
The driver probes each optional call once at bootstrap. If the target CNC
|
||||
returns `EW_FUNC` / `EW_NOOPT` / `EW_VERSION` on the wire, the
|
||||
corresponding subtree is **not emitted** — the operator doesn't see nodes
|
||||
that will only ever return `BadDeviceFailure`. Capability suppression
|
||||
covers `Spindle/`, `Program/` + `OperationMode/`, `Timers/`, and
|
||||
per-axis `ServoLoad` independently. Identity + `Axes/*` position reads
|
||||
(which every Fanuc CNC supports) are always emitted.
|
||||
|
||||
Position values are scaled integers (matching FOCAS's convention). The
|
||||
managed side exposes them as `Float64` OPC UA nodes; a future
|
||||
`cnc_getfigure` integration will add per-axis decimal scaling. Until
|
||||
then, treat the raw integer as the value the CNC reports and scale on
|
||||
the client side if decimal precision matters.
|
||||
|
||||
**Still user-authored**: `PARAM:6711`, `MACRO:500`, `R100` etc. — specific
|
||||
numbers whose meaning is MTB-specific. Those go under the device folder
|
||||
alongside the fixed subtree.
|
||||
|
||||
## Alarm projection
|
||||
|
||||
Alarm surfacing is **disabled by default** because the polling cost is wasted
|
||||
on sites that don't consume CNC alarms. Opt in per driver instance:
|
||||
|
||||
```jsonc
|
||||
"Config": {
|
||||
"Devices": [ ... ],
|
||||
"Tags": [ ... ],
|
||||
"AlarmProjection": {
|
||||
"Enabled": true,
|
||||
"PollInterval": "00:00:02"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Every alarm transition fires `OnAlarmEvent` with:
|
||||
|
||||
- `SourceNodeId` = the device host address (FOCAS has no per-node alarm model;
|
||||
the CNC exposes a single flat active-alarm list per session)
|
||||
- `ConditionId` = `"{host}#{Type}:{AlarmNumber}"`
|
||||
- `AlarmType` = projected from FANUC's `ALM_TYPE_*` (e.g. `Overtravel`, `Servo`,
|
||||
`Parameter`, `MacroAlarm`)
|
||||
- `Severity` = Overtravel / Servo / PulseCode → `Critical`; Parameter / Macro
|
||||
→ `Medium`; everything else → `High`
|
||||
|
||||
Cleared alarms fire a second event with `" (cleared)"` appended to the message
|
||||
so downstream consumers can ignore the clear if they only care about raises.
|
||||
|
||||
## Handle recycling
|
||||
|
||||
FANUC CNCs have a finite FWLIB handle pool (~5–10 concurrent connections) and
|
||||
certain series have documented handle-leak bugs that manifest after long uptime.
|
||||
The driver can proactively close + reopen each device's session on a cadence to
|
||||
release its slot back to the pool:
|
||||
|
||||
```jsonc
|
||||
"Config": {
|
||||
"Devices": [ ... ],
|
||||
"HandleRecycle": {
|
||||
"Enabled": true,
|
||||
"Interval": "01:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Disabled by default — a healthy CNC + driver doesn't need it. Enable when field
|
||||
experience shows handle exhaustion. Typical tuning: 30 min for sites running
|
||||
multiple OtOpcUa instances against the same CNC (they share the pool); 6 h for a
|
||||
single-client deployment. Reads / writes during recycle simply wait for the
|
||||
reconnect rather than failing — worst case, an operator sees a brief read
|
||||
latency spike once per cadence.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit tests** — `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/` cover the
|
||||
driver surface via `FakeFocasClient`. Includes the alarm-projection raise /
|
||||
clear diffing tests.
|
||||
- **Integration tests** — `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/`
|
||||
hold the Docker simulator scaffold (Stream B / C of the simulator plan —
|
||||
`docs/v2/implementation/focas-simulator-plan.md`).
|
||||
- **E2E script** — `scripts/e2e/test-focas.ps1` stages Host + Proxy + a real
|
||||
CNC (or the simulator) and exercises connect → read → write → subscribe
|
||||
round-trips. See [`docs/drivers/FOCAS-Test-Fixture.md`](FOCAS-Test-Fixture.md)
|
||||
for the coverage map.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---------|--------------|-----|
|
||||
| `BadCommunicationError` on every read | CNC unreachable on TCP:8193 | Check firewall / LAN reachability; FOCAS Ethernet option must be licensed on the CNC side |
|
||||
| Every read returns `BadNotWritable` on writes | Expected — OtOpcUa is read-only against FOCAS | If you actually need writes, open a feature request — the driver's managed wire client doesn't expose the write commands |
|
||||
| `BadOutOfRange` on reads for a macro/parameter | Config address outside the declared `Series` range | Check `docs/v2/focas-version-matrix.md` — either fix the address or widen the `Series` |
|
||||
| Alarm events never fire | `AlarmProjection.Enabled` left at default (false) | Set it to `true` in the driver config |
|
||||
|
||||
## Further reading
|
||||
|
||||
- [`docs/v2/driver-specs.md §7`](../v2/driver-specs.md) — full OPC UA node
|
||||
mapping, pre-defined tag set, per-API notes
|
||||
- [`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md) —
|
||||
per-series macro / parameter / PMC range table
|
||||
- [`docs/v2/implementation/focas-wire-protocol.md`](../v2/implementation/focas-wire-protocol.md)
|
||||
— captured FOCAS2 wire semantics (magic prefix, handshake, command-id table)
|
||||
- [upstream `Focas.Wire`](https://github.com/Ladder99/focas-mock/tree/main/dotnet/Focas.Wire)
|
||||
— the managed client implementation OtOpcUa consumes as a NuGet dependency
|
||||
@@ -35,13 +35,14 @@ Multi-project test topology:
|
||||
## How tests skip
|
||||
|
||||
- **E2E parity**: `ParityFixture.SkipIfUnavailable()` runs at class init and
|
||||
checks Windows-only, non-admin user, ZB SQL reachable on
|
||||
`localhost:1433`, Host EXE built in the expected `bin/` folder. Any miss
|
||||
→ tests skip.
|
||||
checks Windows-only, ZB SQL reachable on `localhost:1433`, Host EXE built
|
||||
in the expected `bin/` folder. Any miss → tests skip.
|
||||
- **Live-smoke** (`GalaxyRepositoryLiveSmokeTests`): `Assert.Skip` when ZB
|
||||
unreachable. A `per project_galaxy_host_installed` memory on this repo's
|
||||
dev box notes the MXAccess runtime is installed + pipe ACL denies Admins,
|
||||
so live tests must run from a non-elevated shell.
|
||||
dev box notes the MXAccess runtime is installed. The pipe ACL allows the
|
||||
configured SID outright; elevation of the caller doesn't matter because
|
||||
the per-connection SID check in `PipeServer.VerifyCaller` only compares
|
||||
user SIDs (not group membership or integrity level).
|
||||
- **Unit** tests (Shared, Proxy contract, most Host.Tests) have no skip —
|
||||
they run anywhere.
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ The same Tier-C isolation story applies to FOCAS (decision record in `docs/v2/pl
|
||||
|
||||
- Pipe name: `otopcua-galaxy-{DriverInstanceId}` (localhost-only, no TCP surface)
|
||||
- Wire format: MessagePack-CSharp, length-prefixed frames
|
||||
- ACL: pipe is created with a DACL that grants only the Server's service identity; the Admins group is explicitly denied so a live-smoke test running from an elevated shell fails fast rather than silently bypassing the handshake
|
||||
- ACL: pipe is created with a DACL that grants `ReadWrite | Synchronize` only to the configured Server service-principal SID + denies `LocalSystem`. The per-connection SID check in `PipeServer.VerifyCaller` is the real authorization boundary — any caller whose impersonated token SID doesn't match the allowed SID is dropped before the first frame is read.
|
||||
- Handshake: Proxy presents a shared secret at `OpenSessionRequest`; Host rejects anything else with `MessageKind.OpenSessionResponse{Success=false}`
|
||||
- Heartbeat: Proxy sends a periodic ping; missed heartbeats trigger the Proxy-side crash-loop supervisor to restart the Host
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/ZB.M
|
||||
| AB CIP | `Driver.AbCip` | A | libplctag CIP | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource | ControlLogix / CompactLogix. Tag discovery uses the `@tags` walker to enumerate controller-scoped + program-scoped symbols; UDT member resolution via the UDT template reader |
|
||||
| AB Legacy | `Driver.AbLegacy` | A | libplctag PCCC | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | SLC 500 / MicroLogix. File-based addressing (`N7:0`, `F8:0`) — no symbol table, tag list is user-authored in the config DB |
|
||||
| TwinCAT | `Driver.TwinCAT` | B | Beckhoff `TwinCAT.Ads` (`TcAdsClient`) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | The only native-notification driver outside Galaxy — ADS delivers `ValueChangedCallback` events the driver forwards straight to `ISubscribable.OnDataChange` without polling. Symbol tree uploaded via `SymbolLoaderFactory` |
|
||||
| FOCAS | `Driver.FOCAS` | C | FANUC FOCAS2 (`Fwlib32.dll` P/Invoke) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | Tier C — FOCAS DLL has crash modes that warrant process isolation. CNC-shaped data model (axes, spindle, PMC, macros, alarms) not a flat tag map |
|
||||
| [FOCAS](FOCAS.md) | `Driver.FOCAS` | A | Pure-managed `FocasWireClient` — FOCAS/2 Ethernet binary protocol on TCP:8193, inlined into the driver assembly | IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource | Read-only by design (WriteAsync returns `BadNotWritable`). CNC-shaped data model (axes, spindle, PMC, macros, alarms) not a flat tag map. Previously Tier-C (Host + P/Invoke + shim DLL); retired in the 2026-04-24 migration when the managed wire client landed |
|
||||
| OPC UA Client | `Driver.OpcUaClient` | B | OPCFoundation `Opc.Ua.Client` | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHistoryProvider, IHostConnectivityProbe | Gateway/aggregation driver. Opens a single `Session` against a remote OPC UA server and re-exposes its address space. Owns its own `ApplicationConfiguration` (distinct from `Client.Shared`) because it's always-on with keep-alive + `TransferSubscriptions` across SDK reconnect, not an interactive CLI |
|
||||
|
||||
## Per-driver documentation
|
||||
@@ -35,6 +35,9 @@ Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/ZB.M
|
||||
- [Galaxy.md](Galaxy.md) — COM bridge, STA pump, IPC, runtime probes
|
||||
- [Galaxy-Repository.md](Galaxy-Repository.md) — ZB SQL reader, `LocalPlatform` scope filter, change detection
|
||||
|
||||
- **FOCAS** has a short getting-started doc because the Tier-C two-project deployment + backend-selection env var + alarm projection opt-in all need explaining up front:
|
||||
- [FOCAS.md](FOCAS.md) — deployment, config, capability surface, alarm projection, troubleshooting
|
||||
|
||||
- **All other drivers** share a single per-driver specification in [docs/v2/driver-specs.md](../v2/driver-specs.md) — addressing, data-type maps, connection settings, and quirks live there. That file is the authoritative per-driver reference; this index points at it rather than duplicating.
|
||||
|
||||
## Test-fixture coverage maps
|
||||
|
||||
@@ -2,60 +2,85 @@
|
||||
|
||||
Coverage map + gap inventory for the Beckhoff TwinCAT ADS driver.
|
||||
|
||||
**TL;DR:** Integration-test scaffolding lives at
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/` (task #221).
|
||||
`TwinCATXarFixture` probes TCP 48898 on an operator-supplied VM; three
|
||||
smoke tests (read / write / native notification) run end-to-end through
|
||||
the real ADS stack when the VM is reachable, skip cleanly otherwise.
|
||||
**Remaining operational work**: stand up a TwinCAT 3 XAR runtime in a
|
||||
Hyper-V VM, author the `.tsproj` project documented at
|
||||
`TwinCatProject/README.md`, rotate the 7-day trial license (or buy a
|
||||
paid runtime). Unit tests via `FakeTwinCATClient` still carry the
|
||||
exhaustive contract coverage.
|
||||
**TL;DR:** Integration-test suite lives at
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/`. `TwinCATXarFixture`
|
||||
probes TCP 48898 on an operator-supplied runtime; the suite runs **14
|
||||
`[TwinCATFact]` methods + one 16-case `[TwinCATTheory]` = 30 test cases** end-to-end
|
||||
through the real ADS stack when the runtime is reachable, skips cleanly
|
||||
otherwise. The runtime can be a Hyper-V XAR VM or a TCBSD VM
|
||||
(`TwinCatProject/README.md` covers both). Unit tests via `FakeTwinCATClient`
|
||||
still carry the exhaustive contract coverage alongside.
|
||||
|
||||
TwinCAT is the only driver outside Galaxy that uses **native
|
||||
notifications** (no polling) for `ISubscribable`, and the fake exposes a
|
||||
fire-event harness so notification routing is contract-tested rigorously
|
||||
at the unit layer.
|
||||
TwinCAT is the only driver outside Galaxy that uses **native notifications**
|
||||
(no polling) for `ISubscribable`. The integration suite verifies that path on
|
||||
the wire; the fake exposes a fire-event harness so notification routing is
|
||||
also contract-tested rigorously at the unit layer.
|
||||
|
||||
## What the fixture is
|
||||
|
||||
**Integration layer** (task #221, scaffolded):
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/` —
|
||||
`TwinCATXarFixture` TCP-probes ADS port 48898 on the host specified by
|
||||
`TWINCAT_TARGET_HOST` + requires `TWINCAT_TARGET_NETID` (AmsNetId of the
|
||||
VM). No fixture-owned lifecycle — XAR can't run in Docker because it
|
||||
bypasses the Windows kernel scheduler, so the VM stays
|
||||
operator-managed. `TwinCatProject/README.md` documents the required
|
||||
`.tsproj` project state; the file itself lands once the XAR VM is up +
|
||||
the project is authored. Three smoke tests:
|
||||
`Driver_reads_seeded_DINT_through_real_ADS`,
|
||||
`Driver_write_then_read_round_trip_on_scratch_REAL`, and
|
||||
`Driver_subscribe_receives_native_ADS_notifications_on_counter_changes`
|
||||
— all skip cleanly via `[TwinCATFact]` when the runtime isn't
|
||||
reachable.
|
||||
**Integration layer**: `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/`
|
||||
— `TwinCATXarFixture` TCP-probes ADS port 48898 on the host supplied by
|
||||
`TWINCAT_TARGET_HOST` (defaults to `localhost`) + requires
|
||||
`TWINCAT_TARGET_NETID` (AmsNetId of the runtime). Optionally takes
|
||||
`TWINCAT_TARGET_PORT` (default `851` = TC3 PLC runtime 1). No fixture-owned
|
||||
lifecycle — XAR / TCBSD can't run in Docker because they bypass the host
|
||||
kernel scheduler, so the runtime stays operator-managed.
|
||||
`TwinCatProject/README.md` documents the required project state; the tests
|
||||
gate on `[TwinCATFact]` / `[TwinCATTheory]` and skip cleanly when
|
||||
`TWINCAT_TARGET_NETID` is unset or the probe fails.
|
||||
|
||||
**Unit layer**: `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` is
|
||||
still the primary coverage. `FakeTwinCATClient` also fakes the
|
||||
`AddDeviceNotification` flow so tests can trigger callbacks without a
|
||||
running runtime.
|
||||
**Unit layer**: `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` remains the
|
||||
primary contract coverage. `FakeTwinCATClient` fakes the
|
||||
`AddDeviceNotification` flow so tests can trigger callbacks without a running
|
||||
runtime.
|
||||
|
||||
## What it actually covers
|
||||
|
||||
### Integration (XAR VM, task #221 — code scaffolded, needs VM + project)
|
||||
### Integration (live runtime)
|
||||
|
||||
- `TwinCAT3SmokeTests.Driver_reads_seeded_DINT_through_real_ADS` — real AMS
|
||||
handshake + ADS read of `GVL_Fixture.nCounter` (seeded at 1234, MAIN
|
||||
increments each cycle)
|
||||
- `TwinCAT3SmokeTests.Driver_write_then_read_round_trip_on_scratch_REAL` —
|
||||
real ADS write + read on `GVL_Fixture.rSetpoint`
|
||||
- `TwinCAT3SmokeTests.Driver_subscribe_receives_native_ADS_notifications_on_counter_changes`
|
||||
— real `AddDeviceNotification` against the cycle-incrementing counter;
|
||||
observes `OnDataChange` firing within 3 s of subscribe
|
||||
Every capability the driver implements is exercised on the wire:
|
||||
|
||||
All three gated on `TWINCAT_TARGET_HOST` + `TWINCAT_TARGET_NETID` env
|
||||
vars; skip cleanly via `[TwinCATFact]` when the VM isn't reachable or
|
||||
vars are unset.
|
||||
- **Read** — `Driver_reads_seeded_DINT_through_real_ADS` (AMS handshake +
|
||||
symbolic read of `GVL_Fixture.nCounter`)
|
||||
- **Write + read round-trip** — `Driver_write_then_read_round_trip_on_scratch_REAL`
|
||||
on `GVL_Fixture.rSetpoint`
|
||||
- **Array element round-trip** — `Driver_round_trips_array_element_write_and_read`
|
||||
on `GVL_Arrays.aReal1D[5]` (exercises `TwinCATSymbolPath` subscript
|
||||
rendering)
|
||||
- **Subscribe (native ADS notifications)** —
|
||||
`Driver_subscribe_receives_native_ADS_notifications_on_counter_changes`;
|
||||
observes `OnDataChange` firing within 10 s of subscribe
|
||||
- **Symbol browse (direct client path)** —
|
||||
`Driver_browses_committed_symbol_hierarchy_via_real_ADS` via
|
||||
`ITwinCATClient.BrowseSymbolsAsync`
|
||||
- **Symbol browse (through DiscoverAsync + `IAddressSpaceBuilder` pipeline)** —
|
||||
`DiscoverAsync_renders_declared_tags_and_controller_browse_hits_address_space_builder`
|
||||
verifies the real `TwinCAT/ → device/ → Discovered/` folder tree
|
||||
- **Auto-reconnect** — `Driver_auto_reconnects_after_underlying_client_is_disposed`
|
||||
disposes the `AdsClient` mid-flight; next read must re-establish
|
||||
- **Primitive type coverage** — `Driver_reads_every_primitive_type_with_correct_mapping`
|
||||
runs as a `[Theory]` against the 16 primitives in `GVL_Primitives`
|
||||
(Bool, SInt, USInt, Int, UInt, DInt, UDInt, LInt, ULInt, Real, LReal,
|
||||
String, Time, TimeOfDay, Date, DateTime) — asserts status + CLR type +
|
||||
seed value where ergonomic
|
||||
- **Bit-indexed BOOL** — `Driver_reads_bit_indexed_BOOL_from_word` against
|
||||
`GVL_Primitives.vWord.3` + `.4` (bits of `0xBEEF`)
|
||||
- **Nested UDT navigation** — `Driver_reads_deeply_nested_UDT_path` reads
|
||||
`GVL_Plant.Line1.Stations[1].Axes[1].Motor.Temperature` (LREAL) + `.Running` (BOOL)
|
||||
- **Multi-device routing + isolation** —
|
||||
`Driver_routes_reads_per_device_and_isolates_unreachable_peers` pairs the
|
||||
real runtime with a bogus AmsNetId; healthy device reads still succeed
|
||||
- **Probe loop + `IHostConnectivityProbe`** —
|
||||
`Probe_loop_raises_host_status_transition_to_Running_on_reachable_target`
|
||||
asserts `OnHostStatusChanged → Running` and snapshot parity
|
||||
- **Negative error mappings** —
|
||||
`Driver_reports_errors_for_unknown_tag_and_nonexistent_symbol_and_readonly_write`
|
||||
covers `BadNodeIdUnknown`, ghost-symbol communication errors, and the
|
||||
`BadNotWritable` short-circuit
|
||||
|
||||
All tests gate on `TWINCAT_TARGET_NETID` (required) via `[TwinCATFact]` /
|
||||
`[TwinCATTheory]`; `TWINCAT_TARGET_HOST` (default `localhost`) and
|
||||
`TWINCAT_TARGET_PORT` (default `851`) are optional overrides.
|
||||
|
||||
### Unit
|
||||
|
||||
@@ -65,54 +90,69 @@ vars are unset.
|
||||
- `TwinCATReadWriteTests` — read + write through the fake, status mapping
|
||||
- `TwinCATSymbolPathTests` — symbol-path routing for nested struct members
|
||||
- `TwinCATSymbolBrowserTests` — `ITagDiscovery.DiscoverAsync` via
|
||||
`ReadSymbolsAsync` (#188) + system-symbol filtering
|
||||
- `TwinCATNativeNotificationTests` — `AddDeviceNotification` (#189)
|
||||
registration, callback-delivery-to-`OnDataChange` wiring, unregister on
|
||||
unsubscribe
|
||||
`BrowseSymbolsAsync` + system-symbol filtering
|
||||
- `TwinCATNativeNotificationTests` — `AddDeviceNotification` registration,
|
||||
callback-delivery-to-`OnDataChange` wiring, unregister on unsubscribe
|
||||
- `TwinCATDriverTests` — `IDriver` lifecycle
|
||||
|
||||
Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
|
||||
`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
|
||||
`IPerCallHostResolver`.
|
||||
Capability surfaces whose contract is verified at the unit layer: `IDriver`,
|
||||
`IReadable`, `IWritable`, `ITagDiscovery`, `ISubscribable`,
|
||||
`IHostConnectivityProbe`, `IPerCallHostResolver`. The integration suite now
|
||||
verifies `ITagDiscovery` + `IHostConnectivityProbe` on the wire as well.
|
||||
|
||||
## Bugs caught by live runs
|
||||
|
||||
The integration suite surfaced three driver defects that `FakeTwinCATClient`
|
||||
couldn't, since each lived below the abstraction boundary:
|
||||
|
||||
1. **Notification cycle time unit** — `NotificationSettings(cycleTime, maxDelay)`
|
||||
takes **milliseconds** per Beckhoff InfoSys
|
||||
(`tcadsnetref/7313319051`), but the driver was multiplying by `10_000`
|
||||
under a "100 ns units" assumption. A requested 250 ms cycle was being set
|
||||
to ~41 minutes — subscribe never fired. Fix in `AdsTwinCATClient.AddNotificationAsync`.
|
||||
2. **`STRING(N)` / `WSTRING(N)` type mapper** — `MapSymbolTypeName` only
|
||||
matched bare `"STRING"` / `"WSTRING"`, so sized strings (the common case)
|
||||
fell off `BrowseSymbolsAsync` entirely. Fix: strip the `(…)` bound before
|
||||
the switch.
|
||||
3. **Bit-indexed BOOL path** — driver was sending `"GVL.vWord.3"` to ADS as
|
||||
a BOOL read. TwinCAT's symbol table doesn't expose bit-access paths; the
|
||||
read returned `DeviceSymbolNotFound`. Fix: strip the `.N` suffix, read
|
||||
the parent word as `uint`, extract the bit locally via `ExtractBit`.
|
||||
|
||||
All three paths are now pinned by live-wire tests.
|
||||
|
||||
## What it does NOT cover
|
||||
|
||||
### 1. AMS / ADS wire traffic
|
||||
### 1. AMS / ADS wire framing
|
||||
|
||||
No real AMS router frame is exchanged. Beckhoff's `TwinCAT.Ads` NuGet (their
|
||||
own .NET SDK, not libplctag-style OSS) has no in-process fake; tests stub
|
||||
the `ITwinCATClient` abstraction above it.
|
||||
No raw AMS packet is inspected. Beckhoff's `TwinCAT.Ads` NuGet (their own
|
||||
.NET SDK, not libplctag-style OSS) has no in-process fake at the frame
|
||||
level; tests run against a real router.
|
||||
|
||||
### 2. Multi-route AMS
|
||||
|
||||
ADS supports chained routes (`<localNetId> → <routerNetId> → <targetNetId>`)
|
||||
for PLCs behind an EC master / IPC gateway. Parse coverage exists; wire-path
|
||||
coverage doesn't.
|
||||
coverage is single-hop only.
|
||||
|
||||
### 3. Notification reliability under jitter
|
||||
### 3. Notification coalescing under jitter
|
||||
|
||||
`AddDeviceNotification` delivers at the runtime's cycle boundary; under high
|
||||
CPU load or network jitter real notifications can coalesce. The fake fires
|
||||
one callback per test invocation — real callback-coalescing behavior is
|
||||
untested.
|
||||
`AddDeviceNotification` delivers at the runtime's cycle boundary; under
|
||||
sustained CPU load or network jitter real notifications can coalesce. The
|
||||
live test only asserts at-least-one delivery within a generous window —
|
||||
coalescing behavior under stress isn't verified.
|
||||
|
||||
### 4. TC2 vs TC3 variant handling
|
||||
|
||||
TwinCAT 2 (ADS v1) and TwinCAT 3 (ADS v2) have subtly different
|
||||
`GetSymbolInfoByName` semantics + symbol-table layouts. Driver targets TC3;
|
||||
TC2 compatibility is not exercised.
|
||||
`GetSymbolInfoByName` semantics + symbol-table layouts. Driver + tests target
|
||||
TC3; TC2 compatibility is not exercised.
|
||||
|
||||
### 5. Cycle-time alignment for `ISubscribable`
|
||||
### 5. Alarms / history
|
||||
|
||||
Native ADS notifications fire on the PLC cycle boundary. The fake test
|
||||
harness assumes notifications fire on a timer the test controls;
|
||||
cycle-aligned firing under real PLC control is not verified.
|
||||
|
||||
### 6. Alarms / history
|
||||
|
||||
Driver doesn't implement `IAlarmSource` or `IHistoryProvider` — not in
|
||||
scope for this driver family. TwinCAT 3's TcEventLogger could theoretically
|
||||
back an `IAlarmSource`, but shipping that is a separate feature.
|
||||
Driver doesn't implement `IAlarmSource` or `IHistoryProvider` — not in scope
|
||||
for this driver family. TwinCAT 3's TcEventLogger could theoretically back
|
||||
an `IAlarmSource`, but shipping that is a separate feature.
|
||||
|
||||
## When to trust TwinCAT tests, when to reach for a rig
|
||||
|
||||
@@ -122,37 +162,25 @@ back an `IAlarmSource`, but shipping that is a separate feature.
|
||||
| "Does notification → `OnDataChange` wire correctly?" | yes (contract) | yes |
|
||||
| "Does symbol browsing filter TwinCAT internals?" | yes | yes |
|
||||
| "Does a real ADS read return correct bytes?" | no | yes (required) |
|
||||
| "Do notifications coalesce under load?" | no | yes (required) |
|
||||
| "Does auto-reconnect work on router restart?" | no (contract only) | yes (required) |
|
||||
| "Do notifications coalesce under sustained load?" | no | yes (required) |
|
||||
| "Does a TC2 PLC work the same as TC3?" | no | yes (required) |
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
1. **XAR VM live-population** — scaffolding is in place (this PR); the
|
||||
remaining work is operational: stand up the Hyper-V VM, install XAR,
|
||||
author the `.tsproj` per `TwinCatProject/README.md`, configure the
|
||||
bilateral ADS route, set `TWINCAT_TARGET_HOST` + `TWINCAT_TARGET_NETID`
|
||||
on the dev box. Then the three smoke tests transition skip → pass.
|
||||
Tracked as #221.
|
||||
2. **License-rotation automation** — XAR's 7-day trial expires on
|
||||
schedule. Either automate `TcActivate.exe /reactivate` via a
|
||||
scheduled task on the VM (not officially supported; reportedly works
|
||||
for some TC3 builds), or buy a paid runtime license (~$1k one-time
|
||||
per runtime per CPU) to kill the rotation. The doc at
|
||||
`TwinCatProject/README.md` §License rotation walks through both.
|
||||
3. **Lab rig** — cheapest IPC (CX7000 / CX9020) on a dedicated network;
|
||||
the only route that covers TC2 + real EtherCAT I/O timing + cycle
|
||||
jitter under CPU load.
|
||||
Deferred to v3 — see [`docs/v3/twincat-backlog.md`](../v3/twincat-backlog.md).
|
||||
Covers TC2 coverage, notification-coalescing-under-load, multi-hop AMS,
|
||||
license-rotation automation, and a dedicated lab IPC.
|
||||
|
||||
## Key fixture / config files
|
||||
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATXarFixture.cs`
|
||||
— TCP probe + skip-attributes + env-var parsing
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCAT3SmokeTests.cs`
|
||||
— three wire-level smoke tests
|
||||
— wire-level test suite (14 `[TwinCATFact]` + 16-case `[TwinCATTheory]`)
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md`
|
||||
— project spec + VM setup + license-rotation notes
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs` —
|
||||
in-process fake with the notification-fire harness used by
|
||||
`TwinCATNativeNotificationTests`
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs` — ctor takes
|
||||
`ITwinCATClientFactory`
|
||||
in-process fake with the notification-fire harness
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs` — ctor is
|
||||
`(TwinCATDriverOptions, string driverInstanceId, ITwinCATClientFactory? = null)`
|
||||
|
||||
@@ -174,7 +174,7 @@ Common contract for the proxy in the main server:
|
||||
|
||||
Named pipes default to allowing connections from any local user. Without explicit ACLs, any process on the host machine that knows the pipe name could connect, bypass the OPC UA server's authentication and authorization layers, and issue reads, writes, or alarm acknowledgments directly against the driver host. **This is a real privilege-escalation surface** — a service account with no OPC UA permissions could write field values it should never have access to. Every Tier C driver enforces the following:
|
||||
|
||||
1. **Pipe ACL**: the host creates the pipe with a `PipeSecurity` ACL that grants `ReadWrite | Synchronize` only to the OtOpcUa server's service principal SID. All other local users — including LocalSystem and Administrators — are explicitly denied. The ACL is set at pipe-creation time so it's atomic with the pipe being listenable.
|
||||
1. **Pipe ACL**: the host creates the pipe with a `PipeSecurity` ACL that grants `ReadWrite | Synchronize` only to the OtOpcUa server's service principal SID. `LocalSystem` is explicitly denied. The ACL is set at pipe-creation time so it's atomic with the pipe being listenable. Administrators are **not** added to the deny list — UAC's filtered token carries the Admins group SID as deny-only, so a deny ACE on Administrators would fire even for non-elevated callers whose user account happens to be a member (common on dev boxes). The per-connection SID check in §2 remains the authorization boundary.
|
||||
2. **Caller identity verification**: on each new pipe connection, the host calls `NamedPipeServerStream.GetImpersonationUserName()` (or impersonates and inspects the token) and verifies the connected client's SID matches the configured server service SID. Mismatches are logged and the connection is dropped before any RPC frame is read.
|
||||
3. **Per-message authorization context**: every RPC frame includes the operation's authenticated OPC UA principal (forwarded by the Core after it has done its own authn/authz). The host treats this as input only — the driver-level authorization (e.g. "is this principal allowed to write Tune attributes?") is performed by the Core, but the host's own audit log records the principal so post-incident attribution is possible.
|
||||
4. **No anonymous endpoints**: the heartbeat pipe has the same ACL as the data-plane pipe. There are no "open" pipes a generic client can probe.
|
||||
|
||||
@@ -6,7 +6,7 @@ enforces at driver init time. Every row cites the Fanuc FOCAS Developer
|
||||
Kit function whose documented input range determines the ceiling.
|
||||
|
||||
**Why this exists** — we have no FOCAS hardware on the bench and no
|
||||
working simulator. Fwlib32 returns `EW_NUMBER` / `EW_PARAM` when you
|
||||
working simulator. FWLIB (Fwlib64, or Fwlib32 on legacy deployments) returns `EW_NUMBER` / `EW_PARAM` when you
|
||||
hand it an address outside the controller's supported range; the
|
||||
driver would map that to a per-read `BadOutOfRange` at steady state.
|
||||
Catching at `InitializeAsync` with this matrix surfaces operator
|
||||
@@ -140,6 +140,6 @@ matrix: Macro variable #50000 is outside the documented range
|
||||
This validation closes the cheap half of the FOCAS hardware-free
|
||||
stability gap — config errors now fail at load instead of per-read.
|
||||
The expensive half is Tier-C process isolation so that a crashing
|
||||
`Fwlib32.dll` doesn't take the main OPC UA server down with it. See
|
||||
`Fwlib64.dll` doesn't take the main OPC UA server down with it. See
|
||||
[`docs/v2/implementation/focas-isolation-plan.md`](implementation/focas-isolation-plan.md)
|
||||
for that plan (task #220).
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Admin UI Phase 6 status audit (2026-04-24)
|
||||
|
||||
Audit pass that closes the Phase 6 Admin-UI tasks that were tracked as still-open (#128–#131) but already had their Blazor pages shipped. Every page listed below compiles against the current `OtOpcUaConfigDbContext` schema + the current Admin service surface, has substantive (non-stub) content, and is covered by `ZB.MOM.WW.OtOpcUa.Admin.Tests` (112/112 green).
|
||||
|
||||
## Task #128 — /hosts column refresh (Phase 6.1 Stream E.2/E.3)
|
||||
|
||||
`Components/Pages/Hosts.razor` — 233 LOC. Route `/hosts`. Ships:
|
||||
|
||||
- Per-driver circuit-breaker columns (`ConsecutiveFailures`, `LastCircuitBreakerOpenUtc`).
|
||||
- Stale-row detection via `HostStatusService.IsStale` (publisher heartbeat ≥ 30 s stale threshold).
|
||||
- Summary cards: Running / Stale / Faulted / total.
|
||||
- Auto-refresh every `RefreshIntervalSeconds` driven by the `FleetStatusHub` SignalR feed.
|
||||
- Health band via `DriverHostState` enum colour coding.
|
||||
|
||||
## Task #129 — RoleGrantsTab + AclsTab + Probe (Phase 6.2 Stream D)
|
||||
|
||||
- `Components/Pages/RoleGrants.razor` — 192 LOC. Route `/role-grants`. Edits LDAP-group → OPC-UA-role mappings with live reload over `AclChangeNotifier` SignalR.
|
||||
- `Components/Pages/Clusters/AclsTab.razor` — 279 LOC. NodeAcl CRUD + the **"Probe this permission"** form (task #196 slice 1, embedded at line 38 onward). Binds `_probeGroup` / `_probeNamespaceId` / `_probeUnsAreaId` / `_probeUnsLineId` / `_probeEquipmentId` / `_probeTagId` / `_probePermission` through `PermissionProbeService`.
|
||||
|
||||
## Task #130 — RedundancyTab (Phase 6.3 Stream E)
|
||||
|
||||
`Components/Pages/Clusters/RedundancyTab.razor` — 175 LOC. Topology table, per-peer reachability (via `FleetStatusHub`), ServiceLevel band + `ApplyLeaseRegistry` / `RecoveryStateManager` state surfaces, failover action button. Live updates over the same SignalR hub `RedundancyPublisherHostedService` ticks.
|
||||
|
||||
## Task #131 — Draft / publish / diff / identification (Phase 6.4 Streams A–D)
|
||||
|
||||
- `Components/Pages/Clusters/DraftEditor.razor` — 105 LOC. Route `/clusters/{ClusterId}/draft/{GenerationId:long}`. Calls `DraftValidationService` + `GenerationService`.
|
||||
- `Components/Pages/Clusters/Generations.razor` — 73 LOC. Publish flow (generation state transitions through `sp_PublishGeneration`).
|
||||
- `Components/Pages/Clusters/DiffViewer.razor` — 87 LOC. Route `/clusters/{ClusterId}/draft/{GenerationId:long}/diff`. Renders `sp_ComputeGenerationDiff` output.
|
||||
- `Components/Pages/Clusters/IdentificationFields.razor` — 49 LOC. OPC 40010 Identification folder editor bound to the `Equipment` entity.
|
||||
|
||||
## What's NOT in this audit
|
||||
|
||||
- `#124` — Phase 6.2 3-user interop matrix. Authz layer is now covered by `ThreeUserInteropMatrixTests` in `ZB.MOM.WW.OtOpcUa.Server.Tests` (drives the 5 GLAuth users + admin through `LdapUserAuthenticator` → `AuthorizationGate.IsAllowed` for the role × operation matrix). The wire-level OPC UA-client cross-vendor leg still needs a UserName-token endpoint policy + manual client drill — that part stays a manual deliverable.
|
||||
- `#119` — Phase 6.3 client interop matrix. Manual Ignition/Kepware/Aveva drills.
|
||||
- `#113` — OPC UA CTT conformance pass. Manual CTT run.
|
||||
- `#114` / `#115` — Redundancy cutover + deployment checklist. Manual.
|
||||
|
||||
Those remain GA-gating but require a human at a console, not a code change.
|
||||
@@ -0,0 +1,129 @@
|
||||
# Phase 3 Exit Gate — Driver Fleet (reconstructed retroactively)
|
||||
|
||||
> **Status**: **CLOSED (reconstructed 2026-04-23)**. The original plan split the
|
||||
> driver work across Phases 3 / 4 / 5 (Modbus alone → four PLC drivers → two
|
||||
> specialty drivers). In execution, all seven non-Galaxy drivers shipped under
|
||||
> one umbrella against `Core.Abstractions` + `Core`'s generic driver-hosting
|
||||
> machinery. This doc captures the closure retroactively; no forward work
|
||||
> remains under these three original phase numbers.
|
||||
>
|
||||
> **Plan doc**: none — phases 3/4/5 were intentionally not split out into
|
||||
> separate plan docs once it was clear the capability-interface contract
|
||||
> introduced in Phase 1 (`Core.Abstractions` — plan decision #4) was stable
|
||||
> enough that each driver could land as its own stream rather than as a
|
||||
> gated mini-phase. See `docs/v2/plan.md` §6 for the now-consolidated
|
||||
> migration strategy.
|
||||
|
||||
## Scope
|
||||
|
||||
All seven drivers in the v2 target list (Decision #5) minus Galaxy (closed
|
||||
separately under Phase 2). The Galaxy Proxy+Host+Shared split exited under
|
||||
`exit-gate-phase-2-final.md`; this gate does not re-cover it.
|
||||
|
||||
## What shipped
|
||||
|
||||
### Drivers
|
||||
|
||||
| Driver | Project | Capability surface | Test projects |
|
||||
|---|---|---|---|
|
||||
| Modbus TCP | `Driver.Modbus` + `Driver.Modbus.Cli` | `IDriver` + `ITagDiscovery` + `IReadable` + `IWritable` + `ISubscribable` + `IHostConnectivityProbe` | `Tests`, `IntegrationTests`, `Cli.Tests` |
|
||||
| AB CIP | `Driver.AbCip` + `Driver.AbCip.Cli` | all of the above + `IPerCallHostResolver` + `IAlarmSource` | `Tests`, `IntegrationTests`, `Cli.Tests` |
|
||||
| AB Legacy (PCCC / DF1) | `Driver.AbLegacy` + `Driver.AbLegacy.Cli` | `IDriver` + `IReadable` + `IWritable` + `ITagDiscovery` + `ISubscribable` + `IHostConnectivityProbe` + `IPerCallHostResolver` | `Tests`, `IntegrationTests`, `Cli.Tests` |
|
||||
| Siemens S7 | `Driver.S7` + `Driver.S7.Cli` | `IDriver` + `ITagDiscovery` + `IReadable` + `IWritable` + `ISubscribable` + `IHostConnectivityProbe` | `Tests`, `IntegrationTests`, `Cli.Tests` |
|
||||
| Beckhoff TwinCAT (ADS) | `Driver.TwinCAT` + `Driver.TwinCAT.Cli` | `IDriver` + `IReadable` + `IWritable` + `ITagDiscovery` + `ISubscribable` + `IHostConnectivityProbe` + `IPerCallHostResolver` | `Tests`, `IntegrationTests`, `Cli.Tests` |
|
||||
| FANUC FOCAS | `Driver.FOCAS` + `Driver.FOCAS.Host` + `Driver.FOCAS.Shared` + `Driver.FOCAS.Cli` | `IDriver` + `IReadable` + `IWritable` + `ITagDiscovery` + `ISubscribable` + `IHostConnectivityProbe` + `IPerCallHostResolver`; Tier-C out-of-process backend mirrors the Galaxy Proxy/Host split. `Fwlib64FocasBackend` shipped 2026-04-23 as the production backend (P/Invoke against `Fwlib64.dll`); Host retargeted from net48 x86 to net10.0-windows x64 at the same time. | `Tests`, `Host.Tests`, `Shared.Tests`, `Cli.Tests` |
|
||||
| OPC UA Client (gateway) | `Driver.OpcUaClient` | `IDriver` + `ITagDiscovery` + `IReadable` + `IWritable` + `ISubscribable` + `IHostConnectivityProbe` + `IAlarmSource` + `IHistoryProvider` (richest surface in the fleet — it's bridging another UA server) | `Tests`, `IntegrationTests` |
|
||||
|
||||
### Supporting infrastructure
|
||||
|
||||
| PR / Task | Summary |
|
||||
|---|---|
|
||||
| #248 | `DriverFactoryRegistry` + `DriverInstanceBootstrapper` — central DB `DriverInstance` rows materialise into live `IDriver` instances at server startup. |
|
||||
| #210 | Modbus server-side factory + seed SQL (closed first child of umbrella #209). |
|
||||
| #211 #212 #213 | AB CIP / S7 / AB Legacy server-side factories + seed SQL. |
|
||||
| #220 (FOCAS) | FOCAS factory wired into the bootstrap pipeline; Tier-C split (`Driver.FOCAS.Host` process launcher, named-pipe IPC, NSSM install scripts, post-mortem MMF) shipped across the five-PR series. |
|
||||
| (this session) | TwinCAT factory wired in + Server project reference added; all seven driver factories now register uniformly in `Server/Program.cs`. |
|
||||
| #249 #250 #251 | Per-driver test-client CLI suite (`otopcua-<driver>-cli`) — shared lib + one CLI per driver for direct-to-PLC smoke testing independent of the server. |
|
||||
| #253 + follow-ups | E2E CLI test scripts (`scripts/e2e/test-<driver>.ps1`) — five-stage bidirectional bridge + subscribe-sees-change assertions per driver, plus `test-all.ps1` matrix runner. |
|
||||
| (this session) | OPC UA Client e2e script shipped (`test-opcuaclient.ps1`, 8 stages) — the only driver that was missing an e2e script. |
|
||||
|
||||
### Docs
|
||||
|
||||
Per-driver test-fixture documentation:
|
||||
- `docs/drivers/Modbus-Test-Fixture.md`
|
||||
- `docs/drivers/AbServer-Test-Fixture.md` (covers AB CIP fixture)
|
||||
- `docs/drivers/AbLegacy-Test-Fixture.md`
|
||||
- `docs/drivers/S7-Test-Fixture.md`
|
||||
- `docs/drivers/TwinCAT-Test-Fixture.md`
|
||||
- `docs/drivers/FOCAS-Test-Fixture.md`
|
||||
- `docs/drivers/OpcUaClient-Test-Fixture.md`
|
||||
|
||||
Driver-level ops docs:
|
||||
- `docs/Driver.Modbus.Cli.md`, `docs/Driver.AbCip.Cli.md`, `docs/Driver.AbLegacy.Cli.md`, `docs/Driver.S7.Cli.md`, `docs/Driver.TwinCAT.Cli.md`, `docs/Driver.FOCAS.Cli.md`
|
||||
- `docs/v2/driver-specs.md` — unified capability-matrix spec for all eight drivers (Galaxy + seven).
|
||||
|
||||
## Compliance evidence
|
||||
|
||||
No dedicated `phase-3-compliance.ps1` exists — scope was too broad to fit the
|
||||
single-script pattern that worked for Phases 6.x and 7. Verification instead
|
||||
takes the form of the per-driver test suites + e2e scripts:
|
||||
|
||||
- [x] **Unit tests** — every driver has a `Tests` project with capability-interface contract tests; `dotnet test tests/ZB.MOM.WW.OtOpcUa.Driver.*.Tests` is green.
|
||||
- [x] **Integration tests** — `Driver.*.IntegrationTests` stands up Docker-hosted simulators (pymodbus, ab_server, python-snap7, opc-plc) at collection init and exercises real wire-level read/write/subscribe/probe per driver.
|
||||
- [x] **CLI tests** — `Driver.*.Cli.Tests` covers the per-driver test-client CLIs (#249–#251).
|
||||
- [x] **E2E scripts** — `scripts/e2e/test-<driver>.ps1` covers the driver-CLI → PLC → OtOpcUa server → OPC UA client round-trip for all seven drivers + Galaxy; `test-all.ps1` aggregates; README status section (rewritten this session) summarises live-boot evidence.
|
||||
- [x] **Factory registration** — all seven factories plus Galaxy register in `src/ZB.MOM.WW.OtOpcUa.Server/Program.cs` inside the `DriverFactoryRegistry` composition; the `DriverInstanceBootstrapper` can materialise any configured row.
|
||||
- [x] **Seed SQL** — #210–#213 provide per-driver Config DB seed scripts so a fresh Config DB is populatable without Admin UI interaction.
|
||||
|
||||
### Live-boot verification
|
||||
|
||||
Recorded across the session-level tracking tasks:
|
||||
|
||||
| Driver | Fixture | Stages | Tracking |
|
||||
|---|---|---|---|
|
||||
| Modbus | pymodbus (dl205 profile) | 5/5 | #209 exit gate; bidirectional + subscribe-sees-change added in #253 follow-ups |
|
||||
| AB CIP | `ab_server` ControlLogix | 5/5 | #220 |
|
||||
| S7 | python-snap7 | 5/5 | #220 |
|
||||
| AB Legacy | `ab_server` SLC500 / MicroLogix / PLC-5 (requires `/1,0` cip-path for Docker fixture) | 5/5 | #222 partial |
|
||||
| OPC UA Client | opc-plc Docker fixture | 5/8 (probe, remote read, forward bridge, subscribe, browse) | (this session) |
|
||||
| TwinCAT | TCBSD VM @ 10.100.0.128 (AmsNetId `41.169.163.43.1.1`) — real TwinCAT runtime under FreeBSD on ESXi; bypasses the Hyper-V/RTIME conflict that blocks XAR on this dev box | features validated | fixture is the TCBSD VM; `TWINCAT_TRUST_WIRE=1` still gates the e2e script by default so unintentional runs against cold fixtures don't false-pass |
|
||||
| FOCAS | Lab-rig CNC + `Fwlib64.dll` | — | **deferred** — `Fwlib64FocasBackend` shipped 2026-04-23; wire-level live-boot gated `FOCAS_TRUST_WIRE=1`, lab rig tracked under #222 follow-up |
|
||||
| Galaxy | Live Galaxy + `OtOpcUaGalaxyHost` (this dev box) | 7/7 (read / write / subscribe / alarms / history) | closed under Phase 2 |
|
||||
|
||||
## Deferred to post-gate follow-ups
|
||||
|
||||
Items intentionally not blocking closure of this umbrella — each is hardware-
|
||||
dependent and tracked separately:
|
||||
|
||||
- [ ] **FOCAS wire-level live-boot** — `test-focas.ps1` against a real CNC once `Fwlib64.dll` is on PATH and `FOCAS_TRUST_WIRE=1` (#222 follow-up). The `Fwlib64FocasBackend` shipped 2026-04-23 — code exists, unit-tests green; only the live-CNC smoke test remains.
|
||||
- [x] **FOCAS `Fwlib64FocasBackend`** — **CLOSED 2026-04-23**. The production backend in `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/Fwlib64FocasBackend.cs` wraps `FwlibFocasClient` to fulfil `IFocasBackend` against the licensed `Fwlib64.dll`. Host project retargeted to `net10.0-windows` x64. Default when `OTOPCUA_FOCAS_BACKEND` is unset. 6 new backend tests green. Only wire-level live-boot against real hardware remains — see item above.
|
||||
- [ ] **OPC UA Client stages 5/7/8** — reverse-bridge, alarm, history stages are opt-in via sidecar NodeId params because opc-plc's default image has no writable nodes and doesn't historize. Against a richer upstream (Prosys, UA Expert sample server) all eight stages can run.
|
||||
|
||||
## Completion checklist
|
||||
|
||||
- [x] Modbus driver shipped + unit + integration + CLI tests green
|
||||
- [x] AB CIP driver shipped + tests green + live-boot 5/5
|
||||
- [x] AB Legacy driver shipped + tests green + live-boot 5/5
|
||||
- [x] S7 driver shipped + tests green + live-boot 5/5
|
||||
- [x] TwinCAT driver shipped + tests green + features validated against the TCBSD VM virtual-PLC fixture
|
||||
- [x] FOCAS driver shipped (Tier-C split) + tests green (wire-live deferred)
|
||||
- [x] OPC UA Client driver shipped + tests green + live-boot 5/8
|
||||
- [x] `DriverFactoryRegistry` + `DriverInstanceBootstrapper` shipped
|
||||
- [x] All seven factories registered in `Server/Program.cs`
|
||||
- [x] Per-driver test-client CLI suite shipped
|
||||
- [x] E2E test scripts shipped + `test-all.ps1` aggregator green
|
||||
- [x] Per-driver test-fixture docs present
|
||||
- [x] `docs/v2/driver-specs.md` unified capability spec present
|
||||
- [x] `scripts/e2e/README.md` status section reflects current live-boot matrix
|
||||
- [x] Exit gate doc checked in (this file)
|
||||
- [x] TwinCAT validated against the TCBSD VM virtual-PLC fixture — `TWINCAT_TRUST_WIRE=1` + e2e script still gated by default to prevent false-pass against cold fixtures
|
||||
- [ ] FOCAS lab-rig follow-up filed + tracked (#222)
|
||||
|
||||
## Why no compliance script
|
||||
|
||||
The Phases 6.1/6.2/6.3/6.4/7 pattern of a single `phase-N-compliance.ps1`
|
||||
worked because each of those phases touched a narrow slice of server-side
|
||||
runtime. A "phase-3-compliance.ps1" would have had to boot seven simulators,
|
||||
configure seven DriverInstance rows, and run seven e2e scripts — which is
|
||||
exactly what `scripts/e2e/test-all.ps1` already does. The aggregate runner
|
||||
+ its README is the compliance artefact for this umbrella.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Phase 7 Exit Gate — Scripting, Virtual Tags, Scripted Alarms, Historian Sink
|
||||
|
||||
> **Status**: Open. Closed when every compliance check passes + every deferred item either ships or is filed as a post-v2-release follow-up.
|
||||
> **Status**: **FULLY CLOSED** 2026-04-23 audit — the three original follow-ups (#239 / #240 / #241) were all shipped under later branches but this exit-gate doc wasn't updated at the time. All three verified against the repo + tests green.
|
||||
>
|
||||
> **Compliance script**: `scripts/compliance/phase-7-compliance.ps1`
|
||||
> **Plan doc**: `docs/v2/implementation/phase-7-scripting-and-alarming.md`
|
||||
@@ -45,13 +45,13 @@ Covered by `scripts/compliance/phase-7-compliance.ps1`:
|
||||
- [x] Walker emits `NodeSourceKind.Virtual` + `NodeSourceKind.ScriptedAlarm` variables
|
||||
- [x] `DriverNodeManager` dispatch routes Reads by source; Writes to non-Driver rejected with `BadUserAccessDenied` (plan #6)
|
||||
|
||||
## Deferred to Post-Gate Follow-ups
|
||||
## Deferred to Post-Gate Follow-ups (all closed as of 2026-04-23 audit)
|
||||
|
||||
Kept out of the capstone so the gate can close cleanly while the less-critical wiring lands in targeted PRs:
|
||||
Originally kept out of the capstone so the gate could close cleanly. Each landed as a targeted follow-up PR; audit this session verified them against the repo:
|
||||
|
||||
- [ ] **SealedBootstrap composition root** (task #239) — instantiate `VirtualTagEngine` + `ScriptedAlarmEngine` + `SqliteStoreAndForwardSink` in `Program.cs`; pass `VirtualTagSource` + `ScriptedAlarmSource` as the new `IReadable` parameters on `DriverNodeManager`. Without this, the engines are dormant in production even though every piece is tested.
|
||||
- [ ] **Live OPC UA end-to-end smoke** (task #240) — Client.CLI browse + read a virtual tag computed by Roslyn; Client.CLI acknowledge a scripted alarm via the Part 9 method node; historian-disabled deployment returns `BadNotFound` for virtual nodes rather than silent failure.
|
||||
- [ ] **sp_ComputeGenerationDiff extension** (task #241) — emit Script / VirtualTag / ScriptedAlarm sections alongside the existing Namespace/DriverInstance/Equipment/Tag/NodeAcl rows so the Admin DiffViewer shows Phase 7 changes between generations.
|
||||
- [x] **SealedBootstrap composition root** (task #239) — **CLOSED**. `src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs` instantiates `VirtualTagEngine` + `ScriptedAlarmEngine` via `Phase7EngineComposer.Compose`, and `SqliteStoreAndForwardSink` in `ResolveHistorianSink` when a registered driver provides `IAlarmHistorianWriter` (today: `GalaxyProxyDriver`). `OpcUaServerService.ExecuteAsync` calls `Phase7Composer.PrepareAsync` then `OpcUaApplicationHost.SetPhase7Sources` **before** `applicationHost.StartAsync` so `OtOpcUaServer` + `DriverNodeManager` capture the `VirtualReadable` / `ScriptedAlarmReadable` at construction. 38 tests green under `tests/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/` + `SealedBootstrapIntegrationTests`. The work landed under the label "Phase 7 follow-up #246" and was never re-labelled against #239.
|
||||
- [x] **Live OPC UA end-to-end smoke** (task #240) — **CLOSED**. `scripts/e2e/test-phase7-virtualtags.ps1` drives a full Client.CLI read of a driver-sourced input, reads the VirtualTag computed off it, triggers a scripted alarm by writing the trigger value, and subscribes to the alarm condition — all through a running OtOpcUa server. Covered in `scripts/e2e/test-all.ps1` + `scripts/e2e/README.md` matrix.
|
||||
- [x] **sp_ComputeGenerationDiff extension** (task #241) — **CLOSED**. Migration `20260420232000_ExtendComputeGenerationDiffWithPhase7.cs` extends the stored proc to emit Script / VirtualTag / ScriptedAlarm sections alongside the existing NodeAcl / Tag / Equipment / DriverInstance / Namespace output. Admin DiffViewer picks them up through its existing section-plugin architecture (Phase 6.4 Stream C).
|
||||
|
||||
## Completion Checklist
|
||||
|
||||
@@ -66,9 +66,9 @@ Kept out of the capstone so the gate can close cleanly while the less-critical w
|
||||
- [x] `phase-7-compliance.ps1` present and passes
|
||||
- [x] Full solution `dotnet test` passes (no new failures beyond pre-existing tolerated CLI flake)
|
||||
- [x] Exit-gate doc checked in
|
||||
- [ ] `SealedBootstrap` composition follow-up filed + tracked
|
||||
- [ ] Live end-to-end smoke follow-up filed + tracked
|
||||
- [ ] `sp_ComputeGenerationDiff` extension follow-up filed + tracked
|
||||
- [x] `SealedBootstrap` composition follow-up shipped (#239 / Phase 7 follow-up #246)
|
||||
- [x] Live end-to-end smoke follow-up shipped (#240 — `scripts/e2e/test-phase7-virtualtags.ps1`)
|
||||
- [x] `sp_ComputeGenerationDiff` extension follow-up shipped (#241 — migration `ExtendComputeGenerationDiffWithPhase7`)
|
||||
|
||||
## How to run
|
||||
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
# FOCAS Tier-C isolation — plan for task #220
|
||||
|
||||
> **Status**: PRs A–E shipped. Architecture is in place; the only
|
||||
> remaining FOCAS work is the hardware-dependent production
|
||||
> integration of `Fwlib32.dll` into a real `IFocasBackend`
|
||||
> (`FwlibHostedBackend`), which needs an actual CNC on the bench
|
||||
> and is tracked as a follow-up on #220.
|
||||
> **Status**: **FULLY SHIPPED** (code). PRs A–E shipped the architecture; the
|
||||
> 2026-04-23 follow-up shipped the production `Fwlib64FocasBackend` wrapping
|
||||
> the licensed `Fwlib64.dll`. Only the wire-level live-boot against real
|
||||
> hardware remains (task #222 / requires a bench CNC).
|
||||
>
|
||||
> **Major update 2026-04-23 — Host retargeted to .NET 10 x64 + Fwlib64**:
|
||||
> Both `Fwlib32.dll` and `Fwlib64.dll` are licensed for this project. The
|
||||
> original plan put the Host on .NET 4.8 x86 because Fwlib32 was assumed.
|
||||
> With Fwlib64 available, the Host moves to `net10.0-windows` x64 — same
|
||||
> runtime as the rest of the fleet. **Tier-C isolation stays anyway** — the
|
||||
> blast-radius argument against a closed-source vendor P/Invoke is independent
|
||||
> of bitness. Galaxy (forced x86 by MXAccess COM) is a pure bitness forcing;
|
||||
> FOCAS is a pure blast-radius choice. Body of this document still reflects
|
||||
> the original x86 assumptions in a few places — read them as historical
|
||||
> design context; the current shape is in `docs/drivers/FOCAS-Test-Fixture.md`
|
||||
> and `exit-gate-phase-3.md`.
|
||||
>
|
||||
> **Pre-reqs shipped**: version matrix + pre-flight validation
|
||||
> (PR #168 — the cheap half of the hardware-free stability gap).
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
# FOCAS wire protocol — what's authoritative vs. what's guessed
|
||||
|
||||
Companion to [`focas-simulator-plan.md`](focas-simulator-plan.md). Written during
|
||||
Stream B on 2026-04-23 after a research pass through `strangesast/fwlib` +
|
||||
public FOCAS documentation. Purpose: separate what we *know* about the FOCAS
|
||||
wire protocol (can quote with confidence) from what we're *guessing* (will need
|
||||
Wireshark traces to validate in Stream C).
|
||||
|
||||
This document directly informs `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/server/`.
|
||||
|
||||
## Authoritative — from Fanuc's public `fwlib32.h`
|
||||
|
||||
The header file is distributed with the FOCAS Developer Kit and mirrored in OSS
|
||||
repos (notably `strangesast/fwlib`). The **struct layouts** documented there
|
||||
are stable across FOCAS versions and authoritative for the payload shapes our
|
||||
Python mock has to emit.
|
||||
|
||||
### ODBM — macro variable read buffer
|
||||
|
||||
```c
|
||||
typedef struct odbm {
|
||||
short datano; // macro variable number
|
||||
short dummy; // reserved / alignment padding
|
||||
long mcr_val; // 32-bit signed macro value
|
||||
short dec_val; // decimal-point count (0-9)
|
||||
} ODBM;
|
||||
```
|
||||
|
||||
With `#pragma pack(push, 4)` (the FOCAS default), total size is **10 bytes** on
|
||||
Windows: 2 + 2 + 4 + 2. Our `FwlibNative.cs` matches this exactly.
|
||||
|
||||
Our mock's `_READ_RESP_STRUCT = struct.Struct(">iH")` is **only 6 bytes** —
|
||||
missing `datano` + `dummy`. A real Fwlib decoding the scaffold response will
|
||||
read garbage. Stream C fix: prepend two `short` fields.
|
||||
|
||||
### IODBPSD — CNC parameter read/write buffer
|
||||
|
||||
```c
|
||||
typedef struct iodbpsd {
|
||||
short datano; // parameter number
|
||||
short type; // axis index (0 for non-axis parameters)
|
||||
union {
|
||||
char cdata;
|
||||
short idata;
|
||||
long ldata;
|
||||
char cdatas[MAX_AXIS]; // MAX_AXIS varies — 8 on 0i, 32 on 30i
|
||||
short idatas[MAX_AXIS];
|
||||
long ldatas[MAX_AXIS];
|
||||
} u;
|
||||
} IODBPSD;
|
||||
```
|
||||
|
||||
With `pack(4)` and `MAX_AXIS=8`, total size = 2 + 2 + 32 = **36 bytes**. Our
|
||||
`FwlibNative.cs` matches this (`[SizeConst = 32]` data buffer).
|
||||
|
||||
Our mock's current param handler doesn't return bytes in IODBPSD shape —
|
||||
response payload is just the raw value. Stream C fix: wrap in 4-byte header
|
||||
+ union-padded data.
|
||||
|
||||
### ODBST — status info
|
||||
|
||||
```c
|
||||
typedef struct odbst {
|
||||
short dummy; // reserved
|
||||
short tmmode; // Memory / Tape / MDI / EDIT / DNC
|
||||
short aut; // automatic mode
|
||||
short run; // running state
|
||||
short motion; // motion state
|
||||
short mstb; // M/S/T/B finish signal
|
||||
short emergency; // emergency stop
|
||||
short alarm; // alarm state
|
||||
short edit; // edit mode sub-state
|
||||
} ODBST;
|
||||
```
|
||||
|
||||
9 × short = **18 bytes**. Our mock already emits 18 bytes via
|
||||
`struct.Struct(">9h")`. ✓ correct.
|
||||
|
||||
### IODBPMC — PMC range read/write buffer
|
||||
|
||||
```c
|
||||
typedef struct iodbpmc {
|
||||
short type_a; // PMC address letter encoded as ADR_* numeric code
|
||||
short type_d; // data type: 0=byte, 1=word, 2=long, 4=float, 5=double
|
||||
unsigned short datano_s; // start address number
|
||||
unsigned short datano_e; // end address number
|
||||
union {
|
||||
char cdata[5];
|
||||
short idata[5];
|
||||
long ldata[5];
|
||||
float fdata[5];
|
||||
double dbdata[5];
|
||||
} u; // 40-byte union (widest = dbdata = 5×8 bytes)
|
||||
} IODBPMC;
|
||||
```
|
||||
|
||||
With `pack(4)` the union is 40 bytes; struct total = 8 + 40 = **48 bytes**.
|
||||
Our `FwlibNative.cs` matches this.
|
||||
|
||||
Our mock's PMC handler takes a different layout (uint16 handle + uint8 letter
|
||||
+ ...). Stream C fix: rewrite to IODBPMC shape.
|
||||
|
||||
## Reference trace findings (2026-04-23 dev-box reversing)
|
||||
|
||||
**Good news** — we don't need a bench CNC for first-pass reversing. Loading
|
||||
`Fwlib64.dll` in `otopcua-focas-cli` + pointing it at our Python simulator on
|
||||
`127.0.0.1:8193` + enabling `OTOPCUA_FOCAS_RAW_CAPTURE=1` on the sim lets us
|
||||
observe Fwlib's outbound bytes + iterate on reply shapes. Each cycle is ~5s;
|
||||
progress measure is "Fwlib sends more bytes before disconnecting".
|
||||
|
||||
### Confirmed wire facts
|
||||
|
||||
**Magic prefix** — every frame Fwlib sends begins with `0xA0 0xA0 0xA0 0xA0`
|
||||
(4 bytes). This is NOT a length prefix — our scaffold tried to decode it as
|
||||
uint32-big-endian = 2.7 GB and died. It's a fixed protocol marker.
|
||||
|
||||
**Handshake request** — `cnc_allclibhndl3` produces this 8-byte frame:
|
||||
|
||||
```
|
||||
a0 a0 a0 a0 00 01 01 01
|
||||
└─ magic ─┘ └── negotiation ──┘
|
||||
```
|
||||
|
||||
The 4-byte negotiation field is stable across our observations (always
|
||||
`00 01 01 01`). Interpretation TBD — possibly `(version_major=0x0001,
|
||||
version_minor=0x0101)` or `(protocol=0x01, subtype=0x010101)`.
|
||||
|
||||
**Handshake reply that Fwlib accepts** (empirically confirmed — doesn't
|
||||
disconnect):
|
||||
|
||||
```
|
||||
a0 a0 a0 a0 00 01 01 01 00 XX 00 YY
|
||||
└─ magic ─┘ └── echo ──┘ handle api_version
|
||||
```
|
||||
|
||||
12 bytes: magic + echoed negotiation + 2-byte handle + 2-byte api_version code.
|
||||
|
||||
### Post-handshake frame shape — decoded via drain mode
|
||||
|
||||
The simulator's `OTOPCUA_FOCAS_DRAIN_AFTER_HANDSHAKE=1` mode reads all inbound
|
||||
bytes for 1000 ms after the handshake reply without attempting any decode.
|
||||
Captured payload from `cnc_allclibhndl3`:
|
||||
|
||||
```
|
||||
00 02 00 02 a0 a0 a0 a0 00 01 21 01 00 00
|
||||
└── prefix ─┘ └── magic ─┘ └─── body ────┘
|
||||
4 bytes 4 bytes 6 bytes (total = 14 bytes)
|
||||
```
|
||||
|
||||
**Key discovery**: post-handshake frames have a **4-byte prefix BEFORE the
|
||||
magic**, not magic-first. Frame shape:
|
||||
|
||||
```
|
||||
uint16 msg_counter // starts at 2; handshake was #1 implicitly
|
||||
uint16 handle_echo // matches the handle our open reply returned
|
||||
4 bytes FOCAS_MAGIC // 0xA0A0A0A0
|
||||
N bytes body // function-specific
|
||||
```
|
||||
|
||||
Session 1's drain captured only the prefix (`00 02 00 01`) before timing
|
||||
out — TCP multiplexed the two test sessions's bytes differently. Session 2
|
||||
caught the full 14-byte frame.
|
||||
|
||||
### Body bytes — first post-handshake request
|
||||
|
||||
Body on `cnc_allclibhndl3` first post-handshake frame:
|
||||
|
||||
```
|
||||
00 01 21 01 00 00
|
||||
```
|
||||
|
||||
Informed guesses (unvalidated):
|
||||
|
||||
- `00 01` = body length (1 useful byte?) or sub-request count
|
||||
- `21 01` = function code / operation tag — `0x21` is seen in public FOCAS
|
||||
reverse-engineering notes associated with "system info" / "controller
|
||||
identification" queries
|
||||
- `00 00` = padding / reserved
|
||||
|
||||
Likely this is Fwlib's "tell me what CNC you are" query — part of
|
||||
`cnc_allclibhndl3`'s internal handshake continuation before the handle is
|
||||
fully established. Returning an empty or malformed response causes Fwlib
|
||||
to declare the far end "not a CNC" and error with `EW_FUNC` (16).
|
||||
|
||||
### Iteration 3 — echo response, error-code advances
|
||||
|
||||
Sending back `<prefix><magic><echoed body>` (14 bytes matching request shape)
|
||||
advances Fwlib's client-side error code from **`EW_-16` (socket-level)** to
|
||||
**`EW_-17` (protocol-level rejection)**. Fwlib reads our response in full
|
||||
before disconnecting with `peer closed mid-frame`.
|
||||
|
||||
Meaning: our **frame structure is correct enough** that Fwlib parses it as a
|
||||
valid FOCAS frame; the **body content** (the 6 bytes after magic) is where
|
||||
the semantic mismatch now lives. Fwlib expects specific bytes back for the
|
||||
`0x2101` system-info query and an echo doesn't match.
|
||||
|
||||
### Current iteration block
|
||||
|
||||
Going deeper without reference requires either:
|
||||
|
||||
- **A bench CNC** (#54) to capture a real response to the `0x2101` query.
|
||||
Stream C.2 Wireshark trace gives us the exact byte pattern Fwlib expects.
|
||||
- **Published FOCAS response specs** for sub-function `0x2101` — not present
|
||||
in `strangesast/fwlib` headers; likely only in the licensed Developer Kit
|
||||
binary docs.
|
||||
- **Blind enumeration** — try N variations of the 6-byte body response until
|
||||
Fwlib's error code changes again. High cost, low signal.
|
||||
|
||||
The first two are both blocked on resources we don't have. The third is
|
||||
~hundreds of cycles with no guarantee of convergence.
|
||||
|
||||
### Diminishing-returns checkpoint
|
||||
|
||||
**What we've proven without hardware**:
|
||||
1. Magic prefix `0xA0A0A0A0` confirmed
|
||||
2. Handshake request format decoded (`magic + 4-byte negotiation`)
|
||||
3. Handshake response format that Fwlib accepts (`magic + echo + handle + api`)
|
||||
4. Post-handshake frame format decoded (`prefix + magic + body`)
|
||||
5. First post-handshake function code observed (`0x2101` — likely system-info)
|
||||
6. Error code progression `EW_SOCKET` → `EW_PROTOCOL` confirms our framing is
|
||||
structurally correct
|
||||
|
||||
**What we can't prove without bench CNC or reference docs**:
|
||||
1. The exact 6-byte response body Fwlib expects for `0x2101`
|
||||
2. The full list of post-handshake function codes + their body shapes
|
||||
3. Whether subsequent frames use length prefixes or fixed body sizes
|
||||
|
||||
**Recommendation**: checkpoint here. The framing discoveries above are
|
||||
preserved in `server/frames.py` + `server/state.py` + `server/focas_server.py`
|
||||
+ `server/handlers/__init__.py`. When bench-CNC access unblocks Stream C.2's
|
||||
reference trace, the iteration loop (with the framing work already done)
|
||||
should converge in hours rather than days.
|
||||
|
||||
### Still unknown
|
||||
|
||||
- **Response shape** for the post-handshake body request — we can frame the
|
||||
prefix + magic correctly now, but what the 6-byte body response should
|
||||
carry (CNC series ID? version? capability flags?) needs further iteration.
|
||||
- **Function-id numeric values** for the 9 FWLIB calls our driver makes —
|
||||
one per call, need to be observed separately.
|
||||
- **Error encoding** on the wire.
|
||||
|
||||
### Next iteration cycles
|
||||
|
||||
With the handshake working, each subsequent function gets its own probe-and-observe
|
||||
loop. The simulator now has a `RAW_FRAME_MARKER = 0xFFFF` sentinel that lets a
|
||||
handler return exact wire bytes (bypassing the scaffold envelope) — use that to
|
||||
try different post-handshake replies and watch Fwlib's reaction.
|
||||
|
||||
## Stream C work order
|
||||
|
||||
Given what's authoritative vs. guessed, here's the most efficient path:
|
||||
|
||||
### Phase 1 — payload shapes (no hardware required)
|
||||
|
||||
- [ ] Rewrite `server/handlers/macro.py` response to return 10-byte ODBM:
|
||||
`short datano, short dummy, int32 mcr_val, short dec_val`
|
||||
- [ ] Rewrite `server/handlers/param.py` response to return 36-byte IODBPSD:
|
||||
`short datano, short type, bytes[32] u`
|
||||
- [ ] Rewrite `server/handlers/pmc.py` response to return 48-byte IODBPMC:
|
||||
`short type_a, short type_d, uint16 datano_s, uint16 datano_e, bytes[40] u`
|
||||
- [ ] Add unit tests asserting byte-exact sizes
|
||||
- [ ] Update validate_harness.py to match the new shapes
|
||||
|
||||
Effect: when Stream C gets its first Wireshark trace, the payload-layer of the
|
||||
mock is already correct. Only the framing layer needs iteration.
|
||||
|
||||
### Phase 2 — framing (requires hardware)
|
||||
|
||||
This is the iterative Wireshark loop — no point starting until the Windows rig
|
||||
+ licensed Fwlib64.dll + real CNC are all available. See the implementer's
|
||||
checklist in
|
||||
[`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/README.md`](../../../tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/README.md).
|
||||
|
||||
### Phase 3 — flip the C# test gate
|
||||
|
||||
Once Phase 2 proves Fwlib64 can talk to the mock:
|
||||
|
||||
- [ ] Flip `OTOPCUA_FOCAS_SIM_WIRE_COMPAT=1` in the CI env
|
||||
- [ ] Expand `tests/.../IntegrationTests/Series/WireCompatGatedTests.cs` with
|
||||
real per-series assertions
|
||||
- [ ] Update `scripts/e2e/test-focas.ps1` to accept `-ProfileName`
|
||||
- [ ] Close Stream D
|
||||
|
||||
## References
|
||||
|
||||
- [`src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs`](../../../src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibNative.cs) — P/Invoke surface, authoritative struct layouts
|
||||
- [`src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs`](../../../src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FwlibFocasClient.cs) — reference C# implementation of each FWLIB call
|
||||
- [`src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs`](../../../src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs) — EW_* → OPC UA status mapping
|
||||
- Fanuc FOCAS Developer Kit (licensed, not in repo) — ultimate source of truth
|
||||
- `strangesast/fwlib` on GitHub — redistributes `fwlib32.h` + runtime binaries; no wire protocol docs
|
||||
@@ -172,7 +172,7 @@ Lift the existing `GalaxyRuntimeProbeManager` into the new project. Behaviors pe
|
||||
#### Task B.6 — Named-pipe IPC server with mandatory ACL
|
||||
|
||||
Per decision #76 + `driver-stability.md` §"IPC Security":
|
||||
- Pipe ACL on creation: `ReadWrite | Synchronize` granted only to the OtOpcUa server's service principal SID; LocalSystem and Administrators **explicitly denied**
|
||||
- Pipe ACL on creation: `ReadWrite | Synchronize` granted only to the OtOpcUa server's service principal SID; LocalSystem **explicitly denied**. Administrators was dropped from the deny list so non-elevated admins on dev boxes aren't blocked via UAC-filtered-token deny-only semantics — the per-connection SID check (§2 of driver-stability.md) remains the real authorization boundary.
|
||||
- Caller identity verification on each new connection: `GetImpersonationUserName()` cross-checked against configured server service SID; mismatches dropped before any RPC frame is read
|
||||
- Per-process shared secret: passed by the supervisor at spawn time, required on first frame of every connection
|
||||
- Heartbeat pipe: separate from data-plane pipe, same ACL
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Phase 6.1 — Resilience & Observability Runtime
|
||||
|
||||
> **Status**: **SHIPPED** 2026-04-19 — Streams A/B/C/D + E data layer merged to `v2` across PRs #78-82. Final exit-gate PR #83 turns the compliance script into real checks (all pass) and records this status update. One deferred piece: Stream E.2/E.3 SignalR hub + Blazor `/hosts` column refresh lands in a visual-compliance follow-up PR on the Phase 6.4 Admin UI branch.
|
||||
> **Status**: **SHIPPED** 2026-04-19 — Streams A/B/C/D + E data layer merged to `v2` across PRs #78-82. Final exit-gate PR #83 turns the compliance script into real checks (all pass) and records this status update.
|
||||
>
|
||||
> **Stream E.2/E.3 closed 2026-04-23** — `FleetStatusPoller` now polls `DriverInstanceResilienceStatus`, detects per-`(DriverInstanceId, HostName)` deltas, and pushes `ResilienceStatusChangedMessage` via `FleetStatusHub` on the fleet group. Admin `/hosts` page subscribes on load and upserts the matching `HostStatusRow` in-memory on receipt, so operator-visible resilience state now reflects the runtime within one poller tick (~5 s) instead of the Admin page's own 10-second refresh. `FleetStatusPollerTests.Poller_pushes_ResilienceStatusChanged_on_delta` covers the first-observation push, the no-delta-no-push invariant, and the mutated-row re-push.
|
||||
>
|
||||
> Baseline: 906 solution tests → post-Phase-6.1: 1042 passing (+136 net). One pre-existing Client.CLI Subscribe flake unchanged.
|
||||
>
|
||||
@@ -129,7 +131,7 @@ Closes these gaps flagged in the 2026-04-19 audit:
|
||||
- [ ] Stream B: Tier registry + generalised watchdog + scheduled recycle + wedge detector
|
||||
- [ ] Stream C: `/healthz` + `/readyz` + structured logging + JSON Serilog sink
|
||||
- [ ] Stream D: LiteDB cache + Polly fallback in Configuration
|
||||
- [ ] Stream E: Admin `/hosts` page refresh
|
||||
- [x] Stream E: Admin `/hosts` page refresh (E.1 in PRs #78-82 with the data layer; E.2/E.3 closed 2026-04-23)
|
||||
- [ ] Cross-cutting: `phase-6-1-compliance.ps1` exits 0; full solution `dotnet test` passes; exit-gate doc recorded
|
||||
|
||||
## Adversarial Review — 2026-04-19 (Codex, thread `019da489-e317-7aa1-ab1f-6335e0be2447`)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
# Phase 6.2 — Authorization Runtime (ACL + LDAP grants)
|
||||
|
||||
> **Status**: **SHIPPED (core)** 2026-04-19 — Streams A, B, C (foundation), D (data layer) merged to `v2` across PRs #84-87. Final exit-gate PR #88 turns the compliance stub into real checks (all pass, 2 deferred surfaces tracked).
|
||||
> **Status**: **FULLY SHIPPED** (updated 2026-04-23 audit). Streams A-D core merged to `v2` across PRs #84-87 + exit-gate PR #88 on 2026-04-19; both named deferrals landed separately and were confirmed against the repo this session:
|
||||
>
|
||||
> Deferred follow-ups (tracked separately):
|
||||
> - Stream C dispatch wiring on the 11 OPC UA operation surfaces (task #143).
|
||||
> - Stream D Admin UI — RoleGrantsTab, AclsTab Probe-this-permission, SignalR invalidation, draft-diff ACL section + visual-compliance reviewer signoff (task #144).
|
||||
> - **Task #143 Stream C dispatch wiring** — `DriverNodeManager` calls `AuthorizationGate.IsAllowed(context.UserIdentity, OpcUaOperation.<Op>, scope)` on Read (line 249), Write (line 536) with per-classification `OpcUaOperation.WriteOperate` / `WriteTune` / `WriteConfigure` routed via `WriteAuthzPolicy`, and HistoryRead (4 call sites). `TriePermissionEvaluator` + `PermissionTrieCache` back the gate.
|
||||
> - **Task #144 Stream D Admin UI** — `RoleGrants.razor` (LDAP group → Admin role mapping) + `AclsTab.razor` (per-cluster node-ACL editor with a probe-this-permission surface via `PermissionProbeService`) + `AclChangeNotifier` SignalR hub for cache invalidation all present and wired.
|
||||
>
|
||||
> Baseline pre-Phase-6.2: 1042 solution tests → post-Phase-6.2 core: 1097 passing (+55 net). One pre-existing Client.CLI Subscribe flake unchanged.
|
||||
>
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
# Phase 6.3 — Redundancy Runtime
|
||||
|
||||
> **Status**: **SHIPPED (core)** 2026-04-19 — Streams B (ServiceLevelCalculator + RecoveryStateManager) and D core (ApplyLeaseRegistry) merged to `v2` in PR #89. Exit gate in PR #90.
|
||||
> **Status**: **SHIPPED (core + Stream C)** — original body merged 2026-04-19; audit 2026-04-23 promoted **Stream C (task #147)** into shipped state.
|
||||
>
|
||||
> Deferred follow-ups (tracked separately):
|
||||
> - Stream A — RedundancyCoordinator cluster-topology loader (task #145).
|
||||
> - Stream C — OPC UA node wiring: ServiceLevel + ServerUriArray + RedundancySupport (task #147).
|
||||
> - Stream E — Admin UI RedundancyTab + OpenTelemetry metrics + SignalR (task #149).
|
||||
> - Stream F — client interop matrix + Galaxy MXAccess failover test (task #150).
|
||||
> - sp_PublishGeneration pre-publish validator rejecting unsupported RedundancyMode values (task #148 part 2 — SQL-side).
|
||||
> **In** (verified in repo):
|
||||
> - Stream A — `ClusterTopologyLoader`, `RedundancyCoordinator`, `RedundancyTopology`, `PeerReachability` all present under `src/ZB.MOM.WW.OtOpcUa.Server/Redundancy/`. Coordinator is now also hosted by `Program.cs` via the new `RedundancyPublisherHostedService`, which calls `RefreshAsync` on startup.
|
||||
> - Stream B — `ServiceLevelCalculator` + `RecoveryStateManager`.
|
||||
> - **Stream C (task #147) — OPC UA node wiring**. `ServerRedundancyNodeWriter` maintains `Server.ServiceLevel` (i=2267), `Server.ServerRedundancy.RedundancySupport` (i=2994), and `Server.ServerRedundancy.ServerUriArray` (non-transparent subtype) by writing the `PropertyState.Value` + calling `ClearChangeMasks`. `RedundancyPublisherHostedService` drives the publisher on a 1 s tick and fans `OnStateChanged` / `OnServerUriArrayChanged` into the writer. Mapping of `Configuration.RedundancyMode` → Part 4 `RedundancySupport` is Warm/Hot/None (v2 clusters don't enumerate Cold / HotAndMirrored per decision #85). Idempotent per-value dedupe prevents spurious OPC UA notifications. Unit coverage: `ServerRedundancyNodeWriterTests` (4 tests, green).
|
||||
> - Stream D — `ApplyLeaseRegistry`.
|
||||
> - Stream E — `RedundancyTab.razor` with SignalR `RoleChanged` wiring (via `FleetStatusPoller` + `FleetStatusHub`) — stale-flag + role-swap banner.
|
||||
>
|
||||
> **Closed this session (2026-04-23)**:
|
||||
> - **Task #148 part 2** — `DraftValidator.ValidateClusterTopology(cluster, nodes)` now catches three pre-publish invariants the SQL CHECK can't see: (a) unsupported `NodeCount`/`RedundancyMode` pairs; (b) `Enabled`-node count vs. declared `NodeCount` mismatch (catches disabled-node drift with mode still Hot/Warm); (c) multiple-Primary per decision #84. Returns every failure in one pass — same shape as `Validate`. 8 new tests in `DraftValidatorTests` green.
|
||||
> - **Task #150 Stream F** — `docs/v2/redundancy-interop-playbook.md` captures the manual validation matrix against UaExpert + Kepware + AVEVA MXAccess failover. Automating these closed-source GUI clients in PR-CI is out of scope; the automatable half is already covered by `ServiceLevelCalculatorTests` / `RedundancyStatePublisherTests` / `ClusterTopologyLoaderTests` / `ServerRedundancyNodeWriterTests`.
|
||||
>
|
||||
> **Remaining (documented limitation, not blocking v2.0)**:
|
||||
> - Non-transparent redundancy-state node upgrade — the SDK's default `Server.ServerRedundancy` object is the base `ServerRedundancyState`, so `ApplyServerUriArray` currently logs-and-skips. Operators on the rare deployment that needs `ServerUriArray` read-back get a clear warning with the upgrade path. Documented in the interop playbook's "Known limitations" section.
|
||||
>
|
||||
> Baseline pre-Phase-6.3: 1097 solution tests → post-Phase-6.3 core: 1137 passing (+40 net).
|
||||
>
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
# Phase 6.4 — Admin UI Completion
|
||||
|
||||
> **Status**: **SHIPPED (data layer)** 2026-04-19 — Stream A.2 (UnsImpactAnalyzer + DraftRevisionToken) and Stream B.1 (EquipmentCsvImporter parser) merged to `v2` in PR #91. Exit gate in PR #92.
|
||||
> **Status**: **SHIPPED (mostly)** 2026-04-19; audit 2026-04-23 confirms what landed separately after the data-layer PR #91:
|
||||
>
|
||||
> Deferred follow-ups (Blazor UI + staging tables + address-space wiring):
|
||||
> - Stream A UI — UnsTab MudBlazor drag/drop + 409 concurrent-edit modal + Playwright smoke (task #153).
|
||||
> - Stream B follow-up — EquipmentImportBatch staging + FinaliseImportBatch transaction + CSV import UI (task #155).
|
||||
> - Stream C — DiffViewer refactor into base + 6 section plugins + 1000-row cap + SignalR paging (task #156).
|
||||
> - Stream D — IdentificationFields.razor + DriverNodeManager OPC 40010 sub-folder exposure (task #157).
|
||||
> **In** (verified in repo):
|
||||
> - **Task #153 Stream A UI** — `UnsTab.razor` with drag/drop handlers + concurrent-edit via `DraftRevisionToken` + `UnsImpactAnalyzer`; Playwright smoke test in `tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/UnsTabDragDropE2ETests.cs`.
|
||||
> - **Task #155 Stream B** — `EquipmentImportBatch` entity + migration, `EquipmentImportBatchService.CreateBatchAsync` / `FinaliseBatchAsync` / `DropBatchAsync` / `ListByUserAsync`, `ImportEquipment.razor` UI.
|
||||
> - **Task #156 Stream C** — `DiffViewer.razor` + `DiffSection.razor` refactor in place.
|
||||
> - Admin UI `IdentificationFields.razor` surface shipped (part of #157).
|
||||
>
|
||||
> **Closed this session (2026-04-23)**:
|
||||
> - **Task #157 Stream D server-side half** was a stale audit claim. `src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/IdentificationFolderBuilder.cs` ships the OPC 40010 Identification sub-folder materializer (Manufacturer / Model / SerialNumber / HardwareRevision / SoftwareRevision / YearOfConstruction / AssetLocation / ManufacturerUri / DeviceManualUri); `EquipmentNodeWalker.Walk` calls it per equipment; `IdentificationFolderBuilderTests` (158 lines) + two walker-level tests (`Walk_Materializes_Identification_Subfolder_When_AnyFieldPresent`, `Walk_Omits_Identification_Subfolder_When_AllFieldsNull`) cover the null-handling branches. The initial audit grepped only `src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/`; the builder lives in `Core/OpcUa/`.
|
||||
>
|
||||
> **Phase 6.4 is now FULLY SHIPPED — no deferred surfaces remain.**
|
||||
>
|
||||
> Baseline pre-Phase-6.4: 1137 solution tests → post-Phase-6.4 data layer: 1159 passing (+22).
|
||||
>
|
||||
|
||||
@@ -14,7 +14,7 @@ End-to-end validation that the Phase 7 production wiring chain (#243 / #244 / #2
|
||||
| SQL Server reachable, `OtOpcUaConfig` DB exists with all migrations applied | `sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "..." -Q "SELECT COUNT(*) FROM dbo.__EFMigrationsHistory"` returns ≥ 11 |
|
||||
| Server's `appsettings.json` `Node:ConfigDbConnectionString` matches your SQL Server | `cat src/ZB.MOM.WW.OtOpcUa.Server/appsettings.json` |
|
||||
|
||||
> **Galaxy.Host pipe ACL.** Per `docs/ServiceHosting.md`, the pipe ACL deliberately denies `BUILTIN\Administrators`. **Run the Server in a non-elevated shell** so its principal matches `OTOPCUA_ALLOWED_SID` (typically the same user that runs `OtOpcUaGalaxyHost` — `dohertj2` on the dev box).
|
||||
> **Galaxy.Host pipe ACL.** The pipe allows the configured `OTOPCUA_ALLOWED_SID` (typically the user that runs `OtOpcUaGalaxyHost` — `dohertj2` on the dev box). Run the Server under the same user; elevation doesn't matter — `PipeAcl.cs` no longer denies `BUILTIN\Administrators` since UAC's deny-only Admins SID would have blocked non-elevated dev-box admins too.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -36,11 +36,49 @@ sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" `
|
||||
|
||||
Expected output ends with `Phase 7 smoke seed complete.` plus a Cluster / Node / Generation summary. Idempotent — re-running wipes the prior smoke state and starts clean.
|
||||
|
||||
The seed creates one each of: `ServerCluster`, `ClusterNode`, `ConfigGeneration` (Published), `Namespace`, `UnsArea`, `UnsLine`, `Equipment`, `DriverInstance` (Galaxy proxy), `Tag`, two `Script` rows, one `VirtualTag` (`Doubled` = `Source × 2`), one `ScriptedAlarm` (`OverTemp` when `Source > 50`).
|
||||
The seed creates one each of: `ServerCluster`, `ClusterNode`, `ClusterNodeCredential` (binds the SQL login to the node — without this `sp_GetCurrentGenerationForCluster` returns `Unauthorized: caller X is not bound to NodeId p7-smoke-node`), `ConfigGeneration` (Published), `Namespace`, `UnsArea`, `UnsLine`, `Equipment`, `DriverInstance` (Galaxy proxy), `Tag`, two `Script` rows, one `VirtualTag` (`MachineStatus` = `Source > 0`, Boolean, historized), one `ScriptedAlarm` (`OverTemp` when `Source > 50`).
|
||||
|
||||
### 3. Replace the Galaxy attribute placeholder
|
||||
### 3. (Optional) Swap the Galaxy attribute
|
||||
|
||||
`scripts/smoke/seed-phase-7-smoke.sql` inserts a `dbo.Tag.TagConfig` JSON with `FullName = "REPLACE_WITH_REAL_GALAXY_ATTRIBUTE"`. Edit the SQL + re-run, or `UPDATE dbo.Tag SET TagConfig = N'{"FullName":"YourReal.GalaxyAttr","DataType":"Float64"}' WHERE TagId='p7-smoke-tag-source'`. Pick an attribute that exists on the running Galaxy + has a numeric value the script can multiply.
|
||||
The shipped seed points `dbo.Tag.TagConfig` at `TestMachine_001.TestHistoryValue` — the dev-box Galaxy ships it as Int32, writable (`security_classification = Operate`), and historized (`HistoryExtension` primitive), so every E2E stage has a real live target. To swap to another attribute on a different Galaxy, pick a candidate via the same shape:
|
||||
|
||||
```sql
|
||||
-- Run against the Galaxy Repository DB (ZB).
|
||||
;WITH dpc AS (
|
||||
SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
|
||||
FROM gobject g INNER JOIN package p ON p.package_id = g.deployed_package_id
|
||||
WHERE g.is_template = 0 AND g.deployed_package_id <> 0
|
||||
UNION ALL
|
||||
SELECT c.gobject_id, p.package_id, p.derived_from_package_id, c.depth + 1
|
||||
FROM dpc c INNER JOIN package p ON p.package_id = c.derived_from_package_id
|
||||
WHERE c.derived_from_package_id <> 0 AND c.depth < 10
|
||||
)
|
||||
SELECT DISTINCT g.tag_name + '.' + da.attribute_name AS full_ref,
|
||||
dt.description AS dtype, da.security_classification
|
||||
FROM dpc
|
||||
INNER JOIN dynamic_attribute da ON da.package_id = dpc.package_id
|
||||
INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id
|
||||
LEFT JOIN data_type dt ON dt.mx_data_type = da.mx_data_type
|
||||
WHERE da.attribute_name NOT LIKE '[_]%'
|
||||
AND da.attribute_name NOT LIKE '%.Description'
|
||||
AND da.mx_data_type IN (1, 2, 3, 4)
|
||||
AND da.security_classification > 0 -- writable
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM primitive_instance pi
|
||||
INNER JOIN primitive_definition pd
|
||||
ON pd.primitive_definition_id = pi.primitive_definition_id
|
||||
AND pd.primitive_name = 'HistoryExtension'
|
||||
WHERE pi.package_id = dpc.package_id AND pi.primitive_name = da.attribute_name)
|
||||
ORDER BY full_ref;
|
||||
```
|
||||
|
||||
Then update the seed:
|
||||
|
||||
```sql
|
||||
UPDATE dbo.Tag
|
||||
SET TagConfig = N'{"FullName":"YourReal.GalaxyAttr","DataType":"Int32"}'
|
||||
WHERE TagId = 'p7-smoke-tag-source';
|
||||
```
|
||||
|
||||
### 4. Point Server.appsettings at the smoke node
|
||||
|
||||
@@ -54,9 +92,38 @@ The seed creates one each of: `ServerCluster`, `ClusterNode`, `ConfigGeneration`
|
||||
}
|
||||
```
|
||||
|
||||
### 4a. (Optional) Enable LDAP + SecurityProfile for the write stage
|
||||
|
||||
Anonymous OPC UA sessions are denied writes against `Operate`-classified tags by the PR 26 server-layer classification gate. To exercise the reverse-bridge + alarm-fires stages fully, the Server has to advertise a `UserName` UserTokenPolicy (any profile other than `None`) and authenticate against LDAP.
|
||||
|
||||
```json
|
||||
{
|
||||
"OpcUa": {
|
||||
"SecurityProfile": "Basic256Sha256-Sign",
|
||||
"Ldap": {
|
||||
"Enabled": true,
|
||||
"Server": "localhost",
|
||||
"Port": 3893,
|
||||
"SearchBase": "dc=lmxopcua,dc=local",
|
||||
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
|
||||
"ServiceAccountPassword": "serviceaccount123",
|
||||
"GroupToRole": {
|
||||
"ReadOnly": "ReadOnly",
|
||||
"WriteOperate": "WriteOperate",
|
||||
"WriteTune": "WriteTune",
|
||||
"WriteConfigure": "WriteConfigure",
|
||||
"AlarmAck": "AlarmAck"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Dev-box GLAuth ships `writeop` / `writeop123` in the `WriteOperate` group, `admin` / `admin123` across all write groups. See `C:\publish\glauth\auth.md`.
|
||||
|
||||
## Run
|
||||
|
||||
### 5. Start the Server (non-elevated shell)
|
||||
### 5. Start the Server
|
||||
|
||||
```powershell
|
||||
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server
|
||||
@@ -82,27 +149,39 @@ Any line missing = follow up the failure surface (each step has its own log sign
|
||||
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- browse -u opc.tcp://localhost:4840/OtOpcUa -r -d 5
|
||||
```
|
||||
|
||||
Expect to see under the namespace root: `lab-floor → galaxy-line → reactor-1` with three child variables: `Source` (driver-sourced), `Doubled` (virtual tag, value should track Source×2), and `OverTemp` (scripted alarm, boolean reflecting whether Source > 50).
|
||||
Expect to see under the namespace root: `lab-floor → galaxy-line → reactor-1` with three child variables: `Source` (driver-sourced Int32), `MachineStatus` (virtual tag Boolean, `Source > 0`), and `OverTemp` (scripted alarm Boolean, `Source > 50`). NodeIds are path-based per OPC UA Part 3 §5.2.2 — the walker mints them from `{driverId}/{folder-path}/{browseName}` and stores the driver-side FullReference in an internal NodeId→FullRef map, so client subscriptions survive backend address renames.
|
||||
|
||||
#### Read the virtual tag
|
||||
|
||||
```powershell
|
||||
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840/OtOpcUa -n "ns=2;s=p7-smoke-vt-derived"
|
||||
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- read `
|
||||
-u opc.tcp://localhost:4840/OtOpcUa `
|
||||
-n "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/MachineStatus"
|
||||
```
|
||||
|
||||
Expected: a `Float64` value approximately equal to `2 × Source`. Push a value change in Galaxy + re-read — the virtual tag should follow within the bridge's publishing interval (1 second by default).
|
||||
Expected: `Boolean`. Push a value change into the Source Galaxy attribute and re-read — `MachineStatus` should follow within the bridge's publishing interval (1 second by default).
|
||||
|
||||
#### Read the scripted alarm
|
||||
|
||||
```powershell
|
||||
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840/OtOpcUa -n "ns=2;s=p7-smoke-al-overtemp"
|
||||
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- read `
|
||||
-u opc.tcp://localhost:4840/OtOpcUa `
|
||||
-n "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/OverTemp"
|
||||
```
|
||||
|
||||
Expected: `Boolean` — `false` when Source ≤ 50, `true` when Source > 50.
|
||||
|
||||
#### Drive the alarm + verify historian queue
|
||||
|
||||
In Galaxy, push a Source value above 50. Within ~1 second, `OverTemp.Read` flips to `true`. The alarm engine emits a transition to `Phase7EngineComposer.RouteToHistorianAsync` → `SqliteStoreAndForwardSink.EnqueueAsync` → drain worker (every 2s) → `GalaxyHistorianWriter.WriteBatchAsync` → Galaxy.Host pipe → Aveva Historian alarm schema.
|
||||
Push a Source value above 50 — either from Galaxy itself, or via the Server's OPC UA write path using LDAP credentials (step 4a). Within ~1 second, `OverTemp.Read` flips to `true`. The alarm engine emits a transition to `Phase7EngineComposer.RouteToHistorianAsync` → `SqliteStoreAndForwardSink.EnqueueAsync` → drain worker (every 2s) → `GalaxyHistorianWriter.WriteBatchAsync` → Galaxy.Host pipe → Aveva Historian alarm schema.
|
||||
|
||||
```powershell
|
||||
# OPC UA write path — requires LDAP from step 4a + a writeop-class user.
|
||||
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- write `
|
||||
-u opc.tcp://localhost:4840/OtOpcUa -S sign `
|
||||
-n "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/Source" `
|
||||
-v 75 -U writeop -P writeop123
|
||||
```
|
||||
|
||||
Verify the queue absorbed the event:
|
||||
|
||||
@@ -120,14 +199,32 @@ Open the Historian Client (or InTouch alarm summary) — the `OverTemp` activati
|
||||
|
||||
- [ ] EF migrations applied through `20260420232000_ExtendComputeGenerationDiffWithPhase7`
|
||||
- [ ] Smoke seed completes without errors + creates exactly 1 Published generation
|
||||
- [ ] Server starts in non-elevated shell + logs the Phase 7 composition lines
|
||||
- [ ] Client.CLI browse shows the UNS tree with Source / Doubled / OverTemp under reactor-1
|
||||
- [ ] Read on `Doubled` returns `2 × Source` value
|
||||
- [ ] Server starts + logs the Phase 7 composition lines
|
||||
- [ ] Client.CLI browse shows the UNS tree with Source / MachineStatus / OverTemp under reactor-1
|
||||
- [ ] Read on `Source` returns a Good-quality Int32 value (proves MXAccess round-trip)
|
||||
- [ ] Read on `MachineStatus` returns the live boolean truth of `Source > 0`
|
||||
- [ ] Read on `OverTemp` returns the live boolean truth of `Source > 50`
|
||||
- [ ] Pushing Source past 50 in Galaxy flips `OverTemp` to `true` within 1 s
|
||||
- [ ] `test-galaxy.ps1 -Username writeop -Password writeop123` drives Source past 50 and flips `OverTemp` to `true` within 1 s
|
||||
- [ ] SQLite queue drains (`COUNT(*)` returns to 0 within 2 s of an alarm transition)
|
||||
- [ ] Historian shows the `OverTemp` activation event with the rendered message
|
||||
|
||||
## Second-run evidence (2026-04-24 dev box)
|
||||
|
||||
Full live stack ran end-to-end once the IPC unblocks (commit `d11dd05`), path-based NodeIds (commit `8be82e0`), cold-start engine guards (commit `69e1d32`), and seed retarget to `TestMachine_001.TestHistoryValue` (commit `ec1a590`) landed. Anonymous `scripts/e2e/test-galaxy.ps1` run reaches 3/7:
|
||||
|
||||
```
|
||||
[PASS] source NodeId readable (Galaxy pipe → proxy → server → client chain up)
|
||||
[PASS] source value = System.Byte[]
|
||||
[INFO] BadUserAccessDenied — attribute's Galaxy-side ACL blocks writes for this session.
|
||||
```
|
||||
|
||||
The `INFO` stage is correct behaviour — Source is `Operate`-classified and the anonymous session carries no LDAP roles. The Virtual-tag / Subscribe / Alarm / History stages stay at `[FAIL]` for two further environmental reasons once write is unblocked:
|
||||
|
||||
1. `TestMachine_001.TestHistoryValue` is driven by whatever Galaxy code runs on the object — idle in the default dev-box state, so no subscription pushes fire.
|
||||
2. Historian writes require the Aveva Historian SDK to accept the alarm schema event — dev box doesn't have that path live.
|
||||
|
||||
Running `./test-galaxy.ps1 -Username writeop -Password writeop123` with step 4a's LDAP + `SecurityProfile = Basic256Sha256-Sign` applied unblocks the reverse-bridge + alarm-fires stages. The virtual-tag, subscribe, and history stages depend on further deployment choices (pick an attribute Galaxy is actively writing to, wire Aveva Historian SDK).
|
||||
|
||||
## First-run evidence (2026-04-20 dev box)
|
||||
|
||||
Ran the smoke against the live dev environment. Captured log signatures prove the Phase 7 wiring chain executes in production:
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
# Modbus tag-addressing reference
|
||||
|
||||
Foundational doc for the Modbus addressing grammar shipped across #136–#144.
|
||||
Covers the address-string parser (`ModbusAddressParser`) that the wire driver
|
||||
and the Admin UI both consume, the per-tag suffix modifiers, and the family-
|
||||
native branch.
|
||||
|
||||
## Grammar
|
||||
|
||||
```
|
||||
<region><offset>[.<bit>][:<type>[<len>]][:<order>][:<count>]
|
||||
```
|
||||
|
||||
Each field is optional from left to right; the parser fills defaults.
|
||||
|
||||
### Region + offset
|
||||
|
||||
Three accepted forms — pick whichever matches your tag spreadsheet's
|
||||
convention. All three resolve to the same `(Region, ushort PduOffset)`
|
||||
on the wire.
|
||||
|
||||
| Form | Example | Means |
|
||||
|---|---|---|
|
||||
| Modicon 5-digit | `40001` | Holding register 1 (PDU 0) |
|
||||
| Modicon 6-digit | `400001` | Holding register 1 (PDU 0); supports up to `465536` (PDU 65535) |
|
||||
| Mnemonic | `HR1`, `IR1`, `C100`, `DI1` | Same regions; `1`-based register number |
|
||||
|
||||
Modicon leading-digit → region:
|
||||
|
||||
| Digit | Region | OPC UA wire FC |
|
||||
|---|---|---|
|
||||
| `0` | Coils | FC01 / FC05 / FC15 |
|
||||
| `1` | DiscreteInputs | FC02 (read-only) |
|
||||
| `3` | InputRegisters | FC04 (read-only) |
|
||||
| `4` | HoldingRegisters | FC03 / FC06 / FC16 |
|
||||
|
||||
### Bit suffix `.N`
|
||||
|
||||
`40001.5` = bit 5 (LSB-first) of HR[0]. Implies `DataType=BitInRegister`;
|
||||
mixing with an explicit type or array-count is rejected.
|
||||
|
||||
### Type code `:T`
|
||||
|
||||
Codes verified 2026-04-25 against [Wonderware DASMBTCP user
|
||||
guide](https://cdn.logic-control.com/media/DASMBTCP.pdf) and the
|
||||
[Ignition Modbus addressing
|
||||
manual](https://www.docs.inductiveautomation.com/docs/8.1/ignition-modules/opc-ua/opc-ua-drivers/modbus/modbus-addressing).
|
||||
The `I` / `UI` / `I_64` / `UI_64` / `BCD_32` shapes match Wonderware's
|
||||
suffix convention and Ignition's underscore-N prefix variants where
|
||||
those vendors agree.
|
||||
|
||||
| Code | Type | Registers | Vendor reference |
|
||||
|---|---|---|---|
|
||||
| `BOOL` | Boolean | 1 (region must be Coils / DiscreteInputs) | universal |
|
||||
| `S` | Int16 | 1 | Wonderware DASMBTCP `S` = 16-bit signed |
|
||||
| `US` | UInt16 | 1 | Ignition `HRUS` = Unsigned Short |
|
||||
| `I` | Int32 | 2 | Wonderware DASMBTCP `I` = 32-bit signed; Ignition `HRI` |
|
||||
| `UI` | UInt32 | 2 | Ignition `HRUI` |
|
||||
| `I_64` | Int64 | 4 | Ignition `HRI_64` |
|
||||
| `UI_64` | UInt64 | 4 | Ignition `HRUI_64` |
|
||||
| `F` | Float32 | 2 | Wonderware `F`; Ignition `HRF` |
|
||||
| `D` | Float64 | 4 | Ignition `HRD` |
|
||||
| `BCD` | 16-bit BCD | 1 | Ignition `HRBCD` |
|
||||
| `BCD_32` | 32-bit BCD | 2 | Ignition `HRBCD_32` |
|
||||
| `STR<len>` | ASCII string, `len` chars (2 chars / register) | `ceil(len/2)` | analogous to Ignition `HRS<addr>:<len>` |
|
||||
|
||||
Default when omitted:
|
||||
- Coils / DiscreteInputs → `BOOL`
|
||||
- HoldingRegisters / InputRegisters → `S` (Int16) — matches Ignition's bare-`HR` default
|
||||
|
||||
**Codes removed in #146** (silent wrong-data risk, never compatible with the
|
||||
two reference vendors): `:DI`, `:L`, `:UDI`, `:UL`, `:LI`, `:ULI`, `:LBCD`.
|
||||
Pre-#146 configs that use these get a clear "Unknown type code" diagnostic at
|
||||
parse time; rewrite to the post-#146 codes per the table above.
|
||||
|
||||
### Byte order `:O`
|
||||
|
||||
| Mnemonic | Meaning | Wire |
|
||||
|---|---|---|
|
||||
| `ABCD` | Big-endian (Modbus spec default) | `[A,B,C,D]` |
|
||||
| `CDAB` | Word swap (Siemens, several AB) | `[C,D,A,B]` |
|
||||
| `BADC` | Byte swap (legacy little-endian-internal devices) | `[B,A,D,C]` |
|
||||
| `DCBA` | Full reverse (some EtherNet/IP gateways) | `[D,C,B,A]` |
|
||||
|
||||
For 8-byte values (Int64 / Float64) the same labels apply pairwise.
|
||||
|
||||
### Array count `:N`
|
||||
|
||||
`40001:F:5` = `Float32[5]` (consumes HR[0..9]). Array + bit suffix is
|
||||
rejected. Strings are not arrays.
|
||||
|
||||
### Composition
|
||||
|
||||
The 3-field shorthand `40001:F:5` is parsed as `(type=F, count=5)` because
|
||||
`5` isn't a valid byte-order mnemonic. Use the explicit 4-field form
|
||||
`40001:F:CDAB:5` when you need a non-default order.
|
||||
|
||||
## Family-native syntax (#144)
|
||||
|
||||
When the driver instance has `Family != Generic`, the parser tries the
|
||||
family's native syntax FIRST, then falls back to Modicon / mnemonic.
|
||||
|
||||
### DL205 (AutomationDirect DirectLOGIC)
|
||||
|
||||
| Form | Example | Mapping |
|
||||
|---|---|---|
|
||||
| `Vnnnn` (octal) | `V2000` | HoldingRegisters[1024] (octal 2000 = decimal 1024) |
|
||||
| `Ynn` (octal) | `Y17` | Coils[2048 + 15] (Y-output base + offset) |
|
||||
| `Cnn` (octal) | `C100` | Coils[3072 + 64] (C-relay base + offset) |
|
||||
| `Xnn` (octal) | `X17` | DiscreteInputs[15] |
|
||||
| `SPnn` (octal) | `SP10` | DiscreteInputs[1024 + 8] |
|
||||
|
||||
**Cross-family ambiguity**: `C100` means Coils[99] under `Generic`
|
||||
(mnemonic) but Coils[3136] under `DL205`. Per-driver Family choice
|
||||
disambiguates.
|
||||
|
||||
### MELSEC (Mitsubishi)
|
||||
|
||||
| Form | Example | Mapping (sub-family Q_L_iQR / F_iQF) |
|
||||
|---|---|---|
|
||||
| `Dnnn` (decimal) | `D100` | HoldingRegisters[100] |
|
||||
| `Mnnn` (decimal) | `M50` | Coils[50] |
|
||||
| `Xnn` | `X20` | DiscreteInputs[32 hex / 16 octal] |
|
||||
| `Ynn` | `Y20` | Coils[32 hex / 16 octal] |
|
||||
|
||||
X / Y digit interpretation depends on `MelsecSubFamily`:
|
||||
- `Q_L_iQR` → hex (default)
|
||||
- `F_iQF` → octal
|
||||
|
||||
Bank-base offsets default to 0 in the grammar string. Sites with non-zero
|
||||
"Modbus Device Assignment" bases use the structured tag form.
|
||||
|
||||
## Driver-instance options
|
||||
|
||||
Beyond per-tag addressing, `ModbusDriverOptions` exposes (#139–#143):
|
||||
|
||||
### Connection (#139)
|
||||
- `KeepAlive { Enabled, Time, Interval, RetryCount }` — TCP-level probes.
|
||||
Defaults match the historical PR 53 wire output (Enabled=true, Time=30s,
|
||||
Interval=10s, RetryCount=3).
|
||||
- `IdleDisconnectTimeout` — proactively close + reconnect after this much
|
||||
socket idle time. Default null = disabled.
|
||||
- `Reconnect { InitialDelay, MaxDelay, BackoffMultiplier }` — geometric
|
||||
backoff for the post-drop reconnect loop. Default
|
||||
`(0, 30s, 2.0)` = immediate first retry, geometric thereafter.
|
||||
|
||||
### Protocol (#140)
|
||||
- `MaxCoilsPerRead` (default 2000) — separate cap for FC01/FC02 coil reads.
|
||||
- `UseFC15ForSingleCoilWrites` — force FC15 (write multiple coils
|
||||
qty=1) for single-coil writes. Safety/audit PLCs may require this.
|
||||
- `UseFC16ForSingleRegisterWrites` — same for FC16 vs FC06.
|
||||
- `DisableFC23` — kill switch for FC23 (currently unused; reserved).
|
||||
|
||||
### Subscribe (#141)
|
||||
- Per-tag `Deadband` — suppress sub-threshold publishes on numeric tags.
|
||||
- `WriteOnChangeOnly` (driver-level) — short-circuit identical-value
|
||||
writes. Cache invalidates on read-divergence.
|
||||
|
||||
### Multi-unit (#142)
|
||||
- Per-tag `UnitId` — overrides the driver-level UnitId in the MBAP
|
||||
header. Required for one-Ethernet-gateway / N-RTU-slave deployments.
|
||||
- `IPerCallHostResolver.ResolveHost` returns `host:port/unitN` per tag so
|
||||
per-PLC circuit breakers fire per slave.
|
||||
- Per-tag `CoalesceProhibited` — escape hatch for #143's planner (read
|
||||
this tag in isolation regardless of `MaxReadGap`).
|
||||
|
||||
### Block-read coalescing (#143)
|
||||
- `MaxReadGap` (default 0 = off) — gap budget the planner is willing to
|
||||
bridge between adjacent register tags. With `MaxReadGap=10`, three tags
|
||||
at HR 100/102/110 collapse into one FC03 of quantity 11.
|
||||
|
||||
## JSON DTO shape
|
||||
|
||||
The factory accepts both the structured form (legacy) and the new
|
||||
`AddressString` form per-tag. Mix freely — newer pasted rows use the
|
||||
grammar string; legacy rows keep the structured fields.
|
||||
|
||||
```json
|
||||
{
|
||||
"host": "10.1.2.3",
|
||||
"port": 502,
|
||||
"unitId": 1,
|
||||
"family": "DL205",
|
||||
"keepAlive": { "enabled": true, "timeMs": 30000, "intervalMs": 10000, "retryCount": 3 },
|
||||
"idleDisconnectMs": 120000,
|
||||
"reconnect": { "initialDelayMs": 0, "maxDelayMs": 30000, "backoffMultiplier": 2.0 },
|
||||
"maxCoilsPerRead": 2000,
|
||||
"writeOnChangeOnly": false,
|
||||
"maxReadGap": 8,
|
||||
"tags": [
|
||||
{ "name": "Temp", "addressString": "V2000:F:CDAB" },
|
||||
{ "name": "Setpoint", "addressString": "40001:I" },
|
||||
{ "name": "Outputs", "addressString": "Y0:5" },
|
||||
{ "name": "AlarmCount", "region": "HoldingRegisters", "address": 200, "dataType": "Int16", "deadband": 5.0 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Vendor compatibility caveat
|
||||
|
||||
The exact spelling of type codes (e.g. `I` vs `INT`, `BCD` vs `L_BCD`) and
|
||||
the byte-order mnemonics were synthesised from training-era vendor docs
|
||||
(Wonderware DASMBTCP, Kepware KEPServerEX, Ignition, Matrikon, OAS).
|
||||
Before locking the grammar for a production deployment, verify against
|
||||
the current Kepware "Modbus Ethernet Driver Help" PDF and Ignition's
|
||||
"Modbus Addressing" user-manual page — if a critical tool's mnemonics
|
||||
have shifted, add aliases in `ModbusAddressParser.TryParseType` rather
|
||||
than asking users to rewrite spreadsheets.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Multi-host dispatch — per-PLC circuit breakers
|
||||
|
||||
Phase 6.1 decision #144 / task #135. Motivation: a single DriverInstance that fronts N PLCs (Modbus with multiple slaves, AB CIP with multiple ControlLogix chassis, etc.) must not let one dead PLC trip the resilience breaker for its healthy siblings.
|
||||
|
||||
This note documents the shipped contract so future driver authors don't re-derive it.
|
||||
|
||||
## Contract
|
||||
|
||||
The resilience pipeline keys on `(DriverInstanceId, HostName, DriverCapability)`. One dead PLC opens only the pipeline keyed on its HostName; healthy sibling PLCs keep their own pipelines intact.
|
||||
|
||||
Three participants:
|
||||
|
||||
1. **`DriverResiliencePipelineBuilder.GetOrCreate(driverInstanceId, hostName, capability, options)`** — the pipeline cache. First call per key builds a Polly pipeline (timeout → retry → breaker). Subsequent calls return the cached instance. Covered by `DriverResiliencePipelineBuilderTests.Pipeline_IsIsolated_PerHost`.
|
||||
|
||||
2. **`CapabilityInvoker.ExecuteAsync(capability, hostName, callSite, ct)`** — takes `hostName` per-call. Threads it straight through to the pipeline builder. Covered by `CapabilityInvokerTests`.
|
||||
|
||||
3. **`IPerCallHostResolver.ResolveHost(fullReference)`** — an optional interface a multi-device driver implements. `DriverNodeManager.ResolveHostFor` calls it on every capability dispatch so the host flowing into the invoker comes from the tag's per-PLC metadata, not the driver instance. Single-device drivers don't implement it — `DriverNodeManager` falls back to `DriverInstanceId` as the hostname, which still flows through the same `(instance, host, capability)` key shape (one pipeline per single-device instance).
|
||||
|
||||
End-to-end `dead PLC, healthy PLC` scenario proven by `PerCallHostResolverDispatchTests.DeadPlc_DoesNotOpenBreaker_For_HealthyPlc_With_Resolver`.
|
||||
|
||||
## Driver author checklist
|
||||
|
||||
To light up per-PLC circuit breakers on a multi-device driver:
|
||||
|
||||
1. **Options model** — extend the driver's options type with an explicit device list. See `AbCipDriverOptions.Devices : IReadOnlyList<AbCipDeviceConfig>`.
|
||||
2. **Tag → device mapping** — parse the tag's `DeviceId` from `TagConfig`. The driver's per-tag definition records the device HostAddress alongside the wire address. See `AbCipTagDefinition.DeviceHostAddress`.
|
||||
3. **`IPerCallHostResolver`** — implement it on the driver. `ResolveHost(fullReference)` looks up the tag's definition and returns the device HostAddress. Unknown references should return a deterministic fallback (e.g. the first configured device's host) rather than throw — the invoker handles the mislookup at capability level when the actual read surfaces `BadNodeIdUnknown`.
|
||||
4. **Health surface** — `IHostConnectivityProbe.GetHostStatuses()` returns one `HostConnectivityStatus` per configured device so the Admin UI fleet page lights the per-PLC status distinctly.
|
||||
5. **Transport per device** — one network connection per PLC, serialized per device via `SemaphoreSlim` (or equivalent). Do not share a transport across PLCs; the breaker-isolation guarantee disappears if they share a queue.
|
||||
|
||||
## Current fleet status (2026-04-24)
|
||||
|
||||
| Driver | Per-tag device | `IPerCallHostResolver` | Per-PLC breaker isolation |
|
||||
|---|---|---|---|
|
||||
| AB CIP | ✅ `DeviceId` | ✅ | ✅ live |
|
||||
| AB Legacy | 1 device / instance | — (not needed) | trivial |
|
||||
| Modbus | 1 device / instance today | — | trivial — multi-device refactor tracked separately |
|
||||
| S7 | 1 device / instance today | — | trivial — same |
|
||||
| TwinCAT | 1 device / instance today | — | trivial — same |
|
||||
| FOCAS | 1 CNC / instance | — (not needed) | trivial |
|
||||
| Galaxy | 1 Galaxy Host / instance | — (not needed) | trivial — Host recycle runs per instance |
|
||||
| OPC UA Client | 1 upstream / instance | — (not needed) | trivial |
|
||||
|
||||
"Trivial" above means the pipeline key ends up as `(DriverInstanceId, DriverInstanceId, capability)` via `DriverNodeManager.ResolveHostFor`'s fallback — one pipeline per driver instance, which is correct for single-device drivers.
|
||||
|
||||
Extending Modbus / S7 / TwinCAT to multi-device follows the AB CIP template verbatim; it's per-driver surgery (schema row + options model + resolver implementation + transport fan-out) rather than shared-infrastructure work.
|
||||
+19
-13
@@ -689,7 +689,7 @@ Galaxy.Proxy ──→ Galaxy.Shared ←── Galaxy.Host
|
||||
|
||||
**Decided:**
|
||||
- Mono-repo (Decision #31 above).
|
||||
- `Core.Abstractions` is **internal-only for now** — no standalone NuGet. Keep the contract mutable while the first 8 drivers are being built; revisit publishing after Phase 5 when the shape has stabilized. Design the contract *as if* it will eventually be public (no leaky types, stable names) to minimize churn later.
|
||||
- `Core.Abstractions` is **internal-only for now** — no standalone NuGet. Keep the contract mutable while the first 8 drivers are being built; revisit publishing after the driver fleet (originally Phase 5, folded into the Phase 3 umbrella — see exit gate) once the shape has stabilized. Design the contract *as if* it will eventually be public (no leaky types, stable names) to minimize churn later.
|
||||
|
||||
---
|
||||
|
||||
@@ -742,24 +742,30 @@ Each step leaves the system runnable. The generic extraction is effectively free
|
||||
10. **Build `Galaxy.Proxy`** — .NET 10 in-process proxy implementing IDriver interfaces, forwarding over IPC
|
||||
11. **Validate parity** — v2 Galaxy driver must pass the same integration tests as v1
|
||||
|
||||
**Phase 3 — Modbus TCP driver (prove the abstraction)**
|
||||
12. **Build `Driver.ModbusTcp`** — NModbus, config-driven tags from central DB, internal poll loop, device-as-folder hierarchy
|
||||
13. **Add Modbus config screens to Admin** (first driver-specific config UI)
|
||||
**Phase 3 — Driver fleet (all seven non-Galaxy drivers) — ✅ CLOSED 2026-04-23** (see [`implementation/exit-gate-phase-3.md`](implementation/exit-gate-phase-3.md))
|
||||
|
||||
**Phase 4 — PLC drivers**
|
||||
14. **Build `Driver.AbCip`** — libplctag, ControlLogix/CompactLogix symbolic tags + Admin config screens
|
||||
15. **Build `Driver.AbLegacy`** — libplctag, SLC 500/MicroLogix file-based addressing + Admin config screens
|
||||
16. **Build `Driver.S7`** — S7netplus, Siemens S7-300/400/1200/1500 + Admin config screens
|
||||
17. **Build `Driver.TwinCat`** — Beckhoff.TwinCAT.Ads v6, native ADS notifications, symbol upload + Admin config screens
|
||||
Originally split across Phase 3 (Modbus alone), Phase 4 (PLC drivers), and
|
||||
Phase 5 (specialty drivers). In execution, once `Core.Abstractions` had
|
||||
stabilised under Phase 1 + Phase 2, each driver landed as its own stream
|
||||
rather than as a gated mini-phase; the phase numbers were folded into a
|
||||
single umbrella. Shipped:
|
||||
|
||||
**Phase 5 — Specialty drivers**
|
||||
18. **Build `Driver.Focas`** — FANUC FOCAS2 P/Invoke, pre-defined CNC tag set, PMC/macro config + Admin config screens
|
||||
19. **Build `Driver.OpcUaClient`** — OPC UA client gateway/aggregation, namespace remapping, subscription proxying + Admin config screens
|
||||
12. **`Driver.Modbus`** — NModbus, config-driven tags, internal poll loop, device-as-folder hierarchy (umbrella closure #210)
|
||||
13. **`Driver.AbCip`** — libplctag, ControlLogix/CompactLogix symbolic tags (#211, live-booted under #220)
|
||||
14. **`Driver.AbLegacy`** — libplctag, SLC 500 / MicroLogix / PLC-5 file-based addressing (#213, live-booted under #222)
|
||||
15. **`Driver.S7`** — S7netplus, Siemens S7-300/400/1200/1500 (#212, live-booted under #220)
|
||||
16. **`Driver.TwinCAT`** — Beckhoff.TwinCAT.Ads v7, native ADS notifications, symbol upload (factory wired 2026-04-23; wire-live deferred, #221)
|
||||
17. **`Driver.FOCAS`** — FANUC FOCAS2 P/Invoke via Tier-C out-of-process `Driver.FOCAS.Host` (#220 five-PR split; wire-live deferred, #222 follow-up)
|
||||
18. **`Driver.OpcUaClient`** — OPC UA client gateway / aggregation, namespace remapping, subscription proxying (scaffold #66; live-boot 5/8 stages via `test-opcuaclient.ps1`)
|
||||
|
||||
Supporting infrastructure: `DriverFactoryRegistry` + `DriverInstanceBootstrapper`
|
||||
(#248); per-driver test-client CLI suite (#249–#251); e2e test scripts with
|
||||
aggregate runner (#253); server-side factory + seed SQL per driver (#210–#213).
|
||||
|
||||
**Decided:**
|
||||
- **Parity test for Galaxy**: existing v1 IntegrationTests suite + scripted Client.CLI walkthrough (see Section 4 above).
|
||||
- **Timeline**: no hard deadline. Each phase ships when it's right — tests passing, Galaxy parity bar met. Quality cadence over calendar cadence.
|
||||
- **FOCAS SDK**: license already secured. Phase 5 can proceed as scheduled; `Fwlib64.dll` available for P/Invoke.
|
||||
- **FOCAS SDK**: license already secured. FOCAS driver shipped as part of the Phase 3 umbrella with Tier-C host; `Fwlib64.dll` available for P/Invoke (wire-level live-boot gated on lab rig, #222 follow-up).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Redundancy Interop Playbook (Phase 6.3 Stream F — task #150)
|
||||
|
||||
> **Scope**: manual validation that third-party OPC UA clients + AVEVA MXAccess
|
||||
> observe our non-transparent redundancy signals (ServiceLevel, ServerUriArray,
|
||||
> RedundancySupport) and fail over to the Backup node when the Primary drops.
|
||||
>
|
||||
> **Why manual**: the third-party clients named here are Windows-GUI binaries
|
||||
> (UaExpert, Kepware QuickClient) or embedded inside AVEVA System Platform.
|
||||
> Automating any of them into PR-CI is out of scope for v2. This playbook
|
||||
> captures the minimal dev-box-plus-VM setup and the expected pass criteria so
|
||||
> the work can be executed repeatably at v2 release readiness and after any
|
||||
> Phase 6.3 follow-up change.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Two `OtOpcUa.Server` nodes in one `ServerCluster`:
|
||||
- Declared as `NodeCount = 2`, `RedundancyMode = Hot` (or `Warm`).
|
||||
- Each with a distinct `ApplicationUri` (enforced by unique index per
|
||||
decision #86).
|
||||
- Each node's `StaticRoutes.xml` points at the other (`ServerCluster.Node[].Host`).
|
||||
2. `scripts/install/Install-Services.ps1` applied on each node so the
|
||||
`RedundancyPublisherHostedService` is running.
|
||||
3. At least one `DriverInstance` with a reachable simulator or PLC so both
|
||||
servers have a non-empty address space to browse.
|
||||
4. On the client host:
|
||||
- `UaExpert` ≥ 1.7 installed
|
||||
- Kepware `ClientAce QuickClient` (or equivalent) — optional, for a second
|
||||
client
|
||||
5. For the AVEVA leg: a `Galaxy.Host` running against an MXAccess deployment
|
||||
with an external OPC UA client object pointed at the cluster (not at a
|
||||
single node).
|
||||
|
||||
## Expected signals on a running cluster
|
||||
|
||||
| Node | `ServiceLevel` | `RedundancySupport` | `ServerUriArray` |
|
||||
|---|---|---|---|
|
||||
| Primary, healthy, peer reachable | 200 | `Hot` (or `Warm`) | self + peer |
|
||||
| Primary, mid-apply | 75 (`PrimaryMidApply`) | same | same |
|
||||
| Primary, peer UNreachable | 150 (`PrimaryPeerDown`) | same | same |
|
||||
| Backup, healthy | 100 (`Secondary`) | same | same |
|
||||
| Either, dwelling in recovery | 50 (`Recovering`) | same | same |
|
||||
| Either, invariant violation (two Primary, disabled-node mismatch) | 2 (`InvalidTopology`) | same | same |
|
||||
|
||||
(The band constants live in `ServiceLevelCalculator.Classify`.)
|
||||
|
||||
## Test matrix
|
||||
|
||||
Each row is one manual run; pass criterion in the right column.
|
||||
|
||||
### Block A — UA protocol signals (UaExpert)
|
||||
|
||||
| # | Scenario | Procedure | Pass criterion |
|
||||
|---|---|---|---|
|
||||
| A1 | ServiceLevel published | Connect UaExpert to Primary. Browse to `Server.ServerStatus.ServiceLevel`. | Value = 200 (or the expected Band byte per table above) |
|
||||
| A2 | ServiceLevel updates on peer down | Connect to Primary. Stop Backup (`sc stop OtOpcUa`). Watch `ServiceLevel`. | Transitions 200 → 150 within ~2 s of peer probe timeout |
|
||||
| A3 | RedundancySupport | Browse to `Server.ServerRedundancy.RedundancySupport`. | Value matches the declared `RedundancyMode` (Warm / Hot / None) |
|
||||
| A4 | ServerUriArray (non-transparent upgrade) | Requires a redundancy-object-type upgrade follow-up. | When upgrade lands: `ServerUriArray` reports both ApplicationUris, self first |
|
||||
| A5 | Mid-apply dip | On Primary trigger a `sp_PublishGeneration` apply. | `ServiceLevel` drops to 75 for the apply duration + dwell |
|
||||
|
||||
### Block B — Client failover
|
||||
|
||||
| # | Scenario | Procedure | Pass criterion |
|
||||
|---|---|---|---|
|
||||
| B1 | UaExpert picks Primary by ServiceLevel | In UaExpert configure a Redundancy Group with both endpoint URLs. | Client picks the Primary URL (higher ServiceLevel) |
|
||||
| B2 | UaExpert cuts over on Primary kill | Kill the Primary's `OtOpcUa` service. | Client session reconnects to Backup within UaExpert's reconnect timeout (default 5 s). Data-change monitored items resume. |
|
||||
| B3 | UaExpert cuts back when Primary returns | Start the Primary service. Wait ≥ recovery dwell (see `RecoveryStateManager.DwellTime`). | `ServiceLevel` on returning Primary goes through 50 (Recovering) → 200; UaExpert may or may not switch back (client-policy dependent; both are accepted outcomes) |
|
||||
| B4 | Kepware QuickClient failover | Repeat B1–B3 with Kepware in place of UaExpert. | Same pass criteria; establishes we're not UaExpert-specific |
|
||||
|
||||
### Block C — Galaxy MXAccess failover
|
||||
|
||||
This block validates that an AVEVA System Platform app consuming our cluster
|
||||
via MXAccess tolerates a Primary drop the same way a native OPC UA client does.
|
||||
The MXAccess toolkit internally wraps the OPC UA Client and does its own
|
||||
redundancy negotiation; we're asserting that negotiation honors our
|
||||
`ServiceLevel` signal.
|
||||
|
||||
| # | Scenario | Procedure | Pass criterion |
|
||||
|---|---|---|---|
|
||||
| C1 | Galaxy binds to Primary on first connect | Bring the cluster up. Start a Galaxy `$MxAccessClient` object pointed at the cluster with both node URLs. | Galaxy reports `QUALITY = Good` + initial values from the Primary |
|
||||
| C2 | Galaxy redirects on Primary drop | Stop the Primary. | Galaxy's `QUALITY` briefly goes `Uncertain`, then back to `Good`; values continue streaming from the Backup within MXAccess's `ReconnectInterval` (default 20 s) |
|
||||
| C3 | Galaxy handles mid-apply dip | Trigger a generation apply on the Primary. | Galaxy continues reading — the mid-apply dip is advertisory (ServiceLevel 75), not a session drop; MXAccess should stay bound |
|
||||
|
||||
## Recording results
|
||||
|
||||
Copy the tables above into a tracking doc per run. The tracking doc shape:
|
||||
|
||||
```
|
||||
Run date: 2026-MM-DD
|
||||
Cluster: <id> Primary: <node> Backup: <node> Release: <sha>
|
||||
A1: PASS evidence: UaExpert screenshot uaexpert-a1.png
|
||||
A2: PASS evidence: ServiceLevel trend grafana-a2.png
|
||||
…
|
||||
```
|
||||
|
||||
One pass of every row is the acceptance criterion. Re-run after any Phase 6.3
|
||||
follow-up ships (especially the non-transparent redundancy-type upgrade, which
|
||||
flips A4 from "deferred" to "expected pass").
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **A4 pending**: `Server.ServerRedundancy` on our current SDK build lands as
|
||||
the base `ServerRedundancyState`, which has no `ServerUriArray` child.
|
||||
`ServerRedundancyNodeWriter.ApplyServerUriArray` logs-and-skips until the
|
||||
redundancy-object-type upgrade follow-up lands.
|
||||
- **Recovery dwell default**: `RecoveryStateManager.DwellTime` defaults to 60 s
|
||||
in `Program.cs`. Adjust via future config knob if B3 takes too long to
|
||||
observe.
|
||||
- **C-block external dependency**: The `Galaxy.Host` side of the redundancy
|
||||
story is largely out of our code — it's MXAccess's own client-redundancy
|
||||
policy talking to our published ServiceLevel. A negative result on C1-C3
|
||||
does not necessarily indicate an OtOpcUa bug; cross-check with UaExpert
|
||||
(Block A / B) first.
|
||||
|
||||
## Automation notes (why this is a playbook, not a test)
|
||||
|
||||
- UaExpert and Kepware binaries are closed-source Windows GUIs; they don't
|
||||
ship headless CLIs for the browse/connect/subscribe flows.
|
||||
- The OPC Foundation reference SDK *can* drive every scenario, but our own
|
||||
`Driver.OpcUaClient` tests already cover that client's behaviour; Block B
|
||||
adds value specifically because these two clients have independent
|
||||
redundancy implementations we don't control.
|
||||
- For the sub-set of scenarios that *can* be automated — the self-loopback
|
||||
case where our own `otopcua-cli` drives Primary + Backup — the existing
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Server.Tests/RedundancyStatePublisherTests` +
|
||||
`ServiceLevelCalculatorTests` (unit) + `ClusterTopologyLoaderTests`
|
||||
(integration) already cover the math + data path. The wire-level assertion
|
||||
that the values actually land on the right OPC UA nodes is covered by
|
||||
`ServerRedundancyNodeWriterTests`.
|
||||
@@ -1,7 +1,7 @@
|
||||
# v2 Release Readiness
|
||||
|
||||
> **Last updated**: 2026-04-19 (all three release blockers CLOSED — Phase 6.3 Streams A/C core shipped)
|
||||
> **Status**: **RELEASE-READY (code-path)** for v2 GA — all three code-path release blockers are closed. Remaining work is manual (client interop matrix, deployment checklist signoff, OPC UA CTT pass) + hardening follow-ups; see exit-criteria checklist below.
|
||||
> **Last updated**: 2026-04-24 (Phase 5 driver complement closed — AB CIP, AB Legacy, TwinCAT, FOCAS all shipped; FOCAS Tier-C retired for a pure-managed in-process client)
|
||||
> **Status**: **RELEASE-READY (code-path)** for v2 GA. All three original code-path release blockers remain closed. Phase 5 is now complete. Remaining work is manual (live-hardware validations, client interop matrix, deployment checklist signoff, OPC UA CTT pass) + hardening follow-ups; see exit-criteria checklist below.
|
||||
|
||||
This doc is the single view of where v2 stands against its release criteria. Update it whenever a deferred follow-up closes or a new release blocker is discovered.
|
||||
|
||||
@@ -14,67 +14,78 @@ This doc is the single view of where v2 stands against its release criteria. Upd
|
||||
| Phase 2 — Galaxy driver split (Proxy/Host/Shared) | ✓ | Shipped |
|
||||
| Phase 3 — OPC UA server + LDAP + security profiles | ✓ | Shipped |
|
||||
| Phase 4 — Redundancy scaffold (entities + endpoints) | ✓ | Shipped (runtime closes in 6.3) |
|
||||
| Phase 5 — Drivers | ⚠ partial | Galaxy / Modbus / S7 / OpcUaClient shipped; AB CIP / AB Legacy / TwinCAT / FOCAS deferred (task #120) |
|
||||
| Phase 6.1 — Resilience & Observability | ✓ | **SHIPPED** (PRs #78–83) |
|
||||
| Phase 6.2 — Authorization runtime | ◐ core | **SHIPPED (core)** (PRs #84–88); dispatch wiring + Admin UI deferred |
|
||||
| Phase 6.3 — Redundancy runtime | ◐ core | **SHIPPED (core)** (PRs #89–90); coordinator + UA-node wiring + Admin UI + interop deferred |
|
||||
| Phase 6.4 — Admin UI completion | ◐ data layer | **SHIPPED (data layer)** (PRs #91–92); Blazor UI + OPC 40010 address-space wiring deferred |
|
||||
| Phase 5 — Drivers | ✓ | **Shipped** — Galaxy, Modbus (+ DL205/S7/MELSEC profiles), S7 native, OPC UA Client, AB CIP, AB Legacy, TwinCAT ADS, FOCAS (managed wire client) |
|
||||
| Phase 6.1 — Resilience & Observability | ✓ | Shipped (PRs #78–83) |
|
||||
| Phase 6.2 — Authorization runtime | ◐ core | Core shipped (PRs #84–88, #94 dispatch wiring); finer-grained Browse/Subscribe/Alarm/Call gating + 3-user interop matrix deferred |
|
||||
| Phase 6.3 — Redundancy runtime | ◐ core | Core shipped (PRs #89–90, #98–99); peer-probe HostedServices, OPC UA variable-node binding, `sp_PublishGeneration` lease wrap, client interop matrix deferred |
|
||||
| Phase 6.4 — Admin UI completion | ◐ data layer + Identification | Data layer + OPC 40010 Identification folder shipped (PRs #91–92, Identification audit close-out 2026-04-23); Blazor UI pieces deferred |
|
||||
|
||||
**Aggregate test counts:** 906 baseline (pre-Phase-6) → **1159 passing** across Phase 6. One pre-existing Client.CLI `SubscribeCommandTests.Execute_PrintsSubscriptionMessage` flake tracked separately.
|
||||
**Driver integration-test counts** (end-to-end against live or simulated targets): Modbus 26, FOCAS 9, AbCip 7, OpcUaClient 3, S7 3, AbLegacy 2, TwinCAT 2. Plus Galaxy's separate cross-FX parity/stability suite.
|
||||
|
||||
**Aggregate test counts** (2026-04-19 baseline): 1159 passing across the solution. One pre-existing Client.CLI `SubscribeCommandTests.Execute_PrintsSubscriptionMessage` flake tracked separately. Rerun `dotnet test ZB.MOM.WW.OtOpcUa.slnx` after the FOCAS migration commits land to refresh the number.
|
||||
|
||||
## Release blockers (must close before v2 GA)
|
||||
|
||||
Ordered by severity + impact on production fitness.
|
||||
All code-path release blockers are closed. The remaining items are live-hardware / manual validations listed under exit criteria.
|
||||
|
||||
### ~~Security — Phase 6.2 dispatch wiring~~ (task #143 — **CLOSED** 2026-04-19, PR #94)
|
||||
|
||||
**Closed**. `AuthorizationGate` + `NodeScopeResolver` now thread through `OpcUaApplicationHost → OtOpcUaServer → DriverNodeManager`. `OnReadValue` + `OnWriteValue` + all four HistoryRead paths call `gate.IsAllowed(identity, operation, scope)` before the invoker. Production deployments activate enforcement by constructing `OpcUaApplicationHost` with an `AuthorizationGate(StrictMode: true)` + populating the `NodeAcl` table.
|
||||
**Closed**. `AuthorizationGate` + `NodeScopeResolver` thread through `OpcUaApplicationHost → OtOpcUaServer → DriverNodeManager`. `OnReadValue` + `OnWriteValue` + all four HistoryRead paths call `gate.IsAllowed(identity, operation, scope)` before the invoker. Production deployments activate enforcement by constructing `OpcUaApplicationHost` with an `AuthorizationGate(StrictMode: true)` + populating the `NodeAcl` table.
|
||||
|
||||
Additional Stream C surfaces (not release-blocking, hardening only):
|
||||
Remaining Stream C surfaces (hardening, not release-blocking):
|
||||
|
||||
- Browse + TranslateBrowsePathsToNodeIds gating with ancestor-visibility logic per `acl-design.md` §Browse.
|
||||
- CreateMonitoredItems + TransferSubscriptions gating with per-item `(AuthGenerationId, MembershipVersion)` stamp so revoked grants surface `BadUserAccessDenied` within one publish cycle (decision #153).
|
||||
- Alarm Acknowledge / Confirm / Shelve gating.
|
||||
- Call (method invocation) gating.
|
||||
- Finer-grained scope resolution — current `NodeScopeResolver` returns a flat cluster-level scope. Joining against the live Configuration DB to populate UnsArea / UnsLine / Equipment path is tracked as Stream C.12.
|
||||
- ~~Browse + TranslateBrowsePathsToNodeIds gating with ancestor-visibility logic per `acl-design.md` §Browse.~~ **Partial, 2026-04-24.** `DriverNodeManager.Browse` override post-filters the `ReferenceDescription` list via a new `FilterBrowseReferences` helper — denied nodes disappear silently per OPC UA convention. Ancestor-visibility implication (Read-grant at `Line/Tag` implying Browse on `Line`) still to ship; needs a subtree-has-any-grant query on the trie evaluator. `TranslateBrowsePathsToNodeIds` surface not yet wired.
|
||||
- ~~CreateMonitoredItems + TransferSubscriptions gating with per-item `(AuthGenerationId, MembershipVersion)` stamp so revoked grants surface `BadUserAccessDenied` within one publish cycle (decision #153).~~ **Partial, 2026-04-24.** `DriverNodeManager.CreateMonitoredItems` override pre-gates each request and pre-populates `BadUserAccessDenied` into the errors slot for denied items (the base stack honours pre-set errors and skips those items). Decision #153's per-item `(AuthGenerationId, MembershipVersion)` stamp for detecting mid-subscription revocation is still to ship — needs subscription-layer plumbing. TransferSubscriptions not yet wired (same pattern).
|
||||
- ~~Alarm Acknowledge / Confirm / Shelve gating.~~ **Partial, 2026-04-24.** Acknowledge + Confirm map to dedicated `OpcUaOperation.AlarmAcknowledge` / `AlarmConfirm` via `MapCallOperation`; Shelve falls through to generic `OpcUaOperation.Call` (needs per-instance method NodeId resolution to distinguish — follow-up).
|
||||
- ~~Call (method invocation) gating.~~ **Closed 2026-04-24.** `DriverNodeManager.Call` override pre-gates each `CallMethodRequest` via `GateCallMethodRequests`. Denied calls return `BadUserAccessDenied` without running the method. Alarm methods map to alarm-specific operation kinds; everything else gates as generic `Call`.
|
||||
- ~~Finer-grained scope resolution — current `NodeScopeResolver` returns a flat cluster-level scope. Joining against the live Configuration DB to populate UnsArea / UnsLine / Equipment path is tracked as Stream C.12.~~ **Closed 2026-04-24.** `AuthorizationBootstrap` now loads `NodeAcl` rows for the current generation into a `PermissionTrieCache`, builds the gate, and merges every registered driver's `EquipmentNamespaceContent` into a full-path `NodeScopeResolver` index. `OpcUaServerService` calls the bootstrap after the equipment registry is populated, before `OpcUaApplicationHost.StartAsync`. Disabled by default — operators flip `Node:Authorization:Enabled=true` to enforce, `StrictMode=true` to reject anonymous/no-groups identities.
|
||||
- 3-user integration matrix covering every operation × allow/deny.
|
||||
|
||||
These are additional hardening — the three highest-value surfaces (Read / Write / HistoryRead) are now gated, which covers the base-security gap for v2 GA.
|
||||
|
||||
### ~~Config fallback — Phase 6.1 Stream D wiring~~ (task #136 — **CLOSED** 2026-04-19, PR #96)
|
||||
|
||||
**Closed**. `SealedBootstrap` consumes `ResilientConfigReader` + `GenerationSealedCache` + `StaleConfigFlag` end-to-end: bootstrap calls go through the timeout → retry → fallback-to-sealed pipeline; every central-DB success writes a fresh sealed snapshot so the next cache-miss has a known-good fallback; `StaleConfigFlag.IsStale` is now consumed by `HealthEndpointsHost.usingStaleConfig` so `/healthz` body reports reality.
|
||||
**Closed**. `SealedBootstrap` consumes `ResilientConfigReader` + `GenerationSealedCache` + `StaleConfigFlag` end-to-end; `/healthz` surfaces the stale flag.
|
||||
|
||||
Production activation: Program.cs switches `NodeBootstrap → SealedBootstrap` + constructs `OpcUaApplicationHost` with the `StaleConfigFlag` as an optional ctor parameter.
|
||||
|
||||
Remaining follow-ups (hardening, not release-blocking):
|
||||
Remaining follow-ups (hardening):
|
||||
|
||||
- A `HostedService` that polls `sp_GetCurrentGenerationForCluster` periodically so peer-published generations land in this node's cache without a restart.
|
||||
- Richer snapshot payload via `sp_GetGenerationContent` so fallback can serve the full generation content (DriverInstance enumeration, ACL rows, etc.) from the sealed cache alone.
|
||||
- Richer snapshot payload via `sp_GetGenerationContent` so fallback can serve full generation content (DriverInstance enumeration, ACL rows, etc.) from the sealed cache alone.
|
||||
|
||||
### ~~Redundancy — Phase 6.3 Streams A/C core~~ (tasks #145 + #147 — **CLOSED** 2026-04-19, PRs #98–99)
|
||||
|
||||
**Closed**. The runtime orchestration layer now exists end-to-end:
|
||||
|
||||
- `RedundancyCoordinator` reads `ClusterNode` + peer list at startup (Stream A shipped in PR #98). Invariants enforced: 1-2 nodes (decision #83), unique ApplicationUri (#86), ≤1 Primary in Warm/Hot (#84). Startup fails fast on violation; runtime refresh logs + flips `IsTopologyValid=false` so the calculator falls to band 2 without tearing down.
|
||||
- `RedundancyStatePublisher` orchestrates topology + apply lease + recovery state + peer reachability through `ServiceLevelCalculator` + emits `OnStateChanged` / `OnServerUriArrayChanged` edge-triggered events (Stream C core shipped in PR #99). The OPC UA `ServiceLevel` Byte variable + `ServerUriArray` String[] variable subscribe to these events.
|
||||
**Closed**. `RedundancyCoordinator` + `RedundancyStatePublisher` + `PeerReachabilityTracker` orchestrate topology + apply lease + recovery state + peer reachability through `ServiceLevelCalculator` + emit `OnStateChanged` / `OnServerUriArrayChanged` edge-triggered events.
|
||||
|
||||
Remaining Phase 6.3 surfaces (hardening, not release-blocking):
|
||||
|
||||
- `PeerHttpProbeLoop` + `PeerUaProbeLoop` HostedServices that poll the peer + write to `PeerReachabilityTracker` on each tick. Without these the publisher sees `PeerReachability.Unknown` for every peer → Isolated-Primary band (230) even when the peer is up. Safe default (retains authority) but not the full non-transparent-redundancy UX.
|
||||
- OPC UA variable-node wiring layer: bind the `ServiceLevel` Byte node + `ServerUriArray` String[] node to the publisher's events via `BaseDataVariable.OnReadValue` / direct value push. Scoped follow-up on the Opc.Ua.Server stack integration.
|
||||
- `sp_PublishGeneration` wraps its apply in `await using var lease = coordinator.BeginApplyLease(...)` so the `PrimaryMidApply` band (200) fires during actual publishes (task #148 part 2).
|
||||
- Client interop matrix validation — Ignition / Kepware / Aveva OI Gateway (Stream F, task #150). Manual + doc-only work; doesn't block code ship.
|
||||
- ~~`PeerHttpProbeLoop` + `PeerUaProbeLoop` HostedServices populating `PeerReachabilityTracker` on each tick.~~ **Closed 2026-04-24.** Two-layer probe model shipped: HTTP probe at 2 s / 1 s timeout against `/healthz`; OPC UA probe at 10 s / 5 s timeout via `DiscoveryClient.GetEndpoints`, short-circuiting when HTTP reports the peer unhealthy. Registered on the Server as `AddHostedService<PeerHttpProbeLoop>` + `AddHostedService<PeerUaProbeLoop>`. Publisher now sees accurate `PeerReachability` per peer instead of degrading to `Unknown` → Isolated-Primary band (230).
|
||||
- OPC UA variable-node wiring: bind `ServiceLevel` Byte + `ServerUriArray` String[] to the publisher's events via `BaseDataVariable.OnReadValue` / direct value push.
|
||||
- ~~`sp_PublishGeneration` wraps its apply in `await using var lease = coordinator.BeginApplyLease(...)` so the `PrimaryMidApply` band (200) fires during actual publishes (task #148 part 2).~~ **Closed 2026-04-24.** The apply loop now lives in `GenerationRefreshHostedService` — polls `sp_GetCurrentGenerationForCluster` every 5s, opens a lease when a new generation is detected, calls `RedundancyCoordinator.RefreshAsync` inside the `await using`, releases the lease on all exit paths. Replaces the previous "topology never refreshes without a process restart" behaviour.
|
||||
- Client interop matrix — Ignition / Kepware / Aveva OI Gateway (Stream F, task #150). Manual + doc-only.
|
||||
|
||||
### Remaining drivers (task #120)
|
||||
### ~~Phase 5 driver complement~~ (task #120 — **CLOSED** 2026-04-24)
|
||||
|
||||
AB CIP, AB Legacy, TwinCAT ADS, FOCAS drivers are planned but unshipped. Decision pending on whether these are release-blocking for v2 GA or can slip to a v2.1 follow-up.
|
||||
**Closed**. All four deferred drivers shipped:
|
||||
|
||||
- **AB CIP** (PRs #202–222) — `Driver.AbCip`, `Driver.AbCip.IntegrationTests` (7 tests), AB CIP Cli. Live-boot verified against a ControlLogix rig.
|
||||
- **AB Legacy** (PRs #202, #223) — `Driver.AbLegacy`, `Driver.AbLegacy.IntegrationTests` (2 tests), AB Legacy Cli. PCCC cip-path workaround for SLC/MicroLogix.
|
||||
- **TwinCAT ADS** (PRs #205, this branch `task-galaxy-e2e`) — `Driver.TwinCAT`, `Driver.TwinCAT.IntegrationTests` (2 tests), TwinCAT Cli. TCBSD/ESXi fixture for e2e since local Hyper-V / TwinCAT RTIME are mutually exclusive on the dev box.
|
||||
- **FOCAS** (PRs #173, #199 + this session's migration) — `Driver.FOCAS` with an **in-process managed `FocasWireClient`** that speaks FOCAS/2 over TCP directly. Tier-C isolation retired — `Driver.FOCAS.Host` + `Driver.FOCAS.Shared` + `FwlibNative` P/Invoke + shim DLL + NSSM service all deleted. `Driver.FOCAS.IntegrationTests` covers 9 scenarios (fixed tree identity/axes/program/timers/spindle + user-authored PARAM/MACRO/PMC reads, Browse, Subscribe, IAlarmSource raise/clear, Probe transitions).
|
||||
|
||||
Decision recorded: FOCAS is **read-only** against the CNC by design — writes return `BadNotWritable`. See `docs/drivers/FOCAS.md` + `docs/drivers/FOCAS-Test-Fixture.md` for the deployment + coverage map.
|
||||
|
||||
## Nice-to-haves (not release-blocking)
|
||||
|
||||
- **Admin UI** — Phase 6.1 Stream E.2/E.3 (`/hosts` column refresh), Phase 6.2 Stream D (`RoleGrantsTab` + `AclsTab` Probe), Phase 6.3 Stream E (`RedundancyTab`), Phase 6.4 Streams A/B UI pieces, Stream C DiffViewer, Stream D `IdentificationFields.razor`. Tasks #134, #144, #149, #153, #155, #156, #157.
|
||||
- **Background services** — Phase 6.1 Stream B.4 `ScheduledRecycleScheduler` HostedService (task #137), Phase 6.1 Stream A analyzer (task #135 — Roslyn analyzer asserting every capability surface routes through `CapabilityInvoker`).
|
||||
- **Multi-host dispatch** — Phase 6.1 Stream A follow-up (task #135). Currently every driver gets a single pipeline keyed on `driver.DriverInstanceId`; multi-host drivers (Modbus with N PLCs) need per-PLC host resolution so failing PLCs trip per-PLC breakers without poisoning siblings. Decision #144 requires this but we haven't wired it yet.
|
||||
- **Multi-host dispatch** — Phase 6.1 Stream A follow-up (task #135). Every driver currently gets a single pipeline keyed on `driver.DriverInstanceId`; multi-host drivers (Modbus with N PLCs) need per-PLC host resolution so failing PLCs trip per-PLC breakers without poisoning siblings. Decision #144 requires this but not wired.
|
||||
- **Phase 7** — scripting + alarming + historian sink (plan drafted 2026-04-20 in `docs/v2/implementation/phase-7-*.md`). Out of scope for v2 GA.
|
||||
|
||||
## Live-hardware validations (task #54 + task family)
|
||||
|
||||
The code ships; these tasks remain open as lab/field verification:
|
||||
|
||||
- **#54** — FOCAS live-CNC wire-level smoke against a real FANUC control. The mock's wire responder is PDU-verified against `fwlibe64.dll` upstream but OtOpcUa's managed client has not been pointed at a production CNC.
|
||||
- **AB CIP live-boot** — already passed on a ControlLogix rig (PR #222). Continue to run ahead of each release.
|
||||
- **TwinCAT wire-live** — TCBSD/ESXi fixture covers the common path; production PLC verification remains lab-gated.
|
||||
|
||||
## Running the release-readiness check
|
||||
|
||||
@@ -82,7 +93,12 @@ AB CIP, AB Legacy, TwinCAT ADS, FOCAS drivers are planned but unshipped. Decisio
|
||||
pwsh ./scripts/compliance/phase-6-all.ps1
|
||||
```
|
||||
|
||||
This meta-runner invokes each `phase-6-N-compliance.ps1` script in sequence and reports an aggregate PASS/FAIL. It is the single-command verification that what we claim is shipped still compiles + tests pass + the plan-level invariants are still satisfied.
|
||||
This meta-runner invokes each `phase-6-N-compliance.ps1` script in sequence and reports an aggregate PASS/FAIL:
|
||||
|
||||
- `phase-6-1-compliance.ps1` — Resilience & Observability
|
||||
- `phase-6-2-compliance.ps1` — Authorization runtime
|
||||
- `phase-6-3-compliance.ps1` — Redundancy runtime
|
||||
- `phase-6-4-compliance.ps1` — Admin UI completion
|
||||
|
||||
Exit 0 = every phase passes its compliance checks + no test-count regression.
|
||||
|
||||
@@ -92,18 +108,23 @@ v2 GA requires all of the following:
|
||||
|
||||
- [ ] All four Phase 6.N compliance scripts exit 0.
|
||||
- [ ] `dotnet test ZB.MOM.WW.OtOpcUa.slnx` passes with ≤ 1 known-flake failure.
|
||||
- [ ] Release blockers listed above all closed (or consciously deferred to v2.1 with a written decision).
|
||||
- [x] Release blockers listed above all closed.
|
||||
- [x] Phase 5 driver complement shipped (Galaxy, Modbus, S7, OpcUaClient, AbCip, AbLegacy, TwinCAT, FOCAS).
|
||||
- [ ] Production deployment checklist (separate doc) signed off by Fleet Admin.
|
||||
- [ ] At least one end-to-end integration run against the live Galaxy on the dev box succeeds.
|
||||
- [ ] FOCAS live-CNC wire-level smoke (#54) runs clean against a real FANUC control.
|
||||
- [ ] OPC UA conformance test (CTT or UA Compliance Test Tool) passes against the live endpoint.
|
||||
- [ ] Non-transparent redundancy cutover validated with at least one production client (Ignition 8.3 recommended — see decision #85).
|
||||
|
||||
## Change log
|
||||
|
||||
- **2026-04-19** — Release blocker #3 **closed** (PRs #98–99). Phase 6.3 Streams A + C core shipped: `ClusterTopologyLoader` + `RedundancyCoordinator` + `RedundancyStatePublisher` + `PeerReachabilityTracker`. Code-path release blockers all closed; remaining Phase 6.3 surfaces (peer-probe HostedServices, OPC UA variable-node binding, sp_PublishGeneration lease wrap, client interop matrix) are hardening follow-ups.
|
||||
- **2026-04-19** — Release blocker #2 **closed** (PR #96). `SealedBootstrap` consumes `ResilientConfigReader` + `GenerationSealedCache` + `StaleConfigFlag`; `/healthz` now surfaces the stale flag. Remaining follow-ups (periodic poller + richer snapshot payload) downgraded to hardening.
|
||||
- **2026-04-19** — Release blocker #1 **closed** (PR #94). `AuthorizationGate` wired into `DriverNodeManager` Read / Write / HistoryRead dispatch. Remaining Stream C surfaces (Browse / Subscribe / Alarm / Call + finer-grained scope resolution) downgraded to hardening follow-ups — no longer release-blocking.
|
||||
- **2026-04-19** — Phase 6.4 data layer merged (PRs #91–92). Phase 6 core complete. Capstone doc created.
|
||||
- **2026-04-24** — Phase 5 driver complement closed (task #120 CLOSED). AB CIP, AB Legacy, TwinCAT, FOCAS all shipped. FOCAS migration: retired the Tier-C split (`Driver.FOCAS.Host` + `Driver.FOCAS.Shared` + `FwlibNative` + shim DLL deleted) in favour of a pure-managed in-process `FocasWireClient` inlined into `Driver.FOCAS`; driver is now read-only against the CNC by design. Integration test matrix grew to cover Browse / Subscribe / IAlarmSource / Probe end-to-end.
|
||||
- **2026-04-23** — Phase 6.4 audit close-out. IdentificationFolderBuilder + OPC 40010 Identification folder verified against the shipped code.
|
||||
- **2026-04-20** — Phase 7 plan drafted (`phase-7-scripting-and-alarming.md`, `phase-7-e2e-smoke.md`). Out of scope for v2 GA.
|
||||
- **2026-04-19** — Release blocker #3 closed (PRs #98–99). Phase 6.3 Streams A + C core shipped: `ClusterTopologyLoader` + `RedundancyCoordinator` + `RedundancyStatePublisher` + `PeerReachabilityTracker`. Code-path release blockers all closed; remaining Phase 6.3 surfaces (peer-probe HostedServices, OPC UA variable-node binding, `sp_PublishGeneration` lease wrap, client interop matrix) are hardening follow-ups.
|
||||
- **2026-04-19** — Release blocker #2 closed (PR #96). `SealedBootstrap` consumes `ResilientConfigReader` + `GenerationSealedCache` + `StaleConfigFlag`; `/healthz` surfaces the stale flag. Remaining follow-ups (periodic poller + richer snapshot payload) downgraded to hardening.
|
||||
- **2026-04-19** — Release blocker #1 closed (PR #94). `AuthorizationGate` wired into `DriverNodeManager` Read / Write / HistoryRead dispatch. Remaining Stream C surfaces (Browse / Subscribe / Alarm / Call + finer-grained scope resolution) downgraded to hardening follow-ups — no longer release-blocking.
|
||||
- **2026-04-19** — Phase 6.4 data layer merged (PRs #91–92). Phase 6 core complete.
|
||||
- **2026-04-19** — Phase 6.3 core merged (PRs #89–90). `ServiceLevelCalculator` + `RecoveryStateManager` + `ApplyLeaseRegistry` land as pure logic; coordinator / UA-node wiring / Admin UI / interop deferred.
|
||||
- **2026-04-19** — Phase 6.2 core merged (PRs #84–88). `AuthorizationGate` + `TriePermissionEvaluator` + `LdapGroupRoleMapping` land; dispatch wiring + Admin UI deferred.
|
||||
- **2026-04-19** — Phase 6.1 shipped (PRs #78–83). Polly resilience + Tier A/B/C stability + health endpoints + LiteDB generation-sealed cache + Admin `/hosts` data layer all live.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# TwinCAT driver — v3 backlog
|
||||
|
||||
The v2 TwinCAT driver is considered solid: 28 integration tests (14 `[TwinCATFact]` +
|
||||
16-case `[TwinCATTheory]`) running live against the TCBSD fixture, 110 unit tests,
|
||||
three latent driver bugs shaken out (notification cycle units, `STRING(N)` mapper,
|
||||
bit-indexed BOOL path). Further work is deferred.
|
||||
|
||||
Archived from `docs/drivers/TwinCAT-Test-Fixture.md` § Follow-up candidates.
|
||||
|
||||
## Deferred items
|
||||
|
||||
1. **TC2 coverage** — spin up a TC2 runtime (Windows CE IPC or legacy XAR)
|
||||
and run the same suite; any delta surfaces. Blocked on hardware.
|
||||
|
||||
2. **Notification coalescing under load** — run the subscribe test while
|
||||
the PLC cycle is saturated (bump `lineSim` complexity, watch for
|
||||
dropped notifications). Doable on current rig; deferred as lower
|
||||
priority than v3 feature work.
|
||||
|
||||
3. **Multi-hop AMS route** — add a test behind an IPC gateway with a
|
||||
chained route entry. Blocked on hardware (gateway IPC).
|
||||
|
||||
4. **License-rotation automation** — XAR's 7-day trial expires on
|
||||
schedule. Either automate `TcActivate.exe /reactivate` via a scheduled
|
||||
task on the VM (not officially supported; reportedly works for some
|
||||
TC3 builds), or buy a paid runtime license (~$1k one-time per runtime
|
||||
per CPU) to kill the rotation. Ops item, not code.
|
||||
|
||||
5. **Lab rig** — cheapest IPC (CX7000 / CX9020) on a dedicated network;
|
||||
the only route that covers TC2 + real EtherCAT I/O timing + cycle
|
||||
jitter under CPU load. Blocked on hardware + budget.
|
||||
+43
-20
@@ -53,27 +53,47 @@ read-only tag.
|
||||
|
||||
## Status
|
||||
|
||||
Stages 1 + 2 (driver-side probe + loopback) are verified end-to-end
|
||||
against the pymodbus / ab_server / python-snap7 fixtures. Stages 3-5
|
||||
(anything crossing the OtOpcUa server) are **blocked** on server-side
|
||||
driver factory wiring:
|
||||
All seven driver factories are registered in
|
||||
`src/ZB.MOM.WW.OtOpcUa.Server/Program.cs` — Galaxy, FOCAS, Modbus,
|
||||
AB CIP, AB Legacy, S7, TwinCAT. `DriverInstanceBootstrapper` can
|
||||
materialise any `DriverType` row from the central Config DB into a
|
||||
live driver. The factory-wiring block that originally gated stages
|
||||
3-5 is closed.
|
||||
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Server/Program.cs` only registers Galaxy +
|
||||
FOCAS factories. `DriverInstanceBootstrapper` skips any `DriverType`
|
||||
without a registered factory — so Modbus / AB CIP / AB Legacy / S7 /
|
||||
TwinCAT rows in the Config DB are silently no-op'd even when the seed
|
||||
is perfect.
|
||||
- No Config DB seed script exists for non-Galaxy drivers; Admin UI is
|
||||
currently the only path to author one.
|
||||
Live-boot verification:
|
||||
|
||||
Tracking: **#209** (umbrella) → #210 (Modbus), #211 (AB CIP), #212 (S7),
|
||||
#213 (AB Legacy, also hardware-gated — #222). Each child issue lists
|
||||
the factory class to write + the seed SQL shape + the verification
|
||||
command.
|
||||
- **Galaxy** — 7/7 stages (read / write / subscribe / alarms / history)
|
||||
against a real Galaxy + `OtOpcUaGalaxyHost` on this dev box.
|
||||
- **AB CIP, S7** — 5/5 stages each under task #220 against the
|
||||
`ab_server` + `python-snap7` fixtures.
|
||||
- **AB Legacy** — 5/5 stages under task #222 against `ab_server` SLC500
|
||||
/ MicroLogix / PLC-5 profiles (requires the `cip-path /1,0` workaround
|
||||
for the Docker fixture).
|
||||
- **Modbus** — 5/5 stages against the `pymodbus` + dl205 profile,
|
||||
including HR[200] scratch register + per-protocol bidirectional +
|
||||
subscribe-sees-change stages.
|
||||
- **TwinCAT** — factory registered; driver features validated against the
|
||||
TCBSD VM virtual-PLC fixture (FreeBSD + TwinCAT/BSD runtime on ESXi —
|
||||
bypasses the Hyper-V/RTIME conflict that blocks XAR on the dev box).
|
||||
`TWINCAT_TRUST_WIRE=1` is still required to run the script —
|
||||
false-pass-prevention belt, not an "unverified" flag.
|
||||
- **FOCAS** — factory registered; gated by `FOCAS_TRUST_WIRE=1` pending
|
||||
the lab-rig CNC (task #222 follow-up).
|
||||
- **OpcUaClient (gateway)** — eight-stage script (`test-opcuaclient.ps1`)
|
||||
covers probe / remote read / forward bridge / subscribe / reverse
|
||||
bridge / browse mirror / alarm / history against the opc-plc Docker
|
||||
fixture at `opc.tcp://localhost:50000`. Reverse-bridge / alarm /
|
||||
history stages are opt-in per the parameter docs (opc-plc's default
|
||||
image has no writable nodes and does not historize).
|
||||
|
||||
Until those ship, stages 3-5 will fail with "read failed" (nothing
|
||||
published at that NodeId) and `[FAIL]` the suite even on a running
|
||||
server.
|
||||
Remaining work is **per-protocol seed authoring**: each dev fills in
|
||||
the NodeIds their server publishes under `e2e-config.json` (sidecar
|
||||
is `.gitignore`-d; see `e2e-config.sample.json` for the shape). Admin
|
||||
UI remains the supported path for authoring the matching driver
|
||||
instance rows in the Config DB.
|
||||
|
||||
Tracking: umbrella #209 is closed; remaining TwinCAT / FOCAS work
|
||||
tracks under their hardware-fixture tasks (#221 / #222).
|
||||
|
||||
## Prereqs
|
||||
|
||||
@@ -85,7 +105,9 @@ server.
|
||||
for the simulator matrix — pymodbus / ab_server / python-snap7 /
|
||||
opc-plc cover Modbus / AB / S7 / OPC UA Client. FOCAS and TwinCAT
|
||||
have no public simulator; they are gated with env-var skip flags
|
||||
below.
|
||||
below. For OpcUaClient, `docker compose -f
|
||||
tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/
|
||||
docker-compose.yml up -d` brings up `opc-plc` on port 50000.
|
||||
3. **PowerShell 7+**. The runner uses null-coalescing + `Set-StrictMode`;
|
||||
the Windows-PowerShell-5.1 shell will not parse `test-all.ps1`.
|
||||
4. **.NET 10 SDK**. Each script either runs `dotnet run --project
|
||||
@@ -136,7 +158,8 @@ section to skip it.
|
||||
| Galaxy | — | **PASS** (requires OtOpcUaGalaxyHost + a live Galaxy; 7 stages including alarms + history) |
|
||||
| S7 | — | **PASS** (python-snap7 fixture) |
|
||||
| FOCAS | `FOCAS_TRUST_WIRE=1` | **SKIP** (no public simulator — task #222 lab rig) |
|
||||
| TwinCAT | `TWINCAT_TRUST_WIRE=1` | **SKIP** (needs XAR or standalone Router — task #221) |
|
||||
| TwinCAT | `TWINCAT_TRUST_WIRE=1` | **SKIP** by default; features **validated** against the TCBSD VM fixture — set the env var to run |
|
||||
| OpcUaClient | — | **PASS** stages 1-4 + browse (opc-plc Docker fixture); stages 5/7/8 are opt-in (require writable / alarm / historizing upstream) |
|
||||
| Phase 7 | — | **PASS** if the Modbus instance seeds a `VT_DoubledHR100` virtual tag + `AlarmHigh` scripted alarm |
|
||||
|
||||
Set the `*_TRUST_WIRE` env vars to `1` when you've pointed the script at
|
||||
|
||||
@@ -422,8 +422,11 @@ function Write-Summary {
|
||||
[Parameter(Mandatory)] [string]$Title,
|
||||
[Parameter(Mandatory)] [array]$Results
|
||||
)
|
||||
$passed = ($Results | Where-Object { $_.Passed }).Count
|
||||
$failed = ($Results | Where-Object { -not $_.Passed }).Count
|
||||
# @(...) forces an array even when Where-Object matches 0 or 1 items,
|
||||
# otherwise .Count trips Set-StrictMode -Version 3.0 ("property 'Count'
|
||||
# cannot be found on this object") on $null or on a single hashtable.
|
||||
$passed = @($Results | Where-Object { $_.Passed }).Count
|
||||
$failed = @($Results | Where-Object { -not $_.Passed }).Count
|
||||
Write-Host ""
|
||||
Write-Host "=== $Title summary: $passed/$($Results.Count) passed ===" `
|
||||
-ForegroundColor $(if ($failed -eq 0) { "Green" } else { "Red" })
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
|
||||
"focas": {
|
||||
"$comment": "Gated behind FOCAS_TRUST_WIRE=1 — no public simulator. Point at a real CNC + ensure Fwlib32.dll is on PATH.",
|
||||
"$comment": "Gated behind FOCAS_TRUST_WIRE=1 for real-CNC runs, or pass -ProfileName to run against the focas-mock Docker fixture. Managed wire client — no native dependencies.",
|
||||
"host": "192.168.1.20",
|
||||
"port": 8193,
|
||||
"address": "R100",
|
||||
@@ -60,6 +60,21 @@
|
||||
"historyLookbackSec": 3600
|
||||
},
|
||||
|
||||
"opcuaclient": {
|
||||
"$comment": "OPC UA Client (gateway) driver. Default opc-plc Docker fixture exposes ns=3;s=FastUInt1 as a ticker. The `bridgeNodeId` is the local mirror of remoteNodeId after the OpcUaClient driver's DiscoverAsync runs — dev-specific. Stages 5/7/8 are opt-in: supply writable* NodeIds to enable reverse-bridge, alarmNodeId to enable alarm, historyNodeId to enable history (opc-plc does not historize by default — a Prosys / UA Expert sample server is needed for stage 8).",
|
||||
"remoteUrl": "opc.tcp://localhost:50000",
|
||||
"remoteNodeId": "ns=3;s=FastUInt1",
|
||||
"bridgeNodeId": "ns=2;s=OpcUaClient/FastUInt1",
|
||||
"bridgeRootNodeId": "ns=2;s=OpcUaClient",
|
||||
"browseDepth": 3,
|
||||
"browseMinNodes": 5,
|
||||
"changeWaitSec": 8,
|
||||
"writableRemoteNodeId": "",
|
||||
"writableBridgeNodeId": "",
|
||||
"alarmNodeId": "",
|
||||
"historyNodeId": ""
|
||||
},
|
||||
|
||||
"phase7": {
|
||||
"$comment": "Virtual tags + scripted alarms. The VirtualNodeId must resolve to a server-side virtual tag whose script reads the modbus InputNodeId and writes VT = input * 2. The AlarmNodeId is the ConditionId of a scripted alarm that fires when VT > 100.",
|
||||
"modbusEndpoint": "127.0.0.1:5502",
|
||||
|
||||
@@ -189,6 +189,33 @@ if ($galaxy) {
|
||||
}
|
||||
else { $summary["galaxy"] = "SKIP (no config entry)" }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OPC UA Client (gateway driver)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$opcuaclient = Get-Or $config "opcuaclient"
|
||||
if ($opcuaclient) {
|
||||
Write-Header "== OPC UA CLIENT =="
|
||||
Run-Suite "opcuaclient" {
|
||||
& "$PSScriptRoot/test-opcuaclient.ps1" `
|
||||
-RemoteUrl (Get-Or $opcuaclient "remoteUrl" "opc.tcp://localhost:50000") `
|
||||
-OpcUaUrl (Get-Or $opcuaclient "opcUaUrl" $OpcUaUrl) `
|
||||
-RemoteNodeId (Get-Or $opcuaclient "remoteNodeId" "ns=3;s=FastUInt1") `
|
||||
-BridgeNodeId $opcuaclient["bridgeNodeId"] `
|
||||
-WritableRemoteNodeId (Get-Or $opcuaclient "writableRemoteNodeId" "") `
|
||||
-WritableBridgeNodeId (Get-Or $opcuaclient "writableBridgeNodeId" "") `
|
||||
-BridgeRootNodeId (Get-Or $opcuaclient "bridgeRootNodeId" "i=85") `
|
||||
-BrowseDepth (Get-Or $opcuaclient "browseDepth" 3) `
|
||||
-BrowseMinNodes (Get-Or $opcuaclient "browseMinNodes" 5) `
|
||||
-AlarmNodeId (Get-Or $opcuaclient "alarmNodeId" "") `
|
||||
-AlarmWaitSec (Get-Or $opcuaclient "alarmWaitSec" 15) `
|
||||
-HistoryNodeId (Get-Or $opcuaclient "historyNodeId" "") `
|
||||
-HistoryLookbackSec (Get-Or $opcuaclient "historyLookbackSec" 3600) `
|
||||
-ChangeWaitSec (Get-Or $opcuaclient "changeWaitSec" 8)
|
||||
}
|
||||
}
|
||||
else { $summary["opcuaclient"] = "SKIP (no config entry)" }
|
||||
|
||||
$phase7 = Get-Or $config "phase7"
|
||||
if ($phase7) {
|
||||
Write-Header "== PHASE 7 virtual tags + scripted alarms =="
|
||||
@@ -220,7 +247,9 @@ $summary.GetEnumerator() | ForEach-Object {
|
||||
Write-Host (" {0,-10} {1}" -f $_.Key, $_.Value) -ForegroundColor $color
|
||||
}
|
||||
|
||||
$failed = ($summary.Values | Where-Object { $_ -eq "FAIL" }).Count
|
||||
# @() wrap — Where-Object returns $null / a single scalar for 0-or-1 matches,
|
||||
# and .Count on either trips Set-StrictMode -Version 3.0.
|
||||
$failed = @($summary.Values | Where-Object { $_ -eq "FAIL" }).Count
|
||||
if ($failed -gt 0) {
|
||||
Write-Host "$failed suite(s) failed." -ForegroundColor Red
|
||||
exit 1
|
||||
|
||||
+142
-18
@@ -1,16 +1,35 @@
|
||||
#Requires -Version 7.0
|
||||
#Requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
End-to-end CLI test for the FOCAS (Fanuc CNC) driver.
|
||||
|
||||
.DESCRIPTION
|
||||
**Hardware-gated.** There is no public FOCAS simulator; the driver's
|
||||
FwlibFocasClient P/Invokes Fanuc's licensed Fwlib32.dll. Against a dev
|
||||
box without the DLL on PATH the test will skip with a clear message.
|
||||
Against a real CNC with the DLL present it runs probe / driver-loopback /
|
||||
server-bridge the same way the other scripts do.
|
||||
Runs the CLI against either the managed wire client (default — Driver.FOCAS.Cli
|
||||
dials the CNC on TCP:8193 directly, no native dependencies) or the focas-mock
|
||||
Docker fixture. Hardware-gated by default because the default CncHost is
|
||||
127.0.0.1; set FOCAS_TRUST_WIRE=1 once -CncHost points at a real CNC, or pass
|
||||
-ProfileName to run against the Docker sim.
|
||||
|
||||
Set FOCAS_TRUST_WIRE=1 when -CncHost points at a real CNC to un-gate.
|
||||
The script also supports three nice-to-have modes shipped 2026-04-24:
|
||||
|
||||
-Series — per-series matrix mode. Accepts a comma-separated list; the
|
||||
core stages are run once per series, swapping the -Address to
|
||||
the supplied per-series probe. Fails fast if any series's
|
||||
configured address is outside the documented range (the driver
|
||||
itself enforces that at InitializeAsync).
|
||||
|
||||
-ProfileName — for use with the Python Docker simulator (see
|
||||
docs/v2/implementation/focas-simulator-plan.md). Selects a
|
||||
docker-compose profile + matching -Series. When set, the
|
||||
FOCAS_TRUST_WIRE gate is considered satisfied because the sim
|
||||
is a legitimate non-hardware target.
|
||||
|
||||
-HandleLeakCycles <int> — stress stage that opens + closes <N> sessions
|
||||
via the CLI's `probe` command with a short sleep between
|
||||
cycles. Exercises the Tier-C supervisor's handle-recycle path
|
||||
without touching user data. Typical values: 100–1000. A CNC's
|
||||
FWLIB handle pool is finite (~5–10), so this shakes out
|
||||
handle-leak bugs if either side forgets to free.
|
||||
|
||||
.PARAMETER CncHost
|
||||
IP or hostname of the CNC. Default 127.0.0.1 — override for real runs.
|
||||
@@ -19,7 +38,22 @@
|
||||
FOCAS TCP port. Default 8193.
|
||||
|
||||
.PARAMETER Address
|
||||
FOCAS address to exercise. Default R100 (PMC R-file register).
|
||||
FOCAS address to exercise. Default R100 (PMC R-file register). Ignored
|
||||
when -Series is set and the series profile supplies its own probe.
|
||||
|
||||
.PARAMETER Series
|
||||
Comma-separated list of CNC series to run the matrix against. Known:
|
||||
ZeroI_D, ZeroI_F, ZeroI_MF, ZeroI_TF, Sixteen_i, Thirty_i, ThirtyOne_i,
|
||||
ThirtyTwo_i, PowerMotion_i. When empty the script runs a single pass
|
||||
without a series constraint.
|
||||
|
||||
.PARAMETER ProfileName
|
||||
docker-compose profile name from tests/.../Docker/profiles/. When set,
|
||||
the script assumes the Python simulator is the target + un-gates
|
||||
FOCAS_TRUST_WIRE.
|
||||
|
||||
.PARAMETER HandleLeakCycles
|
||||
Run a handle-leak stress stage with <N> open/close cycles. 0 = skip.
|
||||
|
||||
.PARAMETER OpcUaUrl
|
||||
OtOpcUa server endpoint.
|
||||
@@ -32,6 +66,9 @@ param(
|
||||
[string]$CncHost = "127.0.0.1",
|
||||
[int]$CncPort = 8193,
|
||||
[string]$Address = "R100",
|
||||
[string]$Series = "",
|
||||
[string]$ProfileName = "",
|
||||
[int]$HandleLeakCycles = 0,
|
||||
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||
[Parameter(Mandatory)] [string]$BridgeNodeId
|
||||
)
|
||||
@@ -39,11 +76,41 @@ param(
|
||||
$ErrorActionPreference = "Stop"
|
||||
. "$PSScriptRoot/_common.ps1"
|
||||
|
||||
if (-not ($env:FOCAS_TRUST_WIRE -eq "1" -or $env:FOCAS_TRUST_WIRE -eq "true")) {
|
||||
Write-Skip "FOCAS_TRUST_WIRE not set — no public simulator exists (task #222 tracks the lab rig). Set =1 when -CncHost points at a real CNC with Fwlib32.dll on PATH."
|
||||
$simGated = -not [string]::IsNullOrWhiteSpace($ProfileName)
|
||||
if (-not $simGated -and -not ($env:FOCAS_TRUST_WIRE -eq "1" -or $env:FOCAS_TRUST_WIRE -eq "true")) {
|
||||
Write-Skip "FOCAS_TRUST_WIRE not set. Pass -ProfileName <profile> to run against the Docker mock in tests/.../Driver.FOCAS.IntegrationTests/Docker/, or set FOCAS_TRUST_WIRE=1 when -CncHost points at a real CNC."
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($simGated) {
|
||||
Write-Info "Sim mode — profile '$ProfileName'. FOCAS_TRUST_WIRE gate bypassed."
|
||||
}
|
||||
|
||||
# Per-series probe addresses — each one is inside the authoritative range for
|
||||
# that series (docs/v2/focas-version-matrix.md). Picking one representative per
|
||||
# kind (PMC / parameter / macro) is enough to exercise the driver's validator.
|
||||
$seriesProbes = @{
|
||||
"ZeroI_D" = "R100"
|
||||
"ZeroI_F" = "R100"
|
||||
"ZeroI_MF" = "R100"
|
||||
"ZeroI_TF" = "R100"
|
||||
"Sixteen_i" = "R100"
|
||||
"Thirty_i" = "R100"
|
||||
"ThirtyOne_i" = "R100"
|
||||
"ThirtyTwo_i" = "R100"
|
||||
"PowerMotion_i"= "R100"
|
||||
}
|
||||
|
||||
$seriesList = @()
|
||||
if (-not [string]::IsNullOrWhiteSpace($Series)) {
|
||||
$seriesList = @($Series.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
$unknown = @($seriesList | Where-Object { -not $seriesProbes.ContainsKey($_) })
|
||||
if ($unknown.Count -gt 0) {
|
||||
Write-Fail "Unknown -Series entries: $($unknown -join ', '). Known: $($seriesProbes.Keys -join ', ')."
|
||||
exit 2
|
||||
}
|
||||
}
|
||||
|
||||
$focasCli = Get-CliInvocation `
|
||||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli" `
|
||||
-ExeName "otopcua-focas-cli"
|
||||
@@ -51,24 +118,33 @@ $opcUaCli = Get-CliInvocation `
|
||||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||
-ExeName "otopcua-cli"
|
||||
|
||||
$allResults = @()
|
||||
|
||||
function Invoke-FocasCore {
|
||||
param(
|
||||
[string]$Label,
|
||||
[string]$ProbeAddress
|
||||
)
|
||||
|
||||
Write-Header "FOCAS stages — $Label"
|
||||
$commonFocas = @("-h", $CncHost, "-p", $CncPort)
|
||||
$results = @()
|
||||
|
||||
$results += Test-Probe `
|
||||
-Cli $focasCli `
|
||||
-ProbeArgs (@("probe") + $commonFocas + @("-a", $Address, "--type", "Int16"))
|
||||
-ProbeArgs (@("probe") + $commonFocas + @("-a", $ProbeAddress, "--type", "Int16"))
|
||||
|
||||
$writeValue = Get-Random -Minimum 1 -Maximum 9999
|
||||
$results += Test-DriverLoopback `
|
||||
-Cli $focasCli `
|
||||
-WriteArgs (@("write") + $commonFocas + @("-a", $Address, "-t", "Int16", "-v", $writeValue)) `
|
||||
-ReadArgs (@("read") + $commonFocas + @("-a", $Address, "-t", "Int16")) `
|
||||
-WriteArgs (@("write") + $commonFocas + @("-a", $ProbeAddress, "-t", "Int16", "-v", $writeValue)) `
|
||||
-ReadArgs (@("read") + $commonFocas + @("-a", $ProbeAddress, "-t", "Int16")) `
|
||||
-ExpectedValue "$writeValue"
|
||||
|
||||
$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
|
||||
$results += Test-ServerBridge `
|
||||
-DriverCli $focasCli `
|
||||
-DriverWriteArgs (@("write") + $commonFocas + @("-a", $Address, "-t", "Int16", "-v", $bridgeValue)) `
|
||||
-DriverWriteArgs (@("write") + $commonFocas + @("-a", $ProbeAddress, "-t", "Int16", "-v", $bridgeValue)) `
|
||||
-OpcUaCli $opcUaCli `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-OpcUaNodeId $BridgeNodeId `
|
||||
@@ -80,7 +156,7 @@ $results += Test-OpcUaWriteBridge `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-OpcUaNodeId $BridgeNodeId `
|
||||
-DriverCli $focasCli `
|
||||
-DriverReadArgs (@("read") + $commonFocas + @("-a", $Address, "-t", "Int16")) `
|
||||
-DriverReadArgs (@("read") + $commonFocas + @("-a", $ProbeAddress, "-t", "Int16")) `
|
||||
-ExpectedValue "$reverseValue"
|
||||
|
||||
$subValue = Get-Random -Minimum 30000 -Maximum 32766
|
||||
@@ -89,8 +165,56 @@ $results += Test-SubscribeSeesChange `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-OpcUaNodeId $BridgeNodeId `
|
||||
-DriverCli $focasCli `
|
||||
-DriverWriteArgs (@("write") + $commonFocas + @("-a", $Address, "-t", "Int16", "-v", $subValue)) `
|
||||
-DriverWriteArgs (@("write") + $commonFocas + @("-a", $ProbeAddress, "-t", "Int16", "-v", $subValue)) `
|
||||
-ExpectedValue "$subValue"
|
||||
|
||||
Write-Summary -Title "FOCAS e2e" -Results $results
|
||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||
return $results
|
||||
}
|
||||
|
||||
function Invoke-HandleLeakStage {
|
||||
param([int]$Cycles)
|
||||
|
||||
Write-Header "FOCAS handle-leak stress — $Cycles cycles"
|
||||
$commonFocas = @("-h", $CncHost, "-p", $CncPort)
|
||||
$failed = 0
|
||||
for ($i = 1; $i -le $Cycles; $i++) {
|
||||
$probe = Test-Probe `
|
||||
-Cli $focasCli `
|
||||
-ProbeArgs (@("probe") + $commonFocas + @("-a", $Address, "--type", "Int16"))
|
||||
if (-not $probe.Passed) {
|
||||
$failed++
|
||||
# First 3 failures are informative; the rest just tally.
|
||||
if ($failed -le 3) {
|
||||
Write-Fail "cycle $i failed: $($probe.Reason)"
|
||||
}
|
||||
}
|
||||
# Tiny delay so a broken loop can't DDoS the CNC; FWLIB handles take a
|
||||
# few tens of ms to recycle in practice.
|
||||
Start-Sleep -Milliseconds 50
|
||||
}
|
||||
$passed = $Cycles - $failed
|
||||
if ($failed -eq 0) {
|
||||
Write-Pass "handle-leak stress: $passed/$Cycles cycles succeeded"
|
||||
return @{ Passed = $true; Reason = "$passed/$Cycles" }
|
||||
} else {
|
||||
Write-Fail "handle-leak stress: $failed/$Cycles cycles failed"
|
||||
return @{ Passed = $false; Reason = "$failed/$Cycles failed" }
|
||||
}
|
||||
}
|
||||
|
||||
if ($seriesList.Count -eq 0) {
|
||||
$allResults += Invoke-FocasCore -Label "single" -ProbeAddress $Address
|
||||
} else {
|
||||
foreach ($series in $seriesList) {
|
||||
$probeAddr = $seriesProbes[$series]
|
||||
Write-Info "Running matrix pass for series '$series' with address $probeAddr"
|
||||
$allResults += Invoke-FocasCore -Label $series -ProbeAddress $probeAddr
|
||||
}
|
||||
}
|
||||
|
||||
if ($HandleLeakCycles -gt 0) {
|
||||
$allResults += Invoke-HandleLeakStage -Cycles $HandleLeakCycles
|
||||
}
|
||||
|
||||
Write-Summary -Title "FOCAS e2e" -Results $allResults
|
||||
if ($allResults | Where-Object { -not $_.Passed }) { exit 1 }
|
||||
|
||||
+39
-19
@@ -49,16 +49,20 @@
|
||||
OtOpcUa server endpoint. Default opc.tcp://localhost:4840.
|
||||
|
||||
.PARAMETER SourceNodeId
|
||||
NodeId of the driver-sourced Galaxy tag (numeric, writable preferred).
|
||||
Default matches the Phase 7 seed — `ns=2;s=p7-smoke-tag-source`.
|
||||
NodeId of the driver-sourced Galaxy tag (numeric, writable preferred). NodeIds
|
||||
are path-based per OPC UA Part 3 §5.2.2 — the default matches the Phase 7 seed
|
||||
walking `p7-smoke-galaxy` (DriverInstanceId) → `lab-floor` → `galaxy-line` →
|
||||
`reactor-1` → `Source` (Tag.Name).
|
||||
|
||||
.PARAMETER VirtualNodeId
|
||||
NodeId of the VirtualTag computed as Source × 2 (Phase 7 scripting).
|
||||
Default matches the Phase 7 seed — `ns=2;s=p7-smoke-vt-derived`.
|
||||
NodeId of the VirtualTag that computes MachineStatus = (Source > 0) (Phase 7
|
||||
scripting). Same path-based scheme, ending in the VirtualTag.Name
|
||||
(`MachineStatus`). The tag is historized so the write/subscribe exercise
|
||||
doubles as a historian-sink check.
|
||||
|
||||
.PARAMETER AlarmNodeId
|
||||
NodeId of the scripted-alarm Condition (fires when Source > 50).
|
||||
Default matches the Phase 7 seed — `ns=2;s=p7-smoke-al-overtemp`.
|
||||
NodeId of the scripted-alarm Condition (fires when Source > 50). Same
|
||||
path-based scheme, ending in ScriptedAlarm.Name (`OverTemp`).
|
||||
|
||||
.PARAMETER AlarmTriggerValue
|
||||
Value written to -SourceNodeId to push it over the alarm threshold.
|
||||
@@ -90,13 +94,20 @@
|
||||
|
||||
param(
|
||||
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||
[string]$SourceNodeId = "ns=2;s=p7-smoke-tag-source",
|
||||
[string]$VirtualNodeId = "ns=2;s=p7-smoke-vt-derived",
|
||||
[string]$AlarmNodeId = "ns=2;s=p7-smoke-al-overtemp",
|
||||
[string]$SourceNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/Source",
|
||||
[string]$VirtualNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/MachineStatus",
|
||||
[string]$AlarmNodeId = "ns=2;s=p7-smoke-galaxy/lab-floor/galaxy-line/reactor-1/OverTemp",
|
||||
[string]$AlarmTriggerValue = "75",
|
||||
[int]$ChangeWaitSec = 10,
|
||||
[int]$AlarmWaitSec = 10,
|
||||
[int]$HistoryLookbackSec = 3600
|
||||
[int]$HistoryLookbackSec = 3600,
|
||||
# The default Phase 7 seed uses a Galaxy attribute with
|
||||
# security_classification=Operate. Anonymous OPC UA sessions are denied writes
|
||||
# against Operate-classified tags (PR 26 / docs/Security.md). Supply an LDAP
|
||||
# user with WriteOperate to exercise the reverse-bridge stage — e.g.
|
||||
# `-Username writeop -Password writeop123` against the dev-box GLAuth.
|
||||
[string]$Username = "",
|
||||
[string]$Password = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -106,6 +117,13 @@ $opcUaCli = Get-CliInvocation `
|
||||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||
-ExeName "otopcua-cli"
|
||||
|
||||
# Auth-extension helper — appends `-U / -P` to the CLI args when credentials
|
||||
# were supplied. Stays empty for anonymous runs so the default smoke path
|
||||
# doesn't require an LDAP round-trip.
|
||||
$authArgs = @()
|
||||
if ($Username) { $authArgs += @("-U", $Username) }
|
||||
if ($Password) { $authArgs += @("-P", $Password) }
|
||||
|
||||
$results = @()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -115,7 +133,7 @@ $results = @()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Header "Probe"
|
||||
$probe = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $SourceNodeId)
|
||||
$probe = Invoke-Cli -Cli $opcUaCli -Args (@("read", "-u", $OpcUaUrl, "-n", $SourceNodeId) + $authArgs)
|
||||
if ($probe.ExitCode -eq 0 -and $probe.Output -match "Status:\s+0x00000000") {
|
||||
Write-Pass "source NodeId readable (Galaxy pipe → proxy → server → client chain up)"
|
||||
$results += @{ Passed = $true }
|
||||
@@ -132,7 +150,7 @@ if ($probe.ExitCode -eq 0 -and $probe.Output -match "Status:\s+0x00000000") {
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Header "Source read"
|
||||
$sourceRead = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $SourceNodeId)
|
||||
$sourceRead = Invoke-Cli -Cli $opcUaCli -Args (@("read", "-u", $OpcUaUrl, "-n", $SourceNodeId) + $authArgs)
|
||||
$sourceValue = $null
|
||||
if ($sourceRead.ExitCode -eq 0 -and $sourceRead.Output -match "Value:\s+([^\r\n]+)") {
|
||||
$sourceValue = $Matches[1].Trim()
|
||||
@@ -156,7 +174,7 @@ if ([string]::IsNullOrEmpty($VirtualNodeId)) {
|
||||
Write-Skip "VirtualNodeId not supplied — skipping Phase 7 bridge check"
|
||||
} else {
|
||||
Write-Header "Virtual-tag bridge"
|
||||
$vtRead = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $VirtualNodeId)
|
||||
$vtRead = Invoke-Cli -Cli $opcUaCli -Args (@("read", "-u", $OpcUaUrl, "-n", $VirtualNodeId) + $authArgs)
|
||||
if ($vtRead.ExitCode -eq 0 -and $vtRead.Output -match "Value:\s+([^\r\n]+)") {
|
||||
$vtValue = $Matches[1].Trim()
|
||||
Write-Pass "virtual-tag value = $vtValue (source was $sourceValue)"
|
||||
@@ -179,7 +197,7 @@ $stdout = New-TemporaryFile
|
||||
$stderr = New-TemporaryFile
|
||||
$subArgs = @($opcUaCli.PrefixArgs) + @(
|
||||
"subscribe", "-u", $OpcUaUrl, "-n", $SourceNodeId,
|
||||
"-i", "500", "--duration", "$ChangeWaitSec")
|
||||
"-i", "500", "--duration", "$ChangeWaitSec") + $authArgs
|
||||
$subProc = Start-Process -FilePath $opcUaCli.File `
|
||||
-ArgumentList $subArgs -NoNewWindow -PassThru `
|
||||
-RedirectStandardOutput $stdout.FullName `
|
||||
@@ -191,8 +209,10 @@ $subOut = (Get-Content $stdout.FullName -Raw) + (Get-Content $stderr.FullName -R
|
||||
Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue
|
||||
|
||||
# Any `=` followed by `(Good)` line after the initial subscribe-confirmation
|
||||
# indicates at least one data-change tick arrived.
|
||||
$changeLines = ($subOut -split "`n") | Where-Object { $_ -match "=\s+.*\(Good\)" }
|
||||
# indicates at least one data-change tick arrived. The `@(...)` forces an array
|
||||
# so `.Count` works on the 0-match + single-match cases that Set-StrictMode
|
||||
# -Version 3.0 otherwise flags as `property 'Count' cannot be found`.
|
||||
$changeLines = @(($subOut -split "`n") | Where-Object { $_ -match "=\s+.*\(Good\)" })
|
||||
if ($changeLines.Count -gt 0) {
|
||||
Write-Pass "$($changeLines.Count) data-change events observed"
|
||||
$results += @{ Passed = $true }
|
||||
@@ -210,8 +230,8 @@ if ($changeLines.Count -gt 0) {
|
||||
|
||||
Write-Header "Reverse bridge (OPC UA write)"
|
||||
$writeValue = [int]$AlarmTriggerValue # reuse the alarm trigger value — two stages for one write
|
||||
$w = Invoke-Cli -Cli $opcUaCli -Args @(
|
||||
"write", "-u", $OpcUaUrl, "-n", $SourceNodeId, "-v", "$writeValue")
|
||||
$w = Invoke-Cli -Cli $opcUaCli -Args (@(
|
||||
"write", "-u", $OpcUaUrl, "-n", $SourceNodeId, "-v", "$writeValue") + $authArgs)
|
||||
if ($w.ExitCode -ne 0) {
|
||||
# Connection/protocol failure — still a test failure.
|
||||
Write-Fail "write CLI exit=$($w.ExitCode)"
|
||||
@@ -226,7 +246,7 @@ if ($w.ExitCode -ne 0) {
|
||||
} elseif ($w.Output -match "Write successful") {
|
||||
# Read back — Galaxy poll interval + MXAccess advise may need a second or two to settle.
|
||||
Start-Sleep -Seconds 2
|
||||
$r = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $SourceNodeId)
|
||||
$r = Invoke-Cli -Cli $opcUaCli -Args (@("read", "-u", $OpcUaUrl, "-n", $SourceNodeId) + $authArgs)
|
||||
if ($r.Output -match "Value:\s+$([Regex]::Escape("$writeValue"))\b") {
|
||||
Write-Pass "write propagated — source reads back $writeValue"
|
||||
$results += @{ Passed = $true }
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
#Requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
End-to-end CLI test for the OPC UA Client (gateway) driver bridged through
|
||||
the OtOpcUa server.
|
||||
|
||||
.DESCRIPTION
|
||||
The OpcUaClient driver is unique in the fleet — it's a gateway that connects
|
||||
to ANOTHER OPC UA server and re-exposes its address space through the local
|
||||
OtOpcUa server. So there's no protocol-specific driver CLI; both directions
|
||||
of this test use `otopcua-cli` against two different endpoints:
|
||||
|
||||
remote = the upstream OPC UA server the driver connects to (opc-plc fixture
|
||||
by default, opc.tcp://localhost:50000)
|
||||
local = the OtOpcUa server itself, which mirrors remote nodes through the
|
||||
OpcUaClient driver instance (opc.tcp://localhost:4840)
|
||||
|
||||
Eight stages cover the driver's full capability surface:
|
||||
|
||||
1. Remote probe — otopcua-cli connect to the upstream. Confirms the
|
||||
simulator / target server is reachable and
|
||||
speaking UA Secure Channel.
|
||||
2. Remote read — otopcua-cli read of -RemoteNodeId on the upstream.
|
||||
Captures the current value + confirms the node
|
||||
exists. Baseline for the forward-bridge stage.
|
||||
3. Forward bridge — otopcua-cli read of -BridgeNodeId on the LOCAL
|
||||
server. Proves the driver discovered + mirrored
|
||||
the remote node into the local address space and
|
||||
the read path is live (IReadable via session).
|
||||
4. Subscribe-sees-change — subscribe on local -BridgeNodeId in the
|
||||
background. opc-plc's tickers (FastUInt1, StepUp)
|
||||
mutate autonomously, so no driver poke is needed
|
||||
— a data-change event should arrive within the
|
||||
subscription window. Covers ISubscribable +
|
||||
upstream subscription transfer.
|
||||
5. Reverse bridge — otopcua-cli write to local -WritableBridgeNodeId,
|
||||
then otopcua-cli read of -WritableRemoteNodeId
|
||||
directly on the upstream. Confirms writes flow
|
||||
through the driver to the remote (IWritable). Opt-
|
||||
in — opc-plc default image has no writable nodes
|
||||
without `--sn`; pass -WritableBridgeNodeId AND
|
||||
-WritableRemoteNodeId to enable.
|
||||
6. Browse mirror — otopcua-cli browse of the local -BridgeRootNodeId
|
||||
at depth -BrowseDepth. Asserts at least
|
||||
-BrowseMinNodes descendants appear. Covers
|
||||
ITagDiscovery → local-namespace projection.
|
||||
7. Alarm fires — otopcua-cli alarms subscription on local
|
||||
-AlarmNodeId. opc-plc with `--alm` cycles a
|
||||
TripAlarm autonomously; assert an Active alarm
|
||||
event surfaces. Covers IAlarmSource → OPC UA A&E
|
||||
projection. Opt-in via -AlarmNodeId.
|
||||
8. History read — historyread on local -HistoryNodeId over a
|
||||
lookback window. Covers IHistoryProvider →
|
||||
upstream HistoryRead dispatch. Opt-in via
|
||||
-HistoryNodeId. Note: opc-plc's default image
|
||||
does not historize — a historizing upstream
|
||||
(Prosys, UaExpert sample server) is required.
|
||||
|
||||
Prereqs:
|
||||
|
||||
1. Upstream OPC UA server reachable at -RemoteUrl. Default expects the
|
||||
opc-plc Docker fixture (`tests/.../Driver.OpcUaClient.IntegrationTests/
|
||||
Docker/docker-compose.yml`): `docker compose up -d` before running.
|
||||
2. OtOpcUa server running at -OpcUaUrl with an OpcUaClient DriverInstance
|
||||
in its Config DB whose EndpointUrl = -RemoteUrl. The server's
|
||||
DiscoverAsync populates the mirrored namespace at startup; the
|
||||
-BridgeNodeId / -BridgeRootNodeId you pass must correspond to whatever
|
||||
NodeIds that discovery produced on your local server.
|
||||
3. To exercise stages 5 / 7 / 8, the upstream must expose writable nodes /
|
||||
alarm conditions / history. opc-plc alone doesn't cover all three — see
|
||||
parameter docs below for the combinations that work with opc-plc.
|
||||
|
||||
.PARAMETER RemoteUrl
|
||||
Upstream OPC UA server endpoint (the server the driver connects to).
|
||||
Default matches the opc-plc Docker fixture — opc.tcp://localhost:50000.
|
||||
|
||||
.PARAMETER OpcUaUrl
|
||||
Local OtOpcUa server endpoint. Default opc.tcp://localhost:4840.
|
||||
|
||||
.PARAMETER RemoteNodeId
|
||||
NodeId on the upstream used for stages 1-2. Default ns=3;s=FastUInt1 — opc-plc
|
||||
ticker that increments every 100 ms.
|
||||
|
||||
.PARAMETER BridgeNodeId
|
||||
NodeId on the LOCAL server that mirrors -RemoteNodeId after the OpcUaClient
|
||||
driver discovers it. Dev-specific — whatever the local DiscoverAsync produced
|
||||
for the upstream node. No default; mandatory for stages 3-4.
|
||||
|
||||
.PARAMETER WritableRemoteNodeId
|
||||
Writable NodeId on the upstream for the reverse-bridge stage. opc-plc's
|
||||
default image has no writable nodes; add `--sn=1` to the compose command to
|
||||
expose `ns=3;s=SlowUInt1` as writable (or similar per opc-plc docs). Omit to
|
||||
skip stage 5.
|
||||
|
||||
.PARAMETER WritableBridgeNodeId
|
||||
Matching local mirror of -WritableRemoteNodeId. Omit to skip stage 5.
|
||||
|
||||
.PARAMETER BridgeRootNodeId
|
||||
Root NodeId on the local server under which the mirrored upstream sits. The
|
||||
browse stage walks from this node down to -BrowseDepth. Default i=85
|
||||
(ObjectsFolder) — works but produces a lot of output; pass a narrower root
|
||||
for faster / more targeted coverage.
|
||||
|
||||
.PARAMETER BrowseDepth
|
||||
Max depth for the browse stage. Default 3.
|
||||
|
||||
.PARAMETER BrowseMinNodes
|
||||
Minimum number of descendants expected under -BridgeRootNodeId. Default 5.
|
||||
|
||||
.PARAMETER AlarmNodeId
|
||||
NodeId of the ConditionType on the local server for the alarm-fires stage.
|
||||
opc-plc with `--alm` exposes e.g. TripAlarm conditions; the local mirror path
|
||||
of that condition goes here. Omit to skip stage 7.
|
||||
|
||||
.PARAMETER AlarmWaitSec
|
||||
Seconds to wait for the alarm to cycle. opc-plc's TripAlarm fires on its own
|
||||
cadence; 15 s usually covers one cycle. Default 15.
|
||||
|
||||
.PARAMETER HistoryNodeId
|
||||
NodeId on the local server whose history to query. Omit to skip stage 8.
|
||||
|
||||
.PARAMETER HistoryLookbackSec
|
||||
Seconds back from now to query history. Default 3600.
|
||||
|
||||
.PARAMETER ChangeWaitSec
|
||||
Seconds the subscribe-sees-change stage waits for a natural ticker update.
|
||||
opc-plc's FastUInt1 ticks every 100 ms so a short window suffices. Default 8.
|
||||
|
||||
.EXAMPLE
|
||||
# Bare-minimum: stages 1-4 + browse, against the opc-plc compose fixture.
|
||||
# Requires the local OtOpcUa server to have discovered opc-plc and placed
|
||||
# FastUInt1 under (for example) ns=2;s=OpcUaClient/FastUInt1.
|
||||
./scripts/e2e/test-opcuaclient.ps1 -BridgeNodeId "ns=2;s=OpcUaClient/FastUInt1"
|
||||
|
||||
.EXAMPLE
|
||||
# Full matrix — all eight stages. Requires an opc-plc image with --sn (for
|
||||
# writable) + --alm (for alarms; default compose has this) + a historizing
|
||||
# upstream (opc-plc does not; Prosys does).
|
||||
./scripts/e2e/test-opcuaclient.ps1 `
|
||||
-BridgeNodeId "ns=2;s=OpcUaClient/FastUInt1" `
|
||||
-WritableRemoteNodeId "ns=3;s=SlowUInt1" `
|
||||
-WritableBridgeNodeId "ns=2;s=OpcUaClient/SlowUInt1" `
|
||||
-BridgeRootNodeId "ns=2;s=OpcUaClient" `
|
||||
-AlarmNodeId "ns=2;s=OpcUaClient/TripAlarm" `
|
||||
-HistoryNodeId "ns=2;s=OpcUaClient/StepUp"
|
||||
#>
|
||||
|
||||
param(
|
||||
[string]$RemoteUrl = "opc.tcp://localhost:50000",
|
||||
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||
[string]$RemoteNodeId = "ns=3;s=FastUInt1",
|
||||
[Parameter(Mandatory)] [string]$BridgeNodeId,
|
||||
[string]$WritableRemoteNodeId = "",
|
||||
[string]$WritableBridgeNodeId = "",
|
||||
[string]$BridgeRootNodeId = "i=85",
|
||||
[int]$BrowseDepth = 3,
|
||||
[int]$BrowseMinNodes = 5,
|
||||
[string]$AlarmNodeId = "",
|
||||
[int]$AlarmWaitSec = 15,
|
||||
[string]$HistoryNodeId = "",
|
||||
[int]$HistoryLookbackSec = 3600,
|
||||
[int]$ChangeWaitSec = 8
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. "$PSScriptRoot/_common.ps1"
|
||||
|
||||
$opcUaCli = Get-CliInvocation `
|
||||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||
-ExeName "otopcua-cli"
|
||||
|
||||
$results = @()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1 — Remote probe. `otopcua-cli connect` exits 0 when the Secure Channel
|
||||
# + Session handshake to the upstream complete cleanly. A failure here means
|
||||
# opc-plc isn't running or the endpoint is unreachable — nothing downstream is
|
||||
# worth trying.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Header "Remote probe"
|
||||
$probe = Invoke-Cli -Cli $opcUaCli -Args @("connect", "-u", $RemoteUrl)
|
||||
if ($probe.ExitCode -eq 0 -and $probe.Output -match "Connection successful") {
|
||||
Write-Pass "upstream $RemoteUrl reachable + speaks UA"
|
||||
$results += @{ Passed = $true }
|
||||
} else {
|
||||
Write-Fail "upstream connect failed (exit=$($probe.ExitCode))"
|
||||
Write-Host $probe.Output
|
||||
$results += @{ Passed = $false; Reason = "remote probe failed" }
|
||||
# Fail fast: if the upstream is down every other stage will cascade.
|
||||
Write-Summary -Title "OpcUaClient e2e" -Results $results
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2 — Remote read. Pulls the current value of -RemoteNodeId directly from
|
||||
# the upstream. Recorded for later stages to compare against, and confirms the
|
||||
# chosen NodeId actually exists on this upstream.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Header "Remote read"
|
||||
$remoteRead = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $RemoteUrl, "-n", $RemoteNodeId)
|
||||
$remoteValue = $null
|
||||
if ($remoteRead.ExitCode -eq 0 -and $remoteRead.Output -match "Value:\s+([^\r\n]+)") {
|
||||
$remoteValue = $Matches[1].Trim()
|
||||
Write-Pass "remote $RemoteNodeId = $remoteValue"
|
||||
$results += @{ Passed = $true }
|
||||
} else {
|
||||
Write-Fail "remote read of $RemoteNodeId failed"
|
||||
Write-Host $remoteRead.Output
|
||||
$results += @{ Passed = $false; Reason = "remote read failed" }
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 3 — Forward bridge. Read -BridgeNodeId on the LOCAL server. If the
|
||||
# OpcUaClient driver is live + its discovery mapped -RemoteNodeId into the
|
||||
# local namespace, this should return a Good value. For ticker nodes like
|
||||
# FastUInt1 we don't require exact equality with stage 2 (the ticker has
|
||||
# likely advanced between reads); a Good-status read is the real signal.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Header "Forward bridge (remote → local)"
|
||||
$localRead = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $BridgeNodeId)
|
||||
if ($localRead.ExitCode -eq 0 -and $localRead.Output -match "Status:\s+0x00000000" -and $localRead.Output -match "Value:\s+([^\r\n]+)") {
|
||||
$localValue = $Matches[1].Trim()
|
||||
Write-Pass "local bridge $BridgeNodeId = $localValue (remote was $remoteValue)"
|
||||
$results += @{ Passed = $true }
|
||||
} else {
|
||||
Write-Fail "local bridge read failed — driver instance may not be configured or discovery hasn't run"
|
||||
Write-Host $localRead.Output
|
||||
$results += @{ Passed = $false; Reason = "forward bridge failed" }
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 4 — Subscribe sees change. opc-plc's FastUInt1 ticks autonomously so we
|
||||
# don't need to drive a write. A properly wired OpcUaClient driver forwards
|
||||
# remote MonitoredItem data-change callbacks to the local server, which then
|
||||
# publishes them to our subscribe client. If nothing arrives within the
|
||||
# window, either the remote node isn't a ticker OR the upstream subscription
|
||||
# chain is broken (probe state, keep-alive, SDK publish queue).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Header "Subscribe sees change"
|
||||
$stdout = New-TemporaryFile
|
||||
$stderr = New-TemporaryFile
|
||||
$subArgs = @($opcUaCli.PrefixArgs) + @(
|
||||
"subscribe", "-u", $OpcUaUrl, "-n", $BridgeNodeId,
|
||||
"-i", "200", "--duration", "$ChangeWaitSec")
|
||||
$subProc = Start-Process -FilePath $opcUaCli.File `
|
||||
-ArgumentList $subArgs -NoNewWindow -PassThru `
|
||||
-RedirectStandardOutput $stdout.FullName `
|
||||
-RedirectStandardError $stderr.FullName
|
||||
Write-Info "subscription started (pid $($subProc.Id)) for ${ChangeWaitSec}s"
|
||||
$subProc.WaitForExit(($ChangeWaitSec + 5) * 1000) | Out-Null
|
||||
if (-not $subProc.HasExited) { Stop-Process -Id $subProc.Id -Force }
|
||||
$subOut = (Get-Content $stdout.FullName -Raw) + (Get-Content $stderr.FullName -Raw)
|
||||
Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue
|
||||
|
||||
# SubscribeCommand prints `[timestamp] <NodeId> = <value> (0xNNNNNNNN)` per
|
||||
# data-change event. 0x00000000 == Good; anything else is a non-Good status
|
||||
# we intentionally don't count (a quality drop isn't a "saw the change").
|
||||
$changeLines = @(($subOut -split "`n") | Where-Object { $_ -match "=\s+\S.*\(0x00000000\)" })
|
||||
if ($changeLines.Count -gt 0) {
|
||||
Write-Pass "$($changeLines.Count) data-change events observed on bridge"
|
||||
$results += @{ Passed = $true }
|
||||
} else {
|
||||
Write-Fail "no data-change events in ${ChangeWaitSec}s — upstream node may be static, or subscription chain broken"
|
||||
Write-Host $subOut
|
||||
$results += @{ Passed = $false; Reason = "no data-change" }
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 5 — Reverse bridge. Only runs when both writable NodeIds are supplied.
|
||||
# Writes on the local bridge side, reads directly on the upstream to verify
|
||||
# the write crossed the driver. 2s settle accounts for the driver's next poll
|
||||
# (non-idempotent writes on upstream side may take a tick to propagate).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if ([string]::IsNullOrEmpty($WritableBridgeNodeId) -or [string]::IsNullOrEmpty($WritableRemoteNodeId)) {
|
||||
Write-Header "Reverse bridge (local → remote)"
|
||||
Write-Skip "WritableBridgeNodeId / WritableRemoteNodeId not supplied — opc-plc default has no writable nodes. Add --sn=N to the compose and re-run with both params set."
|
||||
} else {
|
||||
Write-Header "Reverse bridge (local → remote)"
|
||||
$writeValue = Get-Random -Minimum 1 -Maximum 9999
|
||||
$w = Invoke-Cli -Cli $opcUaCli -Args @(
|
||||
"write", "-u", $OpcUaUrl, "-n", $WritableBridgeNodeId, "-v", "$writeValue")
|
||||
if ($w.ExitCode -ne 0 -or $w.Output -notmatch "Write successful") {
|
||||
Write-Fail "local-side write failed"
|
||||
Write-Host $w.Output
|
||||
$results += @{ Passed = $false; Reason = "reverse-bridge write failed" }
|
||||
} else {
|
||||
Write-Info "local write ok, waiting 2s for driver propagate"
|
||||
Start-Sleep -Seconds 2
|
||||
$r = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $RemoteUrl, "-n", $WritableRemoteNodeId)
|
||||
if ($r.ExitCode -eq 0 -and $r.Output -match "Value:\s+$([Regex]::Escape("$writeValue"))\b") {
|
||||
Write-Pass "remote reads back $writeValue"
|
||||
$results += @{ Passed = $true }
|
||||
} else {
|
||||
Write-Fail "remote value did not reflect $writeValue"
|
||||
Write-Host $r.Output
|
||||
$results += @{ Passed = $false; Reason = "reverse-bridge readback mismatch" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 6 — Browse mirror. Walks -BridgeRootNodeId to -BrowseDepth levels. The
|
||||
# BrowseCommand emits one line per encountered node; we count non-empty lines
|
||||
# minus the root-summary line and compare against -BrowseMinNodes. A naked
|
||||
# i=85 root always has something; a narrower dev-specific root is stricter.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Header "Browse mirror"
|
||||
$br = Invoke-Cli -Cli $opcUaCli -Args @(
|
||||
"browse", "-u", $OpcUaUrl, "-n", $BridgeRootNodeId,
|
||||
"-r", "-d", "$BrowseDepth")
|
||||
if ($br.ExitCode -ne 0) {
|
||||
Write-Fail "browse failed (exit=$($br.ExitCode))"
|
||||
Write-Host $br.Output
|
||||
$results += @{ Passed = $false; Reason = "browse failed" }
|
||||
} else {
|
||||
# BrowseCommand prints one line per node: `[Type] Name (NodeId: xxx)` with
|
||||
# indentation for depth. Count every line carrying a NodeId marker.
|
||||
$nodeLines = @(($br.Output -split "`n") | Where-Object { $_ -match "\(NodeId:" })
|
||||
$count = $nodeLines.Count
|
||||
if ($count -ge $BrowseMinNodes) {
|
||||
Write-Pass "$count descendants under $BridgeRootNodeId (>= $BrowseMinNodes)"
|
||||
$results += @{ Passed = $true }
|
||||
} else {
|
||||
Write-Fail "only $count descendants — expected >= $BrowseMinNodes"
|
||||
Write-Host $br.Output
|
||||
$results += @{ Passed = $false; Reason = "browse under-populated" }
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 7 — Alarm fires. opc-plc with --alm (set in the compose) cycles a
|
||||
# TripAlarm Condition autonomously. The local alarm subscription should
|
||||
# surface at least one Active transition within the wait window. Opt-in:
|
||||
# requires the user to know the local mirror of the upstream alarm Condition.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if ([string]::IsNullOrEmpty($AlarmNodeId)) {
|
||||
Write-Header "Alarm fires"
|
||||
Write-Skip "AlarmNodeId not supplied — skipping alarm stage"
|
||||
} else {
|
||||
Write-Header "Alarm fires"
|
||||
$stdout = New-TemporaryFile
|
||||
$stderr = New-TemporaryFile
|
||||
$allArgs = @($opcUaCli.PrefixArgs) + @(
|
||||
"alarms", "-u", $OpcUaUrl, "-n", $AlarmNodeId, "-i", "500", "--refresh")
|
||||
$proc = Start-Process -FilePath $opcUaCli.File `
|
||||
-ArgumentList $allArgs -NoNewWindow -PassThru `
|
||||
-RedirectStandardOutput $stdout.FullName `
|
||||
-RedirectStandardError $stderr.FullName
|
||||
Write-Info "alarm subscription started (pid $($proc.Id)), waiting ${AlarmWaitSec}s for opc-plc alarm cycle"
|
||||
Start-Sleep -Seconds $AlarmWaitSec
|
||||
if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force }
|
||||
$out = (Get-Content $stdout.FullName -Raw) + (Get-Content $stderr.FullName -Raw)
|
||||
Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue
|
||||
|
||||
if ($out -match "ALARM\b" -and $out -match "Active\b") {
|
||||
Write-Pass "alarm condition fired with Active state"
|
||||
$results += @{ Passed = $true }
|
||||
} else {
|
||||
Write-Fail "no Active alarm event observed in ${AlarmWaitSec}s — check opc-plc compose has --alm + the AlarmNodeId is the local mirror of the upstream Condition"
|
||||
Write-Host $out
|
||||
$results += @{ Passed = $false; Reason = "no alarm event" }
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 8 — History read. IHistoryProvider dispatch to the upstream's
|
||||
# HistoryRead service. opc-plc does NOT historize by default, so this stage
|
||||
# SKIPs when -HistoryNodeId is empty. Against a historizing upstream (Prosys,
|
||||
# UA Expert sample server, AVEVA Historian) point -HistoryNodeId at the local
|
||||
# mirror of a historized node.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if ([string]::IsNullOrEmpty($HistoryNodeId)) {
|
||||
Write-Header "History read"
|
||||
Write-Skip "HistoryNodeId not supplied — opc-plc default does not historize; supply a historized-upstream mirror NodeId to enable."
|
||||
} else {
|
||||
$results += Test-HistoryHasSamples `
|
||||
-OpcUaCli $opcUaCli `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-NodeId $HistoryNodeId `
|
||||
-LookbackSec $HistoryLookbackSec
|
||||
}
|
||||
|
||||
Write-Summary -Title "OpcUaClient e2e" -Results $results
|
||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||
@@ -1,108 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Registers the OtOpcUaFocasHost Windows service. Optional companion to
|
||||
Install-Services.ps1 — only run this on nodes where FOCAS driver instances will run
|
||||
with Tier-C process isolation enabled.
|
||||
|
||||
.DESCRIPTION
|
||||
FOCAS PR #220 / Tier-C isolation plan. Wraps OtOpcUa.Driver.FOCAS.Host.exe (net48 x86)
|
||||
as a Windows service using NSSM, running under the same service account as the main
|
||||
OtOpcUa service so the named-pipe ACL works. Passes the per-process shared secret via
|
||||
environment variable at service-start time so it never hits disk.
|
||||
|
||||
.PARAMETER InstallRoot
|
||||
Where the FOCAS Host binaries live (typically
|
||||
C:\Program Files\OtOpcUa\Driver.FOCAS.Host).
|
||||
|
||||
.PARAMETER ServiceAccount
|
||||
Service account SID or DOMAIN\name. Must match the main OtOpcUa server account so the
|
||||
PipeAcl match succeeds.
|
||||
|
||||
.PARAMETER FocasSharedSecret
|
||||
Per-process secret passed via env var. Generated freshly per install if not supplied.
|
||||
|
||||
.PARAMETER FocasBackend
|
||||
Backend selector for the Host process. One of:
|
||||
fwlib32 (default — real Fanuc Fwlib32.dll integration; requires licensed DLL on PATH)
|
||||
fake (in-memory; smoke-test mode)
|
||||
unconfigured (safe default returning structured errors; use until hardware is wired)
|
||||
|
||||
.PARAMETER FocasPipeName
|
||||
Pipe name the Host listens on. Default: OtOpcUaFocas.
|
||||
|
||||
.EXAMPLE
|
||||
.\Install-FocasHost.ps1 -InstallRoot 'C:\Program Files\OtOpcUa\Driver.FOCAS.Host' `
|
||||
-ServiceAccount 'OTOPCUA\svc-otopcua' -FocasBackend fwlib32
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$InstallRoot,
|
||||
[Parameter(Mandatory)] [string]$ServiceAccount,
|
||||
[string]$FocasSharedSecret,
|
||||
[ValidateSet('fwlib32','fake','unconfigured')] [string]$FocasBackend = 'unconfigured',
|
||||
[string]$FocasPipeName = 'OtOpcUaFocas',
|
||||
[string]$ServiceName = 'OtOpcUaFocasHost',
|
||||
[string]$NssmPath = 'C:\Program Files\nssm\nssm.exe'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Resolve-Sid {
|
||||
param([string]$Account)
|
||||
if ($Account -match '^S-\d-\d+') { return $Account }
|
||||
try {
|
||||
$nt = New-Object System.Security.Principal.NTAccount($Account)
|
||||
return $nt.Translate([System.Security.Principal.SecurityIdentifier]).Value
|
||||
} catch {
|
||||
throw "Could not resolve '$Account' to a SID. Pass an explicit SID or check the account name."
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path $NssmPath)) {
|
||||
throw "nssm.exe not found at '$NssmPath'. Install NSSM or pass -NssmPath."
|
||||
}
|
||||
|
||||
$hostExe = Join-Path $InstallRoot 'OtOpcUa.Driver.FOCAS.Host.exe'
|
||||
if (-not (Test-Path $hostExe)) {
|
||||
throw "FOCAS Host binary not found at '$hostExe'. Publish the Driver.FOCAS.Host project first."
|
||||
}
|
||||
|
||||
if (-not $FocasSharedSecret) {
|
||||
$FocasSharedSecret = [System.Guid]::NewGuid().ToString('N')
|
||||
Write-Host "Generated FocasSharedSecret — store it alongside the OtOpcUa service config."
|
||||
}
|
||||
|
||||
$allowedSid = Resolve-Sid $ServiceAccount
|
||||
|
||||
# Idempotent install — remove + re-create if present.
|
||||
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
Write-Host "Removing existing '$ServiceName' service..."
|
||||
& $NssmPath stop $ServiceName confirm | Out-Null
|
||||
& $NssmPath remove $ServiceName confirm | Out-Null
|
||||
}
|
||||
|
||||
& $NssmPath install $ServiceName $hostExe | Out-Null
|
||||
& $NssmPath set $ServiceName DisplayName 'OT-OPC-UA FOCAS Host (Tier-C isolated Fwlib32)' | Out-Null
|
||||
& $NssmPath set $ServiceName Description 'Out-of-process Fwlib32.dll host for OtOpcUa FOCAS driver. Crash-isolated from the main OPC UA server.' | Out-Null
|
||||
& $NssmPath set $ServiceName ObjectName $ServiceAccount | Out-Null
|
||||
& $NssmPath set $ServiceName Start SERVICE_AUTO_START | Out-Null
|
||||
& $NssmPath set $ServiceName AppStdout (Join-Path $env:ProgramData 'OtOpcUa\focas-host-stdout.log') | Out-Null
|
||||
& $NssmPath set $ServiceName AppStderr (Join-Path $env:ProgramData 'OtOpcUa\focas-host-stderr.log') | Out-Null
|
||||
& $NssmPath set $ServiceName AppRotateFiles 1 | Out-Null
|
||||
& $NssmPath set $ServiceName AppRotateBytes 10485760 | Out-Null
|
||||
|
||||
& $NssmPath set $ServiceName AppEnvironmentExtra `
|
||||
"OTOPCUA_FOCAS_PIPE=$FocasPipeName" `
|
||||
"OTOPCUA_ALLOWED_SID=$allowedSid" `
|
||||
"OTOPCUA_FOCAS_SECRET=$FocasSharedSecret" `
|
||||
"OTOPCUA_FOCAS_BACKEND=$FocasBackend" | Out-Null
|
||||
|
||||
& $NssmPath set $ServiceName DependOnService OtOpcUa | Out-Null
|
||||
|
||||
Write-Host "Installed '$ServiceName' under '$ServiceAccount' (SID=$allowedSid)."
|
||||
Write-Host "Pipe: \\.\pipe\$FocasPipeName Backend: $FocasBackend"
|
||||
Write-Host "Start the service with: Start-Service $ServiceName"
|
||||
Write-Host ""
|
||||
Write-Host "NOTE: the Fwlib32 backend requires the licensed Fwlib32.dll on PATH"
|
||||
Write-Host "alongside the Host exe. See docs/v2/focas-deployment.md."
|
||||
@@ -0,0 +1,87 @@
|
||||
# Integration runners
|
||||
|
||||
Scripts that orchestrate multi-component integration-test loops —
|
||||
each one wires up docker fixtures, support binaries, and `dotnet test`
|
||||
in sequence so a developer (or a CI agent) can get from "freshly
|
||||
cloned repo" to "green integration suite" with one command.
|
||||
|
||||
Unlike `scripts/e2e/test-*.ps1` (which drive the built server through
|
||||
the CLI for black-box coverage), scripts in this folder operate
|
||||
**below** the server layer — they bring up the raw fixtures the
|
||||
driver-level `IntegrationTests` projects need.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| [`run-focas.ps1`](run-focas.ps1) | FOCAS driver: builds shim DLLs + starts focas-mock docker + copies shim into test bin + runs `WireCompatGatedTests` + `FocasSimSmokeTests` + tears down docker |
|
||||
|
||||
## run-focas.ps1
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Windows + PowerShell 7+**
|
||||
- **.NET 10 SDK** — `dotnet --version` prints 10.x
|
||||
- **Native C compiler** — one of:
|
||||
- Visual Studio Build Tools with the C++ workload (then run from an
|
||||
"x64 Native Tools Command Prompt for VS" shell), or
|
||||
- Zig (`zig.exe` on PATH) as a drop-in alternative
|
||||
- **Docker Desktop** running, OR pass `-SkipDocker` and run the mock
|
||||
externally
|
||||
|
||||
### One-shot run
|
||||
|
||||
```powershell
|
||||
cd C:\Users\dohertj2\Desktop\lmxopcua
|
||||
pwsh .\scripts\integration\run-focas.ps1
|
||||
```
|
||||
|
||||
That's the default invocation: thirtyone profile, debug build,
|
||||
docker cleans up on exit.
|
||||
|
||||
### Development iteration
|
||||
|
||||
Re-run the tests without rebuilding the shim or restarting docker:
|
||||
|
||||
```powershell
|
||||
# First run bootstraps everything + keeps the mock up.
|
||||
pwsh .\scripts\integration\run-focas.ps1 -KeepDocker
|
||||
|
||||
# Iterate on test bodies without re-doing the slow steps.
|
||||
pwsh .\scripts\integration\run-focas.ps1 -SkipShimBuild -SkipDocker
|
||||
```
|
||||
|
||||
### Per-series runs
|
||||
|
||||
```powershell
|
||||
pwsh .\scripts\integration\run-focas.ps1 -Profile thirty # 30i series
|
||||
pwsh .\scripts\integration\run-focas.ps1 -Profile zerod # 0i-D
|
||||
pwsh .\scripts\integration\run-focas.ps1 -Profile powermotion
|
||||
```
|
||||
|
||||
Full profile list is in
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/docker-compose.yml`.
|
||||
|
||||
### Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | All tests passed or cleanly skipped |
|
||||
| 1 | `dotnet test` reported failures |
|
||||
| 2 | The runner itself crashed (missing file, unexpected exception) |
|
||||
| 3 | No C compiler detected for shim build |
|
||||
| 4 | Docker CLI not on PATH |
|
||||
|
||||
### CI integration
|
||||
|
||||
Wire this into the project's CI runner (Gitea Actions, Jenkins,
|
||||
whatever's hosting this repo) by calling:
|
||||
|
||||
```yaml
|
||||
- name: FOCAS integration
|
||||
shell: pwsh
|
||||
run: ./scripts/integration/run-focas.ps1
|
||||
```
|
||||
|
||||
The script is idempotent; a previous run's `docker compose down`
|
||||
failure won't block the next one.
|
||||
@@ -0,0 +1,123 @@
|
||||
#Requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Orchestrates the FOCAS driver integration-test loop: bring up the
|
||||
focas-mock Docker container, run the managed wire-client integration
|
||||
tests, tear down Docker.
|
||||
|
||||
.DESCRIPTION
|
||||
The FOCAS integration fixture now needs just two things running
|
||||
together:
|
||||
1. A single focas-mock container listening on :8193 (one service,
|
||||
no per-series compose profile ceremony — the mock's native
|
||||
FOCAS Ethernet responder handles every call the managed driver
|
||||
issues).
|
||||
2. The integration-test assembly built. The managed
|
||||
`WireFocasClient` dials the mock directly; there is no shim
|
||||
DLL, no P/Invoke, no test-bin DLL copy step.
|
||||
This script handles both and cleans up on exit.
|
||||
|
||||
Designed to run unattended on a build agent or on a developer box.
|
||||
Exit code matches the test suite (0 = all pass or skip-clean,
|
||||
non-zero when any integration test failed).
|
||||
|
||||
.PARAMETER Profile
|
||||
focas-mock profile name to seed at startup (e.g. `ThirtyOne_i`,
|
||||
`Sixteen_i`, `fwlib30i64`). Defaults to `ThirtyOne_i`. The fixture
|
||||
resolves aliases via `FocasCncSeries`, and tests that need per-series
|
||||
state can call `fixture.LoadProfileAsync` directly at test start to
|
||||
override the default.
|
||||
|
||||
.PARAMETER SkipDocker
|
||||
Skip docker up/down. Use when the mock is already running from
|
||||
another shell.
|
||||
|
||||
.PARAMETER Configuration
|
||||
Build configuration — Debug or Release. Default: Debug.
|
||||
|
||||
.PARAMETER KeepDocker
|
||||
Don't tear down the docker stack on exit. Useful for iterating on
|
||||
tests.
|
||||
#>
|
||||
|
||||
param(
|
||||
[string]$Profile = "ThirtyOne_i",
|
||||
[switch]$SkipDocker,
|
||||
[ValidateSet("Debug", "Release")]
|
||||
[string]$Configuration = "Debug",
|
||||
[switch]$KeepDocker
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
|
||||
$integTests = Join-Path $repoRoot "tests\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests"
|
||||
$dockerYml = Join-Path $integTests "Docker\docker-compose.yml"
|
||||
|
||||
function Write-Step { param([string]$Msg) Write-Host ""; Write-Host "=== $Msg ===" -ForegroundColor Cyan }
|
||||
function Write-Info { param([string]$Msg) Write-Host "[INFO] $Msg" -ForegroundColor Gray }
|
||||
function Write-Fail { param([string]$Msg) Write-Host "[FAIL] $Msg" -ForegroundColor Red }
|
||||
|
||||
$cleanupScripts = @()
|
||||
trap {
|
||||
Write-Host ""
|
||||
Write-Fail "run-focas.ps1 crashed: $_"
|
||||
foreach ($c in $cleanupScripts) { try { & $c } catch { Write-Host "cleanup failed: $_" -ForegroundColor DarkYellow } }
|
||||
exit 2
|
||||
}
|
||||
|
||||
Write-Step "Build FOCAS IntegrationTests ($Configuration)"
|
||||
dotnet build (Join-Path $integTests "ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.csproj") `
|
||||
--configuration $Configuration --nologo --verbosity minimal
|
||||
if ($LASTEXITCODE -ne 0) { throw "dotnet build failed (exit $LASTEXITCODE)" }
|
||||
|
||||
if (-not $SkipDocker) {
|
||||
Write-Step "docker compose up"
|
||||
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
|
||||
Write-Fail "docker CLI not on PATH. Install Docker Desktop or pass -SkipDocker + run the mock externally."
|
||||
exit 4
|
||||
}
|
||||
|
||||
docker compose -f $dockerYml up -d --build --wait 2>&1 | Write-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw "docker compose up failed (exit $LASTEXITCODE)" }
|
||||
|
||||
if (-not $KeepDocker) {
|
||||
$cleanupScripts += {
|
||||
Write-Step "docker compose down"
|
||||
docker compose -f $dockerYml down --remove-orphans 2>&1 | Write-Host
|
||||
}
|
||||
}
|
||||
|
||||
Write-Info "probing localhost:8193..."
|
||||
$tcp = [System.Net.Sockets.TcpClient]::new()
|
||||
try {
|
||||
$ok = $tcp.ConnectAsync("127.0.0.1", 8193).Wait([TimeSpan]::FromSeconds(5))
|
||||
if (-not $ok -or -not $tcp.Connected) {
|
||||
throw "TCP probe to localhost:8193 failed after docker compose --wait succeeded"
|
||||
}
|
||||
}
|
||||
finally { $tcp.Dispose() }
|
||||
Write-Info "mock is accepting connections"
|
||||
}
|
||||
else {
|
||||
Write-Step "Docker (skipped)"
|
||||
}
|
||||
|
||||
Write-Step "dotnet test (wire-backend integration)"
|
||||
$env:OTOPCUA_FOCAS_SIM_PROFILE = $Profile
|
||||
|
||||
dotnet test (Join-Path $integTests "ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.csproj") `
|
||||
--configuration $Configuration --no-build --nologo --verbosity minimal
|
||||
$testExit = $LASTEXITCODE
|
||||
|
||||
foreach ($c in $cleanupScripts) { & $c }
|
||||
|
||||
if ($testExit -ne 0) {
|
||||
Write-Fail "integration tests failed with exit $testExit"
|
||||
exit $testExit
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "run-focas.ps1 completed successfully." -ForegroundColor Green
|
||||
exit 0
|
||||
@@ -71,6 +71,13 @@ INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, Dashb
|
||||
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 5000,
|
||||
'urn:OtOpcUa:p7-smoke-node', 200, 1, 'p7-smoke');
|
||||
|
||||
-- sp_GetCurrentGenerationForCluster gates access by SUSER_SNAME() against
|
||||
-- ClusterNodeCredential; without this binding the Server bootstrap fails with
|
||||
-- `Unauthorized: caller sa is not bound to NodeId p7-smoke-node`. Dev Docker
|
||||
-- SQL runs with `sa`; production deploys would rotate to a per-node login.
|
||||
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
|
||||
VALUES (@NodeId, N'SqlLogin', N'sa', 1, N'p7-smoke');
|
||||
|
||||
-- 2. Generation (created Draft, flipped to Published at the end so insert order
|
||||
-- constraints (one Draft per cluster, etc.) don't fight us).
|
||||
DECLARE @Gen bigint;
|
||||
@@ -106,30 +113,43 @@ VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'galaxy-smoke', 'Galaxy', N'{
|
||||
}', 1);
|
||||
|
||||
-- 6. One driver-sourced Tag bound to the Equipment. TagConfig is the Galaxy
|
||||
-- fullRef ("DelmiaReceiver_001.DownloadPath" style); replace with a real
|
||||
-- attribute on this Galaxy. The script paths below use
|
||||
-- /lab-floor/galaxy-line/reactor-1/Source which the EquipmentNodeWalker
|
||||
-- emits + the DriverSubscriptionBridge maps to this driver fullRef.
|
||||
-- fullRef; the EquipmentNodeWalker reads the JSON's FullName field and
|
||||
-- hands that string to the Galaxy driver as the MXAccess reference.
|
||||
-- Default points at TestMachine_001.TestHistoryValue — the live dev-box
|
||||
-- Galaxy ships it as Int32 + writable (security_classification=Operate)
|
||||
-- + historized (HistoryExtension primitive), so the E2E script can
|
||||
-- exercise read + write + subscribe + alarm + history against the
|
||||
-- same attribute. Swap to any other writable historized attribute on
|
||||
-- this Galaxy by re-running `gr/queries/attributes_extended.sql` with
|
||||
-- `WHERE is_historized=1 AND security_classification > 0`.
|
||||
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
|
||||
AccessLevel, TagConfig, WriteIdempotent)
|
||||
VALUES (@Gen, @TagId, @DrvId, @EqId, 'Source', 'Float64', 'Read',
|
||||
N'{"FullName":"REPLACE_WITH_REAL_GALAXY_ATTRIBUTE","DataType":"Float64"}', 0);
|
||||
VALUES (@Gen, @TagId, @DrvId, @EqId, 'Source', 'Int32', 'ReadWrite',
|
||||
N'{"FullName":"TestMachine_001.TestHistoryValue","DataType":"Int32"}', 0);
|
||||
|
||||
-- 7. Scripts (SourceHash is SHA-256 of SourceCode, computed externally — using
|
||||
-- a placeholder here; the engine recomputes on first use anyway).
|
||||
--
|
||||
-- MachineStatus predicate — the dynamic "is the machine running?" status
|
||||
-- derived from the raw Source value. Boolean: true when Source > 0. Matches
|
||||
-- the shape Aveva operators typically want on a machine-status tile (green
|
||||
-- when above zero, otherwise grey) without needing a separate threshold
|
||||
-- attribute. Rename + re-threshold here to mirror site semantics.
|
||||
INSERT dbo.Script(GenerationId, ScriptId, Name, SourceCode, SourceHash, Language)
|
||||
VALUES
|
||||
(@Gen, @VtScript, 'doubled-source',
|
||||
N'return ((double)ctx.GetTag("/lab-floor/galaxy-line/reactor-1/Source").Value) * 2.0;',
|
||||
(@Gen, @VtScript, 'machine-status-predicate',
|
||||
N'return System.Convert.ToInt32(ctx.GetTag("/lab-floor/galaxy-line/reactor-1/Source").Value) > 0;',
|
||||
'0000000000000000000000000000000000000000000000000000000000000000', 'CSharp'),
|
||||
(@Gen, @AlScript, 'overtemp-predicate',
|
||||
N'return ((double)ctx.GetTag("/lab-floor/galaxy-line/reactor-1/Source").Value) > 50.0;',
|
||||
N'return System.Convert.ToInt32(ctx.GetTag("/lab-floor/galaxy-line/reactor-1/Source").Value) > 50;',
|
||||
'0000000000000000000000000000000000000000000000000000000000000000', 'CSharp');
|
||||
|
||||
-- 8. VirtualTag — derived value computed by Roslyn each time Source changes.
|
||||
-- 8. VirtualTag — MachineStatus boolean computed by Roslyn each time Source
|
||||
-- changes. Historized so the dashboard can plot a running/idle timeline
|
||||
-- next to the raw TestHistoryValue trend from Aveva Historian.
|
||||
INSERT dbo.VirtualTag(GenerationId, VirtualTagId, EquipmentId, Name, DataType,
|
||||
ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled)
|
||||
VALUES (@Gen, @VtId, @EqId, 'Doubled', 'Float64', @VtScript, 1, NULL, 0, 1);
|
||||
VALUES (@Gen, @VtId, @EqId, 'MachineStatus', 'Boolean', @VtScript, 1, NULL, 1, 1);
|
||||
|
||||
-- 9. ScriptedAlarm — Active when Source > 50.
|
||||
INSERT dbo.ScriptedAlarm(GenerationId, ScriptedAlarmId, EquipmentId, Name, AlarmType,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@using System.Text.Json
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Modbus
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject DriverInstanceService DriverSvc
|
||||
@@ -17,7 +19,21 @@ else
|
||||
<tbody>
|
||||
@foreach (var d in _drivers)
|
||||
{
|
||||
<tr><td><code>@d.DriverInstanceId</code></td><td>@d.Name</td><td>@d.DriverType</td><td><code>@d.NamespaceId</code></td></tr>
|
||||
<tr>
|
||||
<td><code>@d.DriverInstanceId</code></td>
|
||||
<td>@d.Name</td>
|
||||
<td>
|
||||
@if (string.Equals(d.DriverType, "Focas", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
<a href="/drivers/focas/@d.DriverInstanceId">@d.DriverType</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
@d.DriverType
|
||||
}
|
||||
</td>
|
||||
<td><code>@d.NamespaceId</code></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -36,13 +52,14 @@ else
|
||||
<label class="form-label">DriverType</label>
|
||||
<select class="form-select" @bind="_type">
|
||||
<option>Galaxy</option>
|
||||
<option>ModbusTcp</option>
|
||||
<option>Modbus</option>
|
||||
<option>AbCip</option>
|
||||
<option>AbLegacy</option>
|
||||
<option>S7</option>
|
||||
<option>Focas</option>
|
||||
<option>OpcUaClient</option>
|
||||
</select>
|
||||
<div class="form-text">Type string must match the driver's registered factory name; this dropdown wraps the canonical names.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Namespace</label>
|
||||
@@ -51,9 +68,19 @@ else
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
@if (string.Equals(_type, "Modbus", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@* #147 — typed editor for Modbus drivers. The generic textarea is a fall-back
|
||||
for driver types that haven't yet shipped a typed editor. *@
|
||||
<label class="form-label">Modbus options (typed editor)</label>
|
||||
<ModbusOptionsEditor Model="_modbusOptions"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<label class="form-label">DriverConfig JSON (schemaless per driver type)</label>
|
||||
<textarea class="form-control font-monospace" rows="6" @bind="_config"></textarea>
|
||||
<div class="form-text">Phase 1: generic JSON editor — per-driver schema validation arrives in each driver's phase (decision #94).</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
|
||||
@@ -73,11 +100,17 @@ else
|
||||
private List<Namespace>? _namespaces;
|
||||
private bool _showForm;
|
||||
private string _name = string.Empty;
|
||||
private string _type = "ModbusTcp";
|
||||
private string _type = "Modbus";
|
||||
private string _nsId = string.Empty;
|
||||
private string _config = "{}";
|
||||
private string? _error;
|
||||
|
||||
// #147 — typed editor model for Modbus drivers. Defaults match ModbusDriverOptions
|
||||
// defaults so an unedited form produces config equivalent to the historical
|
||||
// pre-typed-editor wire output. Serialised to _config on Save when type=Modbus.
|
||||
private ModbusOptionsEditor.ModbusOptionsViewModel _modbusOptions = new();
|
||||
private static readonly JsonSerializerOptions ModbusJsonOptions = new() { WriteIndented = true };
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
@@ -97,11 +130,57 @@ else
|
||||
}
|
||||
try
|
||||
{
|
||||
await DriverSvc.AddAsync(GenerationId, ClusterId, _nsId, _name, _type, _config, CancellationToken.None);
|
||||
// #147 — for Modbus drivers serialize the typed editor model into the DriverConfig
|
||||
// JSON column. Other driver types still use the raw textarea contents until each
|
||||
// ships its own typed editor (decision #94 — per-driver schema validation arrives
|
||||
// per driver phase).
|
||||
var configJson = string.Equals(_type, "Modbus", StringComparison.OrdinalIgnoreCase)
|
||||
? SerializeModbusOptions(_modbusOptions)
|
||||
: _config;
|
||||
|
||||
await DriverSvc.AddAsync(GenerationId, ClusterId, _nsId, _name, _type, configJson, CancellationToken.None);
|
||||
_name = string.Empty; _config = "{}";
|
||||
_modbusOptions = new();
|
||||
_showForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the view-model field names onto the JSON shape <c>ModbusDriverFactoryExtensions</c>
|
||||
/// consumes. Hand-rolled because the DTO uses millisecond / byte field flavours that the
|
||||
/// view model exposes as TimeSpan-derived integers; a System.Text.Json round-trip would
|
||||
/// emit the .NET-native names instead.
|
||||
/// </summary>
|
||||
private static string SerializeModbusOptions(ModbusOptionsEditor.ModbusOptionsViewModel m) =>
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
host = m.Host,
|
||||
port = m.Port,
|
||||
unitId = m.UnitId,
|
||||
family = m.Family.ToString(),
|
||||
melsecSubFamily = m.MelsecSubFamily.ToString(),
|
||||
keepAlive = new
|
||||
{
|
||||
enabled = m.KeepAliveEnabled,
|
||||
timeMs = m.KeepAliveTimeSec * 1000,
|
||||
intervalMs = m.KeepAliveIntervalSec * 1000,
|
||||
retryCount = m.KeepAliveRetryCount,
|
||||
},
|
||||
reconnect = new
|
||||
{
|
||||
initialDelayMs = m.ReconnectInitialDelayMs,
|
||||
maxDelayMs = m.ReconnectMaxDelayMs,
|
||||
backoffMultiplier = m.ReconnectBackoffMultiplier,
|
||||
},
|
||||
maxRegistersPerRead = m.MaxRegistersPerRead,
|
||||
maxRegistersPerWrite = m.MaxRegistersPerWrite,
|
||||
maxCoilsPerRead = m.MaxCoilsPerRead,
|
||||
maxReadGap = m.MaxReadGap,
|
||||
useFC15ForSingleCoilWrites = m.UseFC15ForSingleCoilWrites,
|
||||
useFC16ForSingleRegisterWrites = m.UseFC16ForSingleRegisterWrites,
|
||||
writeOnChangeOnly = m.WriteOnChangeOnly,
|
||||
tags = Array.Empty<object>(),
|
||||
}, ModbusJsonOptions);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,15 @@
|
||||
reservation conflict exists.
|
||||
</div>
|
||||
|
||||
<div class="alert alert-secondary small mb-3">
|
||||
<strong>Per-tag addressing for Modbus drivers</strong> isn't part of equipment import —
|
||||
tags are configured at the driver-instance level via the
|
||||
<a href="/clusters/@ClusterId/draft/@GenerationId">Drivers tab</a>. Use the
|
||||
<a href="/modbus/address-preview" target="_blank">address-preview tool</a> to sanity-check
|
||||
grammar strings (<code>40001:F:CDAB</code>, <code>HR1:I</code>, <code>V2000</code> for
|
||||
DL205 family, etc.) before pasting them into the driver config.
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
@page "/drivers/focas/{InstanceId}"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@inject FocasDriverDetailService DetailSvc
|
||||
|
||||
<h1 class="mb-3">FOCAS driver <code>@InstanceId</code></h1>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_detail is null)
|
||||
{
|
||||
<div class="alert alert-warning">
|
||||
No FOCAS driver instance with id <code>@InstanceId</code> was found.
|
||||
<div class="small text-muted mt-1">
|
||||
Either the id is wrong, or the instance's <code>DriverType</code> is not "Focas". The list of drivers per cluster draft is on the <a href="/clusters">Clusters</a> page.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3"><div class="card"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Name</h6>
|
||||
<div class="fs-5">@_detail.Instance.Name</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Cluster</h6>
|
||||
<div class="fs-5"><code>@_detail.Instance.ClusterId</code></div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Namespace</h6>
|
||||
<div class="fs-5"><code>@_detail.Instance.NamespaceId</code></div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card @(_detail.Instance.Enabled ? "border-success" : "border-secondary")"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Enabled</h6>
|
||||
<div class="fs-5">@(_detail.Instance.Enabled ? "Yes" : "No")</div>
|
||||
</div></div></div>
|
||||
</div>
|
||||
|
||||
@if (_detail.ParseError is not null)
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
<strong>DriverConfig JSON failed to parse:</strong> @_detail.ParseError
|
||||
<div class="small text-muted mt-1">
|
||||
Falling back to raw-JSON view below; the per-section tables are hidden because the shape couldn't be projected.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_detail.Config is not null)
|
||||
{
|
||||
<h2 class="h5 mt-4">Devices</h2>
|
||||
@if (_detail.Config.Devices is null || _detail.Config.Devices.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No devices configured.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm align-middle">
|
||||
<thead><tr><th>HostAddress</th><th>DeviceName</th><th>Series</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var d in _detail.Config.Devices)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@d.HostAddress</code></td>
|
||||
<td>@(d.DeviceName ?? "—")</td>
|
||||
<td>@(string.IsNullOrEmpty(d.Series) ? "Unknown" : d.Series)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<h2 class="h5 mt-4">Tags</h2>
|
||||
@if (_detail.Config.Tags is null || _detail.Config.Tags.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No tags configured.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="small text-muted">@_detail.Config.Tags.Count tag(s) configured.</p>
|
||||
<table class="table table-sm align-middle">
|
||||
<thead><tr><th>Name</th><th>Device</th><th>Address</th><th>DataType</th><th>Writable</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var t in _detail.Config.Tags)
|
||||
{
|
||||
<tr>
|
||||
<td>@t.Name</td>
|
||||
<td><code class="small">@t.DeviceHostAddress</code></td>
|
||||
<td><code>@t.Address</code></td>
|
||||
<td>@t.DataType</td>
|
||||
<td>@(t.Writable ? "Yes" : "No")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<h2 class="h5 mt-4">Driver behaviour</h2>
|
||||
<table class="table table-sm align-middle" style="max-width: 640px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style="width: 30%;">Probe</th>
|
||||
<td>
|
||||
@if (_detail.Config.Probe is { } probe)
|
||||
{
|
||||
<span class="badge @(probe.Enabled ? "bg-success" : "bg-secondary")">@(probe.Enabled ? "Enabled" : "Disabled")</span>
|
||||
<span class="ms-2 small text-muted">Interval: @(probe.Interval ?? "default")</span>
|
||||
}
|
||||
else { <span class="text-muted">default (enabled)</span> }
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Alarm projection</th>
|
||||
<td>
|
||||
@if (_detail.Config.AlarmProjection is { } ap)
|
||||
{
|
||||
<span class="badge @(ap.Enabled ? "bg-success" : "bg-secondary")">@(ap.Enabled ? "Enabled" : "Disabled")</span>
|
||||
<span class="ms-2 small text-muted">PollInterval: @(ap.PollInterval ?? "default")</span>
|
||||
}
|
||||
else { <span class="text-muted">disabled (default)</span> }
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Handle recycling</th>
|
||||
<td>
|
||||
@if (_detail.Config.HandleRecycle is { } hr)
|
||||
{
|
||||
<span class="badge @(hr.Enabled ? "bg-warning text-dark" : "bg-secondary")">@(hr.Enabled ? "Enabled" : "Disabled")</span>
|
||||
<span class="ms-2 small text-muted">Interval: @(hr.Interval ?? "default (01:00:00)")</span>
|
||||
}
|
||||
else { <span class="text-muted">disabled (default)</span> }
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<h2 class="h5 mt-4">Host status</h2>
|
||||
@if (_detail.HostStatuses.Count == 0)
|
||||
{
|
||||
<div class="alert alert-secondary small">
|
||||
No <code>DriverHostStatus</code> rows yet for this instance. The Server publishes its first
|
||||
tick ~2 s after the driver starts — if this stays empty after a minute, check that the Server is running and the instance is in a published generation.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Host</th>
|
||||
<th>State</th>
|
||||
<th class="text-end" title="Consecutive failures">Fail#</th>
|
||||
<th>Breaker last opened</th>
|
||||
<th>Last recycled</th>
|
||||
<th>Last seen</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _detail.HostStatuses)
|
||||
{
|
||||
<tr class="@(IsStale(r) ? "table-warning" : "")">
|
||||
<td><code>@r.NodeId</code></td>
|
||||
<td>@r.HostName</td>
|
||||
<td><span class="badge @StateBadge(r.State)">@r.State</span></td>
|
||||
<td class="text-end small">@r.ConsecutiveFailures</td>
|
||||
<td class="small">@FormatUtc(r.LastCircuitBreakerOpenUtc)</td>
|
||||
<td class="small">@FormatUtc(r.LastRecycleUtc)</td>
|
||||
<td class="small @(IsStale(r) ? "text-warning" : "")">@FormatAge(r.LastSeenUtc)</td>
|
||||
<td class="text-truncate small" style="max-width: 240px;" title="@r.Detail">@r.Detail</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<h2 class="h5 mt-4">Raw DriverConfig JSON</h2>
|
||||
<pre class="small bg-light border p-3"><code>@_detail.Instance.DriverConfig</code></pre>
|
||||
|
||||
<div class="mt-4 small text-muted">
|
||||
Docs: <code>docs/drivers/FOCAS.md</code> (getting started) · <code>docs/v2/focas-deployment.md</code> (NSSM + pipe ACL) · <code>docs/drivers/FOCAS-Test-Fixture.md</code> (test coverage).
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string InstanceId { get; set; } = string.Empty;
|
||||
|
||||
private FocasDriverDetail? _detail;
|
||||
private bool _loading = true;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_loading = true;
|
||||
try { _detail = await DetailSvc.GetAsync(InstanceId, CancellationToken.None); }
|
||||
finally { _loading = false; }
|
||||
}
|
||||
|
||||
private static bool IsStale(FocasHostStatusRow r) =>
|
||||
DateTime.UtcNow - r.LastSeenUtc > TimeSpan.FromSeconds(30);
|
||||
|
||||
private static string StateBadge(string state) => state switch
|
||||
{
|
||||
"Running" => "bg-success",
|
||||
"Faulted" => "bg-danger",
|
||||
"Starting" => "bg-info",
|
||||
"Stopped" => "bg-secondary",
|
||||
_ => "bg-secondary",
|
||||
};
|
||||
|
||||
private static string FormatUtc(DateTime? utc) =>
|
||||
utc is null ? "—" : utc.Value.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
|
||||
private static string FormatAge(DateTime utc)
|
||||
{
|
||||
var age = DateTime.UtcNow - utc;
|
||||
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
|
||||
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
|
||||
if (age.TotalHours < 48) return $"{(int)age.TotalHours}h ago";
|
||||
return utc.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
@page "/hosts"
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject IServiceScopeFactory ScopeFactory
|
||||
@implements IDisposable
|
||||
@inject NavigationManager Nav
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<h1 class="mb-4">Driver host status</h1>
|
||||
|
||||
@@ -128,6 +131,7 @@ else
|
||||
private bool _refreshing;
|
||||
private DateTime? _lastRefreshUtc;
|
||||
private Timer? _timer;
|
||||
private HubConnection? _hub;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -136,6 +140,44 @@ else
|
||||
state: null,
|
||||
dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds),
|
||||
period: TimeSpan.FromSeconds(RefreshIntervalSeconds));
|
||||
await ConnectHubAsync();
|
||||
}
|
||||
|
||||
// Phase 6.1 Stream E.2 — subscribe to FleetStatusHub so resilience deltas upsert the
|
||||
// matching row without waiting for the next RefreshIntervalSeconds tick. The 10 s
|
||||
// poll stays as a safety net in case the hub connection is down.
|
||||
private async Task ConnectHubAsync()
|
||||
{
|
||||
var hubUrl = Nav.ToAbsoluteUri("/hubs/fleet");
|
||||
_hub = new HubConnectionBuilder().WithUrl(hubUrl).WithAutomaticReconnect().Build();
|
||||
_hub.On<ResilienceStatusChangedMessage>("ResilienceStatusChanged", OnResilienceChanged);
|
||||
try
|
||||
{
|
||||
await _hub.StartAsync();
|
||||
await _hub.SendAsync("SubscribeFleet");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Hub is best-effort; polling refresh is the fallback. Swallow connect errors
|
||||
// so the page still renders against the initial RefreshAsync pass.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnResilienceChanged(ResilienceStatusChangedMessage msg)
|
||||
{
|
||||
if (_rows is null) return;
|
||||
var idx = _rows.FindIndex(r =>
|
||||
r.DriverInstanceId == msg.DriverInstanceId && r.HostName == msg.HostName);
|
||||
if (idx < 0) return;
|
||||
var prior = _rows[idx];
|
||||
_rows[idx] = prior with
|
||||
{
|
||||
ConsecutiveFailures = msg.ConsecutiveFailures,
|
||||
LastCircuitBreakerOpenUtc = msg.LastCircuitBreakerOpenUtc,
|
||||
CurrentBulkheadDepth = msg.CurrentBulkheadDepth,
|
||||
LastRecycleUtc = msg.LastRecycleUtc,
|
||||
};
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
@@ -180,5 +222,12 @@ else
|
||||
return t.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
}
|
||||
|
||||
public void Dispose() => _timer?.Dispose();
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_timer?.Dispose();
|
||||
if (_hub is not null)
|
||||
{
|
||||
try { await _hub.DisposeAsync(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
|
||||
|
||||
@*
|
||||
#145 — Live address-string parser preview for Modbus tag editing. Bound to a string
|
||||
AddressString; on every input keystroke the parser runs and surfaces the resolved
|
||||
breakdown (Region, PduOffset, DataType, Bit, ByteOrder, ArrayCount, StringLength) or
|
||||
the parse error. Family flag drives the parser's family-native branch (#144).
|
||||
|
||||
Re-uses the same ModbusAddressParser the wire driver uses, so grammar drift is
|
||||
impossible by construction. Internal-namespace component called from the larger
|
||||
DriverInstance editor.
|
||||
*@
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Address string</label>
|
||||
<input type="text" class="form-control @(IsValid ? "is-valid" : Diagnostic is null ? "" : "is-invalid")"
|
||||
value="@AddressString"
|
||||
@oninput="@OnInputChanged"
|
||||
placeholder="e.g. 40001:F:CDAB:5"/>
|
||||
@if (IsValid && _parsed is not null)
|
||||
{
|
||||
<div class="form-text text-success">
|
||||
<strong>Parsed:</strong>
|
||||
Region=<code>@_parsed.Region</code>
|
||||
Offset=<code>@_parsed.Offset</code>
|
||||
Type=<code>@_parsed.DataType</code>
|
||||
@if (_parsed.Bit.HasValue) { <text>Bit=<code>@_parsed.Bit</code></text> }
|
||||
@if (_parsed.ByteOrder != ModbusByteOrder.BigEndian) { <text>Order=<code>@_parsed.ByteOrder</code></text> }
|
||||
@if (_parsed.ArrayCount.HasValue) { <text>Array[<code>@_parsed.ArrayCount</code>]</text> }
|
||||
@if (_parsed.StringLength > 0) { <text>StrLen=<code>@_parsed.StringLength</code></text> }
|
||||
</div>
|
||||
}
|
||||
else if (Diagnostic is not null)
|
||||
{
|
||||
<div class="invalid-feedback">@Diagnostic</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? AddressString { get; set; }
|
||||
[Parameter] public EventCallback<string?> AddressStringChanged { get; set; }
|
||||
[Parameter] public ModbusFamily Family { get; set; } = ModbusFamily.Generic;
|
||||
[Parameter] public MelsecFamily MelsecSubFamily { get; set; } = MelsecFamily.Q_L_iQR;
|
||||
[Parameter] public EventCallback<ParsedModbusAddress?> ParsedChanged { get; set; }
|
||||
|
||||
private ParsedModbusAddress? _parsed;
|
||||
private string? Diagnostic;
|
||||
private bool IsValid => _parsed is not null && Diagnostic is null;
|
||||
|
||||
protected override void OnParametersSet() => Reparse();
|
||||
|
||||
private async Task OnInputChanged(ChangeEventArgs e)
|
||||
{
|
||||
AddressString = e.Value as string;
|
||||
await AddressStringChanged.InvokeAsync(AddressString);
|
||||
Reparse();
|
||||
await ParsedChanged.InvokeAsync(_parsed);
|
||||
}
|
||||
|
||||
private void Reparse()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(AddressString))
|
||||
{
|
||||
_parsed = null;
|
||||
Diagnostic = null;
|
||||
return;
|
||||
}
|
||||
if (ModbusAddressParser.TryParse(AddressString, Family, MelsecSubFamily, out var parsed, out var err))
|
||||
{
|
||||
_parsed = parsed;
|
||||
Diagnostic = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_parsed = null;
|
||||
Diagnostic = err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
@page "/modbus/address-preview"
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
|
||||
|
||||
@*
|
||||
#149 — standalone preview / sanity-check tool for Modbus address strings. The Admin UI
|
||||
doesn't yet have a per-tag CRUD surface (tags are seeded via SQL or arrive at runtime
|
||||
through ITagDiscovery), so the ModbusAddressEditor component shipped in #145 needs a
|
||||
page where operators can paste an address string and confirm it parses to what they
|
||||
expect before committing it to a config row.
|
||||
|
||||
Doubles as a "did the parser ship correctly" smoke target for QA + a copy-pasteable
|
||||
grammar reference for users skimming the docs.
|
||||
*@
|
||||
|
||||
<PageTitle>Modbus address preview</PageTitle>
|
||||
|
||||
<div class="container py-4">
|
||||
<h1>Modbus address preview</h1>
|
||||
<p class="text-muted">
|
||||
Paste an address string and watch the parser break it down field by field. Useful for
|
||||
sanity-checking a tag spreadsheet row before adding it to a driver's <code>DriverConfig</code>.
|
||||
Full grammar: <a href="https://github.com/" target="_blank">docs/v2/modbus-addressing.md</a>.
|
||||
</p>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">PLC family hint (drives the family-native branch)</label>
|
||||
<select class="form-select" @bind="_family">
|
||||
@foreach (var f in Enum.GetValues<ModbusFamily>())
|
||||
{
|
||||
<option value="@f">@f</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@if (_family == ModbusFamily.MELSEC)
|
||||
{
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">MELSEC sub-family</label>
|
||||
<select class="form-select" @bind="_melsecSubFamily">
|
||||
@foreach (var f in Enum.GetValues<MelsecFamily>())
|
||||
{
|
||||
<option value="@f">@f</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<ModbusAddressEditor @bind-AddressString="_address"
|
||||
Family="_family"
|
||||
MelsecSubFamily="_melsecSubFamily"/>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-5">Quick-reference grammar</h3>
|
||||
<pre class="bg-light p-3 rounded small">@_grammarReference</pre>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? _address;
|
||||
private ModbusFamily _family = ModbusFamily.Generic;
|
||||
private MelsecFamily _melsecSubFamily = MelsecFamily.Q_L_iQR;
|
||||
|
||||
// Held as a const string rather than inline markup so the Razor parser doesn't try to
|
||||
// interpret the angle-bracket grammar tokens as element open/close.
|
||||
private const string _grammarReference = @"<region><offset>[.<bit>][:<type>[<len>]][:<order>][:<count>]
|
||||
|
||||
Examples (post-#146 type codes):
|
||||
40001 HoldingRegisters[0], Int16
|
||||
400001 same, 6-digit form
|
||||
40001:F Float32 (HR[0..1])
|
||||
40001:F:CDAB Float32 word-swapped
|
||||
40001:STR20 20-char ASCII string
|
||||
40001:S:5 Int16[5] array (3-field shorthand)
|
||||
40001:I:CDAB:10 Int32[10] word-swapped (4-field strict)
|
||||
40001.5 bit 5 of HR[0]
|
||||
HR1:I Int32 via mnemonic region (matches Wonderware)
|
||||
C100 Coil 100 (mnemonic, 1-based)
|
||||
V2000:F:CDAB DL205 V-memory at PDU 1024 (Family=DL205)
|
||||
D100:I MELSEC D-register 100, Int32 (Family=MELSEC)
|
||||
|
||||
Type codes: BOOL, S (Int16), US (UInt16), I (Int32), UI (UInt32),
|
||||
I_64 (Int64), UI_64 (UInt64), F, D, BCD, BCD_32, STR<n>
|
||||
Byte order: ABCD (BE default), CDAB (word-swap), BADC (byte-swap), DCBA (full reverse)";
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
|
||||
|
||||
@*
|
||||
#145 — Driver-instance options panel for the Modbus driver. Surfaces every option group
|
||||
added by #136-#144 so users can configure the driver via the UI rather than hand-editing
|
||||
DriverConfig JSON. Bound to a ModbusOptionsViewModel; the parent page round-trips that
|
||||
model to the DriverConfig.json column on save.
|
||||
*@
|
||||
|
||||
<div class="modbus-options-editor">
|
||||
|
||||
<h5>Connection</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label">Host</label>
|
||||
<input class="form-control" @bind="Model.Host"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Port</label>
|
||||
<input type="number" class="form-control" @bind="Model.Port"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Default UnitId</label>
|
||||
<input type="number" class="form-control" @bind="Model.UnitId"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5>Family (#144)</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label">PLC family</label>
|
||||
<select class="form-select" @bind="Model.Family">
|
||||
@foreach (var f in Enum.GetValues<ModbusFamily>())
|
||||
{
|
||||
<option value="@f">@f</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@if (Model.Family == ModbusFamily.MELSEC)
|
||||
{
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label">MELSEC sub-family</label>
|
||||
<select class="form-select" @bind="Model.MelsecSubFamily">
|
||||
@foreach (var f in Enum.GetValues<MelsecFamily>())
|
||||
{
|
||||
<option value="@f">@f</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<h5>Keep-alive (#139)</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-3">
|
||||
<div class="form-check mt-4">
|
||||
<input type="checkbox" class="form-check-input" @bind="Model.KeepAliveEnabled"/>
|
||||
<label class="form-check-label">Enabled</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Time (s)</label>
|
||||
<input type="number" class="form-control" @bind="Model.KeepAliveTimeSec"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Interval (s)</label>
|
||||
<input type="number" class="form-control" @bind="Model.KeepAliveIntervalSec"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Retry count</label>
|
||||
<input type="number" class="form-control" @bind="Model.KeepAliveRetryCount"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5>Reconnect (#139)</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label">Initial delay (ms)</label>
|
||||
<input type="number" class="form-control" @bind="Model.ReconnectInitialDelayMs"/>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label">Max delay (ms)</label>
|
||||
<input type="number" class="form-control" @bind="Model.ReconnectMaxDelayMs"/>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<label class="form-label">Backoff multiplier</label>
|
||||
<input type="number" step="0.1" class="form-control" @bind="Model.ReconnectBackoffMultiplier"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5>Protocol (#140)</h5>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Max regs / read</label>
|
||||
<input type="number" class="form-control" @bind="Model.MaxRegistersPerRead"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Max regs / write</label>
|
||||
<input type="number" class="form-control" @bind="Model.MaxRegistersPerWrite"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Max coils / read</label>
|
||||
<input type="number" class="form-control" @bind="Model.MaxCoilsPerRead"/>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Max read gap (#143)</label>
|
||||
<input type="number" class="form-control" @bind="Model.MaxReadGap"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="Model.UseFC15ForSingleCoilWrites"/>
|
||||
<label class="form-check-label">Use FC15 for single coil</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="Model.UseFC16ForSingleRegisterWrites"/>
|
||||
<label class="form-check-label">Use FC16 for single reg</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="Model.WriteOnChangeOnly"/>
|
||||
<label class="form-check-label">Write-on-change only (#141)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public ModbusOptionsViewModel Model { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// UI binding model. Maps 1:1 onto the JSON DTO the driver factory accepts; serialised
|
||||
/// to DriverConfig.json by the calling save handler. Defaults match
|
||||
/// <c>ModbusDriverOptions</c> defaults so unedited rows produce the historical wire
|
||||
/// output verbatim.
|
||||
/// </summary>
|
||||
public sealed class ModbusOptionsViewModel
|
||||
{
|
||||
public string Host { get; set; } = "127.0.0.1";
|
||||
public int Port { get; set; } = 502;
|
||||
public byte UnitId { get; set; } = 1;
|
||||
public ModbusFamily Family { get; set; } = ModbusFamily.Generic;
|
||||
public MelsecFamily MelsecSubFamily { get; set; } = MelsecFamily.Q_L_iQR;
|
||||
|
||||
public bool KeepAliveEnabled { get; set; } = true;
|
||||
public int KeepAliveTimeSec { get; set; } = 30;
|
||||
public int KeepAliveIntervalSec { get; set; } = 10;
|
||||
public int KeepAliveRetryCount { get; set; } = 3;
|
||||
|
||||
public int ReconnectInitialDelayMs { get; set; } = 0;
|
||||
public int ReconnectMaxDelayMs { get; set; } = 30000;
|
||||
public double ReconnectBackoffMultiplier { get; set; } = 2.0;
|
||||
|
||||
public int MaxRegistersPerRead { get; set; } = 125;
|
||||
public int MaxRegistersPerWrite { get; set; } = 123;
|
||||
public int MaxCoilsPerRead { get; set; } = 2000;
|
||||
public int MaxReadGap { get; set; } = 0;
|
||||
|
||||
public bool UseFC15ForSingleCoilWrites { get; set; } = false;
|
||||
public bool UseFC16ForSingleRegisterWrites { get; set; } = false;
|
||||
public bool WriteOnChangeOnly { get; set; } = false;
|
||||
}
|
||||
}
|
||||
@@ -37,3 +37,18 @@ public sealed record NodeStateChangedMessage(
|
||||
string? LastAppliedError,
|
||||
DateTime? LastAppliedAt,
|
||||
DateTime? LastSeenAt);
|
||||
|
||||
/// <summary>
|
||||
/// Pushed by <c>FleetStatusPoller</c> when it observes a change in a
|
||||
/// <c>DriverInstanceResilienceStatus</c> row. Closes the last Phase 6.1 Stream E.2/E.3
|
||||
/// deferral — lets the Admin <c>/hosts</c> page upsert the matching row without the
|
||||
/// 10-second polling round-trip. Keyed on (DriverInstanceId, HostName); the client
|
||||
/// fan-outs to the matching row by matching both.
|
||||
/// </summary>
|
||||
public sealed record ResilienceStatusChangedMessage(
|
||||
string DriverInstanceId,
|
||||
string HostName,
|
||||
int ConsecutiveFailures,
|
||||
DateTime? LastCircuitBreakerOpenUtc,
|
||||
int CurrentBulkheadDepth,
|
||||
DateTime? LastRecycleUtc);
|
||||
|
||||
Binary file not shown.
@@ -44,6 +44,7 @@ builder.Services.AddScoped<EquipmentService>();
|
||||
builder.Services.AddScoped<UnsService>();
|
||||
builder.Services.AddScoped<NamespaceService>();
|
||||
builder.Services.AddScoped<DriverInstanceService>();
|
||||
builder.Services.AddScoped<FocasDriverDetailService>();
|
||||
builder.Services.AddScoped<NodeAclService>();
|
||||
builder.Services.AddScoped<PermissionProbeService>();
|
||||
builder.Services.AddScoped<AclChangeNotifier>();
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Per-instance detail view for FOCAS driver rows. Loads the latest
|
||||
/// <see cref="DriverInstance"/> row for the requested <c>DriverInstanceId</c> (most-recent
|
||||
/// draft wins when multiple rows exist across generations), parses the schemaless
|
||||
/// <c>DriverConfig</c> JSON into <see cref="FocasDriverConfigView"/>, and joins the
|
||||
/// per-device <see cref="DriverHostStatus"/> rows so the Admin page can render host
|
||||
/// state + consecutive-failure counters next to each configured device.
|
||||
/// </summary>
|
||||
public sealed class FocasDriverDetailService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString,
|
||||
};
|
||||
|
||||
public async Task<FocasDriverDetail?> GetAsync(string driverInstanceId, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(driverInstanceId)) return null;
|
||||
|
||||
var instance = await db.DriverInstances.AsNoTracking()
|
||||
.Where(d => d.DriverInstanceId == driverInstanceId
|
||||
&& d.DriverType.ToLower() == "focas")
|
||||
.OrderByDescending(d => d.GenerationId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (instance is null) return null;
|
||||
|
||||
FocasDriverConfigView? config = null;
|
||||
string? parseError = null;
|
||||
try { config = JsonSerializer.Deserialize<FocasDriverConfigView>(instance.DriverConfig, JsonOpts); }
|
||||
catch (JsonException ex) { parseError = ex.Message; }
|
||||
|
||||
var hostStatuses = await (from s in db.DriverHostStatuses.AsNoTracking()
|
||||
where s.DriverInstanceId == driverInstanceId
|
||||
join r in db.DriverInstanceResilienceStatuses.AsNoTracking()
|
||||
on new { s.DriverInstanceId, s.HostName }
|
||||
equals new { r.DriverInstanceId, r.HostName } into rj
|
||||
from r in rj.DefaultIfEmpty()
|
||||
orderby s.HostName
|
||||
select new FocasHostStatusRow(
|
||||
s.NodeId,
|
||||
s.HostName,
|
||||
s.State.ToString(),
|
||||
s.StateChangedUtc,
|
||||
s.LastSeenUtc,
|
||||
s.Detail,
|
||||
r != null ? r.ConsecutiveFailures : 0,
|
||||
r != null ? r.LastCircuitBreakerOpenUtc : null,
|
||||
r != null ? r.LastRecycleUtc : null)).ToListAsync(ct);
|
||||
|
||||
return new FocasDriverDetail(instance, config, parseError, hostStatuses);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Projected view of a FOCAS driver's parsed config. Unknown fields are ignored.</summary>
|
||||
public sealed record FocasDriverConfigView
|
||||
{
|
||||
public List<FocasDeviceView>? Devices { get; set; }
|
||||
public List<FocasTagView>? Tags { get; set; }
|
||||
public FocasProbeView? Probe { get; set; }
|
||||
public FocasAlarmProjectionView? AlarmProjection { get; set; }
|
||||
public FocasHandleRecycleView? HandleRecycle { get; set; }
|
||||
}
|
||||
|
||||
public sealed record FocasDeviceView
|
||||
{
|
||||
public string? HostAddress { get; set; }
|
||||
public string? DeviceName { get; set; }
|
||||
public string? Series { get; set; }
|
||||
}
|
||||
|
||||
public sealed record FocasTagView
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? DeviceHostAddress { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? DataType { get; set; }
|
||||
public bool Writable { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed record FocasProbeView
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string? Interval { get; set; }
|
||||
}
|
||||
|
||||
public sealed record FocasAlarmProjectionView
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public string? PollInterval { get; set; }
|
||||
}
|
||||
|
||||
public sealed record FocasHandleRecycleView
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public string? Interval { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Composite payload returned to the Admin page.</summary>
|
||||
public sealed record FocasDriverDetail(
|
||||
DriverInstance Instance,
|
||||
FocasDriverConfigView? Config,
|
||||
string? ParseError,
|
||||
IReadOnlyList<FocasHostStatusRow> HostStatuses);
|
||||
|
||||
public sealed record FocasHostStatusRow(
|
||||
string NodeId,
|
||||
string HostName,
|
||||
string State,
|
||||
DateTime StateChangedUtc,
|
||||
DateTime LastSeenUtc,
|
||||
string? Detail,
|
||||
int ConsecutiveFailures,
|
||||
DateTime? LastCircuitBreakerOpenUtc,
|
||||
DateTime? LastRecycleUtc);
|
||||
@@ -16,8 +16,8 @@
|
||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0"/>
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0"/>
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.2"/>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.15.2-beta.1"/>
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3"/>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.15.3-beta.1"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -25,6 +25,7 @@
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing\ZB.MOM.WW.OtOpcUa.Driver.Modbus.Addressing.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
@@ -173,4 +174,65 @@ public static class DraftValidator
|
||||
di.DriverInstanceId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.3 Stream A.2 + task #148 part 2 — managed pre-publish guard for cluster
|
||||
/// topology vs. <see cref="ServerCluster.RedundancyMode"/>. The SQL
|
||||
/// <c>CK_ServerCluster_RedundancyMode_NodeCount</c> CHECK already enforces the
|
||||
/// (NodeCount, RedundancyMode) pair on the row itself, but it cannot see the
|
||||
/// <see cref="ClusterNode.Enabled"/> flag on child nodes — an operator can toggle
|
||||
/// nodes off (effective count = 1) while leaving RedundancyMode at Hot and the
|
||||
/// constraint stays green. This check catches that drift before publish so the
|
||||
/// runtime doesn't boot into a topology the <see cref="Enums.RedundancyMode"/> claims
|
||||
/// is invalid.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Called from the publish pipeline separately from <see cref="Validate"/> because the
|
||||
/// cluster/nodes rows aren't generation-versioned — they don't belong on
|
||||
/// <see cref="DraftSnapshot"/>. Returns every failing rule in one pass, same shape as
|
||||
/// <see cref="Validate"/>.
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<ValidationError> ValidateClusterTopology(
|
||||
ServerCluster cluster,
|
||||
IReadOnlyList<ClusterNode> clusterNodes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cluster);
|
||||
ArgumentNullException.ThrowIfNull(clusterNodes);
|
||||
|
||||
var errors = new List<ValidationError>();
|
||||
var enabledNodes = clusterNodes.Count(n => n.Enabled);
|
||||
|
||||
// Declared count must match declared mode (belt around the SQL CHECK).
|
||||
var declaredOk = (cluster.NodeCount, cluster.RedundancyMode) switch
|
||||
{
|
||||
(1, RedundancyMode.None) => true,
|
||||
(2, RedundancyMode.Warm) => true,
|
||||
(2, RedundancyMode.Hot) => true,
|
||||
_ => false,
|
||||
};
|
||||
if (!declaredOk)
|
||||
errors.Add(new("ClusterRedundancyModeInvalid",
|
||||
$"Cluster '{cluster.ClusterId}' declares NodeCount={cluster.NodeCount} + RedundancyMode={cluster.RedundancyMode}. " +
|
||||
$"Supported combinations: (1, None), (2, Warm), (2, Hot).",
|
||||
cluster.ClusterId));
|
||||
|
||||
// Enabled-node count must match declared count. Disabling a node to 1 while leaving
|
||||
// mode at Hot/Warm would boot the runtime into InvalidTopology band.
|
||||
if (enabledNodes != cluster.NodeCount)
|
||||
errors.Add(new("ClusterEnabledNodeCountMismatch",
|
||||
$"Cluster '{cluster.ClusterId}' declares NodeCount={cluster.NodeCount} but has {enabledNodes} Enabled nodes. " +
|
||||
$"Toggle the missing node(s) back on or change RedundancyMode/NodeCount to match.",
|
||||
cluster.ClusterId));
|
||||
|
||||
// Primary uniqueness — decision #84. Two Primary nodes is always an invariant violation
|
||||
// regardless of mode; catch it here so publish fails loud rather than the runtime
|
||||
// demoting both to ServiceLevelBand.InvalidTopology at boot.
|
||||
var primaryCount = clusterNodes.Count(n => n.Enabled && n.RedundancyRole == RedundancyRole.Primary);
|
||||
if (primaryCount > 1)
|
||||
errors.Add(new("ClusterMultiplePrimary",
|
||||
$"Cluster '{cluster.ClusterId}' has {primaryCount} Enabled Primary nodes. At most one Primary per cluster.",
|
||||
cluster.ClusterId));
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +269,15 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
AlarmState state, AlarmConditionState seed, DateTime nowUtc, CancellationToken ct)
|
||||
{
|
||||
var inputs = BuildReadCache(state.Inputs);
|
||||
|
||||
// Cold-start guard — skip the predicate when any referenced upstream tag has no
|
||||
// cached value yet (the upstream subscription hasn't delivered its first push).
|
||||
// Without this, predicates that cast `(double)ctx.GetTag(path).Value` throw NRE on
|
||||
// every tick until the cache fills, spamming the log with identical stack traces.
|
||||
// Bad quality is treated the same: the input isn't available at the predicate's
|
||||
// expected type, so the only defensible move is to hold the prior condition state.
|
||||
if (!AreInputsReady(inputs)) return seed;
|
||||
|
||||
var context = new AlarmPredicateContext(inputs, state.Logger, _clock);
|
||||
|
||||
bool predicateTrue;
|
||||
@@ -305,6 +314,27 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
||||
return d;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when every entry in <paramref name="cache"/> has a non-null value
|
||||
/// and a Good-quality <see cref="DataValueSnapshot.StatusCode"/>. A false here lets
|
||||
/// callers short-circuit script evaluation — predicates that unconditionally cast
|
||||
/// <c>ctx.GetTag(path).Value</c> to a numeric type would otherwise throw
|
||||
/// <see cref="NullReferenceException"/> until the upstream subscription delivers
|
||||
/// its first push.
|
||||
/// </summary>
|
||||
private static bool AreInputsReady(IReadOnlyDictionary<string, DataValueSnapshot> cache)
|
||||
{
|
||||
foreach (var kv in cache)
|
||||
{
|
||||
if (kv.Value is null || kv.Value.Value is null) return false;
|
||||
// OPC UA Part 4 StatusCode: bit 31 = severity 10 (Bad). Treat Good + Uncertain
|
||||
// as "ready"; Uncertain carries a value the script can still inspect + make a
|
||||
// qualified decision from. Only outright Bad is skipped.
|
||||
if ((kv.Value.StatusCode & 0x80000000u) != 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void EmitEvent(AlarmState state, AlarmConditionState condition, EmissionKind kind)
|
||||
{
|
||||
// Suppressed kind means shelving ate the emission — we don't fire for subscribers
|
||||
|
||||
@@ -228,6 +228,14 @@ public sealed class VirtualTagEngine : IDisposable
|
||||
try
|
||||
{
|
||||
var ctxCache = BuildReadCache(state.Reads);
|
||||
|
||||
// Cold-start guard — hold the prior value when any upstream input is still
|
||||
// unset or Bad-quality. Evaluating with nulls would throw inside the script
|
||||
// (scripts cast ctx.GetTag(path).Value directly) and produce a persistent
|
||||
// BadInternalError result until the upstream cache fills. Keeping the prior
|
||||
// snapshot is more honest: the virtual tag simply hasn't been computed yet.
|
||||
if (!AreInputsReady(ctxCache)) return;
|
||||
|
||||
var context = new VirtualTagContext(
|
||||
ctxCache,
|
||||
(p, v) => OnScriptSetVirtualTag(p, v),
|
||||
@@ -278,6 +286,23 @@ public sealed class VirtualTagEngine : IDisposable
|
||||
return map;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when every entry in <paramref name="cache"/> has a non-null value
|
||||
/// and a Good/Uncertain-quality <see cref="DataValueSnapshot.StatusCode"/>. Scripts
|
||||
/// cast <c>ctx.GetTag(path).Value</c> unconditionally; short-circuiting before the
|
||||
/// script runs keeps a not-yet-cached upstream from faulting the virtual tag with
|
||||
/// <c>BadInternalError</c>.
|
||||
/// </summary>
|
||||
private static bool AreInputsReady(IReadOnlyDictionary<string, DataValueSnapshot> cache)
|
||||
{
|
||||
foreach (var kv in cache)
|
||||
{
|
||||
if (kv.Value is null || kv.Value.Value is null) return false;
|
||||
if ((kv.Value.StatusCode & 0x80000000u) != 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnScriptSetVirtualTag(string path, object? value)
|
||||
{
|
||||
if (!_tags.ContainsKey(path))
|
||||
|
||||
@@ -19,6 +19,8 @@ public sealed class DriverFactoryRegistry
|
||||
{
|
||||
private readonly Dictionary<string, Func<string, string, IDriver>> _factories
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, DriverTier> _tiers
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
@@ -33,7 +35,15 @@ public sealed class DriverFactoryRegistry
|
||||
/// itself — the bootstrapper calls it via <see cref="DriverHost.RegisterAsync"/>
|
||||
/// so the host's per-driver retry semantics apply uniformly.
|
||||
/// </param>
|
||||
public void Register(string driverType, Func<string, string, IDriver> factory)
|
||||
/// <param name="tier">
|
||||
/// Stability tier per <c>docs/v2/driver-stability.md</c>. Defaults to
|
||||
/// <see cref="DriverTier.A"/> (in-process, lowest recycle footprint); Tier C
|
||||
/// drivers SHOULD pass this explicitly so the scheduled-recycle wiring in
|
||||
/// <c>DriverInstanceBootstrapper</c> can skip in-process drivers by tier check
|
||||
/// rather than by driver-type allow-list.
|
||||
/// </param>
|
||||
public void Register(string driverType, Func<string, string, IDriver> factory,
|
||||
DriverTier tier = DriverTier.A)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
||||
ArgumentNullException.ThrowIfNull(factory);
|
||||
@@ -43,6 +53,7 @@ public sealed class DriverFactoryRegistry
|
||||
throw new InvalidOperationException(
|
||||
$"DriverType '{driverType}' factory already registered for this process");
|
||||
_factories[driverType] = factory;
|
||||
_tiers[driverType] = tier;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +68,17 @@ public sealed class DriverFactoryRegistry
|
||||
lock (_lock) return _factories.GetValueOrDefault(driverType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Look up the tier recorded alongside the factory. Returns <see cref="DriverTier.A"/>
|
||||
/// for unknown driver types — a missing registration is already a skipped-bootstrap
|
||||
/// case upstream; we don't double-surface that failure here.
|
||||
/// </summary>
|
||||
public DriverTier GetTier(string driverType)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
||||
lock (_lock) return _tiers.GetValueOrDefault(driverType, DriverTier.A);
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<string> RegisteredTypes
|
||||
{
|
||||
get { lock (_lock) return [.. _factories.Keys]; }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
@@ -157,7 +158,7 @@ public static class EquipmentNodeWalker
|
||||
private static void AddTagVariable(IAddressSpaceBuilder equipmentBuilder, Tag tag)
|
||||
{
|
||||
var attr = new DriverAttributeInfo(
|
||||
FullName: tag.TagConfig,
|
||||
FullName: ExtractFullName(tag.TagConfig),
|
||||
DriverDataType: ParseDriverDataType(tag.DataType),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
@@ -166,6 +167,38 @@ public static class EquipmentNodeWalker
|
||||
equipmentBuilder.Variable(tag.Name, tag.Name, attr);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cross-driver TagConfig convention — the Config DB's <c>CK_Tag_TagConfig_IsJson</c>
|
||||
/// check constraint requires TagConfig to be a JSON object, and every shipped driver
|
||||
/// (Galaxy / Modbus / AB CIP / S7 / FOCAS / TwinCAT / ABLegacy) stores the wire-level
|
||||
/// address in a top-level <c>FullName</c> field. Extracting it here keeps the walker
|
||||
/// driver-agnostic while giving the driver the plain address string its backend
|
||||
/// expects at read-time — the raw JSON would otherwise be passed verbatim to
|
||||
/// <c>IReadable.ReadAsync</c> and the driver would fail to resolve the tag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Falls back to the raw <paramref name="tagConfig"/> if it doesn't parse as JSON or
|
||||
/// the <c>FullName</c> field is absent. This preserves the pre-refactor behaviour for
|
||||
/// any legacy row that slipped past the check constraint or any future driver that
|
||||
/// wants an opaque non-JSON reference.
|
||||
/// </remarks>
|
||||
internal static string ExtractFullName(string tagConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagConfig)) return tagConfig;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(tagConfig);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty("FullName", out var fullName)
|
||||
&& fullName.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return fullName.GetString() ?? tagConfig;
|
||||
}
|
||||
}
|
||||
catch (JsonException) { /* fall through */ }
|
||||
return tagConfig;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse <see cref="Tag.DataType"/> (stored as the <see cref="DriverDataType"/> enum
|
||||
/// name string, decision #138) into the enum value. Unknown names fall back to
|
||||
|
||||
@@ -28,6 +28,16 @@ public sealed record DriverResilienceOptions
|
||||
/// </summary>
|
||||
public int BulkheadMaxQueue { get; init; } = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Periodic scheduled recycle interval for Tier C out-of-process hosts, in seconds.
|
||||
/// Null (the default) = no scheduled recycle; the driver's Host process keeps running
|
||||
/// indefinitely unless a memory breach or operator action triggers a recycle. Only
|
||||
/// respected for <see cref="DriverTier.C"/>; Tier A/B recycle would tear down every
|
||||
/// OPC UA session, so the loader ignores non-null values for those tiers + logs a
|
||||
/// warning (per decisions #74 / #145).
|
||||
/// </summary>
|
||||
public int? RecycleIntervalSeconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Look up the effective policy for a capability, falling back to tier defaults when no
|
||||
/// override is configured. Never returns null.
|
||||
|
||||
@@ -91,12 +91,27 @@ public static class DriverResilienceOptionsParser
|
||||
}
|
||||
}
|
||||
|
||||
// Scheduled recycle is Tier C only — reject a configured interval on Tier A/B as a
|
||||
// misconfiguration surface rather than silently honouring it (recycling an in-process
|
||||
// driver would kill every OPC UA session + every co-hosted driver, per decision #74).
|
||||
int? recycleIntervalSeconds = null;
|
||||
if (shape.RecycleIntervalSeconds is int secs)
|
||||
{
|
||||
if (secs <= 0)
|
||||
parseDiagnostic ??= $"RecycleIntervalSeconds must be positive; got {secs} — ignoring.";
|
||||
else if (tier != DriverTier.C)
|
||||
parseDiagnostic ??= $"RecycleIntervalSeconds is Tier C only; Tier {tier} driver will not scheduled-recycle.";
|
||||
else
|
||||
recycleIntervalSeconds = secs;
|
||||
}
|
||||
|
||||
return new DriverResilienceOptions
|
||||
{
|
||||
Tier = tier,
|
||||
CapabilityPolicies = merged,
|
||||
BulkheadMaxConcurrent = shape.BulkheadMaxConcurrent ?? baseOptions.BulkheadMaxConcurrent,
|
||||
BulkheadMaxQueue = shape.BulkheadMaxQueue ?? baseOptions.BulkheadMaxQueue,
|
||||
RecycleIntervalSeconds = recycleIntervalSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,6 +119,7 @@ public static class DriverResilienceOptionsParser
|
||||
{
|
||||
public int? BulkheadMaxConcurrent { get; set; }
|
||||
public int? BulkheadMaxQueue { get; set; }
|
||||
public int? RecycleIntervalSeconds { get; set; }
|
||||
public Dictionary<string, CapabilityPolicyShape>? CapabilityPolicies { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Probes a Fanuc CNC: opens a FOCAS session + reads one PMC address. No public
|
||||
/// simulator exists — this command only produces meaningful results against a real
|
||||
/// CNC with Fwlib32.dll present. Against a dev box it surfaces
|
||||
/// <c>BadCommunicationError</c> (DLL missing) which is still a useful signal that
|
||||
/// the CLI wire-up is correct.
|
||||
/// Probes a Fanuc CNC: opens a FOCAS session + reads one PMC address. Uses the managed
|
||||
/// <c>WireFocasClient</c> on TCP:8193. Against an unreachable endpoint it surfaces
|
||||
/// <c>BadCommunicationError</c> which is still a useful signal that the CLI wire-up is
|
||||
/// correct. Also runs cleanly against the focas-mock Docker fixture in
|
||||
/// <c>tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Docker/</c>.
|
||||
/// </summary>
|
||||
[Command("probe", Description = "Verify the CNC is reachable + a sample FOCAS read succeeds.")]
|
||||
public sealed class ProbeCommand : FocasCommandBase
|
||||
|
||||
@@ -38,10 +38,9 @@ public abstract class FocasCommandBase : DriverCommandBase
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="FocasDriverOptions"/> with the CNC target this base collected
|
||||
/// + the tag list a subclass supplies. Probe disabled; the default
|
||||
/// <see cref="FwlibFocasClientFactory"/> attempts <c>Fwlib32.dll</c> P/Invoke, which
|
||||
/// throws <see cref="DllNotFoundException"/> at first call when the DLL is absent —
|
||||
/// surfaced through the driver as <c>BadCommunicationError</c>.
|
||||
/// + the tag list a subclass supplies. Probe disabled; the driver's default managed
|
||||
/// wire client opens a TCP:8193 session to the CNC and surfaces unreachable endpoints
|
||||
/// as <c>BadCommunicationError</c>.
|
||||
/// </summary>
|
||||
protected FocasDriverOptions BuildOptions(IReadOnlyList<FocasTagDefinition> tags) => new()
|
||||
{
|
||||
|
||||
@@ -4,9 +4,9 @@ return await new CliApplicationBuilder()
|
||||
.AddCommandsFromThisAssembly()
|
||||
.SetExecutableName("otopcua-focas-cli")
|
||||
.SetDescription(
|
||||
"OtOpcUa FOCAS test-client — ad-hoc probe + PMC/param/macro reads/writes + polled " +
|
||||
"subscriptions against Fanuc CNCs via the FOCAS/2 protocol. Requires a real CNC + a " +
|
||||
"licensed Fwlib32.dll on PATH (or next to the executable) — no public simulator " +
|
||||
"exists. Addresses use FocasAddressParser syntax: R100, X0.0, PARAM:1815/0, MACRO:500.")
|
||||
"OtOpcUa FOCAS test-client — ad-hoc probe + PMC/param/macro reads + polled " +
|
||||
"subscriptions against Fanuc CNCs via the FOCAS/2 protocol. Uses the managed " +
|
||||
"WireFocasClient on TCP:8193 directly; no native dependencies. Addresses use " +
|
||||
"FocasAddressParser syntax: R100, X0.0, PARAM:1815/0, MACRO:500.")
|
||||
.Build()
|
||||
.RunAsync(args);
|
||||
|
||||
@@ -22,4 +22,7 @@
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- CLI runs the managed WireFocasClient and talks to the CNC over TCP:8193
|
||||
directly — no Fwlib64.dll copy step needed. -->
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="IFocasBackend"/> for tests + an operational stub mode when
|
||||
/// <c>OTOPCUA_FOCAS_BACKEND=fake</c>. Keeps per-address values keyed by a canonical
|
||||
/// string; RMW semantics honor PMC bit-writes against the containing byte so the
|
||||
/// <c>PmcBitWriteRequest</c> path can be exercised end-to-end without hardware.
|
||||
/// </summary>
|
||||
public sealed class FakeFocasBackend : IFocasBackend
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private long _nextSessionId;
|
||||
private readonly HashSet<long> _openSessions = [];
|
||||
private readonly Dictionary<string, byte[]> _pmcValues = [];
|
||||
private readonly Dictionary<string, byte[]> _paramValues = [];
|
||||
private readonly Dictionary<string, byte[]> _macroValues = [];
|
||||
|
||||
public Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest request, CancellationToken ct)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var id = ++_nextSessionId;
|
||||
_openSessions.Add(id);
|
||||
return Task.FromResult(new OpenSessionResponse { Success = true, SessionId = id });
|
||||
}
|
||||
}
|
||||
|
||||
public Task CloseSessionAsync(CloseSessionRequest request, CancellationToken ct)
|
||||
{
|
||||
lock (_gate) { _openSessions.Remove(request.SessionId); }
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<ReadResponse> ReadAsync(ReadRequest request, CancellationToken ct)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_openSessions.Contains(request.SessionId))
|
||||
return Task.FromResult(new ReadResponse { Success = false, StatusCode = 0x80020000u, Error = "session-not-open" });
|
||||
|
||||
var store = StoreFor(request.Address.Kind);
|
||||
var key = CanonicalKey(request.Address);
|
||||
store.TryGetValue(key, out var value);
|
||||
return Task.FromResult(new ReadResponse
|
||||
{
|
||||
Success = true,
|
||||
StatusCode = 0,
|
||||
ValueBytes = value ?? MessagePackSerializer.Serialize((int)0),
|
||||
ValueTypeCode = request.DataType,
|
||||
SourceTimestampUtcUnixMs = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public Task<WriteResponse> WriteAsync(WriteRequest request, CancellationToken ct)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_openSessions.Contains(request.SessionId))
|
||||
return Task.FromResult(new WriteResponse { Success = false, StatusCode = 0x80020000u, Error = "session-not-open" });
|
||||
|
||||
var store = StoreFor(request.Address.Kind);
|
||||
store[CanonicalKey(request.Address)] = request.ValueBytes ?? [];
|
||||
return Task.FromResult(new WriteResponse { Success = true, StatusCode = 0 });
|
||||
}
|
||||
}
|
||||
|
||||
public Task<PmcBitWriteResponse> PmcBitWriteAsync(PmcBitWriteRequest request, CancellationToken ct)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_openSessions.Contains(request.SessionId))
|
||||
return Task.FromResult(new PmcBitWriteResponse { Success = false, StatusCode = 0x80020000u, Error = "session-not-open" });
|
||||
if (request.BitIndex is < 0 or > 7)
|
||||
return Task.FromResult(new PmcBitWriteResponse { Success = false, StatusCode = 0x803C0000u, Error = "bit-out-of-range" });
|
||||
|
||||
var key = CanonicalKey(request.Address);
|
||||
_pmcValues.TryGetValue(key, out var current);
|
||||
current ??= MessagePackSerializer.Serialize((byte)0);
|
||||
var b = MessagePackSerializer.Deserialize<byte>(current);
|
||||
var mask = (byte)(1 << request.BitIndex);
|
||||
b = request.Value ? (byte)(b | mask) : (byte)(b & ~mask);
|
||||
_pmcValues[key] = MessagePackSerializer.Serialize(b);
|
||||
return Task.FromResult(new PmcBitWriteResponse { Success = true, StatusCode = 0 });
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ProbeResponse> ProbeAsync(ProbeRequest request, CancellationToken ct)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return Task.FromResult(new ProbeResponse
|
||||
{
|
||||
Healthy = _openSessions.Contains(request.SessionId),
|
||||
ObservedAtUtcUnixMs = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, byte[]> StoreFor(int kind) => kind switch
|
||||
{
|
||||
0 => _pmcValues,
|
||||
1 => _paramValues,
|
||||
2 => _macroValues,
|
||||
_ => _pmcValues,
|
||||
};
|
||||
|
||||
private static string CanonicalKey(FocasAddressDto addr) =>
|
||||
addr.Kind switch
|
||||
{
|
||||
0 => $"{addr.PmcLetter}{addr.Number}",
|
||||
1 => $"P{addr.Number}",
|
||||
2 => $"M{addr.Number}",
|
||||
_ => $"?{addr.Number}",
|
||||
};
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
|
||||
|
||||
/// <summary>
|
||||
/// The Host's view of a FOCAS session. One implementation wraps the real
|
||||
/// <c>Fwlib32.dll</c> via P/Invoke (lands with the real Fwlib32 integration follow-up,
|
||||
/// since no hardware is available today); a second implementation —
|
||||
/// <see cref="FakeFocasBackend"/> — is used by tests.
|
||||
/// Both live on .NET 4.8 x86 so the Host can be deployed in either mode without
|
||||
/// changing the pipe server.
|
||||
/// Invoked via <c>FwlibFrameHandler</c> in the Ipc namespace.
|
||||
/// </summary>
|
||||
public interface IFocasBackend
|
||||
{
|
||||
Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest request, CancellationToken ct);
|
||||
Task CloseSessionAsync(CloseSessionRequest request, CancellationToken ct);
|
||||
Task<ReadResponse> ReadAsync(ReadRequest request, CancellationToken ct);
|
||||
Task<WriteResponse> WriteAsync(WriteRequest request, CancellationToken ct);
|
||||
Task<PmcBitWriteResponse> PmcBitWriteAsync(PmcBitWriteRequest request, CancellationToken ct);
|
||||
Task<ProbeResponse> ProbeAsync(ProbeRequest request, CancellationToken ct);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
|
||||
|
||||
/// <summary>
|
||||
/// Safe default when the deployment hasn't configured a real Fwlib32 backend.
|
||||
/// Returns structured failure responses instead of throwing so the Proxy can map the
|
||||
/// error to <c>BadDeviceFailure</c> and surface a clear operator message pointing at
|
||||
/// <c>docs/v2/focas-deployment.md</c>. Used when <c>OTOPCUA_FOCAS_BACKEND</c> is unset
|
||||
/// or set to <c>unconfigured</c>.
|
||||
/// </summary>
|
||||
public sealed class UnconfiguredFocasBackend : IFocasBackend
|
||||
{
|
||||
private const uint BadDeviceFailure = 0x80550000u;
|
||||
private const string Reason =
|
||||
"FOCAS Host is running without a real Fwlib32 backend. Set OTOPCUA_FOCAS_BACKEND=fwlib32 " +
|
||||
"and ensure Fwlib32.dll is on PATH — see docs/v2/focas-deployment.md.";
|
||||
|
||||
public Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest request, CancellationToken ct) =>
|
||||
Task.FromResult(new OpenSessionResponse { Success = false, Error = Reason, ErrorCode = "NoFwlibBackend" });
|
||||
|
||||
public Task CloseSessionAsync(CloseSessionRequest request, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<ReadResponse> ReadAsync(ReadRequest request, CancellationToken ct) =>
|
||||
Task.FromResult(new ReadResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason });
|
||||
|
||||
public Task<WriteResponse> WriteAsync(WriteRequest request, CancellationToken ct) =>
|
||||
Task.FromResult(new WriteResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason });
|
||||
|
||||
public Task<PmcBitWriteResponse> PmcBitWriteAsync(PmcBitWriteRequest request, CancellationToken ct) =>
|
||||
Task.FromResult(new PmcBitWriteResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason });
|
||||
|
||||
public Task<ProbeResponse> ProbeAsync(ProbeRequest request, CancellationToken ct) =>
|
||||
Task.FromResult(new ProbeResponse { Healthy = false, Error = Reason, ObservedAtUtcUnixMs = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() });
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Real FOCAS frame handler. Deserializes each request DTO, delegates to
|
||||
/// <see cref="IFocasBackend"/>, re-serializes the response. The backend owns the
|
||||
/// Fwlib32 handle + STA thread — the handler is pure dispatch.
|
||||
/// </summary>
|
||||
public sealed class FwlibFrameHandler : IFrameHandler
|
||||
{
|
||||
private readonly IFocasBackend _backend;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public FwlibFrameHandler(IFocasBackend backend, ILogger logger)
|
||||
{
|
||||
_backend = backend ?? throw new ArgumentNullException(nameof(backend));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task HandleAsync(FocasMessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (kind)
|
||||
{
|
||||
case FocasMessageKind.Heartbeat:
|
||||
{
|
||||
var hb = MessagePackSerializer.Deserialize<Heartbeat>(body);
|
||||
await writer.WriteAsync(FocasMessageKind.HeartbeatAck,
|
||||
new HeartbeatAck
|
||||
{
|
||||
MonotonicTicks = hb.MonotonicTicks,
|
||||
HostUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
}, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
case FocasMessageKind.OpenSessionRequest:
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<OpenSessionRequest>(body);
|
||||
var resp = await _backend.OpenSessionAsync(req, ct).ConfigureAwait(false);
|
||||
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse, resp, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
case FocasMessageKind.CloseSessionRequest:
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<CloseSessionRequest>(body);
|
||||
await _backend.CloseSessionAsync(req, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
case FocasMessageKind.ReadRequest:
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<ReadRequest>(body);
|
||||
var resp = await _backend.ReadAsync(req, ct).ConfigureAwait(false);
|
||||
await writer.WriteAsync(FocasMessageKind.ReadResponse, resp, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
case FocasMessageKind.WriteRequest:
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<WriteRequest>(body);
|
||||
var resp = await _backend.WriteAsync(req, ct).ConfigureAwait(false);
|
||||
await writer.WriteAsync(FocasMessageKind.WriteResponse, resp, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
case FocasMessageKind.PmcBitWriteRequest:
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<PmcBitWriteRequest>(body);
|
||||
var resp = await _backend.PmcBitWriteAsync(req, ct).ConfigureAwait(false);
|
||||
await writer.WriteAsync(FocasMessageKind.PmcBitWriteResponse, resp, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
case FocasMessageKind.ProbeRequest:
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<ProbeRequest>(body);
|
||||
var resp = await _backend.ProbeAsync(req, ct).ConfigureAwait(false);
|
||||
await writer.WriteAsync(FocasMessageKind.ProbeResponse, resp, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
default:
|
||||
await writer.WriteAsync(FocasMessageKind.ErrorResponse,
|
||||
new ErrorResponse { Code = "unknown-kind", Message = $"Kind {kind} is not handled by the Host" },
|
||||
ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "FwlibFrameHandler error processing {Kind}", kind);
|
||||
await writer.WriteAsync(FocasMessageKind.ErrorResponse,
|
||||
new ErrorResponse { Code = "backend-exception", Message = ex.Message },
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public IDisposable AttachConnection(FrameWriter writer) => IFrameHandler.NoopAttachment.Instance;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches a single IPC frame to the backend. Implementations own the FOCAS session
|
||||
/// state and translate request DTOs into Fwlib32 calls.
|
||||
/// </summary>
|
||||
public interface IFrameHandler
|
||||
{
|
||||
Task HandleAsync(FocasMessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Called once per accepted connection after the Hello handshake. Lets the handler
|
||||
/// attach server-pushed event sinks (data-change notifications, runtime-status
|
||||
/// changes) to the connection's <paramref name="writer"/>. Returns an
|
||||
/// <see cref="IDisposable"/> the pipe server disposes when the connection closes —
|
||||
/// backends use it to unsubscribe from their push sources.
|
||||
/// </summary>
|
||||
IDisposable AttachConnection(FrameWriter writer);
|
||||
|
||||
public sealed class NoopAttachment : IDisposable
|
||||
{
|
||||
public static readonly NoopAttachment Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using System;
|
||||
using System.IO.Pipes;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <see cref="PipeSecurity"/> for the FOCAS Host pipe. Same pattern as
|
||||
/// Galaxy.Host: only the configured OtOpcUa server principal SID gets
|
||||
/// <c>ReadWrite | Synchronize</c>; LocalSystem + Administrators are explicitly denied
|
||||
/// so a compromised service account on the same host can't escalate via the pipe.
|
||||
/// </summary>
|
||||
public static class PipeAcl
|
||||
{
|
||||
public static PipeSecurity Create(SecurityIdentifier allowedSid)
|
||||
{
|
||||
if (allowedSid is null) throw new ArgumentNullException(nameof(allowedSid));
|
||||
|
||||
var security = new PipeSecurity();
|
||||
|
||||
security.AddAccessRule(new PipeAccessRule(
|
||||
allowedSid,
|
||||
PipeAccessRights.ReadWrite | PipeAccessRights.Synchronize,
|
||||
AccessControlType.Allow));
|
||||
|
||||
var localSystem = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
|
||||
var admins = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
|
||||
|
||||
if (allowedSid != localSystem)
|
||||
security.AddAccessRule(new PipeAccessRule(localSystem, PipeAccessRights.FullControl, AccessControlType.Deny));
|
||||
if (allowedSid != admins)
|
||||
security.AddAccessRule(new PipeAccessRule(admins, PipeAccessRights.FullControl, AccessControlType.Deny));
|
||||
|
||||
security.SetOwner(allowedSid);
|
||||
|
||||
return security;
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
using System;
|
||||
using System.IO.Pipes;
|
||||
using System.Security.Principal;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Accepts one client connection at a time on the FOCAS Host's named pipe with the
|
||||
/// strict ACL from <see cref="PipeAcl"/>. Verifies the peer SID + per-process shared
|
||||
/// secret before any RPC frame is accepted. Mirrors the Galaxy.Host pipe server byte for
|
||||
/// byte — different MessageKind enum, same negotiation semantics.
|
||||
/// </summary>
|
||||
public sealed class PipeServer : IDisposable
|
||||
{
|
||||
private readonly string _pipeName;
|
||||
private readonly SecurityIdentifier _allowedSid;
|
||||
private readonly string _sharedSecret;
|
||||
private readonly ILogger _logger;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private NamedPipeServerStream? _current;
|
||||
|
||||
public PipeServer(string pipeName, SecurityIdentifier allowedSid, string sharedSecret, ILogger logger)
|
||||
{
|
||||
_pipeName = pipeName ?? throw new ArgumentNullException(nameof(pipeName));
|
||||
_allowedSid = allowedSid ?? throw new ArgumentNullException(nameof(allowedSid));
|
||||
_sharedSecret = sharedSecret ?? throw new ArgumentNullException(nameof(sharedSecret));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task RunOneConnectionAsync(IFrameHandler handler, CancellationToken ct)
|
||||
{
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, ct);
|
||||
var acl = PipeAcl.Create(_allowedSid);
|
||||
|
||||
_current = new NamedPipeServerStream(
|
||||
_pipeName,
|
||||
PipeDirection.InOut,
|
||||
maxNumberOfServerInstances: 1,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous,
|
||||
inBufferSize: 64 * 1024,
|
||||
outBufferSize: 64 * 1024,
|
||||
pipeSecurity: acl);
|
||||
|
||||
try
|
||||
{
|
||||
await _current.WaitForConnectionAsync(linked.Token).ConfigureAwait(false);
|
||||
|
||||
if (!VerifyCaller(_current, out var reason))
|
||||
{
|
||||
_logger.Warning("FOCAS IPC caller rejected: {Reason}", reason);
|
||||
_current.Disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
using var reader = new FrameReader(_current, leaveOpen: true);
|
||||
using var writer = new FrameWriter(_current, leaveOpen: true);
|
||||
|
||||
var first = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
|
||||
if (first is null || first.Value.Kind != FocasMessageKind.Hello)
|
||||
{
|
||||
_logger.Warning("FOCAS IPC first frame was not Hello; dropping");
|
||||
return;
|
||||
}
|
||||
|
||||
var hello = MessagePackSerializer.Deserialize<Hello>(first.Value.Body);
|
||||
if (!string.Equals(hello.SharedSecret, _sharedSecret, StringComparison.Ordinal))
|
||||
{
|
||||
await writer.WriteAsync(FocasMessageKind.HelloAck,
|
||||
new HelloAck { Accepted = false, RejectReason = "shared-secret-mismatch" },
|
||||
linked.Token).ConfigureAwait(false);
|
||||
_logger.Warning("FOCAS IPC Hello rejected: shared-secret-mismatch");
|
||||
return;
|
||||
}
|
||||
|
||||
if (hello.ProtocolMajor != Hello.CurrentMajor)
|
||||
{
|
||||
await writer.WriteAsync(FocasMessageKind.HelloAck,
|
||||
new HelloAck
|
||||
{
|
||||
Accepted = false,
|
||||
RejectReason = $"major-version-mismatch-peer={hello.ProtocolMajor}-server={Hello.CurrentMajor}",
|
||||
},
|
||||
linked.Token).ConfigureAwait(false);
|
||||
_logger.Warning("FOCAS IPC Hello rejected: major mismatch peer={Peer} server={Server}",
|
||||
hello.ProtocolMajor, Hello.CurrentMajor);
|
||||
return;
|
||||
}
|
||||
|
||||
await writer.WriteAsync(FocasMessageKind.HelloAck,
|
||||
new HelloAck { Accepted = true, HostName = Environment.MachineName },
|
||||
linked.Token).ConfigureAwait(false);
|
||||
|
||||
using var attachment = handler.AttachConnection(writer);
|
||||
|
||||
while (!linked.Token.IsCancellationRequested)
|
||||
{
|
||||
var frame = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
|
||||
if (frame is null) break;
|
||||
|
||||
await handler.HandleAsync(frame.Value.Kind, frame.Value.Body, writer, linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_current.Dispose();
|
||||
_current = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RunAsync(IFrameHandler handler, CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await RunOneConnectionAsync(handler, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex) { _logger.Error(ex, "FOCAS IPC connection loop error — accepting next"); }
|
||||
}
|
||||
}
|
||||
|
||||
private bool VerifyCaller(NamedPipeServerStream pipe, out string reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
pipe.RunAsClient(() =>
|
||||
{
|
||||
using var wi = WindowsIdentity.GetCurrent();
|
||||
if (wi.User is null)
|
||||
throw new InvalidOperationException("GetCurrent().User is null — cannot verify caller");
|
||||
if (wi.User != _allowedSid)
|
||||
throw new UnauthorizedAccessException(
|
||||
$"caller SID {wi.User.Value} does not match allowed {_allowedSid.Value}");
|
||||
});
|
||||
reason = string.Empty;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) { reason = ex.Message; return false; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_current?.Dispose();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder handler that returns <c>ErrorResponse{Code=not-implemented}</c> for every
|
||||
/// FOCAS data-plane request. Exists so PR B can ship the pipe server + ACL + handshake
|
||||
/// plumbing before PR C moves the Fwlib32 calls. Heartbeats are handled fully so the
|
||||
/// supervisor's liveness detector stays happy.
|
||||
/// </summary>
|
||||
public sealed class StubFrameHandler : IFrameHandler
|
||||
{
|
||||
public Task HandleAsync(FocasMessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct)
|
||||
{
|
||||
if (kind == FocasMessageKind.Heartbeat)
|
||||
{
|
||||
var hb = MessagePackSerializer.Deserialize<Heartbeat>(body);
|
||||
return writer.WriteAsync(FocasMessageKind.HeartbeatAck,
|
||||
new HeartbeatAck
|
||||
{
|
||||
MonotonicTicks = hb.MonotonicTicks,
|
||||
HostUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
}, ct);
|
||||
}
|
||||
|
||||
return writer.WriteAsync(FocasMessageKind.ErrorResponse,
|
||||
new ErrorResponse
|
||||
{
|
||||
Code = "not-implemented",
|
||||
Message = $"Kind {kind} is stubbed — Fwlib32 lift lands in PR C",
|
||||
},
|
||||
ct);
|
||||
}
|
||||
|
||||
public IDisposable AttachConnection(FrameWriter writer) => IFrameHandler.NoopAttachment.Instance;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
using System;
|
||||
using System.Security.Principal;
|
||||
using System.Threading;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Entry point for the <c>OtOpcUaFocasHost</c> Windows service / console host. The
|
||||
/// supervisor (Proxy-side) spawns this process per FOCAS driver instance and passes the
|
||||
/// pipe name, allowed-SID, and per-process shared secret as environment variables. In
|
||||
/// PR B the backend is <see cref="StubFrameHandler"/> — PR C swaps in the real
|
||||
/// Fwlib32-backed handler once the session state + STA thread move out of the .NET 10
|
||||
/// driver.
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.File(
|
||||
@"%ProgramData%\OtOpcUa\focas-host-.log".Replace("%ProgramData%", Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData)),
|
||||
rollingInterval: RollingInterval.Day)
|
||||
.CreateLogger();
|
||||
|
||||
try
|
||||
{
|
||||
var pipeName = Environment.GetEnvironmentVariable("OTOPCUA_FOCAS_PIPE") ?? "OtOpcUaFocas";
|
||||
var allowedSidValue = Environment.GetEnvironmentVariable("OTOPCUA_ALLOWED_SID")
|
||||
?? throw new InvalidOperationException(
|
||||
"OTOPCUA_ALLOWED_SID not set — the FOCAS Proxy supervisor must pass the server principal SID");
|
||||
var sharedSecret = Environment.GetEnvironmentVariable("OTOPCUA_FOCAS_SECRET")
|
||||
?? throw new InvalidOperationException(
|
||||
"OTOPCUA_FOCAS_SECRET not set — the FOCAS Proxy supervisor must pass the per-process secret at spawn time");
|
||||
|
||||
var allowedSid = new SecurityIdentifier(allowedSidValue);
|
||||
|
||||
using var server = new PipeServer(pipeName, allowedSid, sharedSecret, Log.Logger);
|
||||
using var cts = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
|
||||
|
||||
Log.Information("OtOpcUaFocasHost starting — pipe={Pipe} allowedSid={Sid}",
|
||||
pipeName, allowedSidValue);
|
||||
|
||||
var backendKind = (Environment.GetEnvironmentVariable("OTOPCUA_FOCAS_BACKEND") ?? "unconfigured")
|
||||
.ToLowerInvariant();
|
||||
IFocasBackend backend = backendKind switch
|
||||
{
|
||||
"fake" => new FakeFocasBackend(),
|
||||
"unconfigured" => new UnconfiguredFocasBackend(),
|
||||
"fwlib32" => new UnconfiguredFocasBackend(), // real Fwlib32 backend lands with hardware integration follow-up
|
||||
_ => new UnconfiguredFocasBackend(),
|
||||
};
|
||||
Log.Information("OtOpcUaFocasHost backend={Backend}", backendKind);
|
||||
|
||||
var handler = new FwlibFrameHandler(backend, Log.Logger);
|
||||
server.RunAsync(handler, cts.Token).GetAwaiter().GetResult();
|
||||
|
||||
Log.Information("OtOpcUaFocasHost stopped cleanly");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "OtOpcUaFocasHost fatal");
|
||||
return 2;
|
||||
}
|
||||
finally { Log.CloseAndFlush(); }
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.MemoryMappedFiles;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Stability;
|
||||
|
||||
/// <summary>
|
||||
/// Ring-buffer of the last N IPC operations, written into a memory-mapped file. On a
|
||||
/// hard crash the Proxy-side supervisor reads the MMF after the corpse is gone to see
|
||||
/// what was in flight at the moment the Host died. Single-writer (the Host), multi-reader
|
||||
/// (the supervisor) — the file format is identical to the Galaxy Tier-C
|
||||
/// <c>PostMortemMmf</c> so a single reader tool can work both.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// File layout:
|
||||
/// <code>
|
||||
/// [16-byte header: magic(4) | version(4) | capacity(4) | writeIndex(4)]
|
||||
/// [capacity × 256-byte entries: each is [8-byte utcUnixMs | 8-byte opKind | 240-byte UTF-8 message]]
|
||||
/// </code>
|
||||
/// Magic is 'OFPC' (0x4F46_5043) to distinguish a FOCAS file from the Galaxy MMF.
|
||||
/// </remarks>
|
||||
public sealed class PostMortemMmf : IDisposable
|
||||
{
|
||||
private const int Magic = 0x4F465043; // 'OFPC'
|
||||
private const int Version = 1;
|
||||
private const int HeaderBytes = 16;
|
||||
public const int EntryBytes = 256;
|
||||
private const int MessageOffset = 16;
|
||||
private const int MessageCapacity = EntryBytes - MessageOffset;
|
||||
|
||||
public int Capacity { get; }
|
||||
public string Path { get; }
|
||||
|
||||
private readonly MemoryMappedFile _mmf;
|
||||
private readonly MemoryMappedViewAccessor _accessor;
|
||||
private readonly object _writeGate = new();
|
||||
|
||||
public PostMortemMmf(string path, int capacity = 1000)
|
||||
{
|
||||
if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity));
|
||||
Capacity = capacity;
|
||||
Path = path;
|
||||
|
||||
var fileBytes = HeaderBytes + capacity * EntryBytes;
|
||||
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!);
|
||||
|
||||
var fs = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read);
|
||||
fs.SetLength(fileBytes);
|
||||
_mmf = MemoryMappedFile.CreateFromFile(fs, null, fileBytes,
|
||||
MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, leaveOpen: false);
|
||||
_accessor = _mmf.CreateViewAccessor(0, fileBytes, MemoryMappedFileAccess.ReadWrite);
|
||||
|
||||
if (_accessor.ReadInt32(0) != Magic)
|
||||
{
|
||||
_accessor.Write(0, Magic);
|
||||
_accessor.Write(4, Version);
|
||||
_accessor.Write(8, capacity);
|
||||
_accessor.Write(12, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public void Write(long opKind, string message)
|
||||
{
|
||||
lock (_writeGate)
|
||||
{
|
||||
var idx = _accessor.ReadInt32(12);
|
||||
var offset = HeaderBytes + idx * EntryBytes;
|
||||
|
||||
_accessor.Write(offset + 0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
_accessor.Write(offset + 8, opKind);
|
||||
|
||||
var msgBytes = Encoding.UTF8.GetBytes(message ?? string.Empty);
|
||||
var copy = Math.Min(msgBytes.Length, MessageCapacity - 1);
|
||||
_accessor.WriteArray(offset + MessageOffset, msgBytes, 0, copy);
|
||||
_accessor.Write(offset + MessageOffset + copy, (byte)0);
|
||||
|
||||
var next = (idx + 1) % Capacity;
|
||||
_accessor.Write(12, next);
|
||||
}
|
||||
}
|
||||
|
||||
public PostMortemEntry[] ReadAll()
|
||||
{
|
||||
var magic = _accessor.ReadInt32(0);
|
||||
if (magic != Magic) return new PostMortemEntry[0];
|
||||
|
||||
var capacity = _accessor.ReadInt32(8);
|
||||
var writeIndex = _accessor.ReadInt32(12);
|
||||
|
||||
var entries = new PostMortemEntry[capacity];
|
||||
var count = 0;
|
||||
for (var i = 0; i < capacity; i++)
|
||||
{
|
||||
var slot = (writeIndex + i) % capacity;
|
||||
var offset = HeaderBytes + slot * EntryBytes;
|
||||
|
||||
var ts = _accessor.ReadInt64(offset + 0);
|
||||
if (ts == 0) continue;
|
||||
|
||||
var op = _accessor.ReadInt64(offset + 8);
|
||||
var msgBuf = new byte[MessageCapacity];
|
||||
_accessor.ReadArray(offset + MessageOffset, msgBuf, 0, MessageCapacity);
|
||||
var nulTerm = Array.IndexOf<byte>(msgBuf, 0);
|
||||
var msg = Encoding.UTF8.GetString(msgBuf, 0, nulTerm < 0 ? MessageCapacity : nulTerm);
|
||||
|
||||
entries[count++] = new PostMortemEntry(ts, op, msg);
|
||||
}
|
||||
|
||||
Array.Resize(ref entries, count);
|
||||
return entries;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_accessor.Dispose();
|
||||
_mmf.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct PostMortemEntry
|
||||
{
|
||||
public long UtcUnixMs { get; }
|
||||
public long OpKind { get; }
|
||||
public string Message { get; }
|
||||
|
||||
public PostMortemEntry(long utcUnixMs, long opKind, string message)
|
||||
{
|
||||
UtcUnixMs = utcUnixMs;
|
||||
OpKind = opKind;
|
||||
Message = message;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<!-- Fwlib32.dll is 32-bit only — x86 target is mandatory. Matches the Galaxy.Host
|
||||
bitness constraint but for a different native library. -->
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<Prefer32Bit>true</Prefer32Bit>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host</RootNamespace>
|
||||
<AssemblyName>OtOpcUa.Driver.FOCAS.Host</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.IO.Pipes.AccessControl" Version="5.0.0"/>
|
||||
<PackageReference Include="System.Memory" Version="4.5.5"/>
|
||||
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.5.4"/>
|
||||
<PackageReference Include="Serilog" Version="4.2.0"/>
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,39 +0,0 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Wire shape for a parsed FOCAS address. Mirrors <c>FocasAddress</c> in the driver
|
||||
/// package but lives in Shared so the Host (.NET 4.8) can decode without taking a
|
||||
/// reference to the .NET 10 driver assembly. The Proxy serializes from its own
|
||||
/// <c>FocasAddress</c>; the Host maps back to its local equivalent.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class FocasAddressDto
|
||||
{
|
||||
/// <summary>0 = Pmc, 1 = Parameter, 2 = Macro. Matches <c>FocasAreaKind</c> enum order.</summary>
|
||||
[Key(0)] public int Kind { get; set; }
|
||||
|
||||
/// <summary>PMC letter — null for Parameter / Macro.</summary>
|
||||
[Key(1)] public string? PmcLetter { get; set; }
|
||||
|
||||
[Key(2)] public int Number { get; set; }
|
||||
|
||||
/// <summary>Optional bit index (0-7 for PMC, 0-31 for Parameter).</summary>
|
||||
[Key(3)] public int? BitIndex { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 0 = Bit, 1 = Byte, 2 = Int16, 3 = Int32, 4 = Float32, 5 = Float64, 6 = String.
|
||||
/// Matches <c>FocasDataType</c> enum order so both sides can cast <c>(int)</c>.
|
||||
/// </summary>
|
||||
public static class FocasDataTypeCode
|
||||
{
|
||||
public const int Bit = 0;
|
||||
public const int Byte = 1;
|
||||
public const int Int16 = 2;
|
||||
public const int Int32 = 3;
|
||||
public const int Float32 = 4;
|
||||
public const int Float64 = 5;
|
||||
public const int String = 6;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Length-prefixed framing. Each IPC frame is:
|
||||
/// <c>[4-byte big-endian length][1-byte message kind][MessagePack body]</c>.
|
||||
/// Length is the body size only; the kind byte is not part of the prefixed length.
|
||||
/// Mirrors the Galaxy Tier-C framing so operators see one wire format across hosts.
|
||||
/// </summary>
|
||||
public static class Framing
|
||||
{
|
||||
public const int LengthPrefixSize = 4;
|
||||
public const int KindByteSize = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum permitted body length (16 MiB). Protects the receiver from a hostile or
|
||||
/// misbehaving peer sending an oversized length prefix.
|
||||
/// </summary>
|
||||
public const int MaxFrameBodyBytes = 16 * 1024 * 1024;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire identifier for each contract. Values are stable — new contracts append, never
|
||||
/// reuse. Ranges kept aligned with Galaxy so an operator reading a hex dump doesn't have
|
||||
/// to context-switch between drivers.
|
||||
/// </summary>
|
||||
public enum FocasMessageKind : byte
|
||||
{
|
||||
Hello = 0x01,
|
||||
HelloAck = 0x02,
|
||||
Heartbeat = 0x03,
|
||||
HeartbeatAck = 0x04,
|
||||
|
||||
OpenSessionRequest = 0x10,
|
||||
OpenSessionResponse = 0x11,
|
||||
CloseSessionRequest = 0x12,
|
||||
|
||||
ReadRequest = 0x30,
|
||||
ReadResponse = 0x31,
|
||||
WriteRequest = 0x32,
|
||||
WriteResponse = 0x33,
|
||||
PmcBitWriteRequest = 0x34,
|
||||
PmcBitWriteResponse = 0x35,
|
||||
|
||||
SubscribeRequest = 0x40,
|
||||
SubscribeResponse = 0x41,
|
||||
UnsubscribeRequest = 0x42,
|
||||
OnDataChangeNotification = 0x43,
|
||||
|
||||
ProbeRequest = 0x70,
|
||||
ProbeResponse = 0x71,
|
||||
RuntimeStatusChange = 0x72,
|
||||
|
||||
RecycleHostRequest = 0xF0,
|
||||
RecycleStatusResponse = 0xF1,
|
||||
|
||||
ErrorResponse = 0xFE,
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// First frame of every FOCAS Proxy -> Host connection. Advertises protocol major/minor
|
||||
/// and the per-process shared secret the Proxy passed to the Host at spawn time. Major
|
||||
/// mismatch is fatal; minor is advisory.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class Hello
|
||||
{
|
||||
public const int CurrentMajor = 1;
|
||||
public const int CurrentMinor = 0;
|
||||
|
||||
[Key(0)] public int ProtocolMajor { get; set; } = CurrentMajor;
|
||||
[Key(1)] public int ProtocolMinor { get; set; } = CurrentMinor;
|
||||
[Key(2)] public string PeerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Per-process shared secret verified on the Host side against the value passed by the
|
||||
/// supervisor at spawn time. Protects against a local attacker connecting to the pipe
|
||||
/// after authenticating via the pipe ACL.
|
||||
/// </summary>
|
||||
[Key(3)] public string SharedSecret { get; set; } = string.Empty;
|
||||
|
||||
[Key(4)] public string[] Features { get; set; } = System.Array.Empty<string>();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class HelloAck
|
||||
{
|
||||
[Key(0)] public int ProtocolMajor { get; set; } = Hello.CurrentMajor;
|
||||
[Key(1)] public int ProtocolMinor { get; set; } = Hello.CurrentMinor;
|
||||
|
||||
/// <summary>True if the Host accepted the hello; false + <see cref="RejectReason"/> filled if not.</summary>
|
||||
[Key(2)] public bool Accepted { get; set; }
|
||||
[Key(3)] public string? RejectReason { get; set; }
|
||||
|
||||
[Key(4)] public string HostName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class Heartbeat
|
||||
{
|
||||
[Key(0)] public long MonotonicTicks { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class HeartbeatAck
|
||||
{
|
||||
[Key(0)] public long MonotonicTicks { get; set; }
|
||||
[Key(1)] public long HostUtcUnixMs { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ErrorResponse
|
||||
{
|
||||
/// <summary>Stable symbolic code — e.g. <c>InvalidAddress</c>, <c>SessionNotFound</c>, <c>Fwlib32Crashed</c>.</summary>
|
||||
[Key(0)] public string Code { get; set; } = string.Empty;
|
||||
|
||||
[Key(1)] public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||
|
||||
/// <summary>Lightweight connectivity probe — maps to <c>cnc_rdcncstat</c> on the Host.</summary>
|
||||
[MessagePackObject]
|
||||
public sealed class ProbeRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public int TimeoutMs { get; set; } = 2000;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ProbeResponse
|
||||
{
|
||||
[Key(0)] public bool Healthy { get; set; }
|
||||
[Key(1)] public string? Error { get; set; }
|
||||
[Key(2)] public long ObservedAtUtcUnixMs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Per-host runtime status — fan-out target when the Host observes the CNC going unreachable without the Proxy asking.</summary>
|
||||
[MessagePackObject]
|
||||
public sealed class RuntimeStatusChangeNotification
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
|
||||
/// <summary>Running | Stopped | Unknown.</summary>
|
||||
[Key(1)] public string RuntimeStatus { get; set; } = string.Empty;
|
||||
|
||||
[Key(2)] public long ObservedAtUtcUnixMs { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class RecycleHostRequest
|
||||
{
|
||||
/// <summary>Soft | Hard. Soft drains subscriptions first; Hard kills immediately.</summary>
|
||||
[Key(0)] public string Kind { get; set; } = "Soft";
|
||||
[Key(1)] public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class RecycleStatusResponse
|
||||
{
|
||||
[Key(0)] public bool Accepted { get; set; }
|
||||
[Key(1)] public int GraceSeconds { get; set; } = 15;
|
||||
[Key(2)] public string? Error { get; set; }
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Read one FOCAS address. Multi-read is the Proxy's responsibility — it batches
|
||||
/// per-tag reads into parallel <see cref="ReadRequest"/> frames the Host services on its
|
||||
/// STA thread. Keeping the IPC read single-address keeps the Host side trivial; FOCAS
|
||||
/// itself has no multi-read primitive that spans area kinds.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class ReadRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public FocasAddressDto Address { get; set; } = new();
|
||||
[Key(2)] public int DataType { get; set; }
|
||||
[Key(3)] public int TimeoutMs { get; set; } = 2000;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ReadResponse
|
||||
{
|
||||
[Key(0)] public bool Success { get; set; }
|
||||
[Key(1)] public string? Error { get; set; }
|
||||
|
||||
/// <summary>OPC UA status code mapped by the Host via <c>FocasStatusMapper</c> — 0 = Good.</summary>
|
||||
[Key(2)] public uint StatusCode { get; set; }
|
||||
|
||||
/// <summary>MessagePack-serialized boxed value. <c>null</c> when <see cref="Success"/> is false.</summary>
|
||||
[Key(3)] public byte[]? ValueBytes { get; set; }
|
||||
|
||||
/// <summary>Matches <see cref="FocasDataTypeCode"/> so the Proxy knows how to deserialize.</summary>
|
||||
[Key(4)] public int ValueTypeCode { get; set; }
|
||||
|
||||
[Key(5)] public long SourceTimestampUtcUnixMs { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class WriteRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public FocasAddressDto Address { get; set; } = new();
|
||||
[Key(2)] public int DataType { get; set; }
|
||||
[Key(3)] public byte[]? ValueBytes { get; set; }
|
||||
[Key(4)] public int ValueTypeCode { get; set; }
|
||||
[Key(5)] public int TimeoutMs { get; set; } = 2000;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class WriteResponse
|
||||
{
|
||||
[Key(0)] public bool Success { get; set; }
|
||||
[Key(1)] public string? Error { get; set; }
|
||||
|
||||
/// <summary>OPC UA status code — 0 = Good.</summary>
|
||||
[Key(2)] public uint StatusCode { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PMC bit read-modify-write. Handled as a first-class operation (not two separate
|
||||
/// read+write round-trips) so the critical section stays on the Host — serializing
|
||||
/// concurrent bit writers to the same parent byte is Host-side via
|
||||
/// <c>SemaphoreSlim</c> keyed on <c>(PmcLetter, Number)</c>. Mirrors the in-process
|
||||
/// pattern from <c>FocasPmcBitRmw</c>.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class PmcBitWriteRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public FocasAddressDto Address { get; set; } = new();
|
||||
|
||||
/// <summary>The bit index to set/clear. 0-7.</summary>
|
||||
[Key(2)] public int BitIndex { get; set; }
|
||||
|
||||
[Key(3)] public bool Value { get; set; }
|
||||
[Key(4)] public int TimeoutMs { get; set; } = 2000;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class PmcBitWriteResponse
|
||||
{
|
||||
[Key(0)] public bool Success { get; set; }
|
||||
[Key(1)] public string? Error { get; set; }
|
||||
[Key(2)] public uint StatusCode { get; set; }
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Open a FOCAS session against the CNC at <see cref="HostAddress"/>. One session per
|
||||
/// configured device. The Host owns the Fwlib32 handle; the Proxy tracks only the
|
||||
/// opaque <see cref="OpenSessionResponse.SessionId"/> returned on success.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class OpenSessionRequest
|
||||
{
|
||||
[Key(0)] public string HostAddress { get; set; } = string.Empty;
|
||||
[Key(1)] public int TimeoutMs { get; set; } = 2000;
|
||||
[Key(2)] public int CncSeries { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class OpenSessionResponse
|
||||
{
|
||||
[Key(0)] public bool Success { get; set; }
|
||||
[Key(1)] public long SessionId { get; set; }
|
||||
[Key(2)] public string? Error { get; set; }
|
||||
[Key(3)] public string? ErrorCode { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class CloseSessionRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe the Host to polling a set of tags on behalf of the Proxy. FOCAS is
|
||||
/// poll-only — there are no CNC-initiated callbacks — so the Host runs the poll loop and
|
||||
/// pushes <see cref="OnDataChangeNotification"/> frames whenever a value differs from
|
||||
/// the last observation. Delta-only + per-group interval keeps the wire quiet.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class SubscribeRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public long SubscriptionId { get; set; }
|
||||
[Key(2)] public int IntervalMs { get; set; } = 1000;
|
||||
[Key(3)] public SubscribeItem[] Items { get; set; } = System.Array.Empty<SubscribeItem>();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class SubscribeItem
|
||||
{
|
||||
/// <summary>Opaque correlation id the Proxy uses to route notifications back to the right OPC UA MonitoredItem.</summary>
|
||||
[Key(0)] public long MonitoredItemId { get; set; }
|
||||
|
||||
[Key(1)] public FocasAddressDto Address { get; set; } = new();
|
||||
[Key(2)] public int DataType { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class SubscribeResponse
|
||||
{
|
||||
[Key(0)] public bool Success { get; set; }
|
||||
[Key(1)] public string? Error { get; set; }
|
||||
|
||||
/// <summary>Items the Host refused (address mismatch, unsupported type). Empty on full success.</summary>
|
||||
[Key(2)] public long[] RejectedMonitoredItemIds { get; set; } = System.Array.Empty<long>();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class UnsubscribeRequest
|
||||
{
|
||||
[Key(0)] public long SubscriptionId { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class OnDataChangeNotification
|
||||
{
|
||||
[Key(0)] public long SubscriptionId { get; set; }
|
||||
[Key(1)] public DataChange[] Changes { get; set; } = System.Array.Empty<DataChange>();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class DataChange
|
||||
{
|
||||
[Key(0)] public long MonitoredItemId { get; set; }
|
||||
[Key(1)] public uint StatusCode { get; set; }
|
||||
[Key(2)] public byte[]? ValueBytes { get; set; }
|
||||
[Key(3)] public int ValueTypeCode { get; set; }
|
||||
[Key(4)] public long SourceTimestampUtcUnixMs { get; set; }
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Reads length-prefixed, kind-tagged frames from a stream. Single-consumer — do not call
|
||||
/// <see cref="ReadFrameAsync"/> from multiple threads against the same instance.
|
||||
/// </summary>
|
||||
public sealed class FrameReader : IDisposable
|
||||
{
|
||||
private readonly Stream _stream;
|
||||
private readonly bool _leaveOpen;
|
||||
|
||||
public FrameReader(Stream stream, bool leaveOpen = false)
|
||||
{
|
||||
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
|
||||
_leaveOpen = leaveOpen;
|
||||
}
|
||||
|
||||
public async Task<(FocasMessageKind Kind, byte[] Body)?> ReadFrameAsync(CancellationToken ct)
|
||||
{
|
||||
var lengthPrefix = new byte[Framing.LengthPrefixSize];
|
||||
if (!await ReadExactAsync(lengthPrefix, ct).ConfigureAwait(false))
|
||||
return null;
|
||||
|
||||
var length = (lengthPrefix[0] << 24) | (lengthPrefix[1] << 16) | (lengthPrefix[2] << 8) | lengthPrefix[3];
|
||||
if (length < 0 || length > Framing.MaxFrameBodyBytes)
|
||||
throw new InvalidDataException($"IPC frame length {length} out of range.");
|
||||
|
||||
var kindByte = _stream.ReadByte();
|
||||
if (kindByte < 0) throw new EndOfStreamException("EOF after length prefix, before kind byte.");
|
||||
|
||||
var body = new byte[length];
|
||||
if (!await ReadExactAsync(body, ct).ConfigureAwait(false))
|
||||
throw new EndOfStreamException("EOF mid-frame.");
|
||||
|
||||
return ((FocasMessageKind)(byte)kindByte, body);
|
||||
}
|
||||
|
||||
public static T Deserialize<T>(byte[] body) => MessagePackSerializer.Deserialize<T>(body);
|
||||
|
||||
private async Task<bool> ReadExactAsync(byte[] buffer, CancellationToken ct)
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < buffer.Length)
|
||||
{
|
||||
var read = await _stream.ReadAsync(buffer, offset, buffer.Length - offset, ct).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
if (offset == 0) return false;
|
||||
throw new EndOfStreamException($"Stream ended after reading {offset} of {buffer.Length} bytes.");
|
||||
}
|
||||
offset += read;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_leaveOpen) _stream.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Writes length-prefixed, kind-tagged MessagePack frames to a stream. Thread-safe via
|
||||
/// <see cref="SemaphoreSlim"/> — multiple producers (e.g. heartbeat + data-plane sharing a
|
||||
/// stream) get serialized writes.
|
||||
/// </summary>
|
||||
public sealed class FrameWriter : IDisposable
|
||||
{
|
||||
private readonly Stream _stream;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly bool _leaveOpen;
|
||||
|
||||
public FrameWriter(Stream stream, bool leaveOpen = false)
|
||||
{
|
||||
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
|
||||
_leaveOpen = leaveOpen;
|
||||
}
|
||||
|
||||
public async Task WriteAsync<T>(FocasMessageKind kind, T message, CancellationToken ct)
|
||||
{
|
||||
var body = MessagePackSerializer.Serialize(message, cancellationToken: ct);
|
||||
if (body.Length > Framing.MaxFrameBodyBytes)
|
||||
throw new InvalidOperationException(
|
||||
$"IPC frame body {body.Length} exceeds {Framing.MaxFrameBodyBytes} byte cap.");
|
||||
|
||||
var lengthPrefix = new byte[Framing.LengthPrefixSize];
|
||||
lengthPrefix[0] = (byte)((body.Length >> 24) & 0xFF);
|
||||
lengthPrefix[1] = (byte)((body.Length >> 16) & 0xFF);
|
||||
lengthPrefix[2] = (byte)((body.Length >> 8) & 0xFF);
|
||||
lengthPrefix[3] = (byte)( body.Length & 0xFF);
|
||||
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _stream.WriteAsync(lengthPrefix, 0, lengthPrefix.Length, ct).ConfigureAwait(false);
|
||||
_stream.WriteByte((byte)kind);
|
||||
await _stream.WriteAsync(body, 0, body.Length, ct).ConfigureAwait(false);
|
||||
await _stream.FlushAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_gate.Dispose();
|
||||
if (!_leaveOpen) _stream.Dispose();
|
||||
}
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- MessagePack for IPC. Netstandard 2.0 consumable by both .NET 10 (Proxy) + .NET 4.8 (Host). -->
|
||||
<PackageReference Include="MessagePack" Version="2.5.187"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,195 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Polls each device's CNC active-alarm list via <see cref="IFocasClient.ReadAlarmsAsync"/>
|
||||
/// on a timer and translates raise / clear transitions into <see cref="IAlarmSource"/>
|
||||
/// events on the owning <see cref="FocasDriver"/>. One poll loop per subscription; the
|
||||
/// loop fans out across every configured device and diffs the (<c>AlarmNumber</c>,
|
||||
/// <c>Type</c>) keyed active-alarm set between ticks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// FOCAS alarms are flat per session — the CNC exposes a single active-alarm list via
|
||||
/// <c>cnc_rdalmmsg2</c>, not per-node structures the way Galaxy / AbCip ALMD do. So the
|
||||
/// projection ignores <c>sourceNodeIds</c> at the member level: every alarm event is
|
||||
/// raised with <c>SourceNodeId=device-host-address</c>. Callers that want per-device
|
||||
/// filtering can pass the specific host addresses as <c>sourceNodeIds</c> and the
|
||||
/// projection will skip devices not listed.
|
||||
/// </remarks>
|
||||
internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||
{
|
||||
private readonly FocasDriver _driver;
|
||||
private readonly TimeSpan _pollInterval;
|
||||
private readonly Dictionary<long, Subscription> _subs = new();
|
||||
private readonly Lock _subsLock = new();
|
||||
private long _nextId;
|
||||
|
||||
public FocasAlarmProjection(FocasDriver driver, TimeSpan pollInterval)
|
||||
{
|
||||
_driver = driver;
|
||||
_pollInterval = pollInterval;
|
||||
}
|
||||
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextId);
|
||||
var handle = new FocasAlarmSubscriptionHandle(id);
|
||||
var cts = new CancellationTokenSource();
|
||||
// Empty filter = listen to every configured device. Otherwise only devices whose
|
||||
// host address appears in sourceNodeIds are polled.
|
||||
var filter = sourceNodeIds.Count == 0
|
||||
? null
|
||||
: new HashSet<string>(sourceNodeIds, StringComparer.OrdinalIgnoreCase);
|
||||
var sub = new Subscription(handle, filter, cts);
|
||||
|
||||
lock (_subsLock) _subs[id] = sub;
|
||||
|
||||
sub.Loop = Task.Run(() => RunPollLoopAsync(sub, cts.Token), cts.Token);
|
||||
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
|
||||
}
|
||||
|
||||
public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is not FocasAlarmSubscriptionHandle h) return;
|
||||
Subscription? sub;
|
||||
lock (_subsLock)
|
||||
{
|
||||
if (!_subs.Remove(h.Id, out sub)) return;
|
||||
}
|
||||
try { sub.Cts.Cancel(); } catch { }
|
||||
try { await sub.Loop.ConfigureAwait(false); } catch { }
|
||||
sub.Cts.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FOCAS has no ack wire call — the CNC clears alarms on its own when the underlying
|
||||
/// condition resolves. Swallow the request so capability negotiation succeeds, rather
|
||||
/// than surfacing a confusing "not supported" error to the operator.
|
||||
/// </summary>
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
List<Subscription> snap;
|
||||
lock (_subsLock) { snap = [.. _subs.Values]; _subs.Clear(); }
|
||||
foreach (var sub in snap)
|
||||
{
|
||||
try { sub.Cts.Cancel(); } catch { }
|
||||
try { await sub.Loop.ConfigureAwait(false); } catch { }
|
||||
sub.Cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One poll-tick for one device. Diffs the new alarm list against the previous snapshot,
|
||||
/// emits raise + clear events. Extracted so tests can drive a tick without spinning up
|
||||
/// the full Task.Run loop.
|
||||
/// </summary>
|
||||
internal void Tick(Subscription sub, string deviceHostAddress, IReadOnlyList<FocasActiveAlarm> current)
|
||||
{
|
||||
var prev = sub.LastByDevice.GetValueOrDefault(deviceHostAddress) ?? [];
|
||||
var nowKeys = current.Select(a => AlarmKey(a)).ToHashSet();
|
||||
var prevKeys = prev.Select(a => AlarmKey(a)).ToHashSet();
|
||||
|
||||
foreach (var a in current)
|
||||
{
|
||||
if (prevKeys.Contains(AlarmKey(a))) continue;
|
||||
_driver.InvokeAlarmEvent(new AlarmEventArgs(
|
||||
sub.Handle,
|
||||
SourceNodeId: deviceHostAddress,
|
||||
ConditionId: $"{deviceHostAddress}#{AlarmKey(a)}",
|
||||
AlarmType: MapAlarmType(a.Type),
|
||||
Message: a.Message,
|
||||
Severity: MapSeverity(a.Type),
|
||||
SourceTimestampUtc: DateTime.UtcNow));
|
||||
}
|
||||
|
||||
foreach (var a in prev)
|
||||
{
|
||||
if (nowKeys.Contains(AlarmKey(a))) continue;
|
||||
_driver.InvokeAlarmEvent(new AlarmEventArgs(
|
||||
sub.Handle,
|
||||
SourceNodeId: deviceHostAddress,
|
||||
ConditionId: $"{deviceHostAddress}#{AlarmKey(a)}",
|
||||
AlarmType: MapAlarmType(a.Type),
|
||||
Message: $"{a.Message} (cleared)",
|
||||
Severity: MapSeverity(a.Type),
|
||||
SourceTimestampUtc: DateTime.UtcNow));
|
||||
}
|
||||
|
||||
sub.LastByDevice[deviceHostAddress] = [.. current];
|
||||
}
|
||||
|
||||
private async Task RunPollLoopAsync(Subscription sub, CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var (host, alarms) in await _driver.ReadActiveAlarmsAcrossDevicesAsync(sub.DeviceFilter, ct).ConfigureAwait(false))
|
||||
{
|
||||
Tick(sub, host, alarms);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch { /* per-tick failures are non-fatal — next tick retries */ }
|
||||
|
||||
try { await Task.Delay(_pollInterval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
private static string AlarmKey(FocasActiveAlarm a) => $"{a.Type}:{a.AlarmNumber}";
|
||||
|
||||
/// <summary>Map FOCAS type to a human-readable category; falls back to the numeric type.</summary>
|
||||
internal static string MapAlarmType(short type) => type switch
|
||||
{
|
||||
FocasAlarmType.Parameter => "Parameter",
|
||||
FocasAlarmType.PulseCode => "PulseCode",
|
||||
FocasAlarmType.Overtravel => "Overtravel",
|
||||
FocasAlarmType.Overheat => "Overheat",
|
||||
FocasAlarmType.Servo => "Servo",
|
||||
FocasAlarmType.DataIo => "DataIo",
|
||||
FocasAlarmType.MemoryCheck => "MemoryCheck",
|
||||
FocasAlarmType.MacroAlarm => "MacroAlarm",
|
||||
_ => $"Type{type}",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Project FOCAS alarm types into the driver-agnostic 4-band severity. Overtravel /
|
||||
/// Servo / Emergency-equivalents are Critical; Parameter + Macro are Medium; rest land
|
||||
/// at High (everything else on a CNC is safety-relevant).
|
||||
/// </summary>
|
||||
internal static AlarmSeverity MapSeverity(short type) => type switch
|
||||
{
|
||||
FocasAlarmType.Overtravel => AlarmSeverity.Critical,
|
||||
FocasAlarmType.Servo => AlarmSeverity.Critical,
|
||||
FocasAlarmType.PulseCode => AlarmSeverity.Critical,
|
||||
FocasAlarmType.Parameter => AlarmSeverity.Medium,
|
||||
FocasAlarmType.MacroAlarm => AlarmSeverity.Medium,
|
||||
_ => AlarmSeverity.High,
|
||||
};
|
||||
|
||||
internal sealed class Subscription(
|
||||
FocasAlarmSubscriptionHandle handle,
|
||||
HashSet<string>? deviceFilter,
|
||||
CancellationTokenSource cts)
|
||||
{
|
||||
public FocasAlarmSubscriptionHandle Handle { get; } = handle;
|
||||
public HashSet<string>? DeviceFilter { get; } = deviceFilter;
|
||||
public CancellationTokenSource Cts { get; } = cts;
|
||||
public Task Loop { get; set; } = Task.CompletedTask;
|
||||
public Dictionary<string, IReadOnlyList<FocasActiveAlarm>> LastByDevice { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Handle returned by <see cref="FocasAlarmProjection.SubscribeAsync"/>.</summary>
|
||||
public sealed record FocasAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => $"focas-alarm-sub-{Id}";
|
||||
}
|
||||
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
/// fail fast.
|
||||
/// </remarks>
|
||||
public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly FocasDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
@@ -24,10 +24,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private FocasAlarmProjection? _alarmProjection;
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
|
||||
public FocasDriver(FocasDriverOptions options, string driverInstanceId,
|
||||
IFocasClientFactory? clientFactory = null)
|
||||
@@ -35,7 +37,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_clientFactory = clientFactory ?? new FwlibFocasClientFactory();
|
||||
_clientFactory = clientFactory ?? new Wire.WireFocasClientFactory();
|
||||
_poll = new PollGroupEngine(
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
@@ -85,6 +87,30 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
|
||||
}
|
||||
}
|
||||
|
||||
if (_options.HandleRecycle.Enabled)
|
||||
{
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
state.RecycleCts = new CancellationTokenSource();
|
||||
var ct = state.RecycleCts.Token;
|
||||
_ = Task.Run(() => RecycleLoopAsync(state, ct), ct);
|
||||
}
|
||||
}
|
||||
|
||||
if (_options.AlarmProjection.Enabled)
|
||||
_alarmProjection = new FocasAlarmProjection(this, _options.AlarmProjection.PollInterval);
|
||||
|
||||
if (_options.FixedTree.Enabled)
|
||||
{
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
state.FixedTreeCts = new CancellationTokenSource();
|
||||
var ct = state.FixedTreeCts.Token;
|
||||
_ = Task.Run(() => FixedTreeLoopAsync(state, ct), ct);
|
||||
}
|
||||
}
|
||||
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -104,11 +130,22 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
if (_alarmProjection is { } proj)
|
||||
{
|
||||
await proj.DisposeAsync().ConfigureAwait(false);
|
||||
_alarmProjection = null;
|
||||
}
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
try { state.ProbeCts?.Cancel(); } catch { }
|
||||
state.ProbeCts?.Dispose();
|
||||
state.ProbeCts = null;
|
||||
try { state.RecycleCts?.Cancel(); } catch { }
|
||||
state.RecycleCts?.Dispose();
|
||||
state.RecycleCts = null;
|
||||
try { state.FixedTreeCts?.Cancel(); } catch { }
|
||||
state.FixedTreeCts?.Dispose();
|
||||
state.FixedTreeCts = null;
|
||||
state.DisposeClient();
|
||||
}
|
||||
_devices.Clear();
|
||||
@@ -136,6 +173,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var reference = fullReferences[i];
|
||||
|
||||
// Fixed-tree T1 — fixed-tree references are synthesized from the cached
|
||||
// dynamic snapshot + sysinfo; no P/Invoke per Read since the poll loop
|
||||
// already fires them on cadence.
|
||||
if (_options.FixedTree.Enabled && TryReadFixedTree(reference, now) is { } fx)
|
||||
{
|
||||
results[i] = fx;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
@@ -241,6 +288,80 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
var label = device.DeviceName ?? device.HostAddress;
|
||||
var deviceFolder = root.Folder(device.HostAddress, label);
|
||||
|
||||
// Fixed-tree T1 — Identity + Axes subtrees, populated once per session
|
||||
// from cnc_sysinfo + cnc_rdaxisname at init time and kept in DeviceState.
|
||||
if (_options.FixedTree.Enabled
|
||||
&& _devices.TryGetValue(device.HostAddress, out var state)
|
||||
&& state.FixedTreeCache is { } cache)
|
||||
{
|
||||
var identity = deviceFolder.Folder("Identity", "Identity");
|
||||
EmitIdentityVariable(identity, device.HostAddress, "SeriesNumber", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, device.HostAddress, "Version", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, device.HostAddress, "MaxAxes", FocasDriverDataType.Int32);
|
||||
EmitIdentityVariable(identity, device.HostAddress, "CncType", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, device.HostAddress, "MtType", FocasDriverDataType.String);
|
||||
EmitIdentityVariable(identity, device.HostAddress, "AxisCount", FocasDriverDataType.Int32);
|
||||
|
||||
var axesFolder = deviceFolder.Folder("Axes", "Axes");
|
||||
foreach (var axis in cache.Axes)
|
||||
{
|
||||
var axisFolder = axesFolder.Folder(axis.Display, axis.Display);
|
||||
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "AbsolutePosition");
|
||||
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "MachinePosition");
|
||||
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "RelativePosition");
|
||||
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "DistanceToGo");
|
||||
if (cache.Capabilities.ServoLoad)
|
||||
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "ServoLoad");
|
||||
}
|
||||
EmitAxisVariable(axesFolder, device.HostAddress, "FeedRate", "Actual");
|
||||
EmitAxisVariable(axesFolder, device.HostAddress, "SpindleSpeed", "Actual");
|
||||
|
||||
// Spindle subtree — one folder per discovered spindle, suppressed
|
||||
// entirely on series that don't export cnc_rdspdlname. Per-spindle
|
||||
// Load + MaxRpm each gated on their own capability probe.
|
||||
if (cache.Capabilities.Spindles)
|
||||
{
|
||||
var spindleRoot = deviceFolder.Folder("Spindle", "Spindle");
|
||||
for (var i = 0; i < cache.Spindles.Count; i++)
|
||||
{
|
||||
var s = cache.Spindles[i];
|
||||
var name = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
|
||||
var spindleFolder = spindleRoot.Folder(name, name);
|
||||
if (cache.Capabilities.SpindleLoad)
|
||||
EmitFixedVariable(spindleFolder, device.HostAddress, $"Spindle/{name}", "Load", DriverDataType.Int32);
|
||||
if (cache.Capabilities.SpindleMaxRpm && i < cache.SpindleMaxRpms.Count)
|
||||
EmitFixedVariable(spindleFolder, device.HostAddress, $"Spindle/{name}", "MaxRpm", DriverDataType.Int32);
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed-tree T2 — Program + OperationMode subtrees (gated on capability).
|
||||
if (cache.Capabilities.ProgramInfo)
|
||||
{
|
||||
var program = deviceFolder.Folder("Program", "Program");
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "Name", DriverDataType.String);
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "ONumber", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "Number", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "MainNumber", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "Sequence", DriverDataType.Int32);
|
||||
EmitFixedVariable(program, device.HostAddress, "Program", "BlockCount", DriverDataType.Int32);
|
||||
|
||||
var opMode = deviceFolder.Folder("OperationMode", "OperationMode");
|
||||
EmitFixedVariable(opMode, device.HostAddress, "OperationMode", "Mode", DriverDataType.Int32);
|
||||
EmitFixedVariable(opMode, device.HostAddress, "OperationMode", "ModeText", DriverDataType.String);
|
||||
}
|
||||
|
||||
// Fixed-tree T3 — Timers subtree (power-on / operating / cutting / cycle).
|
||||
if (cache.Capabilities.Timers)
|
||||
{
|
||||
var timers = deviceFolder.Folder("Timers", "Timers");
|
||||
EmitFixedVariable(timers, device.HostAddress, "Timers", "PowerOnSeconds", DriverDataType.Float64);
|
||||
EmitFixedVariable(timers, device.HostAddress, "Timers", "OperatingSeconds", DriverDataType.Float64);
|
||||
EmitFixedVariable(timers, device.HostAddress, "Timers", "CuttingSeconds", DriverDataType.Float64);
|
||||
EmitFixedVariable(timers, device.HostAddress, "Timers", "CycleSeconds", DriverDataType.Float64);
|
||||
}
|
||||
}
|
||||
|
||||
var tagsForDevice = _options.Tags.Where(t =>
|
||||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||
foreach (var tag in tagsForDevice)
|
||||
@@ -261,6 +382,72 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private enum FocasDriverDataType { String, Int32, Float64 }
|
||||
|
||||
private static void EmitIdentityVariable(
|
||||
IAddressSpaceBuilder folder, string deviceHost, string field, FocasDriverDataType type)
|
||||
{
|
||||
var fullName = FixedTreeReference(deviceHost, $"Identity/{field}");
|
||||
folder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullName,
|
||||
DriverDataType: type switch
|
||||
{
|
||||
FocasDriverDataType.Int32 => DriverDataType.Int32,
|
||||
FocasDriverDataType.Float64 => DriverDataType.Float64,
|
||||
_ => DriverDataType.String,
|
||||
},
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
|
||||
private static void EmitAxisVariable(
|
||||
IAddressSpaceBuilder folder, string deviceHost, string axisName, string field)
|
||||
{
|
||||
var fullName = FixedTreeReference(deviceHost, $"Axes/{axisName}/{field}");
|
||||
folder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullName,
|
||||
DriverDataType: DriverDataType.Float64,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emit a variable under a named fixed-tree folder (Program, OperationMode,
|
||||
/// …). Full-reference shape is <c>{deviceHost}/{folderPath}/{field}</c>.
|
||||
/// </summary>
|
||||
private static void EmitFixedVariable(
|
||||
IAddressSpaceBuilder folder, string deviceHost, string folderPath,
|
||||
string field, DriverDataType type)
|
||||
{
|
||||
var fullName = FixedTreeReference(deviceHost, $"{folderPath}/{field}");
|
||||
folder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullName,
|
||||
DriverDataType: type,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical full-reference shape for a fixed-tree node. Keeps the device
|
||||
/// host as a prefix so multi-device configs don't collide, and the rest is
|
||||
/// the path inside the tree. Matches what poll-loop snapshots publish +
|
||||
/// what <see cref="ReadAsync"/> looks up.
|
||||
/// </summary>
|
||||
internal static string FixedTreeReference(string deviceHost, string path) =>
|
||||
$"{deviceHost}/{path}";
|
||||
|
||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
@@ -298,6 +485,310 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-device fixed-tree poll loop. First tick resolves sysinfo + axis names
|
||||
/// (once) so <see cref="DiscoverAsync"/> can render the subtree on its next
|
||||
/// invocation; every tick thereafter fires a <c>cnc_rddynamic2</c> per axis
|
||||
/// and publishes OnDataChange for the axis positions + feed rate + spindle
|
||||
/// speed.
|
||||
/// </summary>
|
||||
private async Task FixedTreeLoopAsync(DeviceState state, CancellationToken ct)
|
||||
{
|
||||
// Bootstrap: identity + axis names + per-optional-API capability probe.
|
||||
// Each optional call is attempted once; failures (EW_FUNC / EW_NOOPT / EW_VERSION)
|
||||
// record the capability as unsupported and suppress the corresponding nodes
|
||||
// in DiscoverAsync + the poll loop.
|
||||
while (!ct.IsCancellationRequested && state.FixedTreeCache is null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||
var sys = await client.GetSysInfoAsync(ct).ConfigureAwait(false);
|
||||
var axes = await client.GetAxisNamesAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// Optional-API probes — each returns empty / throws when unsupported.
|
||||
var spindles = await SafeProbe(() => client.GetSpindleNamesAsync(ct), []);
|
||||
var spindleMaxRpms = await SafeProbe(() => client.GetSpindleMaxRpmsAsync(ct), []);
|
||||
var servoLoads = await SafeProbe(() => client.GetServoLoadsAsync(ct), []);
|
||||
var programInfo = await SafeTryProbe(() => client.GetProgramInfoAsync(ct));
|
||||
var timer = await SafeTryProbe(() => client.GetTimerAsync(FocasTimerKind.PowerOn, ct));
|
||||
var spindleLoad = await SafeProbe(() => client.GetSpindleLoadsAsync(ct), []);
|
||||
|
||||
var caps = new FocasFixedTreeCapabilities(
|
||||
Spindles: spindles.Count > 0,
|
||||
SpindleLoad: spindleLoad.Count > 0,
|
||||
SpindleMaxRpm: spindleMaxRpms.Count > 0,
|
||||
ServoLoad: servoLoads.Count > 0,
|
||||
ProgramInfo: programInfo is not null,
|
||||
Timers: timer is not null);
|
||||
|
||||
state.FixedTreeCache = new FocasFixedTreeCache(
|
||||
sys, [.. axes], [.. spindles], [.. spindleMaxRpms], caps);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
||||
catch
|
||||
{
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
}
|
||||
}
|
||||
|
||||
// Prime the spindle-loads cache from bootstrap if supported — avoids a
|
||||
// "tree is there but reads say BadNodeIdUnknown" window on startup.
|
||||
if (state.FixedTreeCache?.Capabilities is { SpindleLoad: true })
|
||||
{
|
||||
try
|
||||
{
|
||||
var client2 = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||
var loads = await client2.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
|
||||
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
|
||||
}
|
||||
catch { /* first-tick poll will retry */ }
|
||||
}
|
||||
|
||||
var programPollDue = DateTime.MinValue;
|
||||
var timerPollDue = DateTime.MinValue;
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||
var cache = state.FixedTreeCache;
|
||||
if (cache is null) break;
|
||||
FocasDynamicSnapshot? firstAxisSnap = null;
|
||||
for (var i = 0; i < cache.Axes.Count; i++)
|
||||
{
|
||||
var axisIndex = i + 1; // FOCAS uses 1-based axis indexing
|
||||
var axis = cache.Axes[i];
|
||||
var snap = await client.ReadDynamicAsync(axisIndex, ct).ConfigureAwait(false);
|
||||
PublishAxisSnapshot(state, axis, snap);
|
||||
if (i == 0) { firstAxisSnap = snap; PublishRateSnapshot(state, snap); }
|
||||
}
|
||||
|
||||
// Servo loads + spindle loads — both return bulk arrays, so folding
|
||||
// into the axis cadence is cheap. Each is gated by the bootstrap
|
||||
// capability probe — unsupported on this series = silent skip.
|
||||
if (cache.Capabilities.ServoLoad)
|
||||
{
|
||||
try
|
||||
{
|
||||
var loads = await client.GetServoLoadsAsync(ct).ConfigureAwait(false);
|
||||
PublishServoLoads(state, loads);
|
||||
}
|
||||
catch { /* transient — next tick retries */ }
|
||||
}
|
||||
if (cache.Capabilities.SpindleLoad)
|
||||
{
|
||||
try
|
||||
{
|
||||
var loads = await client.GetSpindleLoadsAsync(ct).ConfigureAwait(false);
|
||||
for (var i = 0; i < loads.Count; i++) state.LastSpindleLoads[i] = loads[i];
|
||||
}
|
||||
catch { /* transient */ }
|
||||
}
|
||||
|
||||
// Program-info poll runs on its own cadence — much slower than the axis
|
||||
// poll because program / mode transitions are operator-driven.
|
||||
var programInterval = _options.FixedTree.ProgramPollInterval;
|
||||
if (cache.Capabilities.ProgramInfo
|
||||
&& programInterval > TimeSpan.Zero && DateTime.UtcNow >= programPollDue)
|
||||
{
|
||||
try
|
||||
{
|
||||
var program = await client.GetProgramInfoAsync(ct).ConfigureAwait(false);
|
||||
state.LastProgramInfo = program;
|
||||
if (firstAxisSnap is { } s) state.LastProgramAxisRef = s;
|
||||
}
|
||||
catch { /* transient — next tick retries */ }
|
||||
programPollDue = DateTime.UtcNow + programInterval;
|
||||
}
|
||||
|
||||
// Timers — slowest cadence. Fires 4 FWLIB calls per tick (one per kind).
|
||||
var timerInterval = _options.FixedTree.TimerPollInterval;
|
||||
if (cache.Capabilities.Timers
|
||||
&& timerInterval > TimeSpan.Zero && DateTime.UtcNow >= timerPollDue)
|
||||
{
|
||||
foreach (FocasTimerKind kind in Enum.GetValues<FocasTimerKind>())
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = await client.GetTimerAsync(kind, ct).ConfigureAwait(false);
|
||||
state.LastTimers[kind] = t;
|
||||
}
|
||||
catch { /* per-kind failures are non-fatal */ }
|
||||
}
|
||||
timerPollDue = DateTime.UtcNow + timerInterval;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch { /* next tick retries — transient blips are expected */ }
|
||||
|
||||
try { await Task.Delay(_options.FixedTree.PollInterval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache a fresh axis snapshot. The poll loop doesn't fire <c>OnDataChange</c>
|
||||
/// directly — subscribers go through the normal <c>SubscribeAsync</c> →
|
||||
/// <see cref="PollGroupEngine"/> → <see cref="ReadAsync"/> path, which hits
|
||||
/// <see cref="TryReadFixedTree"/> and returns these cached values.
|
||||
/// </summary>
|
||||
private static void PublishAxisSnapshot(DeviceState state, FocasAxisName axis, FocasDynamicSnapshot snap)
|
||||
{
|
||||
var host = state.Options.HostAddress;
|
||||
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/AbsolutePosition")] = snap.AbsolutePosition;
|
||||
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/MachinePosition")] = snap.MachinePosition;
|
||||
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/RelativePosition")] = snap.RelativePosition;
|
||||
state.LastFixedSnapshots[FixedTreeReference(host, $"Axes/{axis.Display}/DistanceToGo")] = snap.DistanceToGo;
|
||||
}
|
||||
|
||||
private static void PublishRateSnapshot(DeviceState state, FocasDynamicSnapshot snap)
|
||||
{
|
||||
var host = state.Options.HostAddress;
|
||||
state.LastFixedSnapshots[FixedTreeReference(host, "Axes/FeedRate/Actual")] = snap.ActualFeedRate;
|
||||
state.LastFixedSnapshots[FixedTreeReference(host, "Axes/SpindleSpeed/Actual")] = snap.ActualSpindleSpeed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache servo-load percentages keyed by axis name. Stored separately from
|
||||
/// <c>LastFixedSnapshots</c> (which is int-typed) so the double-valued load
|
||||
/// values don't need casting on every read.
|
||||
/// </summary>
|
||||
private static void PublishServoLoads(DeviceState state, IReadOnlyList<FocasServoLoad> loads)
|
||||
{
|
||||
foreach (var load in loads)
|
||||
state.LastServoLoads[load.AxisName] = load.LoadPercent;
|
||||
}
|
||||
|
||||
private static object? TimerValue(DeviceState state, FocasTimerKind kind) =>
|
||||
state.LastTimers.TryGetValue(kind, out var t) ? (object)t.TotalSeconds : null;
|
||||
|
||||
/// <summary>
|
||||
/// Call an optional probe that returns a collection; swallow any exception
|
||||
/// and return <paramref name="fallback"/>. Used by bootstrap to capture
|
||||
/// per-series capability without letting one failed probe take down the
|
||||
/// entire bootstrap sequence.
|
||||
/// </summary>
|
||||
private static async Task<IReadOnlyList<T>> SafeProbe<T>(
|
||||
Func<Task<IReadOnlyList<T>>> probe, IReadOnlyList<T> fallback)
|
||||
{
|
||||
try { return await probe().ConfigureAwait(false); }
|
||||
catch { return fallback; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nullable variant — probe returns a single object or null on failure.
|
||||
/// </summary>
|
||||
private static async Task<T?> SafeTryProbe<T>(Func<Task<T>> probe) where T : class
|
||||
{
|
||||
try { return await probe().ConfigureAwait(false); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read cached last-fixed-tree snapshots. Returns the projected value when
|
||||
/// the reference looks like a fixed-tree FullName; null when it doesn't
|
||||
/// (callers fall through to the user-authored tag path).
|
||||
/// </summary>
|
||||
private DataValueSnapshot? TryReadFixedTree(string reference, DateTime now)
|
||||
{
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
if (!reference.StartsWith(state.Options.HostAddress + "/", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (state.LastFixedSnapshots.TryGetValue(reference, out var raw))
|
||||
return new DataValueSnapshot((double)raw, FocasStatusMapper.Good, now, now);
|
||||
|
||||
// Servo-load match: reference shape is "{host}/Axes/{name}/ServoLoad"
|
||||
var suffixFull = reference[(state.Options.HostAddress.Length + 1)..];
|
||||
if (suffixFull.StartsWith("Axes/", StringComparison.Ordinal) && suffixFull.EndsWith("/ServoLoad", StringComparison.Ordinal))
|
||||
{
|
||||
var axisName = suffixFull["Axes/".Length..^"/ServoLoad".Length];
|
||||
if (state.LastServoLoads.TryGetValue(axisName, out var load))
|
||||
return new DataValueSnapshot(load, FocasStatusMapper.Good, now, now);
|
||||
}
|
||||
|
||||
// Spindle matches: "{host}/Spindle/{name}/Load" + "{host}/Spindle/{name}/MaxRpm"
|
||||
if (suffixFull.StartsWith("Spindle/", StringComparison.Ordinal)
|
||||
&& state.FixedTreeCache is { } spindleCache)
|
||||
{
|
||||
var tail = suffixFull["Spindle/".Length..];
|
||||
var slash = tail.IndexOf('/');
|
||||
if (slash > 0)
|
||||
{
|
||||
var spindleName = tail[..slash];
|
||||
var field = tail[(slash + 1)..];
|
||||
var idx = -1;
|
||||
for (var i = 0; i < spindleCache.Spindles.Count; i++)
|
||||
{
|
||||
var s = spindleCache.Spindles[i];
|
||||
var display = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
|
||||
if (string.Equals(display, spindleName, StringComparison.OrdinalIgnoreCase)) { idx = i; break; }
|
||||
}
|
||||
if (idx >= 0)
|
||||
{
|
||||
object? value = field switch
|
||||
{
|
||||
"Load" => state.LastSpindleLoads.TryGetValue(idx, out var l) ? (object)l : null,
|
||||
"MaxRpm" => idx < spindleCache.SpindleMaxRpms.Count ? (object)spindleCache.SpindleMaxRpms[idx] : null,
|
||||
_ => null,
|
||||
};
|
||||
if (value is not null)
|
||||
return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Identity strings + program / op-mode fields aren't cached as doubles —
|
||||
// re-derive from the struct caches.
|
||||
if (state.FixedTreeCache is { } cache)
|
||||
{
|
||||
var suffix = reference[(state.Options.HostAddress.Length + 1)..];
|
||||
var value = suffix switch
|
||||
{
|
||||
"Identity/SeriesNumber" => (object)cache.SysInfo.Series,
|
||||
"Identity/Version" => cache.SysInfo.Version,
|
||||
"Identity/MaxAxes" => cache.SysInfo.MaxAxis,
|
||||
"Identity/CncType" => cache.SysInfo.CncType,
|
||||
"Identity/MtType" => cache.SysInfo.MtType,
|
||||
"Identity/AxisCount" => cache.SysInfo.AxesCount,
|
||||
"Program/Name" => (object?)state.LastProgramInfo?.Name,
|
||||
"Program/ONumber" => state.LastProgramInfo?.ONumber,
|
||||
"Program/BlockCount" => state.LastProgramInfo?.BlockCount,
|
||||
"Program/Number" => state.LastProgramAxisRef?.ProgramNumber,
|
||||
"Program/MainNumber" => state.LastProgramAxisRef?.MainProgramNumber,
|
||||
"Program/Sequence" => state.LastProgramAxisRef?.SequenceNumber,
|
||||
"OperationMode/Mode" => state.LastProgramInfo?.Mode,
|
||||
"OperationMode/ModeText" => state.LastProgramInfo is { } pi
|
||||
? FocasOpMode.ToText(pi.Mode) : null,
|
||||
"Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn),
|
||||
"Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating),
|
||||
"Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting),
|
||||
"Timers/CycleSeconds" => TimerValue(state, FocasTimerKind.Cycle),
|
||||
_ => null,
|
||||
};
|
||||
if (value is not null)
|
||||
return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task RecycleLoopAsync(DeviceState state, CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(_options.HandleRecycle.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
|
||||
// Close the current handle — the next Read / Write / Probe call triggers
|
||||
// EnsureConnectedAsync, which reopens a fresh one. We don't block here on
|
||||
// reconnect because the goal is just to release the FWLIB handle slot; a
|
||||
// readable tick one probe cycle later is an acceptable cost.
|
||||
try { state.DisposeClient(); }
|
||||
catch { /* already disposed or race — next EnsureConnected recovers */ }
|
||||
}
|
||||
}
|
||||
|
||||
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
@@ -312,6 +803,50 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
|
||||
}
|
||||
|
||||
// ---- IAlarmSource ----
|
||||
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_alarmProjection is null)
|
||||
throw new NotSupportedException(
|
||||
"FOCAS alarm projection is disabled — set FocasDriverOptions.AlarmProjection.Enabled=true to opt in.");
|
||||
return _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken);
|
||||
}
|
||||
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
|
||||
_alarmProjection is { } p ? p.UnsubscribeAsync(handle, cancellationToken) : Task.CompletedTask;
|
||||
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
||||
_alarmProjection is { } p ? p.AcknowledgeAsync(acknowledgements, cancellationToken) : Task.CompletedTask;
|
||||
|
||||
internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
|
||||
|
||||
/// <summary>
|
||||
/// Poll every configured device's active-alarm list in one pass. Used by the alarm
|
||||
/// projection — kept <c>internal</c> rather than <c>public</c> because callers that
|
||||
/// want alarm events should subscribe through <c>IAlarmSource</c> instead.
|
||||
/// </summary>
|
||||
internal async Task<IReadOnlyList<(string HostAddress, IReadOnlyList<FocasActiveAlarm> Alarms)>>
|
||||
ReadActiveAlarmsAcrossDevicesAsync(HashSet<string>? deviceFilter, CancellationToken ct)
|
||||
{
|
||||
var result = new List<(string, IReadOnlyList<FocasActiveAlarm>)>();
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
if (deviceFilter is not null && !deviceFilter.Contains(state.Options.HostAddress)) continue;
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||
var alarms = await client.ReadAlarmsAsync(ct).ConfigureAwait(false);
|
||||
result.Add((state.Options.HostAddress, alarms));
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch { /* surface a device-local fault on the next tick */ }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
public string ResolveHost(string fullReference)
|
||||
@@ -341,6 +876,33 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Per-device fixed-tree cache populated once at first successful connect and
|
||||
/// read-only thereafter. Used by <see cref="DiscoverAsync"/> to render the
|
||||
/// tree + by <see cref="TryReadFixedTree"/> for synchronous Identity/* reads.
|
||||
/// </summary>
|
||||
internal sealed record FocasFixedTreeCache(
|
||||
FocasSysInfo SysInfo,
|
||||
IReadOnlyList<FocasAxisName> Axes,
|
||||
IReadOnlyList<FocasSpindleName> Spindles,
|
||||
IReadOnlyList<int> SpindleMaxRpms,
|
||||
FocasFixedTreeCapabilities Capabilities);
|
||||
|
||||
/// <summary>
|
||||
/// Per-device optional-API capability flags — which of the "this may or may not
|
||||
/// exist on this CNC series" calls succeeded at bootstrap. Drives per-series
|
||||
/// node suppression so a 16i that doesn't export <c>cnc_rdspmaxrpm</c> simply
|
||||
/// doesn't get a <c>Spindle/{name}/MaxRpm</c> node (instead of surfacing
|
||||
/// <c>BadDeviceFailure</c> on every read).
|
||||
/// </summary>
|
||||
internal sealed record FocasFixedTreeCapabilities(
|
||||
bool Spindles, // cnc_rdspdlname returned 1+ spindle names
|
||||
bool SpindleLoad, // cnc_rdspload bootstrap probe succeeded
|
||||
bool SpindleMaxRpm, // cnc_rdspmaxrpm bootstrap probe succeeded
|
||||
bool ServoLoad, // cnc_rdsvmeter bootstrap probe returned data
|
||||
bool ProgramInfo, // cnc_exeprgname2 + cnc_rdblkcount + cnc_rdopmode work
|
||||
bool Timers); // cnc_rdtimer works for at least PowerOn
|
||||
|
||||
internal sealed class DeviceState(FocasHostAddress parsedAddress, FocasDeviceOptions options)
|
||||
{
|
||||
public FocasHostAddress ParsedAddress { get; } = parsedAddress;
|
||||
@@ -351,6 +913,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public HostState HostState { get; set; } = HostState.Unknown;
|
||||
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
||||
public CancellationTokenSource? ProbeCts { get; set; }
|
||||
public CancellationTokenSource? RecycleCts { get; set; }
|
||||
public CancellationTokenSource? FixedTreeCts { get; set; }
|
||||
public FocasFixedTreeCache? FixedTreeCache { get; set; }
|
||||
public Dictionary<string, int> LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public FocasProgramInfo? LastProgramInfo { get; set; }
|
||||
/// <summary>Cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence.</summary>
|
||||
public FocasDynamicSnapshot? LastProgramAxisRef { get; set; }
|
||||
public Dictionary<FocasTimerKind, FocasTimer> LastTimers { get; } = [];
|
||||
public Dictionary<string, double> LastServoLoads { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Dictionary<int, int> LastSpindleLoads { get; } = [];
|
||||
|
||||
public void DisposeClient()
|
||||
{
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Static factory registration helper for <see cref="FocasDriver"/>. Server's Program.cs
|
||||
/// calls <see cref="Register"/> once at startup; the bootstrapper (task #248) then
|
||||
/// materialises FOCAS DriverInstance rows from the central config DB into live driver
|
||||
/// instances. Mirrors <c>GalaxyProxyDriverFactoryExtensions</c>; no dependency on
|
||||
/// Microsoft.Extensions.DependencyInjection so the driver project stays DI-free.
|
||||
/// Static factory registration helper for <see cref="FocasDriver"/>. Server's
|
||||
/// Program.cs calls <see cref="Register"/> once at startup; the bootstrapper
|
||||
/// then materialises FOCAS DriverInstance rows from the central config DB
|
||||
/// into live driver instances.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The DriverConfig JSON selects the <see cref="IFocasClientFactory"/> backend:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>"Backend": "ipc"</c> (default) — wires <see cref="IpcFocasClientFactory"/>
|
||||
/// against a named-pipe <see cref="FocasIpcClient"/> talking to a separate
|
||||
/// <c>Driver.FOCAS.Host</c> process (Tier-C isolation). Requires <c>PipeName</c> +
|
||||
/// <c>SharedSecret</c>.</item>
|
||||
/// <item><c>"Backend": "fwlib"</c> — direct in-process Fwlib32.dll P/Invoke via
|
||||
/// <see cref="FwlibFocasClientFactory"/>. Use only when the main server is licensed
|
||||
/// for FOCAS and you accept the native-crash blast-radius trade-off.</item>
|
||||
/// <item><c>"Backend": "unimplemented"</c> — returns the no-op factory; useful for
|
||||
/// scaffolding DriverInstance rows before the Host is deployed so the server boots.</item>
|
||||
/// <item><c>"Backend": "wire"</c> (default) — pure-managed FOCAS2 wire
|
||||
/// client (<see cref="WireFocasClientFactory"/>) speaking directly to
|
||||
/// the CNC on TCP:8193.</item>
|
||||
/// <item><c>"Backend": "unimplemented"</c> / <c>"none"</c> / <c>"stub"</c>
|
||||
/// — returns the no-op factory; useful for scaffolding DriverInstance
|
||||
/// rows before the CNC endpoint is reachable.</item>
|
||||
/// </list>
|
||||
/// Devices / Tags / Probe / Timeout / Series come from the same JSON and feed directly
|
||||
/// into <see cref="FocasDriverOptions"/>.
|
||||
/// Devices / Tags / Probe / Timeout / Series come from the same JSON and
|
||||
/// feed directly into <see cref="FocasDriverOptions"/>.
|
||||
/// </remarks>
|
||||
public static class FocasDriverFactoryExtensions
|
||||
{
|
||||
@@ -92,45 +88,19 @@ public static class FocasDriverFactoryExtensions
|
||||
internal static IFocasClientFactory BuildClientFactory(
|
||||
FocasDriverConfigDto dto, string driverInstanceId)
|
||||
{
|
||||
var backend = (dto.Backend ?? "ipc").Trim().ToLowerInvariant();
|
||||
var backend = (dto.Backend ?? "wire").Trim().ToLowerInvariant();
|
||||
return backend switch
|
||||
{
|
||||
"ipc" => BuildIpcFactory(dto, driverInstanceId),
|
||||
"fwlib" or "fwlib32" => new FwlibFocasClientFactory(),
|
||||
"wire" => new WireFocasClientFactory(),
|
||||
"unimplemented" or "none" or "stub" => new UnimplementedFocasClientFactory(),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"FOCAS driver config for '{driverInstanceId}' has unknown Backend '{dto.Backend}'. " +
|
||||
"Expected one of: ipc, fwlib, unimplemented."),
|
||||
"Expected one of: wire, unimplemented. " +
|
||||
"(The legacy 'ipc' / 'fwlib' backends were retired in the Wire migration — " +
|
||||
"see docs/drivers/FOCAS.md.)"),
|
||||
};
|
||||
}
|
||||
|
||||
private static IpcFocasClientFactory BuildIpcFactory(
|
||||
FocasDriverConfigDto dto, string driverInstanceId)
|
||||
{
|
||||
var pipeName = dto.PipeName
|
||||
?? throw new InvalidOperationException(
|
||||
$"FOCAS driver config for '{driverInstanceId}' missing required PipeName (Tier-C ipc backend)");
|
||||
var sharedSecret = dto.SharedSecret
|
||||
?? throw new InvalidOperationException(
|
||||
$"FOCAS driver config for '{driverInstanceId}' missing required SharedSecret (Tier-C ipc backend)");
|
||||
var connectTimeout = TimeSpan.FromMilliseconds(dto.ConnectTimeoutMs ?? 10_000);
|
||||
var series = ParseSeries(dto.Series);
|
||||
|
||||
// Each IFocasClientFactory.Create() call opens a fresh pipe to the Host — matches the
|
||||
// driver's one-client-per-device invariant. FocasIpcClient.ConnectAsync is awaited
|
||||
// synchronously via GetAwaiter().GetResult() because IFocasClientFactory.Create is a
|
||||
// sync contract; the blocking call lands inside FocasDriver.EnsureConnectedAsync,
|
||||
// which immediately awaits IFocasClient.ConnectAsync afterwards so the perceived
|
||||
// latency is identical to a fully-async factory.
|
||||
return new IpcFocasClientFactory(
|
||||
ipcClientFactory: () => FocasIpcClient.ConnectAsync(
|
||||
pipeName: pipeName,
|
||||
sharedSecret: sharedSecret,
|
||||
connectTimeout: connectTimeout,
|
||||
ct: CancellationToken.None).GetAwaiter().GetResult(),
|
||||
series: series);
|
||||
}
|
||||
|
||||
private static FocasCncSeries ParseSeries(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return FocasCncSeries.Unknown;
|
||||
@@ -162,9 +132,6 @@ public static class FocasDriverFactoryExtensions
|
||||
internal sealed class FocasDriverConfigDto
|
||||
{
|
||||
public string? Backend { get; init; }
|
||||
public string? PipeName { get; init; }
|
||||
public string? SharedSecret { get; init; }
|
||||
public int? ConnectTimeoutMs { get; init; }
|
||||
public string? Series { get; init; }
|
||||
public int? TimeoutMs { get; init; }
|
||||
public List<FocasDeviceDto>? Devices { get; init; }
|
||||
|
||||
@@ -11,6 +11,77 @@ public sealed class FocasDriverOptions
|
||||
public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = [];
|
||||
public FocasProbeOptions Probe { get; init; } = new();
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
public FocasAlarmProjectionOptions AlarmProjection { get; init; } = new();
|
||||
public FocasHandleRecycleOptions HandleRecycle { get; init; } = new();
|
||||
public FocasFixedTreeOptions FixedTree { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fixed-node tree exposed by FOCAS per <c>docs/v2/driver-specs.md §7</c> —
|
||||
/// <c>Identity/</c>, <c>Axes/{name}/</c>, etc. populated from
|
||||
/// <c>cnc_sysinfo</c> / <c>cnc_rdaxisname</c> / <c>cnc_rddynamic2</c>. Disabled by
|
||||
/// default so existing configs that only use user-authored tags don't grow new
|
||||
/// nodes on upgrade.
|
||||
/// </summary>
|
||||
public sealed class FocasFixedTreeOptions
|
||||
{
|
||||
/// <summary>Enable the fixed-node tree for every configured device.</summary>
|
||||
public bool Enabled { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Poll cadence for <c>cnc_rddynamic2</c>. Each tick calls the API once per
|
||||
/// configured axis + publishes OnDataChange for the axis subtree. Real CNCs
|
||||
/// serve ~100ms loops comfortably; the default is conservative.
|
||||
/// </summary>
|
||||
public TimeSpan PollInterval { get; init; } = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
/// <summary>
|
||||
/// Poll cadence for program + operation-mode info. Slower than the axis
|
||||
/// poll because program / mode transitions happen on operator timescales.
|
||||
/// Zero / negative disables the program poll entirely.
|
||||
/// </summary>
|
||||
public TimeSpan ProgramPollInterval { get; init; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Poll cadence for timers (power-on / operating / cutting / cycle).
|
||||
/// These change at human timescales — default is 30s. Zero / negative
|
||||
/// disables the timer poll entirely.
|
||||
/// </summary>
|
||||
public TimeSpan TimerPollInterval { get; init; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Proactive session-recycle cadence. Fanuc CNCs have a finite FWLIB handle pool
|
||||
/// (~5–10 concurrent connections) and certain series have documented handle-leak bugs
|
||||
/// that manifest after long uptime. When <see cref="Enabled"/> is <c>true</c> the
|
||||
/// driver closes + reopens each device's session on the <see cref="Interval"/> cadence,
|
||||
/// forcing FWLIB to release its handle slot back to the pool. Reads / writes during
|
||||
/// recycle wait for the reconnect rather than failing — worst case an operator sees a
|
||||
/// brief read latency spike once per cadence.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Disabled by default because a healthy CNC + driver doesn't need it. Enable when
|
||||
/// field experience shows handle exhaustion against a specific series / firmware.
|
||||
/// Typical tuning: 30 min for sites running multiple OtOpcUa instances against the
|
||||
/// same CNC (they share the pool); 6 h for a single-client deployment.
|
||||
/// </remarks>
|
||||
public sealed class FocasHandleRecycleOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = false;
|
||||
public TimeSpan Interval { get; init; } = TimeSpan.FromHours(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Controls the CNC active-alarm polling projection that surfaces FOCAS alarms via
|
||||
/// <c>IAlarmSource</c>. Disabled by default — operators opt in by setting
|
||||
/// <see cref="Enabled"/> in <c>appsettings.json</c>.
|
||||
/// </summary>
|
||||
public sealed class FocasAlarmProjectionOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = false;
|
||||
|
||||
/// <summary>Poll cadence. One <c>cnc_rdalmmsg2</c> call per device per tick.</summary>
|
||||
public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IFocasClient"/> implementation backed by Fanuc's licensed
|
||||
/// <c>Fwlib32.dll</c> via <see cref="FwlibNative"/> P/Invoke. The DLL is NOT shipped with
|
||||
/// OtOpcUa; the deployment places it next to the server executable or on <c>PATH</c>
|
||||
/// (per Fanuc licensing — see <c>docs/v2/focas-deployment.md</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Construction is licence-safe — .NET P/Invoke is lazy, so instantiating this class
|
||||
/// does NOT load <c>Fwlib32.dll</c>. The DLL only loads on the first wire call (Connect /
|
||||
/// Read / Write / Probe). When missing, those calls throw <see cref="DllNotFoundException"/>
|
||||
/// which the driver surfaces as <c>BadCommunicationError</c> through the normal exception
|
||||
/// mapping.</para>
|
||||
///
|
||||
/// <para>Session-scoped handle — <c>cnc_allclibhndl3</c> opens one FWLIB handle per CNC;
|
||||
/// all PMC / parameter / macro reads on that device go through the same handle. Dispose
|
||||
/// calls <c>cnc_freelibhndl</c>.</para>
|
||||
/// </remarks>
|
||||
internal sealed class FwlibFocasClient : IFocasClient
|
||||
{
|
||||
private ushort _handle;
|
||||
private bool _connected;
|
||||
|
||||
// Per-PMC-byte RMW lock registry. Bit writes to the same byte get serialised so two
|
||||
// concurrent bit updates don't lose one another's modification. Key = "{addrType}:{byteAddr}".
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _rmwLocks = new();
|
||||
|
||||
private SemaphoreSlim GetRmwLock(short addrType, int byteAddr) =>
|
||||
_rmwLocks.GetOrAdd($"{addrType}:{byteAddr}", _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
public bool IsConnected => _connected;
|
||||
|
||||
public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_connected) return Task.CompletedTask;
|
||||
|
||||
var timeoutMs = (int)Math.Max(1, timeout.TotalMilliseconds);
|
||||
var ret = FwlibNative.AllcLibHndl3(address.Host, (ushort)address.Port, timeoutMs, out var handle);
|
||||
if (ret != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"FWLIB cnc_allclibhndl3 failed with EW_{ret} connecting to {address}.");
|
||||
_handle = handle;
|
||||
_connected = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadCommunicationError));
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return address.Kind switch
|
||||
{
|
||||
FocasAreaKind.Pmc => Task.FromResult(ReadPmc(address, type)),
|
||||
FocasAreaKind.Parameter => Task.FromResult(ReadParameter(address, type)),
|
||||
FocasAreaKind.Macro => Task.FromResult(ReadMacro(address)),
|
||||
_ => Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported)),
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return FocasStatusMapper.BadCommunicationError;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return address.Kind switch
|
||||
{
|
||||
FocasAreaKind.Pmc when type == FocasDataType.Bit && address.BitIndex is int =>
|
||||
await WritePmcBitAsync(address, Convert.ToBoolean(value), cancellationToken).ConfigureAwait(false),
|
||||
FocasAreaKind.Pmc => WritePmc(address, type, value),
|
||||
FocasAreaKind.Parameter => WriteParameter(address, type, value),
|
||||
FocasAreaKind.Macro => WriteMacro(address, value),
|
||||
_ => FocasStatusMapper.BadNotSupported,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-modify-write one bit within a PMC byte. Acquires a per-byte semaphore so
|
||||
/// concurrent bit writes against the same byte serialise and neither loses its update.
|
||||
/// </summary>
|
||||
private async Task<uint> WritePmcBitAsync(
|
||||
FocasAddress address, bool newValue, CancellationToken cancellationToken)
|
||||
{
|
||||
var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "") ?? (short)0;
|
||||
var bit = address.BitIndex ?? 0;
|
||||
if (bit is < 0 or > 7)
|
||||
throw new InvalidOperationException(
|
||||
$"PMC bit index {bit} out of range (0-7) for {address.Canonical}.");
|
||||
|
||||
var rmwLock = GetRmwLock(addrType, address.Number);
|
||||
await rmwLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Read the parent byte.
|
||||
var readBuf = new FwlibNative.IODBPMC { Data = new byte[40] };
|
||||
var readRet = FwlibNative.PmcRdPmcRng(
|
||||
_handle, addrType, FocasPmcDataType.Byte,
|
||||
(ushort)address.Number, (ushort)address.Number, 8 + 1, ref readBuf);
|
||||
if (readRet != 0) return FocasStatusMapper.MapFocasReturn(readRet);
|
||||
|
||||
var current = readBuf.Data[0];
|
||||
var updated = newValue
|
||||
? (byte)(current | (1 << bit))
|
||||
: (byte)(current & ~(1 << bit));
|
||||
|
||||
// Write the updated byte.
|
||||
var writeBuf = new FwlibNative.IODBPMC
|
||||
{
|
||||
TypeA = addrType,
|
||||
TypeD = FocasPmcDataType.Byte,
|
||||
DatanoS = (ushort)address.Number,
|
||||
DatanoE = (ushort)address.Number,
|
||||
Data = new byte[40],
|
||||
};
|
||||
writeBuf.Data[0] = updated;
|
||||
|
||||
var writeRet = FwlibNative.PmcWrPmcRng(_handle, 8 + 1, ref writeBuf);
|
||||
return writeRet == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(writeRet);
|
||||
}
|
||||
finally
|
||||
{
|
||||
rmwLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult(false);
|
||||
var buf = new FwlibNative.ODBST();
|
||||
var ret = FwlibNative.StatInfo(_handle, ref buf);
|
||||
return Task.FromResult(ret == 0);
|
||||
}
|
||||
|
||||
// ---- PMC ----
|
||||
|
||||
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)
|
||||
{
|
||||
var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "")
|
||||
?? throw new InvalidOperationException($"Unknown PMC letter '{address.PmcLetter}'.");
|
||||
var dataType = FocasPmcDataType.FromFocasDataType(type);
|
||||
var length = PmcReadLength(type);
|
||||
|
||||
var buf = new FwlibNative.IODBPMC { Data = new byte[40] };
|
||||
var ret = FwlibNative.PmcRdPmcRng(
|
||||
_handle, addrType, dataType,
|
||||
(ushort)address.Number, (ushort)address.Number, (ushort)length, ref buf);
|
||||
if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
|
||||
|
||||
var value = type switch
|
||||
{
|
||||
FocasDataType.Bit => ExtractBit(buf.Data[0], address.BitIndex ?? 0),
|
||||
FocasDataType.Byte => (object)(sbyte)buf.Data[0],
|
||||
FocasDataType.Int16 => (object)BinaryPrimitives.ReadInt16LittleEndian(buf.Data),
|
||||
FocasDataType.Int32 => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
|
||||
FocasDataType.Float32 => (object)BinaryPrimitives.ReadSingleLittleEndian(buf.Data),
|
||||
FocasDataType.Float64 => (object)BinaryPrimitives.ReadDoubleLittleEndian(buf.Data),
|
||||
_ => (object)buf.Data[0],
|
||||
};
|
||||
return (value, FocasStatusMapper.Good);
|
||||
}
|
||||
|
||||
private uint WritePmc(FocasAddress address, FocasDataType type, object? value)
|
||||
{
|
||||
var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "") ?? (short)0;
|
||||
var dataType = FocasPmcDataType.FromFocasDataType(type);
|
||||
var length = PmcWriteLength(type);
|
||||
|
||||
var buf = new FwlibNative.IODBPMC
|
||||
{
|
||||
TypeA = addrType,
|
||||
TypeD = dataType,
|
||||
DatanoS = (ushort)address.Number,
|
||||
DatanoE = (ushort)address.Number,
|
||||
Data = new byte[40],
|
||||
};
|
||||
EncodePmcValue(buf.Data, type, value, address.BitIndex);
|
||||
|
||||
var ret = FwlibNative.PmcWrPmcRng(_handle, (ushort)length, ref buf);
|
||||
return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
|
||||
}
|
||||
|
||||
private (object? value, uint status) ReadParameter(FocasAddress address, FocasDataType type)
|
||||
{
|
||||
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
|
||||
var length = ParamReadLength(type);
|
||||
var ret = FwlibNative.RdParam(_handle, (ushort)address.Number, axis: 0, (short)length, ref buf);
|
||||
if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
|
||||
|
||||
var value = type switch
|
||||
{
|
||||
FocasDataType.Bit when address.BitIndex is int bit => ExtractBit(buf.Data[0], bit),
|
||||
FocasDataType.Byte => (object)(sbyte)buf.Data[0],
|
||||
FocasDataType.Int16 => (object)BinaryPrimitives.ReadInt16LittleEndian(buf.Data),
|
||||
FocasDataType.Int32 => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
|
||||
_ => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
|
||||
};
|
||||
return (value, FocasStatusMapper.Good);
|
||||
}
|
||||
|
||||
private uint WriteParameter(FocasAddress address, FocasDataType type, object? value)
|
||||
{
|
||||
var buf = new FwlibNative.IODBPSD
|
||||
{
|
||||
Datano = (short)address.Number,
|
||||
Type = 0,
|
||||
Data = new byte[32],
|
||||
};
|
||||
var length = ParamReadLength(type);
|
||||
EncodeParamValue(buf.Data, type, value);
|
||||
var ret = FwlibNative.WrParam(_handle, (short)length, ref buf);
|
||||
return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
|
||||
}
|
||||
|
||||
private (object? value, uint status) ReadMacro(FocasAddress address)
|
||||
{
|
||||
var buf = new FwlibNative.ODBM();
|
||||
var ret = FwlibNative.RdMacro(_handle, (short)address.Number, length: 8, ref buf);
|
||||
if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
|
||||
|
||||
// Macro value = mcr_val / 10^dec_val. Convert to double so callers get the correct
|
||||
// scaled value regardless of the decimal-point count the CNC reports.
|
||||
var scaled = buf.McrVal / Math.Pow(10.0, buf.DecVal);
|
||||
return (scaled, FocasStatusMapper.Good);
|
||||
}
|
||||
|
||||
private uint WriteMacro(FocasAddress address, object? value)
|
||||
{
|
||||
// Write as integer + 0 decimal places — callers that need decimal precision can extend
|
||||
// this via a future WriteMacroScaled overload. Consistent with what most HMIs do today.
|
||||
var intValue = Convert.ToInt32(value);
|
||||
var ret = FwlibNative.WrMacro(_handle, (short)address.Number, length: 8, intValue, decimalPointCount: 0);
|
||||
return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_connected)
|
||||
{
|
||||
try { FwlibNative.FreeLibHndl(_handle); } catch { }
|
||||
_connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static int PmcReadLength(FocasDataType type) => type switch
|
||||
{
|
||||
FocasDataType.Bit or FocasDataType.Byte => 8 + 1, // 8-byte header + 1 byte payload
|
||||
FocasDataType.Int16 => 8 + 2,
|
||||
FocasDataType.Int32 => 8 + 4,
|
||||
FocasDataType.Float32 => 8 + 4,
|
||||
FocasDataType.Float64 => 8 + 8,
|
||||
_ => 8 + 1,
|
||||
};
|
||||
|
||||
private static int PmcWriteLength(FocasDataType type) => PmcReadLength(type);
|
||||
private static int ParamReadLength(FocasDataType type) => type switch
|
||||
{
|
||||
FocasDataType.Bit or FocasDataType.Byte => 4 + 1,
|
||||
FocasDataType.Int16 => 4 + 2,
|
||||
FocasDataType.Int32 => 4 + 4,
|
||||
_ => 4 + 4,
|
||||
};
|
||||
|
||||
private static bool ExtractBit(byte word, int bit) => (word & (1 << bit)) != 0;
|
||||
|
||||
internal static void EncodePmcValue(byte[] data, FocasDataType type, object? value, int? bitIndex)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case FocasDataType.Bit:
|
||||
// PMC Bit writes with a non-null bitIndex go through WritePmcBitAsync's RMW path
|
||||
// upstream. This branch only fires when a caller passes Bit with no bitIndex —
|
||||
// treat the value as a whole-byte boolean (non-zero / zero).
|
||||
data[0] = Convert.ToBoolean(value) ? (byte)1 : (byte)0;
|
||||
break;
|
||||
case FocasDataType.Byte:
|
||||
data[0] = (byte)(sbyte)Convert.ToSByte(value);
|
||||
break;
|
||||
case FocasDataType.Int16:
|
||||
BinaryPrimitives.WriteInt16LittleEndian(data, Convert.ToInt16(value));
|
||||
break;
|
||||
case FocasDataType.Int32:
|
||||
BinaryPrimitives.WriteInt32LittleEndian(data, Convert.ToInt32(value));
|
||||
break;
|
||||
case FocasDataType.Float32:
|
||||
BinaryPrimitives.WriteSingleLittleEndian(data, Convert.ToSingle(value));
|
||||
break;
|
||||
case FocasDataType.Float64:
|
||||
BinaryPrimitives.WriteDoubleLittleEndian(data, Convert.ToDouble(value));
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"FocasDataType {type} not writable via PMC.");
|
||||
}
|
||||
_ = bitIndex; // bit-in-byte handled above
|
||||
}
|
||||
|
||||
internal static void EncodeParamValue(byte[] data, FocasDataType type, object? value)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case FocasDataType.Byte:
|
||||
data[0] = (byte)(sbyte)Convert.ToSByte(value);
|
||||
break;
|
||||
case FocasDataType.Int16:
|
||||
BinaryPrimitives.WriteInt16LittleEndian(data, Convert.ToInt16(value));
|
||||
break;
|
||||
case FocasDataType.Int32:
|
||||
BinaryPrimitives.WriteInt32LittleEndian(data, Convert.ToInt32(value));
|
||||
break;
|
||||
default:
|
||||
BinaryPrimitives.WriteInt32LittleEndian(data, Convert.ToInt32(value));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Default <see cref="IFocasClientFactory"/> — produces a fresh <see cref="FwlibFocasClient"/> per device.</summary>
|
||||
public sealed class FwlibFocasClientFactory : IFocasClientFactory
|
||||
{
|
||||
public IFocasClient Create() => new FwlibFocasClient();
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// P/Invoke surface for Fanuc FWLIB (<c>Fwlib32.dll</c>). Declarations extracted from
|
||||
/// <c>fwlib32.h</c> in the strangesast/fwlib repo; the licensed DLL itself is NOT shipped
|
||||
/// with OtOpcUa — the deployment places <c>Fwlib32.dll</c> next to the server executable
|
||||
/// or on <c>PATH</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately narrow — only the calls <see cref="FwlibFocasClient"/> actually makes.
|
||||
/// FOCAS has 800+ functions in <c>fwlib32.h</c>; pulling in every one would bloat the
|
||||
/// P/Invoke surface + signal more coverage than this driver provides. Expand as capabilities
|
||||
/// are added.
|
||||
/// </remarks>
|
||||
internal static class FwlibNative
|
||||
{
|
||||
private const string Library = "Fwlib32.dll";
|
||||
|
||||
// ---- Handle lifetime ----
|
||||
|
||||
/// <summary>Open an Ethernet FWLIB handle. Returns EW_OK (0) on success; handle written out.</summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_allclibhndl3", CharSet = CharSet.Ansi, ExactSpelling = true)]
|
||||
public static extern short AllcLibHndl3(
|
||||
[MarshalAs(UnmanagedType.LPStr)] string ipaddr,
|
||||
ushort port,
|
||||
int timeout,
|
||||
out ushort handle);
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_freelibhndl", ExactSpelling = true)]
|
||||
public static extern short FreeLibHndl(ushort handle);
|
||||
|
||||
// ---- PMC ----
|
||||
|
||||
/// <summary>PMC range read. <paramref name="addrType"/> is the ADR_* enum; <paramref name="dataType"/> is 0 byte / 1 word / 2 long.</summary>
|
||||
[DllImport(Library, EntryPoint = "pmc_rdpmcrng", ExactSpelling = true)]
|
||||
public static extern short PmcRdPmcRng(
|
||||
ushort handle,
|
||||
short addrType,
|
||||
short dataType,
|
||||
ushort startNumber,
|
||||
ushort endNumber,
|
||||
ushort length,
|
||||
ref IODBPMC buffer);
|
||||
|
||||
[DllImport(Library, EntryPoint = "pmc_wrpmcrng", ExactSpelling = true)]
|
||||
public static extern short PmcWrPmcRng(
|
||||
ushort handle,
|
||||
ushort length,
|
||||
ref IODBPMC buffer);
|
||||
|
||||
// ---- Parameters ----
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_rdparam", ExactSpelling = true)]
|
||||
public static extern short RdParam(
|
||||
ushort handle,
|
||||
ushort number,
|
||||
short axis,
|
||||
short length,
|
||||
ref IODBPSD buffer);
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_wrparam", ExactSpelling = true)]
|
||||
public static extern short WrParam(
|
||||
ushort handle,
|
||||
short length,
|
||||
ref IODBPSD buffer);
|
||||
|
||||
// ---- Macro variables ----
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_rdmacro", ExactSpelling = true)]
|
||||
public static extern short RdMacro(
|
||||
ushort handle,
|
||||
short number,
|
||||
short length,
|
||||
ref ODBM buffer);
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_wrmacro", ExactSpelling = true)]
|
||||
public static extern short WrMacro(
|
||||
ushort handle,
|
||||
short number,
|
||||
short length,
|
||||
int macroValue,
|
||||
short decimalPointCount);
|
||||
|
||||
// ---- Status ----
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_statinfo", ExactSpelling = true)]
|
||||
public static extern short StatInfo(ushort handle, ref ODBST buffer);
|
||||
|
||||
// ---- Structs ----
|
||||
|
||||
/// <summary>
|
||||
/// IODBPMC — PMC range I/O buffer. 8-byte header + 40-byte union. We marshal the union
|
||||
/// as a fixed byte buffer + interpret per <see cref="FocasDataType"/> on the managed side.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct IODBPMC
|
||||
{
|
||||
public short TypeA;
|
||||
public short TypeD;
|
||||
public ushort DatanoS;
|
||||
public ushort DatanoE;
|
||||
// 40-byte union: cdata[5] / idata[5] / ldata[5] / fdata[5] / dbdata[5] — dbdata is the widest.
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 40)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IODBPSD — CNC parameter I/O buffer. Axis-aware; for non-axis parameters pass axis=0.
|
||||
/// Union payload is bytes / shorts / longs — we marshal 32 bytes as the widest slot.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct IODBPSD
|
||||
{
|
||||
public short Datano;
|
||||
public short Type; // axis index (0 for non-axis)
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>ODBM — macro variable read buffer. Value = <c>McrVal / 10^DecVal</c>.</summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBM
|
||||
{
|
||||
public short Datano;
|
||||
public short Dummy;
|
||||
public int McrVal; // long in C; 32-bit signed
|
||||
public short DecVal; // decimal-point count
|
||||
}
|
||||
|
||||
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBST
|
||||
{
|
||||
public short Dummy;
|
||||
public short TmMode;
|
||||
public short Aut;
|
||||
public short Run;
|
||||
public short Motion;
|
||||
public short Mstb;
|
||||
public short Emergency;
|
||||
public short Alarm;
|
||||
public short Edit;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PMC address-letter → FOCAS <c>ADR_*</c> numeric code. Per Fanuc FOCAS/2 spec the codes
|
||||
/// are: G=0, F=1, Y=2, X=3, A=4, R=5, T=6, K=7, C=8, D=9, E=10. Exposed internally +
|
||||
/// tested so the FwlibFocasClient translation is verifiable without the DLL loaded.
|
||||
/// </summary>
|
||||
internal static class FocasPmcAddrType
|
||||
{
|
||||
public static short? FromLetter(string letter) => letter.ToUpperInvariant() switch
|
||||
{
|
||||
"G" => 0,
|
||||
"F" => 1,
|
||||
"Y" => 2,
|
||||
"X" => 3,
|
||||
"A" => 4,
|
||||
"R" => 5,
|
||||
"T" => 6,
|
||||
"K" => 7,
|
||||
"C" => 8,
|
||||
"D" => 9,
|
||||
"E" => 10,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>PMC data-type numeric codes per FOCAS/2: 0 = byte, 1 = word, 2 = long, 4 = float, 5 = double.</summary>
|
||||
internal static class FocasPmcDataType
|
||||
{
|
||||
public const short Byte = 0;
|
||||
public const short Word = 1;
|
||||
public const short Long = 2;
|
||||
public const short Float = 4;
|
||||
public const short Double = 5;
|
||||
|
||||
public static short FromFocasDataType(FocasDataType t) => t switch
|
||||
{
|
||||
FocasDataType.Bit or FocasDataType.Byte => Byte,
|
||||
FocasDataType.Int16 => Word,
|
||||
FocasDataType.Int32 => Long,
|
||||
FocasDataType.Float32 => Float,
|
||||
FocasDataType.Float64 => Double,
|
||||
_ => Byte,
|
||||
};
|
||||
}
|
||||
@@ -5,15 +5,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
/// configured device; lifetime matches the device.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>No default wire implementation ships with this assembly.</b> FWLIB
|
||||
/// (<c>Fwlib32.dll</c>) is Fanuc-proprietary and requires a valid customer license — it
|
||||
/// cannot legally be redistributed. The deployment team supplies an
|
||||
/// <see cref="IFocasClientFactory"/> that wraps the licensed <c>Fwlib32.dll</c> via
|
||||
/// P/Invoke and registers it at server startup.</para>
|
||||
/// <para>The default implementation is <see cref="Wire.WireFocasClient"/> — a pure-managed
|
||||
/// FOCAS/2 Ethernet client that speaks the wire protocol directly on TCP:8193. No
|
||||
/// P/Invoke, no native DLLs, no out-of-process isolation.</para>
|
||||
///
|
||||
/// <para>The default <see cref="UnimplementedFocasClientFactory"/> throws with a pointer at
|
||||
/// the deployment docs so misconfigured servers fail fast with a clear error rather than
|
||||
/// mysteriously hanging.</para>
|
||||
/// <para><see cref="UnimplementedFocasClientFactory"/> is a scaffolding backend that
|
||||
/// throws on <see cref="IFocasClientFactory.Create"/> — selected by
|
||||
/// <c>"Backend": "unimplemented"</c> so a DriverInstance row can be seeded before the CNC
|
||||
/// endpoint is reachable without silently reading stale data.</para>
|
||||
/// </remarks>
|
||||
public interface IFocasClient : IDisposable
|
||||
{
|
||||
@@ -48,8 +47,208 @@ public interface IFocasClient : IDisposable
|
||||
/// responds with any valid status.
|
||||
/// </summary>
|
||||
Task<bool> ProbeAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read active alarm messages from the CNC via <c>cnc_rdalmmsg2</c>. Returns
|
||||
/// zero-or-more active alarms. Null / empty list means "no alarms currently
|
||||
/// active". IAlarmSource projection polls this at a configurable interval +
|
||||
/// emits transitions (raise / clear) on the driver's <c>OnAlarmEvent</c>.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T1 (identity + axis discovery + fast-poll dynamic bundle) ----
|
||||
|
||||
/// <summary>
|
||||
/// Read CNC identity via <c>cnc_sysinfo</c>. Populates the <c>Identity/*</c>
|
||||
/// subtree of the fixed-node surface. Callable once at session open; the
|
||||
/// values don't change across the session.
|
||||
/// </summary>
|
||||
Task<FocasSysInfo> GetSysInfoAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read the CNC's configured axis names via <c>cnc_rdaxisname</c>. The driver
|
||||
/// uses these to build the <c>Axes/{name}/</c> subtree and to index
|
||||
/// <see cref="ReadDynamicAsync"/> calls.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read the CNC's configured spindle names via <c>cnc_rdspdlname</c>. Drives
|
||||
/// the <c>Spindle/{name}/</c> subtree.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read the fast-poll dynamic bundle for one axis via <c>cnc_rddynamic2</c>.
|
||||
/// Returns the current position quadruple (absolute / machine / relative /
|
||||
/// distance-to-go) plus actual feed rate + actual spindle speed + alarm
|
||||
/// flags + program / sequence numbers — one network round-trip per call.
|
||||
/// </summary>
|
||||
Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T2 (program + operation mode) ----
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate program + operation-mode snapshot. One wire round-trip per
|
||||
/// underlying FWLIB call — <c>cnc_rdblkcount</c>, <c>cnc_exeprgname2</c>,
|
||||
/// <c>cnc_rdopmode</c>. The driver polls this on a slower cadence than
|
||||
/// <see cref="ReadDynamicAsync"/> since program / mode transitions happen
|
||||
/// on human-operator timescales.
|
||||
/// </summary>
|
||||
Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T3 (timers) ----
|
||||
|
||||
/// <summary>
|
||||
/// Read one CNC cumulative timer. Kind selects PowerOn / Operating / Cutting /
|
||||
/// Cycle. Values are seconds — the managed side already converted the native
|
||||
/// minute+msec representation so downstream nodes display uniform units.
|
||||
/// </summary>
|
||||
Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T3.5 (servo meters) ----
|
||||
|
||||
/// <summary>
|
||||
/// Read the servo-load meter percentages across all configured axes.
|
||||
/// Values are percentages (scaled by <c>10^Dec</c>). Empty list on a
|
||||
/// disconnected session or unsupported CNC.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T3.6 (spindle meters) ----
|
||||
|
||||
/// <summary>
|
||||
/// Read per-spindle load percentages. Result list index corresponds to
|
||||
/// spindle index from <see cref="GetSpindleNamesAsync"/>. Empty list on a
|
||||
/// disconnected session or when the CNC doesn't support the call (older
|
||||
/// series like 16i may return EW_FUNC).
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read per-spindle maximum RPM values. Static configuration, fetched once at
|
||||
/// bootstrap. Index alignment as per <see cref="GetSpindleLoadsAsync"/>.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>One servo-meter entry — one axis's current load percentage.</summary>
|
||||
public sealed record FocasServoLoad(string AxisName, double LoadPercent);
|
||||
|
||||
/// <summary>Which cumulative counter <see cref="IFocasClient.GetTimerAsync"/> reads.</summary>
|
||||
public enum FocasTimerKind
|
||||
{
|
||||
/// <summary>Machine power-on hours — resets never.</summary>
|
||||
PowerOn = 0,
|
||||
/// <summary>Cycle operating time — resets when the operator clears the counter.</summary>
|
||||
Operating = 1,
|
||||
/// <summary>Cutting time — only counts while in cutting feed.</summary>
|
||||
Cutting = 2,
|
||||
/// <summary>Cycle time since the last program start.</summary>
|
||||
Cycle = 3,
|
||||
}
|
||||
|
||||
/// <summary>One cumulative timer reading. <see cref="TotalSeconds"/> is the canonical unit.</summary>
|
||||
public sealed record FocasTimer(FocasTimerKind Kind, int Minutes, int Milliseconds)
|
||||
{
|
||||
/// <summary>Cumulative time in seconds — <c>Minutes * 60 + Milliseconds / 1000</c>.</summary>
|
||||
public double TotalSeconds => Minutes * 60.0 + Milliseconds / 1000.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CNC identity snapshot from <c>cnc_sysinfo</c>. Strings are trimmed ASCII.
|
||||
/// </summary>
|
||||
public sealed record FocasSysInfo(
|
||||
int AddInfo,
|
||||
int MaxAxis,
|
||||
string CncType, // "M" (mill) / "T" (lathe)
|
||||
string MtType,
|
||||
string Series, // e.g. "30i"
|
||||
string Version, // e.g. "A1.0"
|
||||
int AxesCount);
|
||||
|
||||
/// <summary>One configured axis name (e.g. "X", "X1").</summary>
|
||||
public sealed record FocasAxisName(string Name, string Suffix)
|
||||
{
|
||||
/// <summary>
|
||||
/// Display name — name + suffix concatenated, trimmed. Empty suffix yields
|
||||
/// just the name (the common case on single-channel CNCs).
|
||||
/// </summary>
|
||||
public string Display => string.IsNullOrEmpty(Suffix) ? Name : $"{Name}{Suffix}";
|
||||
}
|
||||
|
||||
/// <summary>One configured spindle name (e.g. "S1").</summary>
|
||||
public sealed record FocasSpindleName(string Name, string Suffix1, string Suffix2, string Suffix3)
|
||||
{
|
||||
public string Display
|
||||
{
|
||||
get
|
||||
{
|
||||
var s = Name + Suffix1 + Suffix2 + Suffix3;
|
||||
return s.TrimEnd('\0', ' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fast-poll bundle for one axis. Position values are scaled integers; the caller
|
||||
/// divides by <c>10^DecimalPlaces</c> to get the decimal value. DecimalPlaces is
|
||||
/// currently left to the caller to supply (via device config or a future
|
||||
/// <c>cnc_getfigure</c> path once that export lands).
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Program + operation-mode snapshot. Name is the currently-executing
|
||||
/// program filename (e.g. "O0001.NC"); ONumber is its Fanuc O-number (1-9999).
|
||||
/// Mode is the numeric code from <c>cnc_rdopmode</c> — see <see cref="FocasOpMode"/>.
|
||||
/// </summary>
|
||||
public sealed record FocasProgramInfo(
|
||||
string Name,
|
||||
int ONumber,
|
||||
int BlockCount,
|
||||
int Mode);
|
||||
|
||||
/// <summary>Human-readable text for the <see cref="FocasProgramInfo.Mode"/> integer.</summary>
|
||||
public static class FocasOpMode
|
||||
{
|
||||
public static string ToText(int mode) => mode switch
|
||||
{
|
||||
0 => "MDI",
|
||||
1 => "AUTO",
|
||||
2 => "TJOG",
|
||||
3 => "EDIT",
|
||||
4 => "HANDLE",
|
||||
5 => "JOG",
|
||||
6 => "TEACH_IN_HANDLE",
|
||||
7 => "REFERENCE",
|
||||
8 => "REMOTE",
|
||||
9 => "TEST",
|
||||
_ => $"Mode{mode}",
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record FocasDynamicSnapshot(
|
||||
int AxisIndex,
|
||||
int AlarmFlags,
|
||||
int ProgramNumber,
|
||||
int MainProgramNumber,
|
||||
int SequenceNumber,
|
||||
int ActualFeedRate,
|
||||
int ActualSpindleSpeed,
|
||||
int AbsolutePosition,
|
||||
int MachinePosition,
|
||||
int RelativePosition,
|
||||
int DistanceToGo);
|
||||
|
||||
/// <summary>
|
||||
/// One active alarm surfaced by <see cref="IFocasClient.ReadAlarmsAsync"/>. Shape
|
||||
/// mirrors <c>ODBALMMSG2</c> but normalises the message bytes to a .NET string.
|
||||
/// </summary>
|
||||
public sealed record FocasActiveAlarm(
|
||||
int AlarmNumber,
|
||||
short Type,
|
||||
short Axis,
|
||||
string Message);
|
||||
|
||||
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
|
||||
public interface IFocasClientFactory
|
||||
{
|
||||
@@ -57,14 +256,32 @@ public interface IFocasClientFactory
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default factory that throws at construction time — the deployment must register a real
|
||||
/// factory. Keeps the driver assembly licence-clean while still allowing the skeleton to
|
||||
/// compile + the abstraction tests to run.
|
||||
/// Scaffolding factory — throws on <see cref="Create"/> so a DriverInstance row can be
|
||||
/// seeded ahead of the CNC endpoint being reachable without silently reading stale data.
|
||||
/// Select via <c>"Backend": "unimplemented"</c> in driver config. Flip to
|
||||
/// <c>"Backend": "wire"</c> once the CNC is provisioned.
|
||||
/// </summary>
|
||||
public sealed class UnimplementedFocasClientFactory : IFocasClientFactory
|
||||
{
|
||||
public IFocasClient Create() => throw new NotSupportedException(
|
||||
"FOCAS driver has no wire client configured. Register a real IFocasClientFactory at " +
|
||||
"server startup wrapping the licensed Fwlib32.dll — see docs/v2/focas-deployment.md. " +
|
||||
"Fanuc licensing forbids shipping Fwlib32.dll in the OtOpcUa package.");
|
||||
"FOCAS driver backend is 'unimplemented'. Switch to 'Backend: \"wire\"' in driver config " +
|
||||
"once the CNC is provisioned — see docs/drivers/FOCAS.md.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known FOCAS alarm types from <c>fwlib32.h</c> <c>ALM_TYPE_*</c>. Narrow subset —
|
||||
/// the full list is ~15 types per model; these cover the universally-present categories.
|
||||
/// </summary>
|
||||
public static class FocasAlarmType
|
||||
{
|
||||
/// <summary>Pass to <see cref="IFocasClient.ReadAlarmsAsync"/>-equivalent to mean "any type".</summary>
|
||||
public const int All = -1;
|
||||
public const int Parameter = 0; // ALM_P
|
||||
public const int PulseCode = 1; // ALM_Y (servo)
|
||||
public const int Overtravel = 2; // ALM_O
|
||||
public const int Overheat = 3; // ALM_H
|
||||
public const int Servo = 4; // ALM_S
|
||||
public const int DataIo = 5; // ALM_T
|
||||
public const int MemoryCheck = 6; // ALM_M
|
||||
public const int MacroAlarm = 13; // ALM_MC — used by #3006 etc.
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using MessagePack;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Proxy-side IPC channel to a running <c>Driver.FOCAS.Host</c>. Owns the pipe connection
|
||||
/// and serializes request/response round-trips through a single call gate so
|
||||
/// concurrent callers don't interleave frames. One instance per FOCAS Host session.
|
||||
/// </summary>
|
||||
public sealed class FocasIpcClient : IAsyncDisposable
|
||||
{
|
||||
private readonly Stream _stream;
|
||||
private readonly FrameReader _reader;
|
||||
private readonly FrameWriter _writer;
|
||||
private readonly SemaphoreSlim _callGate = new(1, 1);
|
||||
|
||||
private FocasIpcClient(Stream stream)
|
||||
{
|
||||
_stream = stream;
|
||||
_reader = new FrameReader(stream, leaveOpen: true);
|
||||
_writer = new FrameWriter(stream, leaveOpen: true);
|
||||
}
|
||||
|
||||
/// <summary>Named-pipe factory: connects, sends Hello, awaits HelloAck.</summary>
|
||||
public static async Task<FocasIpcClient> ConnectAsync(
|
||||
string pipeName, string sharedSecret, TimeSpan connectTimeout, CancellationToken ct)
|
||||
{
|
||||
var stream = new NamedPipeClientStream(
|
||||
serverName: ".",
|
||||
pipeName: pipeName,
|
||||
direction: PipeDirection.InOut,
|
||||
options: PipeOptions.Asynchronous);
|
||||
|
||||
await stream.ConnectAsync((int)connectTimeout.TotalMilliseconds, ct);
|
||||
return await HandshakeAsync(stream, sharedSecret, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stream factory — used by tests that wire the Proxy against an in-memory stream
|
||||
/// pair instead of a real pipe. <paramref name="stream"/> is owned by the caller
|
||||
/// until <see cref="DisposeAsync"/>.
|
||||
/// </summary>
|
||||
public static Task<FocasIpcClient> ConnectAsync(Stream stream, string sharedSecret, CancellationToken ct)
|
||||
=> HandshakeAsync(stream, sharedSecret, ct);
|
||||
|
||||
private static async Task<FocasIpcClient> HandshakeAsync(Stream stream, string sharedSecret, CancellationToken ct)
|
||||
{
|
||||
var client = new FocasIpcClient(stream);
|
||||
try
|
||||
{
|
||||
await client._writer.WriteAsync(FocasMessageKind.Hello,
|
||||
new Hello { PeerName = "FOCAS.Proxy", SharedSecret = sharedSecret }, ct).ConfigureAwait(false);
|
||||
|
||||
var ack = await client._reader.ReadFrameAsync(ct).ConfigureAwait(false);
|
||||
if (ack is null || ack.Value.Kind != FocasMessageKind.HelloAck)
|
||||
throw new InvalidOperationException("Did not receive HelloAck from FOCAS.Host");
|
||||
|
||||
var ackMsg = FrameReader.Deserialize<HelloAck>(ack.Value.Body);
|
||||
if (!ackMsg.Accepted)
|
||||
throw new UnauthorizedAccessException($"FOCAS.Host rejected Hello: {ackMsg.RejectReason}");
|
||||
|
||||
return client;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await client.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TResp> CallAsync<TReq, TResp>(
|
||||
FocasMessageKind requestKind, TReq request, FocasMessageKind expectedResponseKind, CancellationToken ct)
|
||||
{
|
||||
await _callGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _writer.WriteAsync(requestKind, request, ct).ConfigureAwait(false);
|
||||
|
||||
var frame = await _reader.ReadFrameAsync(ct).ConfigureAwait(false);
|
||||
if (frame is null) throw new EndOfStreamException("FOCAS IPC peer closed before response");
|
||||
|
||||
if (frame.Value.Kind == FocasMessageKind.ErrorResponse)
|
||||
{
|
||||
var err = MessagePackSerializer.Deserialize<ErrorResponse>(frame.Value.Body);
|
||||
throw new FocasIpcException(err.Code, err.Message);
|
||||
}
|
||||
|
||||
if (frame.Value.Kind != expectedResponseKind)
|
||||
throw new InvalidOperationException(
|
||||
$"Expected {expectedResponseKind}, got {frame.Value.Kind}");
|
||||
|
||||
return MessagePackSerializer.Deserialize<TResp>(frame.Value.Body);
|
||||
}
|
||||
finally { _callGate.Release(); }
|
||||
}
|
||||
|
||||
public async Task SendOneWayAsync<TReq>(FocasMessageKind requestKind, TReq request, CancellationToken ct)
|
||||
{
|
||||
await _callGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try { await _writer.WriteAsync(requestKind, request, ct).ConfigureAwait(false); }
|
||||
finally { _callGate.Release(); }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_callGate.Dispose();
|
||||
_reader.Dispose();
|
||||
_writer.Dispose();
|
||||
await _stream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FocasIpcException(string code, string message) : Exception($"[{code}] {message}")
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
using MessagePack;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IFocasClient"/> implementation that forwards every operation over a
|
||||
/// <see cref="FocasIpcClient"/> to a <c>Driver.FOCAS.Host</c> process. Keeps the
|
||||
/// <c>Fwlib32.dll</c> P/Invoke out of the main server process so a native crash
|
||||
/// blast-radius stops at the Host boundary.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Session lifecycle: <see cref="ConnectAsync"/> sends <c>OpenSessionRequest</c> and
|
||||
/// caches the returned <c>SessionId</c>. Subsequent <see cref="ReadAsync"/> /
|
||||
/// <see cref="WriteAsync"/> / <see cref="ProbeAsync"/> calls thread that session id
|
||||
/// onto each request DTO. <see cref="Dispose"/> sends <c>CloseSessionRequest</c> +
|
||||
/// disposes the underlying pipe.
|
||||
/// </remarks>
|
||||
public sealed class IpcFocasClient : IFocasClient
|
||||
{
|
||||
private readonly FocasIpcClient _ipc;
|
||||
private readonly FocasCncSeries _series;
|
||||
private long _sessionId;
|
||||
private bool _connected;
|
||||
|
||||
public IpcFocasClient(FocasIpcClient ipc, FocasCncSeries series = FocasCncSeries.Unknown)
|
||||
{
|
||||
_ipc = ipc ?? throw new ArgumentNullException(nameof(ipc));
|
||||
_series = series;
|
||||
}
|
||||
|
||||
public bool IsConnected => _connected;
|
||||
|
||||
public async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_connected) return;
|
||||
|
||||
var resp = await _ipc.CallAsync<OpenSessionRequest, OpenSessionResponse>(
|
||||
FocasMessageKind.OpenSessionRequest,
|
||||
new OpenSessionRequest
|
||||
{
|
||||
HostAddress = $"{address.Host}:{address.Port}",
|
||||
TimeoutMs = (int)Math.Max(1, timeout.TotalMilliseconds),
|
||||
CncSeries = (int)_series,
|
||||
},
|
||||
FocasMessageKind.OpenSessionResponse,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!resp.Success)
|
||||
throw new InvalidOperationException(
|
||||
$"FOCAS Host rejected OpenSession for {address}: {resp.ErrorCode ?? "?"} — {resp.Error}");
|
||||
|
||||
_sessionId = resp.SessionId;
|
||||
_connected = true;
|
||||
}
|
||||
|
||||
public async Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return (null, FocasStatusMapper.BadCommunicationError);
|
||||
|
||||
var resp = await _ipc.CallAsync<ReadRequest, ReadResponse>(
|
||||
FocasMessageKind.ReadRequest,
|
||||
new ReadRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
Address = ToDto(address),
|
||||
DataType = (int)type,
|
||||
},
|
||||
FocasMessageKind.ReadResponse,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!resp.Success) return (null, resp.StatusCode);
|
||||
|
||||
var value = DecodeValue(resp.ValueBytes, resp.ValueTypeCode);
|
||||
return (value, resp.StatusCode);
|
||||
}
|
||||
|
||||
public async Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return FocasStatusMapper.BadCommunicationError;
|
||||
|
||||
// PMC bit writes get the first-class RMW frame so the critical section stays on the Host.
|
||||
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit)
|
||||
{
|
||||
var bitResp = await _ipc.CallAsync<PmcBitWriteRequest, PmcBitWriteResponse>(
|
||||
FocasMessageKind.PmcBitWriteRequest,
|
||||
new PmcBitWriteRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
Address = ToDto(address),
|
||||
BitIndex = bit,
|
||||
Value = Convert.ToBoolean(value),
|
||||
},
|
||||
FocasMessageKind.PmcBitWriteResponse,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return bitResp.StatusCode;
|
||||
}
|
||||
|
||||
var resp = await _ipc.CallAsync<WriteRequest, WriteResponse>(
|
||||
FocasMessageKind.WriteRequest,
|
||||
new WriteRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
Address = ToDto(address),
|
||||
DataType = (int)type,
|
||||
ValueTypeCode = (int)type,
|
||||
ValueBytes = EncodeValue(value, type),
|
||||
},
|
||||
FocasMessageKind.WriteResponse,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return resp.StatusCode;
|
||||
}
|
||||
|
||||
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return false;
|
||||
try
|
||||
{
|
||||
var resp = await _ipc.CallAsync<ProbeRequest, ProbeResponse>(
|
||||
FocasMessageKind.ProbeRequest,
|
||||
new ProbeRequest { SessionId = _sessionId },
|
||||
FocasMessageKind.ProbeResponse,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return resp.Healthy;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_connected)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ipc.SendOneWayAsync(FocasMessageKind.CloseSessionRequest,
|
||||
new CloseSessionRequest { SessionId = _sessionId }, CancellationToken.None)
|
||||
.GetAwaiter().GetResult();
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
_connected = false;
|
||||
}
|
||||
_ipc.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
private static FocasAddressDto ToDto(FocasAddress addr) => new()
|
||||
{
|
||||
Kind = (int)addr.Kind,
|
||||
PmcLetter = addr.PmcLetter,
|
||||
Number = addr.Number,
|
||||
BitIndex = addr.BitIndex,
|
||||
};
|
||||
|
||||
private static byte[]? EncodeValue(object? value, FocasDataType type)
|
||||
{
|
||||
if (value is null) return null;
|
||||
return type switch
|
||||
{
|
||||
FocasDataType.Bit => MessagePackSerializer.Serialize(Convert.ToBoolean(value)),
|
||||
FocasDataType.Byte => MessagePackSerializer.Serialize(Convert.ToByte(value)),
|
||||
FocasDataType.Int16 => MessagePackSerializer.Serialize(Convert.ToInt16(value)),
|
||||
FocasDataType.Int32 => MessagePackSerializer.Serialize(Convert.ToInt32(value)),
|
||||
FocasDataType.Float32 => MessagePackSerializer.Serialize(Convert.ToSingle(value)),
|
||||
FocasDataType.Float64 => MessagePackSerializer.Serialize(Convert.ToDouble(value)),
|
||||
FocasDataType.String => MessagePackSerializer.Serialize(Convert.ToString(value) ?? string.Empty),
|
||||
_ => MessagePackSerializer.Serialize(Convert.ToInt32(value)),
|
||||
};
|
||||
}
|
||||
|
||||
private static object? DecodeValue(byte[]? bytes, int typeCode)
|
||||
{
|
||||
if (bytes is null) return null;
|
||||
return typeCode switch
|
||||
{
|
||||
FocasDataTypeCode.Bit => MessagePackSerializer.Deserialize<bool>(bytes),
|
||||
FocasDataTypeCode.Byte => MessagePackSerializer.Deserialize<byte>(bytes),
|
||||
FocasDataTypeCode.Int16 => MessagePackSerializer.Deserialize<short>(bytes),
|
||||
FocasDataTypeCode.Int32 => MessagePackSerializer.Deserialize<int>(bytes),
|
||||
FocasDataTypeCode.Float32 => MessagePackSerializer.Deserialize<float>(bytes),
|
||||
FocasDataTypeCode.Float64 => MessagePackSerializer.Deserialize<double>(bytes),
|
||||
FocasDataTypeCode.String => MessagePackSerializer.Deserialize<string>(bytes),
|
||||
_ => MessagePackSerializer.Deserialize<int>(bytes),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory producing <see cref="IpcFocasClient"/>s. One pipe connection per
|
||||
/// <c>IFocasClient</c> — matches the driver's one-client-per-device invariant. The
|
||||
/// deployment wires this into the DI container in place of
|
||||
/// <see cref="UnimplementedFocasClientFactory"/>.
|
||||
/// </summary>
|
||||
public sealed class IpcFocasClientFactory(Func<FocasIpcClient> ipcClientFactory, FocasCncSeries series = FocasCncSeries.Unknown)
|
||||
: IFocasClientFactory
|
||||
{
|
||||
public IFocasClient Create() => new IpcFocasClient(ipcClientFactory(), series);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||
|
||||
/// <summary>
|
||||
/// Respawn-with-backoff schedule for the FOCAS Host process. Matches Galaxy Tier-C:
|
||||
/// 5s → 15s → 60s cap. A sustained stable run (default 2 min) resets the index so a
|
||||
/// one-off crash after hours of steady-state doesn't start from the top of the ladder.
|
||||
/// </summary>
|
||||
public sealed class Backoff
|
||||
{
|
||||
public static TimeSpan[] DefaultSequence { get; } =
|
||||
[TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(60)];
|
||||
|
||||
public TimeSpan StableRunThreshold { get; init; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
private readonly TimeSpan[] _sequence;
|
||||
private int _index;
|
||||
|
||||
public Backoff(TimeSpan[]? sequence = null) => _sequence = sequence ?? DefaultSequence;
|
||||
|
||||
public TimeSpan Next()
|
||||
{
|
||||
var delay = _sequence[Math.Min(_index, _sequence.Length - 1)];
|
||||
_index++;
|
||||
return delay;
|
||||
}
|
||||
|
||||
public void RecordStableRun() => _index = 0;
|
||||
|
||||
public int AttemptIndex => _index;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||
|
||||
/// <summary>
|
||||
/// Crash-loop circuit breaker for the FOCAS Host. Matches Galaxy Tier-C defaults:
|
||||
/// 3 crashes within 5 minutes opens the breaker; cooldown escalates 1h → 4h → manual
|
||||
/// reset. A sticky alert stays live until the operator explicitly clears it so
|
||||
/// recurring crashes can't silently burn through the cooldown ladder overnight.
|
||||
/// </summary>
|
||||
public sealed class CircuitBreaker
|
||||
{
|
||||
public int CrashesAllowedPerWindow { get; init; } = 3;
|
||||
public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
public TimeSpan[] CooldownEscalation { get; init; } =
|
||||
[TimeSpan.FromHours(1), TimeSpan.FromHours(4), TimeSpan.MaxValue];
|
||||
|
||||
private readonly List<DateTime> _crashesUtc = [];
|
||||
private DateTime? _openSinceUtc;
|
||||
private int _escalationLevel;
|
||||
public bool StickyAlertActive { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Records a crash + returns <c>true</c> if the supervisor may respawn. On
|
||||
/// <c>false</c>, <paramref name="cooldownRemaining"/> is how long to wait before
|
||||
/// trying again (<c>TimeSpan.MaxValue</c> means manual reset required).
|
||||
/// </summary>
|
||||
public bool TryRecordCrash(DateTime utcNow, out TimeSpan cooldownRemaining)
|
||||
{
|
||||
if (_openSinceUtc is { } openedAt)
|
||||
{
|
||||
var cooldown = CooldownEscalation[Math.Min(_escalationLevel, CooldownEscalation.Length - 1)];
|
||||
if (cooldown == TimeSpan.MaxValue)
|
||||
{
|
||||
cooldownRemaining = TimeSpan.MaxValue;
|
||||
return false;
|
||||
}
|
||||
if (utcNow - openedAt < cooldown)
|
||||
{
|
||||
cooldownRemaining = cooldown - (utcNow - openedAt);
|
||||
return false;
|
||||
}
|
||||
|
||||
_openSinceUtc = null;
|
||||
_escalationLevel++;
|
||||
}
|
||||
|
||||
_crashesUtc.RemoveAll(t => utcNow - t > Window);
|
||||
_crashesUtc.Add(utcNow);
|
||||
|
||||
if (_crashesUtc.Count > CrashesAllowedPerWindow)
|
||||
{
|
||||
_openSinceUtc = utcNow;
|
||||
StickyAlertActive = true;
|
||||
cooldownRemaining = CooldownEscalation[Math.Min(_escalationLevel, CooldownEscalation.Length - 1)];
|
||||
return false;
|
||||
}
|
||||
|
||||
cooldownRemaining = TimeSpan.Zero;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void ManualReset()
|
||||
{
|
||||
_crashesUtc.Clear();
|
||||
_openSinceUtc = null;
|
||||
_escalationLevel = 0;
|
||||
StickyAlertActive = false;
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||
|
||||
/// <summary>
|
||||
/// Ties <see cref="IHostProcessLauncher"/> + <see cref="Backoff"/> +
|
||||
/// <see cref="CircuitBreaker"/> + <see cref="HeartbeatMonitor"/> into one object the
|
||||
/// driver asks for <c>IFocasClient</c>s. On a detected crash (process exit or
|
||||
/// heartbeat loss) the supervisor fans out <c>BadCommunicationError</c> to all
|
||||
/// subscribers via the <see cref="OnUnavailable"/> callback, then respawns with
|
||||
/// backoff unless the breaker is open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The supervisor itself is I/O-free — it doesn't know how to spawn processes, probe
|
||||
/// pipes, or send heartbeats. Production wires the concrete
|
||||
/// <see cref="IHostProcessLauncher"/> over <c>FocasIpcClient</c> + <c>Process</c>;
|
||||
/// tests drive the same state machine with a deterministic launcher stub.
|
||||
/// </remarks>
|
||||
public sealed class FocasHostSupervisor : IDisposable
|
||||
{
|
||||
private readonly IHostProcessLauncher _launcher;
|
||||
private readonly Backoff _backoff;
|
||||
private readonly CircuitBreaker _breaker;
|
||||
private readonly Func<DateTime> _clock;
|
||||
private IFocasClient? _current;
|
||||
private DateTime _currentStartedUtc;
|
||||
private bool _disposed;
|
||||
|
||||
public FocasHostSupervisor(
|
||||
IHostProcessLauncher launcher,
|
||||
Backoff? backoff = null,
|
||||
CircuitBreaker? breaker = null,
|
||||
Func<DateTime>? clock = null)
|
||||
{
|
||||
_launcher = launcher ?? throw new ArgumentNullException(nameof(launcher));
|
||||
_backoff = backoff ?? new Backoff();
|
||||
_breaker = breaker ?? new CircuitBreaker();
|
||||
_clock = clock ?? (() => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>Raised with a short reason string whenever the Host goes unavailable (crash / heartbeat loss / breaker-open).</summary>
|
||||
public event Action<string>? OnUnavailable;
|
||||
|
||||
/// <summary>Crash count observed in the current process lifetime. Exposed for /hosts Admin telemetry.</summary>
|
||||
public int ObservedCrashes { get; private set; }
|
||||
|
||||
/// <summary><c>true</c> if the crash-loop breaker has latched a sticky alert that needs operator reset.</summary>
|
||||
public bool StickyAlertActive => _breaker.StickyAlertActive;
|
||||
|
||||
public int BackoffAttempt => _backoff.AttemptIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current live client. If none, tries to launch — applying the
|
||||
/// backoff schedule between attempts and stopping once the breaker opens.
|
||||
/// </summary>
|
||||
public async Task<IFocasClient> GetOrLaunchAsync(CancellationToken ct)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (_current is not null && _launcher.IsProcessAlive) return _current;
|
||||
|
||||
return await LaunchWithBackoffAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by the heartbeat task each time a miss threshold is crossed.
|
||||
/// Treated as a crash: fan out Bad status + attempt respawn.
|
||||
/// </summary>
|
||||
public async Task NotifyHostDeadAsync(string reason, CancellationToken ct)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
OnUnavailable?.Invoke(reason);
|
||||
ObservedCrashes++;
|
||||
try { await _launcher.TerminateAsync(ct).ConfigureAwait(false); }
|
||||
catch { /* best effort */ }
|
||||
_current?.Dispose();
|
||||
_current = null;
|
||||
|
||||
if (!_breaker.TryRecordCrash(_clock(), out var cooldown))
|
||||
{
|
||||
OnUnavailable?.Invoke(cooldown == TimeSpan.MaxValue
|
||||
? "circuit-breaker-open-manual-reset-required"
|
||||
: $"circuit-breaker-open-cooldown-{cooldown:g}");
|
||||
return;
|
||||
}
|
||||
// Successful crash recording — do not respawn synchronously; GetOrLaunchAsync will
|
||||
// pick up the attempt on the next call. Keeps the fan-out fast.
|
||||
}
|
||||
|
||||
/// <summary>Operator action — clear the sticky alert + reset the breaker.</summary>
|
||||
public void AcknowledgeAndReset()
|
||||
{
|
||||
_breaker.ManualReset();
|
||||
_backoff.RecordStableRun();
|
||||
}
|
||||
|
||||
private async Task<IFocasClient> LaunchWithBackoffAsync(CancellationToken ct)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (_breaker.StickyAlertActive)
|
||||
{
|
||||
if (!_breaker.TryRecordCrash(_clock(), out var cooldown) && cooldown == TimeSpan.MaxValue)
|
||||
throw new InvalidOperationException(
|
||||
"FOCAS Host circuit breaker is open and awaiting manual reset. " +
|
||||
"See Admin /hosts; call AcknowledgeAndReset after investigating the Host log.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_current = await _launcher.LaunchAsync(ct).ConfigureAwait(false);
|
||||
_currentStartedUtc = _clock();
|
||||
|
||||
// If the launch sequence itself takes long enough to count as a stable run,
|
||||
// reset the backoff ladder immediately.
|
||||
if (_clock() - _currentStartedUtc >= _backoff.StableRunThreshold)
|
||||
_backoff.RecordStableRun();
|
||||
|
||||
return _current;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
OnUnavailable?.Invoke($"launch-failed: {ex.Message}");
|
||||
ObservedCrashes++;
|
||||
if (!_breaker.TryRecordCrash(_clock(), out var cooldown))
|
||||
{
|
||||
var hint = cooldown == TimeSpan.MaxValue
|
||||
? "manual reset required"
|
||||
: $"cooldown {cooldown:g}";
|
||||
throw new InvalidOperationException(
|
||||
$"FOCAS Host circuit breaker opened after {ObservedCrashes} crashes — {hint}.", ex);
|
||||
}
|
||||
|
||||
var delay = _backoff.Next();
|
||||
await Task.Delay(delay, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Called from the heartbeat loop after a successful ack run — relaxes the backoff ladder.</summary>
|
||||
public void NotifyStableRun()
|
||||
{
|
||||
if (_current is null) return;
|
||||
if (_clock() - _currentStartedUtc >= _backoff.StableRunThreshold)
|
||||
_backoff.RecordStableRun();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
try { _launcher.TerminateAsync(CancellationToken.None).GetAwaiter().GetResult(); }
|
||||
catch { /* best effort */ }
|
||||
_current?.Dispose();
|
||||
_current = null;
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(FocasHostSupervisor));
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks missed heartbeats from the FOCAS Host. 2s cadence + 3 consecutive misses =
|
||||
/// host declared dead (~6s detection). Same defaults as Galaxy Tier-C so operators
|
||||
/// see the same cadence across hosts on the /hosts Admin page.
|
||||
/// </summary>
|
||||
public sealed class HeartbeatMonitor
|
||||
{
|
||||
public int MissesUntilDead { get; init; } = 3;
|
||||
|
||||
public TimeSpan Cadence { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
public int ConsecutiveMisses { get; private set; }
|
||||
public DateTime? LastAckUtc { get; private set; }
|
||||
|
||||
public void RecordAck(DateTime utcNow)
|
||||
{
|
||||
ConsecutiveMisses = 0;
|
||||
LastAckUtc = utcNow;
|
||||
}
|
||||
|
||||
/// <summary>Records a missed heartbeat; returns <c>true</c> when the death threshold is crossed.</summary>
|
||||
public bool RecordMiss()
|
||||
{
|
||||
ConsecutiveMisses++;
|
||||
return ConsecutiveMisses >= MissesUntilDead;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the act of spawning a FOCAS Host process and obtaining an
|
||||
/// <see cref="IFocasClient"/> connected to it. Production wires this to a real
|
||||
/// <c>Process.Start</c> + <c>FocasIpcClient.ConnectAsync</c>; tests use a fake that
|
||||
/// exposes deterministic failure modes so the supervisor logic can be stressed
|
||||
/// without spawning actual exes.
|
||||
/// </summary>
|
||||
public interface IHostProcessLauncher
|
||||
{
|
||||
/// <summary>
|
||||
/// Spawn a new Host process (if one isn't already running) and return a live
|
||||
/// client session. Throws on unrecoverable errors; transient errors (e.g. Host
|
||||
/// not ready yet) should throw <see cref="TimeoutException"/> so the supervisor
|
||||
/// applies the backoff ladder.
|
||||
/// </summary>
|
||||
Task<IFocasClient> LaunchAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Terminate the Host process if one is running. Called on Dispose and after a
|
||||
/// heartbeat loss is detected.
|
||||
/// </summary>
|
||||
Task TerminateAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> when the most recently spawned Host process is still alive.
|
||||
/// Supervisor polls this at heartbeat cadence; going <c>false</c> without a
|
||||
/// clean shutdown counts as a crash.
|
||||
/// </summary>
|
||||
bool IsProcessAlive { get; }
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using System.IO.MemoryMappedFiles;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||
|
||||
/// <summary>
|
||||
/// Proxy-side reader for the Host's post-mortem MMF. After a Host crash the supervisor
|
||||
/// opens the file (which persists beyond the process lifetime) and enumerates the last
|
||||
/// few thousand IPC operations that were in flight. Format matches
|
||||
/// <c>Driver.FOCAS.Host.Stability.PostMortemMmf</c> — magic 'OFPC' / 256-byte entries.
|
||||
/// </summary>
|
||||
public sealed class PostMortemReader
|
||||
{
|
||||
private const int Magic = 0x4F465043; // 'OFPC'
|
||||
private const int HeaderBytes = 16;
|
||||
private const int EntryBytes = 256;
|
||||
private const int MessageOffset = 16;
|
||||
private const int MessageCapacity = EntryBytes - MessageOffset;
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public PostMortemReader(string path) => Path = path;
|
||||
|
||||
public PostMortemEntry[] ReadAll()
|
||||
{
|
||||
if (!File.Exists(Path)) return [];
|
||||
|
||||
using var mmf = MemoryMappedFile.CreateFromFile(Path, FileMode.Open, null, 0, MemoryMappedFileAccess.Read);
|
||||
using var accessor = mmf.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read);
|
||||
|
||||
if (accessor.ReadInt32(0) != Magic) return [];
|
||||
|
||||
var capacity = accessor.ReadInt32(8);
|
||||
var writeIndex = accessor.ReadInt32(12);
|
||||
var entries = new PostMortemEntry[capacity];
|
||||
var count = 0;
|
||||
|
||||
for (var i = 0; i < capacity; i++)
|
||||
{
|
||||
var slot = (writeIndex + i) % capacity;
|
||||
var offset = HeaderBytes + slot * EntryBytes;
|
||||
var ts = accessor.ReadInt64(offset + 0);
|
||||
if (ts == 0) continue;
|
||||
var op = accessor.ReadInt64(offset + 8);
|
||||
var msgBuf = new byte[MessageCapacity];
|
||||
accessor.ReadArray(offset + MessageOffset, msgBuf, 0, MessageCapacity);
|
||||
var nulTerm = Array.IndexOf<byte>(msgBuf, 0);
|
||||
var msg = Encoding.UTF8.GetString(msgBuf, 0, nulTerm < 0 ? MessageCapacity : nulTerm);
|
||||
entries[count++] = new PostMortemEntry(ts, op, msg);
|
||||
}
|
||||
|
||||
Array.Resize(ref entries, count);
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct PostMortemEntry(long UtcUnixMs, long OpKind, string Message);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user