Compare commits
26 Commits
focas-vers
...
phase-7-st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df39809526 | ||
| 2a8bcc8f60 | |||
|
|
479af166ab | ||
| 00724e9784 | |||
|
|
36774842cf | ||
| cb5d7b2d58 | |||
|
|
0ae715cca4 | ||
| d2bfcd9f1e | |||
|
|
e4dae01bac | ||
| 6ae638a6de | |||
|
|
2a74daf228 | ||
| 3eb5f1d9da | |||
|
|
f2c1cc84e9 | ||
| 8384e58655 | |||
|
|
96940aeb24 | ||
| 340f580be0 | |||
|
|
8d88ffa14d | ||
| 446a5c022c | |||
|
|
5033609944 | ||
| 9034294b77 | |||
|
|
3892555631 | ||
| 3609a5c676 | |||
|
|
a6f53e5b22 | ||
| b968496471 | |||
|
|
e6ff39148b | ||
| 4a6fe7fa7e |
@@ -3,6 +3,9 @@
|
|||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
||||||
@@ -14,6 +17,8 @@
|
|||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.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.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/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.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.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||||
@@ -24,6 +29,9 @@
|
|||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Tests/ZB.MOM.WW.OtOpcUa.Core.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Tests/ZB.MOM.WW.OtOpcUa.Core.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>
|
||||||
@@ -41,6 +49,8 @@
|
|||||||
<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.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.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.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.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.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.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"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj"/>
|
||||||
|
|||||||
@@ -69,14 +69,32 @@ covers the common address shapes; per-model quirks are not stressed.
|
|||||||
- Parameter range enforcement (CNC rejects out-of-range writes)
|
- Parameter range enforcement (CNC rejects out-of-range writes)
|
||||||
- MTB (machine tool builder) custom screens that expose non-standard data
|
- MTB (machine tool builder) custom screens that expose non-standard data
|
||||||
|
|
||||||
### 5. Tier-C process isolation behavior
|
### 5. Tier-C process isolation — architecture shipped, Fwlib32 integration hardware-gated
|
||||||
|
|
||||||
Per driver-stability.md, FOCAS should run process-isolated because
|
The Tier-C architecture is now in place as of PRs #169–#173 (FOCAS
|
||||||
`Fwlib32.dll` has documented crash modes. The test suite runs in-process +
|
PR A–E, task #220):
|
||||||
only exercises the happy path + mapped error codes — a native access
|
|
||||||
violation from the DLL would take the test host down. The process-isolation
|
- `Driver.FOCAS.Shared` carries MessagePack IPC contracts
|
||||||
path (similar to Galaxy's out-of-process Host) has been scoped but not
|
- `Driver.FOCAS.Host` (.NET 4.8 x86 Windows service via NSSM) accepts
|
||||||
implemented.
|
a connection on a strictly-ACL'd named pipe + dispatches frames to
|
||||||
|
an `IFocasBackend`
|
||||||
|
- `Driver.FOCAS.Ipc.IpcFocasClient` implements the `IFocasClient` DI
|
||||||
|
seam by forwarding over IPC — swap the DI registration and the
|
||||||
|
driver runs Tier-C with zero other changes
|
||||||
|
- `Driver.FOCAS.Supervisor.FocasHostSupervisor` owns the spawn +
|
||||||
|
heartbeat + respawn + 3-in-5min crash-loop breaker + sticky alert
|
||||||
|
- `Driver.FOCAS.Host.Stability.PostMortemMmf` ↔
|
||||||
|
`Driver.FOCAS.Supervisor.PostMortemReader` — ring-buffer of the
|
||||||
|
last ~1000 IPC operations survives a Host crash
|
||||||
|
|
||||||
|
The one remaining gap is the production `FwlibHostedBackend`: an
|
||||||
|
`IFocasBackend` implementation that wraps the licensed
|
||||||
|
`Fwlib32.dll` P/Invoke. That's hardware-gated on task #222 — we
|
||||||
|
need a CNC on the bench (or the licensed FANUC developer kit DLL
|
||||||
|
with a test harness) to validate it. Until then, the Host ships
|
||||||
|
`FakeFocasBackend` + `UnconfiguredFocasBackend`. Setting
|
||||||
|
`OTOPCUA_FOCAS_BACKEND=fake` lets operators smoke-test the whole
|
||||||
|
Tier-C pipeline end-to-end without any CNC.
|
||||||
|
|
||||||
## When to trust FOCAS tests, when to reach for a rig
|
## When to trust FOCAS tests, when to reach for a rig
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ shaped (neither is a Modbus-side concept).
|
|||||||
- `DL205SmokeTests` — FC16 write → FC03 read round-trip on holding register
|
- `DL205SmokeTests` — FC16 write → FC03 read round-trip on holding register
|
||||||
- `DL205CoilMappingTests` — Y-output / C-relay / X-input address mapping
|
- `DL205CoilMappingTests` — Y-output / C-relay / X-input address mapping
|
||||||
(octal → Modbus offset)
|
(octal → Modbus offset)
|
||||||
- `DL205ExceptionCodeTests` — Modbus exception → OPC UA StatusCode mapping
|
- `DL205ExceptionCodeTests` — Modbus exception 0x02 → OPC UA `BadOutOfRange` against the dl205 profile (natural out-of-range path)
|
||||||
|
- `ExceptionInjectionTests` — every other exception code in the mapping table (0x01 / 0x03 / 0x04 / 0x05 / 0x06 / 0x0A / 0x0B) against the `exception_injection` profile on both read + write paths
|
||||||
- `DL205FloatCdabQuirkTests` — CDAB word-swap float encoding
|
- `DL205FloatCdabQuirkTests` — CDAB word-swap float encoding
|
||||||
- `DL205StringQuirkTests` — packed-string V-memory layout
|
- `DL205StringQuirkTests` — packed-string V-memory layout
|
||||||
- `DL205VMemoryQuirkTests` — V-memory octal addressing
|
- `DL205VMemoryQuirkTests` — V-memory octal addressing
|
||||||
@@ -103,8 +104,13 @@ Not a Modbus concept. Driver doesn't implement `IAlarmSource` or
|
|||||||
|
|
||||||
1. Add `MODBUS_SIM_ENDPOINT` override documentation to
|
1. Add `MODBUS_SIM_ENDPOINT` override documentation to
|
||||||
`docs/v2/test-data-sources.md` so operators can point the suite at a lab rig.
|
`docs/v2/test-data-sources.md` so operators can point the suite at a lab rig.
|
||||||
2. Extend `pymodbus` profiles to inject exception responses — a JSON flag per
|
2. ~~Extend `pymodbus` profiles to inject exception responses~~ — **shipped**
|
||||||
register saying "next read returns exception 0x04."
|
via the `exception_injection` compose profile + standalone
|
||||||
|
`exception_injector.py` server. Rules in
|
||||||
|
`Docker/profiles/exception_injection.json` map `(fc, address)` to an
|
||||||
|
exception code; `ExceptionInjectionTests` exercises every code in
|
||||||
|
`MapModbusExceptionToStatus` (0x01 / 0x02 / 0x03 / 0x04 / 0x05 / 0x06 /
|
||||||
|
0x0A / 0x0B) end-to-end on both read (FC03) and write (FC06) paths.
|
||||||
3. Add an FX5U profile once a lab rig is available; the scaffolding is in place.
|
3. Add an FX5U profile once a lab rig is available; the scaffolding is in place.
|
||||||
|
|
||||||
## Key fixture / config files
|
## Key fixture / config files
|
||||||
|
|||||||
136
docs/v2/implementation/adr-002-driver-vs-virtual-dispatch.md
Normal file
136
docs/v2/implementation/adr-002-driver-vs-virtual-dispatch.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# ADR-002 — Driver-vs-virtual dispatch: how `DriverNodeManager` routes reads, writes, and subscriptions across driver tags and virtual (scripted) tags
|
||||||
|
|
||||||
|
**Status:** Accepted 2026-04-20 — Option B (single NodeManager + NodeSource tag on the resolver output); Options A and C explicitly rejected.
|
||||||
|
|
||||||
|
**Related phase:** [Phase 7 — Scripting Runtime + Scripted Alarms](phase-7-scripting-and-alarming.md) Stream G.
|
||||||
|
|
||||||
|
**Related tasks:** #237 Phase 7 Stream G — Address-space integration.
|
||||||
|
|
||||||
|
**Related ADRs:** [ADR-001 — Equipment node walker](adr-001-equipment-node-walker.md) (this ADR extends the walker + resolver it established).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Phase 7 introduces **virtual tags** — OPC UA variables whose values are computed by user-authored C# scripts against other tags (driver or virtual). Per design decision #2 in the Phase 7 plan, virtual tags **live in the Equipment tree alongside driver tags** (not a separate `/Virtual/...` namespace). An operator browsing `Enterprise/Site/Area/Line/Equipment/` sees a flat list of children that includes both driver-sourced variables (e.g. `SpeedSetpoint` coming from a Modbus tag) and virtual variables (e.g. `LineRate` computed from `SpeedSetpoint × 0.95`).
|
||||||
|
|
||||||
|
From the operator's perspective there is no difference. From the server's perspective there is a big one: a read / write / subscribe on a driver node must dispatch to a driver's `IReadable` / `IWritable` / `ISubscribable` implementation; the same operation on a virtual node must dispatch to the `VirtualTagEngine`. The existing `DriverNodeManager` (shipped in Phase 1, extended by ADR-001) only knows about the driver case today.
|
||||||
|
|
||||||
|
The question is how the dispatch should branch. Three options considered.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
### Option A — A separate `VirtualTagNodeManager` sibling to `DriverNodeManager`
|
||||||
|
|
||||||
|
Register a second `INodeManager` with the OPC UA stack dedicated to virtual-tag nodes. Each tag landed under an Equipment folder would be owned by whichever NodeManager materialized it; mixed folders would have children belonging to two different managers.
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Clean separation — virtual-tag code never touches driver code paths.
|
||||||
|
- Independent lifecycle: restart the virtual-tag engine without touching drivers.
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- ADR-001's `EquipmentNodeWalker` was designed as a single walker producing a single tree under one NodeManager. Forking into two walkers (one per source) risks the UNS / Equipment folders existing twice (once per manager) with different child sets, and the OPC UA stack treating them as distinct nodes.
|
||||||
|
- Mixed equipment folders: when a Line has 3 driver tags + 2 virtual tags, a client browsing the Line folder expects to see 5 children. Two NodeManagers each claiming ownership of the same folder adds the browse-merge problem the stack doesn't do cleanly.
|
||||||
|
- ACL binding (Phase 6.2 trie): one scope per Equipment folder, resolved by `NodeScopeResolver`. Two NodeManagers means two resolution paths or shared resolution logic — cross-manager coupling that defeats the separation.
|
||||||
|
- Audit pathways (Phase 6.2 `IAuditLogger`) and resilience wrappers (Phase 6.1 `CapabilityInvoker`) are wired into the existing `DriverNodeManager`. Duplicating them into a second manager doubles the surface that the Roslyn analyzer from Phase 6.1 Stream A follow-up must keep honest.
|
||||||
|
|
||||||
|
**Rejected** because the sharing of folders (Equipment nodes owning both kinds of children) is the common case, not the exception. Two NodeManagers would fight for ownership on every Equipment node.
|
||||||
|
|
||||||
|
### Option B — Single `DriverNodeManager`, `NodeScopeResolver` returns a `NodeSource` tag, dispatch branches on source
|
||||||
|
|
||||||
|
`NodeScopeResolver` (established in ADR-001) already joins nodes against the config DB to produce a `ScopeId` for ACL enforcement. Extend it to **also return a `NodeSource` enum** (`Driver` or `Virtual`). `DriverNodeManager` dispatch methods check the source and route:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal sealed class DriverNodeManager : CustomNodeManager2
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyDictionary<string, IDriver> _drivers;
|
||||||
|
private readonly IVirtualTagEngine _virtualTagEngine;
|
||||||
|
private readonly NodeScopeResolver _resolver;
|
||||||
|
|
||||||
|
protected override async Task ReadValueAsync(NodeId nodeId, ...)
|
||||||
|
{
|
||||||
|
var scope = _resolver.Resolve(nodeId);
|
||||||
|
// ... ACL check via Phase 6.2 trie (unchanged)
|
||||||
|
return scope.Source switch
|
||||||
|
{
|
||||||
|
NodeSource.Driver => await _drivers[scope.DriverInstanceId].ReadAsync(...),
|
||||||
|
NodeSource.Virtual => await _virtualTagEngine.ReadAsync(scope.VirtualTagId, ...),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Single address-space tree. `EquipmentNodeWalker` emits one folder per Equipment node and hangs both driver and virtual children under it. Browse / subscribe fan-out / ACL resolution all happen in one NodeManager with one mental model.
|
||||||
|
- ACL binding works identically for both kinds. A user with `ReadEquipment` on `Line1/Pump_7` can read every child, driver-sourced or virtual.
|
||||||
|
- Phase 6.1 resilience wrapping + Phase 6.2 audit logging apply uniformly. The `CapabilityInvoker` analyzer stays correct without new exemptions.
|
||||||
|
- Adding future source kinds (e.g. a "derived tag" that's neither a driver read nor a script evaluation) is a single-enum-case addition — no new NodeManager.
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- `NodeScopeResolver` becomes slightly chunkier — it now carries dispatch metadata in addition to ACL scope. We own that complexity; the payoff is one tree, one lifecycle.
|
||||||
|
- A bug in the dispatch branch could leak a driver call into the virtual path or vice versa. Mitigated by an xUnit theory in Stream G.4 that mixes both kinds in one Equipment folder and asserts each routes correctly.
|
||||||
|
|
||||||
|
**Accepted.**
|
||||||
|
|
||||||
|
### Option C — Virtual tag engine registers as a synthetic `IDriver`
|
||||||
|
|
||||||
|
Implement a `VirtualTagDriverAdapter` that wraps `VirtualTagEngine` and registers it alongside real drivers through the existing `DriverTypeRegistry`. Then `DriverNodeManager` dispatches everything through driver plumbing — virtual tags are just "a driver with no wire."
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Reuses every existing `IDriver` pathway without modification.
|
||||||
|
- Dispatch branch is trivial because there's no branch — everything routes through driver plumbing.
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- `DriverInstance` is the wrong shape for virtual-tag config: no `DriverType`, no `HostAddress`, no connectivity probe, no lifecycle-initialization parameters, no NSSM wrapper. Forcing it to fit means adding null columns / sentinel values everywhere.
|
||||||
|
- `IDriver.InitializeAsync` / `IRediscoverable` semantics don't match a scripting engine — the engine doesn't "discover" tags against a wire, it compiles scripts against a config snapshot.
|
||||||
|
- The resilience Polly wrappers are calibrated for network-bound calls (timeout / retry / circuit breaker). Applying them to a script evaluation is either a pointless passthrough or wrong tuning.
|
||||||
|
- The Admin UI would need special-casing in every driver-config screen to hide fields that don't apply. The shape mismatch leaks everywhere.
|
||||||
|
|
||||||
|
**Rejected** because the fit is worse than Option B's lightweight dispatch branch. The pretense of uniformity would cost more than the branch it avoids.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Option B is accepted.**
|
||||||
|
|
||||||
|
`NodeScopeResolver.Resolve(nodeId)` returns a `NodeScope` record with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed record NodeScope(
|
||||||
|
string ScopeId, // ACL scope ID — unchanged from ADR-001
|
||||||
|
NodeSource Source, // NEW: Driver or Virtual
|
||||||
|
string? DriverInstanceId, // populated when Source=Driver
|
||||||
|
string? VirtualTagId); // populated when Source=Virtual
|
||||||
|
|
||||||
|
public enum NodeSource
|
||||||
|
{
|
||||||
|
Driver,
|
||||||
|
Virtual,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`DriverNodeManager` holds a single reference to `IVirtualTagEngine` alongside its driver dictionary. Read / Write / Subscribe dispatch pattern-matches on `scope.Source` and routes accordingly. Writes to a virtual node from an OPC UA client return `BadUserAccessDenied` because per Phase 7 decision #6, virtual tags are writable **only** from scripts via `ctx.SetVirtualTag`. That check lives in `DriverNodeManager` before the dispatch branch — a dedicated ACL rule rather than a capability of the engine.
|
||||||
|
|
||||||
|
Dispatch tests (Phase 7 Stream G.4) must cover at minimum:
|
||||||
|
- Mixed Equipment folder (driver + virtual children) browses with all children visible
|
||||||
|
- Read routes to the correct backend for each source kind
|
||||||
|
- Subscribe delivers changes from both kinds on the same subscription
|
||||||
|
- OPC UA client write to a virtual node returns `BadUserAccessDenied` without invoking the engine
|
||||||
|
- Script-driven write to a virtual node (via `ctx.SetVirtualTag`) updates the value + fires subscription notifications
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- `EquipmentNodeWalker` (ADR-001) gains an extra input channel: the config DB's `VirtualTag` table alongside the existing `Tag` table. Walker emits both kinds of children under each Equipment folder with the `NodeSource` tag set per row.
|
||||||
|
- `NodeScopeResolver` gains a `NodeSource` return value. The change is additive (ADR-001's `ScopeId` field is unchanged), so Phase 6.2's ACL trie keeps working without modification.
|
||||||
|
- `DriverNodeManager` gains a dispatch branch but the shape of every `I*` call into drivers is unchanged. Phase 6.1's resilience wrapping applies identically to the driver branch; the virtual branch wraps separately (virtual tag evaluation errors map to `BadInternalError` per Phase 7 decision #11, not through the Polly pipeline).
|
||||||
|
- Adding a future source kind (e.g. an alias tag, a cross-cluster federation tag) is one enum case + one dispatch arm + the equivalent walker extension. The architecture is extensible without rewrite.
|
||||||
|
|
||||||
|
## Not Decided (revisitable)
|
||||||
|
|
||||||
|
- **Whether `IVirtualTagEngine` should live alongside `IDriver` in `Core.Abstractions` or stay in the Phase 7 project.** Plan currently keeps it in Phase 7's `Core.VirtualTags` project because it's not a driver capability. If Phase 7 Stream G discovers significant shared surface, promote later — not blocking.
|
||||||
|
- **Whether server-side method calls from OPC UA clients (e.g. a future "force-recompute-this-virtual-tag" admin method) should route through the same dispatch.** Out of scope — virtual tags have no method nodes today; scripted alarm method calls (`OneShotShelve` etc.) route through their own `ScriptedAlarmEngine` path per Phase 7 Stream C.6.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Phase 7 — Scripting Runtime + Scripted Alarms](phase-7-scripting-and-alarming.md) Stream G
|
||||||
|
- [ADR-001 — Equipment node walker](adr-001-equipment-node-walker.md)
|
||||||
|
- [`docs/v2/plan.md`](../plan.md) decision #110 (Tag-to-Equipment binding)
|
||||||
|
- [`docs/v2/plan.md`](../plan.md) decision #120 (UNS hierarchy requirements)
|
||||||
|
- Phase 6.2 `NodeScopeResolver` ACL join
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
# FOCAS Tier-C isolation — plan for task #220
|
# FOCAS Tier-C isolation — plan for task #220
|
||||||
|
|
||||||
> **Status**: DRAFT — not yet started. Tracks the multi-PR work to
|
> **Status**: PRs A–E shipped. Architecture is in place; the only
|
||||||
> move `Fwlib32.dll` behind an out-of-process host, mirroring the
|
> remaining FOCAS work is the hardware-dependent production
|
||||||
> Galaxy Tier-C split in [`phase-2-galaxy-out-of-process.md`](phase-2-galaxy-out-of-process.md).
|
> integration of `Fwlib32.dll` into a real `IFocasBackend`
|
||||||
|
> (`FwlibHostedBackend`), which needs an actual CNC on the bench
|
||||||
|
> and is tracked as a follow-up on #220.
|
||||||
>
|
>
|
||||||
> **Pre-reqs shipped** (this PR): version matrix + pre-flight
|
> **Pre-reqs shipped**: version matrix + pre-flight validation
|
||||||
> validation + unit tests. Those close the cheap half of the
|
> (PR #168 — the cheap half of the hardware-free stability gap).
|
||||||
> hardware-free stability gap. Tier-C closes the expensive half.
|
|
||||||
|
|
||||||
## Why isolate
|
## Why isolate
|
||||||
|
|
||||||
@@ -79,32 +80,41 @@ its own timer + pushes change notifications so the Proxy doesn't
|
|||||||
round-trip per poll. Matches `Driver.Galaxy.Host` subscription
|
round-trip per poll. Matches `Driver.Galaxy.Host` subscription
|
||||||
forwarding.
|
forwarding.
|
||||||
|
|
||||||
## PR sequence (proposed)
|
## PR sequence — shipped
|
||||||
|
|
||||||
1. **PR A — shared contracts**
|
1. **PR A (#169) — shared contracts** ✅
|
||||||
Create `Driver.FOCAS.Shared` with the MessagePack DTOs. No
|
`Driver.FOCAS.Shared` netstandard2.0 with MessagePack DTOs for every
|
||||||
behaviour change. ~200 LOC + round-trip tests for each DTO.
|
IPC surface (Hello/Heartbeat/OpenSession/Read/Write/PmcBitWrite/
|
||||||
2. **PR B — Host project skeleton**
|
Subscribe/Probe/RuntimeStatus/Recycle/ErrorResponse) + FrameReader/
|
||||||
Create `Driver.FOCAS.Host` .NET 4.8 x86 project, NSSM wrapper,
|
FrameWriter + 24 round-trip tests.
|
||||||
pipe server scaffold with the same ACL + caller-SID + shared
|
2. **PR B (#170) — Host project skeleton** ✅
|
||||||
secret plumbing as Galaxy.Host. No Fwlib32 wiring yet — returns
|
`Driver.FOCAS.Host` net48 x86 Windows Service entry point,
|
||||||
`NotImplemented` for everything. ~400 LOC.
|
`PipeAcl` + `PipeServer` + `IFrameHandler` + `StubFrameHandler`.
|
||||||
3. **PR C — Move Fwlib32 calls into Host**
|
ACL denies LocalSystem/Administrators; Hello verifies
|
||||||
Move `FocasNativeSession`, `FocasTagReader`, `FocasTagWriter`,
|
shared-secret + protocol major. 3 handshake tests.
|
||||||
`FocasPmcBitRmw` + the STA thread into the Host. Proxy forwards
|
3. **PR C (#171) — IPC path end-to-end** ✅
|
||||||
over IPC. This is the biggest PR — probably 800-1500 LOC of
|
Proxy `Ipc/FocasIpcClient` + `Ipc/IpcFocasClient` (implements
|
||||||
move-with-translation. Existing unit tests keep passing because
|
IFocasClient via IPC). Host `Backend/IFocasBackend` +
|
||||||
`IFocasTagFactory` is the DI seam the tests inject against.
|
`FakeFocasBackend` + `UnconfiguredFocasBackend` +
|
||||||
4. **PR D — Supervisor + respawn**
|
`Ipc/FwlibFrameHandler` replacing the stub. 13 new round-trip
|
||||||
Proxy-side heartbeat + respawn + crash-loop circuit breaker +
|
tests via in-memory loopback.
|
||||||
BackPressure fan-out on Host death. ~500 LOC + chaos tests.
|
4. **PR D (#172) — Supervisor + respawn** ✅
|
||||||
5. **PR E — Post-mortem MMF + operational glue**
|
`Supervisor/Backoff` (5s→15s→60s) + `CircuitBreaker` (3-in-5min →
|
||||||
MMF writer in Host, reader in Proxy. Install scripts for the
|
1h→4h→manual) + `HeartbeatMonitor` + `IHostProcessLauncher` +
|
||||||
new `OtOpcUaFocasHost` Windows service. Docs. ~300 LOC.
|
`FocasHostSupervisor`. 14 tests.
|
||||||
|
5. **PR E — Ops glue** ✅ (this PR)
|
||||||
|
`ProcessHostLauncher` (real Process.Start + FocasIpcClient
|
||||||
|
connect), `Host/Stability/PostMortemMmf` (magic 'OFPC') +
|
||||||
|
Proxy `Supervisor/PostMortemReader`, `scripts/install/
|
||||||
|
Install-FocasHost.ps1` + `Uninstall-FocasHost.ps1` NSSM wrappers.
|
||||||
|
7 tests (4 MMF round-trip + 3 reader format compatibility).
|
||||||
|
|
||||||
Total estimate: 2200-3200 LOC across 5 PRs. Consistent with Galaxy
|
**Post-shipment totals: 189 FOCAS driver tests + 24 Shared tests + 13 Host tests = 226 FOCAS-family tests green.**
|
||||||
Tier-C but narrower since FOCAS has no Historian + no alarm
|
|
||||||
history.
|
What remains is hardware-dependent: wiring `Fwlib32.dll` P/Invoke
|
||||||
|
into a real `FwlibHostedBackend` implementation of `IFocasBackend`
|
||||||
|
+ validating against a live CNC. The architecture is all the
|
||||||
|
plumbing that work needs.
|
||||||
|
|
||||||
## Testing without hardware
|
## Testing without hardware
|
||||||
|
|
||||||
|
|||||||
190
docs/v2/implementation/phase-7-scripting-and-alarming.md
Normal file
190
docs/v2/implementation/phase-7-scripting-and-alarming.md
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
# Phase 7 — Scripting Runtime, Virtual Tags, and Scripted Alarms
|
||||||
|
|
||||||
|
> **Status**: DRAFT — planning output from the 2026-04-20 interactive planning session. Pending review before work begins. Task #230 tracks the draft; #231–#238 are the stream placeholders.
|
||||||
|
>
|
||||||
|
> **Branch**: `v2/phase-7-scripting-and-alarming`
|
||||||
|
> **Estimated duration**: 10–12 weeks (scope-comparable to Phase 6; largest single phase outside Phase 2 Galaxy split)
|
||||||
|
> **Predecessor**: Phase 6.4 (Admin UI completion) — reuses the tab-plugin pattern + draft/publish flow
|
||||||
|
> **Successor**: v2 release-readiness capstone
|
||||||
|
|
||||||
|
## Phase Objective
|
||||||
|
|
||||||
|
Add two **additive** runtime capabilities on top of the existing driver + Equipment address-space foundation:
|
||||||
|
|
||||||
|
1. **Virtual (calculated) tags** — OPC UA variables whose values are computed by user-authored C# scripts against other tags (driver or virtual), evaluated on change and/or timer. They live in the existing Equipment/UNS tree alongside driver tags and behave identically to clients (browse, subscribe, historize).
|
||||||
|
2. **Scripted alarms** — OPC UA Part 9 alarms whose condition is a user-authored C# predicate. Full state machine (EnabledState / ActiveState / AckedState / ConfirmedState / ShelvingState) with persistent operator-supplied state across restarts. Complement the existing Galaxy-native and AB CIP ALMD alarm sources — they do not replace them.
|
||||||
|
|
||||||
|
Tie-in capability — **historian alarm sink**:
|
||||||
|
|
||||||
|
3. **Aveva Historian as alarm system of record** — every qualifying alarm transition (activation, ack, confirm, clear, shelve, disable, comment) from **any `IAlarmSource`** (scripted + Galaxy + ALMD) routes through a new local SQLite store-and-forward queue to Galaxy.Host, which uses its already-loaded `aahClientManaged` DLLs to write to the Historian's alarm schema. Per-alarm `HistorizeToAveva` toggle gates which sources flow (default off for Galaxy-native since Galaxy itself already historizes them). Plant operators query one uniform historical alarm timeline.
|
||||||
|
|
||||||
|
**Why it's additive, not a rewrite**: every `IAlarmSource` implementation shipped in Phase 6.x stays unchanged; scripted alarms register as an additional source in the existing fan-out. The Equipment node walker built in ADR-001 gains a "virtual" source kind alongside "driver" without removing anything. Operator-facing semantics for existing driver tags and alarms are unchanged.
|
||||||
|
|
||||||
|
## Design Decisions (locked in the 2026-04-20 planning session)
|
||||||
|
|
||||||
|
| # | Decision | Rationale |
|
||||||
|
|---|---------|-----------|
|
||||||
|
| 1 | Script language = **C# via Roslyn scripting** | Developer audience, strong typing, AST walkable for dependency inference, existing .NET 10 runtime in main server. |
|
||||||
|
| 2 | Virtual tags live in the **Equipment tree** alongside driver tags (not a separate `/Virtual/...` namespace) | Operator mental model stays unified; calculated `LineRate` shows up under the Line1 folder next to the driver-sourced `SpeedSetpoint` it's derived from. |
|
||||||
|
| 3 | Evaluation trigger = **change-driven + timer-driven**; operator chooses per-tag | Change-driven is cheap at steady state; timer is the escape hatch for polling derivations that don't have a discrete "input changed" signal. |
|
||||||
|
| 4 | Script shape = **Shape A — one script per virtual tag/alarm**; `return` produces the value (or `bool` for alarm condition) | Minimal surface; no predicate/action split. Alarm side-effects (severity, message) configured out-of-band, not in the script. |
|
||||||
|
| 5 | Alarm fidelity = **full OPC UA Part 9** | Uniform with Galaxy + ALMD on the wire; client-side tooling (HMIs, historians, event pipelines) gets one shape. |
|
||||||
|
| 6 | Sandbox = **read-only context**; scripts can only read any tag + write to virtual tags | Strict Roslyn `ScriptOptions` allow-list. No HttpClient / File / Process / reflection. |
|
||||||
|
| 7 | Dependency declaration = **AST inference**; operator doesn't maintain a separate dependency list | `CSharpSyntaxWalker` extracts `ctx.GetTag("path")` string-literal calls at compile time; dynamic paths rejected at publish. |
|
||||||
|
| 8 | Config storage = **config DB with generation-sealed cache** (same as driver instances) | Virtual tags + alarms publish atomically in the same generation as the driver instance config they may depend on. |
|
||||||
|
| 9 | Script return value shape (`ctx.GetTag`) = **`DataValue { Value, StatusCode, Timestamp }`** | Scripts branch on quality naturally without separate `ctx.GetQuality(...)` calls. |
|
||||||
|
| 10 | Historize virtual tags = **per-tag checkbox** | Writes flow through the same history-write path as driver tags. Consumed by existing `IHistoryProvider`. |
|
||||||
|
| 11 | Per-tag error isolation — a throwing script sets that tag's quality to `BadInternalError`; engine keeps running for every other tag | Mirrors Phase 6.1 Stream B's per-surface error handling. |
|
||||||
|
| 12 | Dedicated Serilog sink = `scripts-*.log` rolling file; structured-property `ScriptName` for filtering | Keeps noisy script logs out of the main `opcua-*.log`. `ctx.Logger.Info/Warning/Error/Debug` bound in the script context. |
|
||||||
|
| 13 | Alarm message = **template with substitution** (`"Reactor temp {Reactor/Temp} exceeded {Limit}"`) | Middle ground between static and separate message-script; engine resolves `{path}` tokens at event emission. |
|
||||||
|
| 14 | Alarm state persistence — `ActiveState` recomputed from tag values on startup; `EnabledState / AckedState / ConfirmedState / ShelvingState` + audit trail persist to config DB | Operators don't re-ack after restart; ack history survives for compliance (GxP / 21 CFR Part 11). |
|
||||||
|
| 15 | Historian sink scope = **all `IAlarmSource` implementations**, not just scripted; per-alarm `HistorizeToAveva` toggle | Plant gets one consolidated alarm timeline; Galaxy-native alarms default off to avoid duplication. |
|
||||||
|
| 16 | Historian failure mode = **SQLite store-and-forward queue on the node**; config DB is source of truth, Historian is best-effort projection | Operators never blocked by Historian downtime; failed writes queue + retry when Historian recovers. |
|
||||||
|
| 17 | Historian ingestion path = **IPC to Galaxy.Host**, which calls the already-loaded `aahClientManaged` DLLs | Reuses existing bitness / licensing / Tier-C isolation. No new 32-bit DLL load in the main server. |
|
||||||
|
| 18 | Admin UI code editor = **Monaco** via the Admin project's asset pipeline | Industry default for C# editing in a browser; ~3 MB bundle acceptable given Admin is operator-facing only, not public. Revisitable if bundle size becomes a deployment constraint. |
|
||||||
|
| 19 | Cascade evaluation order = **serial** for v1; parallel promoted to a Phase 7 follow-up | Deterministic, easier to reason about, simplifies cycle + ordering bugs in the rollout. Parallel becomes a tuning knob when real 1000+ virtual-tag deployments measure contention. |
|
||||||
|
| 20 | Shelving UX = **OPC UA method calls only** (`OneShotShelve` / `TimedShelve` / `Unshelve` on the `AlarmConditionType` node); **no Admin UI shelve controls** | Plant HMIs + OPC UA clients already speak these methods by spec; reinventing the UI adds surface without operator value. Admin still renders current shelve state + audit trail read-only on the alarm detail page. |
|
||||||
|
| 21 | Dead-lettered historian events retained for **30 days** in the SQLite queue; Admin `/alarms/historian` exposes a "Retry dead-lettered" button | Long enough for a Historian outage or licensing glitch to be resolved + operator to investigate; short enough that the SQLite file doesn't grow unbounded. Configurable via `AlarmHistorian:DeadLetterRetentionDays` for deployments with stricter compliance windows. |
|
||||||
|
| 22 | Test harness synthetic inputs = **declared inputs only** (from the AST walker's extracted dependency set) | Enforces the dependency declaration — if a path can't be supplied to the harness, the AST walker didn't see it and the script can't reference it at runtime. Catches dependency-inference drift at test time, not publish time. |
|
||||||
|
|
||||||
|
## Scope — What Changes
|
||||||
|
|
||||||
|
| Concern | Change |
|
||||||
|
|---------|--------|
|
||||||
|
| **New project `OtOpcUa.Core.Scripting`** (.NET 10) | Roslyn-based script engine. Compiles user C# scripts with a sandboxed `ScriptOptions` allow-list (numeric / string / datetime / `ScriptContext` API only — no reflection / File / Process / HttpClient). `DependencyExtractor` uses `CSharpSyntaxWalker` to enumerate `ctx.GetTag("...")` literal-string calls; rejects non-literal paths at publish time. Per-script compile cache keyed by source hash. Per-evaluation timeout. Exception in script → tag goes `BadInternalError`; engine unaffected for other tags. `ctx.Logger` is a Serilog `ILogger` bound to the `scripts-*.log` rolling sink with structured property `ScriptName`. |
|
||||||
|
| **New project `OtOpcUa.Core.VirtualTags`** (.NET 10) | `VirtualTagEngine` consumes the `DependencyExtractor` output, builds a topological dependency graph spanning driver tags + other virtual tags (cycle detection at publish time), schedules re-evaluation on change + on timer, propagates results through an `IVirtualTagSource` that implements `IReadable` + `ISubscribable` so `DriverNodeManager` routes reads / subscriptions uniformly. Per-tag `Historize` flag routes to the same history-write path driver tags use. |
|
||||||
|
| **New project `OtOpcUa.Core.ScriptedAlarms`** (.NET 10) | `ScriptedAlarmEngine` materializes each configured alarm as an OPC UA `AlarmConditionType` (or `LimitAlarmType` / `OffNormalAlarmType`). On startup, re-evaluates every predicate against current tag values to rebuild `ActiveState` — no persistence needed for the active flag. Persistent state: `EnabledState`, `AckedState`, `ConfirmedState`, `ShelvingState`, branch stack, ack audit (user/time/comment). Template message substitution resolves `{TagPath}` tokens at event emission. Ack / Confirm / Shelve method nodes bound to the engine; transitions audit-logged via the existing `IAuditLogger` (Phase 6.2). Registers as an additional `IAlarmSource` — no change to the existing fan-out. |
|
||||||
|
| **New project `OtOpcUa.Core.AlarmHistorian`** (.NET 10) | `IAlarmHistorianSink` abstraction + `SqliteStoreAndForwardSink` default implementation. Every qualifying `IAlarmSource` emission (per-alarm `HistorizeToAveva` toggle) persists to a local SQLite queue (`%ProgramData%\OtOpcUa\alarm-historian-queue.db`). Background drain worker reads unsent rows + forwards over IPC to Galaxy.Host. Failed writes keep the row pending with exponential backoff. Queue capacity bounded (default 1M events, oldest-dropped with a structured warning log). |
|
||||||
|
| **`Driver.Galaxy.Shared`** — new IPC contracts | `HistorianAlarmEventRequest` (activation / ack / confirm / clear / shelve / disable / comment payloads matching the Aveva Historian alarm schema) + `HistorianAlarmEventResponse` (ack / retry-please / permanent-fail). `HistorianConnectivityStatusNotification` so the main server can surface "Historian disconnected" on the Admin `/hosts` page. |
|
||||||
|
| **`Driver.Galaxy.Host`** — new frame handler for alarm writes | Reuses the already-loaded `aahClientManaged.dll` + `aahClientCommon.dll`. Maps the IPC request DTOs to the historian SDK's alarm-event API (exact method TBD during Stream D.2 — needs a live-historian smoke to confirm the right SDK entry point). Errors map to structured response codes so the main server's backoff logic can distinguish "transient" from "permanent". |
|
||||||
|
| **Config DB schema** — new tables | `VirtualTag (Id, EquipmentPath, Name, DataType, IntervalMs?, ChangeTriggerEnabled, Historize, ScriptId)`; `Script (Id, SourceCode, CompiledHash, Language='CSharp')`; `ScriptedAlarm (Id, EquipmentPath, Name, AlarmType, Severity, MessageTemplate, HistorizeToAveva, PredicateScriptId)`; `ScriptedAlarmState (AlarmId, EnabledState, AckedState, ConfirmedState, ShelvingState, ShelvingExpiresUtc?, LastAckUser, LastAckComment, LastAckUtc, BranchStack_JSON)`. Every write goes through `sp_PublishGeneration` + `IAuditLogger`. |
|
||||||
|
| **Address-space build** — Phase 6 `EquipmentNodeWalker` extension | Emits virtual-tag nodes alongside driver-sourced nodes under the same Equipment folder. `NodeScopeResolver` gains a `Virtual` source kind alongside `Driver`. `DriverNodeManager` dispatch routes reads / writes / subscriptions to the `VirtualTagEngine` when the source is virtual. |
|
||||||
|
| **Admin UI** — new tabs | `/virtual-tags` and `/scripted-alarms` tabs under the existing draft/publish flow. Monaco-based C# code editor (syntax highlighting, IntelliSense against a hand-written type stub for `ScriptContext`). Dependency preview panel shows the inferred input list from the AST walker. Test-harness lets operator supply synthetic `DataValue` inputs + see script output + logger emissions without publishing. Per-alarm controls: `AlarmType`, `Severity`, `MessageTemplate`, `HistorizeToAveva`. New `/alarms/historian` diagnostics view: queue depth, drain rate, last-successful-write, per-alarm "last routed to historian" timestamp. |
|
||||||
|
| **`DriverTypeRegistry`** — no change | Scripting is not a driver — it doesn't register as a `DriverType`. The engine hangs off the same `SealedBootstrap` as drivers but through a different composition root. |
|
||||||
|
|
||||||
|
## Scope — What Does NOT Change
|
||||||
|
|
||||||
|
| Item | Reason |
|
||||||
|
|------|--------|
|
||||||
|
| Existing `IAlarmSource` implementations (Galaxy, AB CIP ALMD) | Scripted alarms register as an *additional* source; existing sources pass through unchanged. Default `HistorizeToAveva=false` for Galaxy alarms avoids duplicating records the Galaxy historian wiring already captures. |
|
||||||
|
| Driver capability surface (`IReadable` / `IWritable` / `ISubscribable` / etc.) | Virtual tags implement the same interfaces — drivers and virtual tags are interchangeable from the node manager's perspective. No new capability. |
|
||||||
|
| Config DB publication flow (`sp_PublishGeneration` + sealed cache) | Virtual tag + alarm tables plug in as additional rows. Atomic publish semantics unchanged. |
|
||||||
|
| Authorization trie (Phase 6.2) | Virtual-tag nodes inherit the Equipment scope's grants — same treatment as the Phase 6.4 Identification sub-folder. No new scope level. |
|
||||||
|
| Tier-C isolation topology | Scripting engine runs in the main .NET 10 server process. Roslyn scripts are already sandboxed via `ScriptOptions`; no need for process isolation because they have no unmanaged reach. Galaxy.Host's existing Tier-C boundary already owns the historian SDK writes. |
|
||||||
|
| Galaxy alarm ingestion path into the historian | Galaxy writes alarms directly via `aahClientManaged` today; Phase 7 Stream D gives it a *second* path (via the new sink) when a Galaxy alarm has `HistorizeToAveva=true`, but the direct path stays for the default case. |
|
||||||
|
| OPC UA wire protocol / AddressSpace schema | Clients see new nodes under existing folders + new alarm conditions. No new namespaces, no new ObjectTypes beyond what Part 9 already defines. |
|
||||||
|
|
||||||
|
## Entry Gate Checklist
|
||||||
|
|
||||||
|
- [ ] All Phase 6.x exit gates cleared (#133, #142, #151, #158)
|
||||||
|
- [ ] Equipment node walker wired into `DriverNodeManager` (task #212 — done)
|
||||||
|
- [ ] `IAuditLogger` surface live (Phase 6.2 Stream A)
|
||||||
|
- [ ] `sp_PublishGeneration` + sealed-cache flow verified on the existing driver-config tables
|
||||||
|
- [ ] Dev Aveva Historian reachable from the dev box (for Stream D.2 smoke)
|
||||||
|
- [ ] `v2` branch clean + baseline tests green
|
||||||
|
- [ ] Blazor editor component library picked (Monaco confirmed vs alternatives — see decision to log)
|
||||||
|
- [ ] Review this plan — decisions #1–#17 signed off, no open questions
|
||||||
|
|
||||||
|
## Task Breakdown
|
||||||
|
|
||||||
|
### Stream A — `Core.Scripting` (Roslyn engine + sandbox + AST inference + logger) — **2 weeks**
|
||||||
|
|
||||||
|
1. **A.1** Project scaffold + NuGet `Microsoft.CodeAnalysis.CSharp.Scripting`. `ScriptOptions` allow-list (`typeof(object).Assembly`, `typeof(Enumerable).Assembly`, the Core.Scripting assembly itself — nothing else). Hand-written `ScriptContext` base class with `GetTag(string)` / `SetVirtualTag(string, object)` / `Logger` / `Now` / `Deadband(double, double, double)` helpers.
|
||||||
|
2. **A.2** `DependencyExtractor : CSharpSyntaxWalker`. Visits every `InvocationExpressionSyntax` targeting `ctx.GetTag` / `ctx.SetVirtualTag`; accepts only a `LiteralExpressionSyntax` argument. Non-literal arguments (concat, variable, method call) → publish-time rejection with an actionable error pointing the operator at the exact span. Outputs `IReadOnlySet<string> Inputs` + `IReadOnlySet<string> Outputs`.
|
||||||
|
3. **A.3** Compile cache. `(source_hash) → compiled Script<T>`. Recompile only when source changes. Warm on `SealedBootstrap`.
|
||||||
|
4. **A.4** Per-evaluation timeout wrapper (default 250ms; configurable per tag). Timeout = tag quality `BadInternalError` + structured warning log. Keeps a single runaway script from starving the engine.
|
||||||
|
5. **A.5** Serilog sink wiring. New `scripts-*.log` rolling file enricher; `ctx.Logger` returns an `ILogger` with `ForContext("ScriptName", ...)`. Main `opcua-*.log` gets a companion entry at WARN level if a script logs ERROR, so the operator sees it in the primary log.
|
||||||
|
6. **A.6** Tests: AST extraction unit tests (30+ cases covering literal / concat / variable / null / method-returned paths); sandbox escape tests (attempt `typeof`, `Assembly.Load`, `File.OpenRead` — all must fail at compile); exception isolation (throwing script doesn't kill the engine); timeout behavior; logger structured-property binding.
|
||||||
|
|
||||||
|
### Stream B — Virtual tag engine (dependency graph + change/timer schedulers + historize) — **1.5 weeks**
|
||||||
|
|
||||||
|
1. **B.1** `VirtualTagEngine`. Ingests the set of compiled scripts + their inputs/outputs; builds a directed dependency graph (driver tag ID → virtual tag ID → virtual tag ID). Cycle detection at publish-time via Tarjan; publish rejects with a clear error message listing the cycle.
|
||||||
|
2. **B.2** `ChangeTriggerDispatcher`. Subscribes to every referenced driver tag via the existing `ISubscribable` fan-out. On a `DataValueSnapshot` delta (value / status / timestamp — any of the three), enqueues affected virtual tags for re-evaluation in topological order.
|
||||||
|
3. **B.3** `TimerTriggerDispatcher`. Per-tag `IntervalMs` scheduled via a shared timer-wheel. Independent of change triggers — a tag can have both, either, or neither.
|
||||||
|
4. **B.4** `EvaluationPipeline`. Serial evaluation per cascade (parallel promoted to a follow-up — avoids cross-tag ordering bugs on first rollout). Exception handling per A.4; propagates results via `IVirtualTagSource`.
|
||||||
|
5. **B.5** `IVirtualTagSource` implementation. Implements `IReadable` + `ISubscribable`. Reads return the most recent evaluated value; subscriptions receive `OnDataChange` events on each re-evaluation.
|
||||||
|
6. **B.6** History routing. Per-tag `Historize` flag emits the value + timestamp to the existing history-write path used by drivers.
|
||||||
|
7. **B.7** Tests: dependency graph (happy + cycle); change cascade through two levels of virtual tags; timer-only tag ignores input changes; change + timer both configured; error propagation; historize on/off.
|
||||||
|
|
||||||
|
### Stream C — Scripted alarm engine + Part 9 state machine + template messages — **2.5 weeks**
|
||||||
|
|
||||||
|
1. **C.1** Alarm config model + `ScriptedAlarmEngine` skeleton. Alarms materialize as `AlarmConditionType` (or subtype — `LimitAlarm`, `OffNormal`) nodes under their configured Equipment path. Severity loaded from config.
|
||||||
|
2. **C.2** `Part9StateMachine`. Tracks `EnabledState`, `ActiveState`, `AckedState`, `ConfirmedState`, `ShelvingState` per condition ID. Shelving has `OneShotShelving` + `TimedShelving` variants + an `UnshelveTime` timer.
|
||||||
|
3. **C.3** Predicate evaluation. On any input change (same trigger mechanism as Stream B), run the `bool` predicate. On `false → true` transition, activate (increment branch stack if prior Ack-but-not-Confirmed state exists). On `true → false`, clear (but keep condition visible if retain flag set).
|
||||||
|
4. **C.4** Startup recovery. For every configured alarm, run the predicate against current tag values to rebuild `ActiveState` *only*. Load `EnabledState` / `AckedState` / `ConfirmedState` / `ShelvingState` + audit from the `ScriptedAlarmState` table. No re-acknowledgment required for conditions that were acked before restart.
|
||||||
|
5. **C.5** Template substitution. Engine resolves `{TagPath}` tokens in `MessageTemplate` at event emission time using current tag values. Unresolvable tokens (bad path, missing tag) emit a structured error log + substitute `{?}` so the event still fires.
|
||||||
|
6. **C.6** OPC UA method binding. `Acknowledge`, `Confirm`, `AddComment`, `OneShotShelve`, `TimedShelve`, `Unshelve` methods on each condition node route to the engine + persist via audit-logged writes to `ScriptedAlarmState`.
|
||||||
|
7. **C.7** `IAlarmSource` implementation. Emits Part 9-shaped events through the existing fan-out the `AlarmTracker` composes.
|
||||||
|
8. **C.8** Tests: every transition (all 32 state combinations the state machine can produce); startup recovery (seed table with varied ack/confirm/shelve state, restart, verify correct recovery); template substitution (literal path, nested path, bad path); shelving timer expiry; OPC UA method calls via Client.CLI.
|
||||||
|
|
||||||
|
### Stream D — Historian alarm sink (SQLite store-and-forward + Galaxy.Host IPC) — **2 weeks**
|
||||||
|
|
||||||
|
1. **D.1** `Core.AlarmHistorian` project. `IAlarmHistorianSink` interface; `SqliteStoreAndForwardSink` default implementation using Microsoft.Data.Sqlite. Schema: `Queue (RowId, AlarmId, EventType, PayloadJson, EnqueuedUtc, LastAttemptUtc?, AttemptCount, DeadLettered)`. Queue capacity bounded; oldest-dropped on overflow with structured warning.
|
||||||
|
2. **D.2** **Live-historian smoke** against the dev box's Aveva Historian. Identify the exact `aahClientManaged` alarm-write API entry point (likely `IAlarmsDatabase.WriteAlarmEvent` or equivalent — verify with a throwaway Galaxy.Host test hook). Document in a short `docs/v2/historian-alarm-api.md` artifact.
|
||||||
|
3. **D.3** `Driver.Galaxy.Shared` contract additions. `HistorianAlarmEventRequest` / `HistorianAlarmEventResponse` / `HistorianConnectivityStatusNotification`. Round-trip tests in `Driver.Galaxy.Shared.Tests`.
|
||||||
|
4. **D.4** `Driver.Galaxy.Host` handler. Translates incoming `HistorianAlarmEventRequest` to the SDK call identified in D.2. Returns structured response (Ack / RetryPlease / PermanentFail). Connectivity notifications sent proactively when the SDK's session drops.
|
||||||
|
5. **D.5** Drain worker in the main server. Polls the SQLite queue; batches up to 100 events per IPC round-trip; exponential backoff on `RetryPlease` (1s → 2s → 5s → 15s → 60s cap); `PermanentFail` dead-letters the row + structured error log.
|
||||||
|
6. **D.6** Per-alarm toggle wired through: `HistorizeToAveva` column on both `ScriptedAlarm` + a new `AlarmHistorizationPolicy` projection the Galaxy / ALMD alarm sources consult (default `false` for Galaxy, `true` for scripted, operator-adjustable per-alarm).
|
||||||
|
7. **D.7** `/alarms/historian` diagnostics view in Admin. Queue depth, drain rate, last-successful-write, last-error, per-alarm last-routed timestamp.
|
||||||
|
8. **D.8** Tests: SQLite queue round-trip; drain worker with fake IPC (success / retry / perm-fail); overflow eviction; Galaxy.Host handler against a stub historian API; end-to-end with the live historian on the dev box (non-CI — operator-invoked).
|
||||||
|
|
||||||
|
### Stream E — Config DB schema + generation-sealed cache extensions — **1 week**
|
||||||
|
|
||||||
|
1. **E.1** EF migration for new tables. Foreign keys from `VirtualTag.ScriptId` / `ScriptedAlarm.PredicateScriptId` to `Script.Id`.
|
||||||
|
2. **E.2** `sp_PublishGeneration` extension. Sealed-cache snapshot includes virtual tags + scripted alarms + their scripts. Atomic publish guarantees the address-space build sees a consistent view.
|
||||||
|
3. **E.3** CRUD services. `VirtualTagService`, `ScriptedAlarmService`, `ScriptService`. Each audit-logged; Ack / Confirm / Shelve persist through `ScriptedAlarmStateService` with full audit trail (who / when / comment / previous state).
|
||||||
|
4. **E.4** Tests: migration up / down; publish atomicity (concurrent writes to different alarm rows don't leak into an in-flight publish); audit trail on every mutation.
|
||||||
|
|
||||||
|
### Stream F — Admin UI scripting tab — **2 weeks**
|
||||||
|
|
||||||
|
1. **F.1** Monaco editor Razor component. CSS-isolated; loads Monaco via NPM + the Admin project's existing asset pipeline. C# syntax highlighting (Monaco ships it). IntelliSense via a hand-written `ScriptContext.cs` type stub delivered with the editor (not the compiled Core.Scripting DLL — keeps the browser bundle small).
|
||||||
|
2. **F.2** `/virtual-tags` tab. List view (Equipment path / Name / DataType / inputs-summary / Historize / actions). Edit pane splits: Monaco editor left, dependency preview panel right (live-updates from a debounced `/api/scripting/analyze` endpoint that runs the `DependencyExtractor`). Publish button gated by Phase 6.2 `WriteConfigure` permission.
|
||||||
|
3. **F.3** `/scripted-alarms` tab. Same editor shape + extra controls: AlarmType dropdown, Severity slider, MessageTemplate textbox with live-preview showing `{path}` token resolution against latest tag values, `HistorizeToAveva` checkbox. **Alarm detail page displays current `ShelvingState` + `LastAckUser / LastAckUtc / LastAckComment` read-only** — no shelve/unshelve / ack / confirm buttons per decision #20. Operators drive state transitions via OPC UA method calls from plant HMIs or the Client.CLI.
|
||||||
|
4. **F.4** Test harness. Modal that lets the operator supply synthetic `DataValue` inputs for the dependency set + see script output + logger emissions (rendered in a virtual terminal). Enables testing without publishing.
|
||||||
|
5. **F.5** Script log viewer. SignalR stream of the `scripts-*.log` sink filtered by the script under edit (using the structured `ScriptName` property). Tail-last-200 + "load more".
|
||||||
|
6. **F.6** `/alarms/historian` diagnostics view per Stream D.7.
|
||||||
|
7. **F.7** Playwright smoke. Author a calc tag, publish, verify it appears in the equipment tree via a probe OPC UA read. Author an alarm, verify it appears in `AlarmsAndConditions`.
|
||||||
|
|
||||||
|
### Stream G — Address-space integration — **1 week**
|
||||||
|
|
||||||
|
1. **G.1** `EquipmentNodeWalker` extension. Current walker iterates driver tags per equipment; extend to also iterate virtual tags + alarms. `NodeScopeResolver` returns `NodeSource.Virtual` for virtual nodes and `NodeSource.Driver` for existing.
|
||||||
|
2. **G.2** `DriverNodeManager` dispatch. Read / Write / Subscribe operations check the resolved source and route to `VirtualTagEngine` or the driver as appropriate. Writes to virtual tags allowed only from scripts (per decision #6) — OPC UA client writes to a virtual node return `BadUserAccessDenied`.
|
||||||
|
3. **G.3** `AlarmTracker` composition. The `ScriptedAlarmEngine` registers as an additional `IAlarmSource` — no new composition code, the existing fan-out already accepts multiple sources.
|
||||||
|
4. **G.4** Tests: mixed equipment folder (driver tag + virtual tag + driver-native alarm + scripted alarm) browsable via Client.CLI; read / subscribe round-trip for the virtual tag; scripted alarm transitions visible in the alarm event stream.
|
||||||
|
|
||||||
|
### Stream H — Exit gate — **1 week**
|
||||||
|
|
||||||
|
1. **H.1** Compliance script real-checks: schema migrations applied; new tables populated from a draft→publish cycle; sealed-generation snapshot includes virtual tags + alarms; SQLite alarm queue initialized; `scripts-*.log` sink emitting; `AlarmConditionType` nodes materialize in the address space; per-alarm `HistorizeToAveva` toggle enforced end-to-end.
|
||||||
|
2. **H.2** Full-solution `dotnet test` baseline. Target: Phase 6 baseline + ~300 new tests across Streams A–G.
|
||||||
|
3. **H.3** `docs/v2/plan.md` Migration Strategy §6 update — add Phase 7.
|
||||||
|
4. **H.4** Phase-status memory update.
|
||||||
|
5. **H.5** Merge `v2/phase-7-scripting-and-alarming` → `v2`.
|
||||||
|
|
||||||
|
## Compliance Checks (run at exit gate)
|
||||||
|
|
||||||
|
- [ ] **Sandbox escape**: attempts to reference `System.IO.File`, `System.Net.Http.HttpClient`, `System.Diagnostics.Process`, or `typeof(X).Assembly.Load` fail at script compile with an actionable error.
|
||||||
|
- [ ] **Dependency inference**: `ctx.GetTag(myStringVar)` (non-literal path) is rejected at publish with a span-pointed error; `ctx.GetTag("Line1/Speed")` is accepted + appears in the inferred input set.
|
||||||
|
- [ ] **Change cascade**: tag A → virtual tag B → virtual tag C. When A changes, B recomputes, then C recomputes. Single change event triggers the full cascade in topological order within one evaluation pass.
|
||||||
|
- [ ] **Cycle rejection**: publish a config where virtual tag B depends on A and A depends on B. Publish fails pre-commit with a clear cycle message.
|
||||||
|
- [ ] **Startup recovery**: seed `ScriptedAlarmState` with one acked+confirmed alarm + one shelved alarm + one clean alarm, restart, verify operator does NOT see ack prompts for the first two, shelving remains in effect, clean alarm is clear.
|
||||||
|
- [ ] **Ack audit**: acknowledge an alarm; `IAuditLogger` captures user / timestamp / comment / prior state; row persists through restart.
|
||||||
|
- [ ] **Historian queue durability**: take Galaxy.Host offline, fire 10 alarm transitions, bring Galaxy.Host back; queue drains all 10 in order.
|
||||||
|
- [ ] **Per-alarm historian toggle**: Galaxy-native alarm with `HistorizeToAveva=false` does NOT enqueue; scripted alarm with `HistorizeToAveva=true` DOES enqueue.
|
||||||
|
- [ ] **Script timeout**: infinite-loop script times out at 250ms; tag quality `BadInternalError`; other tags unaffected.
|
||||||
|
- [ ] **Log isolation**: `ctx.Logger.Error("test")` lands in `scripts-*.log` with structured property `ScriptName=<name>`; main `opcua-*.log` gets a WARN companion entry.
|
||||||
|
- [ ] **ACL binding**: virtual tag under an Equipment scope inherits the Equipment's grants. User without the Equipment grant reads the virtual tag and gets `BadUserAccessDenied`.
|
||||||
|
|
||||||
|
## Decisions Resolved in Plan Review
|
||||||
|
|
||||||
|
Every open question from the initial draft was resolved in the 2026-04-20 plan review — see decisions #18–#22 in the decisions table above. No pending questions block Stream A.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [`docs/v2/plan.md`](../plan.md) §6 Migration Strategy — add Phase 7 as the final additive phase before v2 release readiness.
|
||||||
|
- [`docs/v2/implementation/overview.md`](overview.md) — phase gate conventions.
|
||||||
|
- [`docs/v2/implementation/phase-6-2-authorization-runtime.md`](phase-6-2-authorization-runtime.md) — `IAuditLogger` surface reused for Ack/Confirm/Shelve + script edits.
|
||||||
|
- [`docs/v2/implementation/phase-6-4-admin-ui-completion.md`](phase-6-4-admin-ui-completion.md) — draft/publish flow, diff viewer, tab-plugin pattern reused.
|
||||||
|
- [`docs/v2/implementation/phase-2-galaxy-out-of-process.md`](phase-2-galaxy-out-of-process.md) — Galaxy.Host IPC shape + shared-contract conventions reused for Stream D.
|
||||||
|
- [`docs/v2/driver-specs.md`](../driver-specs.md) §Alarm semantics — Part 9 fidelity requirements.
|
||||||
|
- [`docs/v2/driver-stability.md`](../driver-stability.md) — per-surface error handling, crash-loop breaker patterns Stream A.4 mirrors.
|
||||||
|
- [`docs/v2/config-db-schema.md`](../config-db-schema.md) — add a Phase 7 §§ for `VirtualTag`, `Script`, `ScriptedAlarm`, `ScriptedAlarmState`.
|
||||||
108
scripts/install/Install-FocasHost.ps1
Normal file
108
scripts/install/Install-FocasHost.ps1
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Registers the OtOpcUaFocasHost Windows service. Optional companion to
|
||||||
|
Install-Services.ps1 — only run this on nodes where FOCAS driver instances will run
|
||||||
|
with Tier-C process isolation enabled.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
FOCAS PR #220 / Tier-C isolation plan. Wraps OtOpcUa.Driver.FOCAS.Host.exe (net48 x86)
|
||||||
|
as a Windows service using NSSM, running under the same service account as the main
|
||||||
|
OtOpcUa service so the named-pipe ACL works. Passes the per-process shared secret via
|
||||||
|
environment variable at service-start time so it never hits disk.
|
||||||
|
|
||||||
|
.PARAMETER InstallRoot
|
||||||
|
Where the FOCAS Host binaries live (typically
|
||||||
|
C:\Program Files\OtOpcUa\Driver.FOCAS.Host).
|
||||||
|
|
||||||
|
.PARAMETER ServiceAccount
|
||||||
|
Service account SID or DOMAIN\name. Must match the main OtOpcUa server account so the
|
||||||
|
PipeAcl match succeeds.
|
||||||
|
|
||||||
|
.PARAMETER FocasSharedSecret
|
||||||
|
Per-process secret passed via env var. Generated freshly per install if not supplied.
|
||||||
|
|
||||||
|
.PARAMETER FocasBackend
|
||||||
|
Backend selector for the Host process. One of:
|
||||||
|
fwlib32 (default — real Fanuc Fwlib32.dll integration; requires licensed DLL on PATH)
|
||||||
|
fake (in-memory; smoke-test mode)
|
||||||
|
unconfigured (safe default returning structured errors; use until hardware is wired)
|
||||||
|
|
||||||
|
.PARAMETER FocasPipeName
|
||||||
|
Pipe name the Host listens on. Default: OtOpcUaFocas.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Install-FocasHost.ps1 -InstallRoot 'C:\Program Files\OtOpcUa\Driver.FOCAS.Host' `
|
||||||
|
-ServiceAccount 'OTOPCUA\svc-otopcua' -FocasBackend fwlib32
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] [string]$InstallRoot,
|
||||||
|
[Parameter(Mandatory)] [string]$ServiceAccount,
|
||||||
|
[string]$FocasSharedSecret,
|
||||||
|
[ValidateSet('fwlib32','fake','unconfigured')] [string]$FocasBackend = 'unconfigured',
|
||||||
|
[string]$FocasPipeName = 'OtOpcUaFocas',
|
||||||
|
[string]$ServiceName = 'OtOpcUaFocasHost',
|
||||||
|
[string]$NssmPath = 'C:\Program Files\nssm\nssm.exe'
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
function Resolve-Sid {
|
||||||
|
param([string]$Account)
|
||||||
|
if ($Account -match '^S-\d-\d+') { return $Account }
|
||||||
|
try {
|
||||||
|
$nt = New-Object System.Security.Principal.NTAccount($Account)
|
||||||
|
return $nt.Translate([System.Security.Principal.SecurityIdentifier]).Value
|
||||||
|
} catch {
|
||||||
|
throw "Could not resolve '$Account' to a SID. Pass an explicit SID or check the account name."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path $NssmPath)) {
|
||||||
|
throw "nssm.exe not found at '$NssmPath'. Install NSSM or pass -NssmPath."
|
||||||
|
}
|
||||||
|
|
||||||
|
$hostExe = Join-Path $InstallRoot 'OtOpcUa.Driver.FOCAS.Host.exe'
|
||||||
|
if (-not (Test-Path $hostExe)) {
|
||||||
|
throw "FOCAS Host binary not found at '$hostExe'. Publish the Driver.FOCAS.Host project first."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $FocasSharedSecret) {
|
||||||
|
$FocasSharedSecret = [System.Guid]::NewGuid().ToString('N')
|
||||||
|
Write-Host "Generated FocasSharedSecret — store it alongside the OtOpcUa service config."
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedSid = Resolve-Sid $ServiceAccount
|
||||||
|
|
||||||
|
# Idempotent install — remove + re-create if present.
|
||||||
|
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||||
|
if ($existing) {
|
||||||
|
Write-Host "Removing existing '$ServiceName' service..."
|
||||||
|
& $NssmPath stop $ServiceName confirm | Out-Null
|
||||||
|
& $NssmPath remove $ServiceName confirm | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
& $NssmPath install $ServiceName $hostExe | Out-Null
|
||||||
|
& $NssmPath set $ServiceName DisplayName 'OT-OPC-UA FOCAS Host (Tier-C isolated Fwlib32)' | Out-Null
|
||||||
|
& $NssmPath set $ServiceName Description 'Out-of-process Fwlib32.dll host for OtOpcUa FOCAS driver. Crash-isolated from the main OPC UA server.' | Out-Null
|
||||||
|
& $NssmPath set $ServiceName ObjectName $ServiceAccount | Out-Null
|
||||||
|
& $NssmPath set $ServiceName Start SERVICE_AUTO_START | Out-Null
|
||||||
|
& $NssmPath set $ServiceName AppStdout (Join-Path $env:ProgramData 'OtOpcUa\focas-host-stdout.log') | Out-Null
|
||||||
|
& $NssmPath set $ServiceName AppStderr (Join-Path $env:ProgramData 'OtOpcUa\focas-host-stderr.log') | Out-Null
|
||||||
|
& $NssmPath set $ServiceName AppRotateFiles 1 | Out-Null
|
||||||
|
& $NssmPath set $ServiceName AppRotateBytes 10485760 | Out-Null
|
||||||
|
|
||||||
|
& $NssmPath set $ServiceName AppEnvironmentExtra `
|
||||||
|
"OTOPCUA_FOCAS_PIPE=$FocasPipeName" `
|
||||||
|
"OTOPCUA_ALLOWED_SID=$allowedSid" `
|
||||||
|
"OTOPCUA_FOCAS_SECRET=$FocasSharedSecret" `
|
||||||
|
"OTOPCUA_FOCAS_BACKEND=$FocasBackend" | Out-Null
|
||||||
|
|
||||||
|
& $NssmPath set $ServiceName DependOnService OtOpcUa | Out-Null
|
||||||
|
|
||||||
|
Write-Host "Installed '$ServiceName' under '$ServiceAccount' (SID=$allowedSid)."
|
||||||
|
Write-Host "Pipe: \\.\pipe\$FocasPipeName Backend: $FocasBackend"
|
||||||
|
Write-Host "Start the service with: Start-Service $ServiceName"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "NOTE: the Fwlib32 backend requires the licensed Fwlib32.dll on PATH"
|
||||||
|
Write-Host "alongside the Host exe. See docs/v2/focas-deployment.md."
|
||||||
27
scripts/install/Uninstall-FocasHost.ps1
Normal file
27
scripts/install/Uninstall-FocasHost.ps1
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Removes the OtOpcUaFocasHost Windows service.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Companion to Install-FocasHost.ps1. Stops + unregisters the service via NSSM.
|
||||||
|
Idempotent — succeeds silently if the service doesn't exist.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\Uninstall-FocasHost.ps1
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$ServiceName = 'OtOpcUaFocasHost',
|
||||||
|
[string]$NssmPath = 'C:\Program Files\nssm\nssm.exe'
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||||
|
if (-not $svc) { Write-Host "Service '$ServiceName' not present — nothing to do."; return }
|
||||||
|
|
||||||
|
if (-not (Test-Path $NssmPath)) { throw "nssm.exe not found at '$NssmPath'." }
|
||||||
|
|
||||||
|
& $NssmPath stop $ServiceName confirm | Out-Null
|
||||||
|
& $NssmPath remove $ServiceName confirm | Out-Null
|
||||||
|
Write-Host "Removed '$ServiceName'."
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persistent per-alarm state tracked by the Part 9 state machine. Every field
|
||||||
|
/// carried here either participates in the state machine or contributes to the
|
||||||
|
/// audit trail required by Phase 7 plan decision #14 (GxP / 21 CFR Part 11).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Active"/> is re-derived from the predicate at startup per Phase 7
|
||||||
|
/// decision #14 — the engine runs every alarm's predicate against current tag
|
||||||
|
/// values at <c>Load</c>, overriding whatever Active state is in the store.
|
||||||
|
/// Every other state field persists verbatim across server restarts so
|
||||||
|
/// operators don't re-ack active alarms after an outage + shelved alarms stay
|
||||||
|
/// shelved + audit history survives.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Comments"/> is append-only; comments + ack/confirm user identities
|
||||||
|
/// are the audit surface regulators consume. The engine never rewrites past
|
||||||
|
/// entries.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record AlarmConditionState(
|
||||||
|
string AlarmId,
|
||||||
|
AlarmEnabledState Enabled,
|
||||||
|
AlarmActiveState Active,
|
||||||
|
AlarmAckedState Acked,
|
||||||
|
AlarmConfirmedState Confirmed,
|
||||||
|
ShelvingState Shelving,
|
||||||
|
DateTime LastTransitionUtc,
|
||||||
|
DateTime? LastActiveUtc,
|
||||||
|
DateTime? LastClearedUtc,
|
||||||
|
DateTime? LastAckUtc,
|
||||||
|
string? LastAckUser,
|
||||||
|
string? LastAckComment,
|
||||||
|
DateTime? LastConfirmUtc,
|
||||||
|
string? LastConfirmUser,
|
||||||
|
string? LastConfirmComment,
|
||||||
|
IReadOnlyList<AlarmComment> Comments)
|
||||||
|
{
|
||||||
|
/// <summary>Initial-load state for a newly registered alarm — everything in the "no-event" position.</summary>
|
||||||
|
public static AlarmConditionState Fresh(string alarmId, DateTime nowUtc) => new(
|
||||||
|
AlarmId: alarmId,
|
||||||
|
Enabled: AlarmEnabledState.Enabled,
|
||||||
|
Active: AlarmActiveState.Inactive,
|
||||||
|
Acked: AlarmAckedState.Acknowledged,
|
||||||
|
Confirmed: AlarmConfirmedState.Confirmed,
|
||||||
|
Shelving: ShelvingState.Unshelved,
|
||||||
|
LastTransitionUtc: nowUtc,
|
||||||
|
LastActiveUtc: null,
|
||||||
|
LastClearedUtc: null,
|
||||||
|
LastAckUtc: null,
|
||||||
|
LastAckUser: null,
|
||||||
|
LastAckComment: null,
|
||||||
|
LastConfirmUtc: null,
|
||||||
|
LastConfirmUser: null,
|
||||||
|
LastConfirmComment: null,
|
||||||
|
Comments: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shelving state — kind plus, for <see cref="ShelvingKind.Timed"/>, the UTC
|
||||||
|
/// timestamp at which the shelving auto-expires. The engine polls the timer on its
|
||||||
|
/// evaluation cadence; callers should not rely on millisecond-precision expiry.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ShelvingState(ShelvingKind Kind, DateTime? UnshelveAtUtc)
|
||||||
|
{
|
||||||
|
public static readonly ShelvingState Unshelved = new(ShelvingKind.Unshelved, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single append-only audit record — acknowledgement / confirmation / explicit
|
||||||
|
/// comment / shelving action. Every entry carries a monotonic UTC timestamp plus the
|
||||||
|
/// user identity Phase 6.2 authenticated.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="TimestampUtc">When the action happened.</param>
|
||||||
|
/// <param name="User">OS / LDAP identity of the actor. For engine-internal events (shelving expiry, startup recovery) this is <c>"system"</c>.</param>
|
||||||
|
/// <param name="Kind">Human-readable classification — "Acknowledge", "Confirm", "ShelveOneShot", "ShelveTimed", "Unshelve", "AddComment", "Enable", "Disable", "AutoUnshelve".</param>
|
||||||
|
/// <param name="Text">Operator-supplied comment or engine-generated message.</param>
|
||||||
|
public sealed record AlarmComment(
|
||||||
|
DateTime TimestampUtc,
|
||||||
|
string User,
|
||||||
|
string Kind,
|
||||||
|
string Text);
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Serilog;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="ScriptContext"/> subclass for alarm predicate evaluation. Reads from
|
||||||
|
/// the engine's shared tag cache (driver + virtual tags), writes are rejected —
|
||||||
|
/// predicates must be side-effect free so their output doesn't depend on evaluation
|
||||||
|
/// order or drive cascade behavior.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Per Phase 7 plan Shape A decision, alarm scripts are one-script-per-alarm
|
||||||
|
/// returning <c>bool</c>. They read any tag they want but should not write
|
||||||
|
/// anything (the owning alarm's state is tracked by the engine, not the script).
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class AlarmPredicateContext : ScriptContext
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyDictionary<string, DataValueSnapshot> _readCache;
|
||||||
|
private readonly Func<DateTime> _clock;
|
||||||
|
|
||||||
|
public AlarmPredicateContext(
|
||||||
|
IReadOnlyDictionary<string, DataValueSnapshot> readCache,
|
||||||
|
ILogger logger,
|
||||||
|
Func<DateTime>? clock = null)
|
||||||
|
{
|
||||||
|
_readCache = readCache ?? throw new ArgumentNullException(nameof(readCache));
|
||||||
|
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
_clock = clock ?? (() => DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override DataValueSnapshot GetTag(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
return new DataValueSnapshot(null, 0x80340000u, null, _clock());
|
||||||
|
return _readCache.TryGetValue(path, out var v)
|
||||||
|
? v
|
||||||
|
: new DataValueSnapshot(null, 0x80340000u, null, _clock());
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void SetVirtualTag(string path, object? value)
|
||||||
|
{
|
||||||
|
// Predicates must be pure — writing from an alarm script couples alarm state to
|
||||||
|
// virtual-tag state in a way that's near-impossible to reason about. Rejected
|
||||||
|
// at runtime with a clear message; operators see it in the scripts-*.log.
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Alarm predicate scripts cannot write to virtual tags. Move the write logic " +
|
||||||
|
"into a virtual tag whose value the alarm predicate then reads.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override DateTime Now => _clock();
|
||||||
|
|
||||||
|
public override ILogger Logger { get; }
|
||||||
|
}
|
||||||
40
src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmTypes.cs
Normal file
40
src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmTypes.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The concrete OPC UA Part 9 alarm subtype a scripted alarm materializes as. The
|
||||||
|
/// engine's internal state machine is identical regardless of kind — the
|
||||||
|
/// <c>AlarmKind</c> only affects how the alarm node appears to OPC UA clients
|
||||||
|
/// (which ObjectType it maps to) and what diagnostic fields are populated.
|
||||||
|
/// </summary>
|
||||||
|
public enum AlarmKind
|
||||||
|
{
|
||||||
|
/// <summary>Base AlarmConditionType — no numeric or discrete interpretation.</summary>
|
||||||
|
AlarmCondition,
|
||||||
|
/// <summary>LimitAlarmType — the condition reflects a numeric setpoint / threshold breach.</summary>
|
||||||
|
LimitAlarm,
|
||||||
|
/// <summary>DiscreteAlarmType — the condition reflects a specific discrete value match.</summary>
|
||||||
|
DiscreteAlarm,
|
||||||
|
/// <summary>OffNormalAlarmType — the condition reflects deviation from a configured "normal" state.</summary>
|
||||||
|
OffNormalAlarm,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>OPC UA Part 9 EnabledState — operator-controlled alarm enable/disable.</summary>
|
||||||
|
public enum AlarmEnabledState { Enabled, Disabled }
|
||||||
|
|
||||||
|
/// <summary>OPC UA Part 9 ActiveState — reflects the current predicate truth.</summary>
|
||||||
|
public enum AlarmActiveState { Inactive, Active }
|
||||||
|
|
||||||
|
/// <summary>OPC UA Part 9 AckedState — operator has acknowledged the active transition.</summary>
|
||||||
|
public enum AlarmAckedState { Unacknowledged, Acknowledged }
|
||||||
|
|
||||||
|
/// <summary>OPC UA Part 9 ConfirmedState — operator has confirmed the clear transition.</summary>
|
||||||
|
public enum AlarmConfirmedState { Unconfirmed, Confirmed }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OPC UA Part 9 shelving mode.
|
||||||
|
/// <see cref="OneShot"/> suppresses the next active transition; once cleared
|
||||||
|
/// the shelving expires and the alarm returns to normal behavior.
|
||||||
|
/// <see cref="Timed"/> suppresses until a configured expiry timestamp passes.
|
||||||
|
/// <see cref="Unshelved"/> is the default state — no suppression.
|
||||||
|
/// </summary>
|
||||||
|
public enum ShelvingKind { Unshelved, OneShot, Timed }
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persistence for <see cref="AlarmConditionState"/> across server restarts. Phase 7
|
||||||
|
/// plan decision #14: operator-supplied state (EnabledState / AckedState /
|
||||||
|
/// ConfirmedState / ShelvingState + audit trail) persists; ActiveState is
|
||||||
|
/// recomputed from the live predicate on startup so operators never re-ack.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Stream E wires this to a SQL-backed store against the <c>ScriptedAlarmState</c>
|
||||||
|
/// table with audit logging through <see cref="Core.Abstractions"/> IAuditLogger.
|
||||||
|
/// Tests + local dev use <see cref="InMemoryAlarmStateStore"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IAlarmStateStore
|
||||||
|
{
|
||||||
|
Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct);
|
||||||
|
Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct);
|
||||||
|
Task SaveAsync(AlarmConditionState state, CancellationToken ct);
|
||||||
|
Task RemoveAsync(string alarmId, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>In-memory default — used by tests + by dev deployments without a SQL backend.</summary>
|
||||||
|
public sealed class InMemoryAlarmStateStore : IAlarmStateStore
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, AlarmConditionState> _map
|
||||||
|
= new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct)
|
||||||
|
=> Task.FromResult(_map.TryGetValue(alarmId, out var v) ? v : null);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct)
|
||||||
|
=> Task.FromResult<IReadOnlyList<AlarmConditionState>>(_map.Values.ToArray());
|
||||||
|
|
||||||
|
public Task SaveAsync(AlarmConditionState state, CancellationToken ct)
|
||||||
|
{
|
||||||
|
_map[state.AlarmId] = state;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task RemoveAsync(string alarmId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
_map.TryRemove(alarmId, out _);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs
Normal file
64
src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per Phase 7 plan decision #13, alarm messages are static-with-substitution
|
||||||
|
/// templates. The engine resolves <c>{TagPath}</c> tokens at event emission time
|
||||||
|
/// against current tag values; unresolvable tokens become <c>{?}</c> so the event
|
||||||
|
/// still fires but the operator sees where the reference broke.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Token syntax: <c>{path/with/slashes}</c>. Brace-stripped the contents must
|
||||||
|
/// match a path the caller's resolver function can look up. No escaping
|
||||||
|
/// currently — if you need literal braces in the message, reach for a feature
|
||||||
|
/// request.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Pure function. Same inputs always produce the same string. Tests verify the
|
||||||
|
/// edge cases (no tokens / one token / many / nested / unresolvable / bad
|
||||||
|
/// quality / null value).
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class MessageTemplate
|
||||||
|
{
|
||||||
|
private static readonly Regex TokenRegex = new(@"\{([^{}]+)\}",
|
||||||
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve every <c>{path}</c> token in <paramref name="template"/> using
|
||||||
|
/// <paramref name="resolveTag"/>. Tokens whose returned <see cref="DataValueSnapshot"/>
|
||||||
|
/// has a non-Good <see cref="DataValueSnapshot.StatusCode"/> or a null
|
||||||
|
/// <see cref="DataValueSnapshot.Value"/> resolve to <c>{?}</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static string Resolve(string template, Func<string, DataValueSnapshot?> resolveTag)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(template)) return template ?? string.Empty;
|
||||||
|
if (resolveTag is null) throw new ArgumentNullException(nameof(resolveTag));
|
||||||
|
|
||||||
|
return TokenRegex.Replace(template, match =>
|
||||||
|
{
|
||||||
|
var path = match.Groups[1].Value.Trim();
|
||||||
|
if (path.Length == 0) return "{?}";
|
||||||
|
var snap = resolveTag(path);
|
||||||
|
if (snap is null) return "{?}";
|
||||||
|
if (snap.StatusCode != 0u) return "{?}";
|
||||||
|
return snap.Value?.ToString() ?? "{?}";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Enumerate the token paths the template references. Used at publish time to validate references exist.</summary>
|
||||||
|
public static IReadOnlyList<string> ExtractTokenPaths(string? template)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(template)) return Array.Empty<string>();
|
||||||
|
var tokens = new List<string>();
|
||||||
|
foreach (Match m in TokenRegex.Matches(template))
|
||||||
|
{
|
||||||
|
var path = m.Groups[1].Value.Trim();
|
||||||
|
if (path.Length > 0) tokens.Add(path);
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
}
|
||||||
294
src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs
Normal file
294
src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure functions for OPC UA Part 9 alarm-condition state transitions. Input = the
|
||||||
|
/// current <see cref="AlarmConditionState"/> + the event; output = the new state +
|
||||||
|
/// optional emission hint. The engine calls these; persistence happens around them.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// No instance state, no I/O, no mutation of the input record. Every transition
|
||||||
|
/// returns a fresh record. Makes the state machine trivially unit-testable —
|
||||||
|
/// tests assert on (input, event) -> (output) without standing anything else up.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Two invariants the machine enforces:
|
||||||
|
/// (1) Disabled alarms never transition ActiveState / AckedState / ConfirmedState
|
||||||
|
/// — all predicate evaluations while disabled produce a no-op result and a
|
||||||
|
/// diagnostic log line. Re-enable restores normal flow with ActiveState
|
||||||
|
/// re-derived from the next predicate evaluation.
|
||||||
|
/// (2) Shelved alarms (OneShot / Timed) don't fire active transitions to
|
||||||
|
/// subscribers, but the state record still advances so that when shelving
|
||||||
|
/// expires the ActiveState reflects current reality. OneShot expires on the
|
||||||
|
/// next clear; Timed expires at <see cref="ShelvingState.UnshelveAtUtc"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class Part9StateMachine
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Apply a predicate re-evaluation result. Handles activation, clearing,
|
||||||
|
/// branch-stack increment when a new active arrives while prior active is
|
||||||
|
/// still un-acked, and shelving suppression.
|
||||||
|
/// </summary>
|
||||||
|
public static TransitionResult ApplyPredicate(
|
||||||
|
AlarmConditionState current,
|
||||||
|
bool predicateTrue,
|
||||||
|
DateTime nowUtc)
|
||||||
|
{
|
||||||
|
if (current.Enabled == AlarmEnabledState.Disabled)
|
||||||
|
return TransitionResult.NoOp(current, "disabled — predicate result ignored");
|
||||||
|
|
||||||
|
// Expire timed shelving if the configured clock has passed.
|
||||||
|
var shelving = MaybeExpireShelving(current.Shelving, nowUtc);
|
||||||
|
var stateWithShelving = current with { Shelving = shelving };
|
||||||
|
|
||||||
|
// Shelved alarms still update state but skip event emission.
|
||||||
|
var shelved = shelving.Kind != ShelvingKind.Unshelved;
|
||||||
|
|
||||||
|
if (predicateTrue && current.Active == AlarmActiveState.Inactive)
|
||||||
|
{
|
||||||
|
// Inactive -> Active transition.
|
||||||
|
// OneShotShelving is consumed on the NEXT clear, not activation — so we
|
||||||
|
// still suppress this transition's emission.
|
||||||
|
var next = stateWithShelving with
|
||||||
|
{
|
||||||
|
Active = AlarmActiveState.Active,
|
||||||
|
Acked = AlarmAckedState.Unacknowledged,
|
||||||
|
Confirmed = AlarmConfirmedState.Unconfirmed,
|
||||||
|
LastActiveUtc = nowUtc,
|
||||||
|
LastTransitionUtc = nowUtc,
|
||||||
|
};
|
||||||
|
return new TransitionResult(next, shelved ? EmissionKind.Suppressed : EmissionKind.Activated);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!predicateTrue && current.Active == AlarmActiveState.Active)
|
||||||
|
{
|
||||||
|
// Active -> Inactive transition.
|
||||||
|
var next = stateWithShelving with
|
||||||
|
{
|
||||||
|
Active = AlarmActiveState.Inactive,
|
||||||
|
LastClearedUtc = nowUtc,
|
||||||
|
LastTransitionUtc = nowUtc,
|
||||||
|
// OneShotShelving expires on clear — resetting here so the next
|
||||||
|
// activation fires normally.
|
||||||
|
Shelving = shelving.Kind == ShelvingKind.OneShot
|
||||||
|
? ShelvingState.Unshelved
|
||||||
|
: shelving,
|
||||||
|
};
|
||||||
|
return new TransitionResult(next, shelved ? EmissionKind.Suppressed : EmissionKind.Cleared);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Predicate matches current Active — no state change beyond possible shelving
|
||||||
|
// expiry.
|
||||||
|
return new TransitionResult(stateWithShelving, EmissionKind.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Operator acknowledges the currently-active transition.</summary>
|
||||||
|
public static TransitionResult ApplyAcknowledge(
|
||||||
|
AlarmConditionState current,
|
||||||
|
string user,
|
||||||
|
string? comment,
|
||||||
|
DateTime nowUtc)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(user))
|
||||||
|
throw new ArgumentException("User identity required for audit.", nameof(user));
|
||||||
|
|
||||||
|
if (current.Acked == AlarmAckedState.Acknowledged)
|
||||||
|
return TransitionResult.NoOp(current, "already acknowledged");
|
||||||
|
|
||||||
|
var audit = AppendComment(current.Comments, nowUtc, user, "Acknowledge", comment);
|
||||||
|
var next = current with
|
||||||
|
{
|
||||||
|
Acked = AlarmAckedState.Acknowledged,
|
||||||
|
LastAckUtc = nowUtc,
|
||||||
|
LastAckUser = user,
|
||||||
|
LastAckComment = comment,
|
||||||
|
LastTransitionUtc = nowUtc,
|
||||||
|
Comments = audit,
|
||||||
|
};
|
||||||
|
return new TransitionResult(next, EmissionKind.Acknowledged);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Operator confirms the cleared transition. Part 9 requires confirm after clear for retain-flag alarms.</summary>
|
||||||
|
public static TransitionResult ApplyConfirm(
|
||||||
|
AlarmConditionState current,
|
||||||
|
string user,
|
||||||
|
string? comment,
|
||||||
|
DateTime nowUtc)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(user))
|
||||||
|
throw new ArgumentException("User identity required for audit.", nameof(user));
|
||||||
|
|
||||||
|
if (current.Confirmed == AlarmConfirmedState.Confirmed)
|
||||||
|
return TransitionResult.NoOp(current, "already confirmed");
|
||||||
|
|
||||||
|
var audit = AppendComment(current.Comments, nowUtc, user, "Confirm", comment);
|
||||||
|
var next = current with
|
||||||
|
{
|
||||||
|
Confirmed = AlarmConfirmedState.Confirmed,
|
||||||
|
LastConfirmUtc = nowUtc,
|
||||||
|
LastConfirmUser = user,
|
||||||
|
LastConfirmComment = comment,
|
||||||
|
LastTransitionUtc = nowUtc,
|
||||||
|
Comments = audit,
|
||||||
|
};
|
||||||
|
return new TransitionResult(next, EmissionKind.Confirmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TransitionResult ApplyOneShotShelve(
|
||||||
|
AlarmConditionState current, string user, DateTime nowUtc)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
|
||||||
|
if (current.Shelving.Kind == ShelvingKind.OneShot)
|
||||||
|
return TransitionResult.NoOp(current, "already one-shot shelved");
|
||||||
|
|
||||||
|
var audit = AppendComment(current.Comments, nowUtc, user, "ShelveOneShot", null);
|
||||||
|
var next = current with
|
||||||
|
{
|
||||||
|
Shelving = new ShelvingState(ShelvingKind.OneShot, null),
|
||||||
|
LastTransitionUtc = nowUtc,
|
||||||
|
Comments = audit,
|
||||||
|
};
|
||||||
|
return new TransitionResult(next, EmissionKind.Shelved);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TransitionResult ApplyTimedShelve(
|
||||||
|
AlarmConditionState current, string user, DateTime unshelveAtUtc, DateTime nowUtc)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
|
||||||
|
if (unshelveAtUtc <= nowUtc)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(unshelveAtUtc), "Unshelve time must be in the future.");
|
||||||
|
|
||||||
|
var audit = AppendComment(current.Comments, nowUtc, user, "ShelveTimed",
|
||||||
|
$"UnshelveAtUtc={unshelveAtUtc:O}");
|
||||||
|
var next = current with
|
||||||
|
{
|
||||||
|
Shelving = new ShelvingState(ShelvingKind.Timed, unshelveAtUtc),
|
||||||
|
LastTransitionUtc = nowUtc,
|
||||||
|
Comments = audit,
|
||||||
|
};
|
||||||
|
return new TransitionResult(next, EmissionKind.Shelved);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TransitionResult ApplyUnshelve(AlarmConditionState current, string user, DateTime nowUtc)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
|
||||||
|
if (current.Shelving.Kind == ShelvingKind.Unshelved)
|
||||||
|
return TransitionResult.NoOp(current, "not shelved");
|
||||||
|
|
||||||
|
var audit = AppendComment(current.Comments, nowUtc, user, "Unshelve", null);
|
||||||
|
var next = current with
|
||||||
|
{
|
||||||
|
Shelving = ShelvingState.Unshelved,
|
||||||
|
LastTransitionUtc = nowUtc,
|
||||||
|
Comments = audit,
|
||||||
|
};
|
||||||
|
return new TransitionResult(next, EmissionKind.Unshelved);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TransitionResult ApplyEnable(AlarmConditionState current, string user, DateTime nowUtc)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
|
||||||
|
if (current.Enabled == AlarmEnabledState.Enabled)
|
||||||
|
return TransitionResult.NoOp(current, "already enabled");
|
||||||
|
|
||||||
|
var audit = AppendComment(current.Comments, nowUtc, user, "Enable", null);
|
||||||
|
var next = current with
|
||||||
|
{
|
||||||
|
Enabled = AlarmEnabledState.Enabled,
|
||||||
|
LastTransitionUtc = nowUtc,
|
||||||
|
Comments = audit,
|
||||||
|
};
|
||||||
|
return new TransitionResult(next, EmissionKind.Enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TransitionResult ApplyDisable(AlarmConditionState current, string user, DateTime nowUtc)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
|
||||||
|
if (current.Enabled == AlarmEnabledState.Disabled)
|
||||||
|
return TransitionResult.NoOp(current, "already disabled");
|
||||||
|
|
||||||
|
var audit = AppendComment(current.Comments, nowUtc, user, "Disable", null);
|
||||||
|
var next = current with
|
||||||
|
{
|
||||||
|
Enabled = AlarmEnabledState.Disabled,
|
||||||
|
LastTransitionUtc = nowUtc,
|
||||||
|
Comments = audit,
|
||||||
|
};
|
||||||
|
return new TransitionResult(next, EmissionKind.Disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TransitionResult ApplyAddComment(
|
||||||
|
AlarmConditionState current, string user, string text, DateTime nowUtc)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
|
||||||
|
if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("Comment text required.", nameof(text));
|
||||||
|
|
||||||
|
var audit = AppendComment(current.Comments, nowUtc, user, "AddComment", text);
|
||||||
|
var next = current with { Comments = audit };
|
||||||
|
return new TransitionResult(next, EmissionKind.CommentAdded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-evaluate whether a currently timed-shelved alarm has expired. Returns
|
||||||
|
/// the (possibly unshelved) state + emission hint so the engine knows to
|
||||||
|
/// publish an Unshelved event at the right moment.
|
||||||
|
/// </summary>
|
||||||
|
public static TransitionResult ApplyShelvingCheck(AlarmConditionState current, DateTime nowUtc)
|
||||||
|
{
|
||||||
|
if (current.Shelving.Kind != ShelvingKind.Timed) return TransitionResult.None(current);
|
||||||
|
if (current.Shelving.UnshelveAtUtc is DateTime t && nowUtc >= t)
|
||||||
|
{
|
||||||
|
var audit = AppendComment(current.Comments, nowUtc, "system", "AutoUnshelve",
|
||||||
|
$"Timed shelving expired at {nowUtc:O}");
|
||||||
|
var next = current with
|
||||||
|
{
|
||||||
|
Shelving = ShelvingState.Unshelved,
|
||||||
|
LastTransitionUtc = nowUtc,
|
||||||
|
Comments = audit,
|
||||||
|
};
|
||||||
|
return new TransitionResult(next, EmissionKind.Unshelved);
|
||||||
|
}
|
||||||
|
return TransitionResult.None(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ShelvingState MaybeExpireShelving(ShelvingState s, DateTime nowUtc)
|
||||||
|
{
|
||||||
|
if (s.Kind != ShelvingKind.Timed) return s;
|
||||||
|
return s.UnshelveAtUtc is DateTime t && nowUtc >= t ? ShelvingState.Unshelved : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<AlarmComment> AppendComment(
|
||||||
|
IReadOnlyList<AlarmComment> existing, DateTime ts, string user, string kind, string? text)
|
||||||
|
{
|
||||||
|
var list = new List<AlarmComment>(existing.Count + 1);
|
||||||
|
list.AddRange(existing);
|
||||||
|
list.Add(new AlarmComment(ts, user, kind, text ?? string.Empty));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Result of a state-machine operation — new state + what to emit (if anything).</summary>
|
||||||
|
public sealed record TransitionResult(AlarmConditionState State, EmissionKind Emission)
|
||||||
|
{
|
||||||
|
public static TransitionResult None(AlarmConditionState state) => new(state, EmissionKind.None);
|
||||||
|
public static TransitionResult NoOp(AlarmConditionState state, string reason) => new(state, EmissionKind.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What kind of event, if any, the engine should emit after a transition.</summary>
|
||||||
|
public enum EmissionKind
|
||||||
|
{
|
||||||
|
/// <summary>State did not change meaningfully — no event to emit.</summary>
|
||||||
|
None,
|
||||||
|
/// <summary>Predicate transitioned to true while shelving was suppressing events.</summary>
|
||||||
|
Suppressed,
|
||||||
|
Activated,
|
||||||
|
Cleared,
|
||||||
|
Acknowledged,
|
||||||
|
Confirmed,
|
||||||
|
Shelved,
|
||||||
|
Unshelved,
|
||||||
|
Enabled,
|
||||||
|
Disabled,
|
||||||
|
CommentAdded,
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Operator-authored scripted-alarm configuration. Phase 7 Stream E (config DB schema)
|
||||||
|
/// materializes these from the <c>ScriptedAlarm</c> + <c>Script</c> tables on publish.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="AlarmId">
|
||||||
|
/// Stable identity for the alarm — used as the OPC UA ConditionId + the key in the
|
||||||
|
/// state store. Should be globally unique within the cluster; convention is
|
||||||
|
/// <c>{EquipmentPath}::{AlarmName}</c>.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="EquipmentPath">
|
||||||
|
/// UNS path of the Equipment node the alarm hangs under. Alarm browse lives here;
|
||||||
|
/// ACL binding inherits this equipment's scope per Phase 6.2.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="AlarmName">Human-readable alarm name — used in the browse tree + Admin UI.</param>
|
||||||
|
/// <param name="Kind">Concrete OPC UA Part 9 subtype the alarm materializes as.</param>
|
||||||
|
/// <param name="Severity">Static severity per Phase 7 plan decision #13; not currently computed by the predicate.</param>
|
||||||
|
/// <param name="MessageTemplate">
|
||||||
|
/// Message text with <c>{TagPath}</c> tokens resolved at event-emission time per
|
||||||
|
/// Phase 7 plan decision #13. Unresolvable tokens emit <c>{?}</c> + a structured
|
||||||
|
/// error so operators can spot stale references.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="PredicateScriptSource">
|
||||||
|
/// Roslyn C# script returning <c>bool</c>. <c>true</c> = alarm condition currently holds (active);
|
||||||
|
/// <c>false</c> = condition has cleared. Same sandbox rules as virtual tags per Phase 7 decision #6.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="HistorizeToAveva">
|
||||||
|
/// When true, every transition emission of this alarm flows to the Historian alarm
|
||||||
|
/// sink (Stream D). Defaults to true — plant alarm history is usually the
|
||||||
|
/// operator's primary diagnostic. Galaxy-native alarms default false since Galaxy
|
||||||
|
/// historises them directly.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Retain">
|
||||||
|
/// Part 9 retain flag — when true, the condition node remains visible after the
|
||||||
|
/// predicate clears as long as it has un-acknowledged or un-confirmed transitions.
|
||||||
|
/// Default true.
|
||||||
|
/// </param>
|
||||||
|
public sealed record ScriptedAlarmDefinition(
|
||||||
|
string AlarmId,
|
||||||
|
string EquipmentPath,
|
||||||
|
string AlarmName,
|
||||||
|
AlarmKind Kind,
|
||||||
|
AlarmSeverity Severity,
|
||||||
|
string MessageTemplate,
|
||||||
|
string PredicateScriptSource,
|
||||||
|
bool HistorizeToAveva = true,
|
||||||
|
bool Retain = true);
|
||||||
429
src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs
Normal file
429
src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Serilog;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase 7 scripted-alarm orchestrator. Compiles every configured alarm's predicate
|
||||||
|
/// against the Stream A sandbox, subscribes to the referenced upstream tags,
|
||||||
|
/// re-evaluates the predicate on every input change + on a shelving-check timer,
|
||||||
|
/// applies the resulting transition through <see cref="Part9StateMachine"/>,
|
||||||
|
/// persists state via <see cref="IAlarmStateStore"/>, and emits the resulting events
|
||||||
|
/// through <see cref="ScriptedAlarmSource"/> (which wires into the existing
|
||||||
|
/// <c>IAlarmSource</c> fan-out).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Scripted alarms are leaves in the evaluation DAG — no alarm's state drives
|
||||||
|
/// another alarm's predicate. The engine maintains only an inverse index from
|
||||||
|
/// upstream tag path → alarms referencing it; no topological sort needed
|
||||||
|
/// (unlike the virtual-tag engine).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Evaluation errors (script throws, timeout, coercion fail) surface as
|
||||||
|
/// structured errors in the dedicated scripts-*.log sink plus a WARN companion
|
||||||
|
/// in the main log. The alarm's ActiveState stays at its prior value — the
|
||||||
|
/// engine does NOT invent a clear transition just because the predicate broke.
|
||||||
|
/// Operators investigating a broken predicate shouldn't see a phantom
|
||||||
|
/// clear-event preceding the failure.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ScriptedAlarmEngine : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ITagUpstreamSource _upstream;
|
||||||
|
private readonly IAlarmStateStore _store;
|
||||||
|
private readonly ScriptLoggerFactory _loggerFactory;
|
||||||
|
private readonly ILogger _engineLogger;
|
||||||
|
private readonly Func<DateTime> _clock;
|
||||||
|
private readonly TimeSpan _scriptTimeout;
|
||||||
|
|
||||||
|
private readonly Dictionary<string, AlarmState> _alarms = new(StringComparer.Ordinal);
|
||||||
|
private readonly ConcurrentDictionary<string, DataValueSnapshot> _valueCache
|
||||||
|
= new(StringComparer.Ordinal);
|
||||||
|
private readonly Dictionary<string, HashSet<string>> _alarmsReferencing
|
||||||
|
= new(StringComparer.Ordinal); // tag path -> alarm ids
|
||||||
|
|
||||||
|
private readonly List<IDisposable> _upstreamSubscriptions = [];
|
||||||
|
private readonly SemaphoreSlim _evalGate = new(1, 1);
|
||||||
|
private Timer? _shelvingTimer;
|
||||||
|
private bool _loaded;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public ScriptedAlarmEngine(
|
||||||
|
ITagUpstreamSource upstream,
|
||||||
|
IAlarmStateStore store,
|
||||||
|
ScriptLoggerFactory loggerFactory,
|
||||||
|
ILogger engineLogger,
|
||||||
|
Func<DateTime>? clock = null,
|
||||||
|
TimeSpan? scriptTimeout = null)
|
||||||
|
{
|
||||||
|
_upstream = upstream ?? throw new ArgumentNullException(nameof(upstream));
|
||||||
|
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||||
|
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
|
||||||
|
_engineLogger = engineLogger ?? throw new ArgumentNullException(nameof(engineLogger));
|
||||||
|
_clock = clock ?? (() => DateTime.UtcNow);
|
||||||
|
_scriptTimeout = scriptTimeout ?? TimedScriptEvaluator<AlarmPredicateContext, bool>.DefaultTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Raised for every emission the Part9StateMachine produces that the engine should publish.</summary>
|
||||||
|
public event EventHandler<ScriptedAlarmEvent>? OnEvent;
|
||||||
|
|
||||||
|
public IReadOnlyCollection<string> LoadedAlarmIds => _alarms.Keys;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load a batch of alarm definitions. Compiles every predicate, aggregates any
|
||||||
|
/// compile failures into one <see cref="InvalidOperationException"/>, subscribes
|
||||||
|
/// to upstream input tags, seeds the value cache, loads persisted state from
|
||||||
|
/// the store (falling back to Fresh for first-load alarms), and recomputes
|
||||||
|
/// ActiveState per Phase 7 plan decision #14 (startup recovery).
|
||||||
|
/// </summary>
|
||||||
|
public async Task LoadAsync(IReadOnlyList<ScriptedAlarmDefinition> definitions, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_disposed) throw new ObjectDisposedException(nameof(ScriptedAlarmEngine));
|
||||||
|
if (definitions is null) throw new ArgumentNullException(nameof(definitions));
|
||||||
|
|
||||||
|
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
UnsubscribeFromUpstream();
|
||||||
|
_alarms.Clear();
|
||||||
|
_alarmsReferencing.Clear();
|
||||||
|
|
||||||
|
var compileFailures = new List<string>();
|
||||||
|
foreach (var def in definitions)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var extraction = DependencyExtractor.Extract(def.PredicateScriptSource);
|
||||||
|
if (!extraction.IsValid)
|
||||||
|
{
|
||||||
|
var joined = string.Join("; ", extraction.Rejections.Select(r => r.Message));
|
||||||
|
compileFailures.Add($"{def.AlarmId}: dependency extraction rejected — {joined}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var evaluator = ScriptEvaluator<AlarmPredicateContext, bool>.Compile(def.PredicateScriptSource);
|
||||||
|
var timed = new TimedScriptEvaluator<AlarmPredicateContext, bool>(evaluator, _scriptTimeout);
|
||||||
|
var logger = _loggerFactory.Create(def.AlarmId);
|
||||||
|
|
||||||
|
var templateTokens = MessageTemplate.ExtractTokenPaths(def.MessageTemplate);
|
||||||
|
var allInputs = new HashSet<string>(extraction.Reads, StringComparer.Ordinal);
|
||||||
|
foreach (var t in templateTokens) allInputs.Add(t);
|
||||||
|
|
||||||
|
_alarms[def.AlarmId] = new AlarmState(def, timed, extraction.Reads, templateTokens, logger,
|
||||||
|
AlarmConditionState.Fresh(def.AlarmId, _clock()));
|
||||||
|
|
||||||
|
foreach (var path in allInputs)
|
||||||
|
{
|
||||||
|
if (!_alarmsReferencing.TryGetValue(path, out var set))
|
||||||
|
_alarmsReferencing[path] = set = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
set.Add(def.AlarmId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
compileFailures.Add($"{def.AlarmId}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compileFailures.Count > 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"ScriptedAlarmEngine load failed. {compileFailures.Count} alarm(s) did not compile:\n "
|
||||||
|
+ string.Join("\n ", compileFailures));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed the value cache with current upstream values + subscribe for changes.
|
||||||
|
foreach (var path in _alarmsReferencing.Keys)
|
||||||
|
{
|
||||||
|
_valueCache[path] = _upstream.ReadTag(path);
|
||||||
|
_upstreamSubscriptions.Add(_upstream.SubscribeTag(path, OnUpstreamChange));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore persisted state, falling back to Fresh where nothing was saved,
|
||||||
|
// then re-derive ActiveState from the current predicate per decision #14.
|
||||||
|
foreach (var (alarmId, state) in _alarms)
|
||||||
|
{
|
||||||
|
var persisted = await _store.LoadAsync(alarmId, ct).ConfigureAwait(false);
|
||||||
|
var seed = persisted ?? state.Condition;
|
||||||
|
var afterPredicate = await EvaluatePredicateToStateAsync(state, seed, nowUtc: _clock(), ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
_alarms[alarmId] = state with { Condition = afterPredicate };
|
||||||
|
await _store.SaveAsync(afterPredicate, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_loaded = true;
|
||||||
|
_engineLogger.Information("ScriptedAlarmEngine loaded {Count} alarm(s)", _alarms.Count);
|
||||||
|
|
||||||
|
// Start the shelving-check timer — ticks every 5s, expires any timed shelves
|
||||||
|
// that have passed their UnshelveAtUtc.
|
||||||
|
_shelvingTimer = new Timer(_ => RunShelvingCheck(),
|
||||||
|
null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_evalGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Current persisted state for <paramref name="alarmId"/>. Returns null for
|
||||||
|
/// unknown alarm. Mainly used for diagnostics + the Admin UI status page.
|
||||||
|
/// </summary>
|
||||||
|
public AlarmConditionState? GetState(string alarmId)
|
||||||
|
=> _alarms.TryGetValue(alarmId, out var s) ? s.Condition : null;
|
||||||
|
|
||||||
|
public IReadOnlyCollection<AlarmConditionState> GetAllStates()
|
||||||
|
=> _alarms.Values.Select(a => a.Condition).ToArray();
|
||||||
|
|
||||||
|
public Task AcknowledgeAsync(string alarmId, string user, string? comment, CancellationToken ct)
|
||||||
|
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAcknowledge(cur, user, comment, _clock()));
|
||||||
|
|
||||||
|
public Task ConfirmAsync(string alarmId, string user, string? comment, CancellationToken ct)
|
||||||
|
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyConfirm(cur, user, comment, _clock()));
|
||||||
|
|
||||||
|
public Task OneShotShelveAsync(string alarmId, string user, CancellationToken ct)
|
||||||
|
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyOneShotShelve(cur, user, _clock()));
|
||||||
|
|
||||||
|
public Task TimedShelveAsync(string alarmId, string user, DateTime unshelveAtUtc, CancellationToken ct)
|
||||||
|
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyTimedShelve(cur, user, unshelveAtUtc, _clock()));
|
||||||
|
|
||||||
|
public Task UnshelveAsync(string alarmId, string user, CancellationToken ct)
|
||||||
|
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyUnshelve(cur, user, _clock()));
|
||||||
|
|
||||||
|
public Task EnableAsync(string alarmId, string user, CancellationToken ct)
|
||||||
|
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyEnable(cur, user, _clock()));
|
||||||
|
|
||||||
|
public Task DisableAsync(string alarmId, string user, CancellationToken ct)
|
||||||
|
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyDisable(cur, user, _clock()));
|
||||||
|
|
||||||
|
public Task AddCommentAsync(string alarmId, string user, string text, CancellationToken ct)
|
||||||
|
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAddComment(cur, user, text, _clock()));
|
||||||
|
|
||||||
|
private async Task ApplyAsync(string alarmId, CancellationToken ct, Func<AlarmConditionState, TransitionResult> op)
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
if (!_alarms.TryGetValue(alarmId, out var state))
|
||||||
|
throw new ArgumentException($"Unknown alarm {alarmId}", nameof(alarmId));
|
||||||
|
|
||||||
|
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = op(state.Condition);
|
||||||
|
_alarms[alarmId] = state with { Condition = result.State };
|
||||||
|
await _store.SaveAsync(result.State, ct).ConfigureAwait(false);
|
||||||
|
if (result.Emission != EmissionKind.None) EmitEvent(state, result.State, result.Emission);
|
||||||
|
}
|
||||||
|
finally { _evalGate.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Upstream-change callback. Updates the value cache + enqueues predicate
|
||||||
|
/// re-evaluation for every alarm referencing the changed path. Fire-and-forget
|
||||||
|
/// so driver-side dispatch isn't blocked.
|
||||||
|
/// </summary>
|
||||||
|
internal void OnUpstreamChange(string path, DataValueSnapshot value)
|
||||||
|
{
|
||||||
|
_valueCache[path] = value;
|
||||||
|
if (_alarmsReferencing.TryGetValue(path, out var alarmIds))
|
||||||
|
{
|
||||||
|
_ = ReevaluateAsync(alarmIds.ToArray(), CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReevaluateAsync(IReadOnlyList<string> alarmIds, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var id in alarmIds)
|
||||||
|
{
|
||||||
|
if (!_alarms.TryGetValue(id, out var state)) continue;
|
||||||
|
var newState = await EvaluatePredicateToStateAsync(
|
||||||
|
state, state.Condition, _clock(), ct).ConfigureAwait(false);
|
||||||
|
if (!ReferenceEquals(newState, state.Condition))
|
||||||
|
{
|
||||||
|
_alarms[id] = state with { Condition = newState };
|
||||||
|
await _store.SaveAsync(newState, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { _evalGate.Release(); }
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_engineLogger.Error(ex, "ScriptedAlarmEngine reevaluate failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluate the predicate + apply the resulting state-machine transition.
|
||||||
|
/// Returns the new condition state. Emits the appropriate event if the
|
||||||
|
/// transition produces one.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<AlarmConditionState> EvaluatePredicateToStateAsync(
|
||||||
|
AlarmState state, AlarmConditionState seed, DateTime nowUtc, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var inputs = BuildReadCache(state.Inputs);
|
||||||
|
var context = new AlarmPredicateContext(inputs, state.Logger, _clock);
|
||||||
|
|
||||||
|
bool predicateTrue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
predicateTrue = await state.Evaluator.RunAsync(context, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (ScriptTimeoutException tex)
|
||||||
|
{
|
||||||
|
state.Logger.Warning("Alarm predicate timed out after {Timeout} — state unchanged", tex.Timeout);
|
||||||
|
return seed;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
state.Logger.Error(ex, "Alarm predicate threw — state unchanged");
|
||||||
|
return seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = Part9StateMachine.ApplyPredicate(seed, predicateTrue, nowUtc);
|
||||||
|
if (result.Emission != EmissionKind.None)
|
||||||
|
EmitEvent(state, result.State, result.Emission);
|
||||||
|
return result.State;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(IReadOnlySet<string> inputs)
|
||||||
|
{
|
||||||
|
var d = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
|
||||||
|
foreach (var p in inputs)
|
||||||
|
d[p] = _valueCache.TryGetValue(p, out var v) ? v : _upstream.ReadTag(p);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EmitEvent(AlarmState state, AlarmConditionState condition, EmissionKind kind)
|
||||||
|
{
|
||||||
|
// Suppressed kind means shelving ate the emission — we don't fire for subscribers
|
||||||
|
// but the state record still advanced so startup recovery reflects reality.
|
||||||
|
if (kind == EmissionKind.Suppressed || kind == EmissionKind.None) return;
|
||||||
|
|
||||||
|
var message = MessageTemplate.Resolve(state.Definition.MessageTemplate, TryLookup);
|
||||||
|
var evt = new ScriptedAlarmEvent(
|
||||||
|
AlarmId: state.Definition.AlarmId,
|
||||||
|
EquipmentPath: state.Definition.EquipmentPath,
|
||||||
|
AlarmName: state.Definition.AlarmName,
|
||||||
|
Kind: state.Definition.Kind,
|
||||||
|
Severity: state.Definition.Severity,
|
||||||
|
Message: message,
|
||||||
|
Condition: condition,
|
||||||
|
Emission: kind,
|
||||||
|
TimestampUtc: _clock());
|
||||||
|
try { OnEvent?.Invoke(this, evt); }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_engineLogger.Warning(ex, "ScriptedAlarmEngine OnEvent subscriber threw for {AlarmId}", state.Definition.AlarmId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DataValueSnapshot? TryLookup(string path)
|
||||||
|
=> _valueCache.TryGetValue(path, out var v) ? v : null;
|
||||||
|
|
||||||
|
private void RunShelvingCheck()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
var ids = _alarms.Keys.ToArray();
|
||||||
|
_ = ShelvingCheckAsync(ids, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ShelvingCheckAsync(IReadOnlyList<string> alarmIds, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var now = _clock();
|
||||||
|
foreach (var id in alarmIds)
|
||||||
|
{
|
||||||
|
if (!_alarms.TryGetValue(id, out var state)) continue;
|
||||||
|
var result = Part9StateMachine.ApplyShelvingCheck(state.Condition, now);
|
||||||
|
if (!ReferenceEquals(result.State, state.Condition))
|
||||||
|
{
|
||||||
|
_alarms[id] = state with { Condition = result.State };
|
||||||
|
await _store.SaveAsync(result.State, ct).ConfigureAwait(false);
|
||||||
|
if (result.Emission != EmissionKind.None)
|
||||||
|
EmitEvent(state, result.State, result.Emission);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { _evalGate.Release(); }
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_engineLogger.Warning(ex, "ScriptedAlarmEngine shelving-check failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UnsubscribeFromUpstream()
|
||||||
|
{
|
||||||
|
foreach (var s in _upstreamSubscriptions)
|
||||||
|
{
|
||||||
|
try { s.Dispose(); } catch { }
|
||||||
|
}
|
||||||
|
_upstreamSubscriptions.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureLoaded()
|
||||||
|
{
|
||||||
|
if (!_loaded) throw new InvalidOperationException(
|
||||||
|
"ScriptedAlarmEngine not loaded. Call LoadAsync first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
_shelvingTimer?.Dispose();
|
||||||
|
UnsubscribeFromUpstream();
|
||||||
|
_alarms.Clear();
|
||||||
|
_alarmsReferencing.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record AlarmState(
|
||||||
|
ScriptedAlarmDefinition Definition,
|
||||||
|
TimedScriptEvaluator<AlarmPredicateContext, bool> Evaluator,
|
||||||
|
IReadOnlySet<string> Inputs,
|
||||||
|
IReadOnlyList<string> TemplateTokens,
|
||||||
|
ILogger Logger,
|
||||||
|
AlarmConditionState Condition);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One alarm emission the engine pushed to subscribers. Carries everything
|
||||||
|
/// downstream consumers (OPC UA alarm-source adapter + historian sink) need to
|
||||||
|
/// publish the event without re-querying the engine.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ScriptedAlarmEvent(
|
||||||
|
string AlarmId,
|
||||||
|
string EquipmentPath,
|
||||||
|
string AlarmName,
|
||||||
|
AlarmKind Kind,
|
||||||
|
AlarmSeverity Severity,
|
||||||
|
string Message,
|
||||||
|
AlarmConditionState Condition,
|
||||||
|
EmissionKind Emission,
|
||||||
|
DateTime TimestampUtc);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Upstream source abstraction — intentionally identical shape to the virtual-tag
|
||||||
|
/// engine's so Stream G can compose them behind one driver bridge.
|
||||||
|
/// </summary>
|
||||||
|
public interface ITagUpstreamSource
|
||||||
|
{
|
||||||
|
DataValueSnapshot ReadTag(string path);
|
||||||
|
IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer);
|
||||||
|
}
|
||||||
122
src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs
Normal file
122
src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adapter that exposes <see cref="ScriptedAlarmEngine"/> through the driver-agnostic
|
||||||
|
/// <see cref="IAlarmSource"/> surface. The existing Phase 6.1 <c>AlarmTracker</c>
|
||||||
|
/// composition fan-out consumes this alongside Galaxy / AB CIP / FOCAS alarm
|
||||||
|
/// sources — no per-source branching in the fan-out.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Per Phase 7 plan Stream C.6, ack / confirm / shelve / unshelve are OPC UA
|
||||||
|
/// method calls per-condition. This adapter implements <see cref="AcknowledgeAsync"/>
|
||||||
|
/// from the base interface; the richer Part 9 methods (Confirm / Shelve /
|
||||||
|
/// Unshelve / AddComment) live directly on the engine, invoked from OPC UA
|
||||||
|
/// method handlers wired up in Stream G.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// SubscribeAlarmsAsync takes a list of source-node-id filters (typically an
|
||||||
|
/// Equipment path prefix). When the list is empty every alarm matches. The
|
||||||
|
/// adapter doesn't maintain per-subscription state beyond the filter set — it
|
||||||
|
/// checks each emission against every live subscription.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
|
||||||
|
{
|
||||||
|
private readonly ScriptedAlarmEngine _engine;
|
||||||
|
private readonly ConcurrentDictionary<string, Subscription> _subscriptions
|
||||||
|
= new(StringComparer.Ordinal);
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public ScriptedAlarmSource(ScriptedAlarmEngine engine)
|
||||||
|
{
|
||||||
|
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
|
||||||
|
_engine.OnEvent += OnEngineEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||||
|
|
||||||
|
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||||
|
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (sourceNodeIds is null) throw new ArgumentNullException(nameof(sourceNodeIds));
|
||||||
|
var handle = new SubscriptionHandle(Guid.NewGuid().ToString("N"));
|
||||||
|
_subscriptions[handle.DiagnosticId] = new Subscription(handle,
|
||||||
|
new HashSet<string>(sourceNodeIds, StringComparer.Ordinal));
|
||||||
|
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (handle is null) throw new ArgumentNullException(nameof(handle));
|
||||||
|
_subscriptions.TryRemove(handle.DiagnosticId, out _);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AcknowledgeAsync(
|
||||||
|
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (acknowledgements is null) throw new ArgumentNullException(nameof(acknowledgements));
|
||||||
|
foreach (var a in acknowledgements)
|
||||||
|
{
|
||||||
|
// The base interface doesn't carry a user identity — Stream G provides the
|
||||||
|
// authenticated principal at the OPC UA dispatch layer + proxies through
|
||||||
|
// the engine's richer AcknowledgeAsync. Here we default to "opcua-client"
|
||||||
|
// so callers using the raw IAlarmSource still produce an audit entry.
|
||||||
|
await _engine.AcknowledgeAsync(a.ConditionId, "opcua-client", a.Comment, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEngineEvent(object? sender, ScriptedAlarmEvent evt)
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
|
||||||
|
foreach (var sub in _subscriptions.Values)
|
||||||
|
{
|
||||||
|
if (!Matches(sub, evt)) continue;
|
||||||
|
var payload = new AlarmEventArgs(
|
||||||
|
SubscriptionHandle: sub.Handle,
|
||||||
|
SourceNodeId: evt.EquipmentPath,
|
||||||
|
ConditionId: evt.AlarmId,
|
||||||
|
AlarmType: evt.Kind.ToString(),
|
||||||
|
Message: evt.Message,
|
||||||
|
Severity: evt.Severity,
|
||||||
|
SourceTimestampUtc: evt.TimestampUtc);
|
||||||
|
try { OnAlarmEvent?.Invoke(this, payload); }
|
||||||
|
catch { /* subscriber exceptions don't crash the adapter */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Matches(Subscription sub, ScriptedAlarmEvent evt)
|
||||||
|
{
|
||||||
|
if (sub.Filter.Count == 0) return true;
|
||||||
|
// A subscription matches if any filter is a prefix of the alarm's equipment
|
||||||
|
// path — typical use is "Enterprise/Site/Area/Line" filtering a whole line.
|
||||||
|
foreach (var f in sub.Filter)
|
||||||
|
{
|
||||||
|
if (evt.EquipmentPath.Equals(f, StringComparison.Ordinal)) return true;
|
||||||
|
if (evt.EquipmentPath.StartsWith(f + "/", StringComparison.Ordinal)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
_engine.OnEvent -= OnEngineEvent;
|
||||||
|
_subscriptions.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SubscriptionHandle : IAlarmSubscriptionHandle
|
||||||
|
{
|
||||||
|
public SubscriptionHandle(string id) { DiagnosticId = id; }
|
||||||
|
public string DiagnosticId { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record Subscription(SubscriptionHandle Handle, IReadOnlySet<string> Filter);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Serilog" Version="4.2.0"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.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>
|
||||||
83
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/CompiledScriptCache.cs
Normal file
83
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/CompiledScriptCache.cs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Source-hash-keyed compile cache for user scripts. Roslyn compilation is the most
|
||||||
|
/// expensive step in the evaluator pipeline (5-20ms per script depending on size);
|
||||||
|
/// re-compiling on every value-change event would starve the virtual-tag engine.
|
||||||
|
/// The cache is generic on the <see cref="ScriptContext"/> subclass + result type so
|
||||||
|
/// different engines (virtual-tag / alarm-predicate / future alarm-action) each get
|
||||||
|
/// their own cache instance — there's no cross-type pollution.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Concurrent-safe: <see cref="ConcurrentDictionary{TKey, TValue}"/> of
|
||||||
|
/// <see cref="Lazy{T}"/> means a miss on two threads compiles exactly once.
|
||||||
|
/// <see cref="LazyThreadSafetyMode.ExecutionAndPublication"/> guarantees other
|
||||||
|
/// threads block on the in-flight compile rather than racing to duplicate work.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Cache is keyed on SHA-256 of the UTF-8 bytes of the source — collision-free in
|
||||||
|
/// practice. Whitespace changes therefore miss the cache on purpose; operators
|
||||||
|
/// see re-compile time on their first evaluation after a format-only edit which
|
||||||
|
/// is rare and benign.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// No capacity bound. Virtual-tag + alarm scripts are operator-authored and
|
||||||
|
/// bounded by config DB (typically low thousands). If that changes in v3, add an
|
||||||
|
/// LRU eviction policy — the API stays the same.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class CompiledScriptCache<TContext, TResult>
|
||||||
|
where TContext : ScriptContext
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, Lazy<ScriptEvaluator<TContext, TResult>>> _cache = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Return the compiled evaluator for <paramref name="scriptSource"/>, compiling
|
||||||
|
/// on first sight + reusing thereafter. If the source fails to compile, the
|
||||||
|
/// original Roslyn / sandbox exception propagates; the cache entry is removed so
|
||||||
|
/// the next call retries (useful during Admin UI authoring when the operator is
|
||||||
|
/// still fixing syntax).
|
||||||
|
/// </summary>
|
||||||
|
public ScriptEvaluator<TContext, TResult> GetOrCompile(string scriptSource)
|
||||||
|
{
|
||||||
|
if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource));
|
||||||
|
|
||||||
|
var key = HashSource(scriptSource);
|
||||||
|
var lazy = _cache.GetOrAdd(key, _ => new Lazy<ScriptEvaluator<TContext, TResult>>(
|
||||||
|
() => ScriptEvaluator<TContext, TResult>.Compile(scriptSource),
|
||||||
|
LazyThreadSafetyMode.ExecutionAndPublication));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return lazy.Value;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Failed compile — evict so a retry with corrected source can succeed.
|
||||||
|
_cache.TryRemove(key, out _);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Current entry count. Exposed for Admin UI diagnostics / tests.</summary>
|
||||||
|
public int Count => _cache.Count;
|
||||||
|
|
||||||
|
/// <summary>Drop every cached compile. Used on config generation publish + tests.</summary>
|
||||||
|
public void Clear() => _cache.Clear();
|
||||||
|
|
||||||
|
/// <summary>True when the exact source has been compiled at least once + is still cached.</summary>
|
||||||
|
public bool Contains(string scriptSource)
|
||||||
|
=> _cache.ContainsKey(HashSource(scriptSource));
|
||||||
|
|
||||||
|
private static string HashSource(string source)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(source);
|
||||||
|
var hash = SHA256.HashData(bytes);
|
||||||
|
return Convert.ToHexString(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs
Normal file
137
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
using Microsoft.CodeAnalysis.Text;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a script's source text + extracts every <c>ctx.GetTag("literal")</c> and
|
||||||
|
/// <c>ctx.SetVirtualTag("literal", ...)</c> call. Outputs the static dependency set
|
||||||
|
/// the virtual-tag engine uses to build its change-trigger subscription graph (Phase
|
||||||
|
/// 7 plan decision #7 — AST inference, operator doesn't maintain a separate list).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The tag-path argument MUST be a literal string expression. Variables,
|
||||||
|
/// concatenation, interpolation, and method-returned strings are rejected because
|
||||||
|
/// the extractor can't statically know what tag they'll resolve to at evaluation
|
||||||
|
/// time — the dependency graph needs to know every possible input up front.
|
||||||
|
/// Rejections carry the exact source span so the Admin UI can point at the offending
|
||||||
|
/// token.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Identifier matching is by spelling: the extractor looks for
|
||||||
|
/// <c>ctx.GetTag(...)</c> / <c>ctx.SetVirtualTag(...)</c> literally. A deliberately
|
||||||
|
/// misspelled method call (<c>ctx.GetTagz</c>) is not picked up but will also fail
|
||||||
|
/// to compile against <see cref="ScriptContext"/>, so there's no way to smuggle a
|
||||||
|
/// dependency past the extractor while still having a working script.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class DependencyExtractor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Parse <paramref name="scriptSource"/> + return the inferred read + write tag
|
||||||
|
/// paths, or a list of rejection messages if non-literal paths were used.
|
||||||
|
/// </summary>
|
||||||
|
public static DependencyExtractionResult Extract(string scriptSource)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(scriptSource))
|
||||||
|
return new DependencyExtractionResult(
|
||||||
|
Reads: new HashSet<string>(StringComparer.Ordinal),
|
||||||
|
Writes: new HashSet<string>(StringComparer.Ordinal),
|
||||||
|
Rejections: []);
|
||||||
|
|
||||||
|
var tree = CSharpSyntaxTree.ParseText(scriptSource, options:
|
||||||
|
new CSharpParseOptions(kind: SourceCodeKind.Script));
|
||||||
|
var root = tree.GetRoot();
|
||||||
|
|
||||||
|
var walker = new Walker();
|
||||||
|
walker.Visit(root);
|
||||||
|
|
||||||
|
return new DependencyExtractionResult(
|
||||||
|
Reads: walker.Reads,
|
||||||
|
Writes: walker.Writes,
|
||||||
|
Rejections: walker.Rejections);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Walker : CSharpSyntaxWalker
|
||||||
|
{
|
||||||
|
private readonly HashSet<string> _reads = new(StringComparer.Ordinal);
|
||||||
|
private readonly HashSet<string> _writes = new(StringComparer.Ordinal);
|
||||||
|
private readonly List<DependencyRejection> _rejections = [];
|
||||||
|
|
||||||
|
public IReadOnlySet<string> Reads => _reads;
|
||||||
|
public IReadOnlySet<string> Writes => _writes;
|
||||||
|
public IReadOnlyList<DependencyRejection> Rejections => _rejections;
|
||||||
|
|
||||||
|
public override void VisitInvocationExpression(InvocationExpressionSyntax node)
|
||||||
|
{
|
||||||
|
// Only interested in member-access form: ctx.GetTag(...) / ctx.SetVirtualTag(...).
|
||||||
|
// Anything else (free functions, chained calls, static calls) is ignored — but
|
||||||
|
// still visit children in case a ctx.GetTag call is nested inside.
|
||||||
|
if (node.Expression is MemberAccessExpressionSyntax member)
|
||||||
|
{
|
||||||
|
var methodName = member.Name.Identifier.ValueText;
|
||||||
|
if (methodName is nameof(ScriptContext.GetTag) or nameof(ScriptContext.SetVirtualTag))
|
||||||
|
{
|
||||||
|
HandleTagCall(node, methodName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base.VisitInvocationExpression(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleTagCall(InvocationExpressionSyntax node, string methodName)
|
||||||
|
{
|
||||||
|
var args = node.ArgumentList.Arguments;
|
||||||
|
if (args.Count == 0)
|
||||||
|
{
|
||||||
|
_rejections.Add(new DependencyRejection(
|
||||||
|
Span: node.Span,
|
||||||
|
Message: $"Call to ctx.{methodName} has no arguments. " +
|
||||||
|
"The tag path must be the first argument."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pathArg = args[0].Expression;
|
||||||
|
if (pathArg is not LiteralExpressionSyntax literal
|
||||||
|
|| !literal.Token.IsKind(SyntaxKind.StringLiteralToken))
|
||||||
|
{
|
||||||
|
_rejections.Add(new DependencyRejection(
|
||||||
|
Span: pathArg.Span,
|
||||||
|
Message: $"Tag path passed to ctx.{methodName} must be a string literal. " +
|
||||||
|
$"Dynamic paths (variables, concatenation, interpolation, method " +
|
||||||
|
$"calls) are rejected at publish so the dependency graph can be " +
|
||||||
|
$"built statically. Got: {pathArg.Kind()} ({pathArg})"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = (string?)literal.Token.Value ?? string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
{
|
||||||
|
_rejections.Add(new DependencyRejection(
|
||||||
|
Span: literal.Span,
|
||||||
|
Message: $"Tag path passed to ctx.{methodName} is empty or whitespace."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (methodName == nameof(ScriptContext.GetTag))
|
||||||
|
_reads.Add(path);
|
||||||
|
else
|
||||||
|
_writes.Add(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Output of <see cref="DependencyExtractor.Extract"/>.</summary>
|
||||||
|
public sealed record DependencyExtractionResult(
|
||||||
|
IReadOnlySet<string> Reads,
|
||||||
|
IReadOnlySet<string> Writes,
|
||||||
|
IReadOnlyList<DependencyRejection> Rejections)
|
||||||
|
{
|
||||||
|
/// <summary>True when no rejections were recorded — safe to publish.</summary>
|
||||||
|
public bool IsValid => Rejections.Count == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A single non-literal-path rejection with the exact source span for UI pointing.</summary>
|
||||||
|
public sealed record DependencyRejection(TextSpan Span, string Message);
|
||||||
152
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ForbiddenTypeAnalyzer.cs
Normal file
152
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ForbiddenTypeAnalyzer.cs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
using Microsoft.CodeAnalysis.Text;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Post-compile sandbox guard. <c>ScriptOptions</c> alone can't reliably
|
||||||
|
/// constrain the type surface a script can reach because .NET 10's type-forwarding
|
||||||
|
/// system resolves many BCL types through multiple assemblies — restricting the
|
||||||
|
/// reference list doesn't stop <c>System.Net.Http.HttpClient</c> from being found if
|
||||||
|
/// any transitive reference forwards to <c>System.Net.Http</c>. This analyzer walks
|
||||||
|
/// the script's syntax tree after compile, uses the <see cref="SemanticModel"/> to
|
||||||
|
/// resolve every type / member reference, and rejects any whose containing namespace
|
||||||
|
/// matches a deny-list pattern.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Deny-list is the authoritative Phase 7 plan decision #6 set:
|
||||||
|
/// <c>System.IO</c>, <c>System.Net</c>, <c>System.Diagnostics.Process</c>,
|
||||||
|
/// <c>System.Reflection</c>, <c>System.Threading.Thread</c>,
|
||||||
|
/// <c>System.Runtime.InteropServices</c>. <c>System.Environment</c> (for process
|
||||||
|
/// env-var read) is explicitly left allowed — it's read-only process state, doesn't
|
||||||
|
/// persist outside, and the test file pins this compromise so tightening later is
|
||||||
|
/// a deliberate plan decision.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Deny-list prefix match. <c>System.Net</c> catches <c>System.Net.Http</c>,
|
||||||
|
/// <c>System.Net.Sockets</c>, <c>System.Net.NetworkInformation</c>, etc. — every
|
||||||
|
/// subnamespace. If a script needs something under a denied prefix, Phase 7's
|
||||||
|
/// operator audience authors it through a helper the plan team adds as part of
|
||||||
|
/// the <see cref="ScriptContext"/> surface, not by unlocking the namespace.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class ForbiddenTypeAnalyzer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Namespace prefixes scripts are NOT allowed to reference. Each string is
|
||||||
|
/// matched as a prefix against the resolved symbol's namespace name (dot-
|
||||||
|
/// delimited), so <c>System.IO</c> catches <c>System.IO.File</c>,
|
||||||
|
/// <c>System.IO.Pipes</c>, and any future subnamespace without needing explicit
|
||||||
|
/// enumeration.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly IReadOnlyList<string> ForbiddenNamespacePrefixes =
|
||||||
|
[
|
||||||
|
"System.IO",
|
||||||
|
"System.Net",
|
||||||
|
"System.Diagnostics", // catches Process, ProcessStartInfo, EventLog, Trace/Debug file sinks
|
||||||
|
"System.Reflection",
|
||||||
|
"System.Threading.Thread", // raw Thread — Tasks stay allowed (different namespace)
|
||||||
|
"System.Runtime.InteropServices",
|
||||||
|
"Microsoft.Win32", // registry
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scan the <paramref name="compilation"/> for references to forbidden types.
|
||||||
|
/// Returns empty list when the script is clean; non-empty list means the script
|
||||||
|
/// must be rejected at publish with the rejections surfaced to the operator.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<ForbiddenTypeRejection> Analyze(Compilation compilation)
|
||||||
|
{
|
||||||
|
if (compilation is null) throw new ArgumentNullException(nameof(compilation));
|
||||||
|
|
||||||
|
var rejections = new List<ForbiddenTypeRejection>();
|
||||||
|
foreach (var tree in compilation.SyntaxTrees)
|
||||||
|
{
|
||||||
|
var semantic = compilation.GetSemanticModel(tree);
|
||||||
|
var root = tree.GetRoot();
|
||||||
|
foreach (var node in root.DescendantNodes())
|
||||||
|
{
|
||||||
|
switch (node)
|
||||||
|
{
|
||||||
|
case ObjectCreationExpressionSyntax obj:
|
||||||
|
CheckSymbol(semantic.GetSymbolInfo(obj.Type).Symbol, obj.Type.Span, rejections);
|
||||||
|
break;
|
||||||
|
case InvocationExpressionSyntax inv when inv.Expression is MemberAccessExpressionSyntax memberAcc:
|
||||||
|
CheckSymbol(semantic.GetSymbolInfo(memberAcc.Expression).Symbol, memberAcc.Expression.Span, rejections);
|
||||||
|
CheckSymbol(semantic.GetSymbolInfo(inv).Symbol, inv.Span, rejections);
|
||||||
|
break;
|
||||||
|
case MemberAccessExpressionSyntax mem:
|
||||||
|
// Catches static calls like System.IO.File.ReadAllText(...) — the
|
||||||
|
// MemberAccess "System.IO.File" resolves to the File type symbol
|
||||||
|
// whose containing namespace is System.IO, triggering a rejection.
|
||||||
|
CheckSymbol(semantic.GetSymbolInfo(mem.Expression).Symbol, mem.Expression.Span, rejections);
|
||||||
|
break;
|
||||||
|
case IdentifierNameSyntax id when node.Parent is not MemberAccessExpressionSyntax:
|
||||||
|
CheckSymbol(semantic.GetSymbolInfo(id).Symbol, id.Span, rejections);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rejections;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CheckSymbol(ISymbol? symbol, TextSpan span, List<ForbiddenTypeRejection> rejections)
|
||||||
|
{
|
||||||
|
if (symbol is null) return;
|
||||||
|
|
||||||
|
var typeSymbol = symbol switch
|
||||||
|
{
|
||||||
|
ITypeSymbol t => t,
|
||||||
|
IMethodSymbol m => m.ContainingType,
|
||||||
|
IPropertySymbol p => p.ContainingType,
|
||||||
|
IFieldSymbol f => f.ContainingType,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (typeSymbol is null) return;
|
||||||
|
|
||||||
|
var ns = typeSymbol.ContainingNamespace?.ToDisplayString() ?? string.Empty;
|
||||||
|
foreach (var forbidden in ForbiddenNamespacePrefixes)
|
||||||
|
{
|
||||||
|
if (ns == forbidden || ns.StartsWith(forbidden + ".", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
rejections.Add(new ForbiddenTypeRejection(
|
||||||
|
Span: span,
|
||||||
|
TypeName: typeSymbol.ToDisplayString(),
|
||||||
|
Namespace: ns,
|
||||||
|
Message: $"Type '{typeSymbol.ToDisplayString()}' is in the forbidden namespace '{ns}'. " +
|
||||||
|
$"Scripts cannot reach {forbidden}* per Phase 7 sandbox rules."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A single forbidden-type reference in a user script.</summary>
|
||||||
|
public sealed record ForbiddenTypeRejection(
|
||||||
|
TextSpan Span,
|
||||||
|
string TypeName,
|
||||||
|
string Namespace,
|
||||||
|
string Message);
|
||||||
|
|
||||||
|
/// <summary>Thrown from <see cref="ScriptEvaluator{TContext, TResult}.Compile"/> when the
|
||||||
|
/// post-compile forbidden-type analyzer finds references to denied namespaces.</summary>
|
||||||
|
public sealed class ScriptSandboxViolationException : Exception
|
||||||
|
{
|
||||||
|
public IReadOnlyList<ForbiddenTypeRejection> Rejections { get; }
|
||||||
|
|
||||||
|
public ScriptSandboxViolationException(IReadOnlyList<ForbiddenTypeRejection> rejections)
|
||||||
|
: base(BuildMessage(rejections))
|
||||||
|
{
|
||||||
|
Rejections = rejections;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildMessage(IReadOnlyList<ForbiddenTypeRejection> rejections)
|
||||||
|
{
|
||||||
|
var lines = rejections.Select(r => $" - {r.Message}");
|
||||||
|
return "Script references types outside the Phase 7 sandbox allow-list:\n"
|
||||||
|
+ string.Join("\n", lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptContext.cs
Normal file
80
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptContext.cs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
using Serilog;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The API user scripts see as the global <c>ctx</c>. Abstract — concrete subclasses
|
||||||
|
/// (e.g. <c>VirtualTagScriptContext</c>, <c>AlarmScriptContext</c>) plug in the
|
||||||
|
/// actual tag-backend + logger + virtual-tag writer for each evaluation. Phase 7 plan
|
||||||
|
/// decision #6: scripts can read any tag, write only to virtual tags, and have no
|
||||||
|
/// other .NET reach — no HttpClient, no File, no Process, no reflection.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Every member on this type MUST be serializable in the narrow sense that
|
||||||
|
/// <see cref="DependencyExtractor"/> can recognize tag-access call sites from the
|
||||||
|
/// script AST. Method names used from scripts are locked — renaming
|
||||||
|
/// <see cref="GetTag"/> or <see cref="SetVirtualTag"/> is a breaking change for every
|
||||||
|
/// authored script and the dependency extractor must update in lockstep.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// New helpers (<see cref="Now"/>, <see cref="Deadband"/>) are additive: adding a
|
||||||
|
/// method doesn't invalidate existing scripts. Do not remove or rename without a
|
||||||
|
/// plan-level decision + migration for authored scripts.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public abstract class ScriptContext
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Read a tag's current value + quality + source timestamp. Path syntax is
|
||||||
|
/// <c>Enterprise/Site/Area/Line/Equipment/TagName</c> (forward-slash delimited,
|
||||||
|
/// matching the Equipment-namespace browse tree). Returns a
|
||||||
|
/// <see cref="DataValueSnapshot"/> so scripts branch on quality without a second
|
||||||
|
/// call.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <paramref name="path"/> MUST be a string literal in the script source — dynamic
|
||||||
|
/// paths (variables, concatenation, method-returned strings) are rejected at
|
||||||
|
/// publish by <see cref="DependencyExtractor"/>. This is intentional: the static
|
||||||
|
/// dependency set is required for the change-driven scheduler to subscribe to the
|
||||||
|
/// right upstream tags at load time.
|
||||||
|
/// </remarks>
|
||||||
|
public abstract DataValueSnapshot GetTag(string path);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write a value to a virtual tag. Operator scripts cannot write to driver-sourced
|
||||||
|
/// tags — the OPC UA dispatch in <c>DriverNodeManager</c> rejects that separately
|
||||||
|
/// per ADR-002 with <c>BadUserAccessDenied</c>. This method is the only write path
|
||||||
|
/// virtual tags have.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Path rules identical to <see cref="GetTag"/> — literal only, dependency
|
||||||
|
/// extractor tracks the write targets so the engine knows what downstream
|
||||||
|
/// subscribers to notify.
|
||||||
|
/// </remarks>
|
||||||
|
public abstract void SetVirtualTag(string path, object? value);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Current UTC timestamp. Prefer this over <see cref="DateTime.UtcNow"/> in
|
||||||
|
/// scripts so the harness can supply a deterministic clock for tests.
|
||||||
|
/// </summary>
|
||||||
|
public abstract DateTime Now { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-script Serilog logger. Output lands in the dedicated <c>scripts-*.log</c>
|
||||||
|
/// sink with structured property <c>ScriptName</c> = the script's configured name.
|
||||||
|
/// Use at error level to surface problems; main <c>opcua-*.log</c> receives a
|
||||||
|
/// companion WARN entry so operators see script errors in the primary log.
|
||||||
|
/// </summary>
|
||||||
|
public abstract ILogger Logger { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deadband helper — returns <c>true</c> when <paramref name="current"/> differs
|
||||||
|
/// from <paramref name="previous"/> by more than <paramref name="tolerance"/>.
|
||||||
|
/// Useful for alarm predicates that shouldn't flicker on small noise. Pure
|
||||||
|
/// function; no side effects.
|
||||||
|
/// </summary>
|
||||||
|
public static bool Deadband(double current, double previous, double tolerance)
|
||||||
|
=> Math.Abs(current - previous) > tolerance;
|
||||||
|
}
|
||||||
75
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs
Normal file
75
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||||
|
using Microsoft.CodeAnalysis.Scripting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compiles + runs user scripts against a <see cref="ScriptContext"/> subclass. Core
|
||||||
|
/// evaluator — no caching, no timeout, no logging side-effects yet (those land in
|
||||||
|
/// Stream A.3, A.4, A.5 respectively). Stream B + C wrap this with the dependency
|
||||||
|
/// scheduler + alarm state machine.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Scripts are compiled against <see cref="ScriptGlobals{TContext}"/> so the
|
||||||
|
/// context member is named <c>ctx</c> in the script, matching the
|
||||||
|
/// <see cref="DependencyExtractor"/>'s walker and the Admin UI type stub.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Compile pipeline is a three-step gate: (1) Roslyn compile — catches syntax
|
||||||
|
/// errors + type-resolution failures, throws <see cref="CompilationErrorException"/>;
|
||||||
|
/// (2) <see cref="ForbiddenTypeAnalyzer"/> runs against the semantic model —
|
||||||
|
/// catches sandbox escapes that slipped past reference restrictions due to .NET's
|
||||||
|
/// type forwarding, throws <see cref="ScriptSandboxViolationException"/>; (3)
|
||||||
|
/// delegate creation — throws at this layer only for internal Roslyn bugs, not
|
||||||
|
/// user error.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Runtime exceptions thrown from user code propagate unwrapped. The virtual-tag
|
||||||
|
/// engine (Stream B) catches them per-tag + maps to <c>BadInternalError</c>
|
||||||
|
/// quality per Phase 7 decision #11 — this layer doesn't swallow anything so
|
||||||
|
/// tests can assert on the original exception type.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ScriptEvaluator<TContext, TResult>
|
||||||
|
where TContext : ScriptContext
|
||||||
|
{
|
||||||
|
private readonly ScriptRunner<TResult> _runner;
|
||||||
|
|
||||||
|
private ScriptEvaluator(ScriptRunner<TResult> runner)
|
||||||
|
{
|
||||||
|
_runner = runner;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ScriptEvaluator<TContext, TResult> Compile(string scriptSource)
|
||||||
|
{
|
||||||
|
if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource));
|
||||||
|
|
||||||
|
var options = ScriptSandbox.Build(typeof(TContext));
|
||||||
|
var script = CSharpScript.Create<TResult>(
|
||||||
|
code: scriptSource,
|
||||||
|
options: options,
|
||||||
|
globalsType: typeof(ScriptGlobals<TContext>));
|
||||||
|
|
||||||
|
// Step 1 — Roslyn compile. Throws CompilationErrorException on syntax / type errors.
|
||||||
|
var diagnostics = script.Compile();
|
||||||
|
|
||||||
|
// Step 2 — forbidden-type semantic analysis. Defense-in-depth against reference-list
|
||||||
|
// leaks due to type forwarding.
|
||||||
|
var rejections = ForbiddenTypeAnalyzer.Analyze(script.GetCompilation());
|
||||||
|
if (rejections.Count > 0)
|
||||||
|
throw new ScriptSandboxViolationException(rejections);
|
||||||
|
|
||||||
|
// Step 3 — materialize the callable delegate.
|
||||||
|
var runner = script.CreateDelegate();
|
||||||
|
return new ScriptEvaluator<TContext, TResult>(runner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Run against an already-constructed context.</summary>
|
||||||
|
public Task<TResult> RunAsync(TContext context, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (context is null) throw new ArgumentNullException(nameof(context));
|
||||||
|
var globals = new ScriptGlobals<TContext> { ctx = context };
|
||||||
|
return _runner(globals, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptGlobals.cs
Normal file
19
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptGlobals.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wraps a <see cref="ScriptContext"/> as a named field so user scripts see
|
||||||
|
/// <c>ctx.GetTag(...)</c> instead of the bare <c>GetTag(...)</c> that Roslyn's
|
||||||
|
/// globalsType convention would produce. Keeps the script ergonomics operators
|
||||||
|
/// author against consistent with the dependency extractor (which looks for the
|
||||||
|
/// <c>ctx.</c> prefix) and with the Admin UI hand-written type stub.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Generic on <typeparamref name="TContext"/> so alarm predicates can use a richer
|
||||||
|
/// context (e.g. with an <c>Alarm</c> property carrying the owning condition's
|
||||||
|
/// metadata) without affecting virtual-tag contexts.
|
||||||
|
/// </remarks>
|
||||||
|
public class ScriptGlobals<TContext>
|
||||||
|
where TContext : ScriptContext
|
||||||
|
{
|
||||||
|
public TContext ctx { get; set; } = default!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using Serilog;
|
||||||
|
using Serilog.Core;
|
||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serilog sink that mirrors script log events at <see cref="LogEventLevel.Error"/>
|
||||||
|
/// or higher to a companion logger (typically the main <c>opcua-*.log</c>) at
|
||||||
|
/// <see cref="LogEventLevel.Warning"/>. Lets operators see script errors in the
|
||||||
|
/// primary server log without drowning it in Debug/Info/Warning noise from scripts.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Registered alongside the dedicated <c>scripts-*.log</c> rolling file sink in
|
||||||
|
/// the root script-logger configuration — events below Error land only in the
|
||||||
|
/// scripts file; Error/Fatal events land in both the scripts file (at original
|
||||||
|
/// level) and the main log (downgraded to Warning since the main log's audience
|
||||||
|
/// is server operators, not script authors).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The forwarded message preserves the <c>ScriptName</c> property so operators
|
||||||
|
/// reading the main log can tell which script raised the error at a glance.
|
||||||
|
/// Original exception (if any) is attached so the main log's diagnostics keep
|
||||||
|
/// the full stack trace.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ScriptLogCompanionSink : ILogEventSink
|
||||||
|
{
|
||||||
|
private readonly ILogger _mainLogger;
|
||||||
|
private readonly LogEventLevel _minMirrorLevel;
|
||||||
|
|
||||||
|
public ScriptLogCompanionSink(ILogger mainLogger, LogEventLevel minMirrorLevel = LogEventLevel.Error)
|
||||||
|
{
|
||||||
|
_mainLogger = mainLogger ?? throw new ArgumentNullException(nameof(mainLogger));
|
||||||
|
_minMirrorLevel = minMirrorLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Emit(LogEvent logEvent)
|
||||||
|
{
|
||||||
|
if (logEvent is null) return;
|
||||||
|
if (logEvent.Level < _minMirrorLevel) return;
|
||||||
|
|
||||||
|
var scriptName = "unknown";
|
||||||
|
if (logEvent.Properties.TryGetValue(ScriptLoggerFactory.ScriptNameProperty, out var prop)
|
||||||
|
&& prop is ScalarValue sv && sv.Value is string s)
|
||||||
|
{
|
||||||
|
scriptName = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rendered = logEvent.RenderMessage();
|
||||||
|
if (logEvent.Exception is not null)
|
||||||
|
{
|
||||||
|
_mainLogger.Warning(logEvent.Exception,
|
||||||
|
"[Script] {ScriptName} emitted {OriginalLevel}: {ScriptMessage}",
|
||||||
|
scriptName, logEvent.Level, rendered);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_mainLogger.Warning(
|
||||||
|
"[Script] {ScriptName} emitted {OriginalLevel}: {ScriptMessage}",
|
||||||
|
scriptName, logEvent.Level, rendered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLoggerFactory.cs
Normal file
48
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLoggerFactory.cs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates per-script Serilog <see cref="ILogger"/> instances with the
|
||||||
|
/// <c>ScriptName</c> structured property pre-bound. Every log call from a user
|
||||||
|
/// script carries the owning virtual-tag or alarm name so operators can filter the
|
||||||
|
/// dedicated <c>scripts-*.log</c> sink by script in the Admin UI.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Factory-based — the engine (Stream B / C) constructs exactly one instance
|
||||||
|
/// from the root script-logger pipeline at startup, then derives a per-script
|
||||||
|
/// logger for each <see cref="ScriptContext"/> it builds. No per-evaluation
|
||||||
|
/// allocation in the hot path.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The wrapped root logger is responsible for output wiring — typically a
|
||||||
|
/// rolling file sink to <c>scripts-*.log</c> plus a
|
||||||
|
/// <see cref="ScriptLogCompanionSink"/> that forwards Error-or-higher events
|
||||||
|
/// to the main server log at Warning level so operators see script errors
|
||||||
|
/// in the primary log without drowning it in Info noise.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ScriptLoggerFactory
|
||||||
|
{
|
||||||
|
/// <summary>Structured property name the enricher binds. Stable for log filtering.</summary>
|
||||||
|
public const string ScriptNameProperty = "ScriptName";
|
||||||
|
|
||||||
|
private readonly ILogger _rootLogger;
|
||||||
|
|
||||||
|
public ScriptLoggerFactory(ILogger rootLogger)
|
||||||
|
{
|
||||||
|
_rootLogger = rootLogger ?? throw new ArgumentNullException(nameof(rootLogger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a per-script logger. Every event it emits carries
|
||||||
|
/// <c>ScriptName=<paramref name="scriptName"/></c> as a structured property.
|
||||||
|
/// </summary>
|
||||||
|
public ILogger Create(string scriptName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(scriptName))
|
||||||
|
throw new ArgumentException("Script name is required.", nameof(scriptName));
|
||||||
|
return _rootLogger.ForContext(ScriptNameProperty, scriptName);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs
Normal file
87
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||||
|
using Microsoft.CodeAnalysis.Scripting;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Factory for the <see cref="ScriptOptions"/> every user script is compiled against.
|
||||||
|
/// Implements Phase 7 plan decision #6 (read-only sandbox) by whitelisting only the
|
||||||
|
/// assemblies + namespaces the script API needs; no <c>System.IO</c>, no
|
||||||
|
/// <c>System.Net</c>, no <c>System.Diagnostics.Process</c>, no
|
||||||
|
/// <c>System.Reflection</c>. Attempts to reference those types in a script fail at
|
||||||
|
/// compile with a compiler error that points at the exact span — the operator sees
|
||||||
|
/// the rejection before publish, not at evaluation.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Roslyn's default <see cref="ScriptOptions"/> references <c>mscorlib</c> /
|
||||||
|
/// <c>System.Runtime</c> transitively which pulls in every type in the BCL — this
|
||||||
|
/// class overrides that with an explicit minimal allow-list.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Namespaces pre-imported so scripts don't have to write <c>using</c> clauses:
|
||||||
|
/// <c>System</c>, <c>System.Math</c>-style statics are reachable via
|
||||||
|
/// <see cref="Math"/>, and <c>ZB.MOM.WW.OtOpcUa.Core.Abstractions</c> so scripts
|
||||||
|
/// can name <see cref="DataValueSnapshot"/> directly.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The sandbox cannot prevent a script from allocating unbounded memory or
|
||||||
|
/// spinning in a tight loop — those are budget concerns, handled by the
|
||||||
|
/// per-evaluation timeout (Stream A.4) + the test-harness (Stream F.4) that lets
|
||||||
|
/// operators preview output before publishing.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class ScriptSandbox
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Build the <see cref="ScriptOptions"/> used for every virtual-tag / alarm
|
||||||
|
/// script. <paramref name="contextType"/> is the concrete
|
||||||
|
/// <see cref="ScriptContext"/> subclass the globals will be of — the compiler
|
||||||
|
/// uses its type to resolve <c>ctx.GetTag(...)</c> calls.
|
||||||
|
/// </summary>
|
||||||
|
public static ScriptOptions Build(Type contextType)
|
||||||
|
{
|
||||||
|
if (contextType is null) throw new ArgumentNullException(nameof(contextType));
|
||||||
|
if (!typeof(ScriptContext).IsAssignableFrom(contextType))
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Script context type must derive from {nameof(ScriptContext)}", nameof(contextType));
|
||||||
|
|
||||||
|
// Allow-listed assemblies — each explicitly chosen. Adding here is a
|
||||||
|
// plan-level decision; do not expand casually. HashSet so adding the
|
||||||
|
// contextType's assembly is idempotent when it happens to be Core.Scripting
|
||||||
|
// already.
|
||||||
|
var allowedAssemblies = new HashSet<System.Reflection.Assembly>
|
||||||
|
{
|
||||||
|
// System.Private.CoreLib — primitives (int, double, bool, string, DateTime,
|
||||||
|
// TimeSpan, Math, Convert, nullable<T>). Can't practically script without it.
|
||||||
|
typeof(object).Assembly,
|
||||||
|
// System.Linq — IEnumerable extensions (Where / Select / Sum / Average / etc.).
|
||||||
|
typeof(System.Linq.Enumerable).Assembly,
|
||||||
|
// Core.Abstractions — DataValueSnapshot + DriverDataType so scripts can name
|
||||||
|
// the types they receive from ctx.GetTag.
|
||||||
|
typeof(DataValueSnapshot).Assembly,
|
||||||
|
// Core.Scripting itself — ScriptContext base class + Deadband static.
|
||||||
|
typeof(ScriptContext).Assembly,
|
||||||
|
// Serilog.ILogger — script-side logger type.
|
||||||
|
typeof(Serilog.ILogger).Assembly,
|
||||||
|
// Concrete context type's assembly — production contexts subclass
|
||||||
|
// ScriptContext in Core.VirtualTags / Core.ScriptedAlarms; tests use their
|
||||||
|
// own subclass. The globals wrapper is generic on this type so Roslyn must
|
||||||
|
// be able to resolve it during compilation.
|
||||||
|
contextType.Assembly,
|
||||||
|
};
|
||||||
|
|
||||||
|
var allowedImports = new[]
|
||||||
|
{
|
||||||
|
"System",
|
||||||
|
"System.Linq",
|
||||||
|
"ZB.MOM.WW.OtOpcUa.Core.Abstractions",
|
||||||
|
"ZB.MOM.WW.OtOpcUa.Core.Scripting",
|
||||||
|
};
|
||||||
|
|
||||||
|
return ScriptOptions.Default
|
||||||
|
.WithReferences(allowedAssemblies)
|
||||||
|
.WithImports(allowedImports);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/TimedScriptEvaluator.cs
Normal file
102
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/TimedScriptEvaluator.cs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wraps a <see cref="ScriptEvaluator{TContext, TResult}"/> with a per-evaluation
|
||||||
|
/// wall-clock timeout. Default is 250ms per Phase 7 plan Stream A.4; configurable
|
||||||
|
/// per tag so deployments with slower backends can widen it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Implemented with <see cref="Task.WaitAsync(TimeSpan, CancellationToken)"/>
|
||||||
|
/// rather than a cancellation-token-only approach because Roslyn-compiled
|
||||||
|
/// scripts don't internally poll the cancellation token unless the user code
|
||||||
|
/// does async work. A CPU-bound infinite loop in a script won't honor a
|
||||||
|
/// cooperative cancel — <c>WaitAsync</c> returns control when the timeout fires
|
||||||
|
/// regardless of whether the inner task completes.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Known limitation:</b> when a script times out, the underlying ScriptRunner
|
||||||
|
/// task continues running on a thread-pool thread until the Roslyn runtime
|
||||||
|
/// returns. In the CPU-bound-infinite-loop case that's effectively "leaked" —
|
||||||
|
/// the thread is tied up until the runtime decides to return, which it may
|
||||||
|
/// never do. Phase 7 plan Stream A.4 accepts this as a known trade-off; tighter
|
||||||
|
/// CPU budgeting would require an out-of-process script runner, which is a v3
|
||||||
|
/// concern. In practice, the timeout + structured warning log surfaces the
|
||||||
|
/// offending script so the operator can fix it; the orphan thread is rare.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Caller-supplied <see cref="CancellationToken"/> is honored — if the caller
|
||||||
|
/// cancels before the timeout fires, the caller's cancel wins and the
|
||||||
|
/// <see cref="OperationCanceledException"/> propagates (not wrapped as
|
||||||
|
/// <see cref="ScriptTimeoutException"/>). That distinction matters: the
|
||||||
|
/// virtual-tag engine's shutdown path cancels scripts on dispose; it shouldn't
|
||||||
|
/// see those as timeouts.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class TimedScriptEvaluator<TContext, TResult>
|
||||||
|
where TContext : ScriptContext
|
||||||
|
{
|
||||||
|
/// <summary>Default timeout per Phase 7 plan Stream A.4 — 250ms.</summary>
|
||||||
|
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromMilliseconds(250);
|
||||||
|
|
||||||
|
private readonly ScriptEvaluator<TContext, TResult> _inner;
|
||||||
|
|
||||||
|
/// <summary>Wall-clock budget per evaluation. Script exceeding this throws <see cref="ScriptTimeoutException"/>.</summary>
|
||||||
|
public TimeSpan Timeout { get; }
|
||||||
|
|
||||||
|
public TimedScriptEvaluator(ScriptEvaluator<TContext, TResult> inner)
|
||||||
|
: this(inner, DefaultTimeout)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public TimedScriptEvaluator(ScriptEvaluator<TContext, TResult> inner, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||||
|
if (timeout <= TimeSpan.Zero)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout must be positive.");
|
||||||
|
Timeout = timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TResult> RunAsync(TContext context, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (context is null) throw new ArgumentNullException(nameof(context));
|
||||||
|
|
||||||
|
// Push evaluation to a thread-pool thread so a CPU-bound script (e.g. a tight
|
||||||
|
// loop with no async work) doesn't hog the caller's thread before WaitAsync
|
||||||
|
// gets to register its timeout. Without this, Roslyn's ScriptRunner executes
|
||||||
|
// synchronously on the calling thread and returns an already-completed Task,
|
||||||
|
// so WaitAsync sees a completed task and never fires the timeout.
|
||||||
|
var runTask = Task.Run(() => _inner.RunAsync(context, ct), ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await runTask.WaitAsync(Timeout, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
// WaitAsync's synthesized timeout — the inner task may still be running
|
||||||
|
// on its thread-pool thread (known leak documented in the class summary).
|
||||||
|
// Wrap so callers can distinguish from user-written timeout logic.
|
||||||
|
throw new ScriptTimeoutException(Timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when a script evaluation exceeds its configured timeout. The virtual-tag
|
||||||
|
/// engine (Stream B) catches this + maps the owning tag's quality to
|
||||||
|
/// <c>BadInternalError</c> per Phase 7 plan decision #11, logging a structured
|
||||||
|
/// warning with the offending script name so operators can locate + fix it.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ScriptTimeoutException : Exception
|
||||||
|
{
|
||||||
|
public TimeSpan Timeout { get; }
|
||||||
|
|
||||||
|
public ScriptTimeoutException(TimeSpan timeout)
|
||||||
|
: base($"Script evaluation exceeded the configured timeout of {timeout.TotalMilliseconds:F1} ms. " +
|
||||||
|
"The script was either CPU-bound or blocked on a slow operation; check ctx.Logger output " +
|
||||||
|
"around the timeout and consider widening the timeout per tag, simplifying the script, or " +
|
||||||
|
"moving heavy work out of the evaluation path.")
|
||||||
|
{
|
||||||
|
Timeout = timeout;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.Scripting</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Roslyn scripting API — compiles user C# snippets with a constrained ScriptOptions
|
||||||
|
allow-list so scripts can't reach Process/File/HttpClient/reflection. Per Phase 7
|
||||||
|
plan decisions #1 + #6. -->
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.12.0"/>
|
||||||
|
<PackageReference Include="Serilog" Version="4.2.0"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Core.Scripting.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>
|
||||||
271
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/DependencyGraph.cs
Normal file
271
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/DependencyGraph.cs
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Directed dependency graph over tag paths. Nodes are tag paths (either driver
|
||||||
|
/// tags — leaves — or virtual tags — internal nodes). Edges run from a virtual tag
|
||||||
|
/// to each tag it reads via <c>ctx.GetTag(...)</c>. Supports cycle detection at
|
||||||
|
/// publish time and topological sort for evaluation ordering.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Cycle detection uses Tarjan's strongly-connected-components algorithm,
|
||||||
|
/// iterative implementation (no recursion) so deeply-nested graphs can't blow
|
||||||
|
/// the stack. A cycle of length > 1 (or a self-loop) is a publish-time error;
|
||||||
|
/// the engine refuses to load such a config.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Topological sort uses Kahn's algorithm. The output order guarantees that when
|
||||||
|
/// tag X depends on tag Y, Y appears before X — so a change cascade starting at
|
||||||
|
/// Y can evaluate the full downstream closure in one serial pass without needing
|
||||||
|
/// a second iteration.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Missing leaf dependencies (a virtual tag reads a driver tag that doesn't
|
||||||
|
/// exist in the live config) are NOT rejected here — the graph treats any
|
||||||
|
/// unregistered path as an implicit leaf. Leaf validity is a separate concern
|
||||||
|
/// handled at engine-load time against the authoritative tag catalog.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class DependencyGraph
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, HashSet<string>> _dependsOn = new(StringComparer.Ordinal);
|
||||||
|
private readonly Dictionary<string, HashSet<string>> _dependents = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register a node and the set of tags it depends on. Idempotent — re-adding
|
||||||
|
/// the same node overwrites the prior dependency set, so re-publishing an edited
|
||||||
|
/// script works without a separate "remove" call.
|
||||||
|
/// </summary>
|
||||||
|
public void Add(string nodeId, IReadOnlySet<string> dependsOn)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(nodeId)) throw new ArgumentException("Node id required.", nameof(nodeId));
|
||||||
|
if (dependsOn is null) throw new ArgumentNullException(nameof(dependsOn));
|
||||||
|
|
||||||
|
// Remove any prior dependents pointing at the previous version of this node.
|
||||||
|
if (_dependsOn.TryGetValue(nodeId, out var previous))
|
||||||
|
{
|
||||||
|
foreach (var dep in previous)
|
||||||
|
{
|
||||||
|
if (_dependents.TryGetValue(dep, out var set))
|
||||||
|
set.Remove(nodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_dependsOn[nodeId] = new HashSet<string>(dependsOn, StringComparer.Ordinal);
|
||||||
|
foreach (var dep in dependsOn)
|
||||||
|
{
|
||||||
|
if (!_dependents.TryGetValue(dep, out var set))
|
||||||
|
_dependents[dep] = set = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
set.Add(nodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Tag paths <paramref name="nodeId"/> directly reads.</summary>
|
||||||
|
public IReadOnlySet<string> DirectDependencies(string nodeId) =>
|
||||||
|
_dependsOn.TryGetValue(nodeId, out var set) ? set : (IReadOnlySet<string>)new HashSet<string>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tags whose evaluation depends on <paramref name="nodeId"/> — i.e. when
|
||||||
|
/// <paramref name="nodeId"/> changes, these need to re-evaluate. Direct only;
|
||||||
|
/// transitive propagation falls out of the topological sort.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlySet<string> DirectDependents(string nodeId) =>
|
||||||
|
_dependents.TryGetValue(nodeId, out var set) ? set : (IReadOnlySet<string>)new HashSet<string>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full transitive dependent closure of <paramref name="nodeId"/> in topological
|
||||||
|
/// order (direct dependents first, then their dependents, and so on). Used by the
|
||||||
|
/// change-trigger dispatcher to schedule the right sequence of re-evaluations
|
||||||
|
/// when a single upstream value changes.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> TransitiveDependentsInOrder(string nodeId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(nodeId)) return [];
|
||||||
|
|
||||||
|
var result = new List<string>();
|
||||||
|
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var order = TopologicalSort();
|
||||||
|
var rank = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||||
|
for (var i = 0; i < order.Count; i++) rank[order[i]] = i;
|
||||||
|
|
||||||
|
// DFS from the changed node collecting every reachable dependent.
|
||||||
|
var stack = new Stack<string>();
|
||||||
|
stack.Push(nodeId);
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
var cur = stack.Pop();
|
||||||
|
foreach (var dep in DirectDependents(cur))
|
||||||
|
{
|
||||||
|
if (visited.Add(dep))
|
||||||
|
{
|
||||||
|
result.Add(dep);
|
||||||
|
stack.Push(dep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by topological rank so when re-evaluation runs serial, earlier entries
|
||||||
|
// are computed before later entries that might depend on them.
|
||||||
|
result.Sort((a, b) =>
|
||||||
|
{
|
||||||
|
var ra = rank.TryGetValue(a, out var va) ? va : int.MaxValue;
|
||||||
|
var rb = rank.TryGetValue(b, out var vb) ? vb : int.MaxValue;
|
||||||
|
return ra.CompareTo(rb);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Iterable of every registered node id (inputs-only tags excluded).</summary>
|
||||||
|
public IReadOnlyCollection<string> RegisteredNodes => _dependsOn.Keys;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Produce an evaluation order where every node appears after all its
|
||||||
|
/// dependencies. Throws <see cref="DependencyCycleException"/> if any cycle
|
||||||
|
/// exists. Implemented via Kahn's algorithm.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> TopologicalSort()
|
||||||
|
{
|
||||||
|
// Kahn's framing: edge u -> v means "u must come before v". For dependencies,
|
||||||
|
// if X depends on Y, Y must come before X, so the edge runs Y -> X and X has
|
||||||
|
// an incoming edge from Y. inDegree[X] = count of X's registered (virtual) deps
|
||||||
|
// — leaf driver-tag deps don't contribute to ordering since they're never emitted.
|
||||||
|
var inDegree = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||||
|
foreach (var node in _dependsOn.Keys) inDegree[node] = 0;
|
||||||
|
foreach (var kv in _dependsOn)
|
||||||
|
{
|
||||||
|
var nodeId = kv.Key;
|
||||||
|
foreach (var dep in kv.Value)
|
||||||
|
{
|
||||||
|
if (_dependsOn.ContainsKey(dep))
|
||||||
|
inDegree[nodeId]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ready = new Queue<string>(inDegree.Where(kv => kv.Value == 0).Select(kv => kv.Key));
|
||||||
|
var result = new List<string>();
|
||||||
|
while (ready.Count > 0)
|
||||||
|
{
|
||||||
|
var n = ready.Dequeue();
|
||||||
|
result.Add(n);
|
||||||
|
// In our edge direction (node -> deps), removing n means decrementing in-degree
|
||||||
|
// of every node that DEPENDS on n.
|
||||||
|
foreach (var dependent in DirectDependents(n))
|
||||||
|
{
|
||||||
|
if (inDegree.TryGetValue(dependent, out var d))
|
||||||
|
{
|
||||||
|
inDegree[dependent] = d - 1;
|
||||||
|
if (inDegree[dependent] == 0) ready.Enqueue(dependent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.Count != inDegree.Count)
|
||||||
|
{
|
||||||
|
var cycles = DetectCycles();
|
||||||
|
throw new DependencyCycleException(cycles);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns every strongly-connected component of size > 1 + every self-loop.
|
||||||
|
/// Empty list means the graph is a DAG. Useful for surfacing every cycle in one
|
||||||
|
/// rejection pass so operators see all of them, not just one at a time.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<IReadOnlyList<string>> DetectCycles()
|
||||||
|
{
|
||||||
|
// Iterative Tarjan's SCC. Avoids recursion so deep graphs don't StackOverflow.
|
||||||
|
var index = 0;
|
||||||
|
var indexOf = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||||
|
var lowlinkOf = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||||
|
var onStack = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var sccStack = new Stack<string>();
|
||||||
|
var cycles = new List<IReadOnlyList<string>>();
|
||||||
|
|
||||||
|
foreach (var root in _dependsOn.Keys)
|
||||||
|
{
|
||||||
|
if (indexOf.ContainsKey(root)) continue;
|
||||||
|
|
||||||
|
var work = new Stack<(string node, IEnumerator<string> iter)>();
|
||||||
|
indexOf[root] = index;
|
||||||
|
lowlinkOf[root] = index;
|
||||||
|
index++;
|
||||||
|
onStack.Add(root);
|
||||||
|
sccStack.Push(root);
|
||||||
|
work.Push((root, _dependsOn[root].GetEnumerator()));
|
||||||
|
|
||||||
|
while (work.Count > 0)
|
||||||
|
{
|
||||||
|
var (v, iter) = work.Peek();
|
||||||
|
if (iter.MoveNext())
|
||||||
|
{
|
||||||
|
var w = iter.Current;
|
||||||
|
if (!_dependsOn.ContainsKey(w))
|
||||||
|
continue; // leaf — not part of any cycle with us
|
||||||
|
if (!indexOf.ContainsKey(w))
|
||||||
|
{
|
||||||
|
indexOf[w] = index;
|
||||||
|
lowlinkOf[w] = index;
|
||||||
|
index++;
|
||||||
|
onStack.Add(w);
|
||||||
|
sccStack.Push(w);
|
||||||
|
work.Push((w, _dependsOn[w].GetEnumerator()));
|
||||||
|
}
|
||||||
|
else if (onStack.Contains(w))
|
||||||
|
{
|
||||||
|
lowlinkOf[v] = Math.Min(lowlinkOf[v], indexOf[w]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// v fully explored — unwind
|
||||||
|
work.Pop();
|
||||||
|
if (lowlinkOf[v] == indexOf[v])
|
||||||
|
{
|
||||||
|
var component = new List<string>();
|
||||||
|
string w;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
w = sccStack.Pop();
|
||||||
|
onStack.Remove(w);
|
||||||
|
component.Add(w);
|
||||||
|
} while (w != v);
|
||||||
|
|
||||||
|
if (component.Count > 1 || _dependsOn[v].Contains(v))
|
||||||
|
cycles.Add(component);
|
||||||
|
}
|
||||||
|
else if (work.Count > 0)
|
||||||
|
{
|
||||||
|
var parent = work.Peek().node;
|
||||||
|
lowlinkOf[parent] = Math.Min(lowlinkOf[parent], lowlinkOf[v]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cycles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
_dependsOn.Clear();
|
||||||
|
_dependents.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Thrown when <see cref="DependencyGraph.TopologicalSort"/> finds one or more cycles.</summary>
|
||||||
|
public sealed class DependencyCycleException : Exception
|
||||||
|
{
|
||||||
|
public IReadOnlyList<IReadOnlyList<string>> Cycles { get; }
|
||||||
|
|
||||||
|
public DependencyCycleException(IReadOnlyList<IReadOnlyList<string>> cycles)
|
||||||
|
: base(BuildMessage(cycles))
|
||||||
|
{
|
||||||
|
Cycles = cycles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildMessage(IReadOnlyList<IReadOnlyList<string>> cycles)
|
||||||
|
{
|
||||||
|
var lines = cycles.Select(c => " - " + string.Join(" -> ", c) + " -> " + c[0]);
|
||||||
|
return "Virtual-tag dependency graph contains cycle(s):\n" + string.Join("\n", lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/IHistoryWriter.cs
Normal file
25
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/IHistoryWriter.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sink for virtual-tag evaluation results that the operator marked
|
||||||
|
/// <c>Historize = true</c>. Stream G wires this to the existing history-write path
|
||||||
|
/// drivers use; tests inject a fake recorder.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Emission is fire-and-forget from the evaluation pipeline — a slow historian must
|
||||||
|
/// not block script evaluations. Implementations queue internally and drain on their
|
||||||
|
/// own cadence.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IHistoryWriter
|
||||||
|
{
|
||||||
|
void Record(string path, DataValueSnapshot value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>No-op default used when no historian is configured.</summary>
|
||||||
|
public sealed class NullHistoryWriter : IHistoryWriter
|
||||||
|
{
|
||||||
|
public static readonly NullHistoryWriter Instance = new();
|
||||||
|
public void Record(string path, DataValueSnapshot value) { }
|
||||||
|
}
|
||||||
40
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ITagUpstreamSource.cs
Normal file
40
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ITagUpstreamSource.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the virtual-tag engine pulls driver-tag values from. Implementations
|
||||||
|
/// shipped in Stream G bridge this to <see cref="IReadable"/> + <see cref="ISubscribable"/>
|
||||||
|
/// on the live driver instances; tests use an in-memory fake.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The read path is synchronous because user scripts call
|
||||||
|
/// <c>ctx.GetTag(path)</c> inline — blocking on a driver wire call per-script
|
||||||
|
/// evaluation would kill throughput. Implementations are expected to serve
|
||||||
|
/// from a last-known-value cache populated by the subscription callbacks.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The subscription path feeds the engine's <c>ChangeTriggerDispatcher</c> so
|
||||||
|
/// change-driven virtual tags re-evaluate on any upstream delta (value, status,
|
||||||
|
/// or timestamp). One subscription per distinct upstream tag path; the engine
|
||||||
|
/// tracks the mapping itself.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public interface ITagUpstreamSource
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Synchronous read returning the last-known value + quality for
|
||||||
|
/// <paramref name="path"/>. Returns a <c>BadNodeIdUnknown</c>-quality snapshot
|
||||||
|
/// when the path isn't configured.
|
||||||
|
/// </summary>
|
||||||
|
DataValueSnapshot ReadTag(string path);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register an observer that fires every time the upstream value at
|
||||||
|
/// <paramref name="path"/> changes. Returns an <see cref="IDisposable"/> the
|
||||||
|
/// engine disposes when the virtual-tag config is reloaded or the engine shuts
|
||||||
|
/// down, so source-side subscriptions don't leak.
|
||||||
|
/// </summary>
|
||||||
|
IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Periodic re-evaluation scheduler for tags with a non-null
|
||||||
|
/// <see cref="VirtualTagDefinition.TimerInterval"/>. Independent of the
|
||||||
|
/// change-trigger path — a tag can be timer-only, change-only, or both. One
|
||||||
|
/// <see cref="System.Threading.Timer"/> per interval-group keeps the wire count
|
||||||
|
/// low regardless of tag count.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TimerTriggerScheduler : IDisposable
|
||||||
|
{
|
||||||
|
private readonly VirtualTagEngine _engine;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
private readonly List<Timer> _timers = [];
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public TimerTriggerScheduler(VirtualTagEngine engine, ILogger logger)
|
||||||
|
{
|
||||||
|
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stand up one <see cref="Timer"/> per unique interval. All tags with
|
||||||
|
/// matching interval share a timer; each tick triggers re-evaluation of the
|
||||||
|
/// group in topological order so cascades are consistent with change-triggered
|
||||||
|
/// behavior.
|
||||||
|
/// </summary>
|
||||||
|
public void Start(IReadOnlyList<VirtualTagDefinition> definitions)
|
||||||
|
{
|
||||||
|
if (_disposed) throw new ObjectDisposedException(nameof(TimerTriggerScheduler));
|
||||||
|
|
||||||
|
var byInterval = definitions
|
||||||
|
.Where(d => d.TimerInterval.HasValue && d.TimerInterval.Value > TimeSpan.Zero)
|
||||||
|
.GroupBy(d => d.TimerInterval!.Value);
|
||||||
|
|
||||||
|
foreach (var group in byInterval)
|
||||||
|
{
|
||||||
|
var paths = group.Select(d => d.Path).ToArray();
|
||||||
|
var interval = group.Key;
|
||||||
|
var timer = new Timer(_ => Tick(paths), null, interval, interval);
|
||||||
|
_timers.Add(timer);
|
||||||
|
_logger.Information("TimerTriggerScheduler: {TagCount} tag(s) on {Interval} cadence",
|
||||||
|
paths.Length, interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Tick(IReadOnlyList<string> paths)
|
||||||
|
{
|
||||||
|
if (_cts.IsCancellationRequested) return;
|
||||||
|
foreach (var p in paths)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_engine.EvaluateOneAsync(p, _cts.Token).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Error(ex, "TimerTriggerScheduler evaluate failed for {Path}", p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
_cts.Cancel();
|
||||||
|
foreach (var t in _timers)
|
||||||
|
{
|
||||||
|
try { t.Dispose(); } catch { }
|
||||||
|
}
|
||||||
|
_timers.Clear();
|
||||||
|
_cts.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagContext.cs
Normal file
64
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagContext.cs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
using Serilog;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-evaluation <see cref="ScriptContext"/> for a virtual-tag script. Reads come
|
||||||
|
/// out of the engine's last-known-value cache (driver tags updated via the
|
||||||
|
/// <see cref="ITagUpstreamSource"/> subscription, virtual tags updated by prior
|
||||||
|
/// evaluations). Writes route through the engine's <c>SetVirtualTag</c> callback so
|
||||||
|
/// cross-tag write side effects still participate in change-trigger cascades.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Context instances are evaluation-scoped, not tag-scoped. The engine
|
||||||
|
/// constructs a fresh context for every run — cheap because the constructor
|
||||||
|
/// just captures references — so scripts can't cache mutable state across runs
|
||||||
|
/// via <c>ctx</c>. Mutable state across runs is a future decision (e.g. a
|
||||||
|
/// dedicated <c>ctx.Memory</c> dictionary); not in scope for Phase 7.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The <see cref="Now"/> clock is injectable so tests can pin time
|
||||||
|
/// deterministically. Production wires to <see cref="DateTime.UtcNow"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class VirtualTagContext : ScriptContext
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyDictionary<string, DataValueSnapshot> _readCache;
|
||||||
|
private readonly Action<string, object?> _setVirtualTag;
|
||||||
|
private readonly Func<DateTime> _clock;
|
||||||
|
|
||||||
|
public VirtualTagContext(
|
||||||
|
IReadOnlyDictionary<string, DataValueSnapshot> readCache,
|
||||||
|
Action<string, object?> setVirtualTag,
|
||||||
|
ILogger logger,
|
||||||
|
Func<DateTime>? clock = null)
|
||||||
|
{
|
||||||
|
_readCache = readCache ?? throw new ArgumentNullException(nameof(readCache));
|
||||||
|
_setVirtualTag = setVirtualTag ?? throw new ArgumentNullException(nameof(setVirtualTag));
|
||||||
|
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
_clock = clock ?? (() => DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override DataValueSnapshot GetTag(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
return new DataValueSnapshot(null, 0x80340000u /* BadNodeIdUnknown */, null, _clock());
|
||||||
|
return _readCache.TryGetValue(path, out var v)
|
||||||
|
? v
|
||||||
|
: new DataValueSnapshot(null, 0x80340000u /* BadNodeIdUnknown */, null, _clock());
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void SetVirtualTag(string path, object? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
throw new ArgumentException("Virtual tag path required.", nameof(path));
|
||||||
|
_setVirtualTag(path, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override DateTime Now => _clock();
|
||||||
|
|
||||||
|
public override ILogger Logger { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Operator-authored virtual-tag configuration row. Phase 7 Stream E (config DB
|
||||||
|
/// schema) materializes these from the <c>VirtualTag</c> + <c>Script</c> tables on
|
||||||
|
/// publish; the engine ingests a list of them at load time.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Path">
|
||||||
|
/// UNS tag path — <c>Enterprise/Site/Area/Line/Equipment/TagName</c>. Used both as
|
||||||
|
/// the engine's internal id and the OPC UA browse path.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="DataType">
|
||||||
|
/// Expected return type. The evaluator coerces the script's return value to this
|
||||||
|
/// type before publishing; mismatch surfaces as <c>BadTypeMismatch</c> quality on
|
||||||
|
/// the tag.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ScriptSource">Roslyn C# script source. Must compile under <c>ScriptSandbox</c>.</param>
|
||||||
|
/// <param name="ChangeTriggered">
|
||||||
|
/// True if any input tag's change (value / status / timestamp delta) should trigger
|
||||||
|
/// re-evaluation. Operator picks per tag — usually true for inputs that change at
|
||||||
|
/// protocol rates.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="TimerInterval">
|
||||||
|
/// Optional periodic re-evaluation cadence. Null = timer-driven disabled. Both can
|
||||||
|
/// be enabled simultaneously; independent scheduling paths both feed
|
||||||
|
/// <c>EvaluationPipeline</c>.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Historize">
|
||||||
|
/// When true, every evaluation result is forwarded to the configured
|
||||||
|
/// <see cref="IHistoryWriter"/>. Operator-set per tag; the Admin UI exposes as a
|
||||||
|
/// checkbox.
|
||||||
|
/// </param>
|
||||||
|
public sealed record VirtualTagDefinition(
|
||||||
|
string Path,
|
||||||
|
DriverDataType DataType,
|
||||||
|
string ScriptSource,
|
||||||
|
bool ChangeTriggered = true,
|
||||||
|
TimeSpan? TimerInterval = null,
|
||||||
|
bool Historize = false);
|
||||||
385
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs
Normal file
385
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Serilog;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Phase 7 virtual-tag evaluation engine. Ingests a set of
|
||||||
|
/// <see cref="VirtualTagDefinition"/>s at load time, compiles each script against
|
||||||
|
/// <see cref="ScriptSandbox"/>, builds the dependency graph, subscribes to every
|
||||||
|
/// referenced upstream tag, and schedules re-evaluations on change + on timer.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Evaluation order is topological per ADR-001 / Phase 7 plan decision #19 —
|
||||||
|
/// serial for the v1 rollout, parallel promoted to a follow-up. When upstream
|
||||||
|
/// tag X changes, the engine computes the transitive dependent closure of X in
|
||||||
|
/// topological rank and evaluates each in turn, so a cascade through multiple
|
||||||
|
/// levels of virtual tags settles within one change-trigger pass.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Per-tag error isolation per Phase 7 plan decision #11 — a script exception
|
||||||
|
/// (or timeout) fails that tag's latest value with <c>BadInternalError</c> or
|
||||||
|
/// <c>BadTypeMismatch</c> quality and logs a structured error; every other tag
|
||||||
|
/// keeps evaluating. The engine itself never faults from a user script.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class VirtualTagEngine : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ITagUpstreamSource _upstream;
|
||||||
|
private readonly IHistoryWriter _history;
|
||||||
|
private readonly ScriptLoggerFactory _loggerFactory;
|
||||||
|
private readonly ILogger _engineLogger;
|
||||||
|
private readonly Func<DateTime> _clock;
|
||||||
|
private readonly TimeSpan _scriptTimeout;
|
||||||
|
|
||||||
|
private readonly DependencyGraph _graph = new();
|
||||||
|
private readonly Dictionary<string, VirtualTagState> _tags = new(StringComparer.Ordinal);
|
||||||
|
private readonly ConcurrentDictionary<string, DataValueSnapshot> _valueCache = new(StringComparer.Ordinal);
|
||||||
|
private readonly ConcurrentDictionary<string, List<Action<string, DataValueSnapshot>>> _observers
|
||||||
|
= new(StringComparer.Ordinal);
|
||||||
|
private readonly List<IDisposable> _upstreamSubscriptions = [];
|
||||||
|
private readonly SemaphoreSlim _evalGate = new(1, 1);
|
||||||
|
private bool _loaded;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public VirtualTagEngine(
|
||||||
|
ITagUpstreamSource upstream,
|
||||||
|
ScriptLoggerFactory loggerFactory,
|
||||||
|
ILogger engineLogger,
|
||||||
|
IHistoryWriter? historyWriter = null,
|
||||||
|
Func<DateTime>? clock = null,
|
||||||
|
TimeSpan? scriptTimeout = null)
|
||||||
|
{
|
||||||
|
_upstream = upstream ?? throw new ArgumentNullException(nameof(upstream));
|
||||||
|
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
|
||||||
|
_engineLogger = engineLogger ?? throw new ArgumentNullException(nameof(engineLogger));
|
||||||
|
_history = historyWriter ?? NullHistoryWriter.Instance;
|
||||||
|
_clock = clock ?? (() => DateTime.UtcNow);
|
||||||
|
_scriptTimeout = scriptTimeout ?? TimedScriptEvaluator<VirtualTagContext, object?>.DefaultTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Registered tag paths, in topological order. Empty before <see cref="Load"/>.</summary>
|
||||||
|
public IReadOnlyCollection<string> LoadedTagPaths => _tags.Keys;
|
||||||
|
|
||||||
|
/// <summary>Compile + register every tag in <paramref name="definitions"/>. Throws on cycle or any compile failure.</summary>
|
||||||
|
public void Load(IReadOnlyList<VirtualTagDefinition> definitions)
|
||||||
|
{
|
||||||
|
if (_disposed) throw new ObjectDisposedException(nameof(VirtualTagEngine));
|
||||||
|
if (definitions is null) throw new ArgumentNullException(nameof(definitions));
|
||||||
|
|
||||||
|
// Start from a clean slate — supports config-publish reloads.
|
||||||
|
UnsubscribeFromUpstream();
|
||||||
|
_tags.Clear();
|
||||||
|
_graph.Clear();
|
||||||
|
|
||||||
|
var compileFailures = new List<string>();
|
||||||
|
foreach (var def in definitions)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var extraction = DependencyExtractor.Extract(def.ScriptSource);
|
||||||
|
if (!extraction.IsValid)
|
||||||
|
{
|
||||||
|
var msgs = string.Join("; ", extraction.Rejections.Select(r => r.Message));
|
||||||
|
compileFailures.Add($"{def.Path}: dependency extraction rejected — {msgs}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var evaluator = ScriptEvaluator<VirtualTagContext, object?>.Compile(def.ScriptSource);
|
||||||
|
var timed = new TimedScriptEvaluator<VirtualTagContext, object?>(evaluator, _scriptTimeout);
|
||||||
|
var scriptLogger = _loggerFactory.Create(def.Path);
|
||||||
|
|
||||||
|
_tags[def.Path] = new VirtualTagState(def, timed, extraction.Reads, extraction.Writes, scriptLogger);
|
||||||
|
_graph.Add(def.Path, extraction.Reads);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
compileFailures.Add($"{def.Path}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compileFailures.Count > 0)
|
||||||
|
{
|
||||||
|
var joined = string.Join("\n ", compileFailures);
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Virtual-tag engine load failed. {compileFailures.Count} script(s) did not compile:\n {joined}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cycle check — throws DependencyCycleException on offense.
|
||||||
|
_ = _graph.TopologicalSort();
|
||||||
|
|
||||||
|
// Subscribe to every referenced upstream path (driver tags only — virtual tags
|
||||||
|
// cascade internally). Seed the cache with current upstream values so first
|
||||||
|
// evaluations see something real.
|
||||||
|
var upstreamPaths = definitions
|
||||||
|
.SelectMany(d => _tags[d.Path].Reads)
|
||||||
|
.Where(p => !_tags.ContainsKey(p))
|
||||||
|
.Distinct(StringComparer.Ordinal);
|
||||||
|
foreach (var path in upstreamPaths)
|
||||||
|
{
|
||||||
|
_valueCache[path] = _upstream.ReadTag(path);
|
||||||
|
_upstreamSubscriptions.Add(_upstream.SubscribeTag(path, OnUpstreamChange));
|
||||||
|
}
|
||||||
|
|
||||||
|
_loaded = true;
|
||||||
|
_engineLogger.Information(
|
||||||
|
"VirtualTagEngine loaded {TagCount} tag(s), {UpstreamCount} upstream subscription(s)",
|
||||||
|
_tags.Count, _upstreamSubscriptions.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluate every registered tag once in topological order — used at startup so
|
||||||
|
/// virtual tags have a defined initial value rather than inheriting the cache
|
||||||
|
/// default. Also called after a config reload.
|
||||||
|
/// </summary>
|
||||||
|
public async Task EvaluateAllAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
var order = _graph.TopologicalSort();
|
||||||
|
foreach (var path in order)
|
||||||
|
{
|
||||||
|
if (_tags.ContainsKey(path))
|
||||||
|
await EvaluateOneAsync(path, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Evaluate a single tag — used by the timer trigger + test hooks.</summary>
|
||||||
|
public Task EvaluateOneAsync(string path, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
if (!_tags.ContainsKey(path))
|
||||||
|
throw new ArgumentException($"Not a registered virtual tag: {path}", nameof(path));
|
||||||
|
return EvaluateInternalAsync(path, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read the most recently evaluated value for <paramref name="path"/>. Driver
|
||||||
|
/// tags return the last-known upstream value; virtual tags return their last
|
||||||
|
/// evaluation result.
|
||||||
|
/// </summary>
|
||||||
|
public DataValueSnapshot Read(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
return new DataValueSnapshot(null, 0x80340000u, null, _clock());
|
||||||
|
return _valueCache.TryGetValue(path, out var v)
|
||||||
|
? v
|
||||||
|
: new DataValueSnapshot(null, 0x80340000u /* BadNodeIdUnknown */, null, _clock());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register an observer that fires on every evaluation of the given tag.
|
||||||
|
/// Returns an <see cref="IDisposable"/> to unsubscribe. Does NOT fire a seed
|
||||||
|
/// value — subscribers call <see cref="Read"/> for the current value if needed.
|
||||||
|
/// </summary>
|
||||||
|
public IDisposable Subscribe(string path, Action<string, DataValueSnapshot> observer)
|
||||||
|
{
|
||||||
|
var list = _observers.GetOrAdd(path, _ => []);
|
||||||
|
lock (list) { list.Add(observer); }
|
||||||
|
return new Unsub(this, path, observer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Change-trigger entry point — called by the upstream subscription callback.
|
||||||
|
/// Updates the cache, fans out to observers (so OPC UA clients see the upstream
|
||||||
|
/// change too if they subscribed via the engine), and schedules every
|
||||||
|
/// change-triggered dependent for re-evaluation in topological order.
|
||||||
|
/// </summary>
|
||||||
|
internal void OnUpstreamChange(string path, DataValueSnapshot value)
|
||||||
|
{
|
||||||
|
_valueCache[path] = value;
|
||||||
|
NotifyObservers(path, value);
|
||||||
|
|
||||||
|
// Fire-and-forget — the upstream subscription callback must not block the
|
||||||
|
// driver's dispatcher. Exceptions during cascade are handled per-tag inside
|
||||||
|
// EvaluateInternalAsync.
|
||||||
|
_ = CascadeAsync(path, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CascadeAsync(string upstreamPath, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dependents = _graph.TransitiveDependentsInOrder(upstreamPath);
|
||||||
|
foreach (var dep in dependents)
|
||||||
|
{
|
||||||
|
if (_tags.TryGetValue(dep, out var state) && state.Definition.ChangeTriggered)
|
||||||
|
await EvaluateInternalAsync(dep, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_engineLogger.Error(ex, "VirtualTagEngine cascade failed for upstream {Path}", upstreamPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EvaluateInternalAsync(string path, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!_tags.TryGetValue(path, out var state)) return;
|
||||||
|
|
||||||
|
// Serial evaluation across all tags. Phase 7 plan decision #19 — parallel is a
|
||||||
|
// follow-up. The semaphore bounds the evaluation graph so two cascades don't
|
||||||
|
// interleave, which would break the "earlier nodes computed first" invariant.
|
||||||
|
// SemaphoreSlim.WaitAsync is async-safe where Monitor.Enter is not (Monitor
|
||||||
|
// ownership is thread-local and lost across await).
|
||||||
|
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ctxCache = BuildReadCache(state.Reads);
|
||||||
|
var context = new VirtualTagContext(
|
||||||
|
ctxCache,
|
||||||
|
(p, v) => OnScriptSetVirtualTag(p, v),
|
||||||
|
state.Logger,
|
||||||
|
_clock);
|
||||||
|
|
||||||
|
DataValueSnapshot result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var raw = await state.Evaluator.RunAsync(context, ct).ConfigureAwait(false);
|
||||||
|
var coerced = CoerceResult(raw, state.Definition.DataType);
|
||||||
|
result = new DataValueSnapshot(coerced, 0u, _clock(), _clock());
|
||||||
|
}
|
||||||
|
catch (ScriptTimeoutException tex)
|
||||||
|
{
|
||||||
|
state.Logger.Warning("Script timed out after {Timeout}", tex.Timeout);
|
||||||
|
result = new DataValueSnapshot(null, 0x80020000u /* BadInternalError */, null, _clock());
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw; // shutdown path — don't misclassify
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
state.Logger.Error(ex, "Virtual-tag script threw");
|
||||||
|
result = new DataValueSnapshot(null, 0x80020000u /* BadInternalError */, null, _clock());
|
||||||
|
}
|
||||||
|
|
||||||
|
_valueCache[path] = result;
|
||||||
|
NotifyObservers(path, result);
|
||||||
|
if (state.Definition.Historize) _history.Record(path, result);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_evalGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(IReadOnlySet<string> reads)
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
|
||||||
|
foreach (var r in reads)
|
||||||
|
{
|
||||||
|
map[r] = _valueCache.TryGetValue(r, out var v)
|
||||||
|
? v
|
||||||
|
: _upstream.ReadTag(r);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnScriptSetVirtualTag(string path, object? value)
|
||||||
|
{
|
||||||
|
if (!_tags.ContainsKey(path))
|
||||||
|
{
|
||||||
|
_engineLogger.Warning(
|
||||||
|
"Script attempted ctx.SetVirtualTag on non-virtual or non-registered path {Path}", path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var snap = new DataValueSnapshot(value, 0u, _clock(), _clock());
|
||||||
|
_valueCache[path] = snap;
|
||||||
|
NotifyObservers(path, snap);
|
||||||
|
if (_tags[path].Definition.Historize) _history.Record(path, snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotifyObservers(string path, DataValueSnapshot value)
|
||||||
|
{
|
||||||
|
if (!_observers.TryGetValue(path, out var list)) return;
|
||||||
|
Action<string, DataValueSnapshot>[] snapshot;
|
||||||
|
lock (list) { snapshot = list.ToArray(); }
|
||||||
|
foreach (var obs in snapshot)
|
||||||
|
{
|
||||||
|
try { obs(path, value); }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_engineLogger.Warning(ex, "Virtual-tag observer for {Path} threw", path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? CoerceResult(object? raw, DriverDataType target)
|
||||||
|
{
|
||||||
|
if (raw is null) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return target switch
|
||||||
|
{
|
||||||
|
DriverDataType.Boolean => Convert.ToBoolean(raw),
|
||||||
|
DriverDataType.Int32 => Convert.ToInt32(raw),
|
||||||
|
DriverDataType.Int64 => Convert.ToInt64(raw),
|
||||||
|
DriverDataType.Float32 => Convert.ToSingle(raw),
|
||||||
|
DriverDataType.Float64 => Convert.ToDouble(raw),
|
||||||
|
DriverDataType.String => Convert.ToString(raw) ?? string.Empty,
|
||||||
|
DriverDataType.DateTime => raw is DateTime dt ? dt : Convert.ToDateTime(raw),
|
||||||
|
_ => raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Caller logs + maps to BadTypeMismatch — we let null propagate so the
|
||||||
|
// outer evaluation path sets the Bad quality.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UnsubscribeFromUpstream()
|
||||||
|
{
|
||||||
|
foreach (var s in _upstreamSubscriptions)
|
||||||
|
{
|
||||||
|
try { s.Dispose(); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
_upstreamSubscriptions.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureLoaded()
|
||||||
|
{
|
||||||
|
if (!_loaded) throw new InvalidOperationException(
|
||||||
|
"VirtualTagEngine not loaded. Call Load(definitions) first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
UnsubscribeFromUpstream();
|
||||||
|
_tags.Clear();
|
||||||
|
_graph.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal DependencyGraph GraphForTesting => _graph;
|
||||||
|
|
||||||
|
private sealed class Unsub : IDisposable
|
||||||
|
{
|
||||||
|
private readonly VirtualTagEngine _engine;
|
||||||
|
private readonly string _path;
|
||||||
|
private readonly Action<string, DataValueSnapshot> _observer;
|
||||||
|
public Unsub(VirtualTagEngine e, string path, Action<string, DataValueSnapshot> observer)
|
||||||
|
{
|
||||||
|
_engine = e; _path = path; _observer = observer;
|
||||||
|
}
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_engine._observers.TryGetValue(_path, out var list))
|
||||||
|
{
|
||||||
|
lock (list) { list.Remove(_observer); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record VirtualTagState(
|
||||||
|
VirtualTagDefinition Definition,
|
||||||
|
TimedScriptEvaluator<VirtualTagContext, object?> Evaluator,
|
||||||
|
IReadOnlySet<string> Reads,
|
||||||
|
IReadOnlySet<string> Writes,
|
||||||
|
ILogger Logger);
|
||||||
|
}
|
||||||
89
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs
Normal file
89
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implements the driver-agnostic capability surface the
|
||||||
|
/// <c>DriverNodeManager</c> dispatches to when a node resolves to
|
||||||
|
/// <c>NodeSource.Virtual</c> per ADR-002. Reads return the engine's last-known
|
||||||
|
/// evaluation result; subscriptions forward engine-emitted change events as
|
||||||
|
/// <see cref="ISubscribable.OnDataChange"/> events.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="IWritable"/> is deliberately not implemented — OPC UA client
|
||||||
|
/// writes to virtual tags are rejected in <c>DriverNodeManager</c> before they
|
||||||
|
/// reach here per Phase 7 decision #6. Scripts are the only write path, routed
|
||||||
|
/// through <c>ctx.SetVirtualTag</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class VirtualTagSource : IReadable, ISubscribable
|
||||||
|
{
|
||||||
|
private readonly VirtualTagEngine _engine;
|
||||||
|
private readonly ConcurrentDictionary<string, Subscription> _subs = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public VirtualTagSource(VirtualTagEngine engine)
|
||||||
|
{
|
||||||
|
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||||
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (fullReferences is null) throw new ArgumentNullException(nameof(fullReferences));
|
||||||
|
var results = new DataValueSnapshot[fullReferences.Count];
|
||||||
|
for (var i = 0; i < fullReferences.Count; i++)
|
||||||
|
results[i] = _engine.Read(fullReferences[i]);
|
||||||
|
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||||
|
IReadOnlyList<string> fullReferences,
|
||||||
|
TimeSpan publishingInterval,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (fullReferences is null) throw new ArgumentNullException(nameof(fullReferences));
|
||||||
|
|
||||||
|
var handle = new SubscriptionHandle(Guid.NewGuid().ToString("N"));
|
||||||
|
var observers = new List<IDisposable>(fullReferences.Count);
|
||||||
|
foreach (var path in fullReferences)
|
||||||
|
{
|
||||||
|
observers.Add(_engine.Subscribe(path, (p, snap) =>
|
||||||
|
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, p, snap))));
|
||||||
|
}
|
||||||
|
_subs[handle.DiagnosticId] = new Subscription(handle, observers);
|
||||||
|
|
||||||
|
// OPC UA convention: emit initial-data callback for each path with the current value.
|
||||||
|
foreach (var path in fullReferences)
|
||||||
|
{
|
||||||
|
var snap = _engine.Read(path);
|
||||||
|
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, path, snap));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<ISubscriptionHandle>(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (handle is null) throw new ArgumentNullException(nameof(handle));
|
||||||
|
if (_subs.TryRemove(handle.DiagnosticId, out var sub))
|
||||||
|
{
|
||||||
|
foreach (var d in sub.Observers)
|
||||||
|
{
|
||||||
|
try { d.Dispose(); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SubscriptionHandle : ISubscriptionHandle
|
||||||
|
{
|
||||||
|
public SubscriptionHandle(string id) { DiagnosticId = id; }
|
||||||
|
public string DiagnosticId { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record Subscription(SubscriptionHandle Handle, IReadOnlyList<IDisposable> Observers);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.VirtualTags</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Serilog" Version="4.2.0"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Core.VirtualTags.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>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MessagePack;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory <see cref="IFocasBackend"/> for tests + an operational stub mode when
|
||||||
|
/// <c>OTOPCUA_FOCAS_BACKEND=fake</c>. Keeps per-address values keyed by a canonical
|
||||||
|
/// string; RMW semantics honor PMC bit-writes against the containing byte so the
|
||||||
|
/// <c>PmcBitWriteRequest</c> path can be exercised end-to-end without hardware.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FakeFocasBackend : IFocasBackend
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private long _nextSessionId;
|
||||||
|
private readonly HashSet<long> _openSessions = [];
|
||||||
|
private readonly Dictionary<string, byte[]> _pmcValues = [];
|
||||||
|
private readonly Dictionary<string, byte[]> _paramValues = [];
|
||||||
|
private readonly Dictionary<string, byte[]> _macroValues = [];
|
||||||
|
|
||||||
|
public Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var id = ++_nextSessionId;
|
||||||
|
_openSessions.Add(id);
|
||||||
|
return Task.FromResult(new OpenSessionResponse { Success = true, SessionId = id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task CloseSessionAsync(CloseSessionRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
lock (_gate) { _openSessions.Remove(request.SessionId); }
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ReadResponse> ReadAsync(ReadRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_openSessions.Contains(request.SessionId))
|
||||||
|
return Task.FromResult(new ReadResponse { Success = false, StatusCode = 0x80020000u, Error = "session-not-open" });
|
||||||
|
|
||||||
|
var store = StoreFor(request.Address.Kind);
|
||||||
|
var key = CanonicalKey(request.Address);
|
||||||
|
store.TryGetValue(key, out var value);
|
||||||
|
return Task.FromResult(new ReadResponse
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
StatusCode = 0,
|
||||||
|
ValueBytes = value ?? MessagePackSerializer.Serialize((int)0),
|
||||||
|
ValueTypeCode = request.DataType,
|
||||||
|
SourceTimestampUtcUnixMs = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<WriteResponse> WriteAsync(WriteRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_openSessions.Contains(request.SessionId))
|
||||||
|
return Task.FromResult(new WriteResponse { Success = false, StatusCode = 0x80020000u, Error = "session-not-open" });
|
||||||
|
|
||||||
|
var store = StoreFor(request.Address.Kind);
|
||||||
|
store[CanonicalKey(request.Address)] = request.ValueBytes ?? [];
|
||||||
|
return Task.FromResult(new WriteResponse { Success = true, StatusCode = 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<PmcBitWriteResponse> PmcBitWriteAsync(PmcBitWriteRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_openSessions.Contains(request.SessionId))
|
||||||
|
return Task.FromResult(new PmcBitWriteResponse { Success = false, StatusCode = 0x80020000u, Error = "session-not-open" });
|
||||||
|
if (request.BitIndex is < 0 or > 7)
|
||||||
|
return Task.FromResult(new PmcBitWriteResponse { Success = false, StatusCode = 0x803C0000u, Error = "bit-out-of-range" });
|
||||||
|
|
||||||
|
var key = CanonicalKey(request.Address);
|
||||||
|
_pmcValues.TryGetValue(key, out var current);
|
||||||
|
current ??= MessagePackSerializer.Serialize((byte)0);
|
||||||
|
var b = MessagePackSerializer.Deserialize<byte>(current);
|
||||||
|
var mask = (byte)(1 << request.BitIndex);
|
||||||
|
b = request.Value ? (byte)(b | mask) : (byte)(b & ~mask);
|
||||||
|
_pmcValues[key] = MessagePackSerializer.Serialize(b);
|
||||||
|
return Task.FromResult(new PmcBitWriteResponse { Success = true, StatusCode = 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ProbeResponse> ProbeAsync(ProbeRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new ProbeResponse
|
||||||
|
{
|
||||||
|
Healthy = _openSessions.Contains(request.SessionId),
|
||||||
|
ObservedAtUtcUnixMs = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, byte[]> StoreFor(int kind) => kind switch
|
||||||
|
{
|
||||||
|
0 => _pmcValues,
|
||||||
|
1 => _paramValues,
|
||||||
|
2 => _macroValues,
|
||||||
|
_ => _pmcValues,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string CanonicalKey(FocasAddressDto addr) =>
|
||||||
|
addr.Kind switch
|
||||||
|
{
|
||||||
|
0 => $"{addr.PmcLetter}{addr.Number}",
|
||||||
|
1 => $"P{addr.Number}",
|
||||||
|
2 => $"M{addr.Number}",
|
||||||
|
_ => $"?{addr.Number}",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Host's view of a FOCAS session. One implementation wraps the real
|
||||||
|
/// <c>Fwlib32.dll</c> via P/Invoke (lands with the real Fwlib32 integration follow-up,
|
||||||
|
/// since no hardware is available today); a second implementation —
|
||||||
|
/// <see cref="FakeFocasBackend"/> — is used by tests.
|
||||||
|
/// Both live on .NET 4.8 x86 so the Host can be deployed in either mode without
|
||||||
|
/// changing the pipe server.
|
||||||
|
/// Invoked via <c>FwlibFrameHandler</c> in the Ipc namespace.
|
||||||
|
/// </summary>
|
||||||
|
public interface IFocasBackend
|
||||||
|
{
|
||||||
|
Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest request, CancellationToken ct);
|
||||||
|
Task CloseSessionAsync(CloseSessionRequest request, CancellationToken ct);
|
||||||
|
Task<ReadResponse> ReadAsync(ReadRequest request, CancellationToken ct);
|
||||||
|
Task<WriteResponse> WriteAsync(WriteRequest request, CancellationToken ct);
|
||||||
|
Task<PmcBitWriteResponse> PmcBitWriteAsync(PmcBitWriteRequest request, CancellationToken ct);
|
||||||
|
Task<ProbeResponse> ProbeAsync(ProbeRequest request, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Safe default when the deployment hasn't configured a real Fwlib32 backend.
|
||||||
|
/// Returns structured failure responses instead of throwing so the Proxy can map the
|
||||||
|
/// error to <c>BadDeviceFailure</c> and surface a clear operator message pointing at
|
||||||
|
/// <c>docs/v2/focas-deployment.md</c>. Used when <c>OTOPCUA_FOCAS_BACKEND</c> is unset
|
||||||
|
/// or set to <c>unconfigured</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UnconfiguredFocasBackend : IFocasBackend
|
||||||
|
{
|
||||||
|
private const uint BadDeviceFailure = 0x80550000u;
|
||||||
|
private const string Reason =
|
||||||
|
"FOCAS Host is running without a real Fwlib32 backend. Set OTOPCUA_FOCAS_BACKEND=fwlib32 " +
|
||||||
|
"and ensure Fwlib32.dll is on PATH — see docs/v2/focas-deployment.md.";
|
||||||
|
|
||||||
|
public Task<OpenSessionResponse> OpenSessionAsync(OpenSessionRequest request, CancellationToken ct) =>
|
||||||
|
Task.FromResult(new OpenSessionResponse { Success = false, Error = Reason, ErrorCode = "NoFwlibBackend" });
|
||||||
|
|
||||||
|
public Task CloseSessionAsync(CloseSessionRequest request, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task<ReadResponse> ReadAsync(ReadRequest request, CancellationToken ct) =>
|
||||||
|
Task.FromResult(new ReadResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason });
|
||||||
|
|
||||||
|
public Task<WriteResponse> WriteAsync(WriteRequest request, CancellationToken ct) =>
|
||||||
|
Task.FromResult(new WriteResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason });
|
||||||
|
|
||||||
|
public Task<PmcBitWriteResponse> PmcBitWriteAsync(PmcBitWriteRequest request, CancellationToken ct) =>
|
||||||
|
Task.FromResult(new PmcBitWriteResponse { Success = false, StatusCode = BadDeviceFailure, Error = Reason });
|
||||||
|
|
||||||
|
public Task<ProbeResponse> ProbeAsync(ProbeRequest request, CancellationToken ct) =>
|
||||||
|
Task.FromResult(new ProbeResponse { Healthy = false, Error = Reason, ObservedAtUtcUnixMs = System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() });
|
||||||
|
}
|
||||||
111
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/FwlibFrameHandler.cs
Normal file
111
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/FwlibFrameHandler.cs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MessagePack;
|
||||||
|
using Serilog;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Real FOCAS frame handler. Deserializes each request DTO, delegates to
|
||||||
|
/// <see cref="IFocasBackend"/>, re-serializes the response. The backend owns the
|
||||||
|
/// Fwlib32 handle + STA thread — the handler is pure dispatch.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FwlibFrameHandler : IFrameHandler
|
||||||
|
{
|
||||||
|
private readonly IFocasBackend _backend;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
|
public FwlibFrameHandler(IFocasBackend backend, ILogger logger)
|
||||||
|
{
|
||||||
|
_backend = backend ?? throw new ArgumentNullException(nameof(backend));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task HandleAsync(FocasMessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case FocasMessageKind.Heartbeat:
|
||||||
|
{
|
||||||
|
var hb = MessagePackSerializer.Deserialize<Heartbeat>(body);
|
||||||
|
await writer.WriteAsync(FocasMessageKind.HeartbeatAck,
|
||||||
|
new HeartbeatAck
|
||||||
|
{
|
||||||
|
MonotonicTicks = hb.MonotonicTicks,
|
||||||
|
HostUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
|
}, ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case FocasMessageKind.OpenSessionRequest:
|
||||||
|
{
|
||||||
|
var req = MessagePackSerializer.Deserialize<OpenSessionRequest>(body);
|
||||||
|
var resp = await _backend.OpenSessionAsync(req, ct).ConfigureAwait(false);
|
||||||
|
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse, resp, ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case FocasMessageKind.CloseSessionRequest:
|
||||||
|
{
|
||||||
|
var req = MessagePackSerializer.Deserialize<CloseSessionRequest>(body);
|
||||||
|
await _backend.CloseSessionAsync(req, ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case FocasMessageKind.ReadRequest:
|
||||||
|
{
|
||||||
|
var req = MessagePackSerializer.Deserialize<ReadRequest>(body);
|
||||||
|
var resp = await _backend.ReadAsync(req, ct).ConfigureAwait(false);
|
||||||
|
await writer.WriteAsync(FocasMessageKind.ReadResponse, resp, ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case FocasMessageKind.WriteRequest:
|
||||||
|
{
|
||||||
|
var req = MessagePackSerializer.Deserialize<WriteRequest>(body);
|
||||||
|
var resp = await _backend.WriteAsync(req, ct).ConfigureAwait(false);
|
||||||
|
await writer.WriteAsync(FocasMessageKind.WriteResponse, resp, ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case FocasMessageKind.PmcBitWriteRequest:
|
||||||
|
{
|
||||||
|
var req = MessagePackSerializer.Deserialize<PmcBitWriteRequest>(body);
|
||||||
|
var resp = await _backend.PmcBitWriteAsync(req, ct).ConfigureAwait(false);
|
||||||
|
await writer.WriteAsync(FocasMessageKind.PmcBitWriteResponse, resp, ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case FocasMessageKind.ProbeRequest:
|
||||||
|
{
|
||||||
|
var req = MessagePackSerializer.Deserialize<ProbeRequest>(body);
|
||||||
|
var resp = await _backend.ProbeAsync(req, ct).ConfigureAwait(false);
|
||||||
|
await writer.WriteAsync(FocasMessageKind.ProbeResponse, resp, ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
await writer.WriteAsync(FocasMessageKind.ErrorResponse,
|
||||||
|
new ErrorResponse { Code = "unknown-kind", Message = $"Kind {kind} is not handled by the Host" },
|
||||||
|
ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Error(ex, "FwlibFrameHandler error processing {Kind}", kind);
|
||||||
|
await writer.WriteAsync(FocasMessageKind.ErrorResponse,
|
||||||
|
new ErrorResponse { Code = "backend-exception", Message = ex.Message },
|
||||||
|
ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IDisposable AttachConnection(FrameWriter writer) => IFrameHandler.NoopAttachment.Instance;
|
||||||
|
}
|
||||||
31
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/IFrameHandler.cs
Normal file
31
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/IFrameHandler.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/PipeAcl.cs
Normal file
39
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/PipeAcl.cs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
152
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/PipeServer.cs
Normal file
152
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Ipc/PipeServer.cs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
72
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Program.cs
Normal file
72
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Program.cs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
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(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.MemoryMappedFiles;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Stability;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ring-buffer of the last N IPC operations, written into a memory-mapped file. On a
|
||||||
|
/// hard crash the Proxy-side supervisor reads the MMF after the corpse is gone to see
|
||||||
|
/// what was in flight at the moment the Host died. Single-writer (the Host), multi-reader
|
||||||
|
/// (the supervisor) — the file format is identical to the Galaxy Tier-C
|
||||||
|
/// <c>PostMortemMmf</c> so a single reader tool can work both.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// File layout:
|
||||||
|
/// <code>
|
||||||
|
/// [16-byte header: magic(4) | version(4) | capacity(4) | writeIndex(4)]
|
||||||
|
/// [capacity × 256-byte entries: each is [8-byte utcUnixMs | 8-byte opKind | 240-byte UTF-8 message]]
|
||||||
|
/// </code>
|
||||||
|
/// Magic is 'OFPC' (0x4F46_5043) to distinguish a FOCAS file from the Galaxy MMF.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class PostMortemMmf : IDisposable
|
||||||
|
{
|
||||||
|
private const int Magic = 0x4F465043; // 'OFPC'
|
||||||
|
private const int Version = 1;
|
||||||
|
private const int HeaderBytes = 16;
|
||||||
|
public const int EntryBytes = 256;
|
||||||
|
private const int MessageOffset = 16;
|
||||||
|
private const int MessageCapacity = EntryBytes - MessageOffset;
|
||||||
|
|
||||||
|
public int Capacity { get; }
|
||||||
|
public string Path { get; }
|
||||||
|
|
||||||
|
private readonly MemoryMappedFile _mmf;
|
||||||
|
private readonly MemoryMappedViewAccessor _accessor;
|
||||||
|
private readonly object _writeGate = new();
|
||||||
|
|
||||||
|
public PostMortemMmf(string path, int capacity = 1000)
|
||||||
|
{
|
||||||
|
if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity));
|
||||||
|
Capacity = capacity;
|
||||||
|
Path = path;
|
||||||
|
|
||||||
|
var fileBytes = HeaderBytes + capacity * EntryBytes;
|
||||||
|
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!);
|
||||||
|
|
||||||
|
var fs = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read);
|
||||||
|
fs.SetLength(fileBytes);
|
||||||
|
_mmf = MemoryMappedFile.CreateFromFile(fs, null, fileBytes,
|
||||||
|
MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, leaveOpen: false);
|
||||||
|
_accessor = _mmf.CreateViewAccessor(0, fileBytes, MemoryMappedFileAccess.ReadWrite);
|
||||||
|
|
||||||
|
if (_accessor.ReadInt32(0) != Magic)
|
||||||
|
{
|
||||||
|
_accessor.Write(0, Magic);
|
||||||
|
_accessor.Write(4, Version);
|
||||||
|
_accessor.Write(8, capacity);
|
||||||
|
_accessor.Write(12, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Write(long opKind, string message)
|
||||||
|
{
|
||||||
|
lock (_writeGate)
|
||||||
|
{
|
||||||
|
var idx = _accessor.ReadInt32(12);
|
||||||
|
var offset = HeaderBytes + idx * EntryBytes;
|
||||||
|
|
||||||
|
_accessor.Write(offset + 0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||||
|
_accessor.Write(offset + 8, opKind);
|
||||||
|
|
||||||
|
var msgBytes = Encoding.UTF8.GetBytes(message ?? string.Empty);
|
||||||
|
var copy = Math.Min(msgBytes.Length, MessageCapacity - 1);
|
||||||
|
_accessor.WriteArray(offset + MessageOffset, msgBytes, 0, copy);
|
||||||
|
_accessor.Write(offset + MessageOffset + copy, (byte)0);
|
||||||
|
|
||||||
|
var next = (idx + 1) % Capacity;
|
||||||
|
_accessor.Write(12, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public PostMortemEntry[] ReadAll()
|
||||||
|
{
|
||||||
|
var magic = _accessor.ReadInt32(0);
|
||||||
|
if (magic != Magic) return new PostMortemEntry[0];
|
||||||
|
|
||||||
|
var capacity = _accessor.ReadInt32(8);
|
||||||
|
var writeIndex = _accessor.ReadInt32(12);
|
||||||
|
|
||||||
|
var entries = new PostMortemEntry[capacity];
|
||||||
|
var count = 0;
|
||||||
|
for (var i = 0; i < capacity; i++)
|
||||||
|
{
|
||||||
|
var slot = (writeIndex + i) % capacity;
|
||||||
|
var offset = HeaderBytes + slot * EntryBytes;
|
||||||
|
|
||||||
|
var ts = _accessor.ReadInt64(offset + 0);
|
||||||
|
if (ts == 0) continue;
|
||||||
|
|
||||||
|
var op = _accessor.ReadInt64(offset + 8);
|
||||||
|
var msgBuf = new byte[MessageCapacity];
|
||||||
|
_accessor.ReadArray(offset + MessageOffset, msgBuf, 0, MessageCapacity);
|
||||||
|
var nulTerm = Array.IndexOf<byte>(msgBuf, 0);
|
||||||
|
var msg = Encoding.UTF8.GetString(msgBuf, 0, nulTerm < 0 ? MessageCapacity : nulTerm);
|
||||||
|
|
||||||
|
entries[count++] = new PostMortemEntry(ts, op, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.Resize(ref entries, count);
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_accessor.Dispose();
|
||||||
|
_mmf.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly struct PostMortemEntry
|
||||||
|
{
|
||||||
|
public long UtcUnixMs { get; }
|
||||||
|
public long OpKind { get; }
|
||||||
|
public string Message { get; }
|
||||||
|
|
||||||
|
public PostMortemEntry(long utcUnixMs, long opKind, string message)
|
||||||
|
{
|
||||||
|
UtcUnixMs = utcUnixMs;
|
||||||
|
OpKind = opKind;
|
||||||
|
Message = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
63
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Hello.cs
Normal file
63
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Hello.cs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
47
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Probe.cs
Normal file
47
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/Contracts/Probe.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
67
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameReader.cs
Normal file
67
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameReader.cs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameWriter.cs
Normal file
56
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/FrameWriter.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<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>
|
||||||
120
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/FocasIpcClient.cs
Normal file
120
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/FocasIpcClient.cs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.IO.Pipes;
|
||||||
|
using MessagePack;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Proxy-side IPC channel to a running <c>Driver.FOCAS.Host</c>. Owns the pipe connection
|
||||||
|
/// and serializes request/response round-trips through a single call gate so
|
||||||
|
/// concurrent callers don't interleave frames. One instance per FOCAS Host session.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FocasIpcClient : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly Stream _stream;
|
||||||
|
private readonly FrameReader _reader;
|
||||||
|
private readonly FrameWriter _writer;
|
||||||
|
private readonly SemaphoreSlim _callGate = new(1, 1);
|
||||||
|
|
||||||
|
private FocasIpcClient(Stream stream)
|
||||||
|
{
|
||||||
|
_stream = stream;
|
||||||
|
_reader = new FrameReader(stream, leaveOpen: true);
|
||||||
|
_writer = new FrameWriter(stream, leaveOpen: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Named-pipe factory: connects, sends Hello, awaits HelloAck.</summary>
|
||||||
|
public static async Task<FocasIpcClient> ConnectAsync(
|
||||||
|
string pipeName, string sharedSecret, TimeSpan connectTimeout, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var stream = new NamedPipeClientStream(
|
||||||
|
serverName: ".",
|
||||||
|
pipeName: pipeName,
|
||||||
|
direction: PipeDirection.InOut,
|
||||||
|
options: PipeOptions.Asynchronous);
|
||||||
|
|
||||||
|
await stream.ConnectAsync((int)connectTimeout.TotalMilliseconds, ct);
|
||||||
|
return await HandshakeAsync(stream, sharedSecret, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stream factory — used by tests that wire the Proxy against an in-memory stream
|
||||||
|
/// pair instead of a real pipe. <paramref name="stream"/> is owned by the caller
|
||||||
|
/// until <see cref="DisposeAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static Task<FocasIpcClient> ConnectAsync(Stream stream, string sharedSecret, CancellationToken ct)
|
||||||
|
=> HandshakeAsync(stream, sharedSecret, ct);
|
||||||
|
|
||||||
|
private static async Task<FocasIpcClient> HandshakeAsync(Stream stream, string sharedSecret, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var client = new FocasIpcClient(stream);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await client._writer.WriteAsync(FocasMessageKind.Hello,
|
||||||
|
new Hello { PeerName = "FOCAS.Proxy", SharedSecret = sharedSecret }, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var ack = await client._reader.ReadFrameAsync(ct).ConfigureAwait(false);
|
||||||
|
if (ack is null || ack.Value.Kind != FocasMessageKind.HelloAck)
|
||||||
|
throw new InvalidOperationException("Did not receive HelloAck from FOCAS.Host");
|
||||||
|
|
||||||
|
var ackMsg = FrameReader.Deserialize<HelloAck>(ack.Value.Body);
|
||||||
|
if (!ackMsg.Accepted)
|
||||||
|
throw new UnauthorizedAccessException($"FOCAS.Host rejected Hello: {ackMsg.RejectReason}");
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await client.DisposeAsync().ConfigureAwait(false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TResp> CallAsync<TReq, TResp>(
|
||||||
|
FocasMessageKind requestKind, TReq request, FocasMessageKind expectedResponseKind, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _callGate.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _writer.WriteAsync(requestKind, request, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var frame = await _reader.ReadFrameAsync(ct).ConfigureAwait(false);
|
||||||
|
if (frame is null) throw new EndOfStreamException("FOCAS IPC peer closed before response");
|
||||||
|
|
||||||
|
if (frame.Value.Kind == FocasMessageKind.ErrorResponse)
|
||||||
|
{
|
||||||
|
var err = MessagePackSerializer.Deserialize<ErrorResponse>(frame.Value.Body);
|
||||||
|
throw new FocasIpcException(err.Code, err.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.Value.Kind != expectedResponseKind)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Expected {expectedResponseKind}, got {frame.Value.Kind}");
|
||||||
|
|
||||||
|
return MessagePackSerializer.Deserialize<TResp>(frame.Value.Body);
|
||||||
|
}
|
||||||
|
finally { _callGate.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendOneWayAsync<TReq>(FocasMessageKind requestKind, TReq request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _callGate.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
try { await _writer.WriteAsync(requestKind, request, ct).ConfigureAwait(false); }
|
||||||
|
finally { _callGate.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
_callGate.Dispose();
|
||||||
|
_reader.Dispose();
|
||||||
|
_writer.Dispose();
|
||||||
|
await _stream.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FocasIpcException(string code, string message) : Exception($"[{code}] {message}")
|
||||||
|
{
|
||||||
|
public string Code { get; } = code;
|
||||||
|
}
|
||||||
199
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/IpcFocasClient.cs
Normal file
199
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Ipc/IpcFocasClient.cs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
using MessagePack;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="IFocasClient"/> implementation that forwards every operation over a
|
||||||
|
/// <see cref="FocasIpcClient"/> to a <c>Driver.FOCAS.Host</c> process. Keeps the
|
||||||
|
/// <c>Fwlib32.dll</c> P/Invoke out of the main server process so a native crash
|
||||||
|
/// blast-radius stops at the Host boundary.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Session lifecycle: <see cref="ConnectAsync"/> sends <c>OpenSessionRequest</c> and
|
||||||
|
/// caches the returned <c>SessionId</c>. Subsequent <see cref="ReadAsync"/> /
|
||||||
|
/// <see cref="WriteAsync"/> / <see cref="ProbeAsync"/> calls thread that session id
|
||||||
|
/// onto each request DTO. <see cref="Dispose"/> sends <c>CloseSessionRequest</c> +
|
||||||
|
/// disposes the underlying pipe.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class IpcFocasClient : IFocasClient
|
||||||
|
{
|
||||||
|
private readonly FocasIpcClient _ipc;
|
||||||
|
private readonly FocasCncSeries _series;
|
||||||
|
private long _sessionId;
|
||||||
|
private bool _connected;
|
||||||
|
|
||||||
|
public IpcFocasClient(FocasIpcClient ipc, FocasCncSeries series = FocasCncSeries.Unknown)
|
||||||
|
{
|
||||||
|
_ipc = ipc ?? throw new ArgumentNullException(nameof(ipc));
|
||||||
|
_series = series;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsConnected => _connected;
|
||||||
|
|
||||||
|
public async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_connected) return;
|
||||||
|
|
||||||
|
var resp = await _ipc.CallAsync<OpenSessionRequest, OpenSessionResponse>(
|
||||||
|
FocasMessageKind.OpenSessionRequest,
|
||||||
|
new OpenSessionRequest
|
||||||
|
{
|
||||||
|
HostAddress = $"{address.Host}:{address.Port}",
|
||||||
|
TimeoutMs = (int)Math.Max(1, timeout.TotalMilliseconds),
|
||||||
|
CncSeries = (int)_series,
|
||||||
|
},
|
||||||
|
FocasMessageKind.OpenSessionResponse,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!resp.Success)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"FOCAS Host rejected OpenSession for {address}: {resp.ErrorCode ?? "?"} — {resp.Error}");
|
||||||
|
|
||||||
|
_sessionId = resp.SessionId;
|
||||||
|
_connected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(object? value, uint status)> ReadAsync(
|
||||||
|
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_connected) return (null, FocasStatusMapper.BadCommunicationError);
|
||||||
|
|
||||||
|
var resp = await _ipc.CallAsync<ReadRequest, ReadResponse>(
|
||||||
|
FocasMessageKind.ReadRequest,
|
||||||
|
new ReadRequest
|
||||||
|
{
|
||||||
|
SessionId = _sessionId,
|
||||||
|
Address = ToDto(address),
|
||||||
|
DataType = (int)type,
|
||||||
|
},
|
||||||
|
FocasMessageKind.ReadResponse,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!resp.Success) return (null, resp.StatusCode);
|
||||||
|
|
||||||
|
var value = DecodeValue(resp.ValueBytes, resp.ValueTypeCode);
|
||||||
|
return (value, resp.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<uint> WriteAsync(
|
||||||
|
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_connected) return FocasStatusMapper.BadCommunicationError;
|
||||||
|
|
||||||
|
// PMC bit writes get the first-class RMW frame so the critical section stays on the Host.
|
||||||
|
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit)
|
||||||
|
{
|
||||||
|
var bitResp = await _ipc.CallAsync<PmcBitWriteRequest, PmcBitWriteResponse>(
|
||||||
|
FocasMessageKind.PmcBitWriteRequest,
|
||||||
|
new PmcBitWriteRequest
|
||||||
|
{
|
||||||
|
SessionId = _sessionId,
|
||||||
|
Address = ToDto(address),
|
||||||
|
BitIndex = bit,
|
||||||
|
Value = Convert.ToBoolean(value),
|
||||||
|
},
|
||||||
|
FocasMessageKind.PmcBitWriteResponse,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
return bitResp.StatusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp = await _ipc.CallAsync<WriteRequest, WriteResponse>(
|
||||||
|
FocasMessageKind.WriteRequest,
|
||||||
|
new WriteRequest
|
||||||
|
{
|
||||||
|
SessionId = _sessionId,
|
||||||
|
Address = ToDto(address),
|
||||||
|
DataType = (int)type,
|
||||||
|
ValueTypeCode = (int)type,
|
||||||
|
ValueBytes = EncodeValue(value, type),
|
||||||
|
},
|
||||||
|
FocasMessageKind.WriteResponse,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return resp.StatusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_connected) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resp = await _ipc.CallAsync<ProbeRequest, ProbeResponse>(
|
||||||
|
FocasMessageKind.ProbeRequest,
|
||||||
|
new ProbeRequest { SessionId = _sessionId },
|
||||||
|
FocasMessageKind.ProbeResponse,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
return resp.Healthy;
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_connected)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ipc.SendOneWayAsync(FocasMessageKind.CloseSessionRequest,
|
||||||
|
new CloseSessionRequest { SessionId = _sessionId }, CancellationToken.None)
|
||||||
|
.GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
catch { /* best effort */ }
|
||||||
|
_connected = false;
|
||||||
|
}
|
||||||
|
_ipc.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FocasAddressDto ToDto(FocasAddress addr) => new()
|
||||||
|
{
|
||||||
|
Kind = (int)addr.Kind,
|
||||||
|
PmcLetter = addr.PmcLetter,
|
||||||
|
Number = addr.Number,
|
||||||
|
BitIndex = addr.BitIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static byte[]? EncodeValue(object? value, FocasDataType type)
|
||||||
|
{
|
||||||
|
if (value is null) return null;
|
||||||
|
return type switch
|
||||||
|
{
|
||||||
|
FocasDataType.Bit => MessagePackSerializer.Serialize(Convert.ToBoolean(value)),
|
||||||
|
FocasDataType.Byte => MessagePackSerializer.Serialize(Convert.ToByte(value)),
|
||||||
|
FocasDataType.Int16 => MessagePackSerializer.Serialize(Convert.ToInt16(value)),
|
||||||
|
FocasDataType.Int32 => MessagePackSerializer.Serialize(Convert.ToInt32(value)),
|
||||||
|
FocasDataType.Float32 => MessagePackSerializer.Serialize(Convert.ToSingle(value)),
|
||||||
|
FocasDataType.Float64 => MessagePackSerializer.Serialize(Convert.ToDouble(value)),
|
||||||
|
FocasDataType.String => MessagePackSerializer.Serialize(Convert.ToString(value) ?? string.Empty),
|
||||||
|
_ => MessagePackSerializer.Serialize(Convert.ToInt32(value)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? DecodeValue(byte[]? bytes, int typeCode)
|
||||||
|
{
|
||||||
|
if (bytes is null) return null;
|
||||||
|
return typeCode switch
|
||||||
|
{
|
||||||
|
FocasDataTypeCode.Bit => MessagePackSerializer.Deserialize<bool>(bytes),
|
||||||
|
FocasDataTypeCode.Byte => MessagePackSerializer.Deserialize<byte>(bytes),
|
||||||
|
FocasDataTypeCode.Int16 => MessagePackSerializer.Deserialize<short>(bytes),
|
||||||
|
FocasDataTypeCode.Int32 => MessagePackSerializer.Deserialize<int>(bytes),
|
||||||
|
FocasDataTypeCode.Float32 => MessagePackSerializer.Deserialize<float>(bytes),
|
||||||
|
FocasDataTypeCode.Float64 => MessagePackSerializer.Deserialize<double>(bytes),
|
||||||
|
FocasDataTypeCode.String => MessagePackSerializer.Deserialize<string>(bytes),
|
||||||
|
_ => MessagePackSerializer.Deserialize<int>(bytes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Factory producing <see cref="IpcFocasClient"/>s. One pipe connection per
|
||||||
|
/// <c>IFocasClient</c> — matches the driver's one-client-per-device invariant. The
|
||||||
|
/// deployment wires this into the DI container in place of
|
||||||
|
/// <see cref="UnimplementedFocasClientFactory"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class IpcFocasClientFactory(Func<FocasIpcClient> ipcClientFactory, FocasCncSeries series = FocasCncSeries.Unknown)
|
||||||
|
: IFocasClientFactory
|
||||||
|
{
|
||||||
|
public IFocasClient Create() => new IpcFocasClient(ipcClientFactory(), series);
|
||||||
|
}
|
||||||
30
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/Backoff.cs
Normal file
30
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Supervisor/Backoff.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Respawn-with-backoff schedule for the FOCAS Host process. Matches Galaxy Tier-C:
|
||||||
|
/// 5s → 15s → 60s cap. A sustained stable run (default 2 min) resets the index so a
|
||||||
|
/// one-off crash after hours of steady-state doesn't start from the top of the ladder.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Backoff
|
||||||
|
{
|
||||||
|
public static TimeSpan[] DefaultSequence { get; } =
|
||||||
|
[TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(60)];
|
||||||
|
|
||||||
|
public TimeSpan StableRunThreshold { get; init; } = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
|
private readonly TimeSpan[] _sequence;
|
||||||
|
private int _index;
|
||||||
|
|
||||||
|
public Backoff(TimeSpan[]? sequence = null) => _sequence = sequence ?? DefaultSequence;
|
||||||
|
|
||||||
|
public TimeSpan Next()
|
||||||
|
{
|
||||||
|
var delay = _sequence[Math.Min(_index, _sequence.Length - 1)];
|
||||||
|
_index++;
|
||||||
|
return delay;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordStableRun() => _index = 0;
|
||||||
|
|
||||||
|
public int AttemptIndex => _index;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Crash-loop circuit breaker for the FOCAS Host. Matches Galaxy Tier-C defaults:
|
||||||
|
/// 3 crashes within 5 minutes opens the breaker; cooldown escalates 1h → 4h → manual
|
||||||
|
/// reset. A sticky alert stays live until the operator explicitly clears it so
|
||||||
|
/// recurring crashes can't silently burn through the cooldown ladder overnight.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CircuitBreaker
|
||||||
|
{
|
||||||
|
public int CrashesAllowedPerWindow { get; init; } = 3;
|
||||||
|
public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
public TimeSpan[] CooldownEscalation { get; init; } =
|
||||||
|
[TimeSpan.FromHours(1), TimeSpan.FromHours(4), TimeSpan.MaxValue];
|
||||||
|
|
||||||
|
private readonly List<DateTime> _crashesUtc = [];
|
||||||
|
private DateTime? _openSinceUtc;
|
||||||
|
private int _escalationLevel;
|
||||||
|
public bool StickyAlertActive { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a crash + returns <c>true</c> if the supervisor may respawn. On
|
||||||
|
/// <c>false</c>, <paramref name="cooldownRemaining"/> is how long to wait before
|
||||||
|
/// trying again (<c>TimeSpan.MaxValue</c> means manual reset required).
|
||||||
|
/// </summary>
|
||||||
|
public bool TryRecordCrash(DateTime utcNow, out TimeSpan cooldownRemaining)
|
||||||
|
{
|
||||||
|
if (_openSinceUtc is { } openedAt)
|
||||||
|
{
|
||||||
|
var cooldown = CooldownEscalation[Math.Min(_escalationLevel, CooldownEscalation.Length - 1)];
|
||||||
|
if (cooldown == TimeSpan.MaxValue)
|
||||||
|
{
|
||||||
|
cooldownRemaining = TimeSpan.MaxValue;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (utcNow - openedAt < cooldown)
|
||||||
|
{
|
||||||
|
cooldownRemaining = cooldown - (utcNow - openedAt);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_openSinceUtc = null;
|
||||||
|
_escalationLevel++;
|
||||||
|
}
|
||||||
|
|
||||||
|
_crashesUtc.RemoveAll(t => utcNow - t > Window);
|
||||||
|
_crashesUtc.Add(utcNow);
|
||||||
|
|
||||||
|
if (_crashesUtc.Count > CrashesAllowedPerWindow)
|
||||||
|
{
|
||||||
|
_openSinceUtc = utcNow;
|
||||||
|
StickyAlertActive = true;
|
||||||
|
cooldownRemaining = CooldownEscalation[Math.Min(_escalationLevel, CooldownEscalation.Length - 1)];
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
cooldownRemaining = TimeSpan.Zero;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ManualReset()
|
||||||
|
{
|
||||||
|
_crashesUtc.Clear();
|
||||||
|
_openSinceUtc = null;
|
||||||
|
_escalationLevel = 0;
|
||||||
|
StickyAlertActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ties <see cref="IHostProcessLauncher"/> + <see cref="Backoff"/> +
|
||||||
|
/// <see cref="CircuitBreaker"/> + <see cref="HeartbeatMonitor"/> into one object the
|
||||||
|
/// driver asks for <c>IFocasClient</c>s. On a detected crash (process exit or
|
||||||
|
/// heartbeat loss) the supervisor fans out <c>BadCommunicationError</c> to all
|
||||||
|
/// subscribers via the <see cref="OnUnavailable"/> callback, then respawns with
|
||||||
|
/// backoff unless the breaker is open.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The supervisor itself is I/O-free — it doesn't know how to spawn processes, probe
|
||||||
|
/// pipes, or send heartbeats. Production wires the concrete
|
||||||
|
/// <see cref="IHostProcessLauncher"/> over <c>FocasIpcClient</c> + <c>Process</c>;
|
||||||
|
/// tests drive the same state machine with a deterministic launcher stub.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class FocasHostSupervisor : IDisposable
|
||||||
|
{
|
||||||
|
private readonly IHostProcessLauncher _launcher;
|
||||||
|
private readonly Backoff _backoff;
|
||||||
|
private readonly CircuitBreaker _breaker;
|
||||||
|
private readonly Func<DateTime> _clock;
|
||||||
|
private IFocasClient? _current;
|
||||||
|
private DateTime _currentStartedUtc;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public FocasHostSupervisor(
|
||||||
|
IHostProcessLauncher launcher,
|
||||||
|
Backoff? backoff = null,
|
||||||
|
CircuitBreaker? breaker = null,
|
||||||
|
Func<DateTime>? clock = null)
|
||||||
|
{
|
||||||
|
_launcher = launcher ?? throw new ArgumentNullException(nameof(launcher));
|
||||||
|
_backoff = backoff ?? new Backoff();
|
||||||
|
_breaker = breaker ?? new CircuitBreaker();
|
||||||
|
_clock = clock ?? (() => DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Raised with a short reason string whenever the Host goes unavailable (crash / heartbeat loss / breaker-open).</summary>
|
||||||
|
public event Action<string>? OnUnavailable;
|
||||||
|
|
||||||
|
/// <summary>Crash count observed in the current process lifetime. Exposed for /hosts Admin telemetry.</summary>
|
||||||
|
public int ObservedCrashes { get; private set; }
|
||||||
|
|
||||||
|
/// <summary><c>true</c> if the crash-loop breaker has latched a sticky alert that needs operator reset.</summary>
|
||||||
|
public bool StickyAlertActive => _breaker.StickyAlertActive;
|
||||||
|
|
||||||
|
public int BackoffAttempt => _backoff.AttemptIndex;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the current live client. If none, tries to launch — applying the
|
||||||
|
/// backoff schedule between attempts and stopping once the breaker opens.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IFocasClient> GetOrLaunchAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
if (_current is not null && _launcher.IsProcessAlive) return _current;
|
||||||
|
|
||||||
|
return await LaunchWithBackoffAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called by the heartbeat task each time a miss threshold is crossed.
|
||||||
|
/// Treated as a crash: fan out Bad status + attempt respawn.
|
||||||
|
/// </summary>
|
||||||
|
public async Task NotifyHostDeadAsync(string reason, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
OnUnavailable?.Invoke(reason);
|
||||||
|
ObservedCrashes++;
|
||||||
|
try { await _launcher.TerminateAsync(ct).ConfigureAwait(false); }
|
||||||
|
catch { /* best effort */ }
|
||||||
|
_current?.Dispose();
|
||||||
|
_current = null;
|
||||||
|
|
||||||
|
if (!_breaker.TryRecordCrash(_clock(), out var cooldown))
|
||||||
|
{
|
||||||
|
OnUnavailable?.Invoke(cooldown == TimeSpan.MaxValue
|
||||||
|
? "circuit-breaker-open-manual-reset-required"
|
||||||
|
: $"circuit-breaker-open-cooldown-{cooldown:g}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Successful crash recording — do not respawn synchronously; GetOrLaunchAsync will
|
||||||
|
// pick up the attempt on the next call. Keeps the fan-out fast.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Operator action — clear the sticky alert + reset the breaker.</summary>
|
||||||
|
public void AcknowledgeAndReset()
|
||||||
|
{
|
||||||
|
_breaker.ManualReset();
|
||||||
|
_backoff.RecordStableRun();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IFocasClient> LaunchWithBackoffAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (_breaker.StickyAlertActive)
|
||||||
|
{
|
||||||
|
if (!_breaker.TryRecordCrash(_clock(), out var cooldown) && cooldown == TimeSpan.MaxValue)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"FOCAS Host circuit breaker is open and awaiting manual reset. " +
|
||||||
|
"See Admin /hosts; call AcknowledgeAndReset after investigating the Host log.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_current = await _launcher.LaunchAsync(ct).ConfigureAwait(false);
|
||||||
|
_currentStartedUtc = _clock();
|
||||||
|
|
||||||
|
// If the launch sequence itself takes long enough to count as a stable run,
|
||||||
|
// reset the backoff ladder immediately.
|
||||||
|
if (_clock() - _currentStartedUtc >= _backoff.StableRunThreshold)
|
||||||
|
_backoff.RecordStableRun();
|
||||||
|
|
||||||
|
return _current;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
OnUnavailable?.Invoke($"launch-failed: {ex.Message}");
|
||||||
|
ObservedCrashes++;
|
||||||
|
if (!_breaker.TryRecordCrash(_clock(), out var cooldown))
|
||||||
|
{
|
||||||
|
var hint = cooldown == TimeSpan.MaxValue
|
||||||
|
? "manual reset required"
|
||||||
|
: $"cooldown {cooldown:g}";
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"FOCAS Host circuit breaker opened after {ObservedCrashes} crashes — {hint}.", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
var delay = _backoff.Next();
|
||||||
|
await Task.Delay(delay, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Called from the heartbeat loop after a successful ack run — relaxes the backoff ladder.</summary>
|
||||||
|
public void NotifyStableRun()
|
||||||
|
{
|
||||||
|
if (_current is null) return;
|
||||||
|
if (_clock() - _currentStartedUtc >= _backoff.StableRunThreshold)
|
||||||
|
_backoff.RecordStableRun();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
try { _launcher.TerminateAsync(CancellationToken.None).GetAwaiter().GetResult(); }
|
||||||
|
catch { /* best effort */ }
|
||||||
|
_current?.Dispose();
|
||||||
|
_current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ThrowIfDisposed()
|
||||||
|
{
|
||||||
|
if (_disposed) throw new ObjectDisposedException(nameof(FocasHostSupervisor));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks missed heartbeats from the FOCAS Host. 2s cadence + 3 consecutive misses =
|
||||||
|
/// host declared dead (~6s detection). Same defaults as Galaxy Tier-C so operators
|
||||||
|
/// see the same cadence across hosts on the /hosts Admin page.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HeartbeatMonitor
|
||||||
|
{
|
||||||
|
public int MissesUntilDead { get; init; } = 3;
|
||||||
|
|
||||||
|
public TimeSpan Cadence { get; init; } = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
public int ConsecutiveMisses { get; private set; }
|
||||||
|
public DateTime? LastAckUtc { get; private set; }
|
||||||
|
|
||||||
|
public void RecordAck(DateTime utcNow)
|
||||||
|
{
|
||||||
|
ConsecutiveMisses = 0;
|
||||||
|
LastAckUtc = utcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Records a missed heartbeat; returns <c>true</c> when the death threshold is crossed.</summary>
|
||||||
|
public bool RecordMiss()
|
||||||
|
{
|
||||||
|
ConsecutiveMisses++;
|
||||||
|
return ConsecutiveMisses >= MissesUntilDead;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abstraction over the act of spawning a FOCAS Host process and obtaining an
|
||||||
|
/// <see cref="IFocasClient"/> connected to it. Production wires this to a real
|
||||||
|
/// <c>Process.Start</c> + <c>FocasIpcClient.ConnectAsync</c>; tests use a fake that
|
||||||
|
/// exposes deterministic failure modes so the supervisor logic can be stressed
|
||||||
|
/// without spawning actual exes.
|
||||||
|
/// </summary>
|
||||||
|
public interface IHostProcessLauncher
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Spawn a new Host process (if one isn't already running) and return a live
|
||||||
|
/// client session. Throws on unrecoverable errors; transient errors (e.g. Host
|
||||||
|
/// not ready yet) should throw <see cref="TimeoutException"/> so the supervisor
|
||||||
|
/// applies the backoff ladder.
|
||||||
|
/// </summary>
|
||||||
|
Task<IFocasClient> LaunchAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Terminate the Host process if one is running. Called on Dispose and after a
|
||||||
|
/// heartbeat loss is detected.
|
||||||
|
/// </summary>
|
||||||
|
Task TerminateAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>true</c> when the most recently spawned Host process is still alive.
|
||||||
|
/// Supervisor polls this at heartbeat cadence; going <c>false</c> without a
|
||||||
|
/// clean shutdown counts as a crash.
|
||||||
|
/// </summary>
|
||||||
|
bool IsProcessAlive { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System.IO.MemoryMappedFiles;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Proxy-side reader for the Host's post-mortem MMF. After a Host crash the supervisor
|
||||||
|
/// opens the file (which persists beyond the process lifetime) and enumerates the last
|
||||||
|
/// few thousand IPC operations that were in flight. Format matches
|
||||||
|
/// <c>Driver.FOCAS.Host.Stability.PostMortemMmf</c> — magic 'OFPC' / 256-byte entries.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PostMortemReader
|
||||||
|
{
|
||||||
|
private const int Magic = 0x4F465043; // 'OFPC'
|
||||||
|
private const int HeaderBytes = 16;
|
||||||
|
private const int EntryBytes = 256;
|
||||||
|
private const int MessageOffset = 16;
|
||||||
|
private const int MessageCapacity = EntryBytes - MessageOffset;
|
||||||
|
|
||||||
|
public string Path { get; }
|
||||||
|
|
||||||
|
public PostMortemReader(string path) => Path = path;
|
||||||
|
|
||||||
|
public PostMortemEntry[] ReadAll()
|
||||||
|
{
|
||||||
|
if (!File.Exists(Path)) return [];
|
||||||
|
|
||||||
|
using var mmf = MemoryMappedFile.CreateFromFile(Path, FileMode.Open, null, 0, MemoryMappedFileAccess.Read);
|
||||||
|
using var accessor = mmf.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read);
|
||||||
|
|
||||||
|
if (accessor.ReadInt32(0) != Magic) return [];
|
||||||
|
|
||||||
|
var capacity = accessor.ReadInt32(8);
|
||||||
|
var writeIndex = accessor.ReadInt32(12);
|
||||||
|
var entries = new PostMortemEntry[capacity];
|
||||||
|
var count = 0;
|
||||||
|
|
||||||
|
for (var i = 0; i < capacity; i++)
|
||||||
|
{
|
||||||
|
var slot = (writeIndex + i) % capacity;
|
||||||
|
var offset = HeaderBytes + slot * EntryBytes;
|
||||||
|
var ts = accessor.ReadInt64(offset + 0);
|
||||||
|
if (ts == 0) continue;
|
||||||
|
var op = accessor.ReadInt64(offset + 8);
|
||||||
|
var msgBuf = new byte[MessageCapacity];
|
||||||
|
accessor.ReadArray(offset + MessageOffset, msgBuf, 0, MessageCapacity);
|
||||||
|
var nulTerm = Array.IndexOf<byte>(msgBuf, 0);
|
||||||
|
var msg = Encoding.UTF8.GetString(msgBuf, 0, nulTerm < 0 ? MessageCapacity : nulTerm);
|
||||||
|
entries[count++] = new PostMortemEntry(ts, op, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.Resize(ref entries, count);
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct PostMortemEntry(long UtcUnixMs, long OpKind, string Message);
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Production <see cref="IHostProcessLauncher"/>. Spawns <c>OtOpcUa.Driver.FOCAS.Host.exe</c>
|
||||||
|
/// with the pipe name / allowed-SID / per-spawn shared secret in the environment, waits for
|
||||||
|
/// the pipe to come up, then connects a <see cref="FocasIpcClient"/> and wraps it in an
|
||||||
|
/// <see cref="IpcFocasClient"/>. On <see cref="TerminateAsync"/> best-effort kills the
|
||||||
|
/// process and closes the IPC stream.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProcessHostLauncher : IHostProcessLauncher
|
||||||
|
{
|
||||||
|
private readonly ProcessHostLauncherOptions _options;
|
||||||
|
private Process? _process;
|
||||||
|
private FocasIpcClient? _ipc;
|
||||||
|
|
||||||
|
public ProcessHostLauncher(ProcessHostLauncherOptions options)
|
||||||
|
{
|
||||||
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsProcessAlive => _process is { HasExited: false };
|
||||||
|
|
||||||
|
public async Task<IFocasClient> LaunchAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
await TerminateAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var secret = _options.SharedSecret ?? Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = _options.HostExePath,
|
||||||
|
Arguments = _options.Arguments ?? string.Empty,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
};
|
||||||
|
psi.Environment["OTOPCUA_FOCAS_PIPE"] = _options.PipeName;
|
||||||
|
psi.Environment["OTOPCUA_ALLOWED_SID"] = _options.AllowedSid;
|
||||||
|
psi.Environment["OTOPCUA_FOCAS_SECRET"] = secret;
|
||||||
|
psi.Environment["OTOPCUA_FOCAS_BACKEND"] = _options.Backend;
|
||||||
|
|
||||||
|
_process = Process.Start(psi)
|
||||||
|
?? throw new InvalidOperationException($"Failed to start {_options.HostExePath}");
|
||||||
|
|
||||||
|
// Poll for pipe readiness up to the configured connect timeout.
|
||||||
|
var deadline = DateTime.UtcNow + _options.ConnectTimeout;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (_process.HasExited)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"FOCAS Host exited before pipe was ready (ExitCode={_process.ExitCode}).");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ipc = await FocasIpcClient.ConnectAsync(
|
||||||
|
_options.PipeName, secret, TimeSpan.FromSeconds(1), ct).ConfigureAwait(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
if (DateTime.UtcNow >= deadline)
|
||||||
|
throw new TimeoutException(
|
||||||
|
$"FOCAS Host pipe {_options.PipeName} did not come up within {_options.ConnectTimeout:g}.");
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(250), ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new IpcFocasClient(_ipc, _options.Series);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task TerminateAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_ipc is not null)
|
||||||
|
{
|
||||||
|
try { await _ipc.DisposeAsync().ConfigureAwait(false); }
|
||||||
|
catch { /* best effort */ }
|
||||||
|
_ipc = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_process is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_process.HasExited)
|
||||||
|
{
|
||||||
|
_process.Kill(entireProcessTree: true);
|
||||||
|
await _process.WaitForExitAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* best effort */ }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_process.Dispose();
|
||||||
|
_process = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ProcessHostLauncherOptions(
|
||||||
|
string HostExePath,
|
||||||
|
string PipeName,
|
||||||
|
string AllowedSid)
|
||||||
|
{
|
||||||
|
public string? SharedSecret { get; init; }
|
||||||
|
public string? Arguments { get; init; }
|
||||||
|
public string Backend { get; init; } = "fwlib32";
|
||||||
|
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(15);
|
||||||
|
public FocasCncSeries Series { get; init; } = FocasCncSeries.Unknown;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
||||||
|
|
||||||
|
public sealed class FakeUpstream : ITagUpstreamSource
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, DataValueSnapshot> _values = new(StringComparer.Ordinal);
|
||||||
|
private readonly ConcurrentDictionary<string, List<Action<string, DataValueSnapshot>>> _subs
|
||||||
|
= new(StringComparer.Ordinal);
|
||||||
|
public int ActiveSubscriptionCount { get; private set; }
|
||||||
|
|
||||||
|
public void Set(string path, object? value, uint statusCode = 0u)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
_values[path] = new DataValueSnapshot(value, statusCode, now, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Push(string path, object? value, uint statusCode = 0u)
|
||||||
|
{
|
||||||
|
Set(path, value, statusCode);
|
||||||
|
if (_subs.TryGetValue(path, out var list))
|
||||||
|
{
|
||||||
|
Action<string, DataValueSnapshot>[] snap;
|
||||||
|
lock (list) { snap = list.ToArray(); }
|
||||||
|
foreach (var obs in snap) obs(path, _values[path]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DataValueSnapshot ReadTag(string path)
|
||||||
|
=> _values.TryGetValue(path, out var v) ? v
|
||||||
|
: new DataValueSnapshot(null, 0x80340000u, null, DateTime.UtcNow);
|
||||||
|
|
||||||
|
public IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer)
|
||||||
|
{
|
||||||
|
var list = _subs.GetOrAdd(path, _ => []);
|
||||||
|
lock (list) { list.Add(observer); }
|
||||||
|
ActiveSubscriptionCount++;
|
||||||
|
return new Unsub(this, path, observer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Unsub : IDisposable
|
||||||
|
{
|
||||||
|
private readonly FakeUpstream _up;
|
||||||
|
private readonly string _path;
|
||||||
|
private readonly Action<string, DataValueSnapshot> _observer;
|
||||||
|
public Unsub(FakeUpstream up, string path, Action<string, DataValueSnapshot> observer)
|
||||||
|
{ _up = up; _path = path; _observer = observer; }
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_up._subs.TryGetValue(_path, out var list))
|
||||||
|
{
|
||||||
|
lock (list)
|
||||||
|
{
|
||||||
|
if (list.Remove(_observer)) _up.ActiveSubscriptionCount--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class MessageTemplateTests
|
||||||
|
{
|
||||||
|
private static DataValueSnapshot Good(object? v) =>
|
||||||
|
new(v, 0u, DateTime.UtcNow, DateTime.UtcNow);
|
||||||
|
private static DataValueSnapshot Bad() =>
|
||||||
|
new(null, 0x80050000u, null, DateTime.UtcNow);
|
||||||
|
|
||||||
|
private static DataValueSnapshot? Resolver(Dictionary<string, DataValueSnapshot> map, string path)
|
||||||
|
=> map.TryGetValue(path, out var v) ? v : null;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void No_tokens_returns_template_unchanged()
|
||||||
|
{
|
||||||
|
MessageTemplate.Resolve("No tokens here", _ => null).ShouldBe("No tokens here");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Single_token_substituted()
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, DataValueSnapshot> { ["Tank/Temp"] = Good(75.5) };
|
||||||
|
MessageTemplate.Resolve("Temp={Tank/Temp}C", p => Resolver(map, p)).ShouldBe("Temp=75.5C");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Multiple_tokens_substituted()
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, DataValueSnapshot>
|
||||||
|
{
|
||||||
|
["A"] = Good(10),
|
||||||
|
["B"] = Good("on"),
|
||||||
|
};
|
||||||
|
MessageTemplate.Resolve("{A}/{B}", p => Resolver(map, p)).ShouldBe("10/on");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bad_quality_token_becomes_question_mark()
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, DataValueSnapshot> { ["Bad"] = Bad() };
|
||||||
|
MessageTemplate.Resolve("value={Bad}", p => Resolver(map, p)).ShouldBe("value={?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_path_becomes_question_mark()
|
||||||
|
{
|
||||||
|
MessageTemplate.Resolve("value={DoesNotExist}", _ => null).ShouldBe("value={?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Null_value_with_good_quality_becomes_question_mark()
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, DataValueSnapshot> { ["X"] = Good(null) };
|
||||||
|
MessageTemplate.Resolve("{X}", p => Resolver(map, p)).ShouldBe("{?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tokens_with_slashes_and_dots_resolved()
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, DataValueSnapshot>
|
||||||
|
{
|
||||||
|
["Line1/Pump.Speed"] = Good(1200),
|
||||||
|
};
|
||||||
|
MessageTemplate.Resolve("rpm={Line1/Pump.Speed}", p => Resolver(map, p))
|
||||||
|
.ShouldBe("rpm=1200");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Empty_template_returns_empty()
|
||||||
|
{
|
||||||
|
MessageTemplate.Resolve("", _ => null).ShouldBe("");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Null_template_returns_empty_without_throwing()
|
||||||
|
{
|
||||||
|
MessageTemplate.Resolve(null!, _ => null).ShouldBe("");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractTokenPaths_returns_every_distinct_token()
|
||||||
|
{
|
||||||
|
var tokens = MessageTemplate.ExtractTokenPaths("{A}/{B}/{A}/{C}");
|
||||||
|
tokens.ShouldBe(new[] { "A", "B", "A", "C" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractTokenPaths_empty_for_tokenless_template()
|
||||||
|
{
|
||||||
|
MessageTemplate.ExtractTokenPaths("No tokens").ShouldBeEmpty();
|
||||||
|
MessageTemplate.ExtractTokenPaths("").ShouldBeEmpty();
|
||||||
|
MessageTemplate.ExtractTokenPaths(null).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Whitespace_inside_token_is_trimmed()
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, DataValueSnapshot> { ["A"] = Good(42) };
|
||||||
|
MessageTemplate.Resolve("{ A }", p => Resolver(map, p)).ShouldBe("42");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure state-machine tests — no engine, no I/O, no async. Every transition rule
|
||||||
|
/// from Phase 7 plan Stream C.2 / C.3 has at least one locking test so regressions
|
||||||
|
/// surface as clear failures rather than subtle alarm-behavior drift.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class Part9StateMachineTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
private static AlarmConditionState Fresh() => AlarmConditionState.Fresh("alarm-1", T0);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Predicate_true_on_inactive_becomes_active_and_emits_Activated()
|
||||||
|
{
|
||||||
|
var r = Part9StateMachine.ApplyPredicate(Fresh(), predicateTrue: true, T0.AddSeconds(1));
|
||||||
|
r.State.Active.ShouldBe(AlarmActiveState.Active);
|
||||||
|
r.State.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
|
||||||
|
r.State.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed);
|
||||||
|
r.Emission.ShouldBe(EmissionKind.Activated);
|
||||||
|
r.State.LastActiveUtc.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Predicate_false_on_active_becomes_inactive_and_emits_Cleared()
|
||||||
|
{
|
||||||
|
var active = Part9StateMachine.ApplyPredicate(Fresh(), true, T0.AddSeconds(1)).State;
|
||||||
|
var r = Part9StateMachine.ApplyPredicate(active, false, T0.AddSeconds(2));
|
||||||
|
r.State.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||||
|
r.Emission.ShouldBe(EmissionKind.Cleared);
|
||||||
|
r.State.LastClearedUtc.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Predicate_unchanged_state_emits_None()
|
||||||
|
{
|
||||||
|
var r = Part9StateMachine.ApplyPredicate(Fresh(), false, T0);
|
||||||
|
r.Emission.ShouldBe(EmissionKind.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Disabled_alarm_ignores_predicate()
|
||||||
|
{
|
||||||
|
var disabled = Part9StateMachine.ApplyDisable(Fresh(), "op1", T0.AddSeconds(1)).State;
|
||||||
|
var r = Part9StateMachine.ApplyPredicate(disabled, true, T0.AddSeconds(2));
|
||||||
|
r.State.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||||
|
r.Emission.ShouldBe(EmissionKind.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Acknowledge_from_unacked_records_user_and_emits()
|
||||||
|
{
|
||||||
|
var active = Part9StateMachine.ApplyPredicate(Fresh(), true, T0.AddSeconds(1)).State;
|
||||||
|
var r = Part9StateMachine.ApplyAcknowledge(active, "alice", "looking into it", T0.AddSeconds(2));
|
||||||
|
r.State.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||||
|
r.State.LastAckUser.ShouldBe("alice");
|
||||||
|
r.State.LastAckComment.ShouldBe("looking into it");
|
||||||
|
r.State.Comments.Count.ShouldBe(1);
|
||||||
|
r.Emission.ShouldBe(EmissionKind.Acknowledged);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Acknowledge_when_already_acked_is_noop()
|
||||||
|
{
|
||||||
|
var active = Part9StateMachine.ApplyPredicate(Fresh(), true, T0.AddSeconds(1)).State;
|
||||||
|
var acked = Part9StateMachine.ApplyAcknowledge(active, "alice", null, T0.AddSeconds(2)).State;
|
||||||
|
var r = Part9StateMachine.ApplyAcknowledge(acked, "alice", null, T0.AddSeconds(3));
|
||||||
|
r.Emission.ShouldBe(EmissionKind.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Acknowledge_without_user_throws()
|
||||||
|
{
|
||||||
|
Should.Throw<ArgumentException>(() =>
|
||||||
|
Part9StateMachine.ApplyAcknowledge(Fresh(), "", null, T0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Confirm_after_clear_records_user_and_emits()
|
||||||
|
{
|
||||||
|
// Walk: activate -> ack -> clear -> confirm
|
||||||
|
var s = Fresh();
|
||||||
|
s = Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(1)).State;
|
||||||
|
s = Part9StateMachine.ApplyAcknowledge(s, "alice", null, T0.AddSeconds(2)).State;
|
||||||
|
s = Part9StateMachine.ApplyPredicate(s, false, T0.AddSeconds(3)).State;
|
||||||
|
|
||||||
|
var r = Part9StateMachine.ApplyConfirm(s, "bob", "resolved", T0.AddSeconds(4));
|
||||||
|
r.State.Confirmed.ShouldBe(AlarmConfirmedState.Confirmed);
|
||||||
|
r.State.LastConfirmUser.ShouldBe("bob");
|
||||||
|
r.Emission.ShouldBe(EmissionKind.Confirmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OneShotShelve_suppresses_next_activation_emission()
|
||||||
|
{
|
||||||
|
var s = Part9StateMachine.ApplyOneShotShelve(Fresh(), "alice", T0.AddSeconds(1)).State;
|
||||||
|
var r = Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(2));
|
||||||
|
r.State.Active.ShouldBe(AlarmActiveState.Active, "state still advances");
|
||||||
|
r.Emission.ShouldBe(EmissionKind.Suppressed, "but subscribers don't see it");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OneShotShelve_expires_on_clear()
|
||||||
|
{
|
||||||
|
var s = Fresh();
|
||||||
|
s = Part9StateMachine.ApplyOneShotShelve(s, "alice", T0.AddSeconds(1)).State;
|
||||||
|
s = Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(2)).State;
|
||||||
|
var r = Part9StateMachine.ApplyPredicate(s, false, T0.AddSeconds(3));
|
||||||
|
r.State.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved, "OneShot expires on clear");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TimedShelve_requires_future_unshelve_time()
|
||||||
|
{
|
||||||
|
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||||
|
Part9StateMachine.ApplyTimedShelve(Fresh(), "alice", T0, T0.AddSeconds(5)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TimedShelve_expires_via_shelving_check()
|
||||||
|
{
|
||||||
|
var until = T0.AddMinutes(5);
|
||||||
|
var shelved = Part9StateMachine.ApplyTimedShelve(Fresh(), "alice", until, T0).State;
|
||||||
|
shelved.Shelving.Kind.ShouldBe(ShelvingKind.Timed);
|
||||||
|
|
||||||
|
// Before expiry — still shelved.
|
||||||
|
var earlier = Part9StateMachine.ApplyShelvingCheck(shelved, T0.AddMinutes(3));
|
||||||
|
earlier.State.Shelving.Kind.ShouldBe(ShelvingKind.Timed);
|
||||||
|
earlier.Emission.ShouldBe(EmissionKind.None);
|
||||||
|
|
||||||
|
// After expiry — auto-unshelved + emission.
|
||||||
|
var after = Part9StateMachine.ApplyShelvingCheck(shelved, T0.AddMinutes(6));
|
||||||
|
after.State.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
|
||||||
|
after.Emission.ShouldBe(EmissionKind.Unshelved);
|
||||||
|
after.State.Comments.Any(c => c.Kind == "AutoUnshelve").ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unshelve_from_unshelved_is_noop()
|
||||||
|
{
|
||||||
|
var r = Part9StateMachine.ApplyUnshelve(Fresh(), "alice", T0);
|
||||||
|
r.Emission.ShouldBe(EmissionKind.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Explicit_Unshelve_emits_event()
|
||||||
|
{
|
||||||
|
var s = Part9StateMachine.ApplyOneShotShelve(Fresh(), "alice", T0).State;
|
||||||
|
var r = Part9StateMachine.ApplyUnshelve(s, "bob", T0.AddSeconds(30));
|
||||||
|
r.State.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
|
||||||
|
r.Emission.ShouldBe(EmissionKind.Unshelved);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddComment_appends_to_audit_trail_with_event()
|
||||||
|
{
|
||||||
|
var r = Part9StateMachine.ApplyAddComment(Fresh(), "alice", "investigating", T0.AddSeconds(5));
|
||||||
|
r.State.Comments.Count.ShouldBe(1);
|
||||||
|
r.State.Comments[0].Kind.ShouldBe("AddComment");
|
||||||
|
r.State.Comments[0].User.ShouldBe("alice");
|
||||||
|
r.State.Comments[0].Text.ShouldBe("investigating");
|
||||||
|
r.Emission.ShouldBe(EmissionKind.CommentAdded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Comments_are_append_only_never_rewritten()
|
||||||
|
{
|
||||||
|
var s = Part9StateMachine.ApplyAddComment(Fresh(), "alice", "first", T0.AddSeconds(1)).State;
|
||||||
|
s = Part9StateMachine.ApplyAddComment(s, "bob", "second", T0.AddSeconds(2)).State;
|
||||||
|
s = Part9StateMachine.ApplyAddComment(s, "carol", "third", T0.AddSeconds(3)).State;
|
||||||
|
s.Comments.Count.ShouldBe(3);
|
||||||
|
s.Comments[0].User.ShouldBe("alice");
|
||||||
|
s.Comments[1].User.ShouldBe("bob");
|
||||||
|
s.Comments[2].User.ShouldBe("carol");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Full_lifecycle_walk_produces_every_expected_emission()
|
||||||
|
{
|
||||||
|
// Walk a condition through its whole lifecycle and make sure emissions line up.
|
||||||
|
var emissions = new List<EmissionKind>();
|
||||||
|
var s = Fresh();
|
||||||
|
|
||||||
|
s = Capture(Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(1)));
|
||||||
|
s = Capture(Part9StateMachine.ApplyAcknowledge(s, "alice", null, T0.AddSeconds(2)));
|
||||||
|
s = Capture(Part9StateMachine.ApplyAddComment(s, "alice", "need to investigate", T0.AddSeconds(3)));
|
||||||
|
s = Capture(Part9StateMachine.ApplyPredicate(s, false, T0.AddSeconds(4)));
|
||||||
|
s = Capture(Part9StateMachine.ApplyConfirm(s, "bob", null, T0.AddSeconds(5)));
|
||||||
|
|
||||||
|
emissions.ShouldBe(new[] {
|
||||||
|
EmissionKind.Activated,
|
||||||
|
EmissionKind.Acknowledged,
|
||||||
|
EmissionKind.CommentAdded,
|
||||||
|
EmissionKind.Cleared,
|
||||||
|
EmissionKind.Confirmed,
|
||||||
|
});
|
||||||
|
|
||||||
|
AlarmConditionState Capture(TransitionResult r) { emissions.Add(r.Emission); return r.State; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
using Serilog;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end engine tests: load, predicate evaluation, change-triggered
|
||||||
|
/// re-evaluation, state persistence, startup recovery, error isolation.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class ScriptedAlarmEngineTests
|
||||||
|
{
|
||||||
|
private static ScriptedAlarmEngine Build(FakeUpstream up, out IAlarmStateStore store)
|
||||||
|
{
|
||||||
|
store = new InMemoryAlarmStateStore();
|
||||||
|
var logger = new LoggerConfiguration().CreateLogger();
|
||||||
|
return new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ScriptedAlarmDefinition Alarm(string id, string predicate,
|
||||||
|
string msg = "condition", AlarmSeverity sev = AlarmSeverity.High) =>
|
||||||
|
new(AlarmId: id,
|
||||||
|
EquipmentPath: "Plant/Line1/Reactor",
|
||||||
|
AlarmName: id,
|
||||||
|
Kind: AlarmKind.AlarmCondition,
|
||||||
|
Severity: sev,
|
||||||
|
MessageTemplate: msg,
|
||||||
|
PredicateScriptSource: predicate);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Load_compiles_and_subscribes_to_referenced_upstreams()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("Temp", 50);
|
||||||
|
using var eng = Build(up, out _);
|
||||||
|
|
||||||
|
await eng.LoadAsync([Alarm("a1", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
eng.LoadedAlarmIds.ShouldContain("a1");
|
||||||
|
up.ActiveSubscriptionCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Compile_failures_aggregated_into_one_error()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
using var eng = Build(up, out _);
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||||
|
await eng.LoadAsync([
|
||||||
|
Alarm("bad1", "return unknownIdentifier;"),
|
||||||
|
Alarm("good", "return true;"),
|
||||||
|
Alarm("bad2", "var x = alsoUnknown; return x;"),
|
||||||
|
], TestContext.Current.CancellationToken));
|
||||||
|
ex.Message.ShouldContain("2 alarm(s) did not compile");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Upstream_change_re_evaluates_predicate_and_emits_Activated()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("Temp", 50);
|
||||||
|
using var eng = Build(up, out _);
|
||||||
|
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var events = new List<ScriptedAlarmEvent>();
|
||||||
|
eng.OnEvent += (_, e) => events.Add(e);
|
||||||
|
|
||||||
|
up.Push("Temp", 150);
|
||||||
|
await WaitForAsync(() => events.Count > 0);
|
||||||
|
|
||||||
|
events[0].AlarmId.ShouldBe("HighTemp");
|
||||||
|
events[0].Emission.ShouldBe(EmissionKind.Activated);
|
||||||
|
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Clearing_upstream_emits_Cleared_event()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("Temp", 150);
|
||||||
|
using var eng = Build(up, out _);
|
||||||
|
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// Startup sees 150 → active.
|
||||||
|
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);
|
||||||
|
|
||||||
|
var events = new List<ScriptedAlarmEvent>();
|
||||||
|
eng.OnEvent += (_, e) => events.Add(e);
|
||||||
|
|
||||||
|
up.Push("Temp", 50);
|
||||||
|
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Cleared));
|
||||||
|
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Message_template_resolves_tag_values_at_emission()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("Temp", 50);
|
||||||
|
up.Set("Limit", 100);
|
||||||
|
using var eng = Build(up, out _);
|
||||||
|
await eng.LoadAsync([
|
||||||
|
new ScriptedAlarmDefinition(
|
||||||
|
"HighTemp", "Plant/Line1", "HighTemp",
|
||||||
|
AlarmKind.LimitAlarm, AlarmSeverity.High,
|
||||||
|
"Temp {Temp}C exceeded limit {Limit}C",
|
||||||
|
"""return (int)ctx.GetTag("Temp").Value > (int)ctx.GetTag("Limit").Value;"""),
|
||||||
|
], TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var events = new List<ScriptedAlarmEvent>();
|
||||||
|
eng.OnEvent += (_, e) => events.Add(e);
|
||||||
|
|
||||||
|
up.Push("Temp", 150);
|
||||||
|
await WaitForAsync(() => events.Any());
|
||||||
|
|
||||||
|
events[0].Message.ShouldBe("Temp 150C exceeded limit 100C");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ack_records_user_and_persists_to_store()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("Temp", 150);
|
||||||
|
using var eng = Build(up, out var store);
|
||||||
|
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await eng.AcknowledgeAsync("HighTemp", "alice", "checking", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var persisted = await store.LoadAsync("HighTemp", TestContext.Current.CancellationToken);
|
||||||
|
persisted.ShouldNotBeNull();
|
||||||
|
persisted!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||||
|
persisted.LastAckUser.ShouldBe("alice");
|
||||||
|
persisted.LastAckComment.ShouldBe("checking");
|
||||||
|
persisted.Comments.Any(c => c.Kind == "Acknowledge" && c.User == "alice").ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Startup_recovery_preserves_ack_but_rederives_active_from_predicate()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("Temp", 50); // predicate will go false on second load
|
||||||
|
|
||||||
|
// First run — alarm goes active + operator acks.
|
||||||
|
using (var eng1 = Build(up, out var sharedStore))
|
||||||
|
{
|
||||||
|
up.Set("Temp", 150);
|
||||||
|
await eng1.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
eng1.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);
|
||||||
|
|
||||||
|
await eng1.AcknowledgeAsync("HighTemp", "alice", null, TestContext.Current.CancellationToken);
|
||||||
|
eng1.GetState("HighTemp")!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate restart — temp is back to 50 (below threshold).
|
||||||
|
up.Set("Temp", 50);
|
||||||
|
var logger = new LoggerConfiguration().CreateLogger();
|
||||||
|
var store2 = new InMemoryAlarmStateStore();
|
||||||
|
// seed store2 with the acked state from before restart
|
||||||
|
await store2.SaveAsync(new AlarmConditionState(
|
||||||
|
"HighTemp",
|
||||||
|
AlarmEnabledState.Enabled,
|
||||||
|
AlarmActiveState.Active, // was active pre-restart
|
||||||
|
AlarmAckedState.Acknowledged, // ack persisted
|
||||||
|
AlarmConfirmedState.Unconfirmed,
|
||||||
|
ShelvingState.Unshelved,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
DateTime.UtcNow, null,
|
||||||
|
DateTime.UtcNow, "alice", null,
|
||||||
|
null, null, null,
|
||||||
|
[new AlarmComment(DateTime.UtcNow, "alice", "Acknowledge", "")]),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
using var eng2 = new ScriptedAlarmEngine(up, store2, new ScriptLoggerFactory(logger), logger);
|
||||||
|
await eng2.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var s = eng2.GetState("HighTemp")!;
|
||||||
|
s.Active.ShouldBe(AlarmActiveState.Inactive, "Active recomputed from current tag value");
|
||||||
|
s.Acked.ShouldBe(AlarmAckedState.Acknowledged, "Ack persisted across restart");
|
||||||
|
s.LastAckUser.ShouldBe("alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Shelved_active_transitions_state_but_suppresses_emission()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("Temp", 50);
|
||||||
|
using var eng = Build(up, out _);
|
||||||
|
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await eng.OneShotShelveAsync("HighTemp", "alice", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var events = new List<ScriptedAlarmEvent>();
|
||||||
|
eng.OnEvent += (_, e) => events.Add(e);
|
||||||
|
|
||||||
|
up.Push("Temp", 150);
|
||||||
|
await Task.Delay(200);
|
||||||
|
|
||||||
|
events.Any(e => e.Emission == EmissionKind.Activated).ShouldBeFalse(
|
||||||
|
"OneShot shelve suppresses activation emission");
|
||||||
|
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active,
|
||||||
|
"state still advances so startup recovery is consistent");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Predicate_runtime_exception_does_not_transition_state()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("Temp", 150);
|
||||||
|
using var eng = Build(up, out _);
|
||||||
|
await eng.LoadAsync([
|
||||||
|
Alarm("BadScript", """throw new InvalidOperationException("boom");"""),
|
||||||
|
Alarm("GoodScript", """return (int)ctx.GetTag("Temp").Value > 100;"""),
|
||||||
|
], TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// Bad script doesn't activate + doesn't disable other alarms.
|
||||||
|
eng.GetState("BadScript")!.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||||
|
eng.GetState("GoodScript")!.Active.ShouldBe(AlarmActiveState.Active);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Disable_prevents_activation_until_re_enabled()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("Temp", 50);
|
||||||
|
using var eng = Build(up, out _);
|
||||||
|
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await eng.DisableAsync("HighTemp", "alice", TestContext.Current.CancellationToken);
|
||||||
|
up.Push("Temp", 150);
|
||||||
|
await Task.Delay(100);
|
||||||
|
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive,
|
||||||
|
"disabled alarm ignores predicate");
|
||||||
|
|
||||||
|
await eng.EnableAsync("HighTemp", "alice", TestContext.Current.CancellationToken);
|
||||||
|
up.Push("Temp", 160);
|
||||||
|
await WaitForAsync(() => eng.GetState("HighTemp")!.Active == AlarmActiveState.Active);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddComment_appends_to_audit_without_state_change()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("Temp", 50);
|
||||||
|
using var eng = Build(up, out var store);
|
||||||
|
await eng.LoadAsync([Alarm("A", """return false;""")], TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await eng.AddCommentAsync("A", "alice", "peeking at this", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var s = await store.LoadAsync("A", TestContext.Current.CancellationToken);
|
||||||
|
s.ShouldNotBeNull();
|
||||||
|
s!.Comments.Count.ShouldBe(1);
|
||||||
|
s.Comments[0].User.ShouldBe("alice");
|
||||||
|
s.Comments[0].Kind.ShouldBe("AddComment");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Predicate_scripts_cannot_SetVirtualTag()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("Temp", 100);
|
||||||
|
using var eng = Build(up, out _);
|
||||||
|
|
||||||
|
// The script compiles fine but throws at runtime when SetVirtualTag is called.
|
||||||
|
// The engine swallows the exception + leaves state unchanged.
|
||||||
|
await eng.LoadAsync([
|
||||||
|
new ScriptedAlarmDefinition(
|
||||||
|
"Bad", "Plant/Line1", "Bad",
|
||||||
|
AlarmKind.AlarmCondition, AlarmSeverity.High, "bad",
|
||||||
|
"""
|
||||||
|
ctx.SetVirtualTag("NotAllowed", 1);
|
||||||
|
return true;
|
||||||
|
"""),
|
||||||
|
], TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// Bad alarm's predicate threw — state unchanged.
|
||||||
|
eng.GetState("Bad")!.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dispose_releases_upstream_subscriptions()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("Temp", 50);
|
||||||
|
var eng = Build(up, out _);
|
||||||
|
await eng.LoadAsync([Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
up.ActiveSubscriptionCount.ShouldBe(1);
|
||||||
|
|
||||||
|
eng.Dispose();
|
||||||
|
up.ActiveSubscriptionCount.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForAsync(Func<bool> cond, int timeoutMs = 2000)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
if (cond()) return;
|
||||||
|
await Task.Delay(25);
|
||||||
|
}
|
||||||
|
throw new TimeoutException("Condition did not become true in time");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using Serilog;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class ScriptedAlarmSourceTests
|
||||||
|
{
|
||||||
|
private static async Task<(ScriptedAlarmEngine e, ScriptedAlarmSource s, FakeUpstream u)> BuildAsync()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("Temp", 50);
|
||||||
|
var logger = new LoggerConfiguration().CreateLogger();
|
||||||
|
var engine = new ScriptedAlarmEngine(up, new InMemoryAlarmStateStore(),
|
||||||
|
new ScriptLoggerFactory(logger), logger);
|
||||||
|
await engine.LoadAsync([
|
||||||
|
new ScriptedAlarmDefinition(
|
||||||
|
"Plant/Line1::HighTemp",
|
||||||
|
"Plant/Line1",
|
||||||
|
"HighTemp",
|
||||||
|
AlarmKind.LimitAlarm,
|
||||||
|
AlarmSeverity.High,
|
||||||
|
"Temp {Temp}C",
|
||||||
|
"""return (int)ctx.GetTag("Temp").Value > 100;"""),
|
||||||
|
new ScriptedAlarmDefinition(
|
||||||
|
"Plant/Line2::OtherAlarm",
|
||||||
|
"Plant/Line2",
|
||||||
|
"OtherAlarm",
|
||||||
|
AlarmKind.AlarmCondition,
|
||||||
|
AlarmSeverity.Low,
|
||||||
|
"other",
|
||||||
|
"""return false;"""),
|
||||||
|
], CancellationToken.None);
|
||||||
|
|
||||||
|
var source = new ScriptedAlarmSource(engine);
|
||||||
|
return (engine, source, up);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Subscribe_with_empty_filter_receives_every_alarm_emission()
|
||||||
|
{
|
||||||
|
var (engine, source, up) = await BuildAsync();
|
||||||
|
using var _e = engine;
|
||||||
|
using var _s = source;
|
||||||
|
|
||||||
|
var events = new List<AlarmEventArgs>();
|
||||||
|
source.OnAlarmEvent += (_, e) => events.Add(e);
|
||||||
|
var handle = await source.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
up.Push("Temp", 150);
|
||||||
|
await Task.Delay(200);
|
||||||
|
|
||||||
|
events.Count.ShouldBe(1);
|
||||||
|
events[0].ConditionId.ShouldBe("Plant/Line1::HighTemp");
|
||||||
|
events[0].SourceNodeId.ShouldBe("Plant/Line1");
|
||||||
|
events[0].Severity.ShouldBe(AlarmSeverity.High);
|
||||||
|
events[0].AlarmType.ShouldBe("LimitAlarm");
|
||||||
|
events[0].Message.ShouldBe("Temp 150C");
|
||||||
|
|
||||||
|
await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Subscribe_with_equipment_prefix_filters_by_that_prefix()
|
||||||
|
{
|
||||||
|
var (engine, source, up) = await BuildAsync();
|
||||||
|
using var _e = engine;
|
||||||
|
using var _s = source;
|
||||||
|
|
||||||
|
var events = new List<AlarmEventArgs>();
|
||||||
|
source.OnAlarmEvent += (_, e) => events.Add(e);
|
||||||
|
|
||||||
|
// Subscribe only to Line1 alarms.
|
||||||
|
var handle = await source.SubscribeAlarmsAsync(["Plant/Line1"], TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
up.Push("Temp", 150);
|
||||||
|
await Task.Delay(200);
|
||||||
|
|
||||||
|
events.Count.ShouldBe(1);
|
||||||
|
events[0].SourceNodeId.ShouldBe("Plant/Line1");
|
||||||
|
|
||||||
|
await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unsubscribe_stops_further_events()
|
||||||
|
{
|
||||||
|
var (engine, source, up) = await BuildAsync();
|
||||||
|
using var _e = engine;
|
||||||
|
using var _s = source;
|
||||||
|
|
||||||
|
var events = new List<AlarmEventArgs>();
|
||||||
|
source.OnAlarmEvent += (_, e) => events.Add(e);
|
||||||
|
var handle = await source.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken);
|
||||||
|
await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
up.Push("Temp", 150);
|
||||||
|
await Task.Delay(200);
|
||||||
|
|
||||||
|
events.Count.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AcknowledgeAsync_routes_to_engine_with_default_user()
|
||||||
|
{
|
||||||
|
var (engine, source, up) = await BuildAsync();
|
||||||
|
using var _e = engine;
|
||||||
|
using var _s = source;
|
||||||
|
|
||||||
|
up.Push("Temp", 150);
|
||||||
|
await Task.Delay(200);
|
||||||
|
engine.GetState("Plant/Line1::HighTemp")!.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
|
||||||
|
|
||||||
|
await source.AcknowledgeAsync([new AlarmAcknowledgeRequest(
|
||||||
|
"Plant/Line1", "Plant/Line1::HighTemp", "ack via opcua")],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var state = engine.GetState("Plant/Line1::HighTemp")!;
|
||||||
|
state.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||||
|
state.LastAckUser.ShouldBe("opcua-client");
|
||||||
|
state.LastAckComment.ShouldBe("ack via opcua");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Null_arguments_rejected()
|
||||||
|
{
|
||||||
|
var (engine, source, _) = await BuildAsync();
|
||||||
|
using var _e = engine;
|
||||||
|
using var _s = source;
|
||||||
|
|
||||||
|
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||||
|
await source.SubscribeAlarmsAsync(null!, TestContext.Current.CancellationToken));
|
||||||
|
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||||
|
await source.UnsubscribeAlarmsAsync(null!, TestContext.Current.CancellationToken));
|
||||||
|
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||||
|
await source.AcknowledgeAsync(null!, TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exercises the source-hash keyed compile cache. Roslyn compilation is the most
|
||||||
|
/// expensive step in the evaluator pipeline; this cache collapses redundant
|
||||||
|
/// compiles of unchanged scripts to zero-cost lookups + makes sure concurrent
|
||||||
|
/// callers never double-compile.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class CompiledScriptCacheTests
|
||||||
|
{
|
||||||
|
private sealed class CompileCountingGate
|
||||||
|
{
|
||||||
|
public int Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void First_call_compiles_and_caches()
|
||||||
|
{
|
||||||
|
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||||
|
cache.Count.ShouldBe(0);
|
||||||
|
|
||||||
|
var e = cache.GetOrCompile("""return 42;""");
|
||||||
|
e.ShouldNotBeNull();
|
||||||
|
cache.Count.ShouldBe(1);
|
||||||
|
cache.Contains("""return 42;""").ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Identical_source_returns_the_same_compiled_evaluator()
|
||||||
|
{
|
||||||
|
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||||
|
var first = cache.GetOrCompile("""return 1;""");
|
||||||
|
var second = cache.GetOrCompile("""return 1;""");
|
||||||
|
ReferenceEquals(first, second).ShouldBeTrue();
|
||||||
|
cache.Count.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Different_source_produces_different_evaluator()
|
||||||
|
{
|
||||||
|
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||||
|
var a = cache.GetOrCompile("""return 1;""");
|
||||||
|
var b = cache.GetOrCompile("""return 2;""");
|
||||||
|
ReferenceEquals(a, b).ShouldBeFalse();
|
||||||
|
cache.Count.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Whitespace_difference_misses_cache()
|
||||||
|
{
|
||||||
|
// Documented behavior: reformatting a script recompiles. Simpler + cheaper
|
||||||
|
// than the alternative (AST-canonicalize then hash) and doesn't happen often.
|
||||||
|
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||||
|
cache.GetOrCompile("""return 1;""");
|
||||||
|
cache.GetOrCompile("return 1; "); // trailing whitespace — different hash
|
||||||
|
cache.Count.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cached_evaluator_still_runs_correctly()
|
||||||
|
{
|
||||||
|
var cache = new CompiledScriptCache<FakeScriptContext, double>();
|
||||||
|
var e = cache.GetOrCompile("""return (double)ctx.GetTag("In").Value * 3.0;""");
|
||||||
|
var ctx = new FakeScriptContext().Seed("In", 7.0);
|
||||||
|
|
||||||
|
// Run twice through the cache — both must return the same correct value.
|
||||||
|
var first = await e.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||||
|
var second = await cache.GetOrCompile("""return (double)ctx.GetTag("In").Value * 3.0;""")
|
||||||
|
.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||||
|
first.ShouldBe(21.0);
|
||||||
|
second.ShouldBe(21.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Failed_compile_is_evicted_so_retry_with_corrected_source_works()
|
||||||
|
{
|
||||||
|
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||||
|
|
||||||
|
// First attempt — undefined identifier, compile throws.
|
||||||
|
Should.Throw<Exception>(() => cache.GetOrCompile("""return unknownIdentifier + 1;"""));
|
||||||
|
cache.Count.ShouldBe(0, "failed compile must be evicted so retry can re-attempt");
|
||||||
|
|
||||||
|
// Retry with corrected source succeeds + caches.
|
||||||
|
cache.GetOrCompile("""return 42;""").ShouldNotBeNull();
|
||||||
|
cache.Count.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Clear_drops_every_entry()
|
||||||
|
{
|
||||||
|
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||||
|
cache.GetOrCompile("""return 1;""");
|
||||||
|
cache.GetOrCompile("""return 2;""");
|
||||||
|
cache.Count.ShouldBe(2);
|
||||||
|
|
||||||
|
cache.Clear();
|
||||||
|
cache.Count.ShouldBe(0);
|
||||||
|
cache.Contains("""return 1;""").ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Concurrent_compiles_of_the_same_source_deduplicate()
|
||||||
|
{
|
||||||
|
// LazyThreadSafetyMode.ExecutionAndPublication guarantees only one compile
|
||||||
|
// even when multiple threads race GetOrCompile against an empty cache.
|
||||||
|
// We can't directly count Roslyn compilations — but we can assert all
|
||||||
|
// concurrent callers see the same evaluator instance.
|
||||||
|
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||||
|
const string src = """return 99;""";
|
||||||
|
|
||||||
|
var tasks = Enumerable.Range(0, 20)
|
||||||
|
.Select(_ => Task.Run(() => cache.GetOrCompile(src)))
|
||||||
|
.ToArray();
|
||||||
|
Task.WhenAll(tasks).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
var firstInstance = tasks[0].Result;
|
||||||
|
foreach (var t in tasks)
|
||||||
|
ReferenceEquals(t.Result, firstInstance).ShouldBeTrue();
|
||||||
|
cache.Count.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Different_TContext_TResult_pairs_use_separate_cache_instances()
|
||||||
|
{
|
||||||
|
// Documented: each engine (virtual-tag / alarm-predicate / alarm-action) owns
|
||||||
|
// its own cache. The type-parametric design makes this the default without
|
||||||
|
// cross-contamination at the dictionary level.
|
||||||
|
var intCache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||||
|
var boolCache = new CompiledScriptCache<FakeScriptContext, bool>();
|
||||||
|
|
||||||
|
intCache.GetOrCompile("""return 1;""");
|
||||||
|
boolCache.GetOrCompile("""return true;""");
|
||||||
|
|
||||||
|
intCache.Count.ShouldBe(1);
|
||||||
|
boolCache.Count.ShouldBe(1);
|
||||||
|
intCache.Contains("""return true;""").ShouldBeFalse();
|
||||||
|
boolCache.Contains("""return 1;""").ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Null_source_throws_ArgumentNullException()
|
||||||
|
{
|
||||||
|
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||||
|
Should.Throw<ArgumentNullException>(() => cache.GetOrCompile(null!));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exercises the AST walker that extracts static tag dependencies from user scripts
|
||||||
|
/// + rejects every form of non-literal path. Locks the parse shape the virtual-tag
|
||||||
|
/// engine's change-trigger scheduler will depend on (Phase 7 plan Stream A.2).
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class DependencyExtractorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Extracts_single_literal_read()
|
||||||
|
{
|
||||||
|
var result = DependencyExtractor.Extract(
|
||||||
|
"""return ctx.GetTag("Line1/Speed").Value;""");
|
||||||
|
|
||||||
|
result.IsValid.ShouldBeTrue();
|
||||||
|
result.Reads.ShouldContain("Line1/Speed");
|
||||||
|
result.Writes.ShouldBeEmpty();
|
||||||
|
result.Rejections.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Extracts_multiple_distinct_reads()
|
||||||
|
{
|
||||||
|
var result = DependencyExtractor.Extract(
|
||||||
|
"""
|
||||||
|
var a = ctx.GetTag("Line1/A").Value;
|
||||||
|
var b = ctx.GetTag("Line1/B").Value;
|
||||||
|
return (double)a + (double)b;
|
||||||
|
""");
|
||||||
|
result.IsValid.ShouldBeTrue();
|
||||||
|
result.Reads.Count.ShouldBe(2);
|
||||||
|
result.Reads.ShouldContain("Line1/A");
|
||||||
|
result.Reads.ShouldContain("Line1/B");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Deduplicates_identical_reads_across_the_script()
|
||||||
|
{
|
||||||
|
var result = DependencyExtractor.Extract(
|
||||||
|
"""
|
||||||
|
if (((double)ctx.GetTag("X").Value) > 0)
|
||||||
|
return ctx.GetTag("X").Value;
|
||||||
|
return 0;
|
||||||
|
""");
|
||||||
|
result.IsValid.ShouldBeTrue();
|
||||||
|
result.Reads.Count.ShouldBe(1);
|
||||||
|
result.Reads.ShouldContain("X");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tracks_virtual_tag_writes_separately_from_reads()
|
||||||
|
{
|
||||||
|
var result = DependencyExtractor.Extract(
|
||||||
|
"""
|
||||||
|
var v = (double)ctx.GetTag("InTag").Value;
|
||||||
|
ctx.SetVirtualTag("OutTag", v * 2);
|
||||||
|
return v;
|
||||||
|
""");
|
||||||
|
result.IsValid.ShouldBeTrue();
|
||||||
|
result.Reads.ShouldContain("InTag");
|
||||||
|
result.Writes.ShouldContain("OutTag");
|
||||||
|
result.Reads.ShouldNotContain("OutTag");
|
||||||
|
result.Writes.ShouldNotContain("InTag");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rejects_variable_path()
|
||||||
|
{
|
||||||
|
var result = DependencyExtractor.Extract(
|
||||||
|
"""
|
||||||
|
var path = "Line1/Speed";
|
||||||
|
return ctx.GetTag(path).Value;
|
||||||
|
""");
|
||||||
|
result.IsValid.ShouldBeFalse();
|
||||||
|
result.Rejections.Count.ShouldBe(1);
|
||||||
|
result.Rejections[0].Message.ShouldContain("string literal");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rejects_concatenated_path()
|
||||||
|
{
|
||||||
|
var result = DependencyExtractor.Extract(
|
||||||
|
"""return ctx.GetTag("Line1/" + "Speed").Value;""");
|
||||||
|
result.IsValid.ShouldBeFalse();
|
||||||
|
result.Rejections[0].Message.ShouldContain("string literal");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rejects_interpolated_path()
|
||||||
|
{
|
||||||
|
var result = DependencyExtractor.Extract(
|
||||||
|
"""
|
||||||
|
var n = 1;
|
||||||
|
return ctx.GetTag($"Line{n}/Speed").Value;
|
||||||
|
""");
|
||||||
|
result.IsValid.ShouldBeFalse();
|
||||||
|
result.Rejections[0].Message.ShouldContain("string literal");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rejects_method_returned_path()
|
||||||
|
{
|
||||||
|
var result = DependencyExtractor.Extract(
|
||||||
|
"""
|
||||||
|
string BuildPath() => "Line1/Speed";
|
||||||
|
return ctx.GetTag(BuildPath()).Value;
|
||||||
|
""");
|
||||||
|
result.IsValid.ShouldBeFalse();
|
||||||
|
result.Rejections[0].Message.ShouldContain("string literal");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rejects_empty_literal_path()
|
||||||
|
{
|
||||||
|
var result = DependencyExtractor.Extract(
|
||||||
|
"""return ctx.GetTag("").Value;""");
|
||||||
|
result.IsValid.ShouldBeFalse();
|
||||||
|
result.Rejections[0].Message.ShouldContain("empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rejects_whitespace_only_path()
|
||||||
|
{
|
||||||
|
var result = DependencyExtractor.Extract(
|
||||||
|
"""return ctx.GetTag(" ").Value;""");
|
||||||
|
result.IsValid.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ignores_non_ctx_method_named_GetTag()
|
||||||
|
{
|
||||||
|
// Scripts are free to define their own helper called "GetTag" — as long as it's
|
||||||
|
// not on the ctx instance, the extractor doesn't pick it up. The sandbox
|
||||||
|
// compile will still reject any path that isn't on the ScriptContext type.
|
||||||
|
var result = DependencyExtractor.Extract(
|
||||||
|
"""
|
||||||
|
string helper_GetTag(string p) => p;
|
||||||
|
return helper_GetTag("NotATag");
|
||||||
|
""");
|
||||||
|
result.IsValid.ShouldBeTrue();
|
||||||
|
result.Reads.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Empty_source_is_a_no_op()
|
||||||
|
{
|
||||||
|
DependencyExtractor.Extract("").IsValid.ShouldBeTrue();
|
||||||
|
DependencyExtractor.Extract(" ").IsValid.ShouldBeTrue();
|
||||||
|
DependencyExtractor.Extract(null!).IsValid.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rejection_carries_source_span_for_UI_pointing()
|
||||||
|
{
|
||||||
|
// Offending path at column 23-29 in the source — Admin UI uses Span to
|
||||||
|
// underline the exact token.
|
||||||
|
const string src = """return ctx.GetTag(path).Value;""";
|
||||||
|
var result = DependencyExtractor.Extract(src);
|
||||||
|
result.IsValid.ShouldBeFalse();
|
||||||
|
result.Rejections[0].Span.Start.ShouldBeGreaterThan(0);
|
||||||
|
result.Rejections[0].Span.Length.ShouldBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Multiple_bad_paths_all_reported_in_one_pass()
|
||||||
|
{
|
||||||
|
var result = DependencyExtractor.Extract(
|
||||||
|
"""
|
||||||
|
var p1 = "A"; var p2 = "B";
|
||||||
|
return ctx.GetTag(p1).Value.ToString() + ctx.GetTag(p2).Value.ToString();
|
||||||
|
""");
|
||||||
|
result.IsValid.ShouldBeFalse();
|
||||||
|
result.Rejections.Count.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Nested_literal_GetTag_inside_expression_is_extracted()
|
||||||
|
{
|
||||||
|
// Supports patterns like ctx.GetTag("A") > ctx.GetTag("B") — both literal args
|
||||||
|
// are captured even when the enclosing expression is complex.
|
||||||
|
var result = DependencyExtractor.Extract(
|
||||||
|
"""
|
||||||
|
return ((double)ctx.GetTag("A").Value) > ((double)ctx.GetTag("B").Value);
|
||||||
|
""");
|
||||||
|
result.IsValid.ShouldBeTrue();
|
||||||
|
result.Reads.Count.ShouldBe(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using Serilog;
|
||||||
|
using Serilog.Core;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory <see cref="ScriptContext"/> for tests. Holds a tag dictionary + a write
|
||||||
|
/// log + a deterministic clock. Concrete subclasses in production will wire
|
||||||
|
/// GetTag/SetVirtualTag through the virtual-tag engine + driver dispatch; here they
|
||||||
|
/// hit a plain dictionary.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FakeScriptContext : ScriptContext
|
||||||
|
{
|
||||||
|
public Dictionary<string, DataValueSnapshot> Tags { get; } = new(StringComparer.Ordinal);
|
||||||
|
public List<(string Path, object? Value)> Writes { get; } = [];
|
||||||
|
|
||||||
|
public override DateTime Now { get; } = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
public override ILogger Logger { get; } = new LoggerConfiguration().CreateLogger();
|
||||||
|
|
||||||
|
public override DataValueSnapshot GetTag(string path)
|
||||||
|
{
|
||||||
|
return Tags.TryGetValue(path, out var v)
|
||||||
|
? v
|
||||||
|
: new DataValueSnapshot(null, 0x80340000u, null, Now); // BadNodeIdUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void SetVirtualTag(string path, object? value)
|
||||||
|
{
|
||||||
|
Writes.Add((path, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public FakeScriptContext Seed(string path, object? value,
|
||||||
|
uint statusCode = 0u, DateTime? sourceTs = null)
|
||||||
|
{
|
||||||
|
Tags[path] = new DataValueSnapshot(value, statusCode, sourceTs ?? Now, Now);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
using Serilog;
|
||||||
|
using Serilog.Core;
|
||||||
|
using Serilog.Events;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the sink that mirrors script Error+ events to the main log at Warning
|
||||||
|
/// level. Ensures script noise (Debug/Info/Warning) doesn't reach the main log
|
||||||
|
/// while genuine script failures DO surface there so operators see them without
|
||||||
|
/// watching a separate log file.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class ScriptLogCompanionSinkTests
|
||||||
|
{
|
||||||
|
private sealed class CapturingSink : ILogEventSink
|
||||||
|
{
|
||||||
|
public List<LogEvent> Events { get; } = [];
|
||||||
|
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (ILogger script, CapturingSink scriptSink, CapturingSink mainSink) BuildPipeline()
|
||||||
|
{
|
||||||
|
// Main logger captures companion forwards.
|
||||||
|
var mainSink = new CapturingSink();
|
||||||
|
var mainLogger = new LoggerConfiguration()
|
||||||
|
.MinimumLevel.Verbose().WriteTo.Sink(mainSink).CreateLogger();
|
||||||
|
|
||||||
|
// Script logger fans out to scripts file (here: capture sink) + the companion sink.
|
||||||
|
var scriptSink = new CapturingSink();
|
||||||
|
var scriptLogger = new LoggerConfiguration()
|
||||||
|
.MinimumLevel.Verbose()
|
||||||
|
.WriteTo.Sink(scriptSink)
|
||||||
|
.WriteTo.Sink(new ScriptLogCompanionSink(mainLogger))
|
||||||
|
.CreateLogger();
|
||||||
|
|
||||||
|
return (scriptLogger, scriptSink, mainSink);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Info_event_lands_in_scripts_sink_but_not_in_main()
|
||||||
|
{
|
||||||
|
var (script, scriptSink, mainSink) = BuildPipeline();
|
||||||
|
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "Test").Information("just info");
|
||||||
|
|
||||||
|
scriptSink.Events.Count.ShouldBe(1);
|
||||||
|
mainSink.Events.Count.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Warning_event_lands_in_scripts_sink_but_not_in_main()
|
||||||
|
{
|
||||||
|
var (script, scriptSink, mainSink) = BuildPipeline();
|
||||||
|
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "Test").Warning("just a warning");
|
||||||
|
|
||||||
|
scriptSink.Events.Count.ShouldBe(1);
|
||||||
|
mainSink.Events.Count.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Error_event_mirrored_to_main_at_Warning_level()
|
||||||
|
{
|
||||||
|
var (script, scriptSink, mainSink) = BuildPipeline();
|
||||||
|
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "MyAlarm")
|
||||||
|
.Error("condition script failed");
|
||||||
|
|
||||||
|
scriptSink.Events[0].Level.ShouldBe(LogEventLevel.Error);
|
||||||
|
mainSink.Events.Count.ShouldBe(1);
|
||||||
|
mainSink.Events[0].Level.ShouldBe(LogEventLevel.Warning, "Error+ is downgraded to Warning in the main log");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Mirrored_event_includes_ScriptName_and_original_level()
|
||||||
|
{
|
||||||
|
var (script, _, mainSink) = BuildPipeline();
|
||||||
|
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "HighTemp")
|
||||||
|
.Error("temp exceeded limit");
|
||||||
|
|
||||||
|
var forwarded = mainSink.Events[0];
|
||||||
|
forwarded.Properties.ShouldContainKey("ScriptName");
|
||||||
|
((ScalarValue)forwarded.Properties["ScriptName"]).Value.ShouldBe("HighTemp");
|
||||||
|
forwarded.Properties.ShouldContainKey("OriginalLevel");
|
||||||
|
((ScalarValue)forwarded.Properties["OriginalLevel"]).Value.ShouldBe(LogEventLevel.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Mirrored_event_preserves_exception_for_main_log_stack_trace()
|
||||||
|
{
|
||||||
|
var (script, _, mainSink) = BuildPipeline();
|
||||||
|
var ex = new InvalidOperationException("user code threw");
|
||||||
|
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "BadScript").Error(ex, "boom");
|
||||||
|
|
||||||
|
mainSink.Events.Count.ShouldBe(1);
|
||||||
|
mainSink.Events[0].Exception.ShouldBeSameAs(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Fatal_event_mirrored_just_like_Error()
|
||||||
|
{
|
||||||
|
var (script, _, mainSink) = BuildPipeline();
|
||||||
|
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "Fatal_Script").Fatal("catastrophic");
|
||||||
|
mainSink.Events.Count.ShouldBe(1);
|
||||||
|
mainSink.Events[0].Level.ShouldBe(LogEventLevel.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Missing_ScriptName_property_falls_back_to_unknown()
|
||||||
|
{
|
||||||
|
var (_, _, mainSink) = BuildPipeline();
|
||||||
|
// Log without the ScriptName property to simulate a direct root-logger call
|
||||||
|
// that bypassed the factory (defensive — shouldn't normally happen).
|
||||||
|
var mainLogger = new LoggerConfiguration().CreateLogger();
|
||||||
|
var companion = new ScriptLogCompanionSink(Log.Logger);
|
||||||
|
|
||||||
|
// Build an event manually so we can omit the property.
|
||||||
|
var ev = new LogEvent(
|
||||||
|
timestamp: DateTimeOffset.UtcNow,
|
||||||
|
level: LogEventLevel.Error,
|
||||||
|
exception: null,
|
||||||
|
messageTemplate: new Serilog.Parsing.MessageTemplateParser().Parse("naked error"),
|
||||||
|
properties: []);
|
||||||
|
// Direct test: sink should not throw + message should be well-formed.
|
||||||
|
Should.NotThrow(() => companion.Emit(ev));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Null_main_logger_rejected()
|
||||||
|
{
|
||||||
|
Should.Throw<ArgumentNullException>(() => new ScriptLogCompanionSink(null!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Custom_mirror_threshold_applied()
|
||||||
|
{
|
||||||
|
// Caller can raise the mirror threshold to Fatal if they want only
|
||||||
|
// catastrophic events in the main log.
|
||||||
|
var mainSink = new CapturingSink();
|
||||||
|
var mainLogger = new LoggerConfiguration()
|
||||||
|
.MinimumLevel.Verbose().WriteTo.Sink(mainSink).CreateLogger();
|
||||||
|
|
||||||
|
var scriptLogger = new LoggerConfiguration()
|
||||||
|
.MinimumLevel.Verbose()
|
||||||
|
.WriteTo.Sink(new ScriptLogCompanionSink(mainLogger, LogEventLevel.Fatal))
|
||||||
|
.CreateLogger();
|
||||||
|
|
||||||
|
scriptLogger.ForContext(ScriptLoggerFactory.ScriptNameProperty, "X").Error("error");
|
||||||
|
mainSink.Events.Count.ShouldBe(0, "Error below configured Fatal threshold — not mirrored");
|
||||||
|
|
||||||
|
scriptLogger.ForContext(ScriptLoggerFactory.ScriptNameProperty, "X").Fatal("fatal");
|
||||||
|
mainSink.Events.Count.ShouldBe(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using Serilog;
|
||||||
|
using Serilog.Core;
|
||||||
|
using Serilog.Events;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exercises the factory that creates per-script Serilog loggers with the
|
||||||
|
/// <c>ScriptName</c> structured property pre-bound. The property is what lets
|
||||||
|
/// Admin UI filter the scripts-*.log sink by which tag/alarm emitted each event.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class ScriptLoggerFactoryTests
|
||||||
|
{
|
||||||
|
/// <summary>Capturing sink that collects every emitted LogEvent for assertion.</summary>
|
||||||
|
private sealed class CapturingSink : ILogEventSink
|
||||||
|
{
|
||||||
|
public List<LogEvent> Events { get; } = [];
|
||||||
|
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_sets_ScriptName_structured_property()
|
||||||
|
{
|
||||||
|
var sink = new CapturingSink();
|
||||||
|
var root = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger();
|
||||||
|
var factory = new ScriptLoggerFactory(root);
|
||||||
|
|
||||||
|
var logger = factory.Create("LineRate");
|
||||||
|
logger.Information("hello");
|
||||||
|
|
||||||
|
sink.Events.Count.ShouldBe(1);
|
||||||
|
var ev = sink.Events[0];
|
||||||
|
ev.Properties.ShouldContainKey(ScriptLoggerFactory.ScriptNameProperty);
|
||||||
|
((ScalarValue)ev.Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("LineRate");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Each_script_gets_its_own_property_value()
|
||||||
|
{
|
||||||
|
var sink = new CapturingSink();
|
||||||
|
var root = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger();
|
||||||
|
var factory = new ScriptLoggerFactory(root);
|
||||||
|
|
||||||
|
factory.Create("Alarm_A").Information("event A");
|
||||||
|
factory.Create("Tag_B").Warning("event B");
|
||||||
|
factory.Create("Alarm_A").Error("event A again");
|
||||||
|
|
||||||
|
sink.Events.Count.ShouldBe(3);
|
||||||
|
((ScalarValue)sink.Events[0].Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("Alarm_A");
|
||||||
|
((ScalarValue)sink.Events[1].Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("Tag_B");
|
||||||
|
((ScalarValue)sink.Events[2].Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("Alarm_A");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Error_level_event_preserves_level_and_exception()
|
||||||
|
{
|
||||||
|
var sink = new CapturingSink();
|
||||||
|
var root = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger();
|
||||||
|
var factory = new ScriptLoggerFactory(root);
|
||||||
|
|
||||||
|
factory.Create("Test").Error(new InvalidOperationException("boom"), "script failed");
|
||||||
|
|
||||||
|
sink.Events[0].Level.ShouldBe(LogEventLevel.Error);
|
||||||
|
sink.Events[0].Exception.ShouldBeOfType<InvalidOperationException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Null_root_rejected()
|
||||||
|
{
|
||||||
|
Should.Throw<ArgumentNullException>(() => new ScriptLoggerFactory(null!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Empty_script_name_rejected()
|
||||||
|
{
|
||||||
|
var root = new LoggerConfiguration().CreateLogger();
|
||||||
|
var factory = new ScriptLoggerFactory(root);
|
||||||
|
Should.Throw<ArgumentException>(() => factory.Create(""));
|
||||||
|
Should.Throw<ArgumentException>(() => factory.Create(" "));
|
||||||
|
Should.Throw<ArgumentException>(() => factory.Create(null!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScriptNameProperty_constant_is_stable()
|
||||||
|
{
|
||||||
|
// Stability is an external contract — the Admin UI's log filter references
|
||||||
|
// this exact string. If it changes, the filter breaks silently.
|
||||||
|
ScriptLoggerFactory.ScriptNameProperty.ShouldBe("ScriptName");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
using Microsoft.CodeAnalysis.Scripting;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compiles scripts against the Phase 7 sandbox + asserts every forbidden API
|
||||||
|
/// (HttpClient / File / Process / reflection) fails at compile, not at evaluation.
|
||||||
|
/// Locks decision #6 — scripts can't escape to the broader .NET surface.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class ScriptSandboxTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Happy_path_script_compiles_and_returns()
|
||||||
|
{
|
||||||
|
// Baseline — ctx + Math + basic types must work.
|
||||||
|
var evaluator = ScriptEvaluator<FakeScriptContext, double>.Compile(
|
||||||
|
"""
|
||||||
|
var v = (double)ctx.GetTag("X").Value;
|
||||||
|
return Math.Abs(v) * 2.0;
|
||||||
|
""");
|
||||||
|
evaluator.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Happy_path_script_runs_and_reads_seeded_tag()
|
||||||
|
{
|
||||||
|
var evaluator = ScriptEvaluator<FakeScriptContext, double>.Compile(
|
||||||
|
"""return (double)ctx.GetTag("In").Value * 2.0;""");
|
||||||
|
|
||||||
|
var ctx = new FakeScriptContext().Seed("In", 21.0);
|
||||||
|
var result = await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||||
|
result.ShouldBe(42.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetVirtualTag_records_the_write()
|
||||||
|
{
|
||||||
|
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||||
|
"""
|
||||||
|
ctx.SetVirtualTag("Out", 42);
|
||||||
|
return 0;
|
||||||
|
""");
|
||||||
|
var ctx = new FakeScriptContext();
|
||||||
|
await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||||
|
ctx.Writes.Count.ShouldBe(1);
|
||||||
|
ctx.Writes[0].Path.ShouldBe("Out");
|
||||||
|
ctx.Writes[0].Value.ShouldBe(42);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rejects_File_IO_at_compile()
|
||||||
|
{
|
||||||
|
Should.Throw<ScriptSandboxViolationException>(() =>
|
||||||
|
ScriptEvaluator<FakeScriptContext, string>.Compile(
|
||||||
|
"""return System.IO.File.ReadAllText("c:/secrets.txt");"""));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rejects_HttpClient_at_compile()
|
||||||
|
{
|
||||||
|
Should.Throw<ScriptSandboxViolationException>(() =>
|
||||||
|
ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||||
|
"""
|
||||||
|
var c = new System.Net.Http.HttpClient();
|
||||||
|
return 0;
|
||||||
|
"""));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rejects_Process_Start_at_compile()
|
||||||
|
{
|
||||||
|
Should.Throw<ScriptSandboxViolationException>(() =>
|
||||||
|
ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||||
|
"""
|
||||||
|
System.Diagnostics.Process.Start("cmd.exe");
|
||||||
|
return 0;
|
||||||
|
"""));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rejects_Reflection_Assembly_Load_at_compile()
|
||||||
|
{
|
||||||
|
Should.Throw<ScriptSandboxViolationException>(() =>
|
||||||
|
ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||||
|
"""
|
||||||
|
System.Reflection.Assembly.Load("System.Core");
|
||||||
|
return 0;
|
||||||
|
"""));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rejects_Environment_GetEnvironmentVariable_at_compile()
|
||||||
|
{
|
||||||
|
// Environment lives in System.Private.CoreLib (allow-listed for primitives) —
|
||||||
|
// BUT calling .GetEnvironmentVariable exposes process state we don't want in
|
||||||
|
// scripts. In an allow-list sandbox this passes because mscorlib is allowed;
|
||||||
|
// relying on ScriptSandbox alone isn't enough for the Environment class. We
|
||||||
|
// document here that the CURRENT sandbox allows Environment — acceptable because
|
||||||
|
// Environment doesn't leak outside the process boundary, doesn't side-effect
|
||||||
|
// persistent state, and Phase 7 plan decision #6 targets File/Net/Process/
|
||||||
|
// reflection specifically.
|
||||||
|
//
|
||||||
|
// This test LOCKS that compromise: operators should not be surprised if a
|
||||||
|
// script reads an env var. If we later decide to tighten, this test flips.
|
||||||
|
var evaluator = ScriptEvaluator<FakeScriptContext, string?>.Compile(
|
||||||
|
"""return System.Environment.GetEnvironmentVariable("PATH");""");
|
||||||
|
evaluator.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Script_exception_propagates_unwrapped()
|
||||||
|
{
|
||||||
|
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||||
|
"""throw new InvalidOperationException("boom");""");
|
||||||
|
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||||
|
await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ctx_Now_is_available_without_DateTime_UtcNow_reaching_wall_clock()
|
||||||
|
{
|
||||||
|
// Scripts that need a timestamp go through ctx.Now so tests can pin it.
|
||||||
|
var evaluator = ScriptEvaluator<FakeScriptContext, DateTime>.Compile("""return ctx.Now;""");
|
||||||
|
evaluator.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Deadband_helper_is_reachable_from_scripts()
|
||||||
|
{
|
||||||
|
var evaluator = ScriptEvaluator<FakeScriptContext, bool>.Compile(
|
||||||
|
"""return ScriptContext.Deadband(10.5, 10.0, 0.3);""");
|
||||||
|
evaluator.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Linq_Enumerable_is_available_from_scripts()
|
||||||
|
{
|
||||||
|
// LINQ is in the allow-list because SCADA math frequently wants Sum / Average
|
||||||
|
// / Where. Confirm it works.
|
||||||
|
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||||
|
"""
|
||||||
|
var nums = new[] { 1, 2, 3, 4, 5 };
|
||||||
|
return nums.Where(n => n > 2).Sum();
|
||||||
|
""");
|
||||||
|
var result = await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken);
|
||||||
|
result.ShouldBe(12);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DataValueSnapshot_is_usable_in_scripts()
|
||||||
|
{
|
||||||
|
// ctx.GetTag returns DataValueSnapshot so scripts branch on quality.
|
||||||
|
var evaluator = ScriptEvaluator<FakeScriptContext, bool>.Compile(
|
||||||
|
"""
|
||||||
|
var v = ctx.GetTag("T");
|
||||||
|
return v.StatusCode == 0;
|
||||||
|
""");
|
||||||
|
var ctx = new FakeScriptContext().Seed("T", 5.0);
|
||||||
|
var result = await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||||
|
result.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compile_error_gives_location_in_diagnostics()
|
||||||
|
{
|
||||||
|
// Compile errors must carry the source span so the Admin UI can point at them.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ScriptEvaluator<FakeScriptContext, int>.Compile("""return fooBarBaz + 1;""");
|
||||||
|
Assert.Fail("expected CompilationErrorException");
|
||||||
|
}
|
||||||
|
catch (CompilationErrorException ex)
|
||||||
|
{
|
||||||
|
ex.Diagnostics.ShouldNotBeEmpty();
|
||||||
|
ex.Diagnostics[0].Location.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the per-evaluation timeout wrapper. Fast scripts complete normally;
|
||||||
|
/// CPU-bound or hung scripts throw <see cref="ScriptTimeoutException"/> instead of
|
||||||
|
/// starving the engine. Caller-supplied cancellation tokens take precedence over the
|
||||||
|
/// timeout so driver-shutdown paths see a clean cancel rather than a timeout.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class TimedScriptEvaluatorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Fast_script_completes_under_timeout_and_returns_value()
|
||||||
|
{
|
||||||
|
var inner = ScriptEvaluator<FakeScriptContext, double>.Compile(
|
||||||
|
"""return (double)ctx.GetTag("In").Value + 1.0;""");
|
||||||
|
var timed = new TimedScriptEvaluator<FakeScriptContext, double>(
|
||||||
|
inner, TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
var ctx = new FakeScriptContext().Seed("In", 41.0);
|
||||||
|
var result = await timed.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||||
|
result.ShouldBe(42.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Script_longer_than_timeout_throws_ScriptTimeoutException()
|
||||||
|
{
|
||||||
|
// Scripts can't easily do Thread.Sleep in the sandbox (System.Threading.Thread
|
||||||
|
// is denied). But a tight CPU loop exceeds any short timeout.
|
||||||
|
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||||
|
"""
|
||||||
|
var end = Environment.TickCount64 + 5000;
|
||||||
|
while (Environment.TickCount64 < end) { }
|
||||||
|
return 1;
|
||||||
|
""");
|
||||||
|
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(
|
||||||
|
inner, TimeSpan.FromMilliseconds(50));
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<ScriptTimeoutException>(async () =>
|
||||||
|
await timed.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
|
||||||
|
ex.Timeout.ShouldBe(TimeSpan.FromMilliseconds(50));
|
||||||
|
ex.Message.ShouldContain("50.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Caller_cancellation_takes_precedence_over_timeout()
|
||||||
|
{
|
||||||
|
// A CPU-bound script that would otherwise timeout; external ct fires first.
|
||||||
|
// Expected: OperationCanceledException (not ScriptTimeoutException) so shutdown
|
||||||
|
// paths aren't misclassified as timeouts.
|
||||||
|
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||||
|
"""
|
||||||
|
var end = Environment.TickCount64 + 10000;
|
||||||
|
while (Environment.TickCount64 < end) { }
|
||||||
|
return 1;
|
||||||
|
""");
|
||||||
|
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(
|
||||||
|
inner, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(80));
|
||||||
|
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||||
|
await timed.RunAsync(new FakeScriptContext(), cts.Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Default_timeout_is_250ms_per_plan()
|
||||||
|
{
|
||||||
|
TimedScriptEvaluator<FakeScriptContext, int>.DefaultTimeout
|
||||||
|
.ShouldBe(TimeSpan.FromMilliseconds(250));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Zero_or_negative_timeout_is_rejected_at_construction()
|
||||||
|
{
|
||||||
|
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile("""return 1;""");
|
||||||
|
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||||
|
new TimedScriptEvaluator<FakeScriptContext, int>(inner, TimeSpan.Zero));
|
||||||
|
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||||
|
new TimedScriptEvaluator<FakeScriptContext, int>(inner, TimeSpan.FromMilliseconds(-1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Null_inner_is_rejected()
|
||||||
|
{
|
||||||
|
Should.Throw<ArgumentNullException>(() =>
|
||||||
|
new TimedScriptEvaluator<FakeScriptContext, int>(null!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Null_context_is_rejected()
|
||||||
|
{
|
||||||
|
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile("""return 1;""");
|
||||||
|
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(inner);
|
||||||
|
Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||||
|
await timed.RunAsync(null!, TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Script_exception_propagates_unwrapped()
|
||||||
|
{
|
||||||
|
// User-thrown exceptions must come through as-is — NOT wrapped in
|
||||||
|
// ScriptTimeoutException. The virtual-tag engine catches them per-tag and
|
||||||
|
// maps to BadInternalError; conflating with timeout would lose that info.
|
||||||
|
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||||
|
"""throw new InvalidOperationException("script boom");""");
|
||||||
|
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(inner, TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||||
|
await timed.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
|
||||||
|
ex.Message.ShouldBe("script boom");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ScriptTimeoutException_message_points_at_diagnostic_path()
|
||||||
|
{
|
||||||
|
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||||
|
"""
|
||||||
|
var end = Environment.TickCount64 + 5000;
|
||||||
|
while (Environment.TickCount64 < end) { }
|
||||||
|
return 1;
|
||||||
|
""");
|
||||||
|
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(
|
||||||
|
inner, TimeSpan.FromMilliseconds(30));
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<ScriptTimeoutException>(async () =>
|
||||||
|
await timed.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
|
||||||
|
ex.Message.ShouldContain("ctx.Logger");
|
||||||
|
ex.Message.ShouldContain("widening the timeout");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies cycle detection + topological sort on the virtual-tag dependency
|
||||||
|
/// graph. Publish-time correctness depends on these being right — a missed cycle
|
||||||
|
/// would deadlock cascade evaluation; a wrong topological order would miscompute
|
||||||
|
/// chained virtual tags.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class DependencyGraphTests
|
||||||
|
{
|
||||||
|
private static IReadOnlySet<string> Set(params string[] items) =>
|
||||||
|
new HashSet<string>(items, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Empty_graph_produces_empty_sort_and_no_cycles()
|
||||||
|
{
|
||||||
|
var g = new DependencyGraph();
|
||||||
|
g.TopologicalSort().ShouldBeEmpty();
|
||||||
|
g.DetectCycles().ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Single_node_with_no_deps()
|
||||||
|
{
|
||||||
|
var g = new DependencyGraph();
|
||||||
|
g.Add("A", Set());
|
||||||
|
g.TopologicalSort().ShouldBe(new[] { "A" });
|
||||||
|
g.DetectCycles().ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Topological_order_places_dependencies_before_dependents()
|
||||||
|
{
|
||||||
|
var g = new DependencyGraph();
|
||||||
|
g.Add("B", Set("A")); // B depends on A
|
||||||
|
g.Add("C", Set("B", "A")); // C depends on B + A
|
||||||
|
g.Add("A", Set()); // A is a leaf
|
||||||
|
|
||||||
|
var order = g.TopologicalSort();
|
||||||
|
var idx = order.Select((x, i) => (x, i)).ToDictionary(p => p.x, p => p.i);
|
||||||
|
idx["A"].ShouldBeLessThan(idx["B"]);
|
||||||
|
idx["B"].ShouldBeLessThan(idx["C"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Self_loop_detected_as_cycle()
|
||||||
|
{
|
||||||
|
var g = new DependencyGraph();
|
||||||
|
g.Add("A", Set("A"));
|
||||||
|
var cycles = g.DetectCycles();
|
||||||
|
cycles.Count.ShouldBe(1);
|
||||||
|
cycles[0].ShouldContain("A");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Two_node_cycle_detected()
|
||||||
|
{
|
||||||
|
var g = new DependencyGraph();
|
||||||
|
g.Add("A", Set("B"));
|
||||||
|
g.Add("B", Set("A"));
|
||||||
|
var cycles = g.DetectCycles();
|
||||||
|
cycles.Count.ShouldBe(1);
|
||||||
|
cycles[0].Count.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Three_node_cycle_detected()
|
||||||
|
{
|
||||||
|
var g = new DependencyGraph();
|
||||||
|
g.Add("A", Set("B"));
|
||||||
|
g.Add("B", Set("C"));
|
||||||
|
g.Add("C", Set("A"));
|
||||||
|
var cycles = g.DetectCycles();
|
||||||
|
cycles.Count.ShouldBe(1);
|
||||||
|
cycles[0].Count.ShouldBe(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Multiple_disjoint_cycles_all_reported()
|
||||||
|
{
|
||||||
|
var g = new DependencyGraph();
|
||||||
|
// Cycle 1: A -> B -> A
|
||||||
|
g.Add("A", Set("B"));
|
||||||
|
g.Add("B", Set("A"));
|
||||||
|
// Cycle 2: X -> Y -> Z -> X
|
||||||
|
g.Add("X", Set("Y"));
|
||||||
|
g.Add("Y", Set("Z"));
|
||||||
|
g.Add("Z", Set("X"));
|
||||||
|
// Clean leaf: M
|
||||||
|
g.Add("M", Set());
|
||||||
|
|
||||||
|
var cycles = g.DetectCycles();
|
||||||
|
cycles.Count.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Topological_sort_throws_DependencyCycleException_on_cycle()
|
||||||
|
{
|
||||||
|
var g = new DependencyGraph();
|
||||||
|
g.Add("A", Set("B"));
|
||||||
|
g.Add("B", Set("A"));
|
||||||
|
Should.Throw<DependencyCycleException>(() => g.TopologicalSort())
|
||||||
|
.Cycles.ShouldNotBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DirectDependents_returns_direct_only()
|
||||||
|
{
|
||||||
|
var g = new DependencyGraph();
|
||||||
|
g.Add("B", Set("A"));
|
||||||
|
g.Add("C", Set("B"));
|
||||||
|
g.DirectDependents("A").ShouldBe(new[] { "B" });
|
||||||
|
g.DirectDependents("B").ShouldBe(new[] { "C" });
|
||||||
|
g.DirectDependents("C").ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TransitiveDependentsInOrder_returns_topological_closure()
|
||||||
|
{
|
||||||
|
var g = new DependencyGraph();
|
||||||
|
g.Add("B", Set("A"));
|
||||||
|
g.Add("C", Set("B"));
|
||||||
|
g.Add("D", Set("C"));
|
||||||
|
var closure = g.TransitiveDependentsInOrder("A");
|
||||||
|
closure.ShouldBe(new[] { "B", "C", "D" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Readding_a_node_overwrites_prior_dependencies()
|
||||||
|
{
|
||||||
|
var g = new DependencyGraph();
|
||||||
|
g.Add("X", Set("A"));
|
||||||
|
g.DirectDependencies("X").ShouldBe(new[] { "A" });
|
||||||
|
// Re-add with different deps (simulates script edit + republish).
|
||||||
|
g.Add("X", Set("B", "C"));
|
||||||
|
g.DirectDependencies("X").OrderBy(s => s).ShouldBe(new[] { "B", "C" });
|
||||||
|
// A should no longer list X as a dependent.
|
||||||
|
g.DirectDependents("A").ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Leaf_dependencies_not_registered_as_nodes_are_treated_as_implicit()
|
||||||
|
{
|
||||||
|
// A is referenced but never Add'd as a node — it's an upstream driver tag.
|
||||||
|
var g = new DependencyGraph();
|
||||||
|
g.Add("B", Set("A"));
|
||||||
|
g.TopologicalSort().ShouldBe(new[] { "B" });
|
||||||
|
g.DirectDependents("A").ShouldBe(new[] { "B" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Deep_graph_no_stack_overflow()
|
||||||
|
{
|
||||||
|
// Iterative Tarjan's + Kahn's — 10k deep chain must complete without blowing the stack.
|
||||||
|
var g = new DependencyGraph();
|
||||||
|
for (var i = 1; i < 10_000; i++)
|
||||||
|
g.Add($"N{i}", Set($"N{i - 1}"));
|
||||||
|
var order = g.TopologicalSort();
|
||||||
|
order.Count.ShouldBe(9_999);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory <see cref="ITagUpstreamSource"/> for tests. Seed tag values via
|
||||||
|
/// <see cref="Set"/>, push changes via <see cref="Push"/>. Tracks subscriptions so
|
||||||
|
/// tests can assert the engine disposes them on reload / shutdown.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FakeUpstream : ITagUpstreamSource
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, DataValueSnapshot> _values = new(StringComparer.Ordinal);
|
||||||
|
private readonly ConcurrentDictionary<string, List<Action<string, DataValueSnapshot>>> _subs = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public int ActiveSubscriptionCount { get; private set; }
|
||||||
|
|
||||||
|
public void Set(string path, object value, uint statusCode = 0u)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
_values[path] = new DataValueSnapshot(value, statusCode, now, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Push(string path, object value, uint statusCode = 0u)
|
||||||
|
{
|
||||||
|
Set(path, value, statusCode);
|
||||||
|
if (_subs.TryGetValue(path, out var list))
|
||||||
|
{
|
||||||
|
Action<string, DataValueSnapshot>[] snap;
|
||||||
|
lock (list) { snap = list.ToArray(); }
|
||||||
|
foreach (var obs in snap) obs(path, _values[path]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DataValueSnapshot ReadTag(string path)
|
||||||
|
=> _values.TryGetValue(path, out var v)
|
||||||
|
? v
|
||||||
|
: new DataValueSnapshot(null, 0x80340000u, null, DateTime.UtcNow);
|
||||||
|
|
||||||
|
public IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer)
|
||||||
|
{
|
||||||
|
var list = _subs.GetOrAdd(path, _ => []);
|
||||||
|
lock (list) { list.Add(observer); }
|
||||||
|
ActiveSubscriptionCount++;
|
||||||
|
return new Unsub(this, path, observer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Unsub : IDisposable
|
||||||
|
{
|
||||||
|
private readonly FakeUpstream _up;
|
||||||
|
private readonly string _path;
|
||||||
|
private readonly Action<string, DataValueSnapshot> _observer;
|
||||||
|
public Unsub(FakeUpstream up, string path, Action<string, DataValueSnapshot> observer)
|
||||||
|
{
|
||||||
|
_up = up; _path = path; _observer = observer;
|
||||||
|
}
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_up._subs.TryGetValue(_path, out var list))
|
||||||
|
{
|
||||||
|
lock (list)
|
||||||
|
{
|
||||||
|
if (list.Remove(_observer))
|
||||||
|
_up.ActiveSubscriptionCount--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
using Serilog;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class TimerTriggerSchedulerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Timer_interval_causes_periodic_reevaluation()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
// Counter source — re-eval should pick up new value each tick.
|
||||||
|
var counter = 0;
|
||||||
|
var logger = new LoggerConfiguration().CreateLogger();
|
||||||
|
|
||||||
|
using var engine = new VirtualTagEngine(up,
|
||||||
|
new ScriptLoggerFactory(logger),
|
||||||
|
logger);
|
||||||
|
|
||||||
|
engine.Load([new VirtualTagDefinition(
|
||||||
|
"Counter", DriverDataType.Int32,
|
||||||
|
"""return ctx.Now.Millisecond;""", // changes on every evaluation
|
||||||
|
ChangeTriggered: false,
|
||||||
|
TimerInterval: TimeSpan.FromMilliseconds(100))]);
|
||||||
|
|
||||||
|
using var sched = new TimerTriggerScheduler(engine, logger);
|
||||||
|
sched.Start([new VirtualTagDefinition(
|
||||||
|
"Counter", DriverDataType.Int32,
|
||||||
|
"""return ctx.Now.Millisecond;""",
|
||||||
|
ChangeTriggered: false,
|
||||||
|
TimerInterval: TimeSpan.FromMilliseconds(100))]);
|
||||||
|
|
||||||
|
// Watch the value change across ticks.
|
||||||
|
var snapshots = new List<object?>();
|
||||||
|
using var sub = engine.Subscribe("Counter", (_, v) => snapshots.Add(v.Value));
|
||||||
|
|
||||||
|
await Task.Delay(500);
|
||||||
|
|
||||||
|
snapshots.Count.ShouldBeGreaterThanOrEqualTo(3, "At least 3 ticks in 500ms at 100ms cadence");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Tags_without_TimerInterval_not_scheduled()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
var logger = new LoggerConfiguration().CreateLogger();
|
||||||
|
using var engine = new VirtualTagEngine(up,
|
||||||
|
new ScriptLoggerFactory(logger), logger);
|
||||||
|
engine.Load([new VirtualTagDefinition(
|
||||||
|
"NoTimer", DriverDataType.Int32, """return 1;""")]);
|
||||||
|
|
||||||
|
using var sched = new TimerTriggerScheduler(engine, logger);
|
||||||
|
sched.Start([new VirtualTagDefinition(
|
||||||
|
"NoTimer", DriverDataType.Int32, """return 1;""")]);
|
||||||
|
|
||||||
|
var events = new List<int>();
|
||||||
|
using var sub = engine.Subscribe("NoTimer", (_, v) => events.Add((int)(v.Value ?? 0)));
|
||||||
|
|
||||||
|
await Task.Delay(300);
|
||||||
|
events.Count.ShouldBe(0, "No TimerInterval = no timer ticks");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Start_groups_tags_by_interval_into_shared_timers()
|
||||||
|
{
|
||||||
|
// Smoke test — Start on a definition list with two distinct intervals must not
|
||||||
|
// throw. Group count matches unique intervals.
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
var logger = new LoggerConfiguration().CreateLogger();
|
||||||
|
using var engine = new VirtualTagEngine(up,
|
||||||
|
new ScriptLoggerFactory(logger), logger);
|
||||||
|
engine.Load([
|
||||||
|
new VirtualTagDefinition("Fast", DriverDataType.Int32, """return 1;""",
|
||||||
|
TimerInterval: TimeSpan.FromSeconds(1)),
|
||||||
|
new VirtualTagDefinition("Slow", DriverDataType.Int32, """return 2;""",
|
||||||
|
TimerInterval: TimeSpan.FromSeconds(5)),
|
||||||
|
new VirtualTagDefinition("AlsoFast", DriverDataType.Int32, """return 3;""",
|
||||||
|
TimerInterval: TimeSpan.FromSeconds(1)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
using var sched = new TimerTriggerScheduler(engine, logger);
|
||||||
|
Should.NotThrow(() => sched.Start(new[]
|
||||||
|
{
|
||||||
|
new VirtualTagDefinition("Fast", DriverDataType.Int32, """return 1;""", TimerInterval: TimeSpan.FromSeconds(1)),
|
||||||
|
new VirtualTagDefinition("Slow", DriverDataType.Int32, """return 2;""", TimerInterval: TimeSpan.FromSeconds(5)),
|
||||||
|
new VirtualTagDefinition("AlsoFast", DriverDataType.Int32, """return 3;""", TimerInterval: TimeSpan.FromSeconds(1)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Disposed_scheduler_stops_firing()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
var logger = new LoggerConfiguration().CreateLogger();
|
||||||
|
using var engine = new VirtualTagEngine(up,
|
||||||
|
new ScriptLoggerFactory(logger), logger);
|
||||||
|
engine.Load([new VirtualTagDefinition(
|
||||||
|
"T", DriverDataType.Int32, """return 1;""",
|
||||||
|
TimerInterval: TimeSpan.FromMilliseconds(50))]);
|
||||||
|
|
||||||
|
var sched = new TimerTriggerScheduler(engine, logger);
|
||||||
|
sched.Start([new VirtualTagDefinition(
|
||||||
|
"T", DriverDataType.Int32, """return 1;""",
|
||||||
|
TimerInterval: TimeSpan.FromMilliseconds(50))]);
|
||||||
|
sched.Dispose();
|
||||||
|
|
||||||
|
// After dispose, second Start throws ObjectDisposedException.
|
||||||
|
Should.Throw<ObjectDisposedException>(() =>
|
||||||
|
sched.Start([new VirtualTagDefinition(
|
||||||
|
"T", DriverDataType.Int32, """return 1;""",
|
||||||
|
TimerInterval: TimeSpan.FromMilliseconds(50))]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
using Serilog;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end VirtualTagEngine behavior: load config, subscribe to upstream,
|
||||||
|
/// evaluate on change, cascade through dependent virtual tags, timer-driven
|
||||||
|
/// re-evaluation, error isolation, historize flag, cycle rejection.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class VirtualTagEngineTests
|
||||||
|
{
|
||||||
|
private static VirtualTagEngine Build(
|
||||||
|
FakeUpstream upstream,
|
||||||
|
IHistoryWriter? history = null,
|
||||||
|
TimeSpan? scriptTimeout = null,
|
||||||
|
Func<DateTime>? clock = null)
|
||||||
|
{
|
||||||
|
var rootLogger = new LoggerConfiguration().CreateLogger();
|
||||||
|
return new VirtualTagEngine(
|
||||||
|
upstream,
|
||||||
|
new ScriptLoggerFactory(rootLogger),
|
||||||
|
rootLogger,
|
||||||
|
history,
|
||||||
|
clock,
|
||||||
|
scriptTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Simple_script_reads_upstream_and_returns_coerced_value()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("InTag", 10.0);
|
||||||
|
using var engine = Build(up);
|
||||||
|
|
||||||
|
engine.Load([new VirtualTagDefinition(
|
||||||
|
Path: "LineRate",
|
||||||
|
DataType: DriverDataType.Float64,
|
||||||
|
ScriptSource: """return (double)ctx.GetTag("InTag").Value * 2.0;""")]);
|
||||||
|
|
||||||
|
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var result = engine.Read("LineRate");
|
||||||
|
result.StatusCode.ShouldBe(0u);
|
||||||
|
result.Value.ShouldBe(20.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Upstream_change_triggers_cascade_through_two_levels()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("A", 1.0);
|
||||||
|
using var engine = Build(up);
|
||||||
|
|
||||||
|
engine.Load([
|
||||||
|
new VirtualTagDefinition("B", DriverDataType.Float64,
|
||||||
|
"""return (double)ctx.GetTag("A").Value + 10.0;"""),
|
||||||
|
new VirtualTagDefinition("C", DriverDataType.Float64,
|
||||||
|
"""return (double)ctx.GetTag("B").Value * 2.0;"""),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||||
|
engine.Read("B").Value.ShouldBe(11.0);
|
||||||
|
engine.Read("C").Value.ShouldBe(22.0);
|
||||||
|
|
||||||
|
// Change upstream — cascade should recompute B (11→15.0) then C (30.0)
|
||||||
|
up.Push("A", 5.0);
|
||||||
|
await WaitForConditionAsync(() => Equals(engine.Read("B").Value, 15.0));
|
||||||
|
engine.Read("B").Value.ShouldBe(15.0);
|
||||||
|
engine.Read("C").Value.ShouldBe(30.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cycle_in_virtual_tags_rejected_at_Load()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
using var engine = Build(up);
|
||||||
|
|
||||||
|
Should.Throw<DependencyCycleException>(() => engine.Load([
|
||||||
|
new VirtualTagDefinition("A", DriverDataType.Int32, """return (int)ctx.GetTag("B").Value + 1;"""),
|
||||||
|
new VirtualTagDefinition("B", DriverDataType.Int32, """return (int)ctx.GetTag("A").Value + 1;"""),
|
||||||
|
]));
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Script_compile_error_surfaces_at_Load_with_all_failures()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
using var engine = Build(up);
|
||||||
|
|
||||||
|
var ex = Should.Throw<InvalidOperationException>(() => engine.Load([
|
||||||
|
new VirtualTagDefinition("A", DriverDataType.Int32, """return undefinedIdentifier;"""),
|
||||||
|
new VirtualTagDefinition("B", DriverDataType.Int32, """return 42;"""),
|
||||||
|
new VirtualTagDefinition("C", DriverDataType.Int32, """var x = anotherUndefined; return x;"""),
|
||||||
|
]));
|
||||||
|
ex.Message.ShouldContain("2 script(s) did not compile");
|
||||||
|
ex.Message.ShouldContain("A");
|
||||||
|
ex.Message.ShouldContain("C");
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Script_runtime_exception_isolates_to_owning_tag()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("OK", 10);
|
||||||
|
using var engine = Build(up);
|
||||||
|
|
||||||
|
engine.Load([
|
||||||
|
new VirtualTagDefinition("GoodTag", DriverDataType.Int32,
|
||||||
|
"""return (int)ctx.GetTag("OK").Value * 2;"""),
|
||||||
|
new VirtualTagDefinition("BadTag", DriverDataType.Int32,
|
||||||
|
"""throw new InvalidOperationException("boom");"""),
|
||||||
|
]);
|
||||||
|
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
engine.Read("GoodTag").StatusCode.ShouldBe(0u);
|
||||||
|
engine.Read("GoodTag").Value.ShouldBe(20);
|
||||||
|
engine.Read("BadTag").StatusCode.ShouldBe(0x80020000u, "BadInternalError for thrown script");
|
||||||
|
engine.Read("BadTag").Value.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Timeout_maps_to_BadInternalError_without_killing_the_engine()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
using var engine = Build(up, scriptTimeout: TimeSpan.FromMilliseconds(30));
|
||||||
|
|
||||||
|
engine.Load([
|
||||||
|
new VirtualTagDefinition("Hang", DriverDataType.Int32, """
|
||||||
|
var end = Environment.TickCount64 + 5000;
|
||||||
|
while (Environment.TickCount64 < end) { }
|
||||||
|
return 1;
|
||||||
|
"""),
|
||||||
|
new VirtualTagDefinition("Ok", DriverDataType.Int32, """return 42;"""),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||||
|
engine.Read("Hang").StatusCode.ShouldBe(0x80020000u);
|
||||||
|
engine.Read("Ok").Value.ShouldBe(42);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Subscribers_receive_engine_emitted_changes()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("In", 1);
|
||||||
|
using var engine = Build(up);
|
||||||
|
|
||||||
|
engine.Load([new VirtualTagDefinition(
|
||||||
|
"Out", DriverDataType.Int32, """return (int)ctx.GetTag("In").Value + 100;""")]);
|
||||||
|
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var received = new List<DataValueSnapshot>();
|
||||||
|
using var sub = engine.Subscribe("Out", (p, v) => received.Add(v));
|
||||||
|
|
||||||
|
up.Push("In", 5);
|
||||||
|
await WaitForConditionAsync(() => received.Count >= 1);
|
||||||
|
|
||||||
|
received[^1].Value.ShouldBe(105);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Historize_flag_routes_to_history_writer()
|
||||||
|
{
|
||||||
|
var recorded = new List<(string, DataValueSnapshot)>();
|
||||||
|
var history = new TestHistory(recorded);
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("In", 1);
|
||||||
|
using var engine = Build(up, history);
|
||||||
|
|
||||||
|
engine.Load([
|
||||||
|
new VirtualTagDefinition("H", DriverDataType.Int32,
|
||||||
|
"""return (int)ctx.GetTag("In").Value + 1;""", Historize: true),
|
||||||
|
new VirtualTagDefinition("NoH", DriverDataType.Int32,
|
||||||
|
"""return (int)ctx.GetTag("In").Value - 1;""", Historize: false),
|
||||||
|
]);
|
||||||
|
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
recorded.Select(p => p.Item1).ShouldContain("H");
|
||||||
|
recorded.Select(p => p.Item1).ShouldNotContain("NoH");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Change_driven_false_ignores_upstream_push()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("In", 1);
|
||||||
|
using var engine = Build(up);
|
||||||
|
engine.Load([new VirtualTagDefinition(
|
||||||
|
"Manual", DriverDataType.Int32,
|
||||||
|
"""return (int)ctx.GetTag("In").Value * 10;""",
|
||||||
|
ChangeTriggered: false)]);
|
||||||
|
|
||||||
|
// Initial eval seeds the value.
|
||||||
|
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||||
|
engine.Read("Manual").Value.ShouldBe(10);
|
||||||
|
|
||||||
|
// Upstream change fires but change-driven is off — no recompute.
|
||||||
|
up.Push("In", 99);
|
||||||
|
await Task.Delay(100);
|
||||||
|
engine.Read("Manual").Value.ShouldBe(10, "change-driven=false ignores upstream deltas");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reload_replaces_existing_tags_and_resubscribes_cleanly()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("A", 1);
|
||||||
|
up.Set("B", 2);
|
||||||
|
using var engine = Build(up);
|
||||||
|
|
||||||
|
engine.Load([new VirtualTagDefinition(
|
||||||
|
"T", DriverDataType.Int32, """return (int)ctx.GetTag("A").Value * 2;""")]);
|
||||||
|
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||||
|
engine.Read("T").Value.ShouldBe(2);
|
||||||
|
up.ActiveSubscriptionCount.ShouldBe(1);
|
||||||
|
|
||||||
|
// Reload — T now depends on B instead of A.
|
||||||
|
engine.Load([new VirtualTagDefinition(
|
||||||
|
"T", DriverDataType.Int32, """return (int)ctx.GetTag("B").Value * 3;""")]);
|
||||||
|
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||||
|
engine.Read("T").Value.ShouldBe(6);
|
||||||
|
up.ActiveSubscriptionCount.ShouldBe(1, "previous subscription on A must be disposed");
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dispose_releases_upstream_subscriptions()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("A", 1);
|
||||||
|
var engine = Build(up);
|
||||||
|
engine.Load([new VirtualTagDefinition(
|
||||||
|
"T", DriverDataType.Int32, """return (int)ctx.GetTag("A").Value;""")]);
|
||||||
|
up.ActiveSubscriptionCount.ShouldBe(1);
|
||||||
|
|
||||||
|
engine.Dispose();
|
||||||
|
up.ActiveSubscriptionCount.ShouldBe(0);
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetVirtualTag_within_script_updates_target_and_triggers_observers()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("In", 5);
|
||||||
|
using var engine = Build(up);
|
||||||
|
|
||||||
|
engine.Load([
|
||||||
|
new VirtualTagDefinition("Target", DriverDataType.Int32,
|
||||||
|
"""return 0;""", ChangeTriggered: false), // placeholder value, operator-written via SetVirtualTag
|
||||||
|
new VirtualTagDefinition("Driver", DriverDataType.Int32,
|
||||||
|
"""
|
||||||
|
var v = (int)ctx.GetTag("In").Value;
|
||||||
|
ctx.SetVirtualTag("Target", v * 100);
|
||||||
|
return v;
|
||||||
|
"""),
|
||||||
|
]);
|
||||||
|
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
engine.Read("Target").Value.ShouldBe(500);
|
||||||
|
engine.Read("Driver").Value.ShouldBe(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Type_coercion_from_script_double_to_config_int32()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("In", 3.7);
|
||||||
|
using var engine = Build(up);
|
||||||
|
|
||||||
|
engine.Load([new VirtualTagDefinition(
|
||||||
|
"Rounded", DriverDataType.Int32,
|
||||||
|
"""return (double)ctx.GetTag("In").Value;""")]);
|
||||||
|
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
engine.Read("Rounded").Value.ShouldBe(4, "Convert.ToInt32 rounds 3.7 to 4");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForConditionAsync(Func<bool> cond, int timeoutMs = 2000)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
if (cond()) return;
|
||||||
|
await Task.Delay(25);
|
||||||
|
}
|
||||||
|
throw new TimeoutException("Condition did not become true in time");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestHistory : IHistoryWriter
|
||||||
|
{
|
||||||
|
private readonly List<(string, DataValueSnapshot)> _buf;
|
||||||
|
public TestHistory(List<(string, DataValueSnapshot)> buf) => _buf = buf;
|
||||||
|
public void Record(string path, DataValueSnapshot value)
|
||||||
|
{
|
||||||
|
lock (_buf) { _buf.Add((path, value)); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
using Serilog;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the IReadable + ISubscribable adapter that DriverNodeManager dispatches
|
||||||
|
/// to for NodeSource.Virtual per ADR-002. Key contract: OPC UA clients see virtual
|
||||||
|
/// tags via the same capability interfaces as driver tags, so dispatch stays
|
||||||
|
/// source-agnostic.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class VirtualTagSourceTests
|
||||||
|
{
|
||||||
|
private static (VirtualTagEngine engine, VirtualTagSource source, FakeUpstream up) Build()
|
||||||
|
{
|
||||||
|
var up = new FakeUpstream();
|
||||||
|
up.Set("In", 10);
|
||||||
|
var logger = new LoggerConfiguration().CreateLogger();
|
||||||
|
var engine = new VirtualTagEngine(up, new ScriptLoggerFactory(logger), logger);
|
||||||
|
engine.Load([new VirtualTagDefinition(
|
||||||
|
"Out", DriverDataType.Int32, """return (int)ctx.GetTag("In").Value * 2;""")]);
|
||||||
|
return (engine, new VirtualTagSource(engine), up);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadAsync_returns_engine_cached_values()
|
||||||
|
{
|
||||||
|
var (engine, source, _) = Build();
|
||||||
|
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var results = await source.ReadAsync(["Out"], TestContext.Current.CancellationToken);
|
||||||
|
results.Count.ShouldBe(1);
|
||||||
|
results[0].Value.ShouldBe(20);
|
||||||
|
results[0].StatusCode.ShouldBe(0u);
|
||||||
|
engine.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadAsync_unknown_path_returns_Bad_quality()
|
||||||
|
{
|
||||||
|
var (engine, source, _) = Build();
|
||||||
|
var results = await source.ReadAsync(["NoSuchTag"], TestContext.Current.CancellationToken);
|
||||||
|
results[0].StatusCode.ShouldBe(0x80340000u);
|
||||||
|
engine.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SubscribeAsync_fires_initial_data_callback()
|
||||||
|
{
|
||||||
|
var (engine, source, _) = Build();
|
||||||
|
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var events = new List<DataChangeEventArgs>();
|
||||||
|
source.OnDataChange += (_, e) => events.Add(e);
|
||||||
|
|
||||||
|
var handle = await source.SubscribeAsync(["Out"], TimeSpan.FromMilliseconds(100),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
handle.ShouldNotBeNull();
|
||||||
|
|
||||||
|
// Per OPC UA convention, initial-data callback fires on subscribe.
|
||||||
|
events.Count.ShouldBeGreaterThanOrEqualTo(1);
|
||||||
|
events[0].FullReference.ShouldBe("Out");
|
||||||
|
events[0].Snapshot.Value.ShouldBe(20);
|
||||||
|
|
||||||
|
await source.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||||
|
engine.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SubscribeAsync_fires_on_upstream_change_via_engine_cascade()
|
||||||
|
{
|
||||||
|
var (engine, source, up) = Build();
|
||||||
|
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var events = new List<DataChangeEventArgs>();
|
||||||
|
source.OnDataChange += (_, e) => events.Add(e);
|
||||||
|
var handle = await source.SubscribeAsync(["Out"], TimeSpan.Zero,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var initialCount = events.Count;
|
||||||
|
up.Push("In", 50);
|
||||||
|
|
||||||
|
// Wait for the cascade.
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||||
|
while (DateTime.UtcNow < deadline && events.Count <= initialCount) await Task.Delay(25);
|
||||||
|
|
||||||
|
events.Count.ShouldBeGreaterThan(initialCount);
|
||||||
|
events[^1].Snapshot.Value.ShouldBe(100);
|
||||||
|
|
||||||
|
await source.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||||
|
engine.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UnsubscribeAsync_stops_further_events()
|
||||||
|
{
|
||||||
|
var (engine, source, up) = Build();
|
||||||
|
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var events = new List<DataChangeEventArgs>();
|
||||||
|
source.OnDataChange += (_, e) => events.Add(e);
|
||||||
|
var handle = await source.SubscribeAsync(["Out"], TimeSpan.Zero,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await source.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||||
|
var countAfterUnsub = events.Count;
|
||||||
|
|
||||||
|
up.Push("In", 99);
|
||||||
|
await Task.Delay(200);
|
||||||
|
|
||||||
|
events.Count.ShouldBe(countAfterUnsub, "Unsubscribe must stop OnDataChange emissions");
|
||||||
|
engine.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Null_arguments_rejected()
|
||||||
|
{
|
||||||
|
var (engine, source, _) = Build();
|
||||||
|
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||||
|
await source.ReadAsync(null!, TestContext.Current.CancellationToken));
|
||||||
|
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||||
|
await source.SubscribeAsync(null!, TimeSpan.Zero, TestContext.Current.CancellationToken));
|
||||||
|
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||||
|
await source.UnsubscribeAsync(null!, TestContext.Current.CancellationToken));
|
||||||
|
engine.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Core.VirtualTags\ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MessagePack;
|
||||||
|
using Serilog;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Backend;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Validates that <see cref="FwlibFrameHandler"/> correctly dispatches each
|
||||||
|
/// <see cref="FocasMessageKind"/> to the corresponding <see cref="IFocasBackend"/>
|
||||||
|
/// method and serializes the response into the expected response kind. Uses
|
||||||
|
/// <see cref="FakeFocasBackend"/> so no hardware is needed.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class FwlibFrameHandlerTests
|
||||||
|
{
|
||||||
|
private static async Task RoundTripAsync<TReq, TResp>(
|
||||||
|
IFrameHandler handler, FocasMessageKind reqKind, TReq req, FocasMessageKind expectedRespKind,
|
||||||
|
Action<TResp> assertResponse)
|
||||||
|
{
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
using var writer = new FrameWriter(buffer, leaveOpen: true);
|
||||||
|
await handler.HandleAsync(reqKind, MessagePackSerializer.Serialize(req), writer, CancellationToken.None);
|
||||||
|
|
||||||
|
buffer.Position = 0;
|
||||||
|
using var reader = new FrameReader(buffer, leaveOpen: true);
|
||||||
|
var frame = await reader.ReadFrameAsync(CancellationToken.None);
|
||||||
|
frame.HasValue.ShouldBeTrue();
|
||||||
|
frame!.Value.Kind.ShouldBe(expectedRespKind);
|
||||||
|
assertResponse(MessagePackSerializer.Deserialize<TResp>(frame.Value.Body));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FwlibFrameHandler BuildHandler() =>
|
||||||
|
new(new FakeFocasBackend(), new LoggerConfiguration().CreateLogger());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenSession_returns_a_new_session_id()
|
||||||
|
{
|
||||||
|
long sessionId = 0;
|
||||||
|
await RoundTripAsync<OpenSessionRequest, OpenSessionResponse>(
|
||||||
|
BuildHandler(),
|
||||||
|
FocasMessageKind.OpenSessionRequest,
|
||||||
|
new OpenSessionRequest { HostAddress = "h:8193" },
|
||||||
|
FocasMessageKind.OpenSessionResponse,
|
||||||
|
resp => { resp.Success.ShouldBeTrue(); resp.SessionId.ShouldBeGreaterThan(0L); sessionId = resp.SessionId; });
|
||||||
|
sessionId.ShouldBeGreaterThan(0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Read_without_open_session_returns_internal_error()
|
||||||
|
{
|
||||||
|
await RoundTripAsync<ReadRequest, ReadResponse>(
|
||||||
|
BuildHandler(),
|
||||||
|
FocasMessageKind.ReadRequest,
|
||||||
|
new ReadRequest
|
||||||
|
{
|
||||||
|
SessionId = 999,
|
||||||
|
Address = new FocasAddressDto { Kind = 0, PmcLetter = "R", Number = 100 },
|
||||||
|
DataType = FocasDataTypeCode.Int32,
|
||||||
|
},
|
||||||
|
FocasMessageKind.ReadResponse,
|
||||||
|
resp => { resp.Success.ShouldBeFalse(); resp.Error.ShouldContain("session-not-open"); });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Full_open_write_read_round_trip_preserves_value()
|
||||||
|
{
|
||||||
|
var handler = BuildHandler();
|
||||||
|
|
||||||
|
// Open.
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
using var writer = new FrameWriter(buffer, leaveOpen: true);
|
||||||
|
await handler.HandleAsync(FocasMessageKind.OpenSessionRequest,
|
||||||
|
MessagePackSerializer.Serialize(new OpenSessionRequest { HostAddress = "h:8193" }), writer, CancellationToken.None);
|
||||||
|
|
||||||
|
buffer.Position = 0;
|
||||||
|
using var reader = new FrameReader(buffer, leaveOpen: true);
|
||||||
|
var openFrame = await reader.ReadFrameAsync(CancellationToken.None);
|
||||||
|
var openResp = MessagePackSerializer.Deserialize<OpenSessionResponse>(openFrame!.Value.Body);
|
||||||
|
var sessionId = openResp.SessionId;
|
||||||
|
|
||||||
|
// Write 42 at MACRO:500 as Int32.
|
||||||
|
buffer.Position = 0;
|
||||||
|
buffer.SetLength(0);
|
||||||
|
await handler.HandleAsync(FocasMessageKind.WriteRequest,
|
||||||
|
MessagePackSerializer.Serialize(new WriteRequest
|
||||||
|
{
|
||||||
|
SessionId = sessionId,
|
||||||
|
Address = new FocasAddressDto { Kind = 2, Number = 500 },
|
||||||
|
DataType = FocasDataTypeCode.Int32,
|
||||||
|
ValueTypeCode = FocasDataTypeCode.Int32,
|
||||||
|
ValueBytes = MessagePackSerializer.Serialize((int)42),
|
||||||
|
}), writer, CancellationToken.None);
|
||||||
|
|
||||||
|
// Read back.
|
||||||
|
buffer.Position = 0;
|
||||||
|
buffer.SetLength(0);
|
||||||
|
await handler.HandleAsync(FocasMessageKind.ReadRequest,
|
||||||
|
MessagePackSerializer.Serialize(new ReadRequest
|
||||||
|
{
|
||||||
|
SessionId = sessionId,
|
||||||
|
Address = new FocasAddressDto { Kind = 2, Number = 500 },
|
||||||
|
DataType = FocasDataTypeCode.Int32,
|
||||||
|
}), writer, CancellationToken.None);
|
||||||
|
|
||||||
|
buffer.Position = 0;
|
||||||
|
var readFrame = await reader.ReadFrameAsync(CancellationToken.None);
|
||||||
|
readFrame.HasValue.ShouldBeTrue();
|
||||||
|
readFrame!.Value.Kind.ShouldBe(FocasMessageKind.ReadResponse);
|
||||||
|
// With buffer reuse there may be multiple queued frames; we want the last one.
|
||||||
|
var lastResp = MessagePackSerializer.Deserialize<ReadResponse>(readFrame.Value.Body);
|
||||||
|
// If the Write frame is first, drain it.
|
||||||
|
if (lastResp.ValueBytes is null)
|
||||||
|
{
|
||||||
|
var next = await reader.ReadFrameAsync(CancellationToken.None);
|
||||||
|
lastResp = MessagePackSerializer.Deserialize<ReadResponse>(next!.Value.Body);
|
||||||
|
}
|
||||||
|
lastResp.Success.ShouldBeTrue();
|
||||||
|
MessagePackSerializer.Deserialize<int>(lastResp.ValueBytes!).ShouldBe(42);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PmcBitWrite_sets_specified_bit()
|
||||||
|
{
|
||||||
|
var handler = BuildHandler();
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
using var writer = new FrameWriter(buffer, leaveOpen: true);
|
||||||
|
|
||||||
|
await handler.HandleAsync(FocasMessageKind.OpenSessionRequest,
|
||||||
|
MessagePackSerializer.Serialize(new OpenSessionRequest { HostAddress = "h:8193" }), writer, CancellationToken.None);
|
||||||
|
buffer.Position = 0;
|
||||||
|
using var reader = new FrameReader(buffer, leaveOpen: true);
|
||||||
|
var openFrame = await reader.ReadFrameAsync(CancellationToken.None);
|
||||||
|
var sessionId = MessagePackSerializer.Deserialize<OpenSessionResponse>(openFrame!.Value.Body).SessionId;
|
||||||
|
|
||||||
|
buffer.Position = 0; buffer.SetLength(0);
|
||||||
|
await handler.HandleAsync(FocasMessageKind.PmcBitWriteRequest,
|
||||||
|
MessagePackSerializer.Serialize(new PmcBitWriteRequest
|
||||||
|
{
|
||||||
|
SessionId = sessionId,
|
||||||
|
Address = new FocasAddressDto { Kind = 0, PmcLetter = "R", Number = 100 },
|
||||||
|
BitIndex = 3,
|
||||||
|
Value = true,
|
||||||
|
}), writer, CancellationToken.None);
|
||||||
|
|
||||||
|
buffer.Position = 0;
|
||||||
|
var resp = MessagePackSerializer.Deserialize<PmcBitWriteResponse>(
|
||||||
|
(await reader.ReadFrameAsync(CancellationToken.None))!.Value.Body);
|
||||||
|
resp.Success.ShouldBeTrue();
|
||||||
|
resp.StatusCode.ShouldBe(0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Probe_reports_healthy_when_session_open()
|
||||||
|
{
|
||||||
|
var handler = BuildHandler();
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
using var writer = new FrameWriter(buffer, leaveOpen: true);
|
||||||
|
await handler.HandleAsync(FocasMessageKind.OpenSessionRequest,
|
||||||
|
MessagePackSerializer.Serialize(new OpenSessionRequest { HostAddress = "h:8193" }), writer, CancellationToken.None);
|
||||||
|
buffer.Position = 0;
|
||||||
|
using var reader = new FrameReader(buffer, leaveOpen: true);
|
||||||
|
var sessionId = MessagePackSerializer.Deserialize<OpenSessionResponse>(
|
||||||
|
(await reader.ReadFrameAsync(CancellationToken.None))!.Value.Body).SessionId;
|
||||||
|
|
||||||
|
buffer.Position = 0; buffer.SetLength(0);
|
||||||
|
await handler.HandleAsync(FocasMessageKind.ProbeRequest,
|
||||||
|
MessagePackSerializer.Serialize(new ProbeRequest { SessionId = sessionId }), writer, CancellationToken.None);
|
||||||
|
buffer.Position = 0;
|
||||||
|
var resp = MessagePackSerializer.Deserialize<ProbeResponse>(
|
||||||
|
(await reader.ReadFrameAsync(CancellationToken.None))!.Value.Body);
|
||||||
|
resp.Healthy.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unconfigured_backend_returns_pointed_error_message()
|
||||||
|
{
|
||||||
|
var handler = new FwlibFrameHandler(new UnconfiguredFocasBackend(), new LoggerConfiguration().CreateLogger());
|
||||||
|
await RoundTripAsync<OpenSessionRequest, OpenSessionResponse>(
|
||||||
|
handler,
|
||||||
|
FocasMessageKind.OpenSessionRequest,
|
||||||
|
new OpenSessionRequest { HostAddress = "h:8193" },
|
||||||
|
FocasMessageKind.OpenSessionResponse,
|
||||||
|
resp =>
|
||||||
|
{
|
||||||
|
resp.Success.ShouldBeFalse();
|
||||||
|
resp.Error.ShouldContain("Fwlib32");
|
||||||
|
resp.ErrorCode.ShouldBe("NoFwlibBackend");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Pipes;
|
||||||
|
using System.Security.Principal;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MessagePack;
|
||||||
|
using Serilog;
|
||||||
|
using Serilog.Core;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Ipc;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Direct FOCAS Host IPC handshake test. Drives <see cref="PipeServer"/> through a
|
||||||
|
/// hand-rolled pipe client built on <see cref="FrameReader"/> / <see cref="FrameWriter"/>
|
||||||
|
/// from FOCAS.Shared. Skipped on Administrator shells because <c>PipeAcl</c> denies
|
||||||
|
/// the BuiltinAdministrators group.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public sealed class IpcHandshakeIntegrationTests
|
||||||
|
{
|
||||||
|
private static bool IsAdministrator()
|
||||||
|
{
|
||||||
|
using var identity = WindowsIdentity.GetCurrent();
|
||||||
|
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(NamedPipeClientStream Stream, FrameReader Reader, FrameWriter Writer)>
|
||||||
|
ConnectAndHelloAsync(string pipeName, string secret, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var stream = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||||
|
await stream.ConnectAsync(5_000, ct);
|
||||||
|
|
||||||
|
var reader = new FrameReader(stream, leaveOpen: true);
|
||||||
|
var writer = new FrameWriter(stream, leaveOpen: true);
|
||||||
|
await writer.WriteAsync(FocasMessageKind.Hello,
|
||||||
|
new Hello { PeerName = "test-client", SharedSecret = secret }, ct);
|
||||||
|
|
||||||
|
var ack = await reader.ReadFrameAsync(ct);
|
||||||
|
if (ack is null) throw new EndOfStreamException("no HelloAck");
|
||||||
|
if (ack.Value.Kind != FocasMessageKind.HelloAck)
|
||||||
|
throw new InvalidOperationException("unexpected first frame kind " + ack.Value.Kind);
|
||||||
|
var ackMsg = MessagePackSerializer.Deserialize<HelloAck>(ack.Value.Body);
|
||||||
|
if (!ackMsg.Accepted) throw new UnauthorizedAccessException(ackMsg.RejectReason);
|
||||||
|
|
||||||
|
return (stream, reader, writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handshake_with_correct_secret_succeeds_and_heartbeat_round_trips()
|
||||||
|
{
|
||||||
|
if (IsAdministrator()) return;
|
||||||
|
|
||||||
|
using var identity = WindowsIdentity.GetCurrent();
|
||||||
|
var sid = identity.User!;
|
||||||
|
var pipe = $"OtOpcUaFocasTest-{Guid.NewGuid():N}";
|
||||||
|
const string secret = "test-secret-2026";
|
||||||
|
Logger log = new LoggerConfiguration().CreateLogger();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
var server = new PipeServer(pipe, sid, secret, log);
|
||||||
|
var serverTask = Task.Run(() => server.RunOneConnectionAsync(new StubFrameHandler(), cts.Token));
|
||||||
|
|
||||||
|
var (stream, reader, writer) = await ConnectAndHelloAsync(pipe, secret, cts.Token);
|
||||||
|
using (stream)
|
||||||
|
using (reader)
|
||||||
|
using (writer)
|
||||||
|
{
|
||||||
|
await writer.WriteAsync(FocasMessageKind.Heartbeat,
|
||||||
|
new Heartbeat { MonotonicTicks = 42 }, cts.Token);
|
||||||
|
|
||||||
|
var hbAck = await reader.ReadFrameAsync(cts.Token);
|
||||||
|
hbAck.HasValue.ShouldBeTrue();
|
||||||
|
hbAck!.Value.Kind.ShouldBe(FocasMessageKind.HeartbeatAck);
|
||||||
|
MessagePackSerializer.Deserialize<HeartbeatAck>(hbAck.Value.Body).MonotonicTicks.ShouldBe(42L);
|
||||||
|
}
|
||||||
|
|
||||||
|
cts.Cancel();
|
||||||
|
try { await serverTask; } catch { }
|
||||||
|
server.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handshake_with_wrong_secret_is_rejected()
|
||||||
|
{
|
||||||
|
if (IsAdministrator()) return;
|
||||||
|
|
||||||
|
using var identity = WindowsIdentity.GetCurrent();
|
||||||
|
var sid = identity.User!;
|
||||||
|
var pipe = $"OtOpcUaFocasTest-{Guid.NewGuid():N}";
|
||||||
|
Logger log = new LoggerConfiguration().CreateLogger();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
var server = new PipeServer(pipe, sid, "real-secret", log);
|
||||||
|
var serverTask = Task.Run(() => server.RunOneConnectionAsync(new StubFrameHandler(), cts.Token));
|
||||||
|
|
||||||
|
await Should.ThrowAsync<UnauthorizedAccessException>(async () =>
|
||||||
|
{
|
||||||
|
var (s, r, w) = await ConnectAndHelloAsync(pipe, "wrong-secret", cts.Token);
|
||||||
|
s.Dispose();
|
||||||
|
r.Dispose();
|
||||||
|
w.Dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
cts.Cancel();
|
||||||
|
try { await serverTask; } catch { }
|
||||||
|
server.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Stub_handler_returns_not_implemented_for_data_plane_request()
|
||||||
|
{
|
||||||
|
if (IsAdministrator()) return;
|
||||||
|
|
||||||
|
using var identity = WindowsIdentity.GetCurrent();
|
||||||
|
var sid = identity.User!;
|
||||||
|
var pipe = $"OtOpcUaFocasTest-{Guid.NewGuid():N}";
|
||||||
|
const string secret = "stub-test";
|
||||||
|
Logger log = new LoggerConfiguration().CreateLogger();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
var server = new PipeServer(pipe, sid, secret, log);
|
||||||
|
var serverTask = Task.Run(() => server.RunOneConnectionAsync(new StubFrameHandler(), cts.Token));
|
||||||
|
|
||||||
|
var (stream, reader, writer) = await ConnectAndHelloAsync(pipe, secret, cts.Token);
|
||||||
|
using (stream)
|
||||||
|
using (reader)
|
||||||
|
using (writer)
|
||||||
|
{
|
||||||
|
await writer.WriteAsync(FocasMessageKind.ReadRequest,
|
||||||
|
new ReadRequest
|
||||||
|
{
|
||||||
|
SessionId = 1,
|
||||||
|
Address = new FocasAddressDto { Kind = 0, PmcLetter = "R", Number = 100 },
|
||||||
|
DataType = FocasDataTypeCode.Int32,
|
||||||
|
},
|
||||||
|
cts.Token);
|
||||||
|
|
||||||
|
var resp = await reader.ReadFrameAsync(cts.Token);
|
||||||
|
resp.HasValue.ShouldBeTrue();
|
||||||
|
resp!.Value.Kind.ShouldBe(FocasMessageKind.ErrorResponse);
|
||||||
|
var err = MessagePackSerializer.Deserialize<ErrorResponse>(resp.Value.Body);
|
||||||
|
err.Code.ShouldBe("not-implemented");
|
||||||
|
err.Message.ShouldContain("PR C");
|
||||||
|
}
|
||||||
|
|
||||||
|
cts.Cancel();
|
||||||
|
try { await serverTask; } catch { }
|
||||||
|
server.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Stability;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests
|
||||||
|
{
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class PostMortemMmfTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _tempPath;
|
||||||
|
|
||||||
|
public PostMortemMmfTests()
|
||||||
|
{
|
||||||
|
_tempPath = Path.Combine(Path.GetTempPath(), $"focas-mmf-{Guid.NewGuid():N}.bin");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (File.Exists(_tempPath)) File.Delete(_tempPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Write_and_read_preserve_order_and_content()
|
||||||
|
{
|
||||||
|
using (var mmf = new PostMortemMmf(_tempPath, capacity: 10))
|
||||||
|
{
|
||||||
|
mmf.Write(opKind: 1, "read R100");
|
||||||
|
mmf.Write(opKind: 2, "write MACRO:500 = 3.14");
|
||||||
|
mmf.Write(opKind: 3, "probe ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reopen (simulating a reader after the writer crashed).
|
||||||
|
using var reader = new PostMortemMmf(_tempPath, capacity: 10);
|
||||||
|
var entries = reader.ReadAll();
|
||||||
|
entries.Length.ShouldBe(3);
|
||||||
|
entries[0].OpKind.ShouldBe(1L);
|
||||||
|
entries[0].Message.ShouldBe("read R100");
|
||||||
|
entries[1].OpKind.ShouldBe(2L);
|
||||||
|
entries[2].Message.ShouldBe("probe ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ring_buffer_wraps_at_capacity()
|
||||||
|
{
|
||||||
|
using var mmf = new PostMortemMmf(_tempPath, capacity: 3);
|
||||||
|
for (var i = 0; i < 10; i++) mmf.Write(i, $"op-{i}");
|
||||||
|
|
||||||
|
var entries = mmf.ReadAll();
|
||||||
|
entries.Length.ShouldBe(3);
|
||||||
|
// Oldest surviving entry is op-7 (entries 7,8,9 survive in FIFO order).
|
||||||
|
entries[0].Message.ShouldBe("op-7");
|
||||||
|
entries[1].Message.ShouldBe("op-8");
|
||||||
|
entries[2].Message.ShouldBe("op-9");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Truncated_message_is_null_terminated_and_does_not_overflow()
|
||||||
|
{
|
||||||
|
using var mmf = new PostMortemMmf(_tempPath, capacity: 4);
|
||||||
|
var big = new string('x', 500); // longer than the 240-byte message capacity
|
||||||
|
mmf.Write(42, big);
|
||||||
|
|
||||||
|
var entries = mmf.ReadAll();
|
||||||
|
entries.Length.ShouldBe(1);
|
||||||
|
entries[0].Message.Length.ShouldBeLessThanOrEqualTo(240);
|
||||||
|
entries[0].OpKind.ShouldBe(42L);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reopening_with_existing_data_preserves_entries()
|
||||||
|
{
|
||||||
|
using (var first = new PostMortemMmf(_tempPath, capacity: 5))
|
||||||
|
{
|
||||||
|
first.Write(1, "first-run-1");
|
||||||
|
first.Write(2, "first-run-2");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var second = new PostMortemMmf(_tempPath, capacity: 5);
|
||||||
|
var entries = second.ReadAll();
|
||||||
|
entries.Length.ShouldBe(2);
|
||||||
|
entries[0].Message.ShouldBe("first-run-1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net48</TargetFramework>
|
||||||
|
<PlatformTarget>x86</PlatformTarget>
|
||||||
|
<Prefer32Bit>true</Prefer32Bit>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
using MessagePack;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MessagePack round-trip coverage for every FOCAS IPC contract. Ensures
|
||||||
|
/// <c>[Key]</c>-tagged fields survive serialize -> deserialize without loss so the
|
||||||
|
/// wire format stays stable across Proxy (.NET 10) and Host (.NET 4.8) processes.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class ContractRoundTripTests
|
||||||
|
{
|
||||||
|
private static T RoundTrip<T>(T value)
|
||||||
|
{
|
||||||
|
var bytes = MessagePackSerializer.Serialize(value);
|
||||||
|
return MessagePackSerializer.Deserialize<T>(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Hello_round_trips()
|
||||||
|
{
|
||||||
|
var original = new Hello
|
||||||
|
{
|
||||||
|
ProtocolMajor = 1,
|
||||||
|
ProtocolMinor = 2,
|
||||||
|
PeerName = "OtOpcUa.Server",
|
||||||
|
SharedSecret = "abc-123",
|
||||||
|
Features = ["bulk-read", "pmc-rmw"],
|
||||||
|
};
|
||||||
|
var decoded = RoundTrip(original);
|
||||||
|
decoded.ProtocolMajor.ShouldBe(1);
|
||||||
|
decoded.ProtocolMinor.ShouldBe(2);
|
||||||
|
decoded.PeerName.ShouldBe("OtOpcUa.Server");
|
||||||
|
decoded.SharedSecret.ShouldBe("abc-123");
|
||||||
|
decoded.Features.ShouldBe(["bulk-read", "pmc-rmw"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HelloAck_rejected_carries_reason()
|
||||||
|
{
|
||||||
|
var original = new HelloAck { Accepted = false, RejectReason = "bad secret" };
|
||||||
|
var decoded = RoundTrip(original);
|
||||||
|
decoded.Accepted.ShouldBeFalse();
|
||||||
|
decoded.RejectReason.ShouldBe("bad secret");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Heartbeat_and_ack_preserve_ticks()
|
||||||
|
{
|
||||||
|
var hb = RoundTrip(new Heartbeat { MonotonicTicks = 987654321 });
|
||||||
|
hb.MonotonicTicks.ShouldBe(987654321);
|
||||||
|
|
||||||
|
var ack = RoundTrip(new HeartbeatAck { MonotonicTicks = 987654321, HostUtcUnixMs = 1_700_000_000_000 });
|
||||||
|
ack.MonotonicTicks.ShouldBe(987654321);
|
||||||
|
ack.HostUtcUnixMs.ShouldBe(1_700_000_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ErrorResponse_preserves_code_and_message()
|
||||||
|
{
|
||||||
|
var decoded = RoundTrip(new ErrorResponse { Code = "Fwlib32Crashed", Message = "EW_UNEXPECTED" });
|
||||||
|
decoded.Code.ShouldBe("Fwlib32Crashed");
|
||||||
|
decoded.Message.ShouldBe("EW_UNEXPECTED");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpenSessionRequest_preserves_series_and_timeout()
|
||||||
|
{
|
||||||
|
var decoded = RoundTrip(new OpenSessionRequest
|
||||||
|
{
|
||||||
|
HostAddress = "192.168.1.50:8193",
|
||||||
|
TimeoutMs = 3500,
|
||||||
|
CncSeries = 5,
|
||||||
|
});
|
||||||
|
decoded.HostAddress.ShouldBe("192.168.1.50:8193");
|
||||||
|
decoded.TimeoutMs.ShouldBe(3500);
|
||||||
|
decoded.CncSeries.ShouldBe(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpenSessionResponse_failure_carries_error_code()
|
||||||
|
{
|
||||||
|
var decoded = RoundTrip(new OpenSessionResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
SessionId = 0,
|
||||||
|
Error = "unreachable",
|
||||||
|
ErrorCode = "EW_SOCKET",
|
||||||
|
});
|
||||||
|
decoded.Success.ShouldBeFalse();
|
||||||
|
decoded.Error.ShouldBe("unreachable");
|
||||||
|
decoded.ErrorCode.ShouldBe("EW_SOCKET");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FocasAddressDto_carries_pmc_with_bit_index()
|
||||||
|
{
|
||||||
|
var decoded = RoundTrip(new FocasAddressDto
|
||||||
|
{
|
||||||
|
Kind = 0,
|
||||||
|
PmcLetter = "R",
|
||||||
|
Number = 100,
|
||||||
|
BitIndex = 3,
|
||||||
|
});
|
||||||
|
decoded.Kind.ShouldBe(0);
|
||||||
|
decoded.PmcLetter.ShouldBe("R");
|
||||||
|
decoded.Number.ShouldBe(100);
|
||||||
|
decoded.BitIndex.ShouldBe(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FocasAddressDto_macro_omits_letter_and_bit()
|
||||||
|
{
|
||||||
|
var decoded = RoundTrip(new FocasAddressDto { Kind = 2, Number = 500 });
|
||||||
|
decoded.Kind.ShouldBe(2);
|
||||||
|
decoded.PmcLetter.ShouldBeNull();
|
||||||
|
decoded.Number.ShouldBe(500);
|
||||||
|
decoded.BitIndex.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReadRequest_and_response_round_trip()
|
||||||
|
{
|
||||||
|
var req = RoundTrip(new ReadRequest
|
||||||
|
{
|
||||||
|
SessionId = 42,
|
||||||
|
Address = new FocasAddressDto { Kind = 1, Number = 1815 },
|
||||||
|
DataType = FocasDataTypeCode.Int32,
|
||||||
|
TimeoutMs = 1500,
|
||||||
|
});
|
||||||
|
req.SessionId.ShouldBe(42);
|
||||||
|
req.Address.Number.ShouldBe(1815);
|
||||||
|
req.DataType.ShouldBe(FocasDataTypeCode.Int32);
|
||||||
|
|
||||||
|
var resp = RoundTrip(new ReadResponse
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
StatusCode = 0,
|
||||||
|
ValueBytes = MessagePackSerializer.Serialize((int)12345),
|
||||||
|
ValueTypeCode = FocasDataTypeCode.Int32,
|
||||||
|
SourceTimestampUtcUnixMs = 1_700_000_000_000,
|
||||||
|
});
|
||||||
|
resp.Success.ShouldBeTrue();
|
||||||
|
resp.StatusCode.ShouldBe(0u);
|
||||||
|
MessagePackSerializer.Deserialize<int>(resp.ValueBytes!).ShouldBe(12345);
|
||||||
|
resp.ValueTypeCode.ShouldBe(FocasDataTypeCode.Int32);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WriteRequest_and_response_round_trip()
|
||||||
|
{
|
||||||
|
var req = RoundTrip(new WriteRequest
|
||||||
|
{
|
||||||
|
SessionId = 1,
|
||||||
|
Address = new FocasAddressDto { Kind = 2, Number = 500 },
|
||||||
|
DataType = FocasDataTypeCode.Float64,
|
||||||
|
ValueBytes = MessagePackSerializer.Serialize(3.14159),
|
||||||
|
ValueTypeCode = FocasDataTypeCode.Float64,
|
||||||
|
});
|
||||||
|
MessagePackSerializer.Deserialize<double>(req.ValueBytes!).ShouldBe(3.14159);
|
||||||
|
|
||||||
|
var resp = RoundTrip(new WriteResponse { Success = true, StatusCode = 0 });
|
||||||
|
resp.Success.ShouldBeTrue();
|
||||||
|
resp.StatusCode.ShouldBe(0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PmcBitWriteRequest_preserves_bit_and_value()
|
||||||
|
{
|
||||||
|
var req = RoundTrip(new PmcBitWriteRequest
|
||||||
|
{
|
||||||
|
SessionId = 7,
|
||||||
|
Address = new FocasAddressDto { Kind = 0, PmcLetter = "Y", Number = 12 },
|
||||||
|
BitIndex = 5,
|
||||||
|
Value = true,
|
||||||
|
});
|
||||||
|
req.BitIndex.ShouldBe(5);
|
||||||
|
req.Value.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubscribeRequest_round_trips_multiple_items()
|
||||||
|
{
|
||||||
|
var original = new SubscribeRequest
|
||||||
|
{
|
||||||
|
SessionId = 1,
|
||||||
|
SubscriptionId = 100,
|
||||||
|
IntervalMs = 250,
|
||||||
|
Items =
|
||||||
|
[
|
||||||
|
new() { MonitoredItemId = 1, Address = new() { Kind = 0, PmcLetter = "R", Number = 100 }, DataType = FocasDataTypeCode.Bit },
|
||||||
|
new() { MonitoredItemId = 2, Address = new() { Kind = 2, Number = 500 }, DataType = FocasDataTypeCode.Float64 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
var decoded = RoundTrip(original);
|
||||||
|
decoded.Items.Length.ShouldBe(2);
|
||||||
|
decoded.Items[0].MonitoredItemId.ShouldBe(1);
|
||||||
|
decoded.Items[0].Address.PmcLetter.ShouldBe("R");
|
||||||
|
decoded.Items[1].DataType.ShouldBe(FocasDataTypeCode.Float64);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubscribeResponse_rejected_items_survive()
|
||||||
|
{
|
||||||
|
var decoded = RoundTrip(new SubscribeResponse
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
RejectedMonitoredItemIds = [2, 7],
|
||||||
|
});
|
||||||
|
decoded.RejectedMonitoredItemIds.ShouldBe([2, 7]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnsubscribeRequest_round_trips()
|
||||||
|
{
|
||||||
|
var decoded = RoundTrip(new UnsubscribeRequest { SubscriptionId = 42 });
|
||||||
|
decoded.SubscriptionId.ShouldBe(42);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OnDataChangeNotification_round_trips()
|
||||||
|
{
|
||||||
|
var original = new OnDataChangeNotification
|
||||||
|
{
|
||||||
|
SubscriptionId = 100,
|
||||||
|
Changes =
|
||||||
|
[
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
MonitoredItemId = 1,
|
||||||
|
StatusCode = 0,
|
||||||
|
ValueBytes = MessagePackSerializer.Serialize(true),
|
||||||
|
ValueTypeCode = FocasDataTypeCode.Bit,
|
||||||
|
SourceTimestampUtcUnixMs = 1_700_000_000_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
var decoded = RoundTrip(original);
|
||||||
|
decoded.Changes.Length.ShouldBe(1);
|
||||||
|
MessagePackSerializer.Deserialize<bool>(decoded.Changes[0].ValueBytes!).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProbeRequest_and_response_round_trip()
|
||||||
|
{
|
||||||
|
var req = RoundTrip(new ProbeRequest { SessionId = 1, TimeoutMs = 500 });
|
||||||
|
req.TimeoutMs.ShouldBe(500);
|
||||||
|
|
||||||
|
var resp = RoundTrip(new ProbeResponse { Healthy = true, ObservedAtUtcUnixMs = 1_700_000_000_000 });
|
||||||
|
resp.Healthy.ShouldBeTrue();
|
||||||
|
resp.ObservedAtUtcUnixMs.ShouldBe(1_700_000_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RuntimeStatusChangeNotification_round_trips()
|
||||||
|
{
|
||||||
|
var decoded = RoundTrip(new RuntimeStatusChangeNotification
|
||||||
|
{
|
||||||
|
SessionId = 5,
|
||||||
|
RuntimeStatus = "Stopped",
|
||||||
|
ObservedAtUtcUnixMs = 1_700_000_000_000,
|
||||||
|
});
|
||||||
|
decoded.RuntimeStatus.ShouldBe("Stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecycleHostRequest_and_response_round_trip()
|
||||||
|
{
|
||||||
|
var req = RoundTrip(new RecycleHostRequest { Kind = "Hard", Reason = "wedge-detected" });
|
||||||
|
req.Kind.ShouldBe("Hard");
|
||||||
|
req.Reason.ShouldBe("wedge-detected");
|
||||||
|
|
||||||
|
var resp = RoundTrip(new RecycleStatusResponse { Accepted = true, GraceSeconds = 20 });
|
||||||
|
resp.Accepted.ShouldBeTrue();
|
||||||
|
resp.GraceSeconds.ShouldBe(20);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using System.IO;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class FramingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task FrameWriter_round_trips_single_frame_through_FrameReader()
|
||||||
|
{
|
||||||
|
var buffer = new MemoryStream();
|
||||||
|
using (var writer = new FrameWriter(buffer, leaveOpen: true))
|
||||||
|
{
|
||||||
|
await writer.WriteAsync(FocasMessageKind.Hello,
|
||||||
|
new Hello { PeerName = "proxy", SharedSecret = "s3cr3t" }, TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.Position = 0;
|
||||||
|
using var reader = new FrameReader(buffer, leaveOpen: true);
|
||||||
|
var frame = await reader.ReadFrameAsync(TestContext.Current.CancellationToken);
|
||||||
|
frame.ShouldNotBeNull();
|
||||||
|
frame!.Value.Kind.ShouldBe(FocasMessageKind.Hello);
|
||||||
|
var hello = FrameReader.Deserialize<Hello>(frame.Value.Body);
|
||||||
|
hello.PeerName.ShouldBe("proxy");
|
||||||
|
hello.SharedSecret.ShouldBe("s3cr3t");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FrameReader_returns_null_on_clean_EOF_at_frame_boundary()
|
||||||
|
{
|
||||||
|
using var empty = new MemoryStream();
|
||||||
|
using var reader = new FrameReader(empty, leaveOpen: true);
|
||||||
|
var frame = await reader.ReadFrameAsync(TestContext.Current.CancellationToken);
|
||||||
|
frame.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FrameReader_throws_on_oversized_length_prefix()
|
||||||
|
{
|
||||||
|
var hostile = new byte[] { 0x7F, 0xFF, 0xFF, 0xFF, 0x01 }; // length > 16 MiB
|
||||||
|
using var stream = new MemoryStream(hostile);
|
||||||
|
using var reader = new FrameReader(stream, leaveOpen: true);
|
||||||
|
await Should.ThrowAsync<InvalidDataException>(async () =>
|
||||||
|
await reader.ReadFrameAsync(TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FrameReader_throws_on_mid_frame_eof()
|
||||||
|
{
|
||||||
|
var buffer = new MemoryStream();
|
||||||
|
using (var writer = new FrameWriter(buffer, leaveOpen: true))
|
||||||
|
{
|
||||||
|
await writer.WriteAsync(FocasMessageKind.Hello, new Hello { PeerName = "x" },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
// Truncate so body is incomplete.
|
||||||
|
var truncated = buffer.ToArray()[..(buffer.ToArray().Length - 2)];
|
||||||
|
using var partial = new MemoryStream(truncated);
|
||||||
|
using var reader = new FrameReader(partial, leaveOpen: true);
|
||||||
|
await Should.ThrowAsync<EndOfStreamException>(async () =>
|
||||||
|
await reader.ReadFrameAsync(TestContext.Current.CancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FrameWriter_serializes_concurrent_writes()
|
||||||
|
{
|
||||||
|
var buffer = new MemoryStream();
|
||||||
|
using var writer = new FrameWriter(buffer, leaveOpen: true);
|
||||||
|
|
||||||
|
var tasks = Enumerable.Range(0, 20).Select(i => writer.WriteAsync(
|
||||||
|
FocasMessageKind.Heartbeat,
|
||||||
|
new Heartbeat { MonotonicTicks = i },
|
||||||
|
TestContext.Current.CancellationToken)).ToArray();
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
buffer.Position = 0;
|
||||||
|
using var reader = new FrameReader(buffer, leaveOpen: true);
|
||||||
|
var seen = new List<long>();
|
||||||
|
while (await reader.ReadFrameAsync(TestContext.Current.CancellationToken) is { } frame)
|
||||||
|
{
|
||||||
|
frame.Kind.ShouldBe(FocasMessageKind.Heartbeat);
|
||||||
|
seen.Add(FrameReader.Deserialize<Heartbeat>(frame.Body).MonotonicTicks);
|
||||||
|
}
|
||||||
|
seen.Count.ShouldBe(20);
|
||||||
|
seen.OrderBy(x => x).ShouldBe(Enumerable.Range(0, 20).Select(x => (long)x));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MessageKind_values_are_stable()
|
||||||
|
{
|
||||||
|
// Guardrail — if someone reorders/renumbers, the wire format breaks for deployed peers.
|
||||||
|
((byte)FocasMessageKind.Hello).ShouldBe((byte)0x01);
|
||||||
|
((byte)FocasMessageKind.Heartbeat).ShouldBe((byte)0x03);
|
||||||
|
((byte)FocasMessageKind.OpenSessionRequest).ShouldBe((byte)0x10);
|
||||||
|
((byte)FocasMessageKind.ReadRequest).ShouldBe((byte)0x30);
|
||||||
|
((byte)FocasMessageKind.WriteRequest).ShouldBe((byte)0x32);
|
||||||
|
((byte)FocasMessageKind.PmcBitWriteRequest).ShouldBe((byte)0x34);
|
||||||
|
((byte)FocasMessageKind.SubscribeRequest).ShouldBe((byte)0x40);
|
||||||
|
((byte)FocasMessageKind.OnDataChangeNotification).ShouldBe((byte)0x43);
|
||||||
|
((byte)FocasMessageKind.ProbeRequest).ShouldBe((byte)0x70);
|
||||||
|
((byte)FocasMessageKind.ErrorResponse).ShouldBe((byte)0xFE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||||
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
using MessagePack;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.Contracts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end IPC round-trips over an in-memory loopback: <c>IpcFocasClient</c> talks
|
||||||
|
/// to a test fake that plays the Host's role by reading frames, dispatching on kind,
|
||||||
|
/// and responding with canned DTOs. Validates that every <see cref="IFocasClient"/>
|
||||||
|
/// method translates to the right wire frame + decodes the response correctly.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class IpcFocasClientTests
|
||||||
|
{
|
||||||
|
private const string Secret = "test-secret";
|
||||||
|
|
||||||
|
private static async Task ServerLoopAsync(Stream serverSide, Func<FocasMessageKind, byte[], FrameWriter, Task> dispatch, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var reader = new FrameReader(serverSide, leaveOpen: true);
|
||||||
|
using var writer = new FrameWriter(serverSide, leaveOpen: true);
|
||||||
|
|
||||||
|
// Hello handshake.
|
||||||
|
var first = await reader.ReadFrameAsync(ct);
|
||||||
|
if (first is null) return;
|
||||||
|
var hello = MessagePackSerializer.Deserialize<Hello>(first.Value.Body);
|
||||||
|
var accepted = hello.SharedSecret == Secret;
|
||||||
|
await writer.WriteAsync(FocasMessageKind.HelloAck,
|
||||||
|
new HelloAck { Accepted = accepted, RejectReason = accepted ? null : "wrong-secret" }, ct);
|
||||||
|
if (!accepted) return;
|
||||||
|
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var frame = await reader.ReadFrameAsync(ct);
|
||||||
|
if (frame is null) return;
|
||||||
|
await dispatch(frame.Value.Kind, frame.Value.Body, writer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Connect_sends_OpenSessionRequest_and_caches_session_id()
|
||||||
|
{
|
||||||
|
await using var loop = new IpcLoopback();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
OpenSessionRequest? received = null;
|
||||||
|
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
|
||||||
|
{
|
||||||
|
if (kind == FocasMessageKind.OpenSessionRequest)
|
||||||
|
{
|
||||||
|
received = MessagePackSerializer.Deserialize<OpenSessionRequest>(body);
|
||||||
|
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
|
||||||
|
new OpenSessionResponse { Success = true, SessionId = 42 }, cts.Token);
|
||||||
|
}
|
||||||
|
}, cts.Token));
|
||||||
|
|
||||||
|
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
|
||||||
|
var client = new IpcFocasClient(ipc, FocasCncSeries.Thirty_i);
|
||||||
|
await client.ConnectAsync(new FocasHostAddress("192.168.1.50", 8193), TimeSpan.FromSeconds(2), cts.Token);
|
||||||
|
|
||||||
|
client.IsConnected.ShouldBeTrue();
|
||||||
|
received.ShouldNotBeNull();
|
||||||
|
received!.HostAddress.ShouldBe("192.168.1.50:8193");
|
||||||
|
received.CncSeries.ShouldBe((int)FocasCncSeries.Thirty_i);
|
||||||
|
|
||||||
|
cts.Cancel();
|
||||||
|
try { await server; } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Connect_throws_when_host_rejects()
|
||||||
|
{
|
||||||
|
await using var loop = new IpcLoopback();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
|
||||||
|
{
|
||||||
|
if (kind == FocasMessageKind.OpenSessionRequest)
|
||||||
|
{
|
||||||
|
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
|
||||||
|
new OpenSessionResponse { Success = false, Error = "unreachable", ErrorCode = "EW_SOCKET" }, cts.Token);
|
||||||
|
}
|
||||||
|
}, cts.Token));
|
||||||
|
|
||||||
|
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
|
||||||
|
var client = new IpcFocasClient(ipc);
|
||||||
|
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||||
|
await client.ConnectAsync(new FocasHostAddress("10.0.0.1", 8193), TimeSpan.FromSeconds(1), cts.Token));
|
||||||
|
|
||||||
|
cts.Cancel();
|
||||||
|
try { await server; } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Read_sends_ReadRequest_and_decodes_response()
|
||||||
|
{
|
||||||
|
await using var loop = new IpcLoopback();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
ReadRequest? received = null;
|
||||||
|
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
|
||||||
|
{
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case FocasMessageKind.OpenSessionRequest:
|
||||||
|
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
|
||||||
|
new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token);
|
||||||
|
break;
|
||||||
|
case FocasMessageKind.ReadRequest:
|
||||||
|
received = MessagePackSerializer.Deserialize<ReadRequest>(body);
|
||||||
|
await writer.WriteAsync(FocasMessageKind.ReadResponse,
|
||||||
|
new ReadResponse
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
StatusCode = 0,
|
||||||
|
ValueBytes = MessagePackSerializer.Serialize((int)12345),
|
||||||
|
ValueTypeCode = FocasDataTypeCode.Int32,
|
||||||
|
}, cts.Token);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, cts.Token));
|
||||||
|
|
||||||
|
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
|
||||||
|
var client = new IpcFocasClient(ipc);
|
||||||
|
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token);
|
||||||
|
|
||||||
|
var addr = new FocasAddress(FocasAreaKind.Parameter, null, 1815, null);
|
||||||
|
var (value, status) = await client.ReadAsync(addr, FocasDataType.Int32, cts.Token);
|
||||||
|
status.ShouldBe(0u);
|
||||||
|
value.ShouldBe(12345);
|
||||||
|
received!.Address.Number.ShouldBe(1815);
|
||||||
|
|
||||||
|
cts.Cancel();
|
||||||
|
try { await server; } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Write_sends_WriteRequest_and_returns_status()
|
||||||
|
{
|
||||||
|
await using var loop = new IpcLoopback();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
|
||||||
|
{
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case FocasMessageKind.OpenSessionRequest:
|
||||||
|
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
|
||||||
|
new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token);
|
||||||
|
break;
|
||||||
|
case FocasMessageKind.WriteRequest:
|
||||||
|
var req = MessagePackSerializer.Deserialize<WriteRequest>(body);
|
||||||
|
MessagePackSerializer.Deserialize<double>(req.ValueBytes!).ShouldBe(3.14);
|
||||||
|
await writer.WriteAsync(FocasMessageKind.WriteResponse,
|
||||||
|
new WriteResponse { Success = true, StatusCode = 0 }, cts.Token);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, cts.Token));
|
||||||
|
|
||||||
|
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
|
||||||
|
var client = new IpcFocasClient(ipc);
|
||||||
|
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token);
|
||||||
|
|
||||||
|
var status = await client.WriteAsync(new FocasAddress(FocasAreaKind.Macro, null, 500, null),
|
||||||
|
FocasDataType.Float64, 3.14, cts.Token);
|
||||||
|
status.ShouldBe(0u);
|
||||||
|
|
||||||
|
cts.Cancel();
|
||||||
|
try { await server; } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Write_pmc_bit_sends_first_class_RMW_frame()
|
||||||
|
{
|
||||||
|
await using var loop = new IpcLoopback();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
PmcBitWriteRequest? received = null;
|
||||||
|
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
|
||||||
|
{
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case FocasMessageKind.OpenSessionRequest:
|
||||||
|
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
|
||||||
|
new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token);
|
||||||
|
break;
|
||||||
|
case FocasMessageKind.PmcBitWriteRequest:
|
||||||
|
received = MessagePackSerializer.Deserialize<PmcBitWriteRequest>(body);
|
||||||
|
await writer.WriteAsync(FocasMessageKind.PmcBitWriteResponse,
|
||||||
|
new PmcBitWriteResponse { Success = true, StatusCode = 0 }, cts.Token);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, cts.Token));
|
||||||
|
|
||||||
|
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
|
||||||
|
var client = new IpcFocasClient(ipc);
|
||||||
|
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token);
|
||||||
|
|
||||||
|
var addr = new FocasAddress(FocasAreaKind.Pmc, "R", 100, BitIndex: 5);
|
||||||
|
var status = await client.WriteAsync(addr, FocasDataType.Bit, true, cts.Token);
|
||||||
|
status.ShouldBe(0u);
|
||||||
|
received.ShouldNotBeNull();
|
||||||
|
received!.BitIndex.ShouldBe(5);
|
||||||
|
received.Value.ShouldBeTrue();
|
||||||
|
received.Address.PmcLetter.ShouldBe("R");
|
||||||
|
|
||||||
|
cts.Cancel();
|
||||||
|
try { await server; } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Probe_round_trips_health_from_host()
|
||||||
|
{
|
||||||
|
await using var loop = new IpcLoopback();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
|
||||||
|
{
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case FocasMessageKind.OpenSessionRequest:
|
||||||
|
await writer.WriteAsync(FocasMessageKind.OpenSessionResponse,
|
||||||
|
new OpenSessionResponse { Success = true, SessionId = 1 }, cts.Token);
|
||||||
|
break;
|
||||||
|
case FocasMessageKind.ProbeRequest:
|
||||||
|
await writer.WriteAsync(FocasMessageKind.ProbeResponse,
|
||||||
|
new ProbeResponse { Healthy = true, ObservedAtUtcUnixMs = 1_700_000_000_000 }, cts.Token);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, cts.Token));
|
||||||
|
|
||||||
|
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
|
||||||
|
var client = new IpcFocasClient(ipc);
|
||||||
|
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token);
|
||||||
|
(await client.ProbeAsync(cts.Token)).ShouldBeTrue();
|
||||||
|
|
||||||
|
cts.Cancel();
|
||||||
|
try { await server; } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Error_response_from_host_surfaces_as_FocasIpcException()
|
||||||
|
{
|
||||||
|
await using var loop = new IpcLoopback();
|
||||||
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
var server = Task.Run(() => ServerLoopAsync(loop.ServerSide, async (kind, body, writer) =>
|
||||||
|
{
|
||||||
|
await writer.WriteAsync(FocasMessageKind.ErrorResponse,
|
||||||
|
new ErrorResponse { Code = "backend-exception", Message = "simulated" }, cts.Token);
|
||||||
|
}, cts.Token));
|
||||||
|
|
||||||
|
var ipc = await FocasIpcClient.ConnectAsync(loop.ClientSide, Secret, cts.Token);
|
||||||
|
var client = new IpcFocasClient(ipc);
|
||||||
|
var ex = await Should.ThrowAsync<FocasIpcException>(async () =>
|
||||||
|
await client.ConnectAsync(new FocasHostAddress("h", 8193), TimeSpan.FromSeconds(1), cts.Token));
|
||||||
|
ex.Code.ShouldBe("backend-exception");
|
||||||
|
|
||||||
|
cts.Cancel();
|
||||||
|
try { await server; } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
72
tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/IpcLoopback.cs
Normal file
72
tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/IpcLoopback.cs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
using System.IO.Pipelines;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bidirectional in-memory stream pair for IPC tests. Two <c>System.IO.Pipelines.Pipe</c>
|
||||||
|
/// instances — one per direction — exposed as <see cref="System.IO.Stream"/> endpoints
|
||||||
|
/// via <c>PipeReader.AsStream</c> / <c>PipeWriter.AsStream</c>. Lets the test set up a
|
||||||
|
/// <c>FocasIpcClient</c> on one end and a minimal fake server loop on the other without
|
||||||
|
/// standing up a real named pipe.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class IpcLoopback : IAsyncDisposable
|
||||||
|
{
|
||||||
|
public Stream ClientSide { get; }
|
||||||
|
public Stream ServerSide { get; }
|
||||||
|
|
||||||
|
public IpcLoopback()
|
||||||
|
{
|
||||||
|
var clientToServer = new Pipe();
|
||||||
|
var serverToClient = new Pipe();
|
||||||
|
|
||||||
|
ClientSide = new DuplexPipeStream(serverToClient.Reader.AsStream(), clientToServer.Writer.AsStream());
|
||||||
|
ServerSide = new DuplexPipeStream(clientToServer.Reader.AsStream(), serverToClient.Writer.AsStream());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await ClientSide.DisposeAsync();
|
||||||
|
await ServerSide.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DuplexPipeStream(Stream read, Stream write) : Stream
|
||||||
|
{
|
||||||
|
public override bool CanRead => true;
|
||||||
|
public override bool CanWrite => true;
|
||||||
|
public override bool CanSeek => false;
|
||||||
|
public override long Length => throw new NotSupportedException();
|
||||||
|
public override long Position
|
||||||
|
{
|
||||||
|
get => throw new NotSupportedException();
|
||||||
|
set => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int Read(byte[] buffer, int offset, int count) => read.Read(buffer, offset, count);
|
||||||
|
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
|
||||||
|
read.ReadAsync(buffer, offset, count, ct);
|
||||||
|
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct = default) =>
|
||||||
|
read.ReadAsync(buffer, ct);
|
||||||
|
|
||||||
|
public override void Write(byte[] buffer, int offset, int count) => write.Write(buffer, offset, count);
|
||||||
|
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
|
||||||
|
write.WriteAsync(buffer, offset, count, ct);
|
||||||
|
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken ct = default) =>
|
||||||
|
write.WriteAsync(buffer, ct);
|
||||||
|
|
||||||
|
public override void Flush() => write.Flush();
|
||||||
|
public override Task FlushAsync(CancellationToken ct) => write.FlushAsync(ct);
|
||||||
|
|
||||||
|
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||||
|
public override void SetLength(long value) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
read.Dispose();
|
||||||
|
write.Dispose();
|
||||||
|
}
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System.IO.MemoryMappedFiles;
|
||||||
|
using System.Text;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Proxy-side <see cref="PostMortemReader"/> must read the Host's MMF format
|
||||||
|
/// (magic 'OFPC', 256-byte entries). This test writes a hand-crafted file that mimics
|
||||||
|
/// the Host's layout exactly + asserts the reader decodes it correctly. Keeps the two
|
||||||
|
/// codebases in lockstep on the wire format without needing to reference the net48
|
||||||
|
/// Host assembly from the net10 test project.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class PostMortemReaderCompatibilityTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _tempPath = Path.Combine(Path.GetTempPath(), $"focas-mmf-compat-{Guid.NewGuid():N}.bin");
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (File.Exists(_tempPath)) File.Delete(_tempPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reader_parses_host_format_and_returns_entries_in_oldest_first_order()
|
||||||
|
{
|
||||||
|
const int magic = 0x4F465043;
|
||||||
|
const int capacity = 5;
|
||||||
|
const int headerBytes = 16;
|
||||||
|
const int entryBytes = 256;
|
||||||
|
const int messageOffset = 16;
|
||||||
|
var fileBytes = headerBytes + capacity * entryBytes;
|
||||||
|
|
||||||
|
using (var fs = new FileStream(_tempPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Read))
|
||||||
|
{
|
||||||
|
fs.SetLength(fileBytes);
|
||||||
|
using var mmf = MemoryMappedFile.CreateFromFile(fs, null, fileBytes,
|
||||||
|
MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, leaveOpen: false);
|
||||||
|
using var acc = mmf.CreateViewAccessor(0, fileBytes, MemoryMappedFileAccess.ReadWrite);
|
||||||
|
acc.Write(0, magic);
|
||||||
|
acc.Write(4, 1);
|
||||||
|
acc.Write(8, capacity);
|
||||||
|
acc.Write(12, 2); // writeIndex — next write would land at slot 2
|
||||||
|
|
||||||
|
void WriteEntry(int slot, long ts, long op, string msg)
|
||||||
|
{
|
||||||
|
var offset = headerBytes + slot * entryBytes;
|
||||||
|
acc.Write(offset + 0, ts);
|
||||||
|
acc.Write(offset + 8, op);
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(msg);
|
||||||
|
acc.WriteArray(offset + messageOffset, bytes, 0, bytes.Length);
|
||||||
|
acc.Write(offset + messageOffset + bytes.Length, (byte)0);
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteEntry(0, 100, 1, "op-a");
|
||||||
|
WriteEntry(1, 200, 2, "op-b");
|
||||||
|
// Slots 2,3 unwritten (ts=0) — reader must skip.
|
||||||
|
WriteEntry(4, 50, 9, "old-wrapped");
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries = new PostMortemReader(_tempPath).ReadAll();
|
||||||
|
entries.Length.ShouldBe(3);
|
||||||
|
// writeIndex=2 means the ring walk starts at slot 2, so iteration order is 2→3→4→0→1.
|
||||||
|
// Slots 2 and 3 are empty; 4 yields "old-wrapped"; then 0="op-a", 1="op-b".
|
||||||
|
entries[0].Message.ShouldBe("old-wrapped");
|
||||||
|
entries[1].Message.ShouldBe("op-a");
|
||||||
|
entries[2].Message.ShouldBe("op-b");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reader_returns_empty_when_file_missing()
|
||||||
|
{
|
||||||
|
new PostMortemReader(_tempPath + "-does-not-exist").ReadAll().ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reader_returns_empty_when_magic_mismatches()
|
||||||
|
{
|
||||||
|
File.WriteAllBytes(_tempPath, new byte[1024]);
|
||||||
|
new PostMortemReader(_tempPath).ReadAll().ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
249
tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/SupervisorTests.cs
Normal file
249
tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/SupervisorTests.cs
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Supervisor;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class BackoffTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Default_sequence_is_5s_15s_60s_then_clamped()
|
||||||
|
{
|
||||||
|
var b = new Backoff();
|
||||||
|
b.Next().ShouldBe(TimeSpan.FromSeconds(5));
|
||||||
|
b.Next().ShouldBe(TimeSpan.FromSeconds(15));
|
||||||
|
b.Next().ShouldBe(TimeSpan.FromSeconds(60));
|
||||||
|
b.Next().ShouldBe(TimeSpan.FromSeconds(60));
|
||||||
|
b.Next().ShouldBe(TimeSpan.FromSeconds(60));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RecordStableRun_resets_the_ladder_to_the_start()
|
||||||
|
{
|
||||||
|
var b = new Backoff();
|
||||||
|
b.Next(); b.Next();
|
||||||
|
b.AttemptIndex.ShouldBe(2);
|
||||||
|
b.RecordStableRun();
|
||||||
|
b.AttemptIndex.ShouldBe(0);
|
||||||
|
b.Next().ShouldBe(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class CircuitBreakerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Allows_crashes_below_threshold()
|
||||||
|
{
|
||||||
|
var b = new CircuitBreaker();
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
b.TryRecordCrash(now, out _).ShouldBeTrue();
|
||||||
|
b.TryRecordCrash(now.AddSeconds(1), out _).ShouldBeTrue();
|
||||||
|
b.TryRecordCrash(now.AddSeconds(2), out _).ShouldBeTrue();
|
||||||
|
b.StickyAlertActive.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Opens_when_exceeding_threshold_in_window()
|
||||||
|
{
|
||||||
|
var b = new CircuitBreaker();
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
b.TryRecordCrash(now, out _);
|
||||||
|
b.TryRecordCrash(now.AddSeconds(1), out _);
|
||||||
|
b.TryRecordCrash(now.AddSeconds(2), out _);
|
||||||
|
b.TryRecordCrash(now.AddSeconds(3), out var cooldown).ShouldBeFalse();
|
||||||
|
cooldown.ShouldBe(TimeSpan.FromHours(1));
|
||||||
|
b.StickyAlertActive.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Escalates_cooldown_after_second_open()
|
||||||
|
{
|
||||||
|
var b = new CircuitBreaker();
|
||||||
|
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
// First burst — 4 crashes opens breaker with 1h cooldown.
|
||||||
|
for (var i = 0; i < 4; i++) b.TryRecordCrash(t0.AddSeconds(i), out _);
|
||||||
|
b.StickyAlertActive.ShouldBeTrue();
|
||||||
|
|
||||||
|
// Wait past cooldown. The first crash after cooldown-elapsed resets _openSinceUtc and
|
||||||
|
// bumps escalation level; the next 3 crashes then re-open with the escalated 4h cooldown.
|
||||||
|
b.TryRecordCrash(t0.AddHours(1).AddMinutes(1), out _);
|
||||||
|
var t1 = t0.AddHours(1).AddMinutes(1).AddSeconds(1);
|
||||||
|
b.TryRecordCrash(t1, out _);
|
||||||
|
b.TryRecordCrash(t1.AddSeconds(1), out _);
|
||||||
|
b.TryRecordCrash(t1.AddSeconds(2), out var cooldown).ShouldBeFalse();
|
||||||
|
cooldown.ShouldBe(TimeSpan.FromHours(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ManualReset_clears_everything()
|
||||||
|
{
|
||||||
|
var b = new CircuitBreaker();
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
for (var i = 0; i < 5; i++) b.TryRecordCrash(now.AddSeconds(i), out _);
|
||||||
|
b.StickyAlertActive.ShouldBeTrue();
|
||||||
|
b.ManualReset();
|
||||||
|
b.StickyAlertActive.ShouldBeFalse();
|
||||||
|
b.TryRecordCrash(now.AddSeconds(10), out _).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class HeartbeatMonitorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Three_consecutive_misses_declares_dead()
|
||||||
|
{
|
||||||
|
var m = new HeartbeatMonitor();
|
||||||
|
m.RecordMiss().ShouldBeFalse();
|
||||||
|
m.RecordMiss().ShouldBeFalse();
|
||||||
|
m.RecordMiss().ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ack_resets_the_miss_counter()
|
||||||
|
{
|
||||||
|
var m = new HeartbeatMonitor();
|
||||||
|
m.RecordMiss(); m.RecordMiss();
|
||||||
|
m.ConsecutiveMisses.ShouldBe(2);
|
||||||
|
m.RecordAck(DateTime.UtcNow);
|
||||||
|
m.ConsecutiveMisses.ShouldBe(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class FocasHostSupervisorTests
|
||||||
|
{
|
||||||
|
private sealed class FakeLauncher : IHostProcessLauncher
|
||||||
|
{
|
||||||
|
public int LaunchAttempts { get; private set; }
|
||||||
|
public int Terminations { get; private set; }
|
||||||
|
public Queue<Func<IFocasClient>> Plan { get; } = new();
|
||||||
|
public bool IsProcessAlive { get; set; }
|
||||||
|
|
||||||
|
public Task<IFocasClient> LaunchAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
LaunchAttempts++;
|
||||||
|
if (Plan.Count == 0) throw new InvalidOperationException("FakeLauncher plan exhausted");
|
||||||
|
var next = Plan.Dequeue()();
|
||||||
|
IsProcessAlive = true;
|
||||||
|
return Task.FromResult(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task TerminateAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
Terminations++;
|
||||||
|
IsProcessAlive = false;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubFocasClient : IFocasClient
|
||||||
|
{
|
||||||
|
public bool IsConnected => true;
|
||||||
|
public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
public Task<(object? value, uint status)> ReadAsync(FocasAddress a, FocasDataType t, CancellationToken ct) =>
|
||||||
|
Task.FromResult<(object?, uint)>((0, 0));
|
||||||
|
public Task<uint> WriteAsync(FocasAddress a, FocasDataType t, object? v, CancellationToken ct) => Task.FromResult(0u);
|
||||||
|
public Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(true);
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetOrLaunch_returns_client_on_first_success()
|
||||||
|
{
|
||||||
|
var launcher = new FakeLauncher();
|
||||||
|
launcher.Plan.Enqueue(() => new StubFocasClient());
|
||||||
|
var supervisor = new FocasHostSupervisor(launcher);
|
||||||
|
var client = await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
|
||||||
|
client.ShouldNotBeNull();
|
||||||
|
launcher.LaunchAttempts.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetOrLaunch_retries_after_transient_failure_with_backoff()
|
||||||
|
{
|
||||||
|
var launcher = new FakeLauncher();
|
||||||
|
launcher.Plan.Enqueue(() => throw new TimeoutException("pipe not ready"));
|
||||||
|
launcher.Plan.Enqueue(() => new StubFocasClient());
|
||||||
|
|
||||||
|
var backoff = new Backoff([TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(20)]);
|
||||||
|
var supervisor = new FocasHostSupervisor(launcher, backoff);
|
||||||
|
|
||||||
|
var unavailableMessages = new List<string>();
|
||||||
|
supervisor.OnUnavailable += m => unavailableMessages.Add(m);
|
||||||
|
|
||||||
|
var client = await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
|
||||||
|
client.ShouldNotBeNull();
|
||||||
|
launcher.LaunchAttempts.ShouldBe(2);
|
||||||
|
unavailableMessages.Count.ShouldBe(1);
|
||||||
|
unavailableMessages[0].ShouldContain("launch-failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Repeated_launch_failures_open_breaker_and_surface_InvalidOperation()
|
||||||
|
{
|
||||||
|
var launcher = new FakeLauncher();
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
launcher.Plan.Enqueue(() => throw new InvalidOperationException("simulated host refused"));
|
||||||
|
|
||||||
|
var supervisor = new FocasHostSupervisor(
|
||||||
|
launcher,
|
||||||
|
backoff: new Backoff([TimeSpan.FromMilliseconds(1)]),
|
||||||
|
breaker: new CircuitBreaker { CrashesAllowedPerWindow = 2, Window = TimeSpan.FromMinutes(5) });
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||||
|
await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken));
|
||||||
|
ex.Message.ShouldContain("circuit breaker");
|
||||||
|
supervisor.StickyAlertActive.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NotifyHostDeadAsync_terminates_current_and_fans_out_unavailable()
|
||||||
|
{
|
||||||
|
var launcher = new FakeLauncher();
|
||||||
|
launcher.Plan.Enqueue(() => new StubFocasClient());
|
||||||
|
var supervisor = new FocasHostSupervisor(launcher);
|
||||||
|
|
||||||
|
var messages = new List<string>();
|
||||||
|
supervisor.OnUnavailable += m => messages.Add(m);
|
||||||
|
await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await supervisor.NotifyHostDeadAsync("heartbeat-loss", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
launcher.Terminations.ShouldBe(1);
|
||||||
|
messages.ShouldContain("heartbeat-loss");
|
||||||
|
supervisor.ObservedCrashes.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AcknowledgeAndReset_clears_sticky_alert()
|
||||||
|
{
|
||||||
|
var launcher = new FakeLauncher();
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
launcher.Plan.Enqueue(() => throw new InvalidOperationException("refused"));
|
||||||
|
var supervisor = new FocasHostSupervisor(
|
||||||
|
launcher,
|
||||||
|
backoff: new Backoff([TimeSpan.FromMilliseconds(1)]),
|
||||||
|
breaker: new CircuitBreaker { CrashesAllowedPerWindow = 1 });
|
||||||
|
|
||||||
|
try { await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken); } catch { }
|
||||||
|
supervisor.StickyAlertActive.ShouldBeTrue();
|
||||||
|
|
||||||
|
supervisor.AcknowledgeAndReset();
|
||||||
|
supervisor.StickyAlertActive.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dispose_terminates_host_process()
|
||||||
|
{
|
||||||
|
var launcher = new FakeLauncher();
|
||||||
|
launcher.Plan.Enqueue(() => new StubFocasClient());
|
||||||
|
var supervisor = new FocasHostSupervisor(launcher);
|
||||||
|
await supervisor.GetOrLaunchAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
supervisor.Dispose();
|
||||||
|
launcher.Terminations.ShouldBe(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user