Compare commits
52 Commits
adr-001-eq
...
phase-7-st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ae715cca4 | ||
| d2bfcd9f1e | |||
|
|
e4dae01bac | ||
| 6ae638a6de | |||
|
|
2a74daf228 | ||
| 3eb5f1d9da | |||
|
|
f2c1cc84e9 | ||
| 8384e58655 | |||
|
|
96940aeb24 | ||
| 340f580be0 | |||
|
|
8d88ffa14d | ||
| 446a5c022c | |||
|
|
5033609944 | ||
| 9034294b77 | |||
|
|
3892555631 | ||
| 3609a5c676 | |||
|
|
a6f53e5b22 | ||
| b968496471 | |||
|
|
e6ff39148b | ||
| 4a6fe7fa7e | |||
|
|
a6be2f77b5 | ||
| 64bbc12e8e | |||
|
|
a0cf7c5860 | ||
| 2fe1a326dc | |||
|
|
7b49ea13c7 | ||
| b820b9a05f | |||
|
|
58a0cccc67 | ||
| 231148d7f0 | |||
|
|
fdb268cee0 | ||
| 4473197cf5 | |||
|
|
0e1dcc119e | ||
| 27d135bd59 | |||
|
|
6609141493 | ||
| 3b3e814855 | |||
|
|
c985c50a96 | ||
| 820567bc2a | |||
|
|
1d3544f18e | ||
| 4fe96fca9b | |||
|
|
4e80db4844 | ||
| f6d5763448 | |||
|
|
780358c790 | ||
| 1ac87f1fac | |||
|
|
432173c5c4 | ||
| f6d98cfa6b | |||
|
|
a29828e41e | ||
| f5076b4cdd | |||
|
|
2d97f241c0 | ||
| 5811ede744 | |||
|
|
1bf3938cdf | ||
| 7a42f6d84c | |||
|
|
2b2991c593 | ||
| 9711d0c097 |
@@ -3,6 +3,7 @@
|
||||
<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.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.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.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
||||
@@ -14,6 +15,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.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||
@@ -24,6 +27,7 @@
|
||||
<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.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.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.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>
|
||||
@@ -34,12 +38,18 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests.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.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.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.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests.csproj"/>
|
||||
|
||||
125
docs/drivers/AbLegacy-Test-Fixture.md
Normal file
125
docs/drivers/AbLegacy-Test-Fixture.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# AB Legacy test fixture
|
||||
|
||||
Coverage map + gap inventory for the AB Legacy (PCCC) driver — SLC 500 /
|
||||
MicroLogix / PLC-5 / LogixPccc-mode.
|
||||
|
||||
**TL;DR:** Docker integration-test scaffolding lives at
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/` (task #224),
|
||||
reusing the AB CIP `ab_server` image in PCCC mode with per-family
|
||||
compose profiles (`slc500` / `micrologix` / `plc5`). Scaffold passes
|
||||
the skip-when-absent contract cleanly. **Wire-level round-trip against
|
||||
`ab_server` PCCC mode currently fails** with `BadCommunicationError`
|
||||
on read/write (verified 2026-04-20) — ab_server's PCCC server-side
|
||||
coverage is narrower than libplctag's PCCC client expects. The smoke
|
||||
tests target the correct shape for real hardware + should pass when
|
||||
`AB_LEGACY_ENDPOINT` points at a real SLC 5/05 / MicroLogix. Unit tests
|
||||
via `FakeAbLegacyTag` still carry the contract coverage.
|
||||
|
||||
## What the fixture is
|
||||
|
||||
**Integration layer** (task #224, scaffolded with a known ab_server
|
||||
gap):
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/` with
|
||||
`AbLegacyServerFixture` (TCP-probes `localhost:44818`) + three smoke
|
||||
tests (parametric read across families, SLC500 write-then-read). Reuses
|
||||
the AB CIP `otopcua-ab-server:libplctag-release` image via a relative
|
||||
`build:` context in `Docker/docker-compose.yml` — one image, different
|
||||
`--plc` flags. See `Docker/README.md` §Known limitations for the
|
||||
ab_server PCCC round-trip gap + resolution paths.
|
||||
|
||||
**Unit layer**: `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` is
|
||||
still the primary coverage. All tests tagged `[Trait("Category", "Unit")]`.
|
||||
The driver accepts `IAbLegacyTagFactory` via ctor DI; every test
|
||||
supplies a `FakeAbLegacyTag`.
|
||||
|
||||
## What it actually covers (unit only)
|
||||
|
||||
- `AbLegacyAddressTests` — PCCC address parsing for SLC / MicroLogix / PLC-5
|
||||
/ LogixPccc-mode (`N7:0`, `F8:12`, `B3:0/5`, etc.)
|
||||
- `AbLegacyCapabilityTests` — data type mapping, read-only enforcement
|
||||
- `AbLegacyReadWriteTests` — read + write happy + error paths against the fake
|
||||
- `AbLegacyBitRmwTests` — bit-within-DINT read-modify-write serialization via
|
||||
per-parent `SemaphoreSlim` (mirrors the AB CIP + FOCAS PMC-bit pattern from #181)
|
||||
- `AbLegacyHostAndStatusTests` — probe + host-status transitions driven by
|
||||
fake-returned statuses
|
||||
- `AbLegacyDriverTests` — `IDriver` lifecycle
|
||||
|
||||
Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
|
||||
`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
|
||||
`IPerCallHostResolver`.
|
||||
|
||||
## What it does NOT cover
|
||||
|
||||
### 1. Wire-level PCCC
|
||||
|
||||
No PCCC frame is sent by the test suite. libplctag's PCCC subset (DF1,
|
||||
ControlNet-over-EtherNet, PLC-5 native EtherNet) is untested here;
|
||||
driver-side correctness depends on libplctag being correct.
|
||||
|
||||
### 2. Family-specific behavior
|
||||
|
||||
- SLC 500 timeout + retry thresholds (SLC's comm module has known slow-response
|
||||
edges) — unit fakes don't simulate timing.
|
||||
- MicroLogix 1100 / 1400 max-connection-count limits — not stressed.
|
||||
- PLC-5 native EtherNet connection setup (PCCC-encapsulated-in-CIP vs raw
|
||||
CSPv4) — routing covered at parse level only.
|
||||
|
||||
### 3. Multi-device routing
|
||||
|
||||
`IPerCallHostResolver` contract is verified; real PCCC wire routing across
|
||||
multiple gateways is not.
|
||||
|
||||
### 4. Alarms / history
|
||||
|
||||
PCCC has no alarm object + no history object. Driver doesn't implement
|
||||
`IAlarmSource` or `IHistoryProvider` — no test coverage is the correct shape.
|
||||
|
||||
### 5. File-type coverage
|
||||
|
||||
PCCC has many file types (N, F, B, T, C, R, S, ST, A) — the parser tests
|
||||
cover the common ones but uncommon ones (`R` counters, `S` status files,
|
||||
`A` ASCII strings) have thin coverage.
|
||||
|
||||
## When to trust AB Legacy tests, when to reach for a rig
|
||||
|
||||
| Question | Unit tests | Real PLC |
|
||||
| --- | --- | --- |
|
||||
| "Does `N7:0/5` parse correctly?" | yes | - |
|
||||
| "Does bit-in-word RMW serialize concurrent writers?" | yes | yes |
|
||||
| "Does the driver lifecycle hang / crash?" | yes | yes |
|
||||
| "Does a real read against an SLC 500 return correct bytes?" | no | yes (required) |
|
||||
| "Does MicroLogix 1100 respect its connection-count cap?" | no | yes (required) |
|
||||
| "Do PLC-5 ST-files round-trip correctly?" | no | yes (required) |
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
1. **Fix ab_server PCCC coverage upstream** — the scaffold lands the
|
||||
Docker infrastructure; the wire-level round-trip gap is in ab_server
|
||||
itself. Filing a patch to `libplctag/libplctag` to expand PCCC
|
||||
server-side opcode coverage would make the scaffolded smoke tests
|
||||
pass without a golden-box tier.
|
||||
2. **Rockwell RSEmulate 500 golden-box tier** — Rockwell's real emulator
|
||||
for SLC/MicroLogix/PLC-5. Would close UDT-equivalent (integer-file
|
||||
indirection), timer/counter decomposition, and real ladder execution
|
||||
gaps. Costs: RSLinx OEM license, Windows-only, Hyper-V conflict
|
||||
matching TwinCAT XAR + Logix Emulate, no clean PR-diffable project
|
||||
format (SLC/ML save as binary `.RSS`). Scaffold like the Logix
|
||||
Emulate tier when operationally worth it.
|
||||
3. **Lab rig** — used SLC 5/05 or MicroLogix 1100 on a dedicated
|
||||
network; parts are end-of-life but still available. PLC-5 +
|
||||
LogixPccc-mode behaviour + DF1 serial need specific controllers.
|
||||
|
||||
## Key fixture / config files
|
||||
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyServerFixture.cs`
|
||||
— TCP probe + skip attributes + env-var parsing
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs`
|
||||
— three wire-level smoke tests (currently blocked by ab_server PCCC gap)
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml`
|
||||
— compose profiles reusing AB CIP Dockerfile
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md`
|
||||
— known-limitations write-up + resolution paths
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs` —
|
||||
in-process fake + factory
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs` — scope remarks
|
||||
at the top of the file
|
||||
216
docs/drivers/AbServer-Test-Fixture.md
Normal file
216
docs/drivers/AbServer-Test-Fixture.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# ab_server test fixture
|
||||
|
||||
Coverage map + gap inventory for the AB CIP integration fixture backed by
|
||||
libplctag's `ab_server` simulator.
|
||||
|
||||
**TL;DR:** `ab_server` is a connectivity + atomic-read smoke harness for the AB
|
||||
CIP driver. It does **not** benchmark UDTs, alarms, or any family-specific
|
||||
quirk. UDT / alarm / quirk behavior is verified only by unit tests with
|
||||
`FakeAbCipTagRuntime`.
|
||||
|
||||
## What the fixture is
|
||||
|
||||
- **Binary**: `ab_server` — a C program in libplctag's
|
||||
`src/tools/ab_server/` ([libplctag/libplctag](https://github.com/libplctag/libplctag),
|
||||
MIT).
|
||||
- **Launcher**: Docker (only supported path). `Docker/Dockerfile`
|
||||
multi-stage-builds `ab_server` from source against a pinned libplctag
|
||||
tag + copies the binary into a slim runtime image.
|
||||
`Docker/docker-compose.yml` has per-family services (`controllogix`
|
||||
/ `compactlogix` / `micro800` / `guardlogix`); all bind `:44818`.
|
||||
- **Lifecycle**: `AbServerFixture` TCP-probes `127.0.0.1:44818` at
|
||||
collection init + records a skip reason when unreachable. Tests skip
|
||||
via `[AbServerFact]` / `[AbServerTheory]` which check the same probe.
|
||||
- **Profiles**: `KnownProfiles.{ControlLogix, CompactLogix, Micro800, GuardLogix}`
|
||||
in `AbServerProfile.cs` — thin Family + ComposeProfile + Notes records;
|
||||
the compose file is the canonical source of truth for which tags get
|
||||
seeded + which `--plc` mode the simulator boots in.
|
||||
- **Tests**: one smoke, `AbCipReadSmokeTests.Driver_reads_seeded_DInt_from_ab_server`,
|
||||
parametrized over all four profiles via `[AbServerTheory]` + `[MemberData]`.
|
||||
- **Endpoint override**: `AB_SERVER_ENDPOINT=host:port` points the
|
||||
fixture at a real PLC instead of the local container.
|
||||
|
||||
## What it actually covers
|
||||
|
||||
- Read path: driver → libplctag → CIP-over-EtherNet/IP → simulator → back.
|
||||
- Atomic Logix types per seed: `DINT`, `REAL`, `BOOL`, `SINT`, `STRING`.
|
||||
- One `DINT[16]` array tag (ControlLogix profile only).
|
||||
- `--plc controllogix` and `--plc compactlogix` mode dispatch.
|
||||
- The skip-on-missing-binary behavior (`AbServerFactAttribute`) so a fresh
|
||||
clone without the simulator stays green.
|
||||
|
||||
## What it does NOT cover
|
||||
|
||||
Each gap below is either stated explicitly in the profile's `Notes` field or
|
||||
inferable from the seed-tag set + smoke-test surface.
|
||||
|
||||
### 1. UDTs / CIP Template Object (class 0x6C)
|
||||
|
||||
ControlLogix profile `Notes`: *"ab_server lacks full UDT emulation."*
|
||||
|
||||
Unverified against `ab_server`:
|
||||
|
||||
- PR 6 structured read/write (`AbCipStructureMember` fan-out)
|
||||
- #179 Template Object shape reader (`CipTemplateObjectDecoder` + `FetchUdtShapeAsync`)
|
||||
- #194 whole-UDT read optimization (`AbCipUdtReadPlanner` +
|
||||
`AbCipUdtMemberLayout` + the `ReadGroupAsync` path in `AbCipDriver`)
|
||||
|
||||
Unit coverage: `AbCipFetchUdtShapeTests`, `CipTemplateObjectDecoderTests`,
|
||||
`AbCipUdtMemberLayoutTests`, `AbCipUdtReadPlannerTests`,
|
||||
`AbCipDriverWholeUdtReadTests` — all with golden Template-Object byte buffers
|
||||
+ offset-keyed `FakeAbCipTag` values.
|
||||
|
||||
### 2. ALMD / ALMA alarm projection (#177)
|
||||
|
||||
Depends on the ALMD UDT shape, which `ab_server` cannot emulate. The
|
||||
`OnAlarmEvent` raise/clear path + ack-write semantics are not exercised
|
||||
end-to-end.
|
||||
|
||||
Unit coverage: `AbCipAlarmProjectionTests` — fakes feed `InFaulted` /
|
||||
`Severity` via `ValuesByOffset` + assert the emitted `AlarmEventArgs`.
|
||||
|
||||
### 3. Micro800 unconnected-only path
|
||||
|
||||
Micro800 profile `Notes`: *"ab_server has no --plc micro800 — falls back to
|
||||
controllogix emulation."*
|
||||
|
||||
The empty routing path + unconnected-session requirement (PR 11) is unit-tested
|
||||
but never challenged at the CIP wire level. Real Micro800 (2080-series) on a
|
||||
lab rig would be the authoritative benchmark.
|
||||
|
||||
### 4. GuardLogix safety subsystem
|
||||
|
||||
GuardLogix profile `Notes`: *"ab_server doesn't emulate the safety
|
||||
subsystem."*
|
||||
|
||||
Only the `_S`-suffix naming classifier (PR 12, `SecurityClassification.ViewOnly`
|
||||
forced on safety tags) runs. Actual safety-partition write rejection — what
|
||||
happens when a non-safety write lands on a real `1756-L8xS` — is not exercised.
|
||||
|
||||
### 5. CompactLogix narrow ConnectionSize cap
|
||||
|
||||
CompactLogix profile `Notes`: *"ab_server lacks the narrower limit itself."*
|
||||
|
||||
Driver-side `AbCipPlcFamilyProfile` caps `ConnectionSize` at the CompactLogix
|
||||
value per PR 10, but `ab_server` accepts whatever the client asks for — the
|
||||
cap's correctness is trusted from its unit test, never stressed against a
|
||||
simulator that rejects oversized requests.
|
||||
|
||||
### 6. BOOL-within-DINT read-modify-write (#181)
|
||||
|
||||
The `AbCipDriver.WriteBitInDIntAsync` RMW path + its per-parent `SemaphoreSlim`
|
||||
serialization is unit-tested only (`AbCipBoolInDIntRmwTests`). `ab_server`
|
||||
seeds a plain `TestBOOL` tag; the `.N` bit-within-DINT syntax that triggers
|
||||
the RMW path is not exercised end-to-end.
|
||||
|
||||
### 7. Capability surfaces beyond read
|
||||
|
||||
No smoke test for:
|
||||
|
||||
- `IWritable.WriteAsync`
|
||||
- `ITagDiscovery.DiscoverAsync` (`@tags` walker)
|
||||
- `ISubscribable.SubscribeAsync` (poll-group engine)
|
||||
- `IHostConnectivityProbe` state transitions under wire failure
|
||||
- `IPerCallHostResolver` multi-device routing
|
||||
|
||||
The driver implements all of these + they have unit coverage, but the only
|
||||
end-to-end path `ab_server` validates today is atomic `ReadAsync`.
|
||||
|
||||
## Logix Emulate golden-box tier
|
||||
|
||||
Rockwell Studio 5000 Logix Emulate sits **above** ab_server in fidelity +
|
||||
**below** real hardware. When an operator has Emulate running on a
|
||||
reachable Windows box + sets two env vars, the suite promotes several
|
||||
behaviours from unit-only to end-to-end wire-level coverage:
|
||||
|
||||
```powershell
|
||||
$env:AB_SERVER_PROFILE = 'emulate'
|
||||
$env:AB_SERVER_ENDPOINT = '<emulate-pc-ip>:44818'
|
||||
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
```
|
||||
|
||||
With `AB_SERVER_PROFILE` unset or `abserver`, the Emulate-tier classes
|
||||
skip cleanly + the ab_server Docker fixture runs as usual.
|
||||
|
||||
| Gap this fixture doc calls out | ab_server | Logix Emulate | Real hardware |
|
||||
|---|---|---|---|
|
||||
| UDT / CIP Template Object (task #194) | no | **yes** | yes |
|
||||
| ALMD alarm projection (task #177) | no | **yes** | yes |
|
||||
| `@tags` Symbol Object walk with `Program:` scope | partial | **yes** | yes |
|
||||
| Add-On Instructions | no | **yes** | yes |
|
||||
| GuardLogix safety-partition write rejection | no | **yes** (Emulate 5580) | yes |
|
||||
| CompactLogix narrow ConnectionSize enforcement | no | **yes** (5370 firmware) | yes |
|
||||
| EtherNet/IP embedded-switch behaviour | no | no | yes |
|
||||
| Redundant chassis failover (1756-RM) | no | no | yes |
|
||||
| Motion control timing | no | no | yes |
|
||||
|
||||
**Tests that promote to Emulate** (gated on `AB_SERVER_PROFILE=emulate`
|
||||
via `AbServerProfileGate.SkipUnless`):
|
||||
|
||||
- `AbCipEmulateUdtReadTests.WholeUdt_read_decodes_each_member_at_its_Template_Object_offset`
|
||||
— #194 whole-UDT optimization, verified against real Template Object
|
||||
bytes
|
||||
- `AbCipEmulateAlmdTests.Real_ALMD_raise_fires_OnAlarmEvent_through_the_driver_projection`
|
||||
— #177 ALMD projection, verified against the real ALMD instruction
|
||||
|
||||
**Required Studio 5000 project state** is documented in
|
||||
[`tests/…/AbCip.IntegrationTests/LogixProject/README.md`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/LogixProject/README.md);
|
||||
the `.L5X` export lands there once the Emulate PC is on-site + the
|
||||
project is authored.
|
||||
|
||||
**Costs to accept**:
|
||||
|
||||
- **Rockwell TechConnect or per-seat license** — not redistributable;
|
||||
not CI-runnable. Each operator licenses their own Emulate install.
|
||||
- **Windows-only + Hyper-V conflict** — Emulate can't coexist with
|
||||
Docker Desktop's WSL 2 backend on the same OS, same way TwinCAT XAR
|
||||
can't (see `docs/v2/dev-environment.md` §Integration host).
|
||||
- **Manual lifecycle** — no `docker compose up` equivalent; operator
|
||||
opens Emulate, loads the L5X, clicks Run. The L5X in the repo keeps
|
||||
project state reproducible, runtime-start is human.
|
||||
|
||||
## When to trust ab_server, when to reach for a rig
|
||||
|
||||
| Question | ab_server | Unit tests | Logix Emulate | Lab rig |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| "Does the driver talk CIP at all?" | yes | - | yes | - |
|
||||
| "Is my atomic read path wired correctly?" | yes | yes | yes | yes |
|
||||
| "Does whole-UDT grouping work?" | no | yes | **yes** | yes |
|
||||
| "Do ALMD alarms raise + clear?" | no | yes | **yes** | yes |
|
||||
| "Is Micro800 unconnected-only enforced wire-side?" | no (emulated as CLX) | partial | yes | yes (required) |
|
||||
| "Does GuardLogix reject non-safety writes on safety tags?" | no | no | yes (Emulate 5580) | yes |
|
||||
| "Does CompactLogix refuse oversized ConnectionSize?" | no | partial | yes (5370 firmware) | yes |
|
||||
| "Does BOOL-in-DINT RMW race against concurrent writers?" | no | yes | partial | yes (stress) |
|
||||
| "Does EtherNet/IP embedded-switch behave correctly?" | no | no | no | yes (required) |
|
||||
| "Does redundant-chassis failover work?" | no | no | no | yes (required) |
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
If integration-level UDT / alarm / quirk proof becomes a shipping gate, the
|
||||
options are roughly:
|
||||
|
||||
1. **Logix Emulate golden-box tier** (scaffolded; see the section above) —
|
||||
highest-fidelity path short of real hardware. Closes UDT / ALMD / AOI /
|
||||
optimized-DB gaps in one license + one Windows PC.
|
||||
2. **Extend `ab_server`** upstream — the project accepts PRs + already
|
||||
carries a CIP framing layer that UDT emulation could plug into.
|
||||
3. **Stand up a lab rig** — physical `1756-L7x` / `5069-L3x` / `2080-LC30`
|
||||
/ `1756-L8xS` controllers. The only path that covers safety partitions
|
||||
across nodes, redundant chassis, embedded-switch behaviour, and motion
|
||||
timing.
|
||||
|
||||
See also:
|
||||
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerFixture.cs`
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfile.cs`
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfileGate.cs`
|
||||
— `AB_SERVER_PROFILE` tier gate
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs`
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/` — ab_server
|
||||
image + compose
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/` — Logix
|
||||
Emulate tier tests
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/LogixProject/README.md`
|
||||
— L5X project state the Emulate tier expects
|
||||
- `docs/v2/test-data-sources.md` §2 — the broader test-data-source picking
|
||||
rationale this fixture slots into
|
||||
133
docs/drivers/FOCAS-Test-Fixture.md
Normal file
133
docs/drivers/FOCAS-Test-Fixture.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# FOCAS test fixture
|
||||
|
||||
Coverage map + gap inventory for the FANUC FOCAS2 CNC driver.
|
||||
|
||||
**TL;DR: there is no integration fixture.** Every test uses a
|
||||
`FakeFocasClient` injected via `IFocasClientFactory`. Fanuc's FOCAS library
|
||||
(`Fwlib32.dll`) is closed-source proprietary with no public simulator;
|
||||
CNC-side behavior is trusted from field deployments.
|
||||
|
||||
## What the fixture is
|
||||
|
||||
Nothing at the integration layer.
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/` is unit-only. The driver ships
|
||||
as Tier C (process-isolated) per `docs/v2/driver-stability.md` because the
|
||||
FANUC DLL has known crash modes; tests can't replicate those in-process.
|
||||
|
||||
## What it actually covers (unit only)
|
||||
|
||||
- `FocasCapabilityTests` — data-type mapping (PMC bit / word / float,
|
||||
macro variable types, parameter types)
|
||||
- `FocasCapabilityMatrixTests` — per-CNC-series range validation (macro
|
||||
/ parameter / PMC letter + number) across 16i / 0i-D / 0i-F /
|
||||
30i / PowerMotion. See [`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md)
|
||||
for the authoritative matrix. 46 theory cases lock every documented
|
||||
range boundary — widening a range without updating the doc fails a
|
||||
test.
|
||||
- `FocasReadWriteTests` — read + write against the fake, FOCAS native status
|
||||
→ OPC UA StatusCode mapping
|
||||
- `FocasScaffoldingTests` — `IDriver` lifecycle + multi-device routing
|
||||
- `FocasPmcBitRmwTests` — PMC bit read-modify-write synchronization (per-byte
|
||||
`SemaphoreSlim`, mirrors the AB / Modbus pattern from #181)
|
||||
- `FwlibNativeHelperTests` — `Focas32.dll` → `Fwlib32.dll` bridge validation
|
||||
+ P/Invoke signature validation
|
||||
|
||||
Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
|
||||
`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
|
||||
`IPerCallHostResolver`.
|
||||
|
||||
Pre-flight validation runs in `FocasDriver.InitializeAsync` — configs
|
||||
referencing out-of-range addresses fail at load time with a diagnostic
|
||||
message naming the CNC series + documented limit. This closes the
|
||||
cheap half of the hardware-free stability gap; Tier-C process
|
||||
isolation (task #220) closes the expensive half — see
|
||||
[`docs/v2/implementation/focas-isolation-plan.md`](../v2/implementation/focas-isolation-plan.md).
|
||||
|
||||
## What it does NOT cover
|
||||
|
||||
### 1. FOCAS wire traffic
|
||||
|
||||
No FOCAS TCP frame is sent. `Fwlib32.dll`'s TCP-to-FANUC-gateway exchange is
|
||||
closed-source; the driver trusts the P/Invoke layer per #193. Real CNC
|
||||
correctness is trusted from field deployments.
|
||||
|
||||
### 2. Alarm / parameter-change callbacks
|
||||
|
||||
FOCAS has no push model — the driver polls via the shared `PollGroupEngine`.
|
||||
There are no CNC-initiated callbacks to test; the absence is by design.
|
||||
|
||||
### 3. Macro / ladder variable types
|
||||
|
||||
FANUC has CNC-specific extensions (macro variables `#100-#999`, system
|
||||
variables `#1000-#5000`, PMC timers / counters / keep-relays) whose
|
||||
per-address semantics differ across 0i-F / 30i / 31i / 32i Series. Driver
|
||||
covers the common address shapes; per-model quirks are not stressed.
|
||||
|
||||
### 4. Model-specific behavior
|
||||
|
||||
- Alarm retention across power cycles (model-specific CNC behavior)
|
||||
- Parameter range enforcement (CNC rejects out-of-range writes)
|
||||
- MTB (machine tool builder) custom screens that expose non-standard data
|
||||
|
||||
### 5. Tier-C process isolation — architecture shipped, Fwlib32 integration hardware-gated
|
||||
|
||||
The Tier-C architecture is now in place as of PRs #169–#173 (FOCAS
|
||||
PR A–E, task #220):
|
||||
|
||||
- `Driver.FOCAS.Shared` carries MessagePack IPC contracts
|
||||
- `Driver.FOCAS.Host` (.NET 4.8 x86 Windows service via NSSM) accepts
|
||||
a connection on a strictly-ACL'd named pipe + dispatches frames to
|
||||
an `IFocasBackend`
|
||||
- `Driver.FOCAS.Ipc.IpcFocasClient` implements the `IFocasClient` DI
|
||||
seam by forwarding over IPC — swap the DI registration and the
|
||||
driver runs Tier-C with zero other changes
|
||||
- `Driver.FOCAS.Supervisor.FocasHostSupervisor` owns the spawn +
|
||||
heartbeat + respawn + 3-in-5min crash-loop breaker + sticky alert
|
||||
- `Driver.FOCAS.Host.Stability.PostMortemMmf` ↔
|
||||
`Driver.FOCAS.Supervisor.PostMortemReader` — ring-buffer of the
|
||||
last ~1000 IPC operations survives a Host crash
|
||||
|
||||
The one remaining gap is the production `FwlibHostedBackend`: an
|
||||
`IFocasBackend` implementation that wraps the licensed
|
||||
`Fwlib32.dll` P/Invoke. That's hardware-gated on task #222 — we
|
||||
need a CNC on the bench (or the licensed FANUC developer kit DLL
|
||||
with a test harness) to validate it. Until then, the Host ships
|
||||
`FakeFocasBackend` + `UnconfiguredFocasBackend`. Setting
|
||||
`OTOPCUA_FOCAS_BACKEND=fake` lets operators smoke-test the whole
|
||||
Tier-C pipeline end-to-end without any CNC.
|
||||
|
||||
## When to trust FOCAS tests, when to reach for a rig
|
||||
|
||||
| Question | Unit tests | Real CNC |
|
||||
| --- | --- | --- |
|
||||
| "Does PMC address `R100.3` route to the right bit?" | yes | yes |
|
||||
| "Does the FANUC status → OPC UA StatusCode map cover every documented code?" | yes (contract) | yes |
|
||||
| "Does a real read against a 30i Series return correct bytes?" | no | yes (required) |
|
||||
| "Does `Fwlib32.dll` crash on concurrent reads?" | no | yes (stress) |
|
||||
| "Do macro variables round-trip across power cycles?" | no | yes (required) |
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
1. **Nothing public** — Fanuc's FOCAS Developer Kit ships an emulator DLL
|
||||
but it's under NDA + tied to licensed dev-kit installations; can't
|
||||
redistribute for CI.
|
||||
2. **Lab rig** — used FANUC 0i-F simulator controller (or a retired machine
|
||||
tool) on a dedicated network; only path that covers real CNC behavior.
|
||||
3. **Process isolation first** — before trusting FOCAS in production at
|
||||
scale, shipping the Tier-C out-of-process Host architecture (similar to
|
||||
Galaxy) is higher value than a CI simulator.
|
||||
|
||||
## Key fixture / config files
|
||||
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs` —
|
||||
in-process fake implementing `IFocasClient`
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityMatrixTests.cs`
|
||||
— parameterized theories locking the per-series matrix
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs` — ctor takes
|
||||
`IFocasClientFactory`
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs` —
|
||||
per-CNC-series range validator (the matrix the doc describes)
|
||||
- `docs/v2/focas-version-matrix.md` — authoritative range reference
|
||||
- `docs/v2/implementation/focas-isolation-plan.md` — Tier-C isolation
|
||||
plan (task #220)
|
||||
- `docs/v2/driver-stability.md` — Tier C scope + process-isolation rationale
|
||||
164
docs/drivers/Galaxy-Test-Fixture.md
Normal file
164
docs/drivers/Galaxy-Test-Fixture.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Galaxy test fixture
|
||||
|
||||
Coverage map + gap inventory for the Galaxy driver — out-of-process Host
|
||||
(net48 x86 MXAccess COM) + Proxy (net10) + Shared protocol.
|
||||
|
||||
**TL;DR: Galaxy has the richest test harness in the fleet** — real Host
|
||||
subprocess spawn, real ZB SQL queries, IPC parity checks against the v1
|
||||
LmxProxy reference, + live-smoke tests when MXAccess runtime is actually
|
||||
installed. Gaps are live-plant + failover-shaped: the E2E suite covers the
|
||||
representative ~50-tag deployment but not large-site discovery stress, real
|
||||
Rockwell/Siemens PLC enumeration through MXAccess, or ZB SQL Always-On
|
||||
replica failover.
|
||||
|
||||
## What the fixture is
|
||||
|
||||
Multi-project test topology:
|
||||
|
||||
- **E2E parity** —
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ParityFixture.cs` spawns the
|
||||
production `OtOpcUa.Driver.Galaxy.Host.exe` as a subprocess, opens the
|
||||
named-pipe IPC, connects `GalaxyProxyDriver` + runs hierarchy / stability
|
||||
parity tests against both.
|
||||
- **Host.Tests** —
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/` — direct Host process
|
||||
testing (18+ test classes covering alarm discovery, AVEVA prerequisite
|
||||
checks, IPC dispatcher, alarm tracker, probe manager, historian
|
||||
cluster/quality/wiring, history read, OPC UA attribute mapping,
|
||||
subscription lifecycle, reconnect, multi-host proxy, ADS address routing,
|
||||
expression evaluation) + `GalaxyRepositoryLiveSmokeTests` that hit real
|
||||
ZB SQL.
|
||||
- **Proxy.Tests** — `GalaxyProxyDriver` client contract tests.
|
||||
- **Shared.Tests** — shared protocol + address model.
|
||||
- **TestSupport** — test helpers reused across the above.
|
||||
|
||||
## How tests skip
|
||||
|
||||
- **E2E parity**: `ParityFixture.SkipIfUnavailable()` runs at class init and
|
||||
checks Windows-only, non-admin user, ZB SQL reachable on
|
||||
`localhost:1433`, Host EXE built in the expected `bin/` folder. Any miss
|
||||
→ tests skip.
|
||||
- **Live-smoke** (`GalaxyRepositoryLiveSmokeTests`): `Assert.Skip` when ZB
|
||||
unreachable. A `per project_galaxy_host_installed` memory on this repo's
|
||||
dev box notes the MXAccess runtime is installed + pipe ACL denies Admins,
|
||||
so live tests must run from a non-elevated shell.
|
||||
- **Unit** tests (Shared, Proxy contract, most Host.Tests) have no skip —
|
||||
they run anywhere.
|
||||
|
||||
## What it actually covers
|
||||
|
||||
### E2E parity suite
|
||||
|
||||
- `HierarchyParityTests` — Host address-space hierarchy vs v1 LmxProxy
|
||||
reference (same ZB, same Galaxy, same shape)
|
||||
- `StabilityFindingsRegressionTests` — probe subscription failure
|
||||
handling + host-status mutation guard from the v1 stability findings
|
||||
backlog
|
||||
|
||||
### Host.Tests (representative)
|
||||
|
||||
- Alarm discovery → subsystem setup
|
||||
- AVEVA prerequisite checks (runtime installed, platform deployed, etc.)
|
||||
- IPC dispatcher — request/response routing over the named pipe
|
||||
- Alarm tracker state machine
|
||||
- Probe manager — per-runtime probe subscription + reconnect
|
||||
- Historian cluster / quality / wiring — Aveva Historian integration
|
||||
- OPC UA attribute mapping
|
||||
- Subscription lifecycle + reconnect
|
||||
- Multi-host proxy routing
|
||||
- ADS address routing + expression evaluation (Galaxy's legacy expression
|
||||
language)
|
||||
|
||||
### Live-smoke
|
||||
|
||||
- `GalaxyRepositoryLiveSmokeTests` — real SQL against ZB database, verifies
|
||||
the ZB schema + `LocalPlatform` scope filter + change-detection query
|
||||
shape match production.
|
||||
|
||||
### Capability surfaces hit
|
||||
|
||||
All of them: `IDriver`, `IReadable`, `IWritable`, `ITagDiscovery`,
|
||||
`ISubscribable`, `IHostConnectivityProbe`, `IPerCallHostResolver`,
|
||||
`IAlarmSource`, `IHistoryProvider`. Galaxy is the only driver where every
|
||||
interface sees both contract + real-integration coverage.
|
||||
|
||||
## What it does NOT cover
|
||||
|
||||
### 1. MXAccess COM by default
|
||||
|
||||
The E2E parity suite backs subscriptions via the DB-only path; MXAccess COM
|
||||
integration opts in via a separate live-smoke. So "does the MXAccess STA
|
||||
pump correctly handle real Wonderware runtime events" is exercised only
|
||||
when the operator runs live smoke on a machine with MXAccess installed.
|
||||
|
||||
### 2. Real Rockwell / Siemens PLC enumeration
|
||||
|
||||
Galaxy runtime talks to PLCs through MXAccess (Device Integration Objects).
|
||||
The CI parity suite uses a representative ~50-tag deployment; large sites
|
||||
(1000+ tag hierarchies, multi-Galaxy replication, deeply-nested templates)
|
||||
are not stressed.
|
||||
|
||||
### 3. ZB SQL Always-On failover
|
||||
|
||||
Live-smoke hits a single SQL instance. Real production ZB often runs on
|
||||
Always-On availability groups; replica failover behavior is not tested.
|
||||
|
||||
### 4. Galaxy replication / backup-restore
|
||||
|
||||
Galaxy supports backup + partial replication across platforms — these
|
||||
rewrite the ZB schema in ways that change the contained_name vs tag_name
|
||||
mapping. Not exercised.
|
||||
|
||||
### 5. Historian failover
|
||||
|
||||
Aveva Historian can be clustered. `historian cluster / quality` tests
|
||||
verify the cluster-config query; they don't exercise actual failover
|
||||
(primary dies → secondary takes over mid-HistoryRead).
|
||||
|
||||
### 6. AVEVA runtime version matrix
|
||||
|
||||
MXAccess COM contract varies subtly across System Platform 2017 / 2020 /
|
||||
2023. The live-smoke runs against whatever version is installed on the dev
|
||||
box; CI has no AVEVA installed at all (licensing + footprint).
|
||||
|
||||
## When to trust the Galaxy suite, when to reach for a live plant
|
||||
|
||||
| Question | E2E parity | Live-smoke | Real plant |
|
||||
| --- | --- | --- | --- |
|
||||
| "Does Host spawn + IPC round-trip work?" | yes | yes | yes |
|
||||
| "Does the ZB schema query match production shape?" | partial | yes | yes |
|
||||
| "Does MXAccess COM handle runtime reconnect correctly?" | no | yes | yes |
|
||||
| "Does the driver scale to 1000+ tags on one Galaxy?" | no | partial | yes (required) |
|
||||
| "Does historian failover mid-read return a clean error?" | no | no | yes (required) |
|
||||
| "Does System Platform 2023's MXAccess differ from 2020?" | no | partial | yes (required) |
|
||||
| "Does ZB Always-On replica failover preserve generation?" | no | no | yes (required) |
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
1. **System Platform 2023 live-smoke matrix** — set up a second dev box
|
||||
running SP2023; run the same live-smoke against both to catch COM-contract
|
||||
drift early.
|
||||
2. **Synthetic large-site fixture** — script a ZB populator that creates a
|
||||
1000-Equipment / 20000-tag hierarchy, run the parity suite against it.
|
||||
Catches O(N) → O(N²) discovery regressions.
|
||||
3. **Historian failover scripted test** — with a two-node AVEVA Historian
|
||||
cluster, tear down primary mid-HistoryRead + verify the driver's failover
|
||||
behavior + error surface.
|
||||
4. **ZB Always-On CI** — SQL Server 2022 on Linux supports Always-On;
|
||||
could stand up a two-replica group for replica-failover coverage.
|
||||
|
||||
This is already the best-tested driver; the remaining work is site-scale
|
||||
+ production-topology coverage, not capability coverage.
|
||||
|
||||
## Key fixture / config files
|
||||
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ParityFixture.cs` — E2E fixture
|
||||
that spawns Host + connects Proxy
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/GalaxyRepositoryLiveSmokeTests.cs`
|
||||
— live ZB smoke with `Assert.Skip` gate
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/` — shared helpers
|
||||
- `docs/drivers/Galaxy.md` — COM bridge + STA pump + IPC architecture
|
||||
- `docs/drivers/Galaxy-Repository.md` — ZB SQL reader + `LocalPlatform`
|
||||
scope filter + change detection
|
||||
- `docs/v2/aveva-system-platform-io-research.md` — MXAccess + Wonderware
|
||||
background
|
||||
123
docs/drivers/Modbus-Test-Fixture.md
Normal file
123
docs/drivers/Modbus-Test-Fixture.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Modbus test fixture
|
||||
|
||||
Coverage map + gap inventory for the Modbus TCP driver's integration-test
|
||||
harness backed by `pymodbus` simulator profiles per PLC family.
|
||||
|
||||
**TL;DR:** Modbus is the best-covered driver — a real `pymodbus` server on
|
||||
localhost with per-family seed-register profiles, plus a skip-gate when the
|
||||
simulator port isn't reachable. Covers DL205 / Mitsubishi MELSEC / Siemens
|
||||
S7-1500 family quirks end-to-end. Gaps are mostly error-path + alarm/history
|
||||
shaped (neither is a Modbus-side concept).
|
||||
|
||||
## What the fixture is
|
||||
|
||||
- **Simulator**: `pymodbus` (Python, BSD) launched as a pinned Docker
|
||||
container at
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/`.
|
||||
Docker is the only supported launch path.
|
||||
- **Lifecycle**: `ModbusSimulatorFixture` (collection-scoped) TCP-probes
|
||||
`localhost:5020` on first use. `MODBUS_SIM_ENDPOINT` env var overrides the
|
||||
endpoint so the same suite can target a real PLC.
|
||||
- **Profiles**: `DL205Profile`, `MitsubishiProfile`, `S7_1500Profile` —
|
||||
each composes device-specific register-format + quirk-seed JSON for pymodbus.
|
||||
Profile JSONs live under `Docker/profiles/` and are baked into the image.
|
||||
- **Compose services**: one per profile (`standard` / `dl205` /
|
||||
`mitsubishi` / `s7_1500`); only one binds `:5020` at a time.
|
||||
- **Tests skip** via `Assert.Skip(sim.SkipReason)` when the probe fails; no
|
||||
custom FactAttribute needed because `ModbusSimulatorCollection` carries the
|
||||
skip reason.
|
||||
|
||||
## What it actually covers
|
||||
|
||||
### DL205 (Automation Direct)
|
||||
|
||||
- `DL205SmokeTests` — FC16 write → FC03 read round-trip on holding register
|
||||
- `DL205CoilMappingTests` — Y-output / C-relay / X-input address mapping
|
||||
(octal → Modbus offset)
|
||||
- `DL205ExceptionCodeTests` — Modbus exception 0x02 → OPC UA `BadOutOfRange` against the dl205 profile (natural out-of-range path)
|
||||
- `ExceptionInjectionTests` — every other exception code in the mapping table (0x01 / 0x03 / 0x04 / 0x05 / 0x06 / 0x0A / 0x0B) against the `exception_injection` profile on both read + write paths
|
||||
- `DL205FloatCdabQuirkTests` — CDAB word-swap float encoding
|
||||
- `DL205StringQuirkTests` — packed-string V-memory layout
|
||||
- `DL205VMemoryQuirkTests` — V-memory octal addressing
|
||||
- `DL205XInputTests` — X-register read-only enforcement
|
||||
|
||||
### Mitsubishi MELSEC
|
||||
|
||||
- `MitsubishiSmokeTests` — read + write round-trip
|
||||
- `MitsubishiQuirkTests` — word-order, device-code mapping (D/M/X/Y ranges)
|
||||
|
||||
### Siemens S7-1500 (Modbus gateway flavor)
|
||||
|
||||
- `S7_1500SmokeTests` — read + write round-trip
|
||||
- `S7_ByteOrderTests` — ABCD/DCBA/BADC/CDAB byte-order matrix
|
||||
|
||||
### Capability surfaces hit
|
||||
|
||||
- `IReadable` + `IWritable` — full round-trip
|
||||
- `ISubscribable` — via the shared `PollGroupEngine` (polled subscription)
|
||||
- `IHostConnectivityProbe` — TCP-reach transitions
|
||||
|
||||
## What it does NOT cover
|
||||
|
||||
### 1. No `ITagDiscovery`
|
||||
|
||||
Modbus has no symbol table — the driver requires a static tag map from
|
||||
`DriverConfig`. There is no discovery path to test + none in the fixture.
|
||||
|
||||
### 2. Error-path fuzzing
|
||||
|
||||
`pymodbus` serves the seeded values happily; the fixture can't easily inject
|
||||
exception responses (code 0x01–0x0B) or malformed PDUs. The
|
||||
`AbCipStatusMapper`-equivalent for exception codes is unit-tested via
|
||||
`DL205ExceptionCodeTests` but the simulator itself never refuses a read.
|
||||
|
||||
### 3. Variant-specific quirks beyond the three profiles
|
||||
|
||||
- FX5U / QJ71MT91 Mitsubishi variants — profile scaffolds exist, no tests yet
|
||||
- Non-S7-1500 Siemens (S7-1200 / ET200SP) — byte-order covered but
|
||||
connection-pool + fragmentation quirks untested
|
||||
- DL205-family cousins (DL06, DL260) — no dedicated profile
|
||||
|
||||
### 4. Subscription stress
|
||||
|
||||
`PollGroupEngine` is unit-tested standalone but the simulator doesn't exercise
|
||||
it under multi-register packing stress (FC03 with 125-register batches,
|
||||
boundary splits, etc.).
|
||||
|
||||
### 5. Alarms / history
|
||||
|
||||
Not a Modbus concept. Driver doesn't implement `IAlarmSource` or
|
||||
`IHistoryProvider`; no test coverage is the correct shape.
|
||||
|
||||
## When to trust the Modbus fixture, when to reach for a rig
|
||||
|
||||
| Question | Fixture | Unit tests | Real PLC |
|
||||
| --- | --- | --- | --- |
|
||||
| "Does FC03/FC06/FC16 work end-to-end?" | yes | - | yes |
|
||||
| "Does DL205 octal addressing map correctly?" | yes | yes | yes |
|
||||
| "Does float CDAB word-swap round-trip?" | yes | yes | yes |
|
||||
| "Does the driver handle exception responses?" | no | yes | yes (required) |
|
||||
| "Does packing 125 regs into one FC03 work?" | no | no | yes (required) |
|
||||
| "Does FX5U behave like Q-series?" | no | no | yes (required) |
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
1. Add `MODBUS_SIM_ENDPOINT` override documentation to
|
||||
`docs/v2/test-data-sources.md` so operators can point the suite at a lab rig.
|
||||
2. ~~Extend `pymodbus` profiles to inject exception responses~~ — **shipped**
|
||||
via the `exception_injection` compose profile + standalone
|
||||
`exception_injector.py` server. Rules in
|
||||
`Docker/profiles/exception_injection.json` map `(fc, address)` to an
|
||||
exception code; `ExceptionInjectionTests` exercises every code in
|
||||
`MapModbusExceptionToStatus` (0x01 / 0x02 / 0x03 / 0x04 / 0x05 / 0x06 /
|
||||
0x0A / 0x0B) end-to-end on both read (FC03) and write (FC06) paths.
|
||||
3. Add an FX5U profile once a lab rig is available; the scaffolding is in place.
|
||||
|
||||
## Key fixture / config files
|
||||
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs`
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205Profile.cs`
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Mitsubishi/MitsubishiProfile.cs`
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/S7/S7_1500Profile.cs`
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/` —
|
||||
Dockerfile + compose + per-family JSON profiles
|
||||
170
docs/drivers/OpcUaClient-Test-Fixture.md
Normal file
170
docs/drivers/OpcUaClient-Test-Fixture.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# OPC UA Client test fixture
|
||||
|
||||
Coverage map + gap inventory for the OPC UA Client (gateway / aggregation)
|
||||
driver.
|
||||
|
||||
**TL;DR:** Wire-level coverage now exists via
|
||||
[opc-plc](https://github.com/Azure-Samples/iot-edge-opc-plc) — Microsoft
|
||||
Industrial IoT's OPC UA PLC simulator running in Docker (task #215). Real
|
||||
Secure Channel, real Session, real MonitoredItem exchange against an
|
||||
independent server implementation. Unit tests still carry the exhaustive
|
||||
capability matrix (cert auth / security policies / reconnect / failover /
|
||||
attribute mapping). Gaps remaining: upstream-server-specific quirks
|
||||
(historian aggregates, typed ConditionType events, SDK-publish-queue edge
|
||||
behavior under load) — opc-plc uses the same OPCFoundation stack internally
|
||||
so fully-independent-stack coverage needs `open62541/open62541` as a second
|
||||
image (follow-up).
|
||||
|
||||
## What the fixture is
|
||||
|
||||
**Integration layer** (task #215):
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/` stands up
|
||||
`mcr.microsoft.com/iotedge/opc-plc:2.14.10` via `Docker/docker-compose.yml`
|
||||
on `opc.tcp://localhost:50000`. `OpcPlcFixture` probes the port at
|
||||
collection init + skips tests with a clear message when the container's
|
||||
not running (matches the Modbus/pymodbus + S7/python-snap7 skip pattern).
|
||||
Docker is the launcher — no PowerShell wrapper needed because opc-plc
|
||||
ships pre-containerized. Compose-file flags: `--ut` (unsecured transport
|
||||
advertised), `--aa` (auto-accept client certs — opc-plc's cert trust store
|
||||
resets on each spin-up), `--alm` (alarm simulation for IAlarmSource
|
||||
follow-up coverage), `--pn=50000` (port).
|
||||
|
||||
**Unit layer**:
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` is still the primary
|
||||
coverage. Tests inject fakes through the driver's construction path; the
|
||||
OPCFoundation.NetStandard `Session` surface is wrapped behind an interface
|
||||
the tests mock.
|
||||
|
||||
## What it actually covers
|
||||
|
||||
### Integration (opc-plc Docker, task #215)
|
||||
|
||||
- `OpcUaClientSmokeTests.Client_connects_and_reads_StepUp_node_through_real_OPC_UA_stack` —
|
||||
full Secure Channel + Session + `ns=3;s=StepUp` Read round-trip
|
||||
- `OpcUaClientSmokeTests.Client_reads_batch_of_varied_types_from_live_simulator` —
|
||||
batch Read of UInt32 / Int32 / Boolean; asserts `bool`-specific Variant
|
||||
decoding to catch a common attribute-mapping regression
|
||||
- `OpcUaClientSmokeTests.Client_subscribe_receives_StepUp_data_changes_from_live_server` —
|
||||
real `MonitoredItem` subscription against `ns=3;s=FastUInt1` (ticks every
|
||||
100 ms); asserts `OnDataChange` fires within 3 s of subscribe
|
||||
|
||||
Wire-level surfaces verified: `IDriver` + `IReadable` + `ISubscribable` +
|
||||
`IHostConnectivityProbe` (via the Secure Channel exchange).
|
||||
|
||||
### Unit
|
||||
|
||||
The surface is broad because `OpcUaClientDriver` is the richest-capability
|
||||
driver in the fleet (it's a gateway for another OPC UA server, so it
|
||||
mirrors the full capability matrix):
|
||||
|
||||
- `OpcUaClientDriverScaffoldTests` — `IDriver` lifecycle
|
||||
- `OpcUaClientReadWriteTests` — read + write lifecycle
|
||||
- `OpcUaClientSubscribeAndProbeTests` — monitored-item subscription + probe
|
||||
state transitions
|
||||
- `OpcUaClientDiscoveryTests` — `GetEndpoints` + endpoint selection
|
||||
- `OpcUaClientAttributeMappingTests` — OPC UA node attribute → driver value
|
||||
mapping
|
||||
- `OpcUaClientSecurityPolicyTests` — `SignAndEncrypt` / `Sign` / `None`
|
||||
policy negotiation contract
|
||||
- `OpcUaClientCertAuthTests` — cert store paths, revocation-list config
|
||||
- `OpcUaClientReconnectTests` — SDK reconnect hook + `TransferSubscriptions`
|
||||
across the disconnect boundary
|
||||
- `OpcUaClientFailoverTests` — primary → secondary session fallback per
|
||||
driver config
|
||||
- `OpcUaClientAlarmTests` — A&E severity bucket (1–1000 → Low / Medium /
|
||||
High / Critical), subscribe / unsubscribe / ack contract
|
||||
- `OpcUaClientHistoryTests` — historical data read + interpolation contract
|
||||
|
||||
Capability surfaces whose contract is verified: `IDriver`, `ITagDiscovery`,
|
||||
`IReadable`, `IWritable`, `ISubscribable`, `IHostConnectivityProbe`,
|
||||
`IAlarmSource`, `IHistoryProvider`.
|
||||
|
||||
## What it does NOT cover
|
||||
|
||||
### 1. Real stack exchange
|
||||
|
||||
No UA Secure Channel is ever opened. Every test mocks `Session.ReadAsync`,
|
||||
`Session.CreateSubscription`, `Session.AddItem`, etc. — the SDK itself is
|
||||
trusted. Certificate validation, signing, nonce handling, chunk assembly,
|
||||
keep-alive cadence — all SDK-internal and untested here.
|
||||
|
||||
### 2. Subscription transfer across reconnect
|
||||
|
||||
Contract test: "after a simulated reconnect, `TransferSubscriptions` is
|
||||
called with the right handles." Real behavior: SDK re-publishes against the
|
||||
new channel and some events can be lost depending on publish-queue state.
|
||||
The lossy window is not characterized.
|
||||
|
||||
### 3. Large-scale subscription stress
|
||||
|
||||
100+ monitored items with heterogeneous publish intervals under a single
|
||||
session — the shape that breaks publish-queue-size tuning in the wild — is
|
||||
not exercised.
|
||||
|
||||
### 4. Real historian mappings
|
||||
|
||||
`IHistoryProvider.ReadRawAsync` + `ReadProcessedAsync` +
|
||||
`ReadAtTimeAsync` + `ReadEventsAsync` are contract-mocked. Against a real
|
||||
historian (AVEVA Historian, Prosys historian, Kepware LocalHistorian) each
|
||||
has specific interpolation + bad-quality-handling quirks the contract test
|
||||
doesn't see.
|
||||
|
||||
### 5. Real A&E events
|
||||
|
||||
Alarm subscription is mocked via filtered monitored items; the actual
|
||||
`EventFilter` select-clause behavior against a server that exposes typed
|
||||
ConditionType events (non-base `BaseEventType`) is not verified.
|
||||
|
||||
### 6. Authentication variants
|
||||
|
||||
- Anonymous, UserName/Password, X509 cert tokens — each is contract-tested
|
||||
but not exchanged against a server that actually enforces each.
|
||||
- LDAP-backed `UserName` (matching this repo's server-side
|
||||
`LdapUserAuthenticator`) requires a live LDAP round-trip; not tested.
|
||||
|
||||
## When to trust OpcUaClient tests, when to reach for a server
|
||||
|
||||
| Question | Unit tests | Real upstream server |
|
||||
| --- | --- | --- |
|
||||
| "Does severity 750 bucket as High?" | yes | yes |
|
||||
| "Does the driver call `TransferSubscriptions` after reconnect?" | yes | yes |
|
||||
| "Does a real OPC UA read/write round-trip work?" | no | yes (required) |
|
||||
| "Does event-filter-based alarm subscription return ConditionType events?" | no | yes (required) |
|
||||
| "Does history read from AVEVA Historian return correct aggregates?" | no | yes (required) |
|
||||
| "Does the SDK's publish queue lose notifications under load?" | no | yes (stress) |
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
The easiest win here is to **wire the client driver tests against this
|
||||
repo's own server**. The integration test project
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs`
|
||||
already stands up a real OPC UA server on a non-default port with a seeded
|
||||
FakeDriver. An `OpcUaClientLiveLoopbackTests` that connects the client
|
||||
driver to that server would give:
|
||||
|
||||
- Real Secure Channel negotiation
|
||||
- Real Session / Subscription / MonitoredItem exchange
|
||||
- Real read/write round-trip
|
||||
- Real certificate validation (the integration test already sets up PKI)
|
||||
|
||||
It wouldn't cover upstream-server-specific quirks (AVEVA Historian, Kepware,
|
||||
Prosys), but it would cover 80% of the SDK surface the driver sits on top
|
||||
of.
|
||||
|
||||
Beyond that:
|
||||
|
||||
1. **Prosys OPC UA Simulation Server** — free, Windows-available, scriptable.
|
||||
2. **UaExpert Server-Side Simulator** — Unified Automation's sample server;
|
||||
good coverage of typed ConditionType events.
|
||||
3. **Dedicated historian integration lab** — only path for
|
||||
historian-specific coverage.
|
||||
|
||||
## Key fixture / config files
|
||||
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` — unit tests with
|
||||
mocked `Session`
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs` — ctor +
|
||||
session-factory seam tests mock through
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs` —
|
||||
the server-side integration harness a future loopback client test could
|
||||
piggyback on
|
||||
@@ -37,6 +37,19 @@ Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/ZB.M
|
||||
|
||||
- **All other drivers** share a single per-driver specification in [docs/v2/driver-specs.md](../v2/driver-specs.md) — addressing, data-type maps, connection settings, and quirks live there. That file is the authoritative per-driver reference; this index points at it rather than duplicating.
|
||||
|
||||
## Test-fixture coverage maps
|
||||
|
||||
Each driver has a dedicated fixture doc that lays out what the integration / unit harness actually covers vs. what's trusted from field deployments. Read the relevant one before claiming "green suite = production-ready" for a driver.
|
||||
|
||||
- [AB CIP](AbServer-Test-Fixture.md) — Dockerized `ab_server` (multi-stage build from libplctag source); atomic-read smoke across 4 families; UDT / ALMD / family quirks unit-only
|
||||
- [Modbus](Modbus-Test-Fixture.md) — Dockerized `pymodbus` + per-family JSON profiles (4 compose profiles); best-covered driver, gaps are error-path-shaped
|
||||
- [Siemens S7](S7-Test-Fixture.md) — Dockerized `python-snap7` server; DB/MB read + write round-trip verified end-to-end on `:1102`
|
||||
- [AB Legacy](AbLegacy-Test-Fixture.md) — Docker scaffold via `ab_server` PCCC mode (task #224); wire-level round-trip currently blocked by ab_server's PCCC coverage gap, docs call out RSEmulate 500 + lab-rig resolution paths
|
||||
- [TwinCAT](TwinCAT-Test-Fixture.md) — XAR-VM integration scaffolding (task #221); three smoke tests skip when VM unreachable. Unit via `FakeTwinCATClient` with native-notification harness
|
||||
- [FOCAS](FOCAS-Test-Fixture.md) — no integration fixture, unit-only via `FakeFocasClient`; Tier C out-of-process isolation scoped but not shipped
|
||||
- [OPC UA Client](OpcUaClient-Test-Fixture.md) — no integration fixture, unit-only via mocked `Session`; loopback against this repo's own server is the obvious next step
|
||||
- [Galaxy](Galaxy-Test-Fixture.md) — richest harness: E2E Host subprocess + ZB SQL live-smoke + MXAccess opt-in
|
||||
|
||||
## Related cross-driver docs
|
||||
|
||||
- [HistoricalDataAccess.md](../HistoricalDataAccess.md) — `IHistoryProvider` dispatch, aggregate mapping, continuation points. The Galaxy driver's Aveva Historian implementation is the first; OPC UA Client forwards to the upstream server; other drivers do not implement the interface and return `BadHistoryOperationUnsupported`.
|
||||
|
||||
121
docs/drivers/S7-Test-Fixture.md
Normal file
121
docs/drivers/S7-Test-Fixture.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Siemens S7 test fixture
|
||||
|
||||
Coverage map + gap inventory for the S7 driver.
|
||||
|
||||
**TL;DR:** S7 now has a wire-level integration fixture backed by
|
||||
[python-snap7](https://github.com/gijzelaerr/python-snap7)'s `Server` class
|
||||
(task #216). Atomic reads (u16 / i16 / i32 / f32 / bool-with-bit) + DB
|
||||
write-then-read round-trip are exercised end-to-end through S7netplus +
|
||||
real ISO-on-TCP on `localhost:1102`. Unit tests still carry everything
|
||||
else (address parsing, error-branch handling, probe-loop contract). Gaps
|
||||
remaining are variant-quirk-shaped: Optimized-DB symbolic access, PG/OP
|
||||
session types, PUT/GET-disabled enforcement — all need real hardware.
|
||||
|
||||
## What the fixture is
|
||||
|
||||
**Integration layer** (task #216):
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/` stands up a
|
||||
python-snap7 `Server` via `Docker/docker-compose.yml --profile s7_1500`
|
||||
on `localhost:1102` (pinned `python:3.12-slim-bookworm` base +
|
||||
`python-snap7>=2.0`). Docker is the only supported launch path.
|
||||
`Snap7ServerFixture` probes the port at collection init + skips with a
|
||||
clear message when unreachable (matches the pymodbus pattern).
|
||||
`server.py` (baked into the image under `Docker/`) reads a JSON profile
|
||||
+ seeds DB/MB bytes at declared offsets; seeds are typed (`u16` / `i16`
|
||||
/ `i32` / `f32` / `bool` / `ascii` for S7 STRING).
|
||||
|
||||
**Unit layer**: `tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/` covers
|
||||
everything the wire-level suite doesn't — address parsing, error
|
||||
branches, probe-loop contract. All tests tagged
|
||||
`[Trait("Category", "Unit")]`.
|
||||
|
||||
The driver ctor change that made this possible:
|
||||
`Plc(CpuType, host, port, rack, slot)` — S7netplus 0.20's 5-arg overload
|
||||
— wires `S7DriverOptions.Port` through so the simulator can bind 1102
|
||||
(non-privileged) instead of 102 (root / Firewall-prompt territory).
|
||||
|
||||
## What it actually covers
|
||||
|
||||
### Integration (python-snap7, task #216)
|
||||
|
||||
- `S7_1500SmokeTests.Driver_reads_seeded_u16_through_real_S7comm` — DB1.DBW0
|
||||
read via real S7netplus over TCP + simulator; proves handshake + read path
|
||||
- `S7_1500SmokeTests.Driver_reads_seeded_typed_batch` — i16, i32, f32,
|
||||
bool-with-bit in one batch call; proves typed decode per S7DataType
|
||||
- `S7_1500SmokeTests.Driver_write_then_read_round_trip_on_scratch_word` —
|
||||
`DB1.DBW100` write → read-back; proves write path + buffer visibility
|
||||
|
||||
### Unit
|
||||
|
||||
- `S7AddressParserTests` — S7 address syntax (`DB1.DBD0`, `M10.3`, `IW4`, etc.)
|
||||
- `S7DriverScaffoldTests` — `IDriver` lifecycle (init / reinit / shutdown / health)
|
||||
- `S7DriverReadWriteTests` — error paths (uninitialized read/write, bad
|
||||
addresses, transport exceptions)
|
||||
- `S7DiscoveryAndSubscribeTests` — `ITagDiscovery.DiscoverAsync` + polled
|
||||
`ISubscribable` contract with the shared `PollGroupEngine`
|
||||
|
||||
Capability surfaces whose contract is verified: `IDriver`, `ITagDiscovery`,
|
||||
`IReadable`, `IWritable`, `ISubscribable`, `IHostConnectivityProbe`.
|
||||
Wire-level surfaces verified: `IReadable`, `IWritable`.
|
||||
|
||||
## What it does NOT cover
|
||||
|
||||
### 1. Wire-level anything
|
||||
|
||||
No ISO-on-TCP frame is ever sent during the test suite. S7netplus is the only
|
||||
wire-path abstraction and it has no in-process fake mode; the shipping choice
|
||||
was to contract-test via `IS7Client` rather than patch into S7netplus
|
||||
internals.
|
||||
|
||||
### 2. Read/write happy path
|
||||
|
||||
Every `S7DriverReadWriteTests` case exercises error branches. A successful
|
||||
read returning real PLC data is not tested end-to-end — the return value is
|
||||
whatever the fake says it is.
|
||||
|
||||
### 3. Mailbox serialization under concurrent reads
|
||||
|
||||
The driver's `SemaphoreSlim` serializes S7netplus calls because the S7 CPU's
|
||||
comm mailbox is scanned at most once per cycle. Contention behavior under
|
||||
real PLC latency is not exercised.
|
||||
|
||||
### 4. Variant quirks
|
||||
|
||||
S7-1200 vs S7-1500 vs S7-300/400 connection semantics (PG vs OP vs S7-Basic)
|
||||
not differentiated at test time.
|
||||
|
||||
### 5. Data types beyond the scalars
|
||||
|
||||
UDT fan-out, `STRING` with length-prefix quirks, `DTL` / `DATE_AND_TIME`,
|
||||
arrays of structs — not covered.
|
||||
|
||||
## When to trust the S7 tests, when to reach for a rig
|
||||
|
||||
| Question | Unit tests | Real PLC |
|
||||
| --- | --- | --- |
|
||||
| "Does the address parser accept X syntax?" | yes | - |
|
||||
| "Does the driver lifecycle hang / crash?" | yes | yes |
|
||||
| "Does a real read against an S7-1500 return correct bytes?" | no | yes (required) |
|
||||
| "Does mailbox serialization actually prevent PG timeouts?" | no | yes (required) |
|
||||
| "Does a UDT fan-out produce usable member variables?" | no | yes (required) |
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
1. **Snap7 server** — [Snap7](https://snap7.sourceforge.net/) ships a
|
||||
C-library-based S7 server that could run in-CI on Linux. A pinned build +
|
||||
a fixture shape similar to `ab_server` would give S7 parity with Modbus /
|
||||
AB CIP coverage.
|
||||
2. **Plcsim Advanced** — Siemens' paid emulator. Licensed per-seat; fits a
|
||||
lab rig but not CI.
|
||||
3. **Real S7 lab rig** — cheapest physical PLC (CPU 1212C) on a dedicated
|
||||
network port, wired via self-hosted runner.
|
||||
|
||||
Without any of these, S7 driver correctness against real hardware is trusted
|
||||
from field deployments, not from the test suite.
|
||||
|
||||
## Key fixture / config files
|
||||
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/` — unit tests only, no harness
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs` — ctor takes
|
||||
`IS7ClientFactory` which tests fake; docstring lines 8-20 note the deferred
|
||||
integration fixture
|
||||
158
docs/drivers/TwinCAT-Test-Fixture.md
Normal file
158
docs/drivers/TwinCAT-Test-Fixture.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# TwinCAT test fixture
|
||||
|
||||
Coverage map + gap inventory for the Beckhoff TwinCAT ADS driver.
|
||||
|
||||
**TL;DR:** Integration-test scaffolding lives at
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/` (task #221).
|
||||
`TwinCATXarFixture` probes TCP 48898 on an operator-supplied VM; three
|
||||
smoke tests (read / write / native notification) run end-to-end through
|
||||
the real ADS stack when the VM is reachable, skip cleanly otherwise.
|
||||
**Remaining operational work**: stand up a TwinCAT 3 XAR runtime in a
|
||||
Hyper-V VM, author the `.tsproj` project documented at
|
||||
`TwinCatProject/README.md`, rotate the 7-day trial license (or buy a
|
||||
paid runtime). Unit tests via `FakeTwinCATClient` still carry the
|
||||
exhaustive contract coverage.
|
||||
|
||||
TwinCAT is the only driver outside Galaxy that uses **native
|
||||
notifications** (no polling) for `ISubscribable`, and the fake exposes a
|
||||
fire-event harness so notification routing is contract-tested rigorously
|
||||
at the unit layer.
|
||||
|
||||
## What the fixture is
|
||||
|
||||
**Integration layer** (task #221, scaffolded):
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/` —
|
||||
`TwinCATXarFixture` TCP-probes ADS port 48898 on the host specified by
|
||||
`TWINCAT_TARGET_HOST` + requires `TWINCAT_TARGET_NETID` (AmsNetId of the
|
||||
VM). No fixture-owned lifecycle — XAR can't run in Docker because it
|
||||
bypasses the Windows kernel scheduler, so the VM stays
|
||||
operator-managed. `TwinCatProject/README.md` documents the required
|
||||
`.tsproj` project state; the file itself lands once the XAR VM is up +
|
||||
the project is authored. Three smoke tests:
|
||||
`Driver_reads_seeded_DINT_through_real_ADS`,
|
||||
`Driver_write_then_read_round_trip_on_scratch_REAL`, and
|
||||
`Driver_subscribe_receives_native_ADS_notifications_on_counter_changes`
|
||||
— all skip cleanly via `[TwinCATFact]` when the runtime isn't
|
||||
reachable.
|
||||
|
||||
**Unit layer**: `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` is
|
||||
still the primary coverage. `FakeTwinCATClient` also fakes the
|
||||
`AddDeviceNotification` flow so tests can trigger callbacks without a
|
||||
running runtime.
|
||||
|
||||
## What it actually covers
|
||||
|
||||
### Integration (XAR VM, task #221 — code scaffolded, needs VM + project)
|
||||
|
||||
- `TwinCAT3SmokeTests.Driver_reads_seeded_DINT_through_real_ADS` — real AMS
|
||||
handshake + ADS read of `GVL_Fixture.nCounter` (seeded at 1234, MAIN
|
||||
increments each cycle)
|
||||
- `TwinCAT3SmokeTests.Driver_write_then_read_round_trip_on_scratch_REAL` —
|
||||
real ADS write + read on `GVL_Fixture.rSetpoint`
|
||||
- `TwinCAT3SmokeTests.Driver_subscribe_receives_native_ADS_notifications_on_counter_changes`
|
||||
— real `AddDeviceNotification` against the cycle-incrementing counter;
|
||||
observes `OnDataChange` firing within 3 s of subscribe
|
||||
|
||||
All three gated on `TWINCAT_TARGET_HOST` + `TWINCAT_TARGET_NETID` env
|
||||
vars; skip cleanly via `[TwinCATFact]` when the VM isn't reachable or
|
||||
vars are unset.
|
||||
|
||||
### Unit
|
||||
|
||||
- `TwinCATAmsAddressTests` — `ads://<netId>:<port>` parsing + routing
|
||||
- `TwinCATCapabilityTests` — data-type mapping (primitives + declared UDTs),
|
||||
read-only classification
|
||||
- `TwinCATReadWriteTests` — read + write through the fake, status mapping
|
||||
- `TwinCATSymbolPathTests` — symbol-path routing for nested struct members
|
||||
- `TwinCATSymbolBrowserTests` — `ITagDiscovery.DiscoverAsync` via
|
||||
`ReadSymbolsAsync` (#188) + system-symbol filtering
|
||||
- `TwinCATNativeNotificationTests` — `AddDeviceNotification` (#189)
|
||||
registration, callback-delivery-to-`OnDataChange` wiring, unregister on
|
||||
unsubscribe
|
||||
- `TwinCATDriverTests` — `IDriver` lifecycle
|
||||
|
||||
Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
|
||||
`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
|
||||
`IPerCallHostResolver`.
|
||||
|
||||
## What it does NOT cover
|
||||
|
||||
### 1. AMS / ADS wire traffic
|
||||
|
||||
No real AMS router frame is exchanged. Beckhoff's `TwinCAT.Ads` NuGet (their
|
||||
own .NET SDK, not libplctag-style OSS) has no in-process fake; tests stub
|
||||
the `ITwinCATClient` abstraction above it.
|
||||
|
||||
### 2. Multi-route AMS
|
||||
|
||||
ADS supports chained routes (`<localNetId> → <routerNetId> → <targetNetId>`)
|
||||
for PLCs behind an EC master / IPC gateway. Parse coverage exists; wire-path
|
||||
coverage doesn't.
|
||||
|
||||
### 3. Notification reliability under jitter
|
||||
|
||||
`AddDeviceNotification` delivers at the runtime's cycle boundary; under high
|
||||
CPU load or network jitter real notifications can coalesce. The fake fires
|
||||
one callback per test invocation — real callback-coalescing behavior is
|
||||
untested.
|
||||
|
||||
### 4. TC2 vs TC3 variant handling
|
||||
|
||||
TwinCAT 2 (ADS v1) and TwinCAT 3 (ADS v2) have subtly different
|
||||
`GetSymbolInfoByName` semantics + symbol-table layouts. Driver targets TC3;
|
||||
TC2 compatibility is not exercised.
|
||||
|
||||
### 5. Cycle-time alignment for `ISubscribable`
|
||||
|
||||
Native ADS notifications fire on the PLC cycle boundary. The fake test
|
||||
harness assumes notifications fire on a timer the test controls;
|
||||
cycle-aligned firing under real PLC control is not verified.
|
||||
|
||||
### 6. Alarms / history
|
||||
|
||||
Driver doesn't implement `IAlarmSource` or `IHistoryProvider` — not in
|
||||
scope for this driver family. TwinCAT 3's TcEventLogger could theoretically
|
||||
back an `IAlarmSource`, but shipping that is a separate feature.
|
||||
|
||||
## When to trust TwinCAT tests, when to reach for a rig
|
||||
|
||||
| Question | Unit tests | Real TwinCAT runtime |
|
||||
| --- | --- | --- |
|
||||
| "Does the AMS address parser accept X?" | yes | - |
|
||||
| "Does notification → `OnDataChange` wire correctly?" | yes (contract) | yes |
|
||||
| "Does symbol browsing filter TwinCAT internals?" | yes | yes |
|
||||
| "Does a real ADS read return correct bytes?" | no | yes (required) |
|
||||
| "Do notifications coalesce under load?" | no | yes (required) |
|
||||
| "Does a TC2 PLC work the same as TC3?" | no | yes (required) |
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
1. **XAR VM live-population** — scaffolding is in place (this PR); the
|
||||
remaining work is operational: stand up the Hyper-V VM, install XAR,
|
||||
author the `.tsproj` per `TwinCatProject/README.md`, configure the
|
||||
bilateral ADS route, set `TWINCAT_TARGET_HOST` + `TWINCAT_TARGET_NETID`
|
||||
on the dev box. Then the three smoke tests transition skip → pass.
|
||||
Tracked as #221.
|
||||
2. **License-rotation automation** — XAR's 7-day trial expires on
|
||||
schedule. Either automate `TcActivate.exe /reactivate` via a
|
||||
scheduled task on the VM (not officially supported; reportedly works
|
||||
for some TC3 builds), or buy a paid runtime license (~$1k one-time
|
||||
per runtime per CPU) to kill the rotation. The doc at
|
||||
`TwinCatProject/README.md` §License rotation walks through both.
|
||||
3. **Lab rig** — cheapest IPC (CX7000 / CX9020) on a dedicated network;
|
||||
the only route that covers TC2 + real EtherCAT I/O timing + cycle
|
||||
jitter under CPU load.
|
||||
|
||||
## Key fixture / config files
|
||||
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATXarFixture.cs`
|
||||
— TCP probe + skip-attributes + env-var parsing
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCAT3SmokeTests.cs`
|
||||
— three wire-level smoke tests
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md`
|
||||
— project spec + VM setup + license-rotation notes
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs` —
|
||||
in-process fake with the notification-fire harness used by
|
||||
`TwinCATNativeNotificationTests`
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs` — ctor takes
|
||||
`ITwinCATClientFactory`
|
||||
@@ -143,15 +143,49 @@ Dev credentials in this inventory are convenience defaults, not secrets. Change
|
||||
|
||||
| Resource | Purpose | Type | Default port | Default credentials | Owner |
|
||||
|----------|---------|------|--------------|---------------------|-------|
|
||||
| **Docker Desktop for Windows** | Host for containerized simulators | Install | (Hyper-V required; not compatible with TwinCAT runtime — see TwinCAT row below for the workaround) | n/a | Integration host admin |
|
||||
| **`oitc/modbus-server`** | Modbus TCP simulator (per `test-data-sources.md` §1) | Docker container | 502 (Modbus TCP) | n/a (no auth in protocol) | Integration host admin |
|
||||
| **`ab_server`** (libplctag binary) | AB CIP + AB Legacy simulator (per `test-data-sources.md` §2 + §3) | Native binary built from libplctag source; runs in a separate VM or host since it conflicts with Docker Desktop's Hyper-V if run on bare metal | 44818 (CIP) | n/a | Integration host admin |
|
||||
| **Snap7 Server** | S7 simulator (per `test-data-sources.md` §4) | Native binary; runs in a separate VM or in WSL2 to avoid Hyper-V conflict | 102 (ISO-TCP) | n/a | Integration host admin |
|
||||
| **Docker Desktop for Windows** | Host for every driver test-fixture simulator (Modbus / AB CIP / S7 / OpcUaClient) + SQL Server | Install | (Hyper-V required; not compatible with TwinCAT runtime — see TwinCAT row below for the workaround) | n/a | Integration host admin |
|
||||
| **Modbus fixture — `otopcua-pymodbus:3.13.0`** | Modbus driver integration tests | Docker image (local build, see `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/`); 4 compose profiles: `standard` / `dl205` / `mitsubishi` / `s7_1500` | 5020 (non-privileged) | n/a (no auth in protocol) | Developer (per machine) |
|
||||
| **AB CIP fixture — `otopcua-ab-server:libplctag-release`** | AB CIP driver integration tests | Docker image (multi-stage build of libplctag's `ab_server` from source, pinned to the `release` tag; see `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/`); 4 compose profiles: `controllogix` / `compactlogix` / `micro800` / `guardlogix` | 44818 (CIP / EtherNet/IP) | n/a | Developer (per machine) |
|
||||
| **S7 fixture — `otopcua-python-snap7:1.0`** | S7 driver integration tests | Docker image (local build, `python-snap7>=2.0`; see `tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/`); 1 compose profile: `s7_1500` | 1102 (non-privileged; driver honours `S7DriverOptions.Port`) | n/a | Developer (per machine) |
|
||||
| **OPC UA Client fixture — `mcr.microsoft.com/iotedge/opc-plc:2.14.10`** | OpcUaClient driver integration tests | Docker image (Microsoft-maintained, pinned; see `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/`) | 50000 (OPC UA) | Anonymous (`--daa` off); auto-accept certs (`--aa`) | Developer (per machine) |
|
||||
| **TwinCAT XAR runtime VM** | TwinCAT ADS testing (per `test-data-sources.md` §5; Beckhoff XAR cannot coexist with Hyper-V on the same OS) | Hyper-V VM with Windows + TwinCAT XAR installed under 7-day renewable trial | 48898 (ADS over TCP) | TwinCAT default route credentials configured per Beckhoff docs | Integration host admin |
|
||||
| **OPC Foundation reference server** | OPC UA Client driver test source (per `test-data-sources.md` §"OPC UA Client") | Built from `OPCFoundation/UA-.NETStandard` `ConsoleReferenceServer` project | 62541 (default for the reference server) | Anonymous + Username (`user1` / `password1`) per the reference server's built-in user list | Integration host admin |
|
||||
| **Rockwell Studio 5000 Logix Emulate** | AB CIP golden-box tier — closes UDT / ALMD / AOI / GuardLogix-safety / CompactLogix-ConnectionSize gaps the ab_server simulator can't cover. Loads the L5X project documented at `tests/.../AbCip.IntegrationTests/LogixProject/README.md`. Tests gated on `AB_SERVER_PROFILE=emulate` + `AB_SERVER_ENDPOINT=<ip>:44818`; see `docs/drivers/AbServer-Test-Fixture.md` §Logix Emulate golden-box tier | Windows-only install; **Hyper-V conflict** — can't coexist with Docker Desktop's WSL 2 backend on the same OS, same story as TwinCAT XAR. Runs on a dedicated Windows PC reachable on the LAN | 44818 (CIP / EtherNet/IP) | None required at the CIP layer; Studio 5000 project credentials per Rockwell install | Integration host admin (license + install); Developer (per session — open Emulate, load L5X, click Run) |
|
||||
| **FOCAS TCP stub** (`Driver.Focas.TestStub`) | FOCAS functional testing (per `test-data-sources.md` §6) | Local .NET 10 console app from this repo | 8193 (FOCAS) | n/a | Developer / integration host (run on demand) |
|
||||
| **FOCAS FaultShim** (`Driver.Focas.FaultShim`) | FOCAS native-fault injection (per `test-data-sources.md` §6) | Test-only native DLL named `Fwlib64.dll`, loaded via DLL search path in the test fixture | n/a (in-process) | n/a | Developer / integration host (test-only) |
|
||||
|
||||
### Docker fixtures — quick reference
|
||||
|
||||
Every driver's integration-test simulator ships as a Docker image (or pulls
|
||||
one from MCR). Start the one you need, run `dotnet test`, stop it.
|
||||
Container lifecycle is always manual — fixtures TCP-probe at collection
|
||||
init + skip cleanly when nothing's running.
|
||||
|
||||
| Driver | Fixture image | Compose file | Bring up |
|
||||
|---|---|---|---|
|
||||
| Modbus | local-build `otopcua-pymodbus:3.13.0` | `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml` | `docker compose -f <compose> --profile <standard\|dl205\|mitsubishi\|s7_1500> up -d` |
|
||||
| AB CIP | local-build `otopcua-ab-server:libplctag-release` | `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml` | `docker compose -f <compose> --profile <controllogix\|compactlogix\|micro800\|guardlogix> up -d` |
|
||||
| S7 | local-build `otopcua-python-snap7:1.0` | `tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/docker-compose.yml` | `docker compose -f <compose> --profile s7_1500 up -d` |
|
||||
| OpcUaClient | `mcr.microsoft.com/iotedge/opc-plc:2.14.10` (pinned) | `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/docker-compose.yml` | `docker compose -f <compose> up -d` |
|
||||
|
||||
First build of a local-build image takes 1–5 minutes; subsequent runs use
|
||||
layer cache. `ab_server` is the slowest (multi-stage build clones
|
||||
libplctag + compiles C). Stop with `docker compose -f <compose> --profile <…> down`.
|
||||
|
||||
**Endpoint overrides** — every fixture respects an env var to point at a
|
||||
real PLC instead of the simulator:
|
||||
|
||||
- `MODBUS_SIM_ENDPOINT` (default `localhost:5020`)
|
||||
- `AB_SERVER_ENDPOINT` (no default; overrides the local container endpoint)
|
||||
- `S7_SIM_ENDPOINT` (default `localhost:1102`)
|
||||
- `OPCUA_SIM_ENDPOINT` (default `opc.tcp://localhost:50000`)
|
||||
|
||||
No native launchers — Docker is the only supported path for these
|
||||
fixtures. A fresh clone needs Docker Desktop and nothing else; fixture
|
||||
TCP probes skip tests cleanly when the container isn't running.
|
||||
|
||||
See each driver's `docs/drivers/*-Test-Fixture.md` for the full coverage
|
||||
map + gap inventory.
|
||||
|
||||
### D. Cloud / external services
|
||||
|
||||
| Resource | Purpose | Type | Access | Owner |
|
||||
|
||||
145
docs/v2/focas-version-matrix.md
Normal file
145
docs/v2/focas-version-matrix.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# FOCAS version / capability matrix
|
||||
|
||||
Authoritative source for the per-CNC-series ranges that
|
||||
[`FocasCapabilityMatrix`](../../src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs)
|
||||
enforces at driver init time. Every row cites the Fanuc FOCAS Developer
|
||||
Kit function whose documented input range determines the ceiling.
|
||||
|
||||
**Why this exists** — we have no FOCAS hardware on the bench and no
|
||||
working simulator. Fwlib32 returns `EW_NUMBER` / `EW_PARAM` when you
|
||||
hand it an address outside the controller's supported range; the
|
||||
driver would map that to a per-read `BadOutOfRange` at steady state.
|
||||
Catching at `InitializeAsync` with this matrix surfaces operator
|
||||
typos + mismatched series declarations as config errors before any
|
||||
session is opened, which is the only feedback loop available without
|
||||
a live CNC to read against.
|
||||
|
||||
**Who declares the series** — `FocasDeviceOptions.Series` in
|
||||
`appsettings.json`. Defaults to `Unknown`, which is permissive — every
|
||||
address passes validation. Pre-matrix configs don't break on upgrade.
|
||||
|
||||
---
|
||||
|
||||
## Series covered
|
||||
|
||||
| Enum value | Controller family | Typical era |
|
||||
| --- | --- | --- |
|
||||
| `Unknown` | (legacy / not declared) | permissive fallback |
|
||||
| `Sixteen_i` | 16i / 18i / 21i | 1997-2008 |
|
||||
| `Zero_i_D` | 0i-D | 2008-2013 |
|
||||
| `Zero_i_F` | 0i-F | 2013-present, general-purpose |
|
||||
| `Zero_i_MF` | 0i-MF | 0i-F lathe variant |
|
||||
| `Zero_i_TF` | 0i-TF | 0i-F turning variant |
|
||||
| `Thirty_i` | 30i-A / 30i-B | 2007-present, high-end |
|
||||
| `ThirtyOne_i` | 31i-A / 31i-B | 30i simpler variant |
|
||||
| `ThirtyTwo_i` | 32i-A / 32i-B | 30i compact |
|
||||
| `PowerMotion_i` | Power Motion i-A / i-MODEL A | motion-only controller |
|
||||
|
||||
## Macro variable range (`cnc_rdmacro` / `cnc_wrmacro`)
|
||||
|
||||
Common macros `1-33` + `100-199` + `500-999` are universal across all
|
||||
series. Extended macros (`#10000+`) exist only on higher-end series.
|
||||
The numbers below reflect the extended ceiling per series per the
|
||||
DevKit range tables.
|
||||
|
||||
| Series | Min | Max | Notes |
|
||||
| --- | ---: | ---: | --- |
|
||||
| `Sixteen_i` | 0 | 999 | legacy ceiling — no extended |
|
||||
| `Zero_i_D` | 0 | 999 | 0i-D still at legacy ceiling |
|
||||
| `Zero_i_F` / `Zero_i_MF` / `Zero_i_TF` | 0 | 9999 | extended added on 0i-F |
|
||||
| `Thirty_i` / `ThirtyOne_i` / `ThirtyTwo_i` | 0 | 99999 | full extended set |
|
||||
| `PowerMotion_i` | 0 | 999 | atypical — limited macro coverage |
|
||||
|
||||
## Parameter range (`cnc_rdparam` / `cnc_wrparam`)
|
||||
|
||||
| Series | Min | Max |
|
||||
| --- | ---: | ---: |
|
||||
| `Sixteen_i` | 0 | 9999 |
|
||||
| `Zero_i_D` / `Zero_i_F` / `Zero_i_MF` / `Zero_i_TF` | 0 | 14999 |
|
||||
| `Thirty_i` / `ThirtyOne_i` / `ThirtyTwo_i` | 0 | 29999 |
|
||||
| `PowerMotion_i` | 0 | 29999 |
|
||||
|
||||
## PMC letters (`pmc_rdpmcrng` / `pmc_wrpmcrng`)
|
||||
|
||||
Addresses are letter + number (e.g. `R100`, `F50.3`). Legacy
|
||||
controllers omit the `F`/`G` signal groups that 30i-family ladder
|
||||
programs use, and only the 30i-family exposes `K` (keep-relay) +
|
||||
`T` (timer).
|
||||
|
||||
| Letter | 16i | 0i-D | 0i-F family | 30i family | Power Motion-i |
|
||||
| --- | :-: | :-: | :-: | :-: | :-: |
|
||||
| `X` | yes | yes | yes | yes | yes |
|
||||
| `Y` | yes | yes | yes | yes | yes |
|
||||
| `R` | yes | yes | yes | yes | yes |
|
||||
| `D` | yes | yes | yes | yes | yes |
|
||||
| `E` | — | yes | yes | yes | — |
|
||||
| `A` | — | yes | yes | yes | — |
|
||||
| `F` | — | — | yes | yes | — |
|
||||
| `G` | — | — | yes | yes | — |
|
||||
| `M` | — | — | yes | yes | — |
|
||||
| `C` | — | — | yes | yes | — |
|
||||
| `K` | — | — | — | yes | — |
|
||||
| `T` | — | — | — | yes | — |
|
||||
|
||||
Letter match is case-insensitive. `FocasAddress.PmcLetter` is carried
|
||||
as a string (not char) so the matrix can do ordinal-ignore-case
|
||||
comparison.
|
||||
|
||||
## PMC address-number ceiling
|
||||
|
||||
PMC addresses are byte-addressed on read + bit-addressed on write;
|
||||
`FocasAddress` carries the bit index separately, so these are byte
|
||||
ceilings.
|
||||
|
||||
| Series | Max byte | Notes |
|
||||
| --- | ---: | --- |
|
||||
| `Sixteen_i` | 999 | legacy |
|
||||
| `Zero_i_D` | 1999 | doubled since 16i |
|
||||
| `Zero_i_F` family | 9999 | |
|
||||
| `Thirty_i` family | 59999 | highest density |
|
||||
| `PowerMotion_i` | 1999 | |
|
||||
|
||||
## Error surface
|
||||
|
||||
When a tag fails validation, `FocasDriver.InitializeAsync` throws
|
||||
`InvalidOperationException` with a message of the form:
|
||||
|
||||
```
|
||||
FOCAS tag '<name>' (<address>) rejected by capability matrix: <reason>
|
||||
```
|
||||
|
||||
`<reason>` is the verbatim string from `FocasCapabilityMatrix.Validate`
|
||||
and always names the series + the documented limit so the operator
|
||||
can either raise the limit (if wrong) or correct the CNC series they
|
||||
declared (if mismatched). Sample:
|
||||
|
||||
```
|
||||
FOCAS tag 'X_axis_macro_ext' (MACRO:50000) rejected by capability
|
||||
matrix: Macro variable #50000 is outside the documented range
|
||||
[0, 9999] for Zero_i_F.
|
||||
```
|
||||
|
||||
## How this matrix stays honest
|
||||
|
||||
- Every row is covered by a parameterized test in
|
||||
[`FocasCapabilityMatrixTests.cs`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityMatrixTests.cs)
|
||||
— 46 cases across macro / parameter / PMC-letter / PMC-number
|
||||
boundaries + unknown-series permissiveness + rejection-message
|
||||
content + case-insensitivity.
|
||||
- Widening or narrowing a range in the matrix without updating this
|
||||
doc will fail a test, because the theories cite the specific row
|
||||
they reflect in their `InlineData`.
|
||||
- The matrix is not comprehensive — it encodes only the subset of
|
||||
FOCAS surface the driver currently exposes (Macro / Parameter /
|
||||
PMC). When the driver gains a new capability (e.g. tool management,
|
||||
alarm history), add its series-specific range tables here + matching
|
||||
tests at the same time.
|
||||
|
||||
## Follow-up
|
||||
|
||||
This validation closes the cheap half of the FOCAS hardware-free
|
||||
stability gap — config errors now fail at load instead of per-read.
|
||||
The expensive half is Tier-C process isolation so that a crashing
|
||||
`Fwlib32.dll` doesn't take the main OPC UA server down with it. See
|
||||
[`docs/v2/implementation/focas-isolation-plan.md`](implementation/focas-isolation-plan.md)
|
||||
for that plan (task #220).
|
||||
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
|
||||
173
docs/v2/implementation/focas-isolation-plan.md
Normal file
173
docs/v2/implementation/focas-isolation-plan.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# FOCAS Tier-C isolation — plan for task #220
|
||||
|
||||
> **Status**: PRs A–E shipped. Architecture is in place; the only
|
||||
> remaining FOCAS work is the hardware-dependent production
|
||||
> integration of `Fwlib32.dll` into a real `IFocasBackend`
|
||||
> (`FwlibHostedBackend`), which needs an actual CNC on the bench
|
||||
> and is tracked as a follow-up on #220.
|
||||
>
|
||||
> **Pre-reqs shipped**: version matrix + pre-flight validation
|
||||
> (PR #168 — the cheap half of the hardware-free stability gap).
|
||||
|
||||
## Why isolate
|
||||
|
||||
`Fwlib32.dll` is a proprietary Fanuc library with no source, no
|
||||
symbols, and a documented habit of crashing the hosting process on
|
||||
network errors, malformed responses, and during handle recycling.
|
||||
Today the FOCAS driver runs in-process with the OPC UA server —
|
||||
a crash inside the Fanuc DLL takes every driver down with it,
|
||||
including ones that have nothing to do with FOCAS. Galaxy has the
|
||||
same class of problem and solved it with the Tier-C pattern (host
|
||||
service + proxy driver + named-pipe IPC); FOCAS should follow that
|
||||
playbook.
|
||||
|
||||
## Topology (target)
|
||||
|
||||
```
|
||||
+-------------------------------------+ +--------------------------+
|
||||
| OtOpcUa.Server (.NET 10 x64) | | OtOpcUaFocasHost |
|
||||
| | pipe | (.NET 4.8 x86 Windows |
|
||||
| ZB.MOM.WW.OtOpcUa.Driver.FOCAS | <-----> | service) |
|
||||
| - FocasProxyDriver (in-proc) | | |
|
||||
| - supervisor / respawn / BackPr | | Fwlib32.dll + session |
|
||||
| | | handles + STA thread |
|
||||
+-------------------------------------+ +--------------------------+
|
||||
```
|
||||
|
||||
Why .NET 4.8 x86 for the host: `Fwlib32.dll` ships as 32-bit only.
|
||||
The Galaxy.Host is already .NET 4.8 x86 for the same reason
|
||||
(MXAccess COM bitness), so the NSSM wrapper pattern transfers
|
||||
directly.
|
||||
|
||||
## Three new projects
|
||||
|
||||
| Project | TFM | Role |
|
||||
| --- | --- | --- |
|
||||
| `ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Shared` | `netstandard2.0` | MessagePack DTOs — `FocasReadRequest`, `FocasReadResponse`, `FocasSubscribeRequest`, `FocasPmcBitWriteRequest`, etc. Same assembly referenced by .NET 10 + .NET 4.8 so the wire format stays identical. |
|
||||
| `ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host` | `net48` x86 | Windows service. Owns the Fwlib32 session handles + STA thread + handle-recycling loop. Pipe server + per-call auth (same ACL + caller SID + shared secret pattern as Galaxy.Host). |
|
||||
| `ZB.MOM.WW.OtOpcUa.Driver.FOCAS` (existing) | `net10.0` | Collapses to a proxy that forwards each `IReadable` / `IWritable` / `ISubscribable` call over the pipe. `FocasCapabilityMatrix` + `FocasAddress` stay here — pre-flight runs before any IPC. |
|
||||
|
||||
## Supervisor responsibilities (in the Proxy)
|
||||
|
||||
Mirrors Galaxy.Proxy 1:1:
|
||||
|
||||
1. Start the Host process on first `InitializeAsync` (NSSM-wrapped
|
||||
service in production, direct spawn in dev) + heartbeat every
|
||||
5s.
|
||||
2. If heartbeat misses 3× in a row, fan out `BadCommunicationError`
|
||||
to every subscription and respawn with exponential backoff
|
||||
(1s / 2s / 4s / max 30s).
|
||||
3. Crash-loop circuit breaker: 5 respawns in 60s → drop to
|
||||
`BadDeviceFailure` steady state until operator resets.
|
||||
4. Post-mortem MMF: on Host exit, Host writes its last-N operations
|
||||
+ session state to an MMF the Proxy reads to log context.
|
||||
|
||||
## IPC surface (approximate)
|
||||
|
||||
Every `FocasDriver` method that today calls into Fwlib32 directly
|
||||
becomes an `ExecuteAsync` call with a typed request:
|
||||
|
||||
| Today (in-process) | Tier-C (IPC) |
|
||||
| --- | --- |
|
||||
| `FocasTagReader.Read(tag)` | `client.Execute(new FocasReadRequest(session, address))` |
|
||||
| `FocasTagWriter.Write(tag, value)` | `client.Execute(new FocasWriteRequest(...))` |
|
||||
| `FocasPmcBitRmw.Write(tag, bit, value)` | `client.Execute(new FocasPmcBitWriteRequest(...))` — RMW happens in Host so the critical section stays on one process |
|
||||
| `FocasConnectivityProbe.ProbeAsync` | `client.Execute(new FocasProbeRequest())` |
|
||||
| `FocasSubscriber.Subscribe(tags)` | `client.Execute(new FocasSubscribeRequest(tags))` — Host owns the poll loop + streams changes back as `FocasDataChangedNotification` over the pipe |
|
||||
|
||||
Subscription streaming is the non-obvious piece: the Host polls on
|
||||
its own timer + pushes change notifications so the Proxy doesn't
|
||||
round-trip per poll. Matches `Driver.Galaxy.Host` subscription
|
||||
forwarding.
|
||||
|
||||
## PR sequence — shipped
|
||||
|
||||
1. **PR A (#169) — shared contracts** ✅
|
||||
`Driver.FOCAS.Shared` netstandard2.0 with MessagePack DTOs for every
|
||||
IPC surface (Hello/Heartbeat/OpenSession/Read/Write/PmcBitWrite/
|
||||
Subscribe/Probe/RuntimeStatus/Recycle/ErrorResponse) + FrameReader/
|
||||
FrameWriter + 24 round-trip tests.
|
||||
2. **PR B (#170) — Host project skeleton** ✅
|
||||
`Driver.FOCAS.Host` net48 x86 Windows Service entry point,
|
||||
`PipeAcl` + `PipeServer` + `IFrameHandler` + `StubFrameHandler`.
|
||||
ACL denies LocalSystem/Administrators; Hello verifies
|
||||
shared-secret + protocol major. 3 handshake tests.
|
||||
3. **PR C (#171) — IPC path end-to-end** ✅
|
||||
Proxy `Ipc/FocasIpcClient` + `Ipc/IpcFocasClient` (implements
|
||||
IFocasClient via IPC). Host `Backend/IFocasBackend` +
|
||||
`FakeFocasBackend` + `UnconfiguredFocasBackend` +
|
||||
`Ipc/FwlibFrameHandler` replacing the stub. 13 new round-trip
|
||||
tests via in-memory loopback.
|
||||
4. **PR D (#172) — Supervisor + respawn** ✅
|
||||
`Supervisor/Backoff` (5s→15s→60s) + `CircuitBreaker` (3-in-5min →
|
||||
1h→4h→manual) + `HeartbeatMonitor` + `IHostProcessLauncher` +
|
||||
`FocasHostSupervisor`. 14 tests.
|
||||
5. **PR E — Ops glue** ✅ (this PR)
|
||||
`ProcessHostLauncher` (real Process.Start + FocasIpcClient
|
||||
connect), `Host/Stability/PostMortemMmf` (magic 'OFPC') +
|
||||
Proxy `Supervisor/PostMortemReader`, `scripts/install/
|
||||
Install-FocasHost.ps1` + `Uninstall-FocasHost.ps1` NSSM wrappers.
|
||||
7 tests (4 MMF round-trip + 3 reader format compatibility).
|
||||
|
||||
**Post-shipment totals: 189 FOCAS driver tests + 24 Shared tests + 13 Host tests = 226 FOCAS-family tests green.**
|
||||
|
||||
What remains is hardware-dependent: wiring `Fwlib32.dll` P/Invoke
|
||||
into a real `FwlibHostedBackend` implementation of `IFocasBackend`
|
||||
+ validating against a live CNC. The architecture is all the
|
||||
plumbing that work needs.
|
||||
|
||||
## Testing without hardware
|
||||
|
||||
Same constraint as today: no CNC, no simulator. The isolation work
|
||||
itself is verifiable without Fwlib32 actually being called:
|
||||
|
||||
- **Pipe contract**: PR A's MessagePack round-trip tests cover every
|
||||
DTO.
|
||||
- **Supervisor**: PR D uses a `FakeFocasHost` stub that can be told
|
||||
to crash, hang, or miss heartbeats. The supervisor's respawn +
|
||||
circuit-breaker behaviour is fully testable against the stub.
|
||||
- **IPC ACL + auth**: reuse the Galaxy.Host's existing test harness
|
||||
pattern — negative tests attempt to connect as the wrong user and
|
||||
assert rejection.
|
||||
- **Fwlib32 integration itself**: still untestable without hardware.
|
||||
When a real CNC becomes available, the smoke tests already
|
||||
scaffolded in `tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/`
|
||||
run against it via `FOCAS_ENDPOINT`.
|
||||
|
||||
## Decisions to confirm before starting
|
||||
|
||||
- **Sharing transport code with Galaxy.Host** — should the pipe
|
||||
server + ACL + shared-secret + MMF plumbing go into a common
|
||||
`Core.Hosting.Tier-C` project both hosts reference? Probably yes;
|
||||
deferred until PR B is drafted because the right abstraction only
|
||||
becomes visible after two uses.
|
||||
- **Handle-recycling cadence** — Fwlib32 session handles leak
|
||||
memory over weeks per the Fanuc-published defect list. Galaxy
|
||||
recycles MXAccess handles on a 24h timer; FOCAS should mirror but
|
||||
the trigger point (idle vs scheduled) needs operator input.
|
||||
- **Per-CNC Host process vs one Host serving N CNCs** — one-per-CNC
|
||||
isolates blast radius but scales poorly past ~20 machines; shared
|
||||
Host scales but one bad CNC can wedge the lot. Start with shared
|
||||
Host + document the blast-radius trade; revisit if operators hit
|
||||
it.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Simulator work. `open_focas` + other OSS FOCAS simulators are
|
||||
untested + not maintained; not worth chasing vs. waiting for real
|
||||
hardware.
|
||||
- Changing the public `FocasDriverOptions` shape beyond what
|
||||
already shipped (the `Series` knob). Operator config continues to
|
||||
look the same after the split — the Tier-C topology is invisible
|
||||
from `appsettings.json`.
|
||||
- Historian / long-term history integration. FOCAS driver doesn't
|
||||
implement `IHistoryProvider` + there's no plan to add it.
|
||||
|
||||
## References
|
||||
|
||||
- [`docs/v2/implementation/phase-2-galaxy-out-of-process.md`](phase-2-galaxy-out-of-process.md)
|
||||
— the working Tier-C template this plan follows.
|
||||
- [`docs/drivers/FOCAS-Test-Fixture.md`](../../drivers/FOCAS-Test-Fixture.md)
|
||||
— what's covered today + what stays blocked on hardware.
|
||||
- [`docs/v2/focas-version-matrix.md`](../focas-version-matrix.md) —
|
||||
the capability matrix that pre-flights configs before IPC runs.
|
||||
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`.
|
||||
@@ -13,9 +13,9 @@ confirmed DL205 quirk lands in a follow-up PR as a named test in that project.
|
||||
|
||||
## Harness
|
||||
|
||||
**Chosen simulator: pymodbus 3.13.0** (`pip install 'pymodbus[simulator]==3.13.0'`).
|
||||
Replaced ModbusPal in PR 43 — see `tests/.../Pymodbus/README.md` for the
|
||||
trade-off rationale. Headline reasons:
|
||||
**Chosen simulator: pymodbus 3.13.0** packaged as a pinned Docker image
|
||||
under `tests/.../Modbus.IntegrationTests/Docker/`. See that folder's
|
||||
`README.md` for image-build notes + compose profiles. Headline reasons:
|
||||
|
||||
- **Headless** pure-Python CLI; no Java GUI, runs cleanly on a CI runner.
|
||||
- **Maintained** — current stable 3.13.0; ModbusPal 1.6b is abandoned.
|
||||
@@ -26,17 +26,18 @@ trade-off rationale. Headline reasons:
|
||||
- **Per-register raw uint16 seeding** — encoding the DL205 string-byte-order
|
||||
/ BCD / CDAB-float quirks stays explicit (the quirk math lives in the
|
||||
`_quirk` JSON-comment fields next to each register).
|
||||
- Pip-installable on Windows; sidesteps the privileged-port admin
|
||||
requirement by defaulting to TCP **5020** instead of 502.
|
||||
- **Dockerized** — pinned image means the CI simulator surface is
|
||||
reproducible + no `pip install` step on the dev box.
|
||||
- Defaults to TCP **5020** (matches the compose port-map + the fixture
|
||||
default endpoint; sidesteps the Windows Firewall prompt on 502).
|
||||
|
||||
**Setup pattern**:
|
||||
1. `pip install "pymodbus[simulator]==3.13.0"`.
|
||||
2. Start the simulator with one of the in-repo profiles:
|
||||
`tests\.../Pymodbus\serve.ps1 -Profile standard` (or `-Profile dl205`).
|
||||
3. `dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` —
|
||||
1. `docker compose -f tests\...\Modbus.IntegrationTests\Docker\docker-compose.yml --profile <standard|dl205|mitsubishi|s7_1500> up -d`.
|
||||
2. `dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` —
|
||||
tests auto-skip when the endpoint is unreachable. Default endpoint is
|
||||
`localhost:5020`; override via `MODBUS_SIM_ENDPOINT` for a real PLC on its
|
||||
native port 502.
|
||||
3. `docker compose -f ... --profile <…> down` when finished.
|
||||
|
||||
## Per-device quirk catalog
|
||||
|
||||
@@ -113,9 +114,11 @@ vendors get promoted into driver defaults or opt-in options:
|
||||
- **PR 42 — ModbusPal `.xmpp` profiles** — **SUPERSEDED by PR 43**. Replaced
|
||||
with pymodbus JSON because ModbusPal 1.6b is abandoned, GUI-only, and only
|
||||
exposes 2 of the 4 standard tables.
|
||||
- **PR 43 — pymodbus JSON profiles** — **DONE**. `Pymodbus/standard.json` +
|
||||
`Pymodbus/dl205.json` + `Pymodbus/serve.ps1` runner. Both bind TCP 5020.
|
||||
- **PR 43 — pymodbus JSON profiles** — **DONE**. Dockerized under
|
||||
`Docker/profiles/` (standard.json, dl205.json, mitsubishi.json,
|
||||
s7_1500.json); compose file launches each via a named profile.
|
||||
All bind TCP 5020.
|
||||
- **PR 44+**: one PR per confirmed DL205 quirk, landing the named test + any
|
||||
driver-side adjustment (string byte order, BCD decoder, V-memory address
|
||||
helper, FC16 cap-per-device-family) needed to pass it. Each quirk's value
|
||||
is already pre-encoded in `Pymodbus/dl205.json`.
|
||||
is already pre-encoded in `Docker/profiles/dl205.json`.
|
||||
|
||||
@@ -191,40 +191,30 @@ Modbus has no native String, DateTime, or Int64 — those rows are skipped on th
|
||||
|
||||
### CI fixture (task #180)
|
||||
|
||||
The integration harness at `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/` exposes two test-time contracts:
|
||||
The integration harness at `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/` is Docker-only — `ab_server` is a source-only tool under libplctag's `src/tools/ab_server/`, and the fixture's multi-stage `Docker/Dockerfile` is the only supported reproducible build path.
|
||||
|
||||
- **`AbServerFixture(AbServerProfile)`** — starts the simulator with the CLI args composed from the profile's `--plc` family + seed-tag set. One fixture instance per family, one simulator process per test case (smoke tier). For larger suites that can share a simulator across several reads/writes, use a `IClassFixture<AbServerFixture>` wrapper per family.
|
||||
- **`KnownProfiles.{ControlLogix, CompactLogix, Micro800, GuardLogix}`** — the four per-family profiles. Drives the simulator's `--plc` mode + the preseed `--tag name:type[:size]` set. Micro800 + GuardLogix fall back to `controllogix` under the hood because ab_server has no dedicated mode for them — the driver-side family profile still enforces the narrower connection shape / safety classification separately.
|
||||
- **`AbServerFixture(AbServerProfile)`** — thin TCP probe against `127.0.0.1:44818` (or `AB_SERVER_ENDPOINT` override). Does not spawn the simulator; the operator brings up the compose service for whichever family the test class targets (`controllogix` / `compactlogix` / `micro800` / `guardlogix`).
|
||||
- **`KnownProfiles.{ControlLogix, CompactLogix, Micro800, GuardLogix}`** — thin `(Family, ComposeProfile, Notes)` records. The compose file (`Docker/docker-compose.yml`) is the canonical source of truth for which tags each family seeds + which `--plc` mode the simulator boots in. `Micro800` uses the dedicated `--plc=Micro800` mode; `GuardLogix` uses `ControlLogix` emulation because ab_server has no safety subsystem (the `_S`-suffixed seed tag triggers driver-side ViewOnly classification only).
|
||||
|
||||
**Pinned version** (recorded in `ci/ab-server.lock.json` so drift is one-file visible):
|
||||
|
||||
- `libplctag` **v2.6.16** (published 2026-03-29) — `ab_server.exe` ships inside the `_tools.zip` asset alongside `plctag.dll` + two `list_tags_*` helpers.
|
||||
- Windows x64: `libplctag_2.6.16_windows_x64_tools.zip` — SHA256 `9b78a3dee73d9cd28ca348c090f453dbe3ad9d07ad6bf42865a9dc3a79bc2232`
|
||||
- Windows x86: `libplctag_2.6.16_windows_x86_tools.zip` — SHA256 `fdfefd58b266c5da9a1ded1a430985e609289c9e67be2544da7513b668761edf`
|
||||
- Windows ARM64: `libplctag_2.6.16_windows_arm64_tools.zip` — SHA256 `d747728e4c4958bb63b4ac23e1c820c4452e4778dfd7d58f8a0aecd5402d4944`
|
||||
**Pinned version**: the `Docker/Dockerfile` clones libplctag at a pinned tag (currently the `release` branch) via its `LIBPLCTAG_TAG` build-arg and compiles `ab_server` from source. Bump deliberately alongside a driver-side change that needs the newer simulator.
|
||||
|
||||
**CI step:**
|
||||
|
||||
```yaml
|
||||
# GitHub Actions step placed before `dotnet test`:
|
||||
- name: Fetch ab_server (libplctag v2.6.16)
|
||||
- name: Start ab_server Docker container
|
||||
shell: pwsh
|
||||
run: |
|
||||
$pin = Get-Content ci/ab-server.lock.json | ConvertFrom-Json
|
||||
$asset = $pin.assets.'windows-x64' # swap to windows-x86 / windows-arm64 on non-x64 runners
|
||||
$url = "https://github.com/libplctag/libplctag/releases/download/$($pin.tag)/$($asset.file)"
|
||||
$zip = Join-Path $env:RUNNER_TEMP 'libplctag-tools.zip'
|
||||
Invoke-WebRequest $url -OutFile $zip
|
||||
$actual = (Get-FileHash -Algorithm SHA256 $zip).Hash.ToLower()
|
||||
if ($actual -ne $asset.sha256) { throw "libplctag tools SHA256 mismatch: expected $($asset.sha256), got $actual" }
|
||||
$dest = Join-Path $env:RUNNER_TEMP 'libplctag-tools'
|
||||
Expand-Archive $zip -DestinationPath $dest
|
||||
Add-Content $env:GITHUB_PATH $dest
|
||||
docker compose -f tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml `
|
||||
--profile controllogix up -d --build
|
||||
# Wait for :44818 to accept connections (compose healthcheck-equivalent)
|
||||
for ($i = 0; $i -lt 30; $i++) {
|
||||
if ((Test-NetConnection -ComputerName localhost -Port 44818 -WarningAction SilentlyContinue).TcpTestSucceeded) { break }
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
```
|
||||
|
||||
The fixture's `LocateBinary()` picks the binary up off PATH so the C# harness doesn't own the download — CI YAML is the right place for version pinning + hash verification. Developer workstations install the binary once from source (`cmake + make ab_server` under a libplctag clone) and the same fixture works identically.
|
||||
|
||||
Tests without ab_server on PATH are marked `Skip` via `AbServerFactAttribute` / `AbServerTheoryAttribute`, so fresh-clone runs without the simulator still pass all unit suites in this project.
|
||||
Tests skip via `AbServerFactAttribute` / `AbServerTheoryAttribute` when the probe fails, so fresh-clone runs without Docker still pass all unit suites in this project.
|
||||
|
||||
---
|
||||
|
||||
|
||||
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'."
|
||||
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!;
|
||||
}
|
||||
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>
|
||||
173
src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs
Normal file
173
src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Materializes the canonical Unified Namespace browse tree for an Equipment-kind
|
||||
/// <see cref="Configuration.Entities.Namespace"/> from the Config DB's
|
||||
/// <c>UnsArea</c> / <c>UnsLine</c> / <c>Equipment</c> / <c>Tag</c> rows. Runs during
|
||||
/// address-space build per <see cref="IDriver"/> whose
|
||||
/// <c>Namespace.Kind = Equipment</c>; SystemPlatform-kind namespaces (Galaxy) are
|
||||
/// exempt per decision #120 and reach this walker only indirectly through
|
||||
/// <see cref="ITagDiscovery.DiscoverAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Composition strategy.</b> ADR-001 (2026-04-20) accepted Option A — Config
|
||||
/// primary. The walker treats the supplied <see cref="EquipmentNamespaceContent"/>
|
||||
/// snapshot as the authoritative published surface. Every Equipment row becomes a
|
||||
/// folder node at the UNS level-5 segment; every <see cref="Tag"/> bound to an
|
||||
/// Equipment (non-null <see cref="Tag.EquipmentId"/>) becomes a variable node under
|
||||
/// it. Driver-discovered tags that have no Config-DB row are not added by this
|
||||
/// walker — the ITagDiscovery path continues to exist for the SystemPlatform case +
|
||||
/// for enrichment, but Equipment-kind composition is fully Tag-row-driven.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Under each Equipment node.</b> Five identifier properties per decision #121
|
||||
/// (<c>EquipmentId</c>, <c>EquipmentUuid</c>, <c>MachineCode</c>, <c>ZTag</c>,
|
||||
/// <c>SAPID</c>) are added as OPC UA properties — external systems (ERP, SAP PM)
|
||||
/// resolve equipment by whichever identifier they natively use without a sidecar.
|
||||
/// <see cref="IdentificationFolderBuilder.Build"/> materializes the OPC 40010
|
||||
/// Identification sub-folder with the nine decision-#139 fields when at least one
|
||||
/// is non-null; when all nine are null the sub-folder is omitted rather than
|
||||
/// appearing empty.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Address resolution.</b> Variable nodes carry the driver-side full reference
|
||||
/// in <see cref="DriverAttributeInfo.FullName"/> copied from <c>Tag.TagConfig</c>
|
||||
/// (the wire-level address JSON blob whose interpretation is driver-specific). At
|
||||
/// runtime the dispatch layer routes Read/Write calls through the configured
|
||||
/// capability invoker; an unreachable address surfaces as an OPC UA Bad status via
|
||||
/// the natural driver-read failure path, NOT as a build-time reject. The ADR calls
|
||||
/// this "BadNotFound placeholder" behavior — legible to operators via their Admin
|
||||
/// UI + OPC UA client inspection of node status.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Pure function.</b> This class has no dependency on the OPC UA SDK, no
|
||||
/// Config-DB access, no state. It consumes pre-loaded EF Core rows + streams calls
|
||||
/// into the supplied <see cref="IAddressSpaceBuilder"/>. The server-side wiring
|
||||
/// (load snapshot → invoke walker → per-tag capability probe) lives in the Task B
|
||||
/// PR alongside <c>NodeScopeResolver</c>'s Config-DB join.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class EquipmentNodeWalker
|
||||
{
|
||||
/// <summary>
|
||||
/// Walk <paramref name="content"/> into <paramref name="namespaceBuilder"/>.
|
||||
/// The builder is scoped to the Equipment-kind namespace root; the walker emits
|
||||
/// Area → Line → Equipment folders under it, then identifier properties + the
|
||||
/// Identification sub-folder + variable nodes per bound Tag under each Equipment.
|
||||
/// </summary>
|
||||
/// <param name="namespaceBuilder">
|
||||
/// The builder scoped to the Equipment-kind namespace root. Caller is responsible for
|
||||
/// creating this (e.g. <c>rootBuilder.Folder(namespace.NamespaceId, namespace.NamespaceUri)</c>).
|
||||
/// </param>
|
||||
/// <param name="content">Pre-loaded + pre-filtered rows for a single published generation.</param>
|
||||
public static void Walk(IAddressSpaceBuilder namespaceBuilder, EquipmentNamespaceContent content)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(namespaceBuilder);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
// Group lines by area + equipment by line + tags by equipment up-front. Avoids an
|
||||
// O(N·M) re-scan at each UNS level on large fleets.
|
||||
var linesByArea = content.Lines
|
||||
.GroupBy(l => l.UnsAreaId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(l => l.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var equipmentByLine = content.Equipment
|
||||
.GroupBy(e => e.UnsLineId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(e => e.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var tagsByEquipment = content.Tags
|
||||
.Where(t => !string.IsNullOrEmpty(t.EquipmentId))
|
||||
.GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var area in content.Areas.OrderBy(a => a.Name, StringComparer.Ordinal))
|
||||
{
|
||||
var areaBuilder = namespaceBuilder.Folder(area.Name, area.Name);
|
||||
if (!linesByArea.TryGetValue(area.UnsAreaId, out var areaLines)) continue;
|
||||
|
||||
foreach (var line in areaLines)
|
||||
{
|
||||
var lineBuilder = areaBuilder.Folder(line.Name, line.Name);
|
||||
if (!equipmentByLine.TryGetValue(line.UnsLineId, out var lineEquipment)) continue;
|
||||
|
||||
foreach (var equipment in lineEquipment)
|
||||
{
|
||||
var equipmentBuilder = lineBuilder.Folder(equipment.Name, equipment.Name);
|
||||
AddIdentifierProperties(equipmentBuilder, equipment);
|
||||
IdentificationFolderBuilder.Build(equipmentBuilder, equipment);
|
||||
|
||||
if (!tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags)) continue;
|
||||
foreach (var tag in equipmentTags)
|
||||
AddTagVariable(equipmentBuilder, tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the five operator-facing identifiers from decision #121 as OPC UA properties
|
||||
/// on the Equipment node. EquipmentId + EquipmentUuid are always populated;
|
||||
/// MachineCode is required per <see cref="Equipment"/>; ZTag + SAPID are nullable in
|
||||
/// the data model so they're skipped when null to avoid empty-string noise in the
|
||||
/// browse tree.
|
||||
/// </summary>
|
||||
private static void AddIdentifierProperties(IAddressSpaceBuilder equipmentBuilder, Equipment equipment)
|
||||
{
|
||||
equipmentBuilder.AddProperty("EquipmentId", DriverDataType.String, equipment.EquipmentId);
|
||||
equipmentBuilder.AddProperty("EquipmentUuid", DriverDataType.String, equipment.EquipmentUuid.ToString());
|
||||
equipmentBuilder.AddProperty("MachineCode", DriverDataType.String, equipment.MachineCode);
|
||||
if (!string.IsNullOrEmpty(equipment.ZTag))
|
||||
equipmentBuilder.AddProperty("ZTag", DriverDataType.String, equipment.ZTag);
|
||||
if (!string.IsNullOrEmpty(equipment.SAPID))
|
||||
equipmentBuilder.AddProperty("SAPID", DriverDataType.String, equipment.SAPID);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emit a single Tag row as an <see cref="IAddressSpaceBuilder.Variable"/>. The driver
|
||||
/// full reference lives in <c>Tag.TagConfig</c> (wire-level address, driver-specific
|
||||
/// JSON blob); the variable node's data type derives from <c>Tag.DataType</c>.
|
||||
/// Unreachable-address behavior per ADR-001 Option A: the variable is created; the
|
||||
/// driver's natural Read failure surfaces an OPC UA Bad status at runtime.
|
||||
/// </summary>
|
||||
private static void AddTagVariable(IAddressSpaceBuilder equipmentBuilder, Tag tag)
|
||||
{
|
||||
var attr = new DriverAttributeInfo(
|
||||
FullName: tag.TagConfig,
|
||||
DriverDataType: ParseDriverDataType(tag.DataType),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.FreeAccess,
|
||||
IsHistorized: false);
|
||||
equipmentBuilder.Variable(tag.Name, tag.Name, attr);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse <see cref="Tag.DataType"/> (stored as the <see cref="DriverDataType"/> enum
|
||||
/// name string, decision #138) into the enum value. Unknown names fall back to
|
||||
/// <see cref="DriverDataType.String"/> so a one-off driver-specific type doesn't
|
||||
/// abort the whole walk; the underlying driver still sees the original TagConfig
|
||||
/// address + can surface its own typed value via the OPC UA variant at read time.
|
||||
/// </summary>
|
||||
private static DriverDataType ParseDriverDataType(string raw) =>
|
||||
Enum.TryParse<DriverDataType>(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-loaded + pre-filtered snapshot of one Equipment-kind namespace's worth of Config
|
||||
/// DB rows. All four collections are scoped to the same
|
||||
/// <see cref="Configuration.Entities.ConfigGeneration"/> + the same
|
||||
/// <see cref="Configuration.Entities.Namespace"/> row. The walker assumes this filter
|
||||
/// was applied by the caller + does no cross-generation or cross-namespace validation.
|
||||
/// </summary>
|
||||
public sealed record EquipmentNamespaceContent(
|
||||
IReadOnlyList<UnsArea> Areas,
|
||||
IReadOnlyList<UnsLine> Lines,
|
||||
IReadOnlyList<Equipment> Equipment,
|
||||
IReadOnlyList<Tag> Tags);
|
||||
232
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs
Normal file
232
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipAlarmProjection.cs
Normal file
@@ -0,0 +1,232 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Task #177 — projects AB Logix ALMD alarm instructions onto the OPC UA alarm surface by
|
||||
/// polling the ALMD UDT's <c>InFaulted</c> / <c>Acked</c> / <c>Severity</c> members at a
|
||||
/// configurable interval + translating state transitions into <c>OnAlarmEvent</c>
|
||||
/// callbacks on the owning <see cref="AbCipDriver"/>. Feature-flagged off by default via
|
||||
/// <see cref="AbCipDriverOptions.EnableAlarmProjection"/>; callers that leave the flag off
|
||||
/// get a no-op subscribe path so capability negotiation still works.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>ALMD-only in this pass. ALMA (analog alarm) projection is a follow-up because
|
||||
/// its threshold + limit semantics need more design — ALMD's "is the alarm active + has
|
||||
/// the operator acked" shape maps cleanly onto the driver-agnostic
|
||||
/// <see cref="IAlarmSource"/> contract without concessions.</para>
|
||||
///
|
||||
/// <para>Polling reuses <see cref="AbCipDriver.ReadAsync"/>, so ALMD reads get the #194
|
||||
/// whole-UDT optimization for free when the ALMD is declared with its standard members.
|
||||
/// One poll loop per subscription call; the loop batches every
|
||||
/// member read across the full source-node set into a single ReadAsync per tick.</para>
|
||||
///
|
||||
/// <para>ALMD <c>Acked</c> write semantics on Logix are rising-edge sensitive at the
|
||||
/// instruction level — writing <c>Acked=1</c> directly is honored by FT View + the
|
||||
/// standard HMI templates, but some PLC programs read <c>AckCmd</c> + look for the edge
|
||||
/// themselves. We pick the simpler <c>Acked</c> write for first pass; operators whose
|
||||
/// ladder watches <c>AckCmd</c> can wire a follow-up "AckCmd 0→1→0" pulse on the client
|
||||
/// side until a driver-level knob lands.</para>
|
||||
/// </remarks>
|
||||
internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
{
|
||||
private readonly AbCipDriver _driver;
|
||||
private readonly TimeSpan _pollInterval;
|
||||
private readonly Dictionary<long, Subscription> _subs = new();
|
||||
private readonly Lock _subsLock = new();
|
||||
private long _nextId;
|
||||
|
||||
public AbCipAlarmProjection(AbCipDriver driver, TimeSpan pollInterval)
|
||||
{
|
||||
_driver = driver;
|
||||
_pollInterval = pollInterval;
|
||||
}
|
||||
|
||||
public async Task<IAlarmSubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextId);
|
||||
var handle = new AbCipAlarmSubscriptionHandle(id);
|
||||
var cts = new CancellationTokenSource();
|
||||
var sub = new Subscription(handle, [..sourceNodeIds], cts);
|
||||
|
||||
lock (_subsLock) _subs[id] = sub;
|
||||
|
||||
sub.Loop = Task.Run(() => RunPollLoopAsync(sub, cts.Token), cts.Token);
|
||||
await Task.CompletedTask;
|
||||
return handle;
|
||||
}
|
||||
|
||||
public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is not AbCipAlarmSubscriptionHandle h) return;
|
||||
Subscription? sub;
|
||||
lock (_subsLock)
|
||||
{
|
||||
if (!_subs.Remove(h.Id, out sub)) return;
|
||||
}
|
||||
try { sub.Cts.Cancel(); } catch { }
|
||||
try { await sub.Loop.ConfigureAwait(false); } catch { }
|
||||
sub.Cts.Dispose();
|
||||
}
|
||||
|
||||
public async Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
||||
{
|
||||
if (acknowledgements.Count == 0) return;
|
||||
|
||||
// Write Acked=1 per request. IWritable isn't on AbCipAlarmProjection so route through
|
||||
// the driver's public interface — delegating instead of re-implementing the write path
|
||||
// keeps the bit-in-DINT + idempotency + per-call-host-resolve knobs intact.
|
||||
var requests = acknowledgements
|
||||
.Select(a => new WriteRequest($"{a.SourceNodeId}.Acked", true))
|
||||
.ToArray();
|
||||
// Best-effort — the driver's WriteAsync returns per-item status; individual ack
|
||||
// failures don't poison the batch. Swallow the return so a single faulted ack
|
||||
// doesn't bubble out of the caller's batch expectation.
|
||||
_ = await _driver.WriteAsync(requests, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
List<Subscription> snap;
|
||||
lock (_subsLock) { snap = _subs.Values.ToList(); _subs.Clear(); }
|
||||
foreach (var sub in snap)
|
||||
{
|
||||
try { sub.Cts.Cancel(); } catch { }
|
||||
try { await sub.Loop.ConfigureAwait(false); } catch { }
|
||||
sub.Cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Poll-tick body — reads <c>InFaulted</c> + <c>Severity</c> for every source node id
|
||||
/// in the subscription, diffs each against last-seen state, fires raise/clear events.
|
||||
/// Extracted so tests can drive one tick without standing up the Task.Run loop.
|
||||
/// </summary>
|
||||
internal void Tick(Subscription sub, IReadOnlyList<DataValueSnapshot> results)
|
||||
{
|
||||
// results index layout: for each sourceNode, [InFaulted, Severity] in order.
|
||||
for (var i = 0; i < sub.SourceNodeIds.Count; i++)
|
||||
{
|
||||
var nodeId = sub.SourceNodeIds[i];
|
||||
var inFaultedDv = results[i * 2];
|
||||
var severityDv = results[i * 2 + 1];
|
||||
if (inFaultedDv.StatusCode != AbCipStatusMapper.Good) continue;
|
||||
|
||||
var nowFaulted = ToBool(inFaultedDv.Value);
|
||||
var severity = ToInt(severityDv.Value);
|
||||
|
||||
var wasFaulted = sub.LastInFaulted.GetValueOrDefault(nodeId, false);
|
||||
sub.LastInFaulted[nodeId] = nowFaulted;
|
||||
|
||||
if (!wasFaulted && nowFaulted)
|
||||
{
|
||||
_driver.InvokeAlarmEvent(new AlarmEventArgs(
|
||||
sub.Handle, nodeId, ConditionId: $"{nodeId}#active",
|
||||
AlarmType: "ALMD",
|
||||
Message: $"ALMD {nodeId} raised",
|
||||
Severity: MapSeverity(severity),
|
||||
SourceTimestampUtc: DateTime.UtcNow));
|
||||
}
|
||||
else if (wasFaulted && !nowFaulted)
|
||||
{
|
||||
_driver.InvokeAlarmEvent(new AlarmEventArgs(
|
||||
sub.Handle, nodeId, ConditionId: $"{nodeId}#active",
|
||||
AlarmType: "ALMD",
|
||||
Message: $"ALMD {nodeId} cleared",
|
||||
Severity: MapSeverity(severity),
|
||||
SourceTimestampUtc: DateTime.UtcNow));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunPollLoopAsync(Subscription sub, CancellationToken ct)
|
||||
{
|
||||
var refs = new List<string>(sub.SourceNodeIds.Count * 2);
|
||||
foreach (var nodeId in sub.SourceNodeIds)
|
||||
{
|
||||
refs.Add($"{nodeId}.InFaulted");
|
||||
refs.Add($"{nodeId}.Severity");
|
||||
}
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var results = await _driver.ReadAsync(refs, ct).ConfigureAwait(false);
|
||||
Tick(sub, results);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch { /* per-tick failures are non-fatal; next tick retries */ }
|
||||
|
||||
try { await Task.Delay(_pollInterval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
internal static AlarmSeverity MapSeverity(int raw) => raw switch
|
||||
{
|
||||
<= 250 => AlarmSeverity.Low,
|
||||
<= 500 => AlarmSeverity.Medium,
|
||||
<= 750 => AlarmSeverity.High,
|
||||
_ => AlarmSeverity.Critical,
|
||||
};
|
||||
|
||||
private static bool ToBool(object? v) => v switch
|
||||
{
|
||||
bool b => b,
|
||||
int i => i != 0,
|
||||
long l => l != 0,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
private static int ToInt(object? v) => v switch
|
||||
{
|
||||
int i => i,
|
||||
long l => (int)l,
|
||||
short s => s,
|
||||
byte b => b,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
internal sealed class Subscription
|
||||
{
|
||||
public Subscription(AbCipAlarmSubscriptionHandle handle, IReadOnlyList<string> sourceNodeIds, CancellationTokenSource cts)
|
||||
{
|
||||
Handle = handle; SourceNodeIds = sourceNodeIds; Cts = cts;
|
||||
}
|
||||
public AbCipAlarmSubscriptionHandle Handle { get; }
|
||||
public IReadOnlyList<string> SourceNodeIds { get; }
|
||||
public CancellationTokenSource Cts { get; }
|
||||
public Task Loop { get; set; } = Task.CompletedTask;
|
||||
public Dictionary<string, bool> LastInFaulted { get; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Handle returned by <see cref="AbCipAlarmProjection.SubscribeAsync"/>.</summary>
|
||||
public sealed record AbCipAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => $"abcip-alarm-sub-{Id}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects the ALMD / ALMA signature in an <see cref="AbCipTagDefinition"/>'s declared
|
||||
/// members. Used by both discovery (to stamp <c>IsAlarm=true</c> on the emitted
|
||||
/// variable) + initial driver setup (to decide which tags the alarm projection owns).
|
||||
/// </summary>
|
||||
public static class AbCipAlarmDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>true</c> when <paramref name="tag"/> is a Structure whose declared members match
|
||||
/// the ALMD signature (<c>InFaulted</c> + <c>Acked</c> present). ALMA detection
|
||||
/// (analog alarms with <c>HHLimit</c>/<c>HLimit</c>/<c>LLimit</c>/<c>LLLimit</c>)
|
||||
/// ships as a follow-up.
|
||||
/// </summary>
|
||||
public static bool IsAlmd(AbCipTagDefinition tag)
|
||||
{
|
||||
if (tag.DataType != AbCipDataType.Structure || tag.Members is null) return false;
|
||||
var names = tag.Members.Select(m => m.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return names.Contains("InFaulted") && names.Contains("Acked");
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// <see cref="PlcTagHandle"/> and reconnects each device.</para>
|
||||
/// </remarks>
|
||||
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly AbCipDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
@@ -32,10 +32,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly AbCipAlarmProjection _alarmProjection;
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
|
||||
/// <summary>Internal seam for the alarm projection to raise events through the driver.</summary>
|
||||
internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
|
||||
|
||||
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
|
||||
IAbCipTagFactory? tagFactory = null,
|
||||
@@ -52,6 +57,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||||
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -162,6 +168,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _alarmProjection.DisposeAsync().ConfigureAwait(false);
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
@@ -187,6 +194,39 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- IAlarmSource (ALMD projection, #177) ----
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe to ALMD alarm transitions on <paramref name="sourceNodeIds"/>. Each id
|
||||
/// names a declared ALMD UDT tag; the projection polls the tag's <c>InFaulted</c> +
|
||||
/// <c>Severity</c> members at <see cref="AbCipDriverOptions.AlarmPollInterval"/> and
|
||||
/// fires <see cref="OnAlarmEvent"/> on 0→1 (raise) + 1→0 (clear) transitions.
|
||||
/// Feature-gated — when <see cref="AbCipDriverOptions.EnableAlarmProjection"/> is
|
||||
/// <c>false</c> (the default), returns a handle wrapping a no-op subscription so
|
||||
/// capability negotiation still works; <see cref="OnAlarmEvent"/> never fires.
|
||||
/// </summary>
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.EnableAlarmProjection)
|
||||
{
|
||||
var disabled = new AbCipAlarmSubscriptionHandle(0);
|
||||
return Task.FromResult<IAlarmSubscriptionHandle>(disabled);
|
||||
}
|
||||
return _alarmProjection.SubscribeAsync(sourceNodeIds, cancellationToken);
|
||||
}
|
||||
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
|
||||
_options.EnableAlarmProjection
|
||||
? _alarmProjection.UnsubscribeAsync(handle, cancellationToken)
|
||||
: Task.CompletedTask;
|
||||
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
||||
_options.EnableAlarmProjection
|
||||
? _alarmProjection.AcknowledgeAsync(acknowledgements, cancellationToken)
|
||||
: Task.CompletedTask;
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
||||
@@ -287,56 +327,127 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var reference = fullReferences[i];
|
||||
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
// Task #194 — plan the batch: members of the same parent UDT get collapsed into one
|
||||
// whole-UDT read + in-memory member decode; every other reference falls back to the
|
||||
// per-tag path that's been here since PR 3. Planner is a pure function over the
|
||||
// current tag map; BOOL/String/Structure members stay on the fallback path because
|
||||
// declaration-only offsets can't place them under Logix alignment rules.
|
||||
var plan = AbCipUdtReadPlanner.Build(fullReferences, _tagsByName);
|
||||
|
||||
try
|
||||
{
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
foreach (var group in plan.Groups)
|
||||
await ReadGroupAsync(group, results, now, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status != 0)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null,
|
||||
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"libplctag status {status} reading {reference}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var tagPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
var bitIndex = tagPath?.BitIndex;
|
||||
var value = runtime.DecodeValue(def.DataType, bitIndex);
|
||||
results[i] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null,
|
||||
AbCipStatusMapper.BadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
foreach (var fb in plan.Fallbacks)
|
||||
await ReadSingleAsync(fb, fullReferences[fb.OriginalIndex], results, now, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task ReadSingleAsync(
|
||||
AbCipUdtReadFallback fb, string reference, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
|
||||
{
|
||||
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||
{
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||||
return;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
|
||||
await runtime.ReadAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status != 0)
|
||||
{
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||||
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"libplctag status {status} reading {reference}");
|
||||
return;
|
||||
}
|
||||
|
||||
var tagPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
var bitIndex = tagPath?.BitIndex;
|
||||
var value = runtime.DecodeValue(def.DataType, bitIndex);
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||||
AbCipStatusMapper.BadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task #194 — perform one whole-UDT read on the parent tag, then decode each
|
||||
/// grouped member from the runtime's buffer at its computed byte offset. A per-group
|
||||
/// failure (parent read raised, non-zero libplctag status, or missing device) stamps
|
||||
/// the mapped fault across every grouped member only — sibling groups + the
|
||||
/// per-tag fallback list are unaffected.
|
||||
/// </summary>
|
||||
private async Task ReadGroupAsync(
|
||||
AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
|
||||
{
|
||||
var parent = group.ParentDefinition;
|
||||
|
||||
if (!_devices.TryGetValue(parent.DeviceHostAddress, out var device))
|
||||
{
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNodeIdUnknown);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var runtime = await EnsureTagRuntimeAsync(device, parent, ct).ConfigureAwait(false);
|
||||
await runtime.ReadAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status != 0)
|
||||
{
|
||||
var mapped = AbCipStatusMapper.MapLibplctagStatus(status);
|
||||
StampGroupStatus(group, results, now, mapped);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"libplctag status {status} reading UDT {group.ParentName}");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var member in group.Members)
|
||||
{
|
||||
var value = runtime.DecodeValueAt(member.Definition.DataType, member.Offset, bitIndex: null);
|
||||
results[member.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadCommunicationError);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void StampGroupStatus(
|
||||
AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, uint statusCode)
|
||||
{
|
||||
foreach (var member in group.Members)
|
||||
results[member.OriginalIndex] = new DataValueSnapshot(null, statusCode, null, now);
|
||||
}
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -38,6 +38,24 @@ public sealed class AbCipDriverOptions
|
||||
/// should appear in the address space.
|
||||
/// </summary>
|
||||
public bool EnableControllerBrowse { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Task #177 — when <c>true</c>, declared ALMD tags are surfaced as alarm conditions
|
||||
/// via <see cref="Core.Abstractions.IAlarmSource"/>; the driver polls each subscribed
|
||||
/// alarm's <c>InFaulted</c> + <c>Severity</c> members + fires <c>OnAlarmEvent</c> on
|
||||
/// state transitions. Default <c>false</c> — operators explicitly opt in because
|
||||
/// projection semantics don't exactly mirror Rockwell FT Alarm & Events; shops
|
||||
/// running FT Live should keep this off + take alarms through the native route.
|
||||
/// </summary>
|
||||
public bool EnableAlarmProjection { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Poll interval for the ALMD projection loop. Shorter intervals catch faster edges
|
||||
/// at the cost of PLC round-trips; edges shorter than this interval are invisible to
|
||||
/// the projection (a 0→1→0 transition within one tick collapses to no event). Default
|
||||
/// 1 second — matches typical SCADA alarm-refresh conventions.
|
||||
/// </summary>
|
||||
public TimeSpan AlarmPollInterval { get; init; } = TimeSpan.FromSeconds(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
78
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtMemberLayout.cs
Normal file
78
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtMemberLayout.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Computes byte offsets for declared UDT members under Logix natural-alignment rules so
|
||||
/// a single whole-UDT read (task #194) can decode each member from one buffer without
|
||||
/// re-reading per member. Declaration-driven — the caller supplies
|
||||
/// <see cref="AbCipStructureMember"/> rows; this helper produces the offset each member
|
||||
/// sits at in the parent tag's read buffer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Alignment rules applied per Rockwell "Logix 5000 Data Access" manual + the
|
||||
/// libplctag test fixtures: each member aligns to its natural boundary (SInt 1, Int 2,
|
||||
/// DInt/Real/Dt 4, LInt/ULInt/LReal 8), padding inserted before the member as needed.
|
||||
/// The total size is padded to the alignment of the largest member so arrays-of-UDT also
|
||||
/// work at element stride — though this helper is used only on single instances today.</para>
|
||||
///
|
||||
/// <para><see cref="TryBuild"/> returns <c>null</c> on unsupported member types
|
||||
/// (<see cref="AbCipDataType.Bool"/>, <see cref="AbCipDataType.String"/>,
|
||||
/// <see cref="AbCipDataType.Structure"/>). Whole-UDT grouping opts out of those groups
|
||||
/// and falls back to the per-tag read path — BOOL members are packed into a hidden host
|
||||
/// byte at the top of the UDT under Logix, so their offset can't be computed from
|
||||
/// declared-member order alone. The CIP Template Object reader produces a
|
||||
/// <see cref="AbCipUdtShape"/> that carries real offsets for BOOL + nested structs; when
|
||||
/// that shape is cached the driver can take the richer path instead.</para>
|
||||
/// </remarks>
|
||||
public static class AbCipUdtMemberLayout
|
||||
{
|
||||
/// <summary>
|
||||
/// Try to compute member offsets for the supplied declared members. Returns <c>null</c>
|
||||
/// if any member type is unsupported for declaration-only layout.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, int>? TryBuild(
|
||||
IReadOnlyList<AbCipStructureMember> members)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(members);
|
||||
if (members.Count == 0) return null;
|
||||
|
||||
var offsets = new Dictionary<string, int>(members.Count, StringComparer.OrdinalIgnoreCase);
|
||||
var cursor = 0;
|
||||
|
||||
foreach (var member in members)
|
||||
{
|
||||
if (!TryGetSizeAlign(member.DataType, out var size, out var align))
|
||||
return null;
|
||||
|
||||
if (cursor % align != 0)
|
||||
cursor += align - (cursor % align);
|
||||
|
||||
offsets[member.Name] = cursor;
|
||||
cursor += size;
|
||||
}
|
||||
|
||||
return offsets;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Natural size + alignment for a Logix atomic type. <c>false</c> for types excluded
|
||||
/// from declaration-only grouping (Bool / String / Structure).
|
||||
/// </summary>
|
||||
private static bool TryGetSizeAlign(AbCipDataType type, out int size, out int align)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case AbCipDataType.SInt: case AbCipDataType.USInt:
|
||||
size = 1; align = 1; return true;
|
||||
case AbCipDataType.Int: case AbCipDataType.UInt:
|
||||
size = 2; align = 2; return true;
|
||||
case AbCipDataType.DInt: case AbCipDataType.UDInt:
|
||||
case AbCipDataType.Real: case AbCipDataType.Dt:
|
||||
size = 4; align = 4; return true;
|
||||
case AbCipDataType.LInt: case AbCipDataType.ULInt:
|
||||
case AbCipDataType.LReal:
|
||||
size = 8; align = 8; return true;
|
||||
default:
|
||||
size = 0; align = 0; return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
109
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtReadPlanner.cs
Normal file
109
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtReadPlanner.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Task #194 — groups a ReadAsync batch of full-references into whole-UDT reads where
|
||||
/// possible. A group is emitted for every parent UDT tag whose declared
|
||||
/// <see cref="AbCipStructureMember"/>s produced a valid offset map AND at least two of
|
||||
/// its members appear in the batch; every other reference stays in the per-tag fallback
|
||||
/// list that <see cref="AbCipDriver.ReadAsync"/> runs through its existing read path.
|
||||
/// Pure function — the planner never touches the runtime + never reads the PLC.
|
||||
/// </summary>
|
||||
public static class AbCipUdtReadPlanner
|
||||
{
|
||||
/// <summary>
|
||||
/// Split <paramref name="requests"/> into whole-UDT groups + per-tag leftovers.
|
||||
/// <paramref name="tagsByName"/> is the driver's <c>_tagsByName</c> map — both parent
|
||||
/// UDT rows and their fanned-out member rows live there. Lookup is OrdinalIgnoreCase
|
||||
/// to match the driver's dictionary semantics.
|
||||
/// </summary>
|
||||
public static AbCipUdtReadPlan Build(
|
||||
IReadOnlyList<string> requests,
|
||||
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(requests);
|
||||
ArgumentNullException.ThrowIfNull(tagsByName);
|
||||
|
||||
var fallback = new List<AbCipUdtReadFallback>(requests.Count);
|
||||
var byParent = new Dictionary<string, List<AbCipUdtReadMember>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (var i = 0; i < requests.Count; i++)
|
||||
{
|
||||
var name = requests[i];
|
||||
if (!tagsByName.TryGetValue(name, out var def))
|
||||
{
|
||||
fallback.Add(new AbCipUdtReadFallback(i, name));
|
||||
continue;
|
||||
}
|
||||
|
||||
var (parentName, memberName) = SplitParentMember(name);
|
||||
if (parentName is null || memberName is null
|
||||
|| !tagsByName.TryGetValue(parentName, out var parent)
|
||||
|| parent.DataType != AbCipDataType.Structure
|
||||
|| parent.Members is not { Count: > 0 })
|
||||
{
|
||||
fallback.Add(new AbCipUdtReadFallback(i, name));
|
||||
continue;
|
||||
}
|
||||
|
||||
var offsets = AbCipUdtMemberLayout.TryBuild(parent.Members);
|
||||
if (offsets is null || !offsets.TryGetValue(memberName, out var offset))
|
||||
{
|
||||
fallback.Add(new AbCipUdtReadFallback(i, name));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!byParent.TryGetValue(parentName, out var members))
|
||||
{
|
||||
members = new List<AbCipUdtReadMember>();
|
||||
byParent[parentName] = members;
|
||||
}
|
||||
members.Add(new AbCipUdtReadMember(i, def, offset));
|
||||
}
|
||||
|
||||
// A single-member group saves nothing (one whole-UDT read replaces one per-member read)
|
||||
// — demote to fallback to avoid paying the cost of reading the full UDT buffer only to
|
||||
// pull one field out.
|
||||
var groups = new List<AbCipUdtReadGroup>(byParent.Count);
|
||||
foreach (var (parentName, members) in byParent)
|
||||
{
|
||||
if (members.Count < 2)
|
||||
{
|
||||
foreach (var m in members)
|
||||
fallback.Add(new AbCipUdtReadFallback(m.OriginalIndex, m.Definition.Name));
|
||||
continue;
|
||||
}
|
||||
groups.Add(new AbCipUdtReadGroup(parentName, tagsByName[parentName], members));
|
||||
}
|
||||
|
||||
return new AbCipUdtReadPlan(groups, fallback);
|
||||
}
|
||||
|
||||
private static (string? Parent, string? Member) SplitParentMember(string reference)
|
||||
{
|
||||
var dot = reference.IndexOf('.');
|
||||
if (dot <= 0 || dot == reference.Length - 1) return (null, null);
|
||||
return (reference[..dot], reference[(dot + 1)..]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A planner output: grouped UDT reads + per-tag fallbacks.</summary>
|
||||
public sealed record AbCipUdtReadPlan(
|
||||
IReadOnlyList<AbCipUdtReadGroup> Groups,
|
||||
IReadOnlyList<AbCipUdtReadFallback> Fallbacks);
|
||||
|
||||
/// <summary>One UDT parent whose members were batched into a single read.</summary>
|
||||
public sealed record AbCipUdtReadGroup(
|
||||
string ParentName,
|
||||
AbCipTagDefinition ParentDefinition,
|
||||
IReadOnlyList<AbCipUdtReadMember> Members);
|
||||
|
||||
/// <summary>
|
||||
/// One member inside an <see cref="AbCipUdtReadGroup"/>. <c>OriginalIndex</c> is the
|
||||
/// slot in the caller's request list so the decoded value lands at the correct output
|
||||
/// offset. <c>Definition</c> is the fanned-out member-level tag definition. <c>Offset</c>
|
||||
/// is the byte offset within the parent UDT buffer where this member lives.
|
||||
/// </summary>
|
||||
public sealed record AbCipUdtReadMember(int OriginalIndex, AbCipTagDefinition Definition, int Offset);
|
||||
|
||||
/// <summary>A reference that falls back to the per-tag read path.</summary>
|
||||
public sealed record AbCipUdtReadFallback(int OriginalIndex, string Reference);
|
||||
@@ -31,6 +31,17 @@ public interface IAbCipTagRuntime : IDisposable
|
||||
/// </summary>
|
||||
object? DecodeValue(AbCipDataType type, int? bitIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Decode a value at an arbitrary byte offset in the local buffer. Task #194 —
|
||||
/// whole-UDT reads perform one <see cref="ReadAsync"/> on the parent UDT tag then
|
||||
/// call this per declared member with its computed offset, avoiding one libplctag
|
||||
/// round-trip per member. Implementations that do not support offset-aware decoding
|
||||
/// may fall back to <see cref="DecodeValue"/> when <paramref name="offset"/> is zero;
|
||||
/// offsets greater than zero against an unsupporting runtime should return <c>null</c>
|
||||
/// so the planner can skip grouping.
|
||||
/// </summary>
|
||||
object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Encode <paramref name="value"/> into the local buffer per the tag's type. Callers
|
||||
/// pair this with <see cref="WriteAsync"/>.
|
||||
|
||||
@@ -32,24 +32,26 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
|
||||
public int GetStatus() => (int)_tag.GetStatus();
|
||||
|
||||
public object? DecodeValue(AbCipDataType type, int? bitIndex) => type switch
|
||||
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
|
||||
|
||||
public object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex) => type switch
|
||||
{
|
||||
AbCipDataType.Bool => bitIndex is int bit
|
||||
? _tag.GetBit(bit)
|
||||
: _tag.GetInt8(0) != 0,
|
||||
AbCipDataType.SInt => (int)(sbyte)_tag.GetInt8(0),
|
||||
AbCipDataType.USInt => (int)_tag.GetUInt8(0),
|
||||
AbCipDataType.Int => (int)_tag.GetInt16(0),
|
||||
AbCipDataType.UInt => (int)_tag.GetUInt16(0),
|
||||
AbCipDataType.DInt => _tag.GetInt32(0),
|
||||
AbCipDataType.UDInt => (int)_tag.GetUInt32(0),
|
||||
AbCipDataType.LInt => _tag.GetInt64(0),
|
||||
AbCipDataType.ULInt => (long)_tag.GetUInt64(0),
|
||||
AbCipDataType.Real => _tag.GetFloat32(0),
|
||||
AbCipDataType.LReal => _tag.GetFloat64(0),
|
||||
AbCipDataType.String => _tag.GetString(0),
|
||||
AbCipDataType.Dt => _tag.GetInt32(0), // seconds-since-epoch DINT; consumer widens as needed
|
||||
AbCipDataType.Structure => null, // UDT whole-tag decode lands in PR 6
|
||||
: _tag.GetInt8(offset) != 0,
|
||||
AbCipDataType.SInt => (int)(sbyte)_tag.GetInt8(offset),
|
||||
AbCipDataType.USInt => (int)_tag.GetUInt8(offset),
|
||||
AbCipDataType.Int => (int)_tag.GetInt16(offset),
|
||||
AbCipDataType.UInt => (int)_tag.GetUInt16(offset),
|
||||
AbCipDataType.DInt => _tag.GetInt32(offset),
|
||||
AbCipDataType.UDInt => (int)_tag.GetUInt32(offset),
|
||||
AbCipDataType.LInt => _tag.GetInt64(offset),
|
||||
AbCipDataType.ULInt => (long)_tag.GetUInt64(offset),
|
||||
AbCipDataType.Real => _tag.GetFloat32(offset),
|
||||
AbCipDataType.LReal => _tag.GetFloat64(offset),
|
||||
AbCipDataType.String => _tag.GetString(offset),
|
||||
AbCipDataType.Dt => _tag.GetInt32(offset),
|
||||
AbCipDataType.Structure => null,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
139
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs
Normal file
139
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCapabilityMatrix.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Documented-API capability matrix — per CNC series, what ranges each
|
||||
/// <see cref="FocasAreaKind"/> supports. Authoritative source for the driver's
|
||||
/// pre-flight validation in <see cref="FocasDriver.InitializeAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Ranges come from the Fanuc FOCAS Developer Kit documentation matrix
|
||||
/// (see <c>docs/v2/focas-version-matrix.md</c> for the authoritative copy with
|
||||
/// per-function citations). Numbers chosen to match what the FOCAS library
|
||||
/// accepts — a read against an address outside the documented range returns
|
||||
/// <c>EW_NUMBER</c> or <c>EW_PARAM</c> at the wire, which this driver maps to
|
||||
/// BadOutOfRange. Catching at init time surfaces the mismatch as a config
|
||||
/// error before any session is opened.</para>
|
||||
/// <para><see cref="FocasCncSeries.Unknown"/> is treated permissively: every
|
||||
/// address passes validation. Pre-matrix configs don't break on upgrade; new
|
||||
/// deployments are encouraged to declare a series in the device options.</para>
|
||||
/// </remarks>
|
||||
public static class FocasCapabilityMatrix
|
||||
{
|
||||
/// <summary>
|
||||
/// Check whether <paramref name="address"/> is accepted by a CNC of
|
||||
/// <paramref name="series"/>. Returns <c>null</c> on pass + a failure reason
|
||||
/// on reject — the driver surfaces the reason string verbatim when failing
|
||||
/// <c>InitializeAsync</c> so operators see the specific out-of-range without
|
||||
/// guessing.
|
||||
/// </summary>
|
||||
public static string? Validate(FocasCncSeries series, FocasAddress address)
|
||||
{
|
||||
if (series == FocasCncSeries.Unknown) return null;
|
||||
|
||||
return address.Kind switch
|
||||
{
|
||||
FocasAreaKind.Macro => ValidateMacro(series, address.Number),
|
||||
FocasAreaKind.Parameter => ValidateParameter(series, address.Number),
|
||||
FocasAreaKind.Pmc => ValidatePmc(series, address.PmcLetter, address.Number),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Macro variable number accepted by a CNC series. Cites
|
||||
/// <c>cnc_rdmacro</c>/<c>cnc_wrmacro</c> in the Developer Kit.</summary>
|
||||
internal static (int min, int max) MacroRange(FocasCncSeries series) => series switch
|
||||
{
|
||||
// Common macros 1-33 + 100-199 + 500-999 universally; extended 10000+ only on
|
||||
// higher-end series. Using the extended ceiling per series per DevKit notes.
|
||||
FocasCncSeries.Sixteen_i => (0, 999),
|
||||
FocasCncSeries.Zero_i_D => (0, 999),
|
||||
FocasCncSeries.Zero_i_F or
|
||||
FocasCncSeries.Zero_i_MF or
|
||||
FocasCncSeries.Zero_i_TF => (0, 9999),
|
||||
FocasCncSeries.Thirty_i or
|
||||
FocasCncSeries.ThirtyOne_i or
|
||||
FocasCncSeries.ThirtyTwo_i => (0, 99999),
|
||||
FocasCncSeries.PowerMotion_i => (0, 999),
|
||||
_ => (0, int.MaxValue),
|
||||
};
|
||||
|
||||
/// <summary>Parameter number accepted; from <c>cnc_rdparam</c>/<c>cnc_wrparam</c>.
|
||||
/// Ranges reflect the highest-numbered parameter documented per series.</summary>
|
||||
internal static (int min, int max) ParameterRange(FocasCncSeries series) => series switch
|
||||
{
|
||||
FocasCncSeries.Sixteen_i => (0, 9999),
|
||||
FocasCncSeries.Zero_i_D or
|
||||
FocasCncSeries.Zero_i_F or
|
||||
FocasCncSeries.Zero_i_MF or
|
||||
FocasCncSeries.Zero_i_TF => (0, 14999),
|
||||
FocasCncSeries.Thirty_i or
|
||||
FocasCncSeries.ThirtyOne_i or
|
||||
FocasCncSeries.ThirtyTwo_i => (0, 29999),
|
||||
FocasCncSeries.PowerMotion_i => (0, 29999),
|
||||
_ => (0, int.MaxValue),
|
||||
};
|
||||
|
||||
/// <summary>PMC letters accepted per series. Legacy controllers omit F/M/C
|
||||
/// signal groups that 30i-family ladder programs use.</summary>
|
||||
internal static IReadOnlySet<string> PmcLetters(FocasCncSeries series) => series switch
|
||||
{
|
||||
FocasCncSeries.Sixteen_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D" },
|
||||
FocasCncSeries.Zero_i_D => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D", "E", "A" },
|
||||
FocasCncSeries.Zero_i_F or
|
||||
FocasCncSeries.Zero_i_MF or
|
||||
FocasCncSeries.Zero_i_TF => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "F", "G", "R", "D", "E", "A", "M", "C" },
|
||||
FocasCncSeries.Thirty_i or
|
||||
FocasCncSeries.ThirtyOne_i or
|
||||
FocasCncSeries.ThirtyTwo_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "F", "G", "R", "D", "E", "A", "M", "C", "K", "T" },
|
||||
FocasCncSeries.PowerMotion_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D" },
|
||||
_ => new HashSet<string>(StringComparer.OrdinalIgnoreCase),
|
||||
};
|
||||
|
||||
/// <summary>PMC address-number ceiling per series. Multiplied by 8 to get bit
|
||||
/// count since PMC addresses are byte-addressed on read + bit-addressed on
|
||||
/// write — FocasAddress carries the bit separately.</summary>
|
||||
internal static int PmcMaxNumber(FocasCncSeries series) => series switch
|
||||
{
|
||||
FocasCncSeries.Sixteen_i => 999,
|
||||
FocasCncSeries.Zero_i_D => 1999,
|
||||
FocasCncSeries.Zero_i_F or
|
||||
FocasCncSeries.Zero_i_MF or
|
||||
FocasCncSeries.Zero_i_TF => 9999,
|
||||
FocasCncSeries.Thirty_i or
|
||||
FocasCncSeries.ThirtyOne_i or
|
||||
FocasCncSeries.ThirtyTwo_i => 59999,
|
||||
FocasCncSeries.PowerMotion_i => 1999,
|
||||
_ => int.MaxValue,
|
||||
};
|
||||
|
||||
private static string? ValidateMacro(FocasCncSeries series, int number)
|
||||
{
|
||||
var (min, max) = MacroRange(series);
|
||||
return (number < min || number > max)
|
||||
? $"Macro variable #{number} is outside the documented range [{min}, {max}] for {series}."
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? ValidateParameter(FocasCncSeries series, int number)
|
||||
{
|
||||
var (min, max) = ParameterRange(series);
|
||||
return (number < min || number > max)
|
||||
? $"Parameter #{number} is outside the documented range [{min}, {max}] for {series}."
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? ValidatePmc(FocasCncSeries series, string? letter, int number)
|
||||
{
|
||||
if (string.IsNullOrEmpty(letter)) return "PMC address is missing its letter prefix.";
|
||||
var letters = PmcLetters(series);
|
||||
if (!letters.Contains(letter))
|
||||
{
|
||||
var letterList = string.Join(", ", letters);
|
||||
return $"PMC letter '{letter}' is not supported on {series}. Accepted: {{{letterList}}}.";
|
||||
}
|
||||
var max = PmcMaxNumber(series);
|
||||
return number > max
|
||||
? $"PMC address {letter}{number} is outside the documented range [0, {max}] for {series}."
|
||||
: null;
|
||||
}
|
||||
}
|
||||
47
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCncSeries.cs
Normal file
47
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasCncSeries.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Fanuc CNC controller series. Used by <see cref="FocasCapabilityMatrix"/> to
|
||||
/// gate which FOCAS addresses + value ranges the driver accepts against a given
|
||||
/// CNC — the FOCAS API surface varies meaningfully between series (macro ranges,
|
||||
/// PMC address letters, parameter numbers). A tag reference that's valid on a
|
||||
/// 30i might be out-of-range on an 0i-MF; validating at driver
|
||||
/// <c>InitializeAsync</c> time surfaces the mismatch as a fast config error
|
||||
/// instead of a runtime read failure after the server's already running.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Values chosen from the Fanuc FOCAS Developer Kit documented series
|
||||
/// matrix. Add a new entry + a row to <see cref="FocasCapabilityMatrix"/> when
|
||||
/// a new controller is targeted — the driver will refuse the device until both
|
||||
/// sides of the enum are filled in.</para>
|
||||
/// <para>Defaults to <see cref="Unknown"/> when the operator doesn't specify;
|
||||
/// the capability matrix treats Unknown as permissive (no range validation,
|
||||
/// same as pre-matrix behaviour) so old configs don't break on upgrade.</para>
|
||||
/// </remarks>
|
||||
public enum FocasCncSeries
|
||||
{
|
||||
/// <summary>No series declared; capability matrix is permissive (legacy behaviour).</summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>Series 0i-D — compact CNC, narrow macro + PMC ranges.</summary>
|
||||
Zero_i_D,
|
||||
/// <summary>Series 0i-F — successor to 0i-D; widened macro range, added Plus variant.</summary>
|
||||
Zero_i_F,
|
||||
/// <summary>Series 0i-MF / 0i-MF Plus — machining-centre variants of 0i-F.</summary>
|
||||
Zero_i_MF,
|
||||
/// <summary>Series 0i-TF / 0i-TF Plus — turning-centre variants of 0i-F.</summary>
|
||||
Zero_i_TF,
|
||||
|
||||
/// <summary>Series 16i / 18i / 21i — mid-range legacy; narrow ranges, limited PMC letters.</summary>
|
||||
Sixteen_i,
|
||||
|
||||
/// <summary>Series 30i — high-end; widest macro / PMC / parameter ranges.</summary>
|
||||
Thirty_i,
|
||||
/// <summary>Series 31i — subset of 30i (fewer axes, same FOCAS surface).</summary>
|
||||
ThirtyOne_i,
|
||||
/// <summary>Series 32i — compact 30i variant.</summary>
|
||||
ThirtyTwo_i,
|
||||
|
||||
/// <summary>Power Motion i — motion-control variant; atypical macro coverage.</summary>
|
||||
PowerMotion_i,
|
||||
}
|
||||
@@ -57,7 +57,24 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
$"FOCAS device has invalid HostAddress '{device.HostAddress}' — expected 'focas://{{ip}}[:{{port}}]'.");
|
||||
_devices[device.HostAddress] = new DeviceState(addr, device);
|
||||
}
|
||||
foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag;
|
||||
// Pre-flight: validate every tag's address against the declared CNC
|
||||
// series so misconfigured addresses fail at init (clear config error)
|
||||
// instead of producing BadOutOfRange on every read at runtime.
|
||||
// Series=Unknown short-circuits the matrix; pre-matrix configs stay permissive.
|
||||
foreach (var tag in _options.Tags)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(tag.Address)
|
||||
?? throw new InvalidOperationException(
|
||||
$"FOCAS tag '{tag.Name}' has invalid Address '{tag.Address}'. " +
|
||||
$"Expected forms: R100, R100.3, PARAM:1815/0, MACRO:500.");
|
||||
if (_devices.TryGetValue(tag.DeviceHostAddress, out var device)
|
||||
&& FocasCapabilityMatrix.Validate(device.Options.Series, parsed) is { } reason)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"FOCAS tag '{tag.Name}' ({tag.Address}) rejected by capability matrix: {reason}");
|
||||
}
|
||||
_tagsByName[tag.Name] = tag;
|
||||
}
|
||||
|
||||
if (_options.Probe.Enabled)
|
||||
{
|
||||
|
||||
@@ -13,9 +13,15 @@ public sealed class FocasDriverOptions
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One CNC the driver talks to. <paramref name="Series"/> enables per-series
|
||||
/// address validation at <see cref="FocasDriver.InitializeAsync"/>; leave as
|
||||
/// <see cref="FocasCncSeries.Unknown"/> to skip validation (legacy behaviour).
|
||||
/// </summary>
|
||||
public sealed record FocasDeviceOptions(
|
||||
string HostAddress,
|
||||
string? DeviceName = null);
|
||||
string? DeviceName = null,
|
||||
FocasCncSeries Series = FocasCncSeries.Unknown);
|
||||
|
||||
/// <summary>
|
||||
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -84,7 +84,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
var plc = new Plc(_options.CpuType, _options.Host, _options.Rack, _options.Slot);
|
||||
var plc = new Plc(_options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot);
|
||||
// S7netplus writes timeouts into the underlying TcpClient via Plc.WriteTimeout /
|
||||
// Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself
|
||||
// honours the bound.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Holds pre-loaded <see cref="EquipmentNamespaceContent"/> snapshots keyed by
|
||||
/// <c>DriverInstanceId</c>. Populated once during <see cref="OpcUaServerService"/> startup
|
||||
/// (after <see cref="NodeBootstrap"/> resolves the generation) so the synchronous lookup
|
||||
/// delegate on <see cref="OpcUaApplicationHost"/> can serve the walker from memory without
|
||||
/// blocking on async DB I/O mid-dispatch.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The registry is intentionally a shared mutable singleton with set-once-per-bootstrap
|
||||
/// semantics rather than an immutable map passed by value — the composition in Program.cs
|
||||
/// builds <see cref="OpcUaApplicationHost"/> before <see cref="NodeBootstrap"/> runs, so the
|
||||
/// registry must exist at DI-compose time but be empty until the generation is known. A
|
||||
/// driver registered after the initial populate pass simply returns null from
|
||||
/// <see cref="Get"/> + the wire-in falls back to the "no UNS content, let DiscoverAsync own
|
||||
/// it" path that PR #155 established.</para>
|
||||
/// </remarks>
|
||||
public sealed class DriverEquipmentContentRegistry
|
||||
{
|
||||
private readonly Dictionary<string, EquipmentNamespaceContent> _content =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
public EquipmentNamespaceContent? Get(string driverInstanceId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _content.TryGetValue(driverInstanceId, out var c) ? c : null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Set(string driverInstanceId, EquipmentNamespaceContent content)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_content[driverInstanceId] = content;
|
||||
}
|
||||
}
|
||||
|
||||
public int Count
|
||||
{
|
||||
get { lock (_lock) { return _content.Count; } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the <see cref="EquipmentNamespaceContent"/> snapshot the
|
||||
/// <see cref="EquipmentNodeWalker"/> consumes, scoped to a single
|
||||
/// (driverInstanceId, generationId) pair. Joins the four row sets the walker expects:
|
||||
/// UnsAreas for the driver's cluster, UnsLines under those areas, Equipment bound to
|
||||
/// this driver + its lines, and Tags bound to this driver + its equipment — all at the
|
||||
/// supplied generation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The walker is driver-instance-scoped (decisions #116–#121 put the UNS in the
|
||||
/// Equipment-kind namespace owned by one driver instance at a time), so this loader is
|
||||
/// too — a single call returns one driver's worth of rows, never the whole fleet.</para>
|
||||
///
|
||||
/// <para>Returns <c>null</c> when the driver instance has no Equipment rows at the
|
||||
/// supplied generation. The wire-in in <see cref="OpcUaApplicationHost"/> treats null as
|
||||
/// "this driver has no UNS content, skip the walker and let DiscoverAsync own the whole
|
||||
/// address space" — the backward-compat path for drivers whose namespace kind is not
|
||||
/// Equipment (Modbus / AB CIP / TwinCAT / FOCAS).</para>
|
||||
/// </remarks>
|
||||
public sealed class EquipmentNamespaceContentLoader
|
||||
{
|
||||
private readonly OtOpcUaConfigDbContext _db;
|
||||
|
||||
public EquipmentNamespaceContentLoader(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load the walker-shaped snapshot for <paramref name="driverInstanceId"/> at
|
||||
/// <paramref name="generationId"/>. Returns <c>null</c> when the driver has no
|
||||
/// Equipment rows at that generation.
|
||||
/// </summary>
|
||||
public async Task<EquipmentNamespaceContent?> LoadAsync(
|
||||
string driverInstanceId, long generationId, CancellationToken ct)
|
||||
{
|
||||
var equipment = await _db.Equipment
|
||||
.AsNoTracking()
|
||||
.Where(e => e.DriverInstanceId == driverInstanceId && e.GenerationId == generationId && e.Enabled)
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
if (equipment.Count == 0)
|
||||
return null;
|
||||
|
||||
// Filter UNS tree to only the lines + areas that host at least one Equipment bound to
|
||||
// this driver — skips loading unrelated UNS branches from the cluster. LinesByArea
|
||||
// grouping is driven off the Equipment rows so an empty line (no equipment) doesn't
|
||||
// pull a pointless folder into the walker output.
|
||||
var lineIds = equipment.Select(e => e.UnsLineId).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
|
||||
var lines = await _db.UnsLines
|
||||
.AsNoTracking()
|
||||
.Where(l => l.GenerationId == generationId && lineIds.Contains(l.UnsLineId))
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var areaIds = lines.Select(l => l.UnsAreaId).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
|
||||
var areas = await _db.UnsAreas
|
||||
.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId && areaIds.Contains(a.UnsAreaId))
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// Tags belonging to this driver at this generation. Walker skips Tags with null
|
||||
// EquipmentId (those are SystemPlatform-kind Galaxy tags per decision #120) but we
|
||||
// load them anyway so the same rowset can drive future non-Equipment-kind walks
|
||||
// without re-hitting the DB. Filtering here is a future optimization; today the
|
||||
// per-tag cost is bounded by driver scope.
|
||||
var tags = await _db.Tags
|
||||
.AsNoTracking()
|
||||
.Where(t => t.DriverInstanceId == driverInstanceId && t.GenerationId == generationId)
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return new EquipmentNamespaceContent(
|
||||
Areas: areas,
|
||||
Lines: lines,
|
||||
Equipment: equipment,
|
||||
Tags: tags);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
private readonly StaleConfigFlag? _staleConfigFlag;
|
||||
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? _tierLookup;
|
||||
private readonly Func<string, string?>? _resilienceConfigLookup;
|
||||
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? _equipmentContentLookup;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ILogger<OpcUaApplicationHost> _logger;
|
||||
private ApplicationInstance? _application;
|
||||
@@ -43,7 +44,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
NodeScopeResolver? scopeResolver = null,
|
||||
StaleConfigFlag? staleConfigFlag = null,
|
||||
Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? tierLookup = null,
|
||||
Func<string, string?>? resilienceConfigLookup = null)
|
||||
Func<string, string?>? resilienceConfigLookup = null,
|
||||
Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? equipmentContentLookup = null)
|
||||
{
|
||||
_options = options;
|
||||
_driverHost = driverHost;
|
||||
@@ -54,6 +56,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
_staleConfigFlag = staleConfigFlag;
|
||||
_tierLookup = tierLookup;
|
||||
_resilienceConfigLookup = resilienceConfigLookup;
|
||||
_equipmentContentLookup = equipmentContentLookup;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -103,11 +106,31 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
// Drive each driver's discovery through its node manager. The node manager IS the
|
||||
// IAddressSpaceBuilder; GenericDriverNodeManager captures alarm-condition sinks into
|
||||
// its internal map and wires OnAlarmEvent → sink routing.
|
||||
//
|
||||
// ADR-001 Option A — when an EquipmentNamespaceContent is supplied for an
|
||||
// Equipment-kind driver, run the EquipmentNodeWalker BEFORE the driver's DiscoverAsync
|
||||
// so the UNS folder skeleton (Area/Line/Equipment) + Identification sub-folders +
|
||||
// the five identifier properties (decision #121) are in place. DiscoverAsync then
|
||||
// streams the driver's native shape on top; Tag rows bound to Equipment already
|
||||
// materialized via the walker don't get duplicated because the driver's DiscoverAsync
|
||||
// output is authoritative for its own native references only.
|
||||
foreach (var nodeManager in _server.DriverNodeManagers)
|
||||
{
|
||||
var driverId = nodeManager.Driver.DriverInstanceId;
|
||||
try
|
||||
{
|
||||
if (_equipmentContentLookup is not null)
|
||||
{
|
||||
var content = _equipmentContentLookup(driverId);
|
||||
if (content is not null)
|
||||
{
|
||||
ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNodeWalker.Walk(nodeManager, content);
|
||||
_logger.LogInformation(
|
||||
"UNS walker populated {Areas} area(s), {Lines} line(s), {Equipment} equipment, {Tags} tag(s) for driver {Driver}",
|
||||
content.Areas.Count, content.Lines.Count, content.Equipment.Count, content.Tags.Count, driverId);
|
||||
}
|
||||
}
|
||||
|
||||
var generic = new GenericDriverNodeManager(nodeManager.Driver);
|
||||
await generic.BuildAddressSpaceAsync(nodeManager, ct).ConfigureAwait(false);
|
||||
_logger.LogInformation("Address space populated for driver {Driver}", driverId);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
@@ -15,6 +16,8 @@ public sealed class OpcUaServerService(
|
||||
NodeBootstrap bootstrap,
|
||||
DriverHost driverHost,
|
||||
OpcUaApplicationHost applicationHost,
|
||||
DriverEquipmentContentRegistry equipmentContentRegistry,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<OpcUaServerService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
@@ -24,6 +27,15 @@ public sealed class OpcUaServerService(
|
||||
var result = await bootstrap.LoadCurrentGenerationAsync(stoppingToken);
|
||||
logger.LogInformation("Bootstrap complete: source={Source} generation={Gen}", result.Source, result.GenerationId);
|
||||
|
||||
// ADR-001 Option A — populate per-driver Equipment namespace snapshots into the
|
||||
// registry before StartAsync walks the address space. The walker on the OPC UA side
|
||||
// reads synchronously from the registry; pre-loading here means the hot path stays
|
||||
// non-blocking + each driver pays at most one Config-DB query at bootstrap time.
|
||||
// Skipped when no generation is Published yet — the fleet boots into a UNS-less
|
||||
// address space until the first publish, then the registry fills on next restart.
|
||||
if (result.GenerationId is { } gen)
|
||||
await PopulateEquipmentContentAsync(gen, stoppingToken);
|
||||
|
||||
// PR 17: stand up the OPC UA server + drive discovery per registered driver. Driver
|
||||
// registration itself (RegisterAsync on DriverHost) happens during an earlier DI
|
||||
// extension once the central config DB query + per-driver factory land; for now the
|
||||
@@ -48,4 +60,30 @@ public sealed class OpcUaServerService(
|
||||
await applicationHost.DisposeAsync();
|
||||
await driverHost.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-load an <c>EquipmentNamespaceContent</c> snapshot for each registered driver at
|
||||
/// the bootstrapped generation. Null results (driver has no Equipment rows —
|
||||
/// Modbus/AB CIP/TwinCAT/FOCAS today per decisions #116–#121) are skipped: the walker
|
||||
/// wire-in sees Get(driverId) return null + falls back to DiscoverAsync-owns-it.
|
||||
/// Opens one scope so the scoped <c>OtOpcUaConfigDbContext</c> is shared across all
|
||||
/// per-driver queries rather than paying scope-setup overhead per driver.
|
||||
/// </summary>
|
||||
private async Task PopulateEquipmentContentAsync(long generationId, CancellationToken ct)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var loader = scope.ServiceProvider.GetRequiredService<EquipmentNamespaceContentLoader>();
|
||||
|
||||
var loaded = 0;
|
||||
foreach (var driverId in driverHost.RegisteredDriverIds)
|
||||
{
|
||||
var content = await loader.LoadAsync(driverId, generationId, ct).ConfigureAwait(false);
|
||||
if (content is null) continue;
|
||||
equipmentContentRegistry.Set(driverId, content);
|
||||
loaded++;
|
||||
}
|
||||
logger.LogInformation(
|
||||
"Equipment namespace snapshots loaded for {Count}/{Total} driver(s) at generation {Gen}",
|
||||
loaded, driverHost.RegisteredDriverIds.Count, generationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,25 @@ builder.Services.AddSingleton<IUserAuthenticator>(sp => ldapOptions.Enabled
|
||||
builder.Services.AddSingleton<ILocalConfigCache>(_ => new LiteDbConfigCache(options.LocalCachePath));
|
||||
builder.Services.AddSingleton<DriverHost>();
|
||||
builder.Services.AddSingleton<NodeBootstrap>();
|
||||
builder.Services.AddSingleton<OpcUaApplicationHost>();
|
||||
|
||||
// ADR-001 Option A wiring — the registry is the handoff between OpcUaServerService's
|
||||
// bootstrap-time population pass + OpcUaApplicationHost's StartAsync walker invocation.
|
||||
// DriverEquipmentContentRegistry.Get is the equipmentContentLookup delegate that PR #155
|
||||
// added to OpcUaApplicationHost's ctor seam.
|
||||
builder.Services.AddSingleton<DriverEquipmentContentRegistry>();
|
||||
builder.Services.AddScoped<EquipmentNamespaceContentLoader>();
|
||||
|
||||
builder.Services.AddSingleton<OpcUaApplicationHost>(sp =>
|
||||
{
|
||||
var registry = sp.GetRequiredService<DriverEquipmentContentRegistry>();
|
||||
return new OpcUaApplicationHost(
|
||||
sp.GetRequiredService<OpcUaServerOptions>(),
|
||||
sp.GetRequiredService<DriverHost>(),
|
||||
sp.GetRequiredService<IUserAuthenticator>(),
|
||||
sp.GetRequiredService<ILoggerFactory>(),
|
||||
sp.GetRequiredService<ILogger<OpcUaApplicationHost>>(),
|
||||
equipmentContentLookup: registry.Get);
|
||||
});
|
||||
builder.Services.AddHostedService<OpcUaServerService>();
|
||||
|
||||
// Central-config DB access for the host-status publisher (LMX follow-up #7). Scoped context
|
||||
|
||||
@@ -1,42 +1,83 @@
|
||||
using System.Collections.Frozen;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a driver-side full reference (e.g. <c>"TestMachine_001/Oven/SetPoint"</c>) to the
|
||||
/// <see cref="NodeScope"/> the Phase 6.2 evaluator walks. Today a simplified resolver that
|
||||
/// returns a cluster-scoped + tag-only scope — the deeper UnsArea / UnsLine / Equipment
|
||||
/// path lookup from the live Configuration DB is a Stream C.12 follow-up.
|
||||
/// <see cref="NodeScope"/> the Phase 6.2 evaluator walks. Supports two modes:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <b>Cluster-only (pre-ADR-001)</b> — when no path index is supplied the resolver
|
||||
/// returns a flat <c>ClusterId + TagId</c> scope. Sufficient while the
|
||||
/// Config-DB-driven Equipment walker isn't live; Cluster-level grants cascade to every
|
||||
/// tag below per decision #129, so finer per-Equipment grants are effectively
|
||||
/// cluster-wide at dispatch.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>Full-path (post-ADR-001 Task B)</b> — when an index is supplied, the resolver
|
||||
/// joins the full reference against the index to produce a complete
|
||||
/// <c>Cluster → Namespace → UnsArea → UnsLine → Equipment → Tag</c> scope. Unblocks
|
||||
/// per-Equipment / per-UnsLine ACL grants at the dispatch layer.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The flat cluster-level scope is sufficient for v2 GA because Phase 6.2 ACL grants
|
||||
/// at the Cluster scope cascade to every tag below (decision #129 — additive grants). The
|
||||
/// finer hierarchy only matters when operators want per-area or per-equipment grants;
|
||||
/// those still work for Cluster-level grants, and landing the finer resolution in a
|
||||
/// follow-up doesn't regress the base security model.</para>
|
||||
/// <para>The index is pre-loaded by the Server bootstrap against the published generation;
|
||||
/// the resolver itself does no live DB access. Resolve is O(1) dictionary lookup on the
|
||||
/// hot path; the fallback for unknown fullReference strings produces the same cluster-only
|
||||
/// scope the pre-ADR-001 resolver returned — new tags picked up via driver discovery but
|
||||
/// not yet indexed (e.g. between a DiscoverAsync result and the next generation publish)
|
||||
/// stay addressable without a scope-resolver crash.</para>
|
||||
///
|
||||
/// <para>Thread-safety: the resolver is stateless once constructed. Callers may cache a
|
||||
/// single instance per DriverNodeManager without locks.</para>
|
||||
/// <para>Thread-safety: both constructor paths freeze inputs into immutable state. Callers
|
||||
/// may cache a single instance per DriverNodeManager without locks. Swap atomically on
|
||||
/// generation change via the server's publish pipeline.</para>
|
||||
/// </remarks>
|
||||
public sealed class NodeScopeResolver
|
||||
{
|
||||
private readonly string _clusterId;
|
||||
private readonly FrozenDictionary<string, NodeScope>? _index;
|
||||
|
||||
/// <summary>Cluster-only resolver — pre-ADR-001 behavior. Kept for Server processes that
|
||||
/// haven't wired the Config-DB snapshot flow yet.</summary>
|
||||
public NodeScopeResolver(string clusterId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
_clusterId = clusterId;
|
||||
_index = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full-path resolver (ADR-001 Task B). <paramref name="pathIndex"/> maps each known
|
||||
/// driver-side full reference to its pre-resolved <see cref="NodeScope"/> carrying
|
||||
/// every UNS level populated. Entries are typically produced by joining
|
||||
/// <c>Tag → Equipment → UnsLine → UnsArea</c> rows of the published generation against
|
||||
/// the driver's discovered full references (or against <c>Tag.TagConfig</c> directly
|
||||
/// when the walker is config-primary per ADR-001 Option A).
|
||||
/// </summary>
|
||||
public NodeScopeResolver(string clusterId, IReadOnlyDictionary<string, NodeScope> pathIndex)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
ArgumentNullException.ThrowIfNull(pathIndex);
|
||||
_clusterId = clusterId;
|
||||
_index = pathIndex.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a node scope for the given driver-side <paramref name="fullReference"/>.
|
||||
/// Phase 1 shape: returns <c>ClusterId</c> + <c>TagId = fullReference</c> only;
|
||||
/// NamespaceId / UnsArea / UnsLine / Equipment stay null. A future resolver will
|
||||
/// join against the Configuration DB to populate the full path.
|
||||
/// Returns the indexed full-path scope when available; falls back to cluster-only
|
||||
/// (TagId populated only) when the index is absent or the reference isn't indexed.
|
||||
/// The fallback is the same shape the pre-ADR-001 resolver produced, so the authz
|
||||
/// evaluator behaves identically for un-indexed references.
|
||||
/// </summary>
|
||||
public NodeScope Resolve(string fullReference)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fullReference);
|
||||
|
||||
if (_index is not null && _index.TryGetValue(fullReference, out var indexed))
|
||||
return indexed;
|
||||
|
||||
return new NodeScope
|
||||
{
|
||||
ClusterId = _clusterId,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <see cref="NodeScope"/> path index consumed by <see cref="NodeScopeResolver"/>
|
||||
/// from a Config-DB snapshot of a single published generation. Runs once per generation
|
||||
/// (or on every generation change) at the Server bootstrap layer; the produced index is
|
||||
/// immutable + hot-path readable per ADR-001 Task B.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The index key is the driver-side full reference (<c>Tag.TagConfig</c>) — the same
|
||||
/// string the dispatch layer passes to <see cref="NodeScopeResolver.Resolve"/>. The value
|
||||
/// is a <see cref="NodeScope"/> with every UNS level populated:
|
||||
/// <c>ClusterId / NamespaceId / UnsAreaId / UnsLineId / EquipmentId / TagId</c>. Tag rows
|
||||
/// with null <c>EquipmentId</c> (SystemPlatform-namespace Galaxy tags per decision #120)
|
||||
/// are excluded from the index — the cluster-only fallback path in the resolver handles
|
||||
/// them without needing an index entry.</para>
|
||||
///
|
||||
/// <para>Duplicate keys are not expected but would be indicative of corrupt data — the
|
||||
/// builder throws <see cref="InvalidOperationException"/> on collision so a config drift
|
||||
/// surfaces at bootstrap instead of producing silently-last-wins scopes at dispatch.</para>
|
||||
/// </remarks>
|
||||
public static class ScopePathIndexBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Build a fullReference → NodeScope index from the four Config-DB collections for a
|
||||
/// single namespace. Callers must filter inputs to a single
|
||||
/// <see cref="Namespace"/> + the same <see cref="ConfigGeneration"/> upstream.
|
||||
/// </summary>
|
||||
/// <param name="clusterId">Owning cluster — populates <see cref="NodeScope.ClusterId"/>.</param>
|
||||
/// <param name="namespaceId">Owning namespace — populates <see cref="NodeScope.NamespaceId"/>.</param>
|
||||
/// <param name="content">Pre-loaded rows for the namespace.</param>
|
||||
public static IReadOnlyDictionary<string, NodeScope> Build(
|
||||
string clusterId,
|
||||
string namespaceId,
|
||||
EquipmentNamespaceContent content)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(namespaceId);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
var areaByLine = content.Lines.ToDictionary(l => l.UnsLineId, l => l.UnsAreaId, StringComparer.OrdinalIgnoreCase);
|
||||
var lineByEquipment = content.Equipment.ToDictionary(e => e.EquipmentId, e => e.UnsLineId, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var index = new Dictionary<string, NodeScope>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var tag in content.Tags)
|
||||
{
|
||||
// Null EquipmentId = SystemPlatform-namespace tag per decision #110 — skip; the
|
||||
// cluster-only resolver fallback handles those without needing an index entry.
|
||||
if (string.IsNullOrEmpty(tag.EquipmentId)) continue;
|
||||
|
||||
// Broken FK — Tag references a missing Equipment row. Skip rather than crash;
|
||||
// sp_ValidateDraft should have caught this at publish, so any drift here is
|
||||
// unexpected but non-fatal.
|
||||
if (!lineByEquipment.TryGetValue(tag.EquipmentId, out var lineId)) continue;
|
||||
if (!areaByLine.TryGetValue(lineId, out var areaId)) continue;
|
||||
|
||||
var scope = new NodeScope
|
||||
{
|
||||
ClusterId = clusterId,
|
||||
NamespaceId = namespaceId,
|
||||
UnsAreaId = areaId,
|
||||
UnsLineId = lineId,
|
||||
EquipmentId = tag.EquipmentId,
|
||||
TagId = tag.TagConfig,
|
||||
Kind = NodeHierarchyKind.Equipment,
|
||||
};
|
||||
|
||||
if (!index.TryAdd(tag.TagConfig, scope))
|
||||
throw new InvalidOperationException(
|
||||
$"Duplicate fullReference '{tag.TagConfig}' in Equipment namespace '{namespaceId}'. " +
|
||||
"Config data is corrupt — two Tag rows produced the same wire-level address.");
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
}
|
||||
@@ -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,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,221 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EquipmentNodeWalkerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Walk_EmptyContent_EmitsNothing()
|
||||
{
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, new EquipmentNamespaceContent([], [], [], []));
|
||||
|
||||
rec.Children.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_EmitsArea_Line_Equipment_Folders_In_UnsOrder()
|
||||
{
|
||||
var content = new EquipmentNamespaceContent(
|
||||
Areas: [Area("area-1", "warsaw"), Area("area-2", "berlin")],
|
||||
Lines: [Line("line-1", "area-1", "oven-line"), Line("line-2", "area-2", "press-line")],
|
||||
Equipment: [Eq("eq-1", "line-1", "oven-3"), Eq("eq-2", "line-2", "press-7")],
|
||||
Tags: []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
rec.Children.Select(c => c.BrowseName).ShouldBe(["berlin", "warsaw"]); // ordered by Name
|
||||
var warsaw = rec.Children.First(c => c.BrowseName == "warsaw");
|
||||
warsaw.Children.Select(c => c.BrowseName).ShouldBe(["oven-line"]);
|
||||
warsaw.Children[0].Children.Select(c => c.BrowseName).ShouldBe(["oven-3"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_AddsFiveIdentifierProperties_OnEquipmentNode_Skipping_NullZTagSapid()
|
||||
{
|
||||
var uuid = Guid.NewGuid();
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
eq.EquipmentUuid = uuid;
|
||||
eq.MachineCode = "MC-42";
|
||||
eq.ZTag = null;
|
||||
eq.SAPID = null;
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
var props = equipmentNode.Properties.Select(p => p.BrowseName).ToList();
|
||||
props.ShouldContain("EquipmentId");
|
||||
props.ShouldContain("EquipmentUuid");
|
||||
props.ShouldContain("MachineCode");
|
||||
props.ShouldNotContain("ZTag");
|
||||
props.ShouldNotContain("SAPID");
|
||||
equipmentNode.Properties.First(p => p.BrowseName == "EquipmentUuid").Value.ShouldBe(uuid.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Adds_ZTag_And_SAPID_When_Present()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
eq.ZTag = "ZT-0042";
|
||||
eq.SAPID = "10000042";
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
equipmentNode.Properties.First(p => p.BrowseName == "ZTag").Value.ShouldBe("ZT-0042");
|
||||
equipmentNode.Properties.First(p => p.BrowseName == "SAPID").Value.ShouldBe("10000042");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Materializes_Identification_Subfolder_When_AnyFieldPresent()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
eq.Manufacturer = "Trumpf";
|
||||
eq.Model = "TruLaser-3030";
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
var identification = equipmentNode.Children.FirstOrDefault(c => c.BrowseName == "Identification");
|
||||
identification.ShouldNotBeNull();
|
||||
identification!.Properties.Select(p => p.BrowseName).ShouldContain("Manufacturer");
|
||||
identification.Properties.Select(p => p.BrowseName).ShouldContain("Model");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Omits_Identification_Subfolder_When_AllFieldsNull()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3"); // no identification fields
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
equipmentNode.Children.ShouldNotContain(c => c.BrowseName == "Identification");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Emits_Variable_Per_BoundTag_Under_Equipment()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var tag1 = NewTag("tag-1", "Temperature", "Int32", "plcaddr-01", equipmentId: "eq-1");
|
||||
var tag2 = NewTag("tag-2", "Setpoint", "Float32", "plcaddr-02", equipmentId: "eq-1");
|
||||
var unboundTag = NewTag("tag-3", "Orphan", "Int32", "plcaddr-03", equipmentId: null); // SystemPlatform-style, walker skips
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
||||
[eq], [tag1, tag2, unboundTag]);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
equipmentNode.Variables.Count.ShouldBe(2);
|
||||
equipmentNode.Variables.Select(v => v.BrowseName).ShouldBe(["Setpoint", "Temperature"]);
|
||||
equipmentNode.Variables.First(v => v.BrowseName == "Temperature").AttributeInfo.FullName.ShouldBe("plcaddr-01");
|
||||
equipmentNode.Variables.First(v => v.BrowseName == "Setpoint").AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_FallsBack_To_String_For_Unparseable_DataType()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var tag = NewTag("tag-1", "Mystery", "NotARealType", "plcaddr-42", equipmentId: "eq-1");
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], [tag]);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var variable = rec.Children[0].Children[0].Children[0].Variables.Single();
|
||||
variable.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.String);
|
||||
}
|
||||
|
||||
// ----- builders for test seed rows -----
|
||||
|
||||
private static UnsArea Area(string id, string name) => new()
|
||||
{
|
||||
UnsAreaId = id, ClusterId = "c1", Name = name, GenerationId = 1,
|
||||
};
|
||||
|
||||
private static UnsLine Line(string id, string areaId, string name) => new()
|
||||
{
|
||||
UnsLineId = id, UnsAreaId = areaId, Name = name, GenerationId = 1,
|
||||
};
|
||||
|
||||
private static Equipment Eq(string equipmentId, string lineId, string name) => new()
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(),
|
||||
GenerationId = 1,
|
||||
EquipmentId = equipmentId,
|
||||
EquipmentUuid = Guid.NewGuid(),
|
||||
DriverInstanceId = "drv",
|
||||
UnsLineId = lineId,
|
||||
Name = name,
|
||||
MachineCode = "MC-" + name,
|
||||
};
|
||||
|
||||
private static Tag NewTag(string tagId, string name, string dataType, string address, string? equipmentId) => new()
|
||||
{
|
||||
TagRowId = Guid.NewGuid(),
|
||||
GenerationId = 1,
|
||||
TagId = tagId,
|
||||
DriverInstanceId = "drv",
|
||||
EquipmentId = equipmentId,
|
||||
Name = name,
|
||||
DataType = dataType,
|
||||
AccessLevel = ZB.MOM.WW.OtOpcUa.Configuration.Enums.TagAccessLevel.ReadWrite,
|
||||
TagConfig = address,
|
||||
};
|
||||
|
||||
// ----- recording IAddressSpaceBuilder -----
|
||||
|
||||
private sealed class RecordingBuilder(string browseName) : IAddressSpaceBuilder
|
||||
{
|
||||
public string BrowseName { get; } = browseName;
|
||||
public List<RecordingBuilder> Children { get; } = new();
|
||||
public List<RecordingVariable> Variables { get; } = new();
|
||||
public List<RecordingProperty> Properties { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string name, string _)
|
||||
{
|
||||
var child = new RecordingBuilder(name);
|
||||
Children.Add(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
public IVariableHandle Variable(string name, string _, DriverAttributeInfo attr)
|
||||
{
|
||||
var v = new RecordingVariable(name, attr);
|
||||
Variables.Add(v);
|
||||
return v;
|
||||
}
|
||||
|
||||
public void AddProperty(string name, DriverDataType _, object? value) =>
|
||||
Properties.Add(new RecordingProperty(name, value));
|
||||
}
|
||||
|
||||
private sealed record RecordingProperty(string BrowseName, object? Value);
|
||||
|
||||
private sealed record RecordingVariable(string BrowseName, DriverAttributeInfo AttributeInfo) : IVariableHandle
|
||||
{
|
||||
public string FullReference => AttributeInfo.FullName;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -1,139 +1,116 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared fixture that starts libplctag's <c>ab_server</c> simulator in the background for
|
||||
/// the duration of an integration test collection. The fixture takes an
|
||||
/// <see cref="AbServerProfile"/> (see <see cref="KnownProfiles"/>) so each AB family — ControlLogix,
|
||||
/// CompactLogix, Micro800, GuardLogix — starts the simulator with the right <c>--plc</c>
|
||||
/// mode + preseed tag set. Binary is expected on PATH; CI resolves that via a job step
|
||||
/// that downloads the pinned Windows build from libplctag GitHub Releases before
|
||||
/// <c>dotnet test</c> — see <c>docs/v2/test-data-sources.md §2.CI</c> for the exact step.
|
||||
/// Reachability probe for the <c>ab_server</c> Docker container (libplctag's CIP
|
||||
/// simulator built via <c>Docker/Dockerfile</c>) or any real AB PLC the
|
||||
/// <c>AB_SERVER_ENDPOINT</c> env var points at. Parses
|
||||
/// <c>AB_SERVER_ENDPOINT</c> (default <c>localhost:44818</c>) + TCP-connects
|
||||
/// once at fixture construction. Tests skip via <see cref="AbServerFactAttribute"/>
|
||||
/// / <see cref="AbServerTheoryAttribute"/> when the port isn't live, so
|
||||
/// <c>dotnet test</c> stays green on a fresh clone without Docker running.
|
||||
/// Matches the <see cref="ModbusSimulatorFixture"/> / <c>Snap7ServerFixture</c> /
|
||||
/// <c>OpcPlcFixture</c> shape.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><c>ab_server</c> is a C binary shipped in libplctag's repo (MIT). On developer
|
||||
/// workstations it's built once from source and placed on PATH; on CI the workflow file
|
||||
/// fetches a version-pinned prebuilt + stages it. Tests skip (via
|
||||
/// <see cref="AbServerFactAttribute"/>) when the binary is not on PATH so a fresh clone
|
||||
/// without the simulator still gets a green unit-test run.</para>
|
||||
///
|
||||
/// <para>Per-family profiles live in <see cref="KnownProfiles"/>. When a test wants a
|
||||
/// specific family, instantiate the fixture with that profile — either via a
|
||||
/// <see cref="IClassFixture{TFixture}"/> derived type or by constructing directly in a
|
||||
/// parametric test (the latter is used below for the smoke suite).</para>
|
||||
/// Docker is the only supported launch path — no native-binary spawn + no
|
||||
/// PATH lookup. Bring the container up before <c>dotnet test</c>:
|
||||
/// <c>docker compose -f Docker/docker-compose.yml --profile controllogix up</c>.
|
||||
/// </remarks>
|
||||
public sealed class AbServerFixture : IAsyncLifetime
|
||||
{
|
||||
private Process? _proc;
|
||||
private const string EndpointEnvVar = "AB_SERVER_ENDPOINT";
|
||||
|
||||
/// <summary>The profile the simulator was started with. Same instance the driver-side options should use.</summary>
|
||||
/// <summary>The profile this fixture instance represents. Parallel family smoke tests
|
||||
/// instantiate the fixture with the profile matching their compose-file service.</summary>
|
||||
public AbServerProfile Profile { get; }
|
||||
public int Port { get; }
|
||||
public bool IsAvailable { get; private set; }
|
||||
|
||||
public AbServerFixture() : this(KnownProfiles.ControlLogix, AbServerProfile.DefaultPort) { }
|
||||
public string Host { get; } = "127.0.0.1";
|
||||
public int Port { get; } = AbServerProfile.DefaultPort;
|
||||
|
||||
public AbServerFixture(AbServerProfile profile) : this(profile, AbServerProfile.DefaultPort) { }
|
||||
public AbServerFixture() : this(KnownProfiles.ControlLogix) { }
|
||||
|
||||
public AbServerFixture(AbServerProfile profile, int port)
|
||||
public AbServerFixture(AbServerProfile profile)
|
||||
{
|
||||
Profile = profile ?? throw new ArgumentNullException(nameof(profile));
|
||||
Port = port;
|
||||
}
|
||||
|
||||
public ValueTask InitializeAsync() => InitializeAsync(default);
|
||||
public ValueTask DisposeAsync() => DisposeAsync(default);
|
||||
|
||||
public async ValueTask InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (LocateBinary() is not string binary)
|
||||
// Endpoint override applies to both host + port — targeting a real PLC at
|
||||
// non-default host or port shouldn't need fixture changes.
|
||||
if (Environment.GetEnvironmentVariable(EndpointEnvVar) is { Length: > 0 } raw)
|
||||
{
|
||||
IsAvailable = false;
|
||||
return;
|
||||
var parts = raw.Split(':', 2);
|
||||
Host = parts[0];
|
||||
if (parts.Length == 2 && int.TryParse(parts[1], out var p)) Port = p;
|
||||
}
|
||||
IsAvailable = true;
|
||||
|
||||
_proc = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = binary,
|
||||
Arguments = Profile.BuildCliArgs(Port),
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
},
|
||||
};
|
||||
_proc.Start();
|
||||
|
||||
// Give the server a moment to accept its listen socket before tests try to connect.
|
||||
await Task.Delay(500, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync(CancellationToken cancellationToken)
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> when ab_server is reachable at this fixture's Host/Port. Used by
|
||||
/// <see cref="AbServerFactAttribute"/> / <see cref="AbServerTheoryAttribute"/>
|
||||
/// to decide whether to skip tests on a fresh clone without a running container.
|
||||
/// </summary>
|
||||
public static bool IsServerAvailable() =>
|
||||
TcpProbe(ResolveHost(), ResolvePort());
|
||||
|
||||
private static string ResolveHost() =>
|
||||
Environment.GetEnvironmentVariable(EndpointEnvVar)?.Split(':', 2)[0] ?? "127.0.0.1";
|
||||
|
||||
private static int ResolvePort()
|
||||
{
|
||||
var raw = Environment.GetEnvironmentVariable(EndpointEnvVar);
|
||||
if (raw is null) return AbServerProfile.DefaultPort;
|
||||
var parts = raw.Split(':', 2);
|
||||
return parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : AbServerProfile.DefaultPort;
|
||||
}
|
||||
|
||||
/// <summary>One-shot TCP probe; 500 ms budget so a missing container fails the probe fast.</summary>
|
||||
private static bool TcpProbe(string host, int port)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_proc is { HasExited: false })
|
||||
{
|
||||
_proc.Kill(entireProcessTree: true);
|
||||
_proc.WaitForExit(5_000);
|
||||
}
|
||||
using var client = new TcpClient();
|
||||
var task = client.ConnectAsync(host, port);
|
||||
return task.Wait(TimeSpan.FromMilliseconds(500)) && client.Connected;
|
||||
}
|
||||
catch { /* best-effort cleanup */ }
|
||||
_proc?.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locate <c>ab_server</c> on PATH. Returns <c>null</c> when missing — tests that
|
||||
/// depend on it should use <see cref="AbServerFactAttribute"/> so CI runs without the binary
|
||||
/// simply skip rather than fail.
|
||||
/// </summary>
|
||||
public static string? LocateBinary()
|
||||
{
|
||||
var names = new[] { "ab_server.exe", "ab_server" };
|
||||
var path = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||
foreach (var dir in path.Split(Path.PathSeparator))
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
var candidate = Path.Combine(dir, name);
|
||||
if (File.Exists(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>[Fact]</c>-equivalent that skips when <c>ab_server</c> is not available on PATH.
|
||||
/// Integration tests use this instead of <c>[Fact]</c> so a developer box without
|
||||
/// <c>ab_server</c> installed still gets a green run.
|
||||
/// <c>[Fact]</c>-equivalent that skips when ab_server isn't reachable — accepts a
|
||||
/// live Docker listener on <c>localhost:44818</c> or an <c>AB_SERVER_ENDPOINT</c>
|
||||
/// override pointing at a real PLC.
|
||||
/// </summary>
|
||||
public sealed class AbServerFactAttribute : FactAttribute
|
||||
{
|
||||
public AbServerFactAttribute()
|
||||
{
|
||||
if (AbServerFixture.LocateBinary() is null)
|
||||
Skip = "ab_server not on PATH; install libplctag test binaries to run.";
|
||||
if (!AbServerFixture.IsServerAvailable())
|
||||
Skip = "ab_server not reachable. Start the Docker container " +
|
||||
"(docker compose -f Docker/docker-compose.yml --profile controllogix up) " +
|
||||
"or set AB_SERVER_ENDPOINT to a real PLC.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>[Theory]</c>-equivalent that skips when <c>ab_server</c> is not on PATH. Pair with
|
||||
/// <c>[MemberData(nameof(KnownProfiles.All))]</c>-style providers to run one theory row per
|
||||
/// profile so a single test covers all four families.
|
||||
/// <c>[Theory]</c>-equivalent with the same availability rules as
|
||||
/// <see cref="AbServerFactAttribute"/>. Pair with
|
||||
/// <c>[MemberData(nameof(KnownProfiles.All))]</c>-style providers to run one theory
|
||||
/// row per family.
|
||||
/// </summary>
|
||||
public sealed class AbServerTheoryAttribute : TheoryAttribute
|
||||
{
|
||||
public AbServerTheoryAttribute()
|
||||
{
|
||||
if (AbServerFixture.LocateBinary() is null)
|
||||
Skip = "ab_server not on PATH; install libplctag test binaries to run.";
|
||||
if (!AbServerFixture.IsServerAvailable())
|
||||
Skip = "ab_server not reachable. Start the Docker container " +
|
||||
"(docker compose -f Docker/docker-compose.yml --profile controllogix up) " +
|
||||
"or set AB_SERVER_ENDPOINT to a real PLC.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,130 +3,51 @@ using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Per-family provisioning profile for the <c>ab_server</c> simulator. Instead of hard-coding
|
||||
/// one fixture shape + one set of CLI args, each integration test picks a profile matching the
|
||||
/// family it wants to exercise — ControlLogix / CompactLogix / Micro800 / GuardLogix. The
|
||||
/// profile composes the CLI arg list passed to <c>ab_server</c> + the tag-definition set the
|
||||
/// driver uses to address the simulator's pre-provisioned tags.
|
||||
/// Per-family marker for the <c>ab_server</c> Docker compose profile a given test
|
||||
/// targets. The compose file (<c>Docker/docker-compose.yml</c>) is the canonical
|
||||
/// source of truth for which tags a family seeds + which <c>--plc</c> mode the
|
||||
/// simulator boots in; this record just ties a family enum to operator-facing
|
||||
/// notes so fixture + test code can filter / branch by family.
|
||||
/// </summary>
|
||||
/// <param name="Family">OtOpcUa driver family this profile targets. Drives
|
||||
/// <see cref="AbCipDeviceOptions.PlcFamily"/> + driver-side connection-parameter profile
|
||||
/// (ConnectionSize, unconnected-only, etc.) per decision #9.</param>
|
||||
/// <param name="AbServerPlcArg">The value passed to <c>ab_server --plc <arg></c>. Some families
|
||||
/// map 1:1 (ControlLogix → "controllogix"); Micro800/GuardLogix fall back to the family whose
|
||||
/// CIP behavior ab_server emulates most faithfully (see per-profile Notes).</param>
|
||||
/// <param name="SeedTags">Tags to preseed on the simulator via <c>--tag <name>:<type>[:<size>]</c>
|
||||
/// flags. Each entry becomes one CLI arg; the driver-side <see cref="AbCipTagDefinition"/>
|
||||
/// list references the same names so tests can read/write without walking the @tags surface
|
||||
/// first.</param>
|
||||
/// <param name="Notes">Operator-facing description of what the profile covers + any quirks.</param>
|
||||
/// <param name="Family">OtOpcUa driver family this profile targets.</param>
|
||||
/// <param name="ComposeProfile">The <c>docker compose --profile</c> name that brings
|
||||
/// this family's ab_server up. Matches the service key in the compose file.</param>
|
||||
/// <param name="Notes">Operator-facing description of coverage + any quirks.</param>
|
||||
public sealed record AbServerProfile(
|
||||
AbCipPlcFamily Family,
|
||||
string AbServerPlcArg,
|
||||
IReadOnlyList<AbServerSeedTag> SeedTags,
|
||||
string ComposeProfile,
|
||||
string Notes)
|
||||
{
|
||||
/// <summary>Default port — every profile uses the same so parallel-runs-of-different-families
|
||||
/// would conflict (deliberately — one simulator per test collection is the model).</summary>
|
||||
/// <summary>Default ab_server port — matches the compose-file port-map + the
|
||||
/// CIP / EtherNet/IP standard.</summary>
|
||||
public const int DefaultPort = 44818;
|
||||
|
||||
/// <summary>Compose the full <c>ab_server</c> CLI arg string for
|
||||
/// <see cref="System.Diagnostics.ProcessStartInfo.Arguments"/>.</summary>
|
||||
public string BuildCliArgs(int port)
|
||||
{
|
||||
var parts = new List<string>
|
||||
{
|
||||
"--port", port.ToString(),
|
||||
"--plc", AbServerPlcArg,
|
||||
};
|
||||
foreach (var tag in SeedTags)
|
||||
{
|
||||
parts.Add("--tag");
|
||||
parts.Add(tag.ToCliSpec());
|
||||
}
|
||||
return string.Join(' ', parts);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One tag the simulator pre-creates. ab_server spec format:
|
||||
/// <c><name>:<type>[:<array_size>]</c>.</summary>
|
||||
public sealed record AbServerSeedTag(string Name, string AbServerType, int? ArraySize = null)
|
||||
{
|
||||
public string ToCliSpec() => ArraySize is { } n ? $"{Name}:{AbServerType}:{n}" : $"{Name}:{AbServerType}";
|
||||
}
|
||||
|
||||
/// <summary>Canonical profiles covering every AB CIP family shipped in PRs 9–12.</summary>
|
||||
public static class KnownProfiles
|
||||
{
|
||||
/// <summary>
|
||||
/// ControlLogix — the widest-coverage family: full CIP capabilities, generous connection
|
||||
/// size, @tags controller-walk supported. Tag shape covers atomic types + a Program-scoped
|
||||
/// tag so the Symbol-Object decoder's scope-split path is exercised.
|
||||
/// </summary>
|
||||
public static readonly AbServerProfile ControlLogix = new(
|
||||
Family: AbCipPlcFamily.ControlLogix,
|
||||
AbServerPlcArg: "controllogix",
|
||||
SeedTags: new AbServerSeedTag[]
|
||||
{
|
||||
new("TestDINT", "DINT"),
|
||||
new("TestREAL", "REAL"),
|
||||
new("TestBOOL", "BOOL"),
|
||||
new("TestSINT", "SINT"),
|
||||
new("TestString","STRING"),
|
||||
new("TestArray", "DINT", ArraySize: 16),
|
||||
},
|
||||
Notes: "Widest-coverage profile — PR 9 baseline. UDTs live in PR 6-shipped Template Object tests; ab_server lacks full UDT emulation.");
|
||||
ComposeProfile: "controllogix",
|
||||
Notes: "Widest-coverage profile — PR 9 baseline. UDTs unit-tested via golden Template Object buffers; ab_server lacks full UDT emulation.");
|
||||
|
||||
/// <summary>
|
||||
/// CompactLogix — narrower ConnectionSize quirk exercised here. ab_server doesn't
|
||||
/// enforce the narrower limit itself; the driver-side profile caps it + this simulator
|
||||
/// honors whatever the client asks for. Tag set is a subset of ControlLogix.
|
||||
/// </summary>
|
||||
public static readonly AbServerProfile CompactLogix = new(
|
||||
Family: AbCipPlcFamily.CompactLogix,
|
||||
AbServerPlcArg: "compactlogix",
|
||||
SeedTags: new AbServerSeedTag[]
|
||||
{
|
||||
new("TestDINT", "DINT"),
|
||||
new("TestREAL", "REAL"),
|
||||
new("TestBOOL", "BOOL"),
|
||||
},
|
||||
Notes: "Narrower ConnectionSize than ControlLogix — driver-side profile caps it per PR 10. Tag set mirrors the CompactLogix atomic subset.");
|
||||
ComposeProfile: "compactlogix",
|
||||
Notes: "ab_server doesn't enforce the narrower ConnectionSize; driver-side profile caps it per PR 10.");
|
||||
|
||||
/// <summary>
|
||||
/// Micro800 — unconnected-only family. ab_server has no explicit micro800 plc mode so
|
||||
/// we fall back to the nearest CIP-compatible emulation (controllogix) + document the
|
||||
/// discrepancy. Driver-side path enforcement (empty routing path, unconnected-only
|
||||
/// sessions) is exercised in the unit suite; this integration profile smoke-tests that
|
||||
/// reads work end-to-end against the unconnected path.
|
||||
/// </summary>
|
||||
public static readonly AbServerProfile Micro800 = new(
|
||||
Family: AbCipPlcFamily.Micro800,
|
||||
AbServerPlcArg: "controllogix", // ab_server lacks dedicated micro800 mode — see Notes
|
||||
SeedTags: new AbServerSeedTag[]
|
||||
{
|
||||
new("TestDINT", "DINT"),
|
||||
new("TestREAL", "REAL"),
|
||||
},
|
||||
Notes: "ab_server has no --plc micro800 — falls back to controllogix emulation. Driver side still enforces empty path + unconnected-only per PR 11. Real Micro800 coverage requires a 2080 on a lab rig.");
|
||||
ComposeProfile: "micro800",
|
||||
Notes: "--plc=Micro800 mode (unconnected-only, empty path). Driver-side enforcement verified in the unit suite.");
|
||||
|
||||
/// <summary>
|
||||
/// GuardLogix — safety-capable ControlLogix variant with ViewOnly safety tags. ab_server
|
||||
/// doesn't emulate the safety subsystem; we preseed a safety-suffixed name (<c>_S</c>) so
|
||||
/// the driver's read-only classification path is exercised against a real tag.
|
||||
/// </summary>
|
||||
public static readonly AbServerProfile GuardLogix = new(
|
||||
Family: AbCipPlcFamily.GuardLogix,
|
||||
AbServerPlcArg: "controllogix",
|
||||
SeedTags: new AbServerSeedTag[]
|
||||
{
|
||||
new("TestDINT", "DINT"),
|
||||
new("SafetyDINT_S", "DINT"), // _S-suffixed → driver classifies as safety-ViewOnly per PR 12
|
||||
},
|
||||
Notes: "ab_server has no safety subsystem — this profile emulates the tag-naming contract. Real safety-lock behavior requires a physical GuardLogix 1756-L8xS rig.");
|
||||
ComposeProfile: "guardlogix",
|
||||
Notes: "ab_server has no safety subsystem — _S-suffixed seed tag triggers driver-side ViewOnly classification only.");
|
||||
|
||||
public static IReadOnlyList<AbServerProfile> All { get; } =
|
||||
new[] { ControlLogix, CompactLogix, Micro800, GuardLogix };
|
||||
[ControlLogix, CompactLogix, Micro800, GuardLogix];
|
||||
|
||||
public static AbServerProfile ForFamily(AbCipPlcFamily family) =>
|
||||
All.FirstOrDefault(p => p.Family == family)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime gate that lets an integration-test class declare which target-server tier
|
||||
/// it requires. Reads <c>AB_SERVER_PROFILE</c> from the environment; tests call
|
||||
/// <see cref="SkipUnless"/> with the profile names they support + skip otherwise.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Two tiers today:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>abserver</c> (default) — the Dockerized libplctag <c>ab_server</c>
|
||||
/// simulator. Covers atomic reads / writes / basic discovery across the four
|
||||
/// families (ControlLogix / CompactLogix / Micro800 / GuardLogix).</item>
|
||||
/// <item><c>emulate</c> — Rockwell Studio 5000 Logix Emulate on an operator's
|
||||
/// Windows box, exposed via <c>AB_SERVER_ENDPOINT</c>. Adds real UDT / ALMD /
|
||||
/// AOI / Program-scoped-tag coverage that ab_server can't emulate. Tier-gated
|
||||
/// because Emulate is per-seat licensed + Windows-only + manually launched;
|
||||
/// a stock `dotnet test` run against ab_server must skip Emulate-only classes
|
||||
/// cleanly.</item>
|
||||
/// </list>
|
||||
/// <para>Tests assert their target tier at the top of each <c>[Fact]</c> /
|
||||
/// <c>[Theory]</c> body, mirroring the <c>MODBUS_SIM_PROFILE</c> gate pattern in
|
||||
/// <c>tests/.../Modbus.IntegrationTests/DL205/DL205StringQuirkTests.cs</c>.</para>
|
||||
/// </remarks>
|
||||
public static class AbServerProfileGate
|
||||
{
|
||||
public const string Default = "abserver";
|
||||
public const string Emulate = "emulate";
|
||||
|
||||
/// <summary>Active profile from <c>AB_SERVER_PROFILE</c>; defaults to <see cref="Default"/>.</summary>
|
||||
public static string CurrentProfile =>
|
||||
Environment.GetEnvironmentVariable("AB_SERVER_PROFILE") is { Length: > 0 } raw
|
||||
? raw.Trim().ToLowerInvariant()
|
||||
: Default;
|
||||
|
||||
/// <summary>
|
||||
/// Skip the calling test via <c>Assert.Skip</c> when <see cref="CurrentProfile"/>
|
||||
/// isn't in <paramref name="requiredProfiles"/>. Case-insensitive match.
|
||||
/// </summary>
|
||||
public static void SkipUnless(params string[] requiredProfiles)
|
||||
{
|
||||
foreach (var p in requiredProfiles)
|
||||
if (string.Equals(p, CurrentProfile, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
Assert.Skip(
|
||||
$"Test requires AB_SERVER_PROFILE in {{{string.Join(", ", requiredProfiles)}}}; " +
|
||||
$"current value is '{CurrentProfile}'. " +
|
||||
$"Set AB_SERVER_PROFILE=emulate + point AB_SERVER_ENDPOINT at a Logix Emulate instance to run the golden-box-tier tests.");
|
||||
}
|
||||
}
|
||||
@@ -5,61 +5,23 @@ using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Pure-unit tests for the profile → CLI arg composition. Runs without <c>ab_server</c>
|
||||
/// on PATH so CI without the binary still exercises these contracts + catches any
|
||||
/// profile-definition drift (e.g. a typo in <c>--plc</c> mapping would silently make the
|
||||
/// simulator boot with the wrong family).
|
||||
/// Pure-unit tests for the profile catalog. Verifies <see cref="KnownProfiles"/>
|
||||
/// stays in sync with <see cref="AbCipPlcFamily"/> + with the compose-file service
|
||||
/// names — a typo in either would surface as a test failure rather than a silent
|
||||
/// "wrong family booted" at runtime. Runs without Docker, so CI without the
|
||||
/// container still exercises these contracts.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbServerProfileTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildCliArgs_Emits_Port_And_Plc_And_TagFlags()
|
||||
{
|
||||
var profile = new AbServerProfile(
|
||||
Family: AbCipPlcFamily.ControlLogix,
|
||||
AbServerPlcArg: "controllogix",
|
||||
SeedTags: new AbServerSeedTag[]
|
||||
{
|
||||
new("A", "DINT"),
|
||||
new("B", "REAL"),
|
||||
},
|
||||
Notes: "test");
|
||||
|
||||
profile.BuildCliArgs(44818).ShouldBe("--port 44818 --plc controllogix --tag A:DINT --tag B:REAL");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCliArgs_NoSeedTags_Emits_Just_Port_And_Plc()
|
||||
{
|
||||
var profile = new AbServerProfile(
|
||||
AbCipPlcFamily.ControlLogix, "controllogix", [], "empty");
|
||||
|
||||
profile.BuildCliArgs(5000).ShouldBe("--port 5000 --plc controllogix");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbServerSeedTag_ArraySize_FormatsAsThirdSegment()
|
||||
{
|
||||
new AbServerSeedTag("TestArray", "DINT", ArraySize: 16)
|
||||
.ToCliSpec().ShouldBe("TestArray:DINT:16");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbServerSeedTag_NoArraySize_TwoSegments()
|
||||
{
|
||||
new AbServerSeedTag("TestScalar", "REAL")
|
||||
.ToCliSpec().ShouldBe("TestScalar:REAL");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbCipPlcFamily.ControlLogix, "controllogix")]
|
||||
[InlineData(AbCipPlcFamily.CompactLogix, "compactlogix")]
|
||||
[InlineData(AbCipPlcFamily.Micro800, "controllogix")] // falls back — ab_server lacks dedicated mode
|
||||
[InlineData(AbCipPlcFamily.GuardLogix, "controllogix")] // falls back — ab_server lacks safety subsystem
|
||||
public void KnownProfiles_ForFamily_Returns_Expected_AbServerPlcArg(AbCipPlcFamily family, string expected)
|
||||
[InlineData(AbCipPlcFamily.Micro800, "micro800")]
|
||||
[InlineData(AbCipPlcFamily.GuardLogix, "guardlogix")]
|
||||
public void KnownProfiles_ForFamily_Returns_Expected_ComposeProfile(AbCipPlcFamily family, string expected)
|
||||
{
|
||||
KnownProfiles.ForFamily(family).AbServerPlcArg.ShouldBe(expected);
|
||||
KnownProfiles.ForFamily(family).ComposeProfile.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -71,20 +33,8 @@ public sealed class AbServerProfileTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KnownProfiles_ControlLogix_Includes_AllAtomicTypes()
|
||||
public void DefaultPort_Matches_EtherNetIP_Standard()
|
||||
{
|
||||
var tags = KnownProfiles.ControlLogix.SeedTags.Select(t => t.AbServerType).ToHashSet();
|
||||
tags.ShouldContain("DINT");
|
||||
tags.ShouldContain("REAL");
|
||||
tags.ShouldContain("BOOL");
|
||||
tags.ShouldContain("SINT");
|
||||
tags.ShouldContain("STRING");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KnownProfiles_GuardLogix_SeedsSafetySuffixedTag()
|
||||
{
|
||||
KnownProfiles.GuardLogix.SeedTags
|
||||
.ShouldContain(t => t.Name.EndsWith("_S"), "GuardLogix profile must seed at least one _S-suffixed tag for safety-classification coverage.");
|
||||
AbServerProfile.DefaultPort.ShouldBe(44818);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# ab_server container for the AB CIP integration suite.
|
||||
#
|
||||
# ab_server is a C program in libplctag/libplctag under src/tools/ab_server.
|
||||
# We clone at a pinned commit, build just the ab_server target via CMake,
|
||||
# and copy the resulting binary into a slim runtime stage so the published
|
||||
# image stays small (~60MB vs ~350MB for the build stage).
|
||||
|
||||
# -------- stage 1: build ab_server from source --------
|
||||
FROM debian:bookworm-slim AS build
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
build-essential \
|
||||
cmake \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Pinned tag matches the `ab_server` version AbServerFixture + CI treat as
|
||||
# canonical. Bump deliberately alongside a driver-side change that needs
|
||||
# something newer.
|
||||
ARG LIBPLCTAG_TAG=release
|
||||
RUN git clone --depth 1 --branch "${LIBPLCTAG_TAG}" https://github.com/libplctag/libplctag.git /src
|
||||
|
||||
WORKDIR /src
|
||||
RUN cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \
|
||||
&& cmake --build build --target ab_server --parallel
|
||||
|
||||
# -------- stage 2: runtime --------
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/dohertj2/lmxopcua" \
|
||||
org.opencontainers.image.description="libplctag ab_server for OtOpcUa AB CIP driver integration tests"
|
||||
|
||||
# libplctag's ab_server is statically linked against libc / libstdc++ on
|
||||
# Debian bookworm; no runtime dependencies beyond what the slim image
|
||||
# already has.
|
||||
COPY --from=build /src/build/bin_dist/ab_server /usr/local/bin/ab_server
|
||||
|
||||
EXPOSE 44818
|
||||
|
||||
# docker-compose.yml overrides the command with per-family flags.
|
||||
CMD ["ab_server", "--plc=ControlLogix", "--path=1,0", "--port=44818", \
|
||||
"--tag=TestDINT:DINT[1]", "--tag=TestREAL:REAL[1]", "--tag=TestBOOL:BOOL[1]"]
|
||||
@@ -0,0 +1,89 @@
|
||||
# AB CIP integration-test fixture — `ab_server` (Docker)
|
||||
|
||||
[libplctag](https://github.com/libplctag/libplctag)'s `ab_server` — a
|
||||
MIT-licensed C program that emulates a ControlLogix / CompactLogix CIP
|
||||
endpoint over EtherNet/IP. Docker is the only supported launch path;
|
||||
`ab_server` ships as a source-only tool under libplctag's
|
||||
`src/tools/ab_server/` so the Dockerfile's multi-stage build is the only
|
||||
reproducible way to get a working binary across developer boxes. A fresh
|
||||
clone needs Docker Desktop and nothing else.
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| [`Dockerfile`](Dockerfile) | Multi-stage: build from libplctag at pinned tag → copy binary into `debian:bookworm-slim` runtime image |
|
||||
| [`docker-compose.yml`](docker-compose.yml) | One service per family (`controllogix` / `compactlogix` / `micro800` / `guardlogix`); all bind `:44818` |
|
||||
|
||||
## Run
|
||||
|
||||
From the repo root:
|
||||
|
||||
```powershell
|
||||
# ControlLogix — widest-coverage profile
|
||||
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests\Docker\docker-compose.yml --profile controllogix up
|
||||
|
||||
# Per-family
|
||||
docker compose -f tests\...\Docker\docker-compose.yml --profile compactlogix up
|
||||
docker compose -f tests\...\Docker\docker-compose.yml --profile micro800 up
|
||||
docker compose -f tests\...\Docker\docker-compose.yml --profile guardlogix up
|
||||
```
|
||||
|
||||
Detached + stop:
|
||||
|
||||
```powershell
|
||||
docker compose -f tests\...\Docker\docker-compose.yml --profile controllogix up -d
|
||||
docker compose -f tests\...\Docker\docker-compose.yml --profile controllogix down
|
||||
```
|
||||
|
||||
First run builds the image (~3-5 minutes — clones libplctag + compiles
|
||||
`ab_server` + its dependencies). Subsequent runs are fast because the
|
||||
multi-stage build layer-caches the checkout + compile.
|
||||
|
||||
## Endpoint
|
||||
|
||||
- Default: `localhost:44818` (EtherNet/IP standard; non-privileged)
|
||||
- Override with `AB_SERVER_ENDPOINT=host:port` to point at a real PLC.
|
||||
|
||||
## Run the integration tests
|
||||
|
||||
In a separate shell with a container up:
|
||||
|
||||
```powershell
|
||||
cd C:\Users\dohertj2\Desktop\lmxopcua
|
||||
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
```
|
||||
|
||||
`AbServerFixture` TCP-probes `localhost:44818` at collection init +
|
||||
records a skip reason when unreachable, so tests stay green on a fresh
|
||||
clone without the container running. Tests use `[AbServerFact]` /
|
||||
`[AbServerTheory]` which check the same probe.
|
||||
|
||||
## What each family seeds
|
||||
|
||||
Tag sets match `AbServerProfile.cs` exactly — changing seeds in one
|
||||
place means updating both.
|
||||
|
||||
| Family | Seeded tags | Notes |
|
||||
|---|---|---|
|
||||
| ControlLogix | `TestDINT` `TestREAL` `TestBOOL` `TestSINT` `TestString` `TestArray` | Widest-coverage; PR 9 baseline. UDT emulation missing from ab_server |
|
||||
| CompactLogix | `TestDINT` `TestREAL` `TestBOOL` | Narrow ConnectionSize cap enforced driver-side; ab_server accepts any size |
|
||||
| Micro800 | `TestDINT` `TestREAL` | ab_server has no `micro800` mode; falls back to `controllogix` emulation |
|
||||
| GuardLogix | `TestDINT` `SafetyDINT_S` | ab_server has no safety subsystem; `_S` suffix triggers driver-side classification only |
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **No UDT / CIP Template Object emulation** — `ab_server` covers atomic
|
||||
types only. UDT reads + task #194 whole-UDT optimization verify via
|
||||
unit tests with golden byte buffers.
|
||||
- **Family-specific quirks trust driver-side code** — ab_server emulates
|
||||
a generic Logix CPU; the ConnectionSize cap, empty-path unconnected
|
||||
mode, and safety-partition write rejection all need lab rigs for
|
||||
wire-level proof.
|
||||
|
||||
See [`docs/drivers/AbServer-Test-Fixture.md`](../../../docs/drivers/AbServer-Test-Fixture.md)
|
||||
for the full coverage map.
|
||||
|
||||
## References
|
||||
|
||||
- [libplctag on GitHub](https://github.com/libplctag/libplctag)
|
||||
- [`docs/drivers/AbServer-Test-Fixture.md`](../../../docs/drivers/AbServer-Test-Fixture.md) — coverage map
|
||||
- [`docs/v2/dev-environment.md`](../../../docs/v2/dev-environment.md) §Docker fixtures
|
||||
@@ -0,0 +1,97 @@
|
||||
# AB CIP integration-test fixture — ab_server (libplctag).
|
||||
#
|
||||
# One service per family. All bind :44818 on the host; only one runs at a
|
||||
# time. Commands mirror the CLI args AbServerProfile.cs constructs for the
|
||||
# native-binary path.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose --profile controllogix up
|
||||
# docker compose --profile compactlogix up
|
||||
# docker compose --profile micro800 up
|
||||
# docker compose --profile guardlogix up
|
||||
services:
|
||||
controllogix:
|
||||
profiles: ["controllogix"]
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: otopcua-ab-server:libplctag-release
|
||||
container_name: otopcua-ab-server-controllogix
|
||||
restart: "no"
|
||||
ports:
|
||||
- "44818:44818"
|
||||
command: [
|
||||
"ab_server",
|
||||
"--plc=ControlLogix",
|
||||
"--path=1,0",
|
||||
"--port=44818",
|
||||
"--tag=TestDINT:DINT[1]",
|
||||
"--tag=TestREAL:REAL[1]",
|
||||
"--tag=TestBOOL:BOOL[1]",
|
||||
"--tag=TestSINT:SINT[1]",
|
||||
"--tag=TestString:STRING[1]",
|
||||
"--tag=TestArray:DINT[16]"
|
||||
]
|
||||
|
||||
compactlogix:
|
||||
profiles: ["compactlogix"]
|
||||
image: otopcua-ab-server:libplctag-release
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: otopcua-ab-server-compactlogix
|
||||
restart: "no"
|
||||
ports:
|
||||
- "44818:44818"
|
||||
# ab_server doesn't distinguish CompactLogix from ControlLogix — no
|
||||
# dedicated --plc mode. Driver-side ConnectionSize cap is enforced
|
||||
# separately (see AbServerProfile.CompactLogix Notes).
|
||||
command: [
|
||||
"ab_server",
|
||||
"--plc=ControlLogix",
|
||||
"--path=1,0",
|
||||
"--port=44818",
|
||||
"--tag=TestDINT:DINT[1]",
|
||||
"--tag=TestREAL:REAL[1]",
|
||||
"--tag=TestBOOL:BOOL[1]"
|
||||
]
|
||||
|
||||
micro800:
|
||||
profiles: ["micro800"]
|
||||
image: otopcua-ab-server:libplctag-release
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: otopcua-ab-server-micro800
|
||||
restart: "no"
|
||||
ports:
|
||||
- "44818:44818"
|
||||
# ab_server does have a Micro800 plc mode (unconnected-only, empty path).
|
||||
command: [
|
||||
"ab_server",
|
||||
"--plc=Micro800",
|
||||
"--port=44818",
|
||||
"--tag=TestDINT:DINT[1]",
|
||||
"--tag=TestREAL:REAL[1]"
|
||||
]
|
||||
|
||||
guardlogix:
|
||||
profiles: ["guardlogix"]
|
||||
image: otopcua-ab-server:libplctag-release
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: otopcua-ab-server-guardlogix
|
||||
restart: "no"
|
||||
ports:
|
||||
- "44818:44818"
|
||||
# ab_server has no safety subsystem — _S suffix triggers driver-side
|
||||
# classification only.
|
||||
command: [
|
||||
"ab_server",
|
||||
"--plc=ControlLogix",
|
||||
"--path=1,0",
|
||||
"--port=44818",
|
||||
"--tag=TestDINT:DINT[1]",
|
||||
"--tag=SafetyDINT_S:DINT[1]"
|
||||
]
|
||||
@@ -0,0 +1,105 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate;
|
||||
|
||||
/// <summary>
|
||||
/// Golden-box-tier ALMD alarm projection tests against Logix Emulate.
|
||||
/// Promotes the feature-flagged ALMD projection (task #177) from unit-only coverage
|
||||
/// (<c>AbCipAlarmProjectionTests</c> with faked InFaulted sequences) to end-to-end
|
||||
/// wire-level coverage — Emulate runs the real ALMD instruction, with real
|
||||
/// rising-edge semantics on <c>InFaulted</c> + <c>Ack</c>, so the driver's poll-based
|
||||
/// projection gets validated against the actual behaviour shops running FT View see.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Required Emulate project state</b> (see <c>LogixProject/README.md</c>):</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Controller-scope ALMD tag <c>HighTempAlarm</c> — a standard ALMD instruction
|
||||
/// with default member set (<c>In</c>, <c>InFaulted</c>, <c>Acked</c>,
|
||||
/// <c>Severity</c>, <c>Cfg_ProgTime</c>, …).</item>
|
||||
/// <item>A periodic task that drives <c>HighTempAlarm.In</c> false→true→false at a
|
||||
/// cadence the operator can script via a one-shot routine (e.g. a
|
||||
/// <c>SimulateAlarm</c> bit the test case pulses through
|
||||
/// <c>IWritable.WriteAsync</c>).</item>
|
||||
/// <item>Operator writes <c>1</c> to <c>SimulateAlarm</c> to trigger the rising
|
||||
/// edge on <c>HighTempAlarm.In</c>; ladder uses that as the alarm input.</item>
|
||||
/// </list>
|
||||
/// <para>Runs only when <c>AB_SERVER_PROFILE=emulate</c>. ab_server has no ALMD
|
||||
/// instruction + no alarm subsystem, so this tier-gated class couldn't produce a
|
||||
/// meaningful result against the default simulator.</para>
|
||||
/// </remarks>
|
||||
[Collection("AbServerEmulate")]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Tier", "Emulate")]
|
||||
public sealed class AbCipEmulateAlmdTests
|
||||
{
|
||||
[AbServerFact]
|
||||
public async Task Real_ALMD_raise_fires_OnAlarmEvent_through_the_driver_projection()
|
||||
{
|
||||
AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate);
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AB_SERVER_ENDPOINT")
|
||||
?? throw new InvalidOperationException(
|
||||
"AB_SERVER_ENDPOINT must be set to the Logix Emulate instance when AB_SERVER_PROFILE=emulate.");
|
||||
|
||||
var options = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions($"ab://{endpoint}/1,0")],
|
||||
EnableAlarmProjection = true,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(200),
|
||||
Tags = [
|
||||
new AbCipTagDefinition(
|
||||
Name: "HighTempAlarm",
|
||||
DeviceHostAddress: $"ab://{endpoint}/1,0",
|
||||
TagPath: "HighTempAlarm",
|
||||
DataType: AbCipDataType.Structure,
|
||||
Members: [
|
||||
new AbCipStructureMember("InFaulted", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("Acked", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("Severity", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("In", AbCipDataType.DInt),
|
||||
]),
|
||||
// The "simulate the alarm input" bit the ladder watches.
|
||||
new AbCipTagDefinition(
|
||||
Name: "SimulateAlarm",
|
||||
DeviceHostAddress: $"ab://{endpoint}/1,0",
|
||||
TagPath: "SimulateAlarm",
|
||||
DataType: AbCipDataType.Bool,
|
||||
Writable: true),
|
||||
],
|
||||
};
|
||||
|
||||
await using var drv = new AbCipDriver(options, driverInstanceId: "emulate-almd");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var raised = new TaskCompletionSource<AlarmEventArgs>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
drv.OnAlarmEvent += (_, e) =>
|
||||
{
|
||||
if (e.Message.Contains("raised")) raised.TrySetResult(e);
|
||||
};
|
||||
|
||||
var sub = await drv.SubscribeAlarmsAsync(
|
||||
["HighTempAlarm"], TestContext.Current.CancellationToken);
|
||||
|
||||
// Pulse the input bit the ladder watches, then wait for the driver's poll loop
|
||||
// to see InFaulted rise + fire the raise event.
|
||||
_ = await drv.WriteAsync(
|
||||
[new WriteRequest("SimulateAlarm", true)],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var got = await Task.WhenAny(raised.Task, Task.Delay(TimeSpan.FromSeconds(5)));
|
||||
got.ShouldBe(raised.Task, "driver must surface the ALMD raise within 5 s of the ladder-driven edge");
|
||||
var args = await raised.Task;
|
||||
args.SourceNodeId.ShouldBe("HighTempAlarm");
|
||||
args.AlarmType.ShouldBe("ALMD");
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(sub, TestContext.Current.CancellationToken);
|
||||
|
||||
// Reset the bit so the next test run starts from a known state.
|
||||
_ = await drv.WriteAsync(
|
||||
[new WriteRequest("SimulateAlarm", false)],
|
||||
TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate;
|
||||
|
||||
/// <summary>
|
||||
/// Golden-box-tier UDT read tests against Rockwell Studio 5000 Logix Emulate.
|
||||
/// Promotes the whole-UDT-read optimization (task #194) from unit-only coverage
|
||||
/// (golden Template Object byte buffers + <see cref="AbCipUdtMemberLayoutTests"/>)
|
||||
/// to end-to-end wire-level coverage — Emulate's firmware speaks the same CIP
|
||||
/// Template Object responses real hardware does, so the member-offset math + the
|
||||
/// <c>AbCipUdtReadPlanner</c> grouping get validated against production semantics.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Required Emulate project state</b> (see <c>LogixProject/README.md</c>
|
||||
/// for the L5X export that seeds this; ship the project once Emulate is on the
|
||||
/// integration host):</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>UDT <c>Motor_UDT</c> with members <c>Speed : DINT</c>, <c>Torque : REAL</c>,
|
||||
/// <c>Status : DINT</c> — the member set <see cref="AbCipUdtMemberLayoutTests"/>
|
||||
/// uses as its declared-layout golden reference.</item>
|
||||
/// <item>Controller-scope tag <c>Motor1 : Motor_UDT</c> with seed values
|
||||
/// Speed=<c>1800</c>, Torque=<c>42.5f</c>, Status=<c>0x0001</c>.</item>
|
||||
/// </list>
|
||||
/// <para>Runs only when <c>AB_SERVER_PROFILE=emulate</c>. With ab_server
|
||||
/// (the default), skips cleanly — ab_server lacks UDT / Template Object emulation
|
||||
/// so this wire-level test couldn't pass against it regardless.</para>
|
||||
/// </remarks>
|
||||
[Collection("AbServerEmulate")]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Tier", "Emulate")]
|
||||
public sealed class AbCipEmulateUdtReadTests
|
||||
{
|
||||
[AbServerFact]
|
||||
public async Task WholeUdt_read_decodes_each_member_at_its_Template_Object_offset()
|
||||
{
|
||||
AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate);
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AB_SERVER_ENDPOINT")
|
||||
?? throw new InvalidOperationException(
|
||||
"AB_SERVER_ENDPOINT must be set to the Logix Emulate instance " +
|
||||
"(e.g. '10.0.0.42:44818') when AB_SERVER_PROFILE=emulate.");
|
||||
|
||||
var options = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions($"ab://{endpoint}/1,0")],
|
||||
Tags = [
|
||||
new AbCipTagDefinition(
|
||||
Name: "Motor1",
|
||||
DeviceHostAddress: $"ab://{endpoint}/1,0",
|
||||
TagPath: "Motor1",
|
||||
DataType: AbCipDataType.Structure,
|
||||
Members: [
|
||||
new AbCipStructureMember("Speed", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("Torque", AbCipDataType.Real),
|
||||
new AbCipStructureMember("Status", AbCipDataType.DInt),
|
||||
]),
|
||||
],
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
};
|
||||
|
||||
await using var drv = new AbCipDriver(options, driverInstanceId: "emulate-udt-smoke");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
// Whole-UDT read optimization from task #194: one libplctag read on the
|
||||
// parent tag, three decodes from the buffer at member offsets. Asserts
|
||||
// Emulate's Template Object response matches what AbCipUdtMemberLayout
|
||||
// computes from the declared member set.
|
||||
var snapshots = await drv.ReadAsync(
|
||||
["Motor1.Speed", "Motor1.Torque", "Motor1.Status"],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
snapshots.Count.ShouldBe(3);
|
||||
foreach (var s in snapshots) s.StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
Convert.ToInt32(snapshots[0].Value).ShouldBe(1800);
|
||||
Convert.ToSingle(snapshots[1].Value).ShouldBe(42.5f, tolerance: 0.001f);
|
||||
Convert.ToInt32(snapshots[2].Value).ShouldBe(0x0001);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
# Logix Emulate project stub
|
||||
|
||||
This folder holds the Studio 5000 project that Logix Emulate loads when running
|
||||
the Emulate-tier integration tests
|
||||
(`tests/.../AbCip.IntegrationTests/Emulate/*.cs`, gated on
|
||||
`AB_SERVER_PROFILE=emulate`).
|
||||
|
||||
**Status today**: stub. The actual `.L5X` export isn't committed yet; once
|
||||
the Emulate PC is running + a project with the required state exists,
|
||||
export to L5X + drop it here as `OtOpcUaAbCipFixture.L5X`.
|
||||
|
||||
## Why L5X, not .ACD
|
||||
|
||||
Studio 5000 ships two save formats: `.ACD` (binary, the runtime project)
|
||||
and `.L5X` (XML export). Ship the L5X because:
|
||||
|
||||
- Text format — reviewable in PR diffs, diffable in git
|
||||
- Reproducible import across Studio 5000 versions
|
||||
- Doesn't carry per-installation state (license watermarks, revision history)
|
||||
|
||||
Reconstruction workflow: Studio 5000 → open project → File → Save As
|
||||
→ `.L5X`. On a fresh Emulate install: File → Open → select the L5X → it
|
||||
rebuilds the ACD from the XML.
|
||||
|
||||
## Required project state
|
||||
|
||||
The Emulate-tier tests rely on this exact tag / UDT set. Missing any of
|
||||
these makes the dependent test fail loudly (TagNotFound, wrong value,
|
||||
wrong type), not skip silently — Emulate is a tier above the Docker
|
||||
simulator; operators who opted into it get opt-in-level coverage
|
||||
expectations.
|
||||
|
||||
### UDT definitions
|
||||
|
||||
| UDT name | Members | Notes |
|
||||
|---|---|---|
|
||||
| `Motor_UDT` | `Speed : DINT`, `Torque : REAL`, `Status : DINT` | Matches `AbCipUdtMemberLayoutTests` declared-layout golden. Member order fixed — Logix Template Object offsets depend on it |
|
||||
|
||||
### Controller tags
|
||||
|
||||
| Tag | Type | Seed value | Purpose |
|
||||
|---|---|---|---|
|
||||
| `Motor1` | `Motor_UDT` | `{Speed=1800, Torque=42.5, Status=0x0001}` | `AbCipEmulateUdtReadTests.WholeUdt_read_decodes_each_member_at_its_Template_Object_offset` |
|
||||
| `HighTempAlarm` | `ALMD` | default ALMD config, `In` tied to `SimulateAlarm` bit | `AbCipEmulateAlmdTests.Real_ALMD_raise_fires_OnAlarmEvent_through_the_driver_projection` |
|
||||
| `SimulateAlarm` | `BOOL` | `0` | Operator-writable bit the ladder routes into `HighTempAlarm.In` — gives the test a clean way to drive the alarm edge without scripting Emulate directly |
|
||||
|
||||
### Program structure
|
||||
|
||||
- One periodic task `MainTask` @ 100 ms
|
||||
- One program `MainProgram`
|
||||
- One routine `MainRoutine` (Ladder) with a single rung:
|
||||
`XIC SimulateAlarm OTE HighTempAlarm.In`
|
||||
|
||||
That's enough ladder for `SimulateAlarm := 1` to raise the alarm + for
|
||||
`SimulateAlarm := 0` to clear it.
|
||||
|
||||
## Tier-level behaviours this project enables
|
||||
|
||||
Coverage the existing Dockerized `ab_server` fixture can't produce —
|
||||
each verified by an `Emulate/*Tests.cs` class gated on
|
||||
`AB_SERVER_PROFILE=emulate`:
|
||||
|
||||
- **CIP Template Object round-trip** — real Logix template bytes,
|
||||
reads produce the same offset layout the CIP Symbol Object decoder +
|
||||
`AbCipUdtMemberLayout` expect.
|
||||
- **ALMD rising-edge semantics** — real Logix ALMD instruction fires
|
||||
`InFaulted` / `Acked` transitions at cycle boundaries, not at our
|
||||
unit-test fake's timer boundaries.
|
||||
- **Optimized vs unoptimized DB behaviour** — Logix 5380/5580 series
|
||||
runs the Studio 5000 project with optimized-DB-equivalent member
|
||||
access; the driver's read path exercises that wire surface.
|
||||
|
||||
Not in scope even with Emulate — needs real hardware:
|
||||
|
||||
- EtherNet/IP embedded-switch behaviour (Stratix 5700, 1756-EN4TR)
|
||||
- CIP Safety across partitions (Emulate 5580 emulates safety within
|
||||
the chassis but not across nodes)
|
||||
- Redundant chassis failover (1756-RM)
|
||||
- Motion control timing
|
||||
- High-speed discrete-input scheduling
|
||||
|
||||
## How to run the Emulate-tier tests
|
||||
|
||||
On the dev box:
|
||||
|
||||
```powershell
|
||||
$env:AB_SERVER_PROFILE = 'emulate'
|
||||
$env:AB_SERVER_ENDPOINT = '10.0.0.42:44818' # replace with the Emulate PC IP
|
||||
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
```
|
||||
|
||||
With `AB_SERVER_PROFILE` unset or `abserver`, the `Emulate/*Tests.cs`
|
||||
classes skip cleanly; ab_server-backed tests run as usual.
|
||||
|
||||
## See also
|
||||
|
||||
- [`docs/drivers/AbServer-Test-Fixture.md`](../../../docs/drivers/AbServer-Test-Fixture.md)
|
||||
§Logix Emulate golden-box tier — coverage map
|
||||
- [`docs/v2/dev-environment.md`](../../../docs/v2/dev-environment.md)
|
||||
§Integration host — license + networking notes
|
||||
- Studio 5000 Logix Designer + Logix Emulate product pages on the
|
||||
Rockwell TechConnect portal (licensed; internal link only).
|
||||
@@ -23,6 +23,11 @@
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Docker\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
<None Update="LogixProject\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task #177 — tests covering ALMD projection detection, feature-flag gate,
|
||||
/// subscribe/unsubscribe lifecycle, state-transition event emission, and acknowledge.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipAlarmProjectionTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
private static AbCipTagDefinition AlmdTag(string name) => new(
|
||||
name, Device, name, AbCipDataType.Structure, Members:
|
||||
[
|
||||
new AbCipStructureMember("InFaulted", AbCipDataType.DInt), // Logix stores ALMD bools as DINT
|
||||
new AbCipStructureMember("Acked", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("Severity", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("In", AbCipDataType.DInt),
|
||||
]);
|
||||
|
||||
[Fact]
|
||||
public void AbCipAlarmDetector_Flags_AlmdSignature_As_Alarm()
|
||||
{
|
||||
var almd = AlmdTag("HighTemp");
|
||||
AbCipAlarmDetector.IsAlmd(almd).ShouldBeTrue();
|
||||
|
||||
var plainUdt = new AbCipTagDefinition("Plain", Device, "Plain", AbCipDataType.Structure, Members:
|
||||
[new AbCipStructureMember("X", AbCipDataType.DInt)]);
|
||||
AbCipAlarmDetector.IsAlmd(plainUdt).ShouldBeFalse();
|
||||
|
||||
var atomic = new AbCipTagDefinition("Plain", Device, "Plain", AbCipDataType.DInt);
|
||||
AbCipAlarmDetector.IsAlmd(atomic).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Severity_Mapping_Matches_OPC_UA_Convention()
|
||||
{
|
||||
// Logix severity 1–1000 — mirror the OpcUaClient ACAndC bucketing.
|
||||
AbCipAlarmProjection.MapSeverity(100).ShouldBe(AlarmSeverity.Low);
|
||||
AbCipAlarmProjection.MapSeverity(400).ShouldBe(AlarmSeverity.Medium);
|
||||
AbCipAlarmProjection.MapSeverity(600).ShouldBe(AlarmSeverity.High);
|
||||
AbCipAlarmProjection.MapSeverity(900).ShouldBe(AlarmSeverity.Critical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FeatureFlag_Off_SubscribeAlarms_Returns_Handle_But_Never_Polls()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
Tags = [AlmdTag("HighTemp")],
|
||||
EnableAlarmProjection = false, // explicit; also the default
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
handle.ShouldNotBeNull();
|
||||
handle.DiagnosticId.ShouldContain("abcip-alarm-sub-");
|
||||
|
||||
// Wait a touch — if polling were active, a fake member-read would be triggered.
|
||||
await Task.Delay(100);
|
||||
factory.Tags.ShouldNotContainKey("HighTemp.InFaulted");
|
||||
factory.Tags.ShouldNotContainKey("HighTemp.Severity");
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FeatureFlag_On_Subscribe_Starts_Polling_And_Fires_Raise_On_0_to_1()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
Tags = [AlmdTag("HighTemp")],
|
||||
EnableAlarmProjection = true,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
||||
|
||||
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
|
||||
// The ALMD UDT is declared so whole-UDT grouping kicks in; the parent HighTemp runtime
|
||||
// gets created + polled. Set InFaulted offset-value to 0 first (clear), wait a tick,
|
||||
// then flip to 1 (fault) + wait for the raise event.
|
||||
await WaitForTagCreation(factory, "HighTemp");
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InFaulted=false at offset 0
|
||||
factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8 (after InFaulted+Acked)
|
||||
await Task.Delay(80); // let a tick seed the "last-seen false" state
|
||||
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // flip to faulted
|
||||
await Task.Delay(200); // allow several polls to be safe
|
||||
|
||||
lock (events)
|
||||
{
|
||||
events.ShouldContain(e => e.SourceNodeId == "HighTemp" && e.AlarmType == "ALMD"
|
||||
&& e.Message.Contains("raised"));
|
||||
}
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clear_Event_Fires_On_1_to_0_Transition()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
Tags = [AlmdTag("HighTemp")],
|
||||
EnableAlarmProjection = true,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
||||
|
||||
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
await WaitForTagCreation(factory, "HighTemp");
|
||||
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
|
||||
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
|
||||
await Task.Delay(80); // observe raise
|
||||
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 0;
|
||||
await Task.Delay(200);
|
||||
|
||||
lock (events)
|
||||
{
|
||||
events.ShouldContain(e => e.Message.Contains("raised"));
|
||||
events.ShouldContain(e => e.Message.Contains("cleared"));
|
||||
}
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsubscribe_Stops_The_Poll_Loop()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
Tags = [AlmdTag("HighTemp")],
|
||||
EnableAlarmProjection = true,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
|
||||
};
|
||||
var drv = new AbCipDriver(opts, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
await WaitForTagCreation(factory, "HighTemp");
|
||||
var preUnsubReadCount = factory.Tags["HighTemp"].ReadCount;
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
await Task.Delay(100); // well past several poll intervals if the loop were still alive
|
||||
|
||||
var postDelayReadCount = factory.Tags["HighTemp"].ReadCount;
|
||||
// Allow at most one straggler read between the unsubscribe-cancel + the loop exit.
|
||||
(postDelayReadCount - preUnsubReadCount).ShouldBeLessThanOrEqualTo(1);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task WaitForTagCreation(FakeAbCipTagFactory factory, string tagName)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (factory.Tags.ContainsKey(tagName)) return;
|
||||
await Task.Delay(10);
|
||||
}
|
||||
throw new TimeoutException($"Tag {tagName} was never created by the fake factory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task #194 — ReadAsync integration tests for the whole-UDT grouping path. The fake
|
||||
/// runtime records ReadCount + surfaces member values by byte offset so we can assert
|
||||
/// both "one read per parent UDT" and "each member decoded at the correct offset."
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipDriverWholeUdtReadTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var opts = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device)],
|
||||
Tags = tags,
|
||||
};
|
||||
return (new AbCipDriver(opts, "drv-1", factory), factory);
|
||||
}
|
||||
|
||||
private static AbCipTagDefinition MotorUdt() => new(
|
||||
"Motor", Device, "Motor", AbCipDataType.Structure, Members:
|
||||
[
|
||||
new AbCipStructureMember("Speed", AbCipDataType.DInt), // offset 0
|
||||
new AbCipStructureMember("Torque", AbCipDataType.Real), // offset 4
|
||||
]);
|
||||
|
||||
[Fact]
|
||||
public async Task Two_members_of_same_udt_trigger_one_parent_read()
|
||||
{
|
||||
var (drv, factory) = NewDriver(MotorUdt());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
|
||||
|
||||
snapshots.Count.ShouldBe(2);
|
||||
snapshots[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
snapshots[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
|
||||
// Factory should have created ONE runtime (for the parent "Motor") + issued ONE read.
|
||||
// Without the optimization two runtimes (one per member) + two reads would appear.
|
||||
factory.Tags.Count.ShouldBe(1);
|
||||
factory.Tags.ShouldContainKey("Motor");
|
||||
factory.Tags["Motor"].ReadCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Each_member_decodes_at_its_own_offset()
|
||||
{
|
||||
var (drv, factory) = NewDriver(MotorUdt());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Arrange the offset-keyed values before the read fires — the planner places
|
||||
// Speed at offset 0 (DInt) and Torque at offset 4 (Real).
|
||||
// The fake records CreationParams so we fetch it up front by the parent name.
|
||||
var snapshotsTask = drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
|
||||
// The factory creates the runtime inside ReadAsync; we need to set the offset map
|
||||
// AFTER creation. Easier path: create the runtime on demand by reading once then
|
||||
// re-arming. Instead: seed via a pre-read by constructing the fake in the factory's
|
||||
// customise hook.
|
||||
var snapshots = await snapshotsTask;
|
||||
|
||||
// First run establishes the runtime + gives the fake a chance to hold its reference.
|
||||
factory.Tags["Motor"].ValuesByOffset[0] = 1234; // Speed
|
||||
factory.Tags["Motor"].ValuesByOffset[4] = 9.5f; // Torque
|
||||
|
||||
snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
|
||||
snapshots[0].Value.ShouldBe(1234);
|
||||
snapshots[1].Value.ShouldBe(9.5f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parent_read_failure_stamps_every_grouped_member_Bad()
|
||||
{
|
||||
var (drv, factory) = NewDriver(MotorUdt());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Prime runtime existence via a first (successful) read so we can flip it to error.
|
||||
await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
|
||||
factory.Tags["Motor"].Status = -3; // libplctag BadTimeout — mapped in AbCipStatusMapper
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
|
||||
|
||||
snapshots.Count.ShouldBe(2);
|
||||
snapshots[0].StatusCode.ShouldNotBe(AbCipStatusMapper.Good);
|
||||
snapshots[0].Value.ShouldBeNull();
|
||||
snapshots[1].StatusCode.ShouldNotBe(AbCipStatusMapper.Good);
|
||||
snapshots[1].Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mixed_batch_groups_udt_and_falls_back_atomics()
|
||||
{
|
||||
var plain = new AbCipTagDefinition("PlainDint", Device, "PlainDint", AbCipDataType.DInt);
|
||||
var (drv, factory) = NewDriver(MotorUdt(), plain);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(
|
||||
["Motor.Speed", "PlainDint", "Motor.Torque"], CancellationToken.None);
|
||||
|
||||
snapshots.Count.ShouldBe(3);
|
||||
// Motor parent ran one read, PlainDint ran its own read = 2 runtimes, 2 reads total.
|
||||
factory.Tags.Count.ShouldBe(2);
|
||||
factory.Tags.ShouldContainKey("Motor");
|
||||
factory.Tags.ShouldContainKey("PlainDint");
|
||||
factory.Tags["Motor"].ReadCount.ShouldBe(1);
|
||||
factory.Tags["PlainDint"].ReadCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Single_member_of_Udt_uses_per_tag_read_path()
|
||||
{
|
||||
// One member of a UDT doesn't benefit from grouping — the planner demotes to
|
||||
// fallback so the member-level runtime (distinct from the parent runtime) is used,
|
||||
// matching pre-#194 behavior.
|
||||
var (drv, factory) = NewDriver(MotorUdt());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["Motor.Speed"], CancellationToken.None);
|
||||
|
||||
factory.Tags.ShouldContainKey("Motor.Speed");
|
||||
factory.Tags.ShouldNotContainKey("Motor");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipUdtMemberLayoutTests
|
||||
{
|
||||
[Fact]
|
||||
public void Packed_Atomics_Get_Natural_Alignment_Offsets()
|
||||
{
|
||||
// DInt (4 align) + Real (4) + Int (2) + LInt (8 — forces 2-byte pad before it)
|
||||
var members = new[]
|
||||
{
|
||||
new AbCipStructureMember("A", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("B", AbCipDataType.Real),
|
||||
new AbCipStructureMember("C", AbCipDataType.Int),
|
||||
new AbCipStructureMember("D", AbCipDataType.LInt),
|
||||
};
|
||||
|
||||
var offsets = AbCipUdtMemberLayout.TryBuild(members);
|
||||
offsets.ShouldNotBeNull();
|
||||
offsets!["A"].ShouldBe(0);
|
||||
offsets["B"].ShouldBe(4);
|
||||
offsets["C"].ShouldBe(8);
|
||||
// cursor at 10 after Int; LInt needs 8-byte alignment → pad to 16
|
||||
offsets["D"].ShouldBe(16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SInt_Packed_Without_Padding()
|
||||
{
|
||||
var members = new[]
|
||||
{
|
||||
new AbCipStructureMember("X", AbCipDataType.SInt),
|
||||
new AbCipStructureMember("Y", AbCipDataType.SInt),
|
||||
new AbCipStructureMember("Z", AbCipDataType.SInt),
|
||||
};
|
||||
var offsets = AbCipUdtMemberLayout.TryBuild(members);
|
||||
offsets!["X"].ShouldBe(0);
|
||||
offsets["Y"].ShouldBe(1);
|
||||
offsets["Z"].ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_Null_When_Member_Is_Bool()
|
||||
{
|
||||
// BOOL storage in Logix UDTs is packed into a hidden host byte; declaration-only
|
||||
// layout can't place it. Grouping opts out; per-tag read path handles the member.
|
||||
var members = new[]
|
||||
{
|
||||
new AbCipStructureMember("A", AbCipDataType.DInt),
|
||||
new AbCipStructureMember("Flag", AbCipDataType.Bool),
|
||||
};
|
||||
AbCipUdtMemberLayout.TryBuild(members).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_Null_When_Member_Is_String_Or_Structure()
|
||||
{
|
||||
AbCipUdtMemberLayout.TryBuild(
|
||||
new[] { new AbCipStructureMember("Name", AbCipDataType.String) }).ShouldBeNull();
|
||||
AbCipUdtMemberLayout.TryBuild(
|
||||
new[] { new AbCipStructureMember("Nested", AbCipDataType.Structure) }).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_Null_On_Empty_Members()
|
||||
{
|
||||
AbCipUdtMemberLayout.TryBuild(Array.Empty<AbCipStructureMember>()).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user