Compare commits
30 Commits
equipment-
...
focas-vers
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -34,12 +34,16 @@
|
|||||||
<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.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.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.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.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.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.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.Tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.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.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"/>
|
<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
|
||||||
115
docs/drivers/FOCAS-Test-Fixture.md
Normal file
115
docs/drivers/FOCAS-Test-Fixture.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# 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 behavior
|
||||||
|
|
||||||
|
Per driver-stability.md, FOCAS should run process-isolated because
|
||||||
|
`Fwlib32.dll` has documented crash modes. The test suite runs in-process +
|
||||||
|
only exercises the happy path + mapped error codes — a native access
|
||||||
|
violation from the DLL would take the test host down. The process-isolation
|
||||||
|
path (similar to Galaxy's out-of-process Host) has been scoped but not
|
||||||
|
implemented.
|
||||||
|
|
||||||
|
## 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
|
||||||
117
docs/drivers/Modbus-Test-Fixture.md
Normal file
117
docs/drivers/Modbus-Test-Fixture.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# 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 → OPC UA StatusCode mapping
|
||||||
|
- `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 — a JSON flag per
|
||||||
|
register saying "next read returns exception 0x04."
|
||||||
|
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.
|
- **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
|
## 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`.
|
- [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 |
|
| 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 |
|
| **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 |
|
||||||
| **`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 |
|
| **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_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 |
|
| **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) |
|
||||||
| **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 |
|
| **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 |
|
| **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 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) |
|
| **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
|
### D. Cloud / external services
|
||||||
|
|
||||||
| Resource | Purpose | Type | Access | Owner |
|
| 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).
|
||||||
163
docs/v2/implementation/focas-isolation-plan.md
Normal file
163
docs/v2/implementation/focas-isolation-plan.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# FOCAS Tier-C isolation — plan for task #220
|
||||||
|
|
||||||
|
> **Status**: DRAFT — not yet started. Tracks the multi-PR work to
|
||||||
|
> move `Fwlib32.dll` behind an out-of-process host, mirroring the
|
||||||
|
> Galaxy Tier-C split in [`phase-2-galaxy-out-of-process.md`](phase-2-galaxy-out-of-process.md).
|
||||||
|
>
|
||||||
|
> **Pre-reqs shipped** (this PR): version matrix + pre-flight
|
||||||
|
> validation + unit tests. Those close the cheap half of the
|
||||||
|
> hardware-free stability gap. Tier-C closes the expensive half.
|
||||||
|
|
||||||
|
## 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 (proposed)
|
||||||
|
|
||||||
|
1. **PR A — shared contracts**
|
||||||
|
Create `Driver.FOCAS.Shared` with the MessagePack DTOs. No
|
||||||
|
behaviour change. ~200 LOC + round-trip tests for each DTO.
|
||||||
|
2. **PR B — Host project skeleton**
|
||||||
|
Create `Driver.FOCAS.Host` .NET 4.8 x86 project, NSSM wrapper,
|
||||||
|
pipe server scaffold with the same ACL + caller-SID + shared
|
||||||
|
secret plumbing as Galaxy.Host. No Fwlib32 wiring yet — returns
|
||||||
|
`NotImplemented` for everything. ~400 LOC.
|
||||||
|
3. **PR C — Move Fwlib32 calls into Host**
|
||||||
|
Move `FocasNativeSession`, `FocasTagReader`, `FocasTagWriter`,
|
||||||
|
`FocasPmcBitRmw` + the STA thread into the Host. Proxy forwards
|
||||||
|
over IPC. This is the biggest PR — probably 800-1500 LOC of
|
||||||
|
move-with-translation. Existing unit tests keep passing because
|
||||||
|
`IFocasTagFactory` is the DI seam the tests inject against.
|
||||||
|
4. **PR D — Supervisor + respawn**
|
||||||
|
Proxy-side heartbeat + respawn + crash-loop circuit breaker +
|
||||||
|
BackPressure fan-out on Host death. ~500 LOC + chaos tests.
|
||||||
|
5. **PR E — Post-mortem MMF + operational glue**
|
||||||
|
MMF writer in Host, reader in Proxy. Install scripts for the
|
||||||
|
new `OtOpcUaFocasHost` Windows service. Docs. ~300 LOC.
|
||||||
|
|
||||||
|
Total estimate: 2200-3200 LOC across 5 PRs. Consistent with Galaxy
|
||||||
|
Tier-C but narrower since FOCAS has no Historian + no alarm
|
||||||
|
history.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -13,9 +13,9 @@ confirmed DL205 quirk lands in a follow-up PR as a named test in that project.
|
|||||||
|
|
||||||
## Harness
|
## Harness
|
||||||
|
|
||||||
**Chosen simulator: pymodbus 3.13.0** (`pip install 'pymodbus[simulator]==3.13.0'`).
|
**Chosen simulator: pymodbus 3.13.0** packaged as a pinned Docker image
|
||||||
Replaced ModbusPal in PR 43 — see `tests/.../Pymodbus/README.md` for the
|
under `tests/.../Modbus.IntegrationTests/Docker/`. See that folder's
|
||||||
trade-off rationale. Headline reasons:
|
`README.md` for image-build notes + compose profiles. Headline reasons:
|
||||||
|
|
||||||
- **Headless** pure-Python CLI; no Java GUI, runs cleanly on a CI runner.
|
- **Headless** pure-Python CLI; no Java GUI, runs cleanly on a CI runner.
|
||||||
- **Maintained** — current stable 3.13.0; ModbusPal 1.6b is abandoned.
|
- **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
|
- **Per-register raw uint16 seeding** — encoding the DL205 string-byte-order
|
||||||
/ BCD / CDAB-float quirks stays explicit (the quirk math lives in the
|
/ BCD / CDAB-float quirks stays explicit (the quirk math lives in the
|
||||||
`_quirk` JSON-comment fields next to each register).
|
`_quirk` JSON-comment fields next to each register).
|
||||||
- Pip-installable on Windows; sidesteps the privileged-port admin
|
- **Dockerized** — pinned image means the CI simulator surface is
|
||||||
requirement by defaulting to TCP **5020** instead of 502.
|
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**:
|
**Setup pattern**:
|
||||||
1. `pip install "pymodbus[simulator]==3.13.0"`.
|
1. `docker compose -f tests\...\Modbus.IntegrationTests\Docker\docker-compose.yml --profile <standard|dl205|mitsubishi|s7_1500> up -d`.
|
||||||
2. Start the simulator with one of the in-repo profiles:
|
2. `dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` —
|
||||||
`tests\.../Pymodbus\serve.ps1 -Profile standard` (or `-Profile dl205`).
|
|
||||||
3. `dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` —
|
|
||||||
tests auto-skip when the endpoint is unreachable. Default endpoint is
|
tests auto-skip when the endpoint is unreachable. Default endpoint is
|
||||||
`localhost:5020`; override via `MODBUS_SIM_ENDPOINT` for a real PLC on its
|
`localhost:5020`; override via `MODBUS_SIM_ENDPOINT` for a real PLC on its
|
||||||
native port 502.
|
native port 502.
|
||||||
|
3. `docker compose -f ... --profile <…> down` when finished.
|
||||||
|
|
||||||
## Per-device quirk catalog
|
## 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
|
- **PR 42 — ModbusPal `.xmpp` profiles** — **SUPERSEDED by PR 43**. Replaced
|
||||||
with pymodbus JSON because ModbusPal 1.6b is abandoned, GUI-only, and only
|
with pymodbus JSON because ModbusPal 1.6b is abandoned, GUI-only, and only
|
||||||
exposes 2 of the 4 standard tables.
|
exposes 2 of the 4 standard tables.
|
||||||
- **PR 43 — pymodbus JSON profiles** — **DONE**. `Pymodbus/standard.json` +
|
- **PR 43 — pymodbus JSON profiles** — **DONE**. Dockerized under
|
||||||
`Pymodbus/dl205.json` + `Pymodbus/serve.ps1` runner. Both bind TCP 5020.
|
`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
|
- **PR 44+**: one PR per confirmed DL205 quirk, landing the named test + any
|
||||||
driver-side adjustment (string byte order, BCD decoder, V-memory address
|
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
|
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)
|
### 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.
|
- **`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}`** — 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.
|
- **`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):
|
**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.
|
||||||
|
|
||||||
- `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`
|
|
||||||
|
|
||||||
**CI step:**
|
**CI step:**
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# GitHub Actions step placed before `dotnet test`:
|
# GitHub Actions step placed before `dotnet test`:
|
||||||
- name: Fetch ab_server (libplctag v2.6.16)
|
- name: Start ab_server Docker container
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$pin = Get-Content ci/ab-server.lock.json | ConvertFrom-Json
|
docker compose -f tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml `
|
||||||
$asset = $pin.assets.'windows-x64' # swap to windows-x86 / windows-arm64 on non-x64 runners
|
--profile controllogix up -d --build
|
||||||
$url = "https://github.com/libplctag/libplctag/releases/download/$($pin.tag)/$($asset.file)"
|
# Wait for :44818 to accept connections (compose healthcheck-equivalent)
|
||||||
$zip = Join-Path $env:RUNNER_TEMP 'libplctag-tools.zip'
|
for ($i = 0; $i -lt 30; $i++) {
|
||||||
Invoke-WebRequest $url -OutFile $zip
|
if ((Test-NetConnection -ComputerName localhost -Port 44818 -WarningAction SilentlyContinue).TcpTestSucceeded) { break }
|
||||||
$actual = (Get-FileHash -Algorithm SHA256 $zip).Hash.ToLower()
|
Start-Sleep -Seconds 1
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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 skip via `AbServerFactAttribute` / `AbServerTheoryAttribute` when the probe fails, so fresh-clone runs without Docker still pass all unit suites in this project.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
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>
|
/// <see cref="PlcTagHandle"/> and reconnects each device.</para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||||
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly AbCipDriverOptions _options;
|
private readonly AbCipDriverOptions _options;
|
||||||
private readonly string _driverInstanceId;
|
private readonly string _driverInstanceId;
|
||||||
@@ -32,10 +32,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
private readonly PollGroupEngine _poll;
|
private readonly PollGroupEngine _poll;
|
||||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly AbCipAlarmProjection _alarmProjection;
|
||||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
|
|
||||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
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,
|
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
|
||||||
IAbCipTagFactory? tagFactory = null,
|
IAbCipTagFactory? tagFactory = null,
|
||||||
@@ -52,6 +57,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
reader: ReadAsync,
|
reader: ReadAsync,
|
||||||
onChange: (handle, tagRef, snapshot) =>
|
onChange: (handle, tagRef, snapshot) =>
|
||||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||||||
|
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -162,6 +168,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
|
|
||||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
await _alarmProjection.DisposeAsync().ConfigureAwait(false);
|
||||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||||
foreach (var state in _devices.Values)
|
foreach (var state in _devices.Values)
|
||||||
{
|
{
|
||||||
@@ -187,6 +194,39 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
return Task.CompletedTask;
|
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 ----
|
// ---- IHostConnectivityProbe ----
|
||||||
|
|
||||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() =>
|
||||||
@@ -287,39 +327,55 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var results = new DataValueSnapshot[fullReferences.Count];
|
var results = new DataValueSnapshot[fullReferences.Count];
|
||||||
|
|
||||||
for (var i = 0; i < fullReferences.Count; i++)
|
// 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);
|
||||||
|
|
||||||
|
foreach (var group in plan.Groups)
|
||||||
|
await ReadGroupAsync(group, results, now, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
var reference = fullReferences[i];
|
|
||||||
if (!_tagsByName.TryGetValue(reference, out var def))
|
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||||
{
|
{
|
||||||
results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||||
{
|
{
|
||||||
results[i] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNodeIdUnknown, null, now);
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
|
||||||
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
|
await runtime.ReadAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
var status = runtime.GetStatus();
|
var status = runtime.GetStatus();
|
||||||
if (status != 0)
|
if (status != 0)
|
||||||
{
|
{
|
||||||
results[i] = new DataValueSnapshot(null,
|
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||||||
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
|
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
|
||||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||||
$"libplctag status {status} reading {reference}");
|
$"libplctag status {status} reading {reference}");
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tagPath = AbCipTagPath.TryParse(def.TagPath);
|
var tagPath = AbCipTagPath.TryParse(def.TagPath);
|
||||||
var bitIndex = tagPath?.BitIndex;
|
var bitIndex = tagPath?.BitIndex;
|
||||||
var value = runtime.DecodeValue(def.DataType, bitIndex);
|
var value = runtime.DecodeValue(def.DataType, bitIndex);
|
||||||
results[i] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
||||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
@@ -328,13 +384,68 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
results[i] = new DataValueSnapshot(null,
|
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||||||
AbCipStatusMapper.BadCommunicationError, null, now);
|
AbCipStatusMapper.BadCommunicationError, null, now);
|
||||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
/// <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 ----
|
// ---- IWritable ----
|
||||||
|
|||||||
@@ -38,6 +38,24 @@ public sealed class AbCipDriverOptions
|
|||||||
/// should appear in the address space.
|
/// should appear in the address space.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool EnableControllerBrowse { get; init; }
|
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>
|
/// <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>
|
/// </summary>
|
||||||
object? DecodeValue(AbCipDataType type, int? bitIndex);
|
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>
|
/// <summary>
|
||||||
/// Encode <paramref name="value"/> into the local buffer per the tag's type. Callers
|
/// Encode <paramref name="value"/> into the local buffer per the tag's type. Callers
|
||||||
/// pair this with <see cref="WriteAsync"/>.
|
/// pair this with <see cref="WriteAsync"/>.
|
||||||
|
|||||||
@@ -32,24 +32,26 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
|||||||
|
|
||||||
public int GetStatus() => (int)_tag.GetStatus();
|
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
|
AbCipDataType.Bool => bitIndex is int bit
|
||||||
? _tag.GetBit(bit)
|
? _tag.GetBit(bit)
|
||||||
: _tag.GetInt8(0) != 0,
|
: _tag.GetInt8(offset) != 0,
|
||||||
AbCipDataType.SInt => (int)(sbyte)_tag.GetInt8(0),
|
AbCipDataType.SInt => (int)(sbyte)_tag.GetInt8(offset),
|
||||||
AbCipDataType.USInt => (int)_tag.GetUInt8(0),
|
AbCipDataType.USInt => (int)_tag.GetUInt8(offset),
|
||||||
AbCipDataType.Int => (int)_tag.GetInt16(0),
|
AbCipDataType.Int => (int)_tag.GetInt16(offset),
|
||||||
AbCipDataType.UInt => (int)_tag.GetUInt16(0),
|
AbCipDataType.UInt => (int)_tag.GetUInt16(offset),
|
||||||
AbCipDataType.DInt => _tag.GetInt32(0),
|
AbCipDataType.DInt => _tag.GetInt32(offset),
|
||||||
AbCipDataType.UDInt => (int)_tag.GetUInt32(0),
|
AbCipDataType.UDInt => (int)_tag.GetUInt32(offset),
|
||||||
AbCipDataType.LInt => _tag.GetInt64(0),
|
AbCipDataType.LInt => _tag.GetInt64(offset),
|
||||||
AbCipDataType.ULInt => (long)_tag.GetUInt64(0),
|
AbCipDataType.ULInt => (long)_tag.GetUInt64(offset),
|
||||||
AbCipDataType.Real => _tag.GetFloat32(0),
|
AbCipDataType.Real => _tag.GetFloat32(offset),
|
||||||
AbCipDataType.LReal => _tag.GetFloat64(0),
|
AbCipDataType.LReal => _tag.GetFloat64(offset),
|
||||||
AbCipDataType.String => _tag.GetString(0),
|
AbCipDataType.String => _tag.GetString(offset),
|
||||||
AbCipDataType.Dt => _tag.GetInt32(0), // seconds-since-epoch DINT; consumer widens as needed
|
AbCipDataType.Dt => _tag.GetInt32(offset),
|
||||||
AbCipDataType.Structure => null, // UDT whole-tag decode lands in PR 6
|
AbCipDataType.Structure => null,
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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}}]'.");
|
$"FOCAS device has invalid HostAddress '{device.HostAddress}' — expected 'focas://{{ip}}[:{{port}}]'.");
|
||||||
_devices[device.HostAddress] = new DeviceState(addr, device);
|
_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)
|
if (_options.Probe.Enabled)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,9 +13,15 @@ public sealed class FocasDriverOptions
|
|||||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
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(
|
public sealed record FocasDeviceOptions(
|
||||||
string HostAddress,
|
string HostAddress,
|
||||||
string? DeviceName = null);
|
string? DeviceName = null,
|
||||||
|
FocasCncSeries Series = FocasCncSeries.Unknown);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
|
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||||
try
|
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 /
|
// S7netplus writes timeouts into the underlying TcpClient via Plc.WriteTimeout /
|
||||||
// Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself
|
// Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself
|
||||||
// honours the bound.
|
// 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 StaleConfigFlag? _staleConfigFlag;
|
||||||
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? _tierLookup;
|
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? _tierLookup;
|
||||||
private readonly Func<string, string?>? _resilienceConfigLookup;
|
private readonly Func<string, string?>? _resilienceConfigLookup;
|
||||||
|
private readonly Func<string, ZB.MOM.WW.OtOpcUa.Core.OpcUa.EquipmentNamespaceContent?>? _equipmentContentLookup;
|
||||||
private readonly ILoggerFactory _loggerFactory;
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
private readonly ILogger<OpcUaApplicationHost> _logger;
|
private readonly ILogger<OpcUaApplicationHost> _logger;
|
||||||
private ApplicationInstance? _application;
|
private ApplicationInstance? _application;
|
||||||
@@ -43,7 +44,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
NodeScopeResolver? scopeResolver = null,
|
NodeScopeResolver? scopeResolver = null,
|
||||||
StaleConfigFlag? staleConfigFlag = null,
|
StaleConfigFlag? staleConfigFlag = null,
|
||||||
Func<string, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTier>? tierLookup = 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;
|
_options = options;
|
||||||
_driverHost = driverHost;
|
_driverHost = driverHost;
|
||||||
@@ -54,6 +56,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
_staleConfigFlag = staleConfigFlag;
|
_staleConfigFlag = staleConfigFlag;
|
||||||
_tierLookup = tierLookup;
|
_tierLookup = tierLookup;
|
||||||
_resilienceConfigLookup = resilienceConfigLookup;
|
_resilienceConfigLookup = resilienceConfigLookup;
|
||||||
|
_equipmentContentLookup = equipmentContentLookup;
|
||||||
_loggerFactory = loggerFactory;
|
_loggerFactory = loggerFactory;
|
||||||
_logger = logger;
|
_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
|
// Drive each driver's discovery through its node manager. The node manager IS the
|
||||||
// IAddressSpaceBuilder; GenericDriverNodeManager captures alarm-condition sinks into
|
// IAddressSpaceBuilder; GenericDriverNodeManager captures alarm-condition sinks into
|
||||||
// its internal map and wires OnAlarmEvent → sink routing.
|
// 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)
|
foreach (var nodeManager in _server.DriverNodeManagers)
|
||||||
{
|
{
|
||||||
var driverId = nodeManager.Driver.DriverInstanceId;
|
var driverId = nodeManager.Driver.DriverInstanceId;
|
||||||
try
|
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);
|
var generic = new GenericDriverNodeManager(nodeManager.Driver);
|
||||||
await generic.BuildAddressSpaceAsync(nodeManager, ct).ConfigureAwait(false);
|
await generic.BuildAddressSpaceAsync(nodeManager, ct).ConfigureAwait(false);
|
||||||
_logger.LogInformation("Address space populated for driver {Driver}", driverId);
|
_logger.LogInformation("Address space populated for driver {Driver}", driverId);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
@@ -15,6 +16,8 @@ public sealed class OpcUaServerService(
|
|||||||
NodeBootstrap bootstrap,
|
NodeBootstrap bootstrap,
|
||||||
DriverHost driverHost,
|
DriverHost driverHost,
|
||||||
OpcUaApplicationHost applicationHost,
|
OpcUaApplicationHost applicationHost,
|
||||||
|
DriverEquipmentContentRegistry equipmentContentRegistry,
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
ILogger<OpcUaServerService> logger) : BackgroundService
|
ILogger<OpcUaServerService> logger) : BackgroundService
|
||||||
{
|
{
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
@@ -24,6 +27,15 @@ public sealed class OpcUaServerService(
|
|||||||
var result = await bootstrap.LoadCurrentGenerationAsync(stoppingToken);
|
var result = await bootstrap.LoadCurrentGenerationAsync(stoppingToken);
|
||||||
logger.LogInformation("Bootstrap complete: source={Source} generation={Gen}", result.Source, result.GenerationId);
|
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
|
// PR 17: stand up the OPC UA server + drive discovery per registered driver. Driver
|
||||||
// registration itself (RegisterAsync on DriverHost) happens during an earlier DI
|
// registration itself (RegisterAsync on DriverHost) happens during an earlier DI
|
||||||
// extension once the central config DB query + per-driver factory land; for now the
|
// 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 applicationHost.DisposeAsync();
|
||||||
await driverHost.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<ILocalConfigCache>(_ => new LiteDbConfigCache(options.LocalCachePath));
|
||||||
builder.Services.AddSingleton<DriverHost>();
|
builder.Services.AddSingleton<DriverHost>();
|
||||||
builder.Services.AddSingleton<NodeBootstrap>();
|
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>();
|
builder.Services.AddHostedService<OpcUaServerService>();
|
||||||
|
|
||||||
// Central-config DB access for the host-status publisher (LMX follow-up #7). Scoped context
|
// 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;
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps a driver-side full reference (e.g. <c>"TestMachine_001/Oven/SetPoint"</c>) to the
|
/// 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
|
/// <see cref="NodeScope"/> the Phase 6.2 evaluator walks. Supports two modes:
|
||||||
/// returns a cluster-scoped + tag-only scope — the deeper UnsArea / UnsLine / Equipment
|
/// <list type="bullet">
|
||||||
/// path lookup from the live Configuration DB is a Stream C.12 follow-up.
|
/// <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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>The flat cluster-level scope is sufficient for v2 GA because Phase 6.2 ACL grants
|
/// <para>The index is pre-loaded by the Server bootstrap against the published generation;
|
||||||
/// at the Cluster scope cascade to every tag below (decision #129 — additive grants). The
|
/// the resolver itself does no live DB access. Resolve is O(1) dictionary lookup on the
|
||||||
/// finer hierarchy only matters when operators want per-area or per-equipment grants;
|
/// hot path; the fallback for unknown fullReference strings produces the same cluster-only
|
||||||
/// those still work for Cluster-level grants, and landing the finer resolution in a
|
/// scope the pre-ADR-001 resolver returned — new tags picked up via driver discovery but
|
||||||
/// follow-up doesn't regress the base security model.</para>
|
/// 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
|
/// <para>Thread-safety: both constructor paths freeze inputs into immutable state. Callers
|
||||||
/// single instance per DriverNodeManager without locks.</para>
|
/// may cache a single instance per DriverNodeManager without locks. Swap atomically on
|
||||||
|
/// generation change via the server's publish pipeline.</para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class NodeScopeResolver
|
public sealed class NodeScopeResolver
|
||||||
{
|
{
|
||||||
private readonly string _clusterId;
|
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)
|
public NodeScopeResolver(string clusterId)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||||
_clusterId = 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>
|
/// <summary>
|
||||||
/// Resolve a node scope for the given driver-side <paramref name="fullReference"/>.
|
/// Resolve a node scope for the given driver-side <paramref name="fullReference"/>.
|
||||||
/// Phase 1 shape: returns <c>ClusterId</c> + <c>TagId = fullReference</c> only;
|
/// Returns the indexed full-path scope when available; falls back to cluster-only
|
||||||
/// NamespaceId / UnsArea / UnsLine / Equipment stay null. A future resolver will
|
/// (TagId populated only) when the index is absent or the reference isn't indexed.
|
||||||
/// join against the Configuration DB to populate the full path.
|
/// The fallback is the same shape the pre-ADR-001 resolver produced, so the authz
|
||||||
|
/// evaluator behaves identically for un-indexed references.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public NodeScope Resolve(string fullReference)
|
public NodeScope Resolve(string fullReference)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(fullReference);
|
ArgumentException.ThrowIfNullOrWhiteSpace(fullReference);
|
||||||
|
|
||||||
|
if (_index is not null && _index.TryGetValue(fullReference, out var indexed))
|
||||||
|
return indexed;
|
||||||
|
|
||||||
return new NodeScope
|
return new NodeScope
|
||||||
{
|
{
|
||||||
ClusterId = _clusterId,
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,139 +1,116 @@
|
|||||||
using System.Diagnostics;
|
using System.Net.Sockets;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using Xunit.Sdk;
|
using Xunit.Sdk;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shared fixture that starts libplctag's <c>ab_server</c> simulator in the background for
|
/// Reachability probe for the <c>ab_server</c> Docker container (libplctag's CIP
|
||||||
/// the duration of an integration test collection. The fixture takes an
|
/// simulator built via <c>Docker/Dockerfile</c>) or any real AB PLC the
|
||||||
/// <see cref="AbServerProfile"/> (see <see cref="KnownProfiles"/>) so each AB family — ControlLogix,
|
/// <c>AB_SERVER_ENDPOINT</c> env var points at. Parses
|
||||||
/// CompactLogix, Micro800, GuardLogix — starts the simulator with the right <c>--plc</c>
|
/// <c>AB_SERVER_ENDPOINT</c> (default <c>localhost:44818</c>) + TCP-connects
|
||||||
/// mode + preseed tag set. Binary is expected on PATH; CI resolves that via a job step
|
/// once at fixture construction. Tests skip via <see cref="AbServerFactAttribute"/>
|
||||||
/// that downloads the pinned Windows build from libplctag GitHub Releases before
|
/// / <see cref="AbServerTheoryAttribute"/> when the port isn't live, so
|
||||||
/// <c>dotnet test</c> — see <c>docs/v2/test-data-sources.md §2.CI</c> for the exact step.
|
/// <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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para><c>ab_server</c> is a C binary shipped in libplctag's repo (MIT). On developer
|
/// Docker is the only supported launch path — no native-binary spawn + no
|
||||||
/// workstations it's built once from source and placed on PATH; on CI the workflow file
|
/// PATH lookup. Bring the container up before <c>dotnet test</c>:
|
||||||
/// fetches a version-pinned prebuilt + stages it. Tests skip (via
|
/// <c>docker compose -f Docker/docker-compose.yml --profile controllogix up</c>.
|
||||||
/// <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>
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class AbServerFixture : IAsyncLifetime
|
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 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));
|
Profile = profile ?? throw new ArgumentNullException(nameof(profile));
|
||||||
Port = port;
|
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
var parts = raw.Split(':', 2);
|
||||||
|
Host = parts[0];
|
||||||
|
if (parts.Length == 2 && int.TryParse(parts[1], out var p)) Port = p;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public ValueTask InitializeAsync() => InitializeAsync(default);
|
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||||
public ValueTask DisposeAsync() => DisposeAsync(default);
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
|
||||||
public async ValueTask InitializeAsync(CancellationToken cancellationToken)
|
/// <summary>
|
||||||
{
|
/// <c>true</c> when ab_server is reachable at this fixture's Host/Port. Used by
|
||||||
if (LocateBinary() is not string binary)
|
/// <see cref="AbServerFactAttribute"/> / <see cref="AbServerTheoryAttribute"/>
|
||||||
{
|
/// to decide whether to skip tests on a fresh clone without a running container.
|
||||||
IsAvailable = false;
|
/// </summary>
|
||||||
return;
|
public static bool IsServerAvailable() =>
|
||||||
}
|
TcpProbe(ResolveHost(), ResolvePort());
|
||||||
IsAvailable = true;
|
|
||||||
|
|
||||||
_proc = new Process
|
private static string ResolveHost() =>
|
||||||
{
|
Environment.GetEnvironmentVariable(EndpointEnvVar)?.Split(':', 2)[0] ?? "127.0.0.1";
|
||||||
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.
|
private static int ResolvePort()
|
||||||
await Task.Delay(500, cancellationToken).ConfigureAwait(false);
|
{
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ValueTask DisposeAsync(CancellationToken cancellationToken)
|
/// <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
|
try
|
||||||
{
|
{
|
||||||
if (_proc is { HasExited: false })
|
using var client = new TcpClient();
|
||||||
{
|
var task = client.ConnectAsync(host, port);
|
||||||
_proc.Kill(entireProcessTree: true);
|
return task.Wait(TimeSpan.FromMilliseconds(500)) && client.Connected;
|
||||||
_proc.WaitForExit(5_000);
|
|
||||||
}
|
}
|
||||||
}
|
catch { return false; }
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <c>[Fact]</c>-equivalent that skips when <c>ab_server</c> is not available on PATH.
|
/// <c>[Fact]</c>-equivalent that skips when ab_server isn't reachable — accepts a
|
||||||
/// Integration tests use this instead of <c>[Fact]</c> so a developer box without
|
/// live Docker listener on <c>localhost:44818</c> or an <c>AB_SERVER_ENDPOINT</c>
|
||||||
/// <c>ab_server</c> installed still gets a green run.
|
/// override pointing at a real PLC.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AbServerFactAttribute : FactAttribute
|
public sealed class AbServerFactAttribute : FactAttribute
|
||||||
{
|
{
|
||||||
public AbServerFactAttribute()
|
public AbServerFactAttribute()
|
||||||
{
|
{
|
||||||
if (AbServerFixture.LocateBinary() is null)
|
if (!AbServerFixture.IsServerAvailable())
|
||||||
Skip = "ab_server not on PATH; install libplctag test binaries to run.";
|
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>
|
/// <summary>
|
||||||
/// <c>[Theory]</c>-equivalent that skips when <c>ab_server</c> is not on PATH. Pair with
|
/// <c>[Theory]</c>-equivalent with the same availability rules as
|
||||||
/// <c>[MemberData(nameof(KnownProfiles.All))]</c>-style providers to run one theory row per
|
/// <see cref="AbServerFactAttribute"/>. Pair with
|
||||||
/// profile so a single test covers all four families.
|
/// <c>[MemberData(nameof(KnownProfiles.All))]</c>-style providers to run one theory
|
||||||
|
/// row per family.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AbServerTheoryAttribute : TheoryAttribute
|
public sealed class AbServerTheoryAttribute : TheoryAttribute
|
||||||
{
|
{
|
||||||
public AbServerTheoryAttribute()
|
public AbServerTheoryAttribute()
|
||||||
{
|
{
|
||||||
if (AbServerFixture.LocateBinary() is null)
|
if (!AbServerFixture.IsServerAvailable())
|
||||||
Skip = "ab_server not on PATH; install libplctag test binaries to run.";
|
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;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-family provisioning profile for the <c>ab_server</c> simulator. Instead of hard-coding
|
/// Per-family marker for the <c>ab_server</c> Docker compose profile a given test
|
||||||
/// one fixture shape + one set of CLI args, each integration test picks a profile matching the
|
/// targets. The compose file (<c>Docker/docker-compose.yml</c>) is the canonical
|
||||||
/// family it wants to exercise — ControlLogix / CompactLogix / Micro800 / GuardLogix. The
|
/// source of truth for which tags a family seeds + which <c>--plc</c> mode the
|
||||||
/// profile composes the CLI arg list passed to <c>ab_server</c> + the tag-definition set the
|
/// simulator boots in; this record just ties a family enum to operator-facing
|
||||||
/// driver uses to address the simulator's pre-provisioned tags.
|
/// notes so fixture + test code can filter / branch by family.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="Family">OtOpcUa driver family this profile targets. Drives
|
/// <param name="Family">OtOpcUa driver family this profile targets.</param>
|
||||||
/// <see cref="AbCipDeviceOptions.PlcFamily"/> + driver-side connection-parameter profile
|
/// <param name="ComposeProfile">The <c>docker compose --profile</c> name that brings
|
||||||
/// (ConnectionSize, unconnected-only, etc.) per decision #9.</param>
|
/// this family's ab_server up. Matches the service key in the compose file.</param>
|
||||||
/// <param name="AbServerPlcArg">The value passed to <c>ab_server --plc <arg></c>. Some families
|
/// <param name="Notes">Operator-facing description of coverage + any quirks.</param>
|
||||||
/// 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>
|
|
||||||
public sealed record AbServerProfile(
|
public sealed record AbServerProfile(
|
||||||
AbCipPlcFamily Family,
|
AbCipPlcFamily Family,
|
||||||
string AbServerPlcArg,
|
string ComposeProfile,
|
||||||
IReadOnlyList<AbServerSeedTag> SeedTags,
|
|
||||||
string Notes)
|
string Notes)
|
||||||
{
|
{
|
||||||
/// <summary>Default port — every profile uses the same so parallel-runs-of-different-families
|
/// <summary>Default ab_server port — matches the compose-file port-map + the
|
||||||
/// would conflict (deliberately — one simulator per test collection is the model).</summary>
|
/// CIP / EtherNet/IP standard.</summary>
|
||||||
public const int DefaultPort = 44818;
|
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>
|
/// <summary>Canonical profiles covering every AB CIP family shipped in PRs 9–12.</summary>
|
||||||
public static class KnownProfiles
|
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(
|
public static readonly AbServerProfile ControlLogix = new(
|
||||||
Family: AbCipPlcFamily.ControlLogix,
|
Family: AbCipPlcFamily.ControlLogix,
|
||||||
AbServerPlcArg: "controllogix",
|
ComposeProfile: "controllogix",
|
||||||
SeedTags: new AbServerSeedTag[]
|
Notes: "Widest-coverage profile — PR 9 baseline. UDTs unit-tested via golden Template Object buffers; ab_server lacks full UDT emulation.");
|
||||||
{
|
|
||||||
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.");
|
|
||||||
|
|
||||||
/// <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(
|
public static readonly AbServerProfile CompactLogix = new(
|
||||||
Family: AbCipPlcFamily.CompactLogix,
|
Family: AbCipPlcFamily.CompactLogix,
|
||||||
AbServerPlcArg: "compactlogix",
|
ComposeProfile: "compactlogix",
|
||||||
SeedTags: new AbServerSeedTag[]
|
Notes: "ab_server doesn't enforce the narrower ConnectionSize; driver-side profile caps it per PR 10.");
|
||||||
{
|
|
||||||
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.");
|
|
||||||
|
|
||||||
/// <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(
|
public static readonly AbServerProfile Micro800 = new(
|
||||||
Family: AbCipPlcFamily.Micro800,
|
Family: AbCipPlcFamily.Micro800,
|
||||||
AbServerPlcArg: "controllogix", // ab_server lacks dedicated micro800 mode — see Notes
|
ComposeProfile: "micro800",
|
||||||
SeedTags: new AbServerSeedTag[]
|
Notes: "--plc=Micro800 mode (unconnected-only, empty path). Driver-side enforcement verified in the unit suite.");
|
||||||
{
|
|
||||||
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.");
|
|
||||||
|
|
||||||
/// <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(
|
public static readonly AbServerProfile GuardLogix = new(
|
||||||
Family: AbCipPlcFamily.GuardLogix,
|
Family: AbCipPlcFamily.GuardLogix,
|
||||||
AbServerPlcArg: "controllogix",
|
ComposeProfile: "guardlogix",
|
||||||
SeedTags: new AbServerSeedTag[]
|
Notes: "ab_server has no safety subsystem — _S-suffixed seed tag triggers driver-side ViewOnly classification only.");
|
||||||
{
|
|
||||||
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.");
|
|
||||||
|
|
||||||
public static IReadOnlyList<AbServerProfile> All { get; } =
|
public static IReadOnlyList<AbServerProfile> All { get; } =
|
||||||
new[] { ControlLogix, CompactLogix, Micro800, GuardLogix };
|
[ControlLogix, CompactLogix, Micro800, GuardLogix];
|
||||||
|
|
||||||
public static AbServerProfile ForFamily(AbCipPlcFamily family) =>
|
public static AbServerProfile ForFamily(AbCipPlcFamily family) =>
|
||||||
All.FirstOrDefault(p => p.Family == 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;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pure-unit tests for the profile → CLI arg composition. Runs without <c>ab_server</c>
|
/// Pure-unit tests for the profile catalog. Verifies <see cref="KnownProfiles"/>
|
||||||
/// on PATH so CI without the binary still exercises these contracts + catches any
|
/// stays in sync with <see cref="AbCipPlcFamily"/> + with the compose-file service
|
||||||
/// profile-definition drift (e.g. a typo in <c>--plc</c> mapping would silently make the
|
/// names — a typo in either would surface as a test failure rather than a silent
|
||||||
/// simulator boot with the wrong family).
|
/// "wrong family booted" at runtime. Runs without Docker, so CI without the
|
||||||
|
/// container still exercises these contracts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Trait("Category", "Unit")]
|
[Trait("Category", "Unit")]
|
||||||
public sealed class AbServerProfileTests
|
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]
|
[Theory]
|
||||||
[InlineData(AbCipPlcFamily.ControlLogix, "controllogix")]
|
[InlineData(AbCipPlcFamily.ControlLogix, "controllogix")]
|
||||||
[InlineData(AbCipPlcFamily.CompactLogix, "compactlogix")]
|
[InlineData(AbCipPlcFamily.CompactLogix, "compactlogix")]
|
||||||
[InlineData(AbCipPlcFamily.Micro800, "controllogix")] // falls back — ab_server lacks dedicated mode
|
[InlineData(AbCipPlcFamily.Micro800, "micro800")]
|
||||||
[InlineData(AbCipPlcFamily.GuardLogix, "controllogix")] // falls back — ab_server lacks safety subsystem
|
[InlineData(AbCipPlcFamily.GuardLogix, "guardlogix")]
|
||||||
public void KnownProfiles_ForFamily_Returns_Expected_AbServerPlcArg(AbCipPlcFamily family, string expected)
|
public void KnownProfiles_ForFamily_Returns_Expected_ComposeProfile(AbCipPlcFamily family, string expected)
|
||||||
{
|
{
|
||||||
KnownProfiles.ForFamily(family).AbServerPlcArg.ShouldBe(expected);
|
KnownProfiles.ForFamily(family).ComposeProfile.ShouldBe(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -71,20 +33,8 @@ public sealed class AbServerProfileTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void KnownProfiles_ControlLogix_Includes_AllAtomicTypes()
|
public void DefaultPort_Matches_EtherNetIP_Standard()
|
||||||
{
|
{
|
||||||
var tags = KnownProfiles.ControlLogix.SeedTags.Select(t => t.AbServerType).ToHashSet();
|
AbServerProfile.DefaultPort.ShouldBe(44818);
|
||||||
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.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"/>
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="Docker\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||||
|
<None Update="LogixProject\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class AbCipUdtReadPlannerTests
|
||||||
|
{
|
||||||
|
private const string Device = "ab://10.0.0.1/1,0";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Groups_Two_Members_Of_The_Same_Udt_Parent()
|
||||||
|
{
|
||||||
|
var tags = BuildUdtTagMap(out var _);
|
||||||
|
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque" }, tags);
|
||||||
|
|
||||||
|
plan.Groups.Count.ShouldBe(1);
|
||||||
|
plan.Groups[0].ParentName.ShouldBe("Motor");
|
||||||
|
plan.Groups[0].Members.Count.ShouldBe(2);
|
||||||
|
plan.Fallbacks.Count.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Single_Member_Reference_Falls_Back_To_Per_Tag_Path()
|
||||||
|
{
|
||||||
|
// Reading just one member of a UDT gains nothing from grouping — one whole-UDT read
|
||||||
|
// vs one member read is equivalent cost but more client-side work. Planner demotes.
|
||||||
|
var tags = BuildUdtTagMap(out var _);
|
||||||
|
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed" }, tags);
|
||||||
|
|
||||||
|
plan.Groups.ShouldBeEmpty();
|
||||||
|
plan.Fallbacks.Count.ShouldBe(1);
|
||||||
|
plan.Fallbacks[0].Reference.ShouldBe("Motor.Speed");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_References_Fall_Back_Without_Affecting_Groups()
|
||||||
|
{
|
||||||
|
var tags = BuildUdtTagMap(out var _);
|
||||||
|
var plan = AbCipUdtReadPlanner.Build(
|
||||||
|
new[] { "Motor.Speed", "Motor.Torque", "DoesNotExist", "Motor.NonMember" }, tags);
|
||||||
|
|
||||||
|
plan.Groups.Count.ShouldBe(1);
|
||||||
|
plan.Groups[0].Members.Count.ShouldBe(2);
|
||||||
|
plan.Fallbacks.Count.ShouldBe(2);
|
||||||
|
plan.Fallbacks.ShouldContain(f => f.Reference == "DoesNotExist");
|
||||||
|
plan.Fallbacks.ShouldContain(f => f.Reference == "Motor.NonMember");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Atomic_Top_Level_Tag_Falls_Back_Untouched()
|
||||||
|
{
|
||||||
|
var tags = BuildUdtTagMap(out var _);
|
||||||
|
tags = new Dictionary<string, AbCipTagDefinition>(tags, StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["PlainDint"] = new("PlainDint", Device, "PlainDint", AbCipDataType.DInt),
|
||||||
|
};
|
||||||
|
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque", "PlainDint" }, tags);
|
||||||
|
|
||||||
|
plan.Groups.Count.ShouldBe(1);
|
||||||
|
plan.Fallbacks.Count.ShouldBe(1);
|
||||||
|
plan.Fallbacks[0].Reference.ShouldBe("PlainDint");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Udt_With_Bool_Member_Does_Not_Group()
|
||||||
|
{
|
||||||
|
// Any BOOL in the declared members disqualifies the group — offset rules for BOOL
|
||||||
|
// can't be determined from declaration alone (Logix packs them into a hidden host
|
||||||
|
// byte). Fallback path reads each member individually.
|
||||||
|
var members = new[]
|
||||||
|
{
|
||||||
|
new AbCipStructureMember("Run", AbCipDataType.Bool),
|
||||||
|
new AbCipStructureMember("Speed", AbCipDataType.DInt),
|
||||||
|
};
|
||||||
|
var parent = new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure,
|
||||||
|
Members: members);
|
||||||
|
var tags = new Dictionary<string, AbCipTagDefinition>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["Motor"] = parent,
|
||||||
|
["Motor.Run"] = new("Motor.Run", Device, "Motor.Run", AbCipDataType.Bool),
|
||||||
|
["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt),
|
||||||
|
};
|
||||||
|
|
||||||
|
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Run", "Motor.Speed" }, tags);
|
||||||
|
|
||||||
|
plan.Groups.ShouldBeEmpty();
|
||||||
|
plan.Fallbacks.Count.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Original_Indices_Preserved_For_Out_Of_Order_Batches()
|
||||||
|
{
|
||||||
|
var tags = BuildUdtTagMap(out var _);
|
||||||
|
var plan = AbCipUdtReadPlanner.Build(
|
||||||
|
new[] { "Other", "Motor.Speed", "DoesNotExist", "Motor.Torque" }, tags);
|
||||||
|
|
||||||
|
// Motor.Speed was at index 1, Motor.Torque at 3 — must survive through the plan so
|
||||||
|
// ReadAsync can write decoded values back at the right output slot.
|
||||||
|
plan.Groups.ShouldHaveSingleItem();
|
||||||
|
var group = plan.Groups[0];
|
||||||
|
group.Members.ShouldContain(m => m.OriginalIndex == 1 && m.Definition.Name == "Motor.Speed");
|
||||||
|
group.Members.ShouldContain(m => m.OriginalIndex == 3 && m.Definition.Name == "Motor.Torque");
|
||||||
|
plan.Fallbacks.ShouldContain(f => f.OriginalIndex == 0 && f.Reference == "Other");
|
||||||
|
plan.Fallbacks.ShouldContain(f => f.OriginalIndex == 2 && f.Reference == "DoesNotExist");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, AbCipTagDefinition> BuildUdtTagMap(out AbCipTagDefinition parent)
|
||||||
|
{
|
||||||
|
var members = new[]
|
||||||
|
{
|
||||||
|
new AbCipStructureMember("Speed", AbCipDataType.DInt),
|
||||||
|
new AbCipStructureMember("Torque", AbCipDataType.Real),
|
||||||
|
};
|
||||||
|
parent = new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure, Members: members);
|
||||||
|
return new Dictionary<string, AbCipTagDefinition>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["Motor"] = parent,
|
||||||
|
["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt),
|
||||||
|
["Motor.Torque"] = new("Motor.Torque", Device, "Motor.Torque", AbCipDataType.Real),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,21 @@ internal class FakeAbCipTag : IAbCipTagRuntime
|
|||||||
|
|
||||||
public virtual object? DecodeValue(AbCipDataType type, int? bitIndex) => Value;
|
public virtual object? DecodeValue(AbCipDataType type, int? bitIndex) => Value;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task #194 whole-UDT read support. Tests drive multi-member decoding by setting
|
||||||
|
/// <see cref="ValuesByOffset"/> — keyed by member byte offset — before invoking
|
||||||
|
/// <see cref="AbCipDriver.ReadAsync"/>. Falls back to <see cref="Value"/> when the
|
||||||
|
/// offset is zero or unmapped so existing tests that never set the offset map keep
|
||||||
|
/// working unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<int, object?> ValuesByOffset { get; } = new();
|
||||||
|
|
||||||
|
public virtual object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex)
|
||||||
|
{
|
||||||
|
if (ValuesByOffset.TryGetValue(offset, out var v)) return v;
|
||||||
|
return offset == 0 ? Value : null;
|
||||||
|
}
|
||||||
|
|
||||||
public virtual void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value;
|
public virtual void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value;
|
||||||
|
|
||||||
public virtual void Dispose() => Disposed = true;
|
public virtual void Dispose() => Disposed = true;
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end smoke tests against the <c>ab_server</c> PCCC Docker container.
|
||||||
|
/// Promotes the AB Legacy driver from unit-only coverage (<c>FakeAbLegacyTag</c>)
|
||||||
|
/// to wire-level: real libplctag PCCC stack over real TCP against the ab_server
|
||||||
|
/// simulator. Parametrised over all three families (SLC 500 / MicroLogix / PLC-5)
|
||||||
|
/// via <c>[AbLegacyTheory]</c> + <c>[MemberData]</c>.
|
||||||
|
/// </summary>
|
||||||
|
[Collection(AbLegacyServerCollection.Name)]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
[Trait("Simulator", "ab_server-PCCC")]
|
||||||
|
public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim)
|
||||||
|
{
|
||||||
|
public static IEnumerable<object[]> Profiles =>
|
||||||
|
KnownProfiles.All.Select(p => new object[] { p });
|
||||||
|
|
||||||
|
[AbLegacyTheory]
|
||||||
|
[MemberData(nameof(Profiles))]
|
||||||
|
public async Task Driver_reads_seeded_N_file_from_ab_server_PCCC(AbLegacyServerProfile profile)
|
||||||
|
{
|
||||||
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||||
|
|
||||||
|
// PCCC PLCs use empty cip-path, but AbLegacyHostAddress still requires the
|
||||||
|
// /cip-path suffix to parse.
|
||||||
|
var deviceUri = $"ab://{sim.Host}:{sim.Port}/";
|
||||||
|
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbLegacyDeviceOptions(deviceUri, profile.Family)],
|
||||||
|
Tags = [
|
||||||
|
new AbLegacyTagDefinition(
|
||||||
|
Name: "IntCounter",
|
||||||
|
DeviceHostAddress: deviceUri,
|
||||||
|
Address: "N7:0",
|
||||||
|
DataType: AbLegacyDataType.Int),
|
||||||
|
],
|
||||||
|
Timeout = TimeSpan.FromSeconds(5),
|
||||||
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||||
|
}, driverInstanceId: $"ablegacy-smoke-{profile.Family}");
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var snapshots = await drv.ReadAsync(
|
||||||
|
["IntCounter"], TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good,
|
||||||
|
$"N7:0 read must succeed against the {profile.Family} compose profile");
|
||||||
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[AbLegacyFact]
|
||||||
|
public async Task Slc500_write_then_read_round_trip_on_N7_scratch_register()
|
||||||
|
{
|
||||||
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||||
|
|
||||||
|
// PCCC PLCs use empty cip-path, but AbLegacyHostAddress still requires the
|
||||||
|
// /cip-path suffix to parse.
|
||||||
|
var deviceUri = $"ab://{sim.Host}:{sim.Port}/";
|
||||||
|
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbLegacyDeviceOptions(deviceUri, AbLegacyPlcFamily.Slc500)],
|
||||||
|
Tags = [
|
||||||
|
new AbLegacyTagDefinition(
|
||||||
|
Name: "Scratch",
|
||||||
|
DeviceHostAddress: deviceUri,
|
||||||
|
Address: "N7:5",
|
||||||
|
DataType: AbLegacyDataType.Int,
|
||||||
|
Writable: true),
|
||||||
|
],
|
||||||
|
Timeout = TimeSpan.FromSeconds(5),
|
||||||
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||||
|
}, driverInstanceId: "ablegacy-smoke-rw");
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
const short probe = 0x1234;
|
||||||
|
var writeResults = await drv.WriteAsync(
|
||||||
|
[new WriteRequest("Scratch", probe)],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
writeResults.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good,
|
||||||
|
"PCCC N7:5 write must succeed end-to-end");
|
||||||
|
|
||||||
|
var readResults = await drv.ReadAsync(
|
||||||
|
["Scratch"], TestContext.Current.CancellationToken);
|
||||||
|
readResults.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||||
|
Convert.ToInt32(readResults.Single().Value).ShouldBe(probe);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
using System.Net.Sockets;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Sdk;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reachability probe for the <c>ab_server</c> Docker container running in a PCCC
|
||||||
|
/// plc mode (<c>SLC500</c> / <c>Micrologix</c> / <c>PLC/5</c>). Same container image
|
||||||
|
/// the AB CIP integration suite uses — libplctag's <c>ab_server</c> supports both
|
||||||
|
/// CIP + PCCC families from one binary. Tests skip via
|
||||||
|
/// <see cref="AbLegacyFactAttribute"/> / <see cref="AbLegacyTheoryAttribute"/> when
|
||||||
|
/// the port isn't live, so <c>dotnet test</c> stays green on a fresh clone without
|
||||||
|
/// Docker running.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Env-var overrides:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>AB_LEGACY_ENDPOINT</c> — <c>host:port</c> of the PCCC-mode simulator.
|
||||||
|
/// Defaults to <c>localhost:44818</c> (EtherNet/IP port; ab_server's PCCC
|
||||||
|
/// emulation exposes PCCC-over-CIP on the same port as CIP itself).</item>
|
||||||
|
/// </list>
|
||||||
|
/// Distinct from <c>AB_SERVER_ENDPOINT</c> used by the AB CIP fixture so both
|
||||||
|
/// can point at different containers simultaneously during a combined test run.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class AbLegacyServerFixture : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string EndpointEnvVar = "AB_LEGACY_ENDPOINT";
|
||||||
|
|
||||||
|
/// <summary>Standard EtherNet/IP port. PCCC-over-CIP rides on the same port as
|
||||||
|
/// native CIP; the differentiator is the <c>--plc</c> flag ab_server was started
|
||||||
|
/// with, not a different TCP listener.</summary>
|
||||||
|
public const int DefaultPort = 44818;
|
||||||
|
|
||||||
|
public string Host { get; } = "127.0.0.1";
|
||||||
|
public int Port { get; } = DefaultPort;
|
||||||
|
public string? SkipReason { get; }
|
||||||
|
|
||||||
|
public AbLegacyServerFixture()
|
||||||
|
{
|
||||||
|
if (Environment.GetEnvironmentVariable(EndpointEnvVar) is { Length: > 0 } raw)
|
||||||
|
{
|
||||||
|
var parts = raw.Split(':', 2);
|
||||||
|
Host = parts[0];
|
||||||
|
if (parts.Length == 2 && int.TryParse(parts[1], out var p)) Port = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TcpProbe(Host, Port))
|
||||||
|
{
|
||||||
|
SkipReason =
|
||||||
|
$"AB Legacy PCCC simulator at {Host}:{Port} not reachable within 2 s. " +
|
||||||
|
$"Start the Docker container (docker compose -f Docker/docker-compose.yml " +
|
||||||
|
$"--profile slc500 up -d) or override {EndpointEnvVar}.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
|
||||||
|
public static bool IsServerAvailable()
|
||||||
|
{
|
||||||
|
var (host, port) = ResolveEndpoint();
|
||||||
|
return TcpProbe(host, port);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string Host, int Port) ResolveEndpoint()
|
||||||
|
{
|
||||||
|
var raw = Environment.GetEnvironmentVariable(EndpointEnvVar);
|
||||||
|
if (raw is null) return ("127.0.0.1", DefaultPort);
|
||||||
|
var parts = raw.Split(':', 2);
|
||||||
|
var port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : DefaultPort;
|
||||||
|
return (parts[0], port);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TcpProbe(string host, int port)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new TcpClient();
|
||||||
|
var task = client.ConnectAsync(host, port);
|
||||||
|
return task.Wait(TimeSpan.FromSeconds(2)) && client.Connected;
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-family marker for the PCCC-mode compose profile a given test targets. The
|
||||||
|
/// compose file (<c>Docker/docker-compose.yml</c>) is the canonical source of truth
|
||||||
|
/// for which <c>--plc</c> mode + tags each family seeds; this record just ties a
|
||||||
|
/// family enum to its compose-profile name + operator-facing notes.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AbLegacyServerProfile(
|
||||||
|
AbLegacyPlcFamily Family,
|
||||||
|
string ComposeProfile,
|
||||||
|
string Notes);
|
||||||
|
|
||||||
|
/// <summary>Canonical profiles covering every PCCC family the driver supports.</summary>
|
||||||
|
public static class KnownProfiles
|
||||||
|
{
|
||||||
|
public static readonly AbLegacyServerProfile Slc500 = new(
|
||||||
|
Family: AbLegacyPlcFamily.Slc500,
|
||||||
|
ComposeProfile: "slc500",
|
||||||
|
Notes: "SLC 500 / 5/05 family. ab_server SLC500 mode covers N/F/B/L files.");
|
||||||
|
|
||||||
|
public static readonly AbLegacyServerProfile MicroLogix = new(
|
||||||
|
Family: AbLegacyPlcFamily.MicroLogix,
|
||||||
|
ComposeProfile: "micrologix",
|
||||||
|
Notes: "MicroLogix 1000 / 1100 / 1400. Shares N/F/B file-type coverage with SLC500; ST (ASCII strings) included.");
|
||||||
|
|
||||||
|
public static readonly AbLegacyServerProfile Plc5 = new(
|
||||||
|
Family: AbLegacyPlcFamily.Plc5,
|
||||||
|
ComposeProfile: "plc5",
|
||||||
|
Notes: "PLC-5 family. ab_server PLC/5 mode covers N/F/B; per-family quirks on ST / timer file layouts unit-tested only.");
|
||||||
|
|
||||||
|
public static IReadOnlyList<AbLegacyServerProfile> All { get; } =
|
||||||
|
[Slc500, MicroLogix, Plc5];
|
||||||
|
|
||||||
|
public static AbLegacyServerProfile ForFamily(AbLegacyPlcFamily family) =>
|
||||||
|
All.FirstOrDefault(p => p.Family == family)
|
||||||
|
?? throw new ArgumentOutOfRangeException(nameof(family), family, "No integration profile for this family.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Xunit.CollectionDefinition(Name)]
|
||||||
|
public sealed class AbLegacyServerCollection : Xunit.ICollectionFixture<AbLegacyServerFixture>
|
||||||
|
{
|
||||||
|
public const string Name = "AbLegacyServer";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>[Fact]</c>-equivalent that skips when the PCCC simulator isn't reachable.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AbLegacyFactAttribute : FactAttribute
|
||||||
|
{
|
||||||
|
public AbLegacyFactAttribute()
|
||||||
|
{
|
||||||
|
if (!AbLegacyServerFixture.IsServerAvailable())
|
||||||
|
Skip = "AB Legacy PCCC simulator not reachable. Start the Docker container " +
|
||||||
|
"(docker compose -f Docker/docker-compose.yml --profile slc500 up -d) " +
|
||||||
|
"or set AB_LEGACY_ENDPOINT.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>[Theory]</c>-equivalent with the same gate as <see cref="AbLegacyFactAttribute"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AbLegacyTheoryAttribute : TheoryAttribute
|
||||||
|
{
|
||||||
|
public AbLegacyTheoryAttribute()
|
||||||
|
{
|
||||||
|
if (!AbLegacyServerFixture.IsServerAvailable())
|
||||||
|
Skip = "AB Legacy PCCC simulator not reachable. Start the Docker container " +
|
||||||
|
"(docker compose -f Docker/docker-compose.yml --profile slc500 up -d) " +
|
||||||
|
"or set AB_LEGACY_ENDPOINT.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# AB Legacy PCCC integration-test fixture — `ab_server` (Docker)
|
||||||
|
|
||||||
|
[libplctag](https://github.com/libplctag/libplctag)'s `ab_server` supports
|
||||||
|
both CIP (ControlLogix / CompactLogix / Micro800) and PCCC (SLC 500 /
|
||||||
|
MicroLogix / PLC-5) families from one binary. This fixture reuses the AB
|
||||||
|
CIP Docker image (`otopcua-ab-server:libplctag-release`) with different
|
||||||
|
`--plc` flags. No new Dockerfile needed — the compose file's `build:`
|
||||||
|
block points at the AB CIP `Docker/` folder so `docker compose build`
|
||||||
|
from here reuses the same multi-stage build.
|
||||||
|
|
||||||
|
**Docker is the only supported launch path**; a fresh clone needs Docker
|
||||||
|
Desktop and nothing else.
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| [`docker-compose.yml`](docker-compose.yml) | Three per-family services (`slc500` / `micrologix` / `plc5`); all bind `:44818` |
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
From the repo root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# SLC 500 family — widest PCCC coverage
|
||||||
|
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests\Docker\docker-compose.yml --profile slc500 up
|
||||||
|
|
||||||
|
# Per-family
|
||||||
|
docker compose -f tests\...\Docker\docker-compose.yml --profile micrologix up
|
||||||
|
docker compose -f tests\...\Docker\docker-compose.yml --profile plc5 up
|
||||||
|
```
|
||||||
|
|
||||||
|
Detached + stop:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose -f tests\...\Docker\docker-compose.yml --profile slc500 up -d
|
||||||
|
docker compose -f tests\...\Docker\docker-compose.yml --profile slc500 down
|
||||||
|
```
|
||||||
|
|
||||||
|
First run builds the `otopcua-ab-server:libplctag-release` image (~3-5
|
||||||
|
min — clones libplctag + compiles `ab_server`). If the AB CIP fixture
|
||||||
|
already built the image locally, docker reuses the cached layers + this
|
||||||
|
runs in seconds. Only one family binds `:44818` at a time; to switch
|
||||||
|
families stop the current service + start another.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
- Default: `localhost:44818` (EtherNet/IP standard)
|
||||||
|
- Override with `AB_LEGACY_ENDPOINT=host:port` to point at a real SLC /
|
||||||
|
MicroLogix / PLC-5 PLC on its native port.
|
||||||
|
|
||||||
|
## 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.AbLegacy.IntegrationTests
|
||||||
|
```
|
||||||
|
|
||||||
|
`AbLegacyServerFixture` TCP-probes `localhost:44818` at collection init +
|
||||||
|
records a skip reason when unreachable. Tests use `[AbLegacyFact]` /
|
||||||
|
`[AbLegacyTheory]` which check the same probe.
|
||||||
|
|
||||||
|
## What each family seeds
|
||||||
|
|
||||||
|
PCCC tag format is `<file>[<size>]` without a type suffix — file letter
|
||||||
|
implies type:
|
||||||
|
|
||||||
|
- `N` = 16-bit signed integer
|
||||||
|
- `F` = 32-bit IEEE 754 float
|
||||||
|
- `B` = 1-bit boolean (stored as uint16, bit-addressable via `/n`)
|
||||||
|
- `L` = 32-bit signed integer (SLC 5/05 V15+ only)
|
||||||
|
- `ST` = 82-byte ASCII string (MicroLogix-specific extension)
|
||||||
|
|
||||||
|
| Family | Seeded tags | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| SLC 500 | `N7[10]`, `F8[10]`, `B3[10]`, `L19[10]` | Baseline; covers the four numeric file types a typical SLC project uses |
|
||||||
|
| MicroLogix | `B3[10]`, `N7[10]`, `L19[10]` | No `F8` — MicroLogix 1000 has no float file; use L19 when scaled integers aren't enough |
|
||||||
|
| PLC-5 | `N7[10]`, `F8[10]`, `B3[10]` | No `L` — PLC-5 predates the L file type; DINT equivalents went in integer files |
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
### ab_server PCCC read/write round-trip (verified 2026-04-20)
|
||||||
|
|
||||||
|
**Scaffold is in place; wire-level round-trip does NOT currently pass
|
||||||
|
against `ab_server --plc=SLC500`.** With the SLC500 compose profile up,
|
||||||
|
TCP 44818 accepts connections and libplctag negotiates the session,
|
||||||
|
but the three smoke tests in `AbLegacyReadSmokeTests.cs` all fail at
|
||||||
|
read/write with `BadCommunicationError` (libplctag status `0x80050000`).
|
||||||
|
Possible root causes:
|
||||||
|
|
||||||
|
- ab_server's PCCC server-side opcode coverage may be narrower than
|
||||||
|
libplctag's PCCC client expects — the tool is primarily a CIP
|
||||||
|
server; PCCC was added later + is noted in libplctag docs as less
|
||||||
|
mature.
|
||||||
|
- libplctag's PCCC-over-CIP encapsulation may assume a real SLC 5/05
|
||||||
|
EtherNet/IP NIC's framing that ab_server doesn't emit.
|
||||||
|
|
||||||
|
The scaffold ships **as-is** because:
|
||||||
|
|
||||||
|
1. The Docker infrastructure + fixture pattern works cleanly (probe
|
||||||
|
passes, container lifecycle is clean, tests skip when absent).
|
||||||
|
2. The test classes target the correct shape for what the AB Legacy
|
||||||
|
driver would do against real hardware.
|
||||||
|
3. Pointing `AB_LEGACY_ENDPOINT` at a real SLC 5/05 / MicroLogix
|
||||||
|
1100 / 1400 should make the tests pass outright — the failure
|
||||||
|
mode is ab_server-specific, not driver-specific.
|
||||||
|
|
||||||
|
Resolution paths (pick one):
|
||||||
|
|
||||||
|
1. **File an ab_server bug** in `libplctag/libplctag` to expand PCCC
|
||||||
|
server-side coverage.
|
||||||
|
2. **Golden-box tier** via Rockwell RSEmulate 500 — closer to real
|
||||||
|
firmware, but license-gated + RSLinx-dependent.
|
||||||
|
3. **Lab rig** — used SLC 5/05 / MicroLogix 1100 on a dedicated
|
||||||
|
network; the authoritative path.
|
||||||
|
|
||||||
|
### Other known gaps (unchanged from ab_server)
|
||||||
|
|
||||||
|
- **Timer / Counter file decomposition** — PCCC T4 / C5 files contain
|
||||||
|
three-field structs (`.ACC` / `.PRE` / `.DN`). Not in ab_server's
|
||||||
|
scope; tests targeting `T4:0.ACC` stay unit-only.
|
||||||
|
- **ST (ASCII string) files** — real MicroLogix ST files have a length
|
||||||
|
field plus CRLF-sensitive semantics that don't round-trip cleanly.
|
||||||
|
- **Indirect addressing** (`N7:[N10:5]`) — not in ab_server's scope.
|
||||||
|
- **DF1 serial wire behaviour** — the whole ab_server path is TCP;
|
||||||
|
DF1 radio / serial fidelity needs real hardware.
|
||||||
|
|
||||||
|
See [`docs/drivers/AbLegacy-Test-Fixture.md`](../../../docs/drivers/AbLegacy-Test-Fixture.md)
|
||||||
|
for the full coverage map.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [libplctag on GitHub](https://github.com/libplctag/libplctag) — `ab_server`
|
||||||
|
lives under `src/tools/ab_server/`
|
||||||
|
- [`docs/drivers/AbLegacy-Test-Fixture.md`](../../../docs/drivers/AbLegacy-Test-Fixture.md)
|
||||||
|
— coverage map + gap inventory
|
||||||
|
- [`docs/v2/dev-environment.md`](../../../docs/v2/dev-environment.md)
|
||||||
|
§Docker fixtures — full fixture inventory
|
||||||
|
- [`../../ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/`](../../ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/)
|
||||||
|
— the shared Dockerfile this compose file's `build:` block references
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# AB Legacy PCCC integration-test fixture — ab_server in PCCC mode.
|
||||||
|
#
|
||||||
|
# Same image as the AB CIP fixture (otopcua-ab-server:libplctag-release).
|
||||||
|
# The build context points at the AB CIP Docker folder one directory over
|
||||||
|
# so `docker compose build` from here produces the same image if it
|
||||||
|
# doesn't already exist; if it does, docker's cache reuses the layer.
|
||||||
|
#
|
||||||
|
# One service per PCCC family. All bind :44818 on the host; run one at a
|
||||||
|
# time. PCCC tag format differs from CIP: `<file>[<size>]` without a
|
||||||
|
# type suffix since the type is implicit in the file letter (N = INT,
|
||||||
|
# F = REAL, B = bit-packed, L = DINT).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose --profile slc500 up
|
||||||
|
# docker compose --profile micrologix up
|
||||||
|
# docker compose --profile plc5 up
|
||||||
|
services:
|
||||||
|
slc500:
|
||||||
|
profiles: ["slc500"]
|
||||||
|
build:
|
||||||
|
context: ../../ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: otopcua-ab-server:libplctag-release
|
||||||
|
container_name: otopcua-ab-server-slc500
|
||||||
|
restart: "no"
|
||||||
|
ports:
|
||||||
|
- "44818:44818"
|
||||||
|
command: [
|
||||||
|
"ab_server",
|
||||||
|
"--plc=SLC500",
|
||||||
|
"--port=44818",
|
||||||
|
"--tag=N7[10]",
|
||||||
|
"--tag=F8[10]",
|
||||||
|
"--tag=B3[10]",
|
||||||
|
"--tag=L19[10]"
|
||||||
|
]
|
||||||
|
|
||||||
|
micrologix:
|
||||||
|
profiles: ["micrologix"]
|
||||||
|
image: otopcua-ab-server:libplctag-release
|
||||||
|
build:
|
||||||
|
context: ../../ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: otopcua-ab-server-micrologix
|
||||||
|
restart: "no"
|
||||||
|
ports:
|
||||||
|
- "44818:44818"
|
||||||
|
command: [
|
||||||
|
"ab_server",
|
||||||
|
"--plc=Micrologix",
|
||||||
|
"--port=44818",
|
||||||
|
"--tag=B3[10]",
|
||||||
|
"--tag=N7[10]",
|
||||||
|
"--tag=L19[10]"
|
||||||
|
]
|
||||||
|
|
||||||
|
plc5:
|
||||||
|
profiles: ["plc5"]
|
||||||
|
image: otopcua-ab-server:libplctag-release
|
||||||
|
build:
|
||||||
|
context: ../../ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: otopcua-ab-server-plc5
|
||||||
|
restart: "no"
|
||||||
|
ports:
|
||||||
|
- "44818:44818"
|
||||||
|
command: [
|
||||||
|
"ab_server",
|
||||||
|
"--plc=PLC/5",
|
||||||
|
"--port=44818",
|
||||||
|
"--tag=N7[10]",
|
||||||
|
"--tag=F8[10]",
|
||||||
|
"--tag=B3[10]"
|
||||||
|
]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="Docker\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||||
|
</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,156 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Version-matrix coverage for <see cref="FocasCapabilityMatrix"/>. Encodes the
|
||||||
|
/// documented Fanuc FOCAS Developer Kit support boundaries per CNC series so a
|
||||||
|
/// config-time change that widens or narrows a range without updating
|
||||||
|
/// <c>docs/v2/focas-version-matrix.md</c> fails a test. Every assertion cites the
|
||||||
|
/// specific matrix row it reflects.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class FocasCapabilityMatrixTests
|
||||||
|
{
|
||||||
|
// ---- Macro ranges ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(FocasCncSeries.Sixteen_i, 999, true)]
|
||||||
|
[InlineData(FocasCncSeries.Sixteen_i, 1000, false)] // above legacy ceiling
|
||||||
|
[InlineData(FocasCncSeries.Zero_i_D, 999, true)]
|
||||||
|
[InlineData(FocasCncSeries.Zero_i_D, 9999, false)] // 0i-D is still legacy-ceiling
|
||||||
|
[InlineData(FocasCncSeries.Zero_i_F, 9999, true)] // widened on 0i-F
|
||||||
|
[InlineData(FocasCncSeries.Zero_i_F, 10000, false)]
|
||||||
|
[InlineData(FocasCncSeries.Thirty_i, 99999, true)] // highest-end
|
||||||
|
[InlineData(FocasCncSeries.Thirty_i, 100000, false)]
|
||||||
|
[InlineData(FocasCncSeries.PowerMotion_i, 999, true)]
|
||||||
|
[InlineData(FocasCncSeries.PowerMotion_i, 1000, false)] // atypical coverage
|
||||||
|
public void Macro_range_matches_series(FocasCncSeries series, int number, bool accepted)
|
||||||
|
{
|
||||||
|
var address = new FocasAddress(FocasAreaKind.Macro, null, number, null);
|
||||||
|
var result = FocasCapabilityMatrix.Validate(series, address);
|
||||||
|
(result is null).ShouldBe(accepted,
|
||||||
|
$"Macro #{number} on {series}: expected {(accepted ? "accept" : "reject")}, got {(result ?? "accept")}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Parameter ranges ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(FocasCncSeries.Sixteen_i, 9999, true)]
|
||||||
|
[InlineData(FocasCncSeries.Sixteen_i, 10000, false)] // 16i capped at 9999
|
||||||
|
[InlineData(FocasCncSeries.Zero_i_F, 14999, true)]
|
||||||
|
[InlineData(FocasCncSeries.Zero_i_F, 15000, false)]
|
||||||
|
[InlineData(FocasCncSeries.Thirty_i, 29999, true)]
|
||||||
|
[InlineData(FocasCncSeries.Thirty_i, 30000, false)]
|
||||||
|
public void Parameter_range_matches_series(FocasCncSeries series, int number, bool accepted)
|
||||||
|
{
|
||||||
|
var address = new FocasAddress(FocasAreaKind.Parameter, null, number, null);
|
||||||
|
var result = FocasCapabilityMatrix.Validate(series, address);
|
||||||
|
(result is null).ShouldBe(accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PMC letters ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(FocasCncSeries.Sixteen_i, "X", true)]
|
||||||
|
[InlineData(FocasCncSeries.Sixteen_i, "Y", true)]
|
||||||
|
[InlineData(FocasCncSeries.Sixteen_i, "R", true)]
|
||||||
|
[InlineData(FocasCncSeries.Sixteen_i, "F", false)] // 16i has no F/G signal groups
|
||||||
|
[InlineData(FocasCncSeries.Sixteen_i, "G", false)]
|
||||||
|
[InlineData(FocasCncSeries.Sixteen_i, "K", false)]
|
||||||
|
[InlineData(FocasCncSeries.Zero_i_D, "E", true)] // widened since 0i-D
|
||||||
|
[InlineData(FocasCncSeries.Zero_i_D, "F", false)] // still no F on 0i-D
|
||||||
|
[InlineData(FocasCncSeries.Zero_i_F, "F", true)] // F/G added on 0i-F
|
||||||
|
[InlineData(FocasCncSeries.Zero_i_F, "K", false)] // K/T still 30i-only
|
||||||
|
[InlineData(FocasCncSeries.Thirty_i, "K", true)]
|
||||||
|
[InlineData(FocasCncSeries.Thirty_i, "T", true)]
|
||||||
|
[InlineData(FocasCncSeries.Thirty_i, "Q", false)] // unsupported even on 30i
|
||||||
|
public void Pmc_letter_matches_series(FocasCncSeries series, string letter, bool accepted)
|
||||||
|
{
|
||||||
|
var address = new FocasAddress(FocasAreaKind.Pmc, letter, 0, null);
|
||||||
|
var result = FocasCapabilityMatrix.Validate(series, address);
|
||||||
|
(result is null).ShouldBe(accepted,
|
||||||
|
$"PMC letter '{letter}' on {series}: expected {(accepted ? "accept" : "reject")}, got {(result ?? "accept")}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PMC number ceiling ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(FocasCncSeries.Sixteen_i, "R", 999, true)]
|
||||||
|
[InlineData(FocasCncSeries.Sixteen_i, "R", 1000, false)]
|
||||||
|
[InlineData(FocasCncSeries.Zero_i_D, "R", 1999, true)]
|
||||||
|
[InlineData(FocasCncSeries.Zero_i_D, "R", 2000, false)]
|
||||||
|
[InlineData(FocasCncSeries.Zero_i_F, "R", 9999, true)]
|
||||||
|
[InlineData(FocasCncSeries.Zero_i_F, "R", 10000, false)]
|
||||||
|
[InlineData(FocasCncSeries.Thirty_i, "R", 59999, true)]
|
||||||
|
[InlineData(FocasCncSeries.Thirty_i, "R", 60000, false)]
|
||||||
|
public void Pmc_number_ceiling_matches_series(FocasCncSeries series, string letter, int number, bool accepted)
|
||||||
|
{
|
||||||
|
var address = new FocasAddress(FocasAreaKind.Pmc, letter, number, null);
|
||||||
|
var result = FocasCapabilityMatrix.Validate(series, address);
|
||||||
|
(result is null).ShouldBe(accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Unknown series is permissive ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Z", 999_999)] // absurd PMC address
|
||||||
|
[InlineData("Q", 0)] // non-existent letter
|
||||||
|
public void Unknown_series_accepts_any_PMC(string letter, int number)
|
||||||
|
{
|
||||||
|
var address = new FocasAddress(FocasAreaKind.Pmc, letter, number, null);
|
||||||
|
FocasCapabilityMatrix.Validate(FocasCncSeries.Unknown, address).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_series_accepts_any_macro_number()
|
||||||
|
{
|
||||||
|
var address = new FocasAddress(FocasAreaKind.Macro, null, 999_999, null);
|
||||||
|
FocasCapabilityMatrix.Validate(FocasCncSeries.Unknown, address).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_series_accepts_any_parameter_number()
|
||||||
|
{
|
||||||
|
var address = new FocasAddress(FocasAreaKind.Parameter, null, 999_999, null);
|
||||||
|
FocasCapabilityMatrix.Validate(FocasCncSeries.Unknown, address).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Reason messages include enough context to diagnose ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rejection_message_names_series_and_limit()
|
||||||
|
{
|
||||||
|
var address = new FocasAddress(FocasAreaKind.Macro, null, 100_000, null);
|
||||||
|
var reason = FocasCapabilityMatrix.Validate(FocasCncSeries.Zero_i_F, address);
|
||||||
|
reason.ShouldNotBeNull();
|
||||||
|
reason.ShouldContain("100000");
|
||||||
|
reason.ShouldContain("Zero_i_F");
|
||||||
|
reason.ShouldContain("9999");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pmc_rejection_lists_accepted_letters()
|
||||||
|
{
|
||||||
|
var address = new FocasAddress(FocasAreaKind.Pmc, "Q", 0, null);
|
||||||
|
var reason = FocasCapabilityMatrix.Validate(FocasCncSeries.Thirty_i, address);
|
||||||
|
reason.ShouldNotBeNull();
|
||||||
|
reason.ShouldContain("'Q'");
|
||||||
|
reason.ShouldContain("X"); // some accepted letter should appear
|
||||||
|
reason.ShouldContain("Y");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PMC address letter is case-insensitive ----
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("x")]
|
||||||
|
[InlineData("X")]
|
||||||
|
[InlineData("f")]
|
||||||
|
public void Pmc_letter_match_is_case_insensitive_on_30i(string letter)
|
||||||
|
{
|
||||||
|
var address = new FocasAddress(FocasAreaKind.Pmc, letter, 0, null);
|
||||||
|
FocasCapabilityMatrix.Validate(FocasCncSeries.Thirty_i, address).ShouldBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tag map for the AutomationDirect DL205 device class. Mirrors what the pymodbus
|
/// Tag map for the AutomationDirect DL205 device class. Mirrors what the pymodbus
|
||||||
/// <c>dl205.json</c> profile in <c>Pymodbus/dl205.json</c> exposes (or the real PLC, when
|
/// <c>dl205.json</c> profile in <c>Docker/profiles/dl205.json</c> exposes (or the real PLC, when
|
||||||
/// <see cref="ModbusSimulatorFixture"/> is pointed at one).
|
/// <see cref="ModbusSimulatorFixture"/> is pointed at one).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -16,8 +16,8 @@ public static class DL205Profile
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Holding register the smoke test writes + reads. Address 200 is the first cell of the
|
/// Holding register the smoke test writes + reads. Address 200 is the first cell of the
|
||||||
/// scratch HR range in both <c>Pymodbus/standard.json</c> (HR[200..209] = 0) and
|
/// scratch HR range in both <c>Docker/profiles/standard.json</c> (HR[200..209] = 0) and
|
||||||
/// <c>Pymodbus/dl205.json</c> (HR[4096..4103] added in PR 43 for the same purpose), so
|
/// <c>Docker/profiles/dl205.json</c> (HR[4096..4103] added in PR 43 for the same purpose), so
|
||||||
/// the smoke test runs identically against either simulator profile. Originally
|
/// the smoke test runs identically against either simulator profile. Originally
|
||||||
/// targeted HR[100] — moved to HR[200] when the standard profile claimed HR[100] as
|
/// targeted HR[100] — moved to HR[200] when the standard profile claimed HR[100] as
|
||||||
/// the auto-incrementing register that drives subscribe-and-receive tests.
|
/// the auto-incrementing register that drives subscribe-and-receive tests.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Requires the dl205 profile (<c>Pymodbus\serve.ps1 -Profile dl205</c>). The standard
|
/// Requires the dl205 profile (<c>docker compose -f Docker/docker-compose.yml --profile dl205 up</c>). The standard
|
||||||
/// profile does not seed HR[1040..1042] with string bytes, so running this against the
|
/// profile does not seed HR[1040..1042] with string bytes, so running this against the
|
||||||
/// standard profile returns <c>"\0\0\0\0\0"</c> and the test fails. Skip when the env
|
/// standard profile returns <c>"\0\0\0\0\0"</c> and the test fails. Skip when the env
|
||||||
/// var <c>MODBUS_SIM_PROFILE</c> is not set to <c>dl205</c>.
|
/// var <c>MODBUS_SIM_PROFILE</c> is not set to <c>dl205</c>.
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# pymodbus simulator container for the Modbus integration suite.
|
||||||
|
#
|
||||||
|
# Pinned base + package version so the fixture surface is reproducible —
|
||||||
|
# matches the version referenced in docs/drivers/Modbus-Test-Fixture.md.
|
||||||
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.source="https://github.com/dohertj2/lmxopcua" \
|
||||||
|
org.opencontainers.image.description="pymodbus simulator for OtOpcUa Modbus driver integration tests"
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir "pymodbus[simulator]==3.13.0"
|
||||||
|
|
||||||
|
# Ship every profile in the image so one container can serve whichever
|
||||||
|
# family a test run needs; the compose file picks which JSON is active via
|
||||||
|
# the command override.
|
||||||
|
WORKDIR /fixtures
|
||||||
|
COPY profiles/ /fixtures/
|
||||||
|
|
||||||
|
EXPOSE 5020
|
||||||
|
|
||||||
|
# Default to the standard profile; docker-compose.yml overrides per service.
|
||||||
|
# --http_port intentionally omitted; pymodbus 3.13's web UI binds on a
|
||||||
|
# container-local default we don't publish, so it's not reachable from the
|
||||||
|
# host and costs nothing.
|
||||||
|
CMD ["pymodbus.simulator", \
|
||||||
|
"--modbus_server", "srv", \
|
||||||
|
"--modbus_device", "dev", \
|
||||||
|
"--json_file", "/fixtures/standard.json"]
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Modbus integration-test fixture — pymodbus simulator
|
||||||
|
|
||||||
|
The Modbus driver's integration tests talk to a
|
||||||
|
[`pymodbus`](https://pymodbus.readthedocs.io/) simulator running as a
|
||||||
|
pinned Docker container. One image, per-profile service in compose, same
|
||||||
|
port binding (`5020`) regardless of which profile is live. Docker is the
|
||||||
|
only supported launch path — a fresh clone needs Docker Desktop and
|
||||||
|
nothing else.
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| [`Dockerfile`](Dockerfile) | `python:3.12-slim-bookworm` + `pymodbus[simulator]==3.13.0` + the four profile JSONs |
|
||||||
|
| [`docker-compose.yml`](docker-compose.yml) | One service per profile (`standard` / `dl205` / `mitsubishi` / `s7_1500`); all bind `:5020` so only one runs at a time |
|
||||||
|
| [`profiles/*.json`](profiles/) | Same seed-register definitions the native launcher uses — canonical source |
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
From the repo root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Build + start the standard profile
|
||||||
|
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile standard up
|
||||||
|
|
||||||
|
# DL205 quirks
|
||||||
|
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile dl205 up
|
||||||
|
|
||||||
|
# Mitsubishi MELSEC quirks
|
||||||
|
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile mitsubishi up
|
||||||
|
|
||||||
|
# Siemens S7-1500 MB_SERVER quirks
|
||||||
|
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile s7_1500 up
|
||||||
|
```
|
||||||
|
|
||||||
|
Detached + stop:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 up -d
|
||||||
|
docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 down
|
||||||
|
```
|
||||||
|
|
||||||
|
Only one profile binds `:5020` at a time; switch by stopping the current
|
||||||
|
service + starting another. The integration tests discriminate by a
|
||||||
|
separate `MODBUS_SIM_PROFILE` env var so they skip correctly when the
|
||||||
|
wrong profile is live.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
- Default: `localhost:5020`
|
||||||
|
- Override with `MODBUS_SIM_ENDPOINT` (e.g. a real PLC on `:502`).
|
||||||
|
|
||||||
|
## Run the integration tests
|
||||||
|
|
||||||
|
In a separate shell with one profile live:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\dohertj2\Desktop\lmxopcua
|
||||||
|
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests
|
||||||
|
```
|
||||||
|
|
||||||
|
`ModbusSimulatorFixture` probes `localhost:5020` at collection init +
|
||||||
|
records a `SkipReason` when unreachable, so tests stay green on a fresh
|
||||||
|
clone without Docker running.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [`docs/drivers/Modbus-Test-Fixture.md`](../../../docs/drivers/Modbus-Test-Fixture.md) — coverage map + gap inventory
|
||||||
|
- [`docs/v2/dev-environment.md`](../../../docs/v2/dev-environment.md) §Docker fixtures — full fixture inventory
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Modbus integration-test fixture — pymodbus simulator.
|
||||||
|
#
|
||||||
|
# One service per profile. Bring up only the profile a test class needs;
|
||||||
|
# they all bind :5020 on the host so can't run concurrently. The compose
|
||||||
|
# `profiles:` feature gates which service spins up via `--profile <name>`.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose --profile standard up
|
||||||
|
# docker compose --profile dl205 up
|
||||||
|
# docker compose --profile mitsubishi up
|
||||||
|
# docker compose --profile s7_1500 up
|
||||||
|
services:
|
||||||
|
standard:
|
||||||
|
profiles: ["standard"]
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: otopcua-pymodbus:3.13.0
|
||||||
|
container_name: otopcua-pymodbus-standard
|
||||||
|
restart: "no"
|
||||||
|
ports:
|
||||||
|
- "5020:5020"
|
||||||
|
command: [
|
||||||
|
"pymodbus.simulator",
|
||||||
|
"--modbus_server", "srv",
|
||||||
|
"--modbus_device", "dev",
|
||||||
|
"--json_file", "/fixtures/standard.json"
|
||||||
|
]
|
||||||
|
|
||||||
|
dl205:
|
||||||
|
profiles: ["dl205"]
|
||||||
|
image: otopcua-pymodbus:3.13.0
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: otopcua-pymodbus-dl205
|
||||||
|
restart: "no"
|
||||||
|
ports:
|
||||||
|
- "5020:5020"
|
||||||
|
command: [
|
||||||
|
"pymodbus.simulator",
|
||||||
|
"--modbus_server", "srv",
|
||||||
|
"--modbus_device", "dev",
|
||||||
|
"--json_file", "/fixtures/dl205.json"
|
||||||
|
]
|
||||||
|
|
||||||
|
mitsubishi:
|
||||||
|
profiles: ["mitsubishi"]
|
||||||
|
image: otopcua-pymodbus:3.13.0
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: otopcua-pymodbus-mitsubishi
|
||||||
|
restart: "no"
|
||||||
|
ports:
|
||||||
|
- "5020:5020"
|
||||||
|
command: [
|
||||||
|
"pymodbus.simulator",
|
||||||
|
"--modbus_server", "srv",
|
||||||
|
"--modbus_device", "dev",
|
||||||
|
"--json_file", "/fixtures/mitsubishi.json"
|
||||||
|
]
|
||||||
|
|
||||||
|
s7_1500:
|
||||||
|
profiles: ["s7_1500"]
|
||||||
|
image: otopcua-pymodbus:3.13.0
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: otopcua-pymodbus-s7_1500
|
||||||
|
restart: "no"
|
||||||
|
ports:
|
||||||
|
- "5020:5020"
|
||||||
|
command: [
|
||||||
|
"pymodbus.simulator",
|
||||||
|
"--modbus_server", "srv",
|
||||||
|
"--modbus_device", "dev",
|
||||||
|
"--json_file", "/fixtures/s7_1500.json"
|
||||||
|
]
|
||||||
@@ -3,8 +3,8 @@ using System.Net.Sockets;
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reachability probe for a Modbus TCP simulator (pymodbus-driven, see
|
/// Reachability probe for a Modbus TCP simulator (pymodbus in Docker, see
|
||||||
/// <c>Pymodbus/serve.ps1</c>) or a real PLC. Parses
|
/// <c>Docker/docker-compose.yml</c>) or a real PLC. Parses
|
||||||
/// <c>MODBUS_SIM_ENDPOINT</c> (default <c>localhost:5020</c> per PR 43) and TCP-connects once at
|
/// <c>MODBUS_SIM_ENDPOINT</c> (default <c>localhost:5020</c> per PR 43) and TCP-connects once at
|
||||||
/// fixture construction. Each test checks <see cref="SkipReason"/> and calls
|
/// fixture construction. Each test checks <see cref="SkipReason"/> and calls
|
||||||
/// <c>Assert.Skip</c> when the endpoint was unreachable, so a dev box without a running
|
/// <c>Assert.Skip</c> when the endpoint was unreachable, so a dev box without a running
|
||||||
@@ -28,7 +28,7 @@ public sealed class ModbusSimulatorFixture : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
// PR 43: default port is 5020 (pymodbus convention) instead of 502 (Modbus standard).
|
// PR 43: default port is 5020 (pymodbus convention) instead of 502 (Modbus standard).
|
||||||
// Picking 5020 sidesteps the privileged-port admin requirement on Windows + matches the
|
// Picking 5020 sidesteps the privileged-port admin requirement on Windows + matches the
|
||||||
// port baked into the pymodbus simulator JSON profiles in Pymodbus/. Override with
|
// port baked into the pymodbus simulator JSON profiles in Docker/profiles/. Override with
|
||||||
// MODBUS_SIM_ENDPOINT to point at a real PLC on its native port 502.
|
// MODBUS_SIM_ENDPOINT to point at a real PLC on its native port 502.
|
||||||
private const string DefaultEndpoint = "localhost:5020";
|
private const string DefaultEndpoint = "localhost:5020";
|
||||||
private const string EndpointEnvVar = "MODBUS_SIM_ENDPOINT";
|
private const string EndpointEnvVar = "MODBUS_SIM_ENDPOINT";
|
||||||
@@ -61,14 +61,14 @@ public sealed class ModbusSimulatorFixture : IAsyncDisposable
|
|||||||
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
|
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
|
||||||
{
|
{
|
||||||
SkipReason = $"Modbus simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
|
SkipReason = $"Modbus simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
|
||||||
$"Start the pymodbus simulator (Pymodbus\\serve.ps1 -Profile standard) " +
|
$"Start the pymodbus Docker container (docker compose -f Docker/docker-compose.yml --profile standard up -d) " +
|
||||||
$"or override {EndpointEnvVar}, then re-run.";
|
$"or override {EndpointEnvVar}, then re-run.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
SkipReason = $"Modbus simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
|
SkipReason = $"Modbus simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
|
||||||
$"Start the pymodbus simulator (Pymodbus\\serve.ps1 -Profile standard) " +
|
$"Start the pymodbus Docker container (docker compose -f Docker/docker-compose.yml --profile standard up -d) " +
|
||||||
$"or override {EndpointEnvVar}, then re-run.";
|
$"or override {EndpointEnvVar}, then re-run.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
# pymodbus simulator profiles
|
|
||||||
|
|
||||||
Two JSON-config profiles for pymodbus's `ModbusSimulatorServer`. Replaces the
|
|
||||||
ModbusPal `.xmpp` profiles that lived here in PR 42 — pymodbus is headless,
|
|
||||||
maintained, semantic about register layout, and pip-installable on Windows.
|
|
||||||
|
|
||||||
| File | What it simulates | Test category |
|
|
||||||
|---|---|---|
|
|
||||||
| [`standard.json`](standard.json) | Generic Modbus TCP server — HR[0..31] = address-as-value, HR[100] declarative auto-increment via `"action": "increment"`, alternating coils, scratch ranges for write tests. | `Trait=Standard` |
|
|
||||||
| [`dl205.json`](dl205.json) | AutomationDirect DirectLOGIC DL205 / DL260 quirks per [`docs/v2/dl205.md`](../../../docs/v2/dl205.md): low-byte-first string packing, CDAB Float32, BCD numerics, V-memory address markers, Y/C coil mappings. Inline `_quirk` comments per register name the behavior. | `Trait=DL205` |
|
|
||||||
|
|
||||||
Both bind TCP **5020** (pymodbus convention; sidesteps the Windows admin
|
|
||||||
requirement for privileged port 502). The integration-test fixture
|
|
||||||
(`ModbusSimulatorFixture`) defaults to `localhost:5020` to match — override
|
|
||||||
via `MODBUS_SIM_ENDPOINT` to point at a real PLC on its native port 502.
|
|
||||||
|
|
||||||
Run only **one profile at a time** (they share TCP 5020).
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
pip install "pymodbus[simulator]==3.13.0"
|
|
||||||
```
|
|
||||||
|
|
||||||
The `[simulator]` extra pulls in `aiohttp` for the optional web UI / REST API.
|
|
||||||
Pinned to 3.13.0 for reproducibility — avoid 4.x dev releases until stabilized.
|
|
||||||
Requires Python ≥ 3.10. Windows Firewall will prompt on first bind; allow
|
|
||||||
Private network.
|
|
||||||
|
|
||||||
## Run
|
|
||||||
|
|
||||||
Foreground (Ctrl+C to stop). Use the `serve.ps1` wrapper:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\serve.ps1 -Profile standard
|
|
||||||
.\serve.ps1 -Profile dl205
|
|
||||||
```
|
|
||||||
|
|
||||||
Or invoke pymodbus directly:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
pymodbus.simulator `
|
|
||||||
--modbus_server srv `
|
|
||||||
--modbus_device dev `
|
|
||||||
--json_file .\standard.json `
|
|
||||||
--http_port 8080
|
|
||||||
```
|
|
||||||
|
|
||||||
Web UI at `http://localhost:8080` lets you inspect + poke registers manually.
|
|
||||||
Pass `--no_http` (or `-HttpPort 0` to `serve.ps1`) to disable.
|
|
||||||
|
|
||||||
## Run the integration tests
|
|
||||||
|
|
||||||
In a separate shell, with the simulator running:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd C:\Users\dohertj2\Desktop\lmxopcua
|
|
||||||
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests
|
|
||||||
```
|
|
||||||
|
|
||||||
Tests auto-skip with a clear `SkipReason` if `localhost:5020` isn't reachable
|
|
||||||
within 2 seconds. Filter by trait when both profiles' tests coexist:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
dotnet test ... --filter "Trait=Standard"
|
|
||||||
dotnet test ... --filter "Trait=DL205"
|
|
||||||
```
|
|
||||||
|
|
||||||
## What's encoded in each profile
|
|
||||||
|
|
||||||
### standard.json
|
|
||||||
|
|
||||||
- HR[0..31]: each register's value equals its address. Easy mental map.
|
|
||||||
- HR[100]: `"action": "increment"` ticks 0..65535 on every register access — drives subscribe-and-receive tests so they have a register that changes without a write.
|
|
||||||
- HR[200..209]: scratch range for write-roundtrip tests.
|
|
||||||
- Coils[0..31]: alternating on/off (even=on).
|
|
||||||
- Coils[100..109]: scratch.
|
|
||||||
- All addresses 0..1023 are writable (`"write": [[0, 1023]]`).
|
|
||||||
|
|
||||||
### dl205.json (per `docs/v2/dl205.md`)
|
|
||||||
|
|
||||||
| HR address | Quirk demonstrated | Raw value | Decoded |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `0` (V0) | Register 0 is valid (rejects-register-0 rumour disproved) | `51966` (0xCAFE) | marker |
|
|
||||||
| `1024` (V2000 octal) | V-memory octal-to-decimal mapping | `8192` (0x2000) | marker |
|
|
||||||
| `8448` (V40400 octal) | V40400 → PDU 0x2100 (NOT register 0) | `16448` (0x4040) | marker |
|
|
||||||
| `1040..1042` | String "Hello" packed first-char-low-byte | `25928, 27756, 111` | `"Hello"` |
|
|
||||||
| `1056..1057` | Float32 1.5f in CDAB word order | `0, 16320` | `1.5f` |
|
|
||||||
| `1072` | Decimal 1234 in BCD encoding | `4660` (0x1234) | `1234` |
|
|
||||||
| `1280..1407` | 128-register block (FC03 cap = 128 above spec's 125) | first/last/mid markers; rest defaults to 0 | for FC03 cap test |
|
|
||||||
|
|
||||||
| Coil address | Quirk demonstrated |
|
|
||||||
|---|---|
|
|
||||||
| `2048` | Y0 maps to coil 2048 (DL260 layout) |
|
|
||||||
| `3072` | C0 maps to coil 3072 (DL260 layout) |
|
|
||||||
| `4000..4007` | Scratch C-relay range for write-roundtrip tests |
|
|
||||||
|
|
||||||
The DL260 X-input markers (FC02 discrete inputs) **are not encoded separately**
|
|
||||||
because the profile uses `shared blocks: true` (matches DL series memory
|
|
||||||
model) — coils/DI/HR/IR overlay the same word address space. Tests that
|
|
||||||
target FC02 against this profile end up reading the same bit positions as
|
|
||||||
the coils they share with.
|
|
||||||
|
|
||||||
## What's IN pymodbus that wasn't in ModbusPal
|
|
||||||
|
|
||||||
- **All four standard tables** (HR, IR, coils, DI) configurable via `co size` / `di size` / `hr size` / `ir size` setup keys.
|
|
||||||
- **Per-register raw uint16 seeding** — `{"addr": 1040, "value": 25928}` puts exactly that 16-bit value on the wire. No interpretation.
|
|
||||||
- **Built-in actions**: `increment`, `random`, `timestamp`, `reset`, `uptime` for declarative dynamic registers. No Python script alongside the config required.
|
|
||||||
- **Custom actions** — point `--custom_actions_module` at a `.py` file exposing callables to express anything more complex (per-second wall-clock ticks, BCD synthesis, etc.).
|
|
||||||
- **Headless** — pure CLI process, no Java, no Swing. Pip-installable. Plays well with CI runners.
|
|
||||||
- **Web UI / REST API** — `--http_port 8080` adds an aiohttp server for live inspection. Optional.
|
|
||||||
- **Maintained** — current stable 3.13.0 (April 2026), active development on 4.0 dev branch.
|
|
||||||
|
|
||||||
## Trade-offs vs the hand-authored ModbusPal profiles
|
|
||||||
|
|
||||||
- pymodbus's built-in `float32` type stores in pymodbus's word order; for explicit DL205 CDAB control we seed two raw `uint16` entries instead. Documented inline in `dl205.json`.
|
|
||||||
- `increment` action ticks per-access, not wall-clock. A 250ms-poll integration test sees variation either way; for strict 1Hz cadence add `--custom_actions_module my_actions.py` with a `time.time()`-based callable.
|
|
||||||
- `dl205.json` uses `shared blocks: true` because it matches DL series memory model; `standard.json` uses `shared blocks: false` so coils and HR address spaces are independent (more like a textbook PLC).
|
|
||||||
|
|
||||||
## File format reference
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"server_list": {
|
|
||||||
"<server-name>": {
|
|
||||||
"comm": "tcp",
|
|
||||||
"host": "0.0.0.0",
|
|
||||||
"port": 5020,
|
|
||||||
"framer": "socket",
|
|
||||||
"device_id": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"device_list": {
|
|
||||||
"<device-name>": {
|
|
||||||
"setup": {
|
|
||||||
"co size": N, "di size": N, "hr size": N, "ir size": N,
|
|
||||||
"shared blocks": false,
|
|
||||||
"type exception": false,
|
|
||||||
"defaults": { "value": {...}, "action": {...} }
|
|
||||||
},
|
|
||||||
"invalid": [],
|
|
||||||
"write": [[<from>, <to>]],
|
|
||||||
"bits": [{"addr": N, "value": 0|1}],
|
|
||||||
"uint16": [{"addr": N, "value": <0..65535>, "action"?: "increment", "parameters"?: {...}}],
|
|
||||||
"uint32": [{"addr": N, "value": <int>}],
|
|
||||||
"float32": [{"addr": N, "value": <float>}],
|
|
||||||
"string": [{"addr": N, "value": "<text>"}],
|
|
||||||
"repeat": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The CLI args `--modbus_server <server-name> --modbus_device <device-name>`
|
|
||||||
pick which entries the simulator binds.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [pymodbus on PyPI](https://pypi.org/project/pymodbus/) — install, version pin
|
|
||||||
- [Simulator config docs](https://pymodbus.readthedocs.io/en/dev/source/library/simulator/config.html) — full schema reference
|
|
||||||
- [Simulator REST API](https://pymodbus.readthedocs.io/en/latest/source/library/simulator/restapi.html) — for the optional web UI
|
|
||||||
- [`docs/v2/dl205.md`](../../../docs/v2/dl205.md) — what each DL205 profile entry simulates
|
|
||||||
- [`docs/v2/modbus-test-plan.md`](../../../docs/v2/modbus-test-plan.md) — the `DL205_<behavior>` test naming convention
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Launches the pymodbus simulator with one of the integration-test profiles
|
|
||||||
(Standard or DL205). Foreground process — Ctrl+C to stop.
|
|
||||||
|
|
||||||
.PARAMETER Profile
|
|
||||||
Which simulator profile to run: 'standard' or 'dl205'. Both bind TCP 5020 by
|
|
||||||
default so they can't run simultaneously on the same box.
|
|
||||||
|
|
||||||
.PARAMETER HttpPort
|
|
||||||
Port for pymodbus's optional web UI / REST API. Default 8080. Pass 0 to
|
|
||||||
disable (passes --no_http).
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\serve.ps1 -Profile standard
|
|
||||||
Starts the standard server on TCP 5020 with web UI on 8080.
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
.\serve.ps1 -Profile dl205 -HttpPort 0
|
|
||||||
Starts the DL205 server on TCP 5020, no web UI.
|
|
||||||
#>
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory)] [ValidateSet('standard', 'dl205', 's7_1500', 'mitsubishi')] [string]$Profile,
|
|
||||||
[int]$HttpPort = 8080
|
|
||||||
)
|
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
$here = $PSScriptRoot
|
|
||||||
|
|
||||||
# Confirm pymodbus.simulator is on PATH — clearer message than the
|
|
||||||
# 'CommandNotFoundException' dotnet style.
|
|
||||||
$cmd = Get-Command pymodbus.simulator -ErrorAction SilentlyContinue
|
|
||||||
if (-not $cmd) {
|
|
||||||
Write-Error "pymodbus.simulator not found. Install with: pip install 'pymodbus[simulator]==3.13.0'"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
$jsonFile = Join-Path $here "$Profile.json"
|
|
||||||
if (-not (Test-Path $jsonFile)) {
|
|
||||||
Write-Error "Profile config not found: $jsonFile"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
$args = @(
|
|
||||||
'--modbus_server', 'srv',
|
|
||||||
'--modbus_device', 'dev',
|
|
||||||
'--json_file', $jsonFile
|
|
||||||
)
|
|
||||||
|
|
||||||
if ($HttpPort -gt 0) {
|
|
||||||
$args += @('--http_port', $HttpPort)
|
|
||||||
Write-Host "Web UI will be at http://localhost:$HttpPort"
|
|
||||||
} else {
|
|
||||||
$args += '--no_http'
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Starting pymodbus simulator: profile=$Profile TCP=localhost:5020"
|
|
||||||
Write-Host "Ctrl+C to stop."
|
|
||||||
& pymodbus.simulator @args
|
|
||||||
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.S7;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tag map for the Siemens SIMATIC S7-1500 device class with the <c>MB_SERVER</c> library
|
/// Tag map for the Siemens SIMATIC S7-1500 device class with the <c>MB_SERVER</c> library
|
||||||
/// block mapping HR[0..] to DB1.DBW0+. Mirrors <c>s7_1500.json</c> in <c>Pymodbus/</c>.
|
/// block mapping HR[0..] to DB1.DBW0+. Mirrors <c>s7_1500.json</c> in <c>Docker/profiles/</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Unlike DL205, S7 has no fixed Modbus memory map — every site wires MB_SERVER to a
|
/// Unlike DL205, S7 has no fixed Modbus memory map — every site wires MB_SERVER to a
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Update="Pymodbus\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
<None Update="Docker\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||||
<None Update="DL205\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
<None Update="DL205\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||||
<None Update="S7\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
<None Update="S7\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||||
<None Update="Mitsubishi\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
<None Update="Mitsubishi\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# opc-plc Docker fixture
|
||||||
|
|
||||||
|
[Microsoft Industrial IoT's opc-plc](https://github.com/Azure-Samples/iot-edge-opc-plc)
|
||||||
|
— pinned Docker image that stands up an OPC UA server at
|
||||||
|
`opc.tcp://localhost:50000` with step-up counters, random nodes, alarm
|
||||||
|
simulation, and other canonical simulated shapes. Replaces the PowerShell
|
||||||
|
launcher pattern used by the Modbus / S7 fixtures — Docker is the launcher
|
||||||
|
here since opc-plc ships pre-containerized.
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| [`docker-compose.yml`](docker-compose.yml) | Service definition for `otopcua-opc-plc` — image pin, port map, command flags. |
|
||||||
|
| (this file) | How to run it. |
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Docker Desktop (Windows) or the docker CLI + daemon (Linux/macOS). Per
|
||||||
|
`CLAUDE.md` Phase 1 decision #134 the dev box already has Docker Desktop
|
||||||
|
configured with the WSL 2 backend — nothing new to install.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
From the repo root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests\Docker\docker-compose.yml up
|
||||||
|
```
|
||||||
|
|
||||||
|
Or from this folder:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
First run pulls the image (~250 MB). Startup takes ~5-10 seconds; the
|
||||||
|
healthcheck in the compose file surfaces ready state in `docker ps`.
|
||||||
|
|
||||||
|
To run detached (CI pattern):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop with `docker compose down` (removes the container) or `docker compose stop`
|
||||||
|
(keeps it for fast restart).
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
- Default: `opc.tcp://localhost:50000`
|
||||||
|
- Override by setting `OPCUA_SIM_ENDPOINT` before `dotnet test` — e.g. point
|
||||||
|
at a real OPC UA server in the lab, or at a different Docker host.
|
||||||
|
|
||||||
|
## What opc-plc advertises
|
||||||
|
|
||||||
|
Command flags in `docker-compose.yml` enable:
|
||||||
|
|
||||||
|
- `--pn=50000` — OPC UA endpoint on port 50000
|
||||||
|
- `--ut` — unsecured transport endpoint advertised (SecurityPolicy=None).
|
||||||
|
Secured policies are still on the endpoint list; `--ut` just adds an
|
||||||
|
unsecured option.
|
||||||
|
- `--aa` — auto-accept client certs (opc-plc's cert trust store lives
|
||||||
|
inside the container + resets each spin-up, so without this the driver's
|
||||||
|
first contact would be rejected).
|
||||||
|
- `--alm` — alarm simulation enabled; opc-plc publishes
|
||||||
|
`TripAlarmType`, `ExclusiveDeviationAlarmType`,
|
||||||
|
`NonExclusiveLevelAlarmType`, and `DialogConditionType` events.
|
||||||
|
|
||||||
|
Not turned on (but available via compose-file tweaks):
|
||||||
|
|
||||||
|
- `--daa` — disable anonymous auth; forces username or cert tokens. Flip
|
||||||
|
on when username-auth / cert-auth smoke tests land.
|
||||||
|
- `--fn` / `--fr` / `--ft` — fast-node variants (100 / 1 000 / 10 000 Hz
|
||||||
|
update rates) for subscription-stress coverage. Not needed for smoke.
|
||||||
|
- `--sn` / `--sr` — slow-node / special-shape coverage.
|
||||||
|
|
||||||
|
## Run the integration tests
|
||||||
|
|
||||||
|
In a separate shell, with the simulator running:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\dohertj2\Desktop\lmxopcua
|
||||||
|
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests auto-skip with a clear `SkipReason` when `localhost:50000` isn't
|
||||||
|
reachable within 2 seconds (`OpcPlcFixture`).
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
opc-plc uses the OPCFoundation.NetStandard stack internally — same as
|
||||||
|
our driver. That means bugs common to the stack itself are **not** caught
|
||||||
|
by this fixture; the follow-up to add `open62541/open62541` as a second
|
||||||
|
independent-stack image (task tracked in #215's follow-ups) would close
|
||||||
|
that.
|
||||||
|
|
||||||
|
See [`docs/drivers/OpcUaClient-Test-Fixture.md`](../../../docs/drivers/OpcUaClient-Test-Fixture.md)
|
||||||
|
for the full coverage map + what's still trusted from field deployments.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [opc-plc GitHub](https://github.com/Azure-Samples/iot-edge-opc-plc)
|
||||||
|
- [mcr.microsoft.com/iotedge/opc-plc tags](https://mcr.microsoft.com/v2/iotedge/opc-plc/tags/list)
|
||||||
|
- [`docs/drivers/OpcUaClient-Test-Fixture.md`](../../../docs/drivers/OpcUaClient-Test-Fixture.md)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# opc-plc — OPC UA PLC simulator from Microsoft Industrial IoT.
|
||||||
|
# https://github.com/Azure-Samples/iot-edge-opc-plc
|
||||||
|
#
|
||||||
|
# Why pinned: MCR tags only go forward; keeping the suite reproducible means
|
||||||
|
# we test against a known feature surface. Bump deliberately alongside a
|
||||||
|
# driver-side change that needs the newer image.
|
||||||
|
services:
|
||||||
|
opc-plc:
|
||||||
|
image: mcr.microsoft.com/iotedge/opc-plc:2.14.10
|
||||||
|
container_name: otopcua-opc-plc
|
||||||
|
restart: "no"
|
||||||
|
ports:
|
||||||
|
- "50000:50000"
|
||||||
|
command:
|
||||||
|
# --pn: Bind port 50000 (opc-plc default; matches fixture default)
|
||||||
|
# --ut: Advertise an Unsecured transport endpoint (SecurityPolicy=None).
|
||||||
|
# Tests that need signed/encrypted endpoints pick those off the
|
||||||
|
# negotiated endpoint list separately — opc-plc always advertises
|
||||||
|
# the secure policies even with --ut on.
|
||||||
|
# --aa: Auto-accept client certs. Tests wouldn't otherwise survive the
|
||||||
|
# first contact because opc-plc's cert trust store lives inside
|
||||||
|
# the container + resets each spin-up.
|
||||||
|
# --daa: Disable anonymous auth — forces the driver to go through the
|
||||||
|
# Anonymous user-token policy negotiation rather than opc-plc's
|
||||||
|
# "no auth required" short-circuit. Would flip to username/cert
|
||||||
|
# if we needed that coverage.
|
||||||
|
# Commented out for first-pass smoke; flip on when the cert-auth
|
||||||
|
# and username-auth smoke tests land.
|
||||||
|
# --alm: Turn on alarm simulation (TripAlarm / ExclusiveDeviation /
|
||||||
|
# NonExclusiveLevel / DialogCondition). Closes the IAlarmSource
|
||||||
|
# gap the OpcUaClient-Test-Fixture doc calls out.
|
||||||
|
- "--pn=50000"
|
||||||
|
- "--ut"
|
||||||
|
- "--aa"
|
||||||
|
- "--alm"
|
||||||
|
# - "--daa"
|
||||||
|
healthcheck:
|
||||||
|
# opc-plc doesn't expose an HTTP health endpoint by default; use a TCP
|
||||||
|
# probe via a shell the base image ships with. The fixture does its own
|
||||||
|
# TCP probe but healthcheck surfaces status in `docker ps` for humans.
|
||||||
|
test: ["CMD-SHELL", "netstat -an | grep -q ':50000.*LISTEN' || exit 1"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 10
|
||||||
|
start_period: 10s
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reachability probe for an <c>opc-plc</c> simulator (Microsoft Industrial IoT's
|
||||||
|
/// OPC UA PLC from <c>mcr.microsoft.com/iotedge/opc-plc</c>) or any real OPC UA
|
||||||
|
/// server the <c>OPCUA_SIM_ENDPOINT</c> env var points at. Parses
|
||||||
|
/// <c>OPCUA_SIM_ENDPOINT</c> (default <c>opc.tcp://localhost:50000</c>),
|
||||||
|
/// TCP-connects to the resolved host:port at collection init, and records a
|
||||||
|
/// <see cref="SkipReason"/> on failure. Tests call <c>Assert.Skip</c> on that, so
|
||||||
|
/// `dotnet test` stays green when Docker isn't running the simulator — mirrors the
|
||||||
|
/// <see cref="ModbusSimulatorFixture"/> / <c>Snap7ServerFixture</c> pattern.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why opc-plc over loopback against our own server</b> — (1) independent
|
||||||
|
/// cert chain + user-token handling catches interop bugs loopback can't;
|
||||||
|
/// (2) built-in alarm ConditionType + history simulation gives
|
||||||
|
/// <see cref="Core.Abstractions.IAlarmSource"/> +
|
||||||
|
/// <see cref="Core.Abstractions.IHistoryProvider"/> coverage without a custom
|
||||||
|
/// driver fake; (3) pinned image tag fixes the test surface in a way our own
|
||||||
|
/// evolving server wouldn't. Follow-up: add <c>open62541/open62541</c> as a
|
||||||
|
/// second image once this lands, for fully-independent-stack interop.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Endpoint URL contract: parser strips the <c>opc.tcp://</c> scheme + resolves
|
||||||
|
/// host + port for the liveness probe only. The real test session always
|
||||||
|
/// dials the full endpoint URL via <see cref="OpcUaClientDriverOptions.EndpointUrl"/>
|
||||||
|
/// so cert negotiation + security-policy selection run end-to-end.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class OpcPlcFixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private const string DefaultEndpoint = "opc.tcp://localhost:50000";
|
||||||
|
private const string EndpointEnvVar = "OPCUA_SIM_ENDPOINT";
|
||||||
|
|
||||||
|
/// <summary>Full <c>opc.tcp://host:port</c> URL the driver session should connect to.</summary>
|
||||||
|
public string EndpointUrl { get; }
|
||||||
|
public string Host { get; }
|
||||||
|
public int Port { get; }
|
||||||
|
public string? SkipReason { get; }
|
||||||
|
|
||||||
|
public OpcPlcFixture()
|
||||||
|
{
|
||||||
|
EndpointUrl = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint;
|
||||||
|
|
||||||
|
(Host, Port) = ParseHostPort(EndpointUrl);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new TcpClient(AddressFamily.InterNetwork);
|
||||||
|
var task = client.ConnectAsync(
|
||||||
|
System.Net.Dns.GetHostAddresses(Host)
|
||||||
|
.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork)
|
||||||
|
?? System.Net.IPAddress.Loopback,
|
||||||
|
Port);
|
||||||
|
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
|
||||||
|
{
|
||||||
|
SkipReason = $"opc-plc simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
|
||||||
|
$"Start it (`docker compose -f Docker/docker-compose.yml up`) or override {EndpointEnvVar}.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
SkipReason = $"opc-plc simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
|
||||||
|
$"Start it (`docker compose -f Docker/docker-compose.yml up`) or override {EndpointEnvVar}.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse "opc.tcp://host:port[/path]" → (host, port). Defaults to port 4840
|
||||||
|
/// (OPC UA standard) when the URL omits the port, but opc-plc's default is
|
||||||
|
/// 50000 so DefaultEndpoint carries it explicitly.
|
||||||
|
/// </summary>
|
||||||
|
private static (string Host, int Port) ParseHostPort(string endpointUrl)
|
||||||
|
{
|
||||||
|
const string scheme = "opc.tcp://";
|
||||||
|
var body = endpointUrl.StartsWith(scheme, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? endpointUrl[scheme.Length..]
|
||||||
|
: endpointUrl;
|
||||||
|
var slash = body.IndexOf('/');
|
||||||
|
if (slash >= 0) body = body[..slash];
|
||||||
|
var colon = body.IndexOf(':');
|
||||||
|
if (colon < 0) return (body, 4840);
|
||||||
|
var host = body[..colon];
|
||||||
|
return int.TryParse(body[(colon + 1)..], out var p) ? (host, p) : (host, 4840);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Xunit.CollectionDefinition(Name)]
|
||||||
|
public sealed class OpcPlcCollection : Xunit.ICollectionFixture<OpcPlcFixture>
|
||||||
|
{
|
||||||
|
public const string Name = "OpcPlc";
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver-side configuration + well-known opc-plc node identifiers that the smoke
|
||||||
|
/// tests address. Node IDs are stable across opc-plc releases — the simulator
|
||||||
|
/// guarantees the same <c>ns=3;s=...</c> names shipped since v1.0. If a release
|
||||||
|
/// bump breaks these, the fixture's pinned image tag needs a coordinated bump.
|
||||||
|
/// </summary>
|
||||||
|
public static class OpcPlcProfile
|
||||||
|
{
|
||||||
|
/// <summary>opc-plc monotonically-increasing UInt32; ticks once per second under default opts.</summary>
|
||||||
|
public const string StepUp = "ns=3;s=StepUp";
|
||||||
|
|
||||||
|
/// <summary>opc-plc random Int32 node; new value ~every 100ms.</summary>
|
||||||
|
public const string RandomSignedInt32 = "ns=3;s=RandomSignedInt32";
|
||||||
|
|
||||||
|
/// <summary>opc-plc alternating boolean; flips every second.</summary>
|
||||||
|
public const string AlternatingBoolean = "ns=3;s=AlternatingBoolean";
|
||||||
|
|
||||||
|
/// <summary>opc-plc fast uint node — ticks every 100ms. Used for subscription-cadence tests.</summary>
|
||||||
|
public const string FastUInt1 = "ns=3;s=FastUInt1";
|
||||||
|
|
||||||
|
public static OpcUaClientDriverOptions BuildOptions(string endpointUrl) => new()
|
||||||
|
{
|
||||||
|
EndpointUrl = endpointUrl,
|
||||||
|
SecurityPolicy = OpcUaSecurityPolicy.None,
|
||||||
|
SecurityMode = OpcUaSecurityMode.None,
|
||||||
|
AuthType = OpcUaAuthType.Anonymous,
|
||||||
|
// opc-plc auto-accepts client certs (--aa) but we still present one; trust the
|
||||||
|
// server's cert back since the simulator regenerates it each container spin-up
|
||||||
|
// and there's no meaningful chain to validate against.
|
||||||
|
AutoAcceptCertificates = true,
|
||||||
|
Timeout = TimeSpan.FromSeconds(10),
|
||||||
|
SessionTimeout = TimeSpan.FromSeconds(30),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end smoke against a live <c>opc-plc</c> (task #215). Drives the real
|
||||||
|
/// OPC UA Secure Channel + Session + MonitoredItem exchange — no mocks. Every
|
||||||
|
/// test here proves a capability surface that loopback against our own server
|
||||||
|
/// couldn't exercise cleanly: real cert negotiation, real endpoint descriptions,
|
||||||
|
/// real simulated nodes that change without a write.
|
||||||
|
/// </summary>
|
||||||
|
[Collection(OpcPlcCollection.Name)]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
[Trait("Simulator", "opc-plc")]
|
||||||
|
public sealed class OpcUaClientSmokeTests(OpcPlcFixture sim)
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Client_connects_and_reads_StepUp_node_through_real_OPC_UA_stack()
|
||||||
|
{
|
||||||
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||||
|
|
||||||
|
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
|
||||||
|
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-read");
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var snapshots = await drv.ReadAsync(
|
||||||
|
[OpcPlcProfile.StepUp], TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
snapshots.Count.ShouldBe(1);
|
||||||
|
snapshots[0].StatusCode.ShouldBe(0u, "opc-plc StepUp read must succeed end-to-end");
|
||||||
|
snapshots[0].Value.ShouldNotBeNull("StepUp always has a current value");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Client_reads_batch_of_varied_types_from_live_simulator()
|
||||||
|
{
|
||||||
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||||
|
|
||||||
|
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
|
||||||
|
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-batch");
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var snapshots = await drv.ReadAsync(
|
||||||
|
[OpcPlcProfile.StepUp, OpcPlcProfile.RandomSignedInt32, OpcPlcProfile.AlternatingBoolean],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
snapshots.Count.ShouldBe(3);
|
||||||
|
foreach (var s in snapshots)
|
||||||
|
{
|
||||||
|
s.StatusCode.ShouldBe(0u);
|
||||||
|
s.Value.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
// AlternatingBoolean should decode as a bool specifically — catches a common
|
||||||
|
// attribute-mapping regression where the driver stringifies variant values.
|
||||||
|
snapshots[2].Value.ShouldBeOfType<bool>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Client_subscribe_receives_StepUp_data_changes_from_live_server()
|
||||||
|
{
|
||||||
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||||
|
|
||||||
|
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
|
||||||
|
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-sub");
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var observed = new List<DataChangeEventArgs>();
|
||||||
|
var gate = new SemaphoreSlim(0);
|
||||||
|
drv.OnDataChange += (_, e) =>
|
||||||
|
{
|
||||||
|
lock (observed) observed.Add(e);
|
||||||
|
gate.Release();
|
||||||
|
};
|
||||||
|
|
||||||
|
var handle = await drv.SubscribeAsync(
|
||||||
|
[OpcPlcProfile.FastUInt1], TimeSpan.FromMilliseconds(250),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// FastUInt1 ticks every 100 ms — one publishing interval (250 ms) should deliver.
|
||||||
|
// Wait up to 3 s to tolerate container warm-up + first-publish delay.
|
||||||
|
var got = await gate.WaitAsync(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
||||||
|
got.ShouldBeTrue("opc-plc FastUInt1 must publish at least one data change within 3s");
|
||||||
|
|
||||||
|
int observedCount;
|
||||||
|
lock (observed) observedCount = observed.Count;
|
||||||
|
observedCount.ShouldBeGreaterThan(0);
|
||||||
|
|
||||||
|
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="Docker\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||||
|
</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,24 @@
|
|||||||
|
# python-snap7 S7 server container for the S7 integration suite.
|
||||||
|
#
|
||||||
|
# python-snap7 wraps the upstream snap7 C library; the pip install pulls
|
||||||
|
# platform-specific binaries automatically on Debian-based images. No build
|
||||||
|
# step needed — unlike ab_server which needs compiling from source.
|
||||||
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.source="https://github.com/dohertj2/lmxopcua" \
|
||||||
|
org.opencontainers.image.description="python-snap7 S7 simulator for OtOpcUa S7 driver integration tests"
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir "python-snap7>=2.0"
|
||||||
|
|
||||||
|
WORKDIR /fixtures
|
||||||
|
|
||||||
|
# server.py is the Python shim that loads a JSON profile + starts the
|
||||||
|
# snap7.server.Server; profiles/ carries the seed definitions.
|
||||||
|
COPY server.py /fixtures/
|
||||||
|
COPY profiles/ /fixtures/
|
||||||
|
|
||||||
|
EXPOSE 1102
|
||||||
|
|
||||||
|
# -u for unbuffered stdout so `docker logs` tails the "seeded DB…"
|
||||||
|
# diagnostics without a buffer-flush delay.
|
||||||
|
CMD ["python", "-u", "/fixtures/server.py", "/fixtures/s7_1500.json", "--port", "1102"]
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# S7 integration-test fixture — python-snap7
|
||||||
|
|
||||||
|
[python-snap7](https://github.com/gijzelaerr/python-snap7) `Server` class
|
||||||
|
wrapped in a pinned `python:3.12-slim-bookworm` image. Docker is the
|
||||||
|
only supported launch path — a fresh clone needs Docker Desktop and
|
||||||
|
nothing else.
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| [`Dockerfile`](Dockerfile) | `python:3.12-slim-bookworm` + `python-snap7>=2.0` + the server shim + the profile JSONs |
|
||||||
|
| [`docker-compose.yml`](docker-compose.yml) | One service per profile; currently only `s7_1500` |
|
||||||
|
| [`server.py`](server.py) | Same Python shim the native fallback uses — copy kept in the build context |
|
||||||
|
| [`profiles/*.json`](profiles/) | Area-seed definitions (DB1 / MB layouts with typed seeds) |
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
From the repo root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests\Docker\docker-compose.yml --profile s7_1500 up
|
||||||
|
```
|
||||||
|
|
||||||
|
Detached + stop:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose -f tests\...\Docker\docker-compose.yml --profile s7_1500 up -d
|
||||||
|
docker compose -f tests\...\Docker\docker-compose.yml --profile s7_1500 down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
- Default: `localhost:1102` (non-privileged; sidesteps Windows Firewall
|
||||||
|
prompt + Linux's root-required bind on port 102).
|
||||||
|
- Override with `S7_SIM_ENDPOINT` to point at a real S7 CPU on `:102`.
|
||||||
|
- The driver's S7DriverOptions.Port flows through S7netplus's 5-arg
|
||||||
|
`Plc(CpuType, host, port, rack, slot)` ctor so the non-standard port
|
||||||
|
works end-to-end.
|
||||||
|
|
||||||
|
## Run the integration tests
|
||||||
|
|
||||||
|
In a separate shell with the container up:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\dohertj2\Desktop\lmxopcua
|
||||||
|
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests
|
||||||
|
```
|
||||||
|
|
||||||
|
`Snap7ServerFixture` probes `localhost:1102` at collection init + records
|
||||||
|
a `SkipReason` when unreachable, so tests stay green on a fresh clone
|
||||||
|
without Docker running.
|
||||||
|
|
||||||
|
## What's encoded in `profiles/s7_1500.json`
|
||||||
|
|
||||||
|
DB1 (1024 bytes) + MB (256 bytes) with typed seeds at known offsets:
|
||||||
|
|
||||||
|
| Address | Type | Seed | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `DB1.DBW0` | u16 | `4242` | read-back probe |
|
||||||
|
| `DB1.DBW10` | i16 | `-12345` | smoke i16 read |
|
||||||
|
| `DB1.DBD20` | i32 | `1234567890` | smoke i32 read |
|
||||||
|
| `DB1.DBD30` | f32 | `3.14159` | smoke f32 read (big-endian) |
|
||||||
|
| `DB1.DBX50.3` | bool | `true` | smoke bool read at bit 3 |
|
||||||
|
| `DB1.DBW100` | u16 | `0` | scratch for write-then-read |
|
||||||
|
| `DB1.STRING[200]` | S7 STRING | `"Hello"` | S7 STRING read |
|
||||||
|
| `MW0` | u16 | `1` | `S7ProbeOptions.ProbeAddress` default |
|
||||||
|
|
||||||
|
Seed types supported: `u8`, `i8`, `u16`, `i16`, `u32`, `i32`, `f32`,
|
||||||
|
`bool` (with `"bit": 0..7`), `ascii` (S7 STRING).
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
From the `snap7.server.Server` docstring upstream:
|
||||||
|
|
||||||
|
> "Legacy S7 server implementation. Emulates a Siemens S7 PLC for testing
|
||||||
|
> and development purposes. [...] pure Python emulator implementation that
|
||||||
|
> simulates PLC behaviour for protocol compliance testing rather than
|
||||||
|
> full industrial-grade functionality."
|
||||||
|
|
||||||
|
Not exercised here — needs a lab rig:
|
||||||
|
|
||||||
|
- S7-1500 Optimized-DB symbolic access
|
||||||
|
- PG / OP / S7-Basic session-type differentiation
|
||||||
|
- PUT/GET-disabled-by-default enforcement
|
||||||
|
|
||||||
|
See [`docs/drivers/S7-Test-Fixture.md`](../../../docs/drivers/S7-Test-Fixture.md)
|
||||||
|
for the full coverage map.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [python-snap7 GitHub](https://github.com/gijzelaerr/python-snap7)
|
||||||
|
- [`docs/drivers/S7-Test-Fixture.md`](../../../docs/drivers/S7-Test-Fixture.md) — coverage map
|
||||||
|
- [`docs/v2/dev-environment.md`](../../../docs/v2/dev-environment.md) §Docker fixtures
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# S7 integration-test fixture — python-snap7 server.
|
||||||
|
#
|
||||||
|
# One service per profile (only s7_1500 ships today; add S7-1200 / S7-300
|
||||||
|
# as new profile JSONs drop into profiles/). All bind :1102 on the host;
|
||||||
|
# run one at a time.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose --profile s7_1500 up
|
||||||
|
services:
|
||||||
|
s7_1500:
|
||||||
|
profiles: ["s7_1500"]
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: otopcua-python-snap7:1.0
|
||||||
|
container_name: otopcua-python-snap7-s7_1500
|
||||||
|
restart: "no"
|
||||||
|
ports:
|
||||||
|
- "1102:1102"
|
||||||
|
command: ["python", "-u", "/fixtures/server.py", "/fixtures/s7_1500.json", "--port", "1102"]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"_description": "S7-1500 profile — single DB1 (1024 bytes) + MB (256 bytes) with well-known seeds at named offsets for the smoke + byte-order + string tests. Big-endian Siemens wire order throughout.",
|
||||||
|
"areas": [
|
||||||
|
{
|
||||||
|
"area": "DB",
|
||||||
|
"index": 1,
|
||||||
|
"size": 1024,
|
||||||
|
"seeds": [
|
||||||
|
{ "_desc": "DB1.DBW0 — read-back probe, S7Driver default ProbeAddress target is MW0; this shadows it",
|
||||||
|
"offset": 0, "type": "u16", "value": 4242 },
|
||||||
|
{ "_desc": "DB1.DBW10 — i16 smoke value for SmokeI16 read path",
|
||||||
|
"offset": 10, "type": "i16", "value": -12345 },
|
||||||
|
{ "_desc": "DB1.DBD20 — i32 smoke value for SmokeI32 read path",
|
||||||
|
"offset": 20, "type": "i32", "value": 1234567890 },
|
||||||
|
{ "_desc": "DB1.DBD30 — f32 smoke value for SmokeF32 read path (IEEE-754 big-endian)",
|
||||||
|
"offset": 30, "type": "f32", "value": 3.14159 },
|
||||||
|
{ "_desc": "DB1.DBX50.3 — bool bit at byte-50 bit-3 for SmokeBool read path",
|
||||||
|
"offset": 50, "type": "bool", "value": true, "bit": 3 },
|
||||||
|
{ "_desc": "DB1.DBW100 — scratch for write-then-read round-trip tests; seeded 0",
|
||||||
|
"offset": 100, "type": "u16", "value": 0 },
|
||||||
|
{ "_desc": "DB1.STRING[200] — S7 string 'Hello' (max 32, cur 5)",
|
||||||
|
"offset": 200, "type": "ascii", "value": "Hello", "max_len": 32 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "MK",
|
||||||
|
"index": 0,
|
||||||
|
"size": 256,
|
||||||
|
"seeds": [
|
||||||
|
{ "_desc": "MW0 — probe target for S7ProbeOptions.ProbeAddress default",
|
||||||
|
"offset": 0, "type": "u16", "value": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""python-snap7 S7 server for integration tests.
|
||||||
|
|
||||||
|
Reads a JSON profile from argv[1], allocates bytearrays for each declared area
|
||||||
|
(DB / MB / EB / AB), poke-seeds values at declared offsets, then starts the
|
||||||
|
snap7 Server on the configured port + blocks until Ctrl+C. Shape intentionally
|
||||||
|
mirrors the pymodbus `serve.ps1 + *.json` pattern one directory over so
|
||||||
|
someone familiar with the Modbus fixture can read this without re-learning.
|
||||||
|
|
||||||
|
The snap7.server.Server class is the MIT-licensed S7 PLC emulator wrapped by
|
||||||
|
python-snap7 (https://github.com/gijzelaerr/python-snap7). Its own docstring
|
||||||
|
admits "protocol compliance testing rather than full industrial-grade
|
||||||
|
functionality" — good enough for ISO-on-TCP wire-level round-trip but NOT
|
||||||
|
for S7-1500 Optimized-DB symbolic access, SCL variant-specific behaviour, or
|
||||||
|
PG/OP/S7-Basic session differentiation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ctypes
|
||||||
|
import json
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# python-snap7 installs as `snap7` package; Server class lives under `snap7.server`.
|
||||||
|
import snap7
|
||||||
|
from snap7.type import SrvArea
|
||||||
|
|
||||||
|
|
||||||
|
# Map JSON area names → SrvArea enum values. PE = inputs (I/E), PA = outputs
|
||||||
|
# (Q/A), MK = memory (M), DB = data blocks, TM = timers, CT = counters.
|
||||||
|
AREA_MAP: dict[str, int] = {
|
||||||
|
"PE": SrvArea.PE,
|
||||||
|
"PA": SrvArea.PA,
|
||||||
|
"MK": SrvArea.MK,
|
||||||
|
"DB": SrvArea.DB,
|
||||||
|
"TM": SrvArea.TM,
|
||||||
|
"CT": SrvArea.CT,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def seed_buffer(buf: bytearray, seeds: list[dict]) -> None:
|
||||||
|
"""Poke seed values into the area buffer at declared byte offsets.
|
||||||
|
|
||||||
|
Each seed is {"offset": int, "type": str, "value": int|float|bool|str}
|
||||||
|
where type ∈ {u8, i8, u16, i16, u32, i32, f32, bool, ascii}. Endianness is
|
||||||
|
big-endian (Siemens wire format).
|
||||||
|
"""
|
||||||
|
for seed in seeds:
|
||||||
|
off = int(seed["offset"])
|
||||||
|
t = seed["type"]
|
||||||
|
v = seed["value"]
|
||||||
|
if t == "u8":
|
||||||
|
buf[off] = int(v) & 0xFF
|
||||||
|
elif t == "i8":
|
||||||
|
buf[off] = int(v) & 0xFF
|
||||||
|
elif t == "u16":
|
||||||
|
buf[off:off + 2] = int(v).to_bytes(2, "big", signed=False)
|
||||||
|
elif t == "i16":
|
||||||
|
buf[off:off + 2] = int(v).to_bytes(2, "big", signed=True)
|
||||||
|
elif t == "u32":
|
||||||
|
buf[off:off + 4] = int(v).to_bytes(4, "big", signed=False)
|
||||||
|
elif t == "i32":
|
||||||
|
buf[off:off + 4] = int(v).to_bytes(4, "big", signed=True)
|
||||||
|
elif t == "f32":
|
||||||
|
import struct
|
||||||
|
buf[off:off + 4] = struct.pack(">f", float(v))
|
||||||
|
elif t == "bool":
|
||||||
|
bit = int(seed.get("bit", 0))
|
||||||
|
if bool(v):
|
||||||
|
buf[off] |= (1 << bit)
|
||||||
|
else:
|
||||||
|
buf[off] &= ~(1 << bit) & 0xFF
|
||||||
|
elif t == "ascii":
|
||||||
|
# Siemens STRING type: byte 0 = max length, byte 1 = current length,
|
||||||
|
# bytes 2+ = payload. Seeds supply the payload text; we fill max/cur.
|
||||||
|
payload = str(v).encode("ascii")
|
||||||
|
max_len = int(seed.get("max_len", 254))
|
||||||
|
buf[off] = max_len
|
||||||
|
buf[off + 1] = len(payload)
|
||||||
|
buf[off + 2:off + 2 + len(payload)] = payload
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown seed type '{t}'")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="python-snap7 S7 server for integration tests")
|
||||||
|
parser.add_argument("profile", help="Path to profile JSON")
|
||||||
|
parser.add_argument("--port", type=int, default=1102, help="TCP port (default 1102 non-privileged)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
profile_path = Path(args.profile)
|
||||||
|
if not profile_path.is_file():
|
||||||
|
print(f"profile not found: {profile_path}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
with profile_path.open() as f:
|
||||||
|
profile = json.load(f)
|
||||||
|
|
||||||
|
server = snap7.server.Server()
|
||||||
|
# Keep bytearray refs alive for the server's lifetime — snap7 doesn't copy
|
||||||
|
# the buffer, it takes a pointer. Letting GC collect would corrupt reads.
|
||||||
|
buffers: list[bytearray] = []
|
||||||
|
|
||||||
|
for area_decl in profile.get("areas", []):
|
||||||
|
area_name = area_decl["area"]
|
||||||
|
if area_name not in AREA_MAP:
|
||||||
|
print(f"unknown area '{area_name}' (expected one of {list(AREA_MAP)})", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
index = int(area_decl.get("index", 0)) # DB number for DB area, 0 for MK/PE/PA
|
||||||
|
size = int(area_decl["size"])
|
||||||
|
buf = bytearray(size)
|
||||||
|
seed_buffer(buf, area_decl.get("seeds", []))
|
||||||
|
buffers.append(buf)
|
||||||
|
# register_area takes (area, index, c-array); we wrap the bytearray
|
||||||
|
# into a ctypes char array so the native lib can take &buf[0].
|
||||||
|
arr_type = ctypes.c_char * size
|
||||||
|
arr = arr_type.from_buffer(buf)
|
||||||
|
server.register_area(AREA_MAP[area_name], index, arr)
|
||||||
|
print(f" seeded {area_name}{index} size={size} seeds={len(area_decl.get('seeds', []))}")
|
||||||
|
|
||||||
|
port = int(args.port)
|
||||||
|
print(f"Starting python-snap7 server on TCP {port} (Ctrl+C to stop)")
|
||||||
|
server.start(tcp_port=port)
|
||||||
|
|
||||||
|
stop = {"sig": False}
|
||||||
|
def _handle(*_a):
|
||||||
|
stop["sig"] = True
|
||||||
|
signal.signal(signal.SIGINT, _handle)
|
||||||
|
try:
|
||||||
|
signal.signal(signal.SIGTERM, _handle)
|
||||||
|
except Exception:
|
||||||
|
pass # SIGTERM not on all platforms
|
||||||
|
|
||||||
|
try:
|
||||||
|
while not stop["sig"]:
|
||||||
|
time.sleep(0.25)
|
||||||
|
finally:
|
||||||
|
print("stopping python-snap7 server")
|
||||||
|
try:
|
||||||
|
server.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using S7NetCpuType = global::S7.Net.CpuType;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver-side configuration matching what <c>Docker/profiles/s7_1500.json</c> seeds
|
||||||
|
/// into the simulator's DB1 + MB areas. Tag names here become the full references
|
||||||
|
/// the smoke tests read/write against; addresses map 1:1 to the JSON profile's
|
||||||
|
/// seed offsets so a seed drift in the JSON surfaces as a driver-side read
|
||||||
|
/// mismatch, not a mystery test failure.
|
||||||
|
/// </summary>
|
||||||
|
public static class S7_1500Profile
|
||||||
|
{
|
||||||
|
public const string ProbeTag = "ProbeProbeWord";
|
||||||
|
public const int ProbeSeedValue = 4242;
|
||||||
|
|
||||||
|
public const string SmokeI16Tag = "SmokeI16";
|
||||||
|
public const short SmokeI16SeedValue = -12345;
|
||||||
|
|
||||||
|
public const string SmokeI32Tag = "SmokeI32";
|
||||||
|
public const int SmokeI32SeedValue = 1234567890;
|
||||||
|
|
||||||
|
public const string SmokeF32Tag = "SmokeF32";
|
||||||
|
public const float SmokeF32SeedValue = 3.14159f;
|
||||||
|
|
||||||
|
public const string SmokeBoolTag = "SmokeBool";
|
||||||
|
|
||||||
|
public const string WriteScratchTag = "WriteScratch";
|
||||||
|
|
||||||
|
public static S7DriverOptions BuildOptions(string host, int port) => new()
|
||||||
|
{
|
||||||
|
Host = host,
|
||||||
|
Port = port,
|
||||||
|
CpuType = S7NetCpuType.S71500,
|
||||||
|
Rack = 0,
|
||||||
|
Slot = 0,
|
||||||
|
Timeout = TimeSpan.FromSeconds(5),
|
||||||
|
// Disable the probe loop — the integration tests run their own reads +
|
||||||
|
// a background probe would race with them for the S7netplus mailbox
|
||||||
|
// gate, injecting flakiness that has nothing to do with the code
|
||||||
|
// under test.
|
||||||
|
Probe = new S7ProbeOptions { Enabled = false },
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
new S7TagDefinition(ProbeTag, "DB1.DBW0", S7DataType.UInt16),
|
||||||
|
new S7TagDefinition(SmokeI16Tag, "DB1.DBW10", S7DataType.Int16),
|
||||||
|
new S7TagDefinition(SmokeI32Tag, "DB1.DBD20", S7DataType.Int32),
|
||||||
|
new S7TagDefinition(SmokeF32Tag, "DB1.DBD30", S7DataType.Float32),
|
||||||
|
new S7TagDefinition(SmokeBoolTag, "DB1.DBX50.3", S7DataType.Bool),
|
||||||
|
new S7TagDefinition(WriteScratchTag, "DB1.DBW100", S7DataType.UInt16),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end smoke against the python-snap7 S7-1500 profile. Drives the real
|
||||||
|
/// <see cref="S7Driver"/> + real S7netplus ISO-on-TCP stack + real CIP-free
|
||||||
|
/// S7comm exchange against <c>localhost:1102</c>. Success proves initialisation,
|
||||||
|
/// typed reads (u16 / i16 / i32 / f32 / bool-with-bit), and a write-then-read
|
||||||
|
/// round-trip all work against a real S7 server — the baseline everything
|
||||||
|
/// S7-specific (byte-order, optimized-DB differences, probe behaviour) layers on.
|
||||||
|
/// </summary>
|
||||||
|
[Collection(Snap7ServerCollection.Name)]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
[Trait("Device", "S7_1500")]
|
||||||
|
public sealed class S7_1500SmokeTests(Snap7ServerFixture sim)
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Driver_reads_seeded_u16_through_real_S7comm()
|
||||||
|
{
|
||||||
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||||
|
|
||||||
|
var options = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
|
||||||
|
await using var drv = new S7Driver(options, driverInstanceId: "s7-smoke-u16");
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var snapshots = await drv.ReadAsync(
|
||||||
|
[S7_1500Profile.ProbeTag], TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
snapshots.Count.ShouldBe(1);
|
||||||
|
snapshots[0].StatusCode.ShouldBe(0u, "seeded u16 read must succeed end-to-end");
|
||||||
|
Convert.ToInt32(snapshots[0].Value).ShouldBe(S7_1500Profile.ProbeSeedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Driver_reads_seeded_typed_batch()
|
||||||
|
{
|
||||||
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||||
|
|
||||||
|
var options = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
|
||||||
|
await using var drv = new S7Driver(options, driverInstanceId: "s7-smoke-batch");
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var snapshots = await drv.ReadAsync(
|
||||||
|
[S7_1500Profile.SmokeI16Tag, S7_1500Profile.SmokeI32Tag,
|
||||||
|
S7_1500Profile.SmokeF32Tag, S7_1500Profile.SmokeBoolTag],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
snapshots.Count.ShouldBe(4);
|
||||||
|
foreach (var s in snapshots) s.StatusCode.ShouldBe(0u);
|
||||||
|
|
||||||
|
Convert.ToInt32(snapshots[0].Value).ShouldBe((int)S7_1500Profile.SmokeI16SeedValue);
|
||||||
|
Convert.ToInt32(snapshots[1].Value).ShouldBe(S7_1500Profile.SmokeI32SeedValue);
|
||||||
|
Convert.ToSingle(snapshots[2].Value).ShouldBe(S7_1500Profile.SmokeF32SeedValue, tolerance: 0.0001f);
|
||||||
|
Convert.ToBoolean(snapshots[3].Value).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Driver_write_then_read_round_trip_on_scratch_word()
|
||||||
|
{
|
||||||
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||||
|
|
||||||
|
var options = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
|
||||||
|
await using var drv = new S7Driver(options, driverInstanceId: "s7-smoke-write");
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
const ushort probe = 0xBEEF;
|
||||||
|
var writeResults = await drv.WriteAsync(
|
||||||
|
[new WriteRequest(S7_1500Profile.WriteScratchTag, probe)],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
writeResults.Count.ShouldBe(1);
|
||||||
|
writeResults[0].StatusCode.ShouldBe(0u,
|
||||||
|
"write must succeed against snap7's DB1.DBW100 scratch register");
|
||||||
|
|
||||||
|
var readResults = await drv.ReadAsync(
|
||||||
|
[S7_1500Profile.WriteScratchTag], TestContext.Current.CancellationToken);
|
||||||
|
readResults.Count.ShouldBe(1);
|
||||||
|
readResults[0].StatusCode.ShouldBe(0u);
|
||||||
|
Convert.ToInt32(readResults[0].Value).ShouldBe(probe);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reachability probe for the python-snap7 simulator Docker container (see
|
||||||
|
/// <c>Docker/docker-compose.yml</c>) or a real S7 PLC. Parses <c>S7_SIM_ENDPOINT</c>
|
||||||
|
/// (default <c>localhost:1102</c>) + TCP-connects once at fixture construction.
|
||||||
|
/// Tests check <see cref="SkipReason"/> + call <c>Assert.Skip</c> when unreachable, so
|
||||||
|
/// `dotnet test` stays green on a fresh box without the simulator installed —
|
||||||
|
/// mirrors the <c>ModbusSimulatorFixture</c> pattern.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Default port is <b>1102</b>, not the S7-standard 102. 102 is a privileged port
|
||||||
|
/// on Linux (needs root) + triggers the Windows Firewall prompt on first bind;
|
||||||
|
/// 1102 sidesteps both. S7netplus 0.20 supports the 5-arg <c>Plc</c> ctor that
|
||||||
|
/// takes an explicit port (verified + wired through <c>S7DriverOptions.Port</c>),
|
||||||
|
/// so the driver can reach the simulator on its non-standard port without
|
||||||
|
/// hacks.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The probe is a one-shot liveness check; tests open their own S7netplus
|
||||||
|
/// sessions against the same endpoint. Don't share a socket — S7 CPUs serialise
|
||||||
|
/// concurrent connections against the same mailbox anyway, but sharing would
|
||||||
|
/// couple test ordering to socket reuse in ways this harness shouldn't care
|
||||||
|
/// about.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Fixture is a collection fixture so the probe runs once per test session, not
|
||||||
|
/// per test.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class Snap7ServerFixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
// Default 1102 (non-privileged) matches Docker/server.py. Override with
|
||||||
|
// S7_SIM_ENDPOINT to point at a real PLC on its native 102.
|
||||||
|
private const string DefaultEndpoint = "localhost:1102";
|
||||||
|
private const string EndpointEnvVar = "S7_SIM_ENDPOINT";
|
||||||
|
|
||||||
|
public string Host { get; }
|
||||||
|
public int Port { get; }
|
||||||
|
public string? SkipReason { get; }
|
||||||
|
|
||||||
|
public Snap7ServerFixture()
|
||||||
|
{
|
||||||
|
var raw = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint;
|
||||||
|
var parts = raw.Split(':', 2);
|
||||||
|
Host = parts[0];
|
||||||
|
Port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : 102;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Force IPv4 — python-snap7 binds 0.0.0.0 (IPv4) and .NET's default
|
||||||
|
// dual-stack "localhost" resolves IPv6 ::1 first then times out before
|
||||||
|
// falling back. Same story the Modbus fixture hits.
|
||||||
|
using var client = new TcpClient(AddressFamily.InterNetwork);
|
||||||
|
var task = client.ConnectAsync(
|
||||||
|
System.Net.Dns.GetHostAddresses(Host)
|
||||||
|
.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork)
|
||||||
|
?? System.Net.IPAddress.Loopback,
|
||||||
|
Port);
|
||||||
|
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
|
||||||
|
{
|
||||||
|
SkipReason = $"python-snap7 simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
|
||||||
|
$"Start it (docker compose -f Docker/docker-compose.yml --profile s7_1500 up -d) or override {EndpointEnvVar}.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
SkipReason = $"python-snap7 simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
|
||||||
|
$"Start it (docker compose -f Docker/docker-compose.yml --profile s7_1500 up -d) or override {EndpointEnvVar}.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Xunit.CollectionDefinition(Name)]
|
||||||
|
public sealed class Snap7ServerCollection : Xunit.ICollectionFixture<Snap7ServerFixture>
|
||||||
|
{
|
||||||
|
public const string Name = "Snap7Server";
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="Docker\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||||
|
</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,135 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end smoke tests against a live TwinCAT 3 XAR runtime. Skipped via
|
||||||
|
/// <see cref="TwinCATFactAttribute"/> when the VM isn't reachable / the AmsNetId
|
||||||
|
/// isn't set. Proves the driver's AMS route setup, ADS read/write, symbol browse,
|
||||||
|
/// and native <c>AddDeviceNotification</c> subscription all work on the wire —
|
||||||
|
/// coverage the <c>FakeTwinCATClient</c>-backed unit suite can only contract-test.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>Required VM project state</b> (see <c>TwinCatProject/README.md</c>):</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>GVL <c>GVL_Fixture</c> with <c>nCounter : DINT</c> (seed <c>1234</c>),
|
||||||
|
/// <c>rSetpoint : REAL</c> (scratch; smoke writes + reads), <c>bFlag : BOOL</c>
|
||||||
|
/// (seed <c>TRUE</c>).</item>
|
||||||
|
/// <item>PLC program <c>MAIN</c> that increments <c>GVL_Fixture.nCounter</c>
|
||||||
|
/// every cycle (so the native-notification test can observe monotonic changes
|
||||||
|
/// without writing).</item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
[Collection("TwinCATXar")]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
[Trait("Simulator", "TwinCAT-XAR")]
|
||||||
|
public sealed class TwinCAT3SmokeTests(TwinCATXarFixture sim)
|
||||||
|
{
|
||||||
|
[TwinCATFact]
|
||||||
|
public async Task Driver_reads_seeded_DINT_through_real_ADS()
|
||||||
|
{
|
||||||
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||||
|
|
||||||
|
var options = BuildOptions(sim);
|
||||||
|
await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-read");
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var snapshots = await drv.ReadAsync(
|
||||||
|
["Counter"], TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
snapshots.Count.ShouldBe(1);
|
||||||
|
snapshots[0].StatusCode.ShouldBe(0u,
|
||||||
|
"ADS read against GVL_Fixture.nCounter must succeed end-to-end");
|
||||||
|
// MAIN increments the counter every cycle, so the seed value (1234) is only the
|
||||||
|
// minimum we can assert — value grows monotonically.
|
||||||
|
Convert.ToInt32(snapshots[0].Value).ShouldBeGreaterThanOrEqualTo(1234);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TwinCATFact]
|
||||||
|
public async Task Driver_write_then_read_round_trip_on_scratch_REAL()
|
||||||
|
{
|
||||||
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||||
|
|
||||||
|
var options = BuildOptions(sim);
|
||||||
|
await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-write");
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
const float probe = 42.5f;
|
||||||
|
var writeResults = await drv.WriteAsync(
|
||||||
|
[new WriteRequest("Setpoint", probe)],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
writeResults.Count.ShouldBe(1);
|
||||||
|
writeResults[0].StatusCode.ShouldBe(0u);
|
||||||
|
|
||||||
|
var readResults = await drv.ReadAsync(
|
||||||
|
["Setpoint"], TestContext.Current.CancellationToken);
|
||||||
|
readResults.Count.ShouldBe(1);
|
||||||
|
readResults[0].StatusCode.ShouldBe(0u);
|
||||||
|
Convert.ToSingle(readResults[0].Value).ShouldBe(probe, tolerance: 0.001f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TwinCATFact]
|
||||||
|
public async Task Driver_subscribe_receives_native_ADS_notifications_on_counter_changes()
|
||||||
|
{
|
||||||
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||||
|
|
||||||
|
var options = BuildOptions(sim);
|
||||||
|
await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-sub");
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var observed = new List<DataChangeEventArgs>();
|
||||||
|
var gate = new SemaphoreSlim(0);
|
||||||
|
drv.OnDataChange += (_, e) =>
|
||||||
|
{
|
||||||
|
lock (observed) observed.Add(e);
|
||||||
|
gate.Release();
|
||||||
|
};
|
||||||
|
|
||||||
|
var handle = await drv.SubscribeAsync(
|
||||||
|
["Counter"], TimeSpan.FromMilliseconds(250),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// MAIN increments the counter every PLC cycle (default 10 ms task tick).
|
||||||
|
// Native ADS notifications fire on cycle boundaries so 3 s is generous for
|
||||||
|
// at least one OnDataChange to land.
|
||||||
|
var got = await gate.WaitAsync(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
||||||
|
got.ShouldBeTrue("native ADS notification on GVL_Fixture.nCounter must fire within 3 s of subscribe");
|
||||||
|
|
||||||
|
int observedCount;
|
||||||
|
lock (observed) observedCount = observed.Count;
|
||||||
|
observedCount.ShouldBeGreaterThan(0);
|
||||||
|
|
||||||
|
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TwinCATDriverOptions BuildOptions(TwinCATXarFixture sim) => new()
|
||||||
|
{
|
||||||
|
Devices = [
|
||||||
|
new TwinCATDeviceOptions(
|
||||||
|
HostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
|
||||||
|
DeviceName: "XAR-VM"),
|
||||||
|
],
|
||||||
|
Tags = [
|
||||||
|
new TwinCATTagDefinition(
|
||||||
|
Name: "Counter",
|
||||||
|
DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
|
||||||
|
SymbolPath: "GVL_Fixture.nCounter",
|
||||||
|
DataType: TwinCATDataType.DInt),
|
||||||
|
new TwinCATTagDefinition(
|
||||||
|
Name: "Setpoint",
|
||||||
|
DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
|
||||||
|
SymbolPath: "GVL_Fixture.rSetpoint",
|
||||||
|
DataType: TwinCATDataType.Real,
|
||||||
|
Writable: true),
|
||||||
|
],
|
||||||
|
UseNativeNotifications = true,
|
||||||
|
Timeout = TimeSpan.FromSeconds(5),
|
||||||
|
// Disable the probe loop — the smoke tests run their own reads; a background
|
||||||
|
// probe against GVL_Fixture.nCounter would race with them for the ADS client
|
||||||
|
// gate + inject flakiness unrelated to the code under test.
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
using System.Net.Sockets;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Sdk;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reachability probe for a TwinCAT 3 XAR runtime on a Hyper-V VM or dedicated
|
||||||
|
/// Windows box. TCP-probes ADS port 48898 on the operator-supplied host. Tests
|
||||||
|
/// skip via <see cref="TwinCATFactAttribute"/> / <see cref="TwinCATTheoryAttribute"/>
|
||||||
|
/// when the runtime isn't reachable, so <c>dotnet test</c> on a fresh clone without
|
||||||
|
/// a TwinCAT VM stays green. Matches the
|
||||||
|
/// <see cref="Modbus.IntegrationTests.ModbusSimulatorFixture"/> /
|
||||||
|
/// <see cref="S7.IntegrationTests.Snap7ServerFixture"/> /
|
||||||
|
/// <c>OpcPlcFixture</c> / <c>AbServerFixture</c> patterns.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>Why a VM, not a container</b>: TwinCAT XAR bypasses the Windows
|
||||||
|
/// kernel scheduler to hit real-time PLC cycles. It can't run inside Docker, and
|
||||||
|
/// on bare metal it conflicts with Hyper-V / WSL 2 — that's why this repo's dev
|
||||||
|
/// environment puts XAR in a dedicated Hyper-V VM per
|
||||||
|
/// <c>docs/v2/dev-environment.md</c> §Integration host. The fixture treats the VM
|
||||||
|
/// as a black-box ADS endpoint reachable over TCP.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>License rotation</b>: the free XAR trial license expires every 7 days.
|
||||||
|
/// When it lapses the runtime goes silent + the fixture's TCP probe fails; tests
|
||||||
|
/// skip with the reason message until the operator renews via
|
||||||
|
/// <c>TcActivate.exe /reactivate</c> (or buys a paid runtime). Intentionally surfaces
|
||||||
|
/// as a skip rather than a hang because "trial expired" is operator action, not a
|
||||||
|
/// test failure.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Env var overrides</b>:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>TWINCAT_TARGET_HOST</c> — IP or hostname of the XAR VM (default
|
||||||
|
/// <c>localhost</c>, assumed to be unset on the average dev box + result in a
|
||||||
|
/// clean skip).</item>
|
||||||
|
/// <item><c>TWINCAT_TARGET_NETID</c> — AMS NetId the tests address (e.g.
|
||||||
|
/// <c>5.23.91.23.1.1</c>). Seeded on the target VM via TwinCAT System
|
||||||
|
/// Manager → Routes; the dev box's AmsNetId also needs a bilateral route
|
||||||
|
/// entry on the VM side. No sensible default — tests skip if unset.</item>
|
||||||
|
/// <item><c>TWINCAT_TARGET_PORT</c> — ADS target port (default <c>851</c> =
|
||||||
|
/// TC3 PLC runtime 1). Set to <c>852</c> for runtime 2, etc.</item>
|
||||||
|
/// </list></para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class TwinCATXarFixture : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string HostEnvVar = "TWINCAT_TARGET_HOST";
|
||||||
|
private const string NetIdEnvVar = "TWINCAT_TARGET_NETID";
|
||||||
|
private const string PortEnvVar = "TWINCAT_TARGET_PORT";
|
||||||
|
|
||||||
|
/// <summary>ADS-over-TCP port on the XAR host. Not the PLC runtime port (that's
|
||||||
|
/// <see cref="AmsPort"/>).</summary>
|
||||||
|
public const int AdsTcpPort = 48898;
|
||||||
|
|
||||||
|
/// <summary>TC3 PLC runtime 1. Override via <see cref="PortEnvVar"/>.</summary>
|
||||||
|
public const int DefaultAmsPort = 851;
|
||||||
|
|
||||||
|
public string TargetHost { get; }
|
||||||
|
public string? TargetNetId { get; }
|
||||||
|
public int AmsPort { get; }
|
||||||
|
public string? SkipReason { get; }
|
||||||
|
|
||||||
|
public TwinCATXarFixture()
|
||||||
|
{
|
||||||
|
TargetHost = Environment.GetEnvironmentVariable(HostEnvVar) ?? "localhost";
|
||||||
|
TargetNetId = Environment.GetEnvironmentVariable(NetIdEnvVar);
|
||||||
|
AmsPort = int.TryParse(Environment.GetEnvironmentVariable(PortEnvVar), out var p)
|
||||||
|
? p : DefaultAmsPort;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(TargetNetId))
|
||||||
|
{
|
||||||
|
SkipReason = $"TwinCAT XAR unreachable: {NetIdEnvVar} is not set. " +
|
||||||
|
$"Start the XAR VM + set {HostEnvVar}=<vm-ip> and {NetIdEnvVar}=<vm-ams-netid>.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TcpProbe(TargetHost, AdsTcpPort, TimeSpan.FromSeconds(2)))
|
||||||
|
{
|
||||||
|
SkipReason = $"TwinCAT XAR at {TargetHost}:{AdsTcpPort} not reachable within 2 s. " +
|
||||||
|
$"Verify the XAR VM is running, its trial license hasn't expired " +
|
||||||
|
$"(run TcActivate.exe /reactivate on the VM), and {HostEnvVar}/{NetIdEnvVar} point at it.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
|
||||||
|
/// <summary><c>true</c> when the XAR runtime is reachable + the AmsNetId is set.
|
||||||
|
/// Used by the skip attributes to avoid spinning up the fixture for every test
|
||||||
|
/// class.</summary>
|
||||||
|
public static bool IsRuntimeAvailable()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(NetIdEnvVar))) return false;
|
||||||
|
var host = Environment.GetEnvironmentVariable(HostEnvVar) ?? "localhost";
|
||||||
|
return TcpProbe(host, AdsTcpPort, TimeSpan.FromMilliseconds(500));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TcpProbe(string host, int port, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new TcpClient();
|
||||||
|
var task = client.ConnectAsync(host, port);
|
||||||
|
return task.Wait(timeout) && client.Connected;
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Xunit.CollectionDefinition(Name)]
|
||||||
|
public sealed class TwinCATXarCollection : Xunit.ICollectionFixture<TwinCATXarFixture>
|
||||||
|
{
|
||||||
|
public const string Name = "TwinCATXar";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c>[Fact]</c>-equivalent gated on <see cref="TwinCATXarFixture.IsRuntimeAvailable"/>.</summary>
|
||||||
|
public sealed class TwinCATFactAttribute : FactAttribute
|
||||||
|
{
|
||||||
|
public TwinCATFactAttribute()
|
||||||
|
{
|
||||||
|
if (!TwinCATXarFixture.IsRuntimeAvailable())
|
||||||
|
Skip = "TwinCAT XAR not reachable. See docs/drivers/TwinCAT-Test-Fixture.md " +
|
||||||
|
"for setup; typical cause is the trial license expired or TWINCAT_TARGET_NETID is unset.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary><c>[Theory]</c>-equivalent with the same gate as <see cref="TwinCATFactAttribute"/>.</summary>
|
||||||
|
public sealed class TwinCATTheoryAttribute : TheoryAttribute
|
||||||
|
{
|
||||||
|
public TwinCATTheoryAttribute()
|
||||||
|
{
|
||||||
|
if (!TwinCATXarFixture.IsRuntimeAvailable())
|
||||||
|
Skip = "TwinCAT XAR not reachable. See docs/drivers/TwinCAT-Test-Fixture.md " +
|
||||||
|
"for setup; typical cause is the trial license expired or TWINCAT_TARGET_NETID is unset.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# TwinCAT XAR fixture project
|
||||||
|
|
||||||
|
This folder holds the TwinCAT 3 XAE project that the XAR VM runs for the
|
||||||
|
integration-tests suite (`tests/.../TwinCAT.IntegrationTests/*.cs`).
|
||||||
|
|
||||||
|
**Status today**: stub. The `.tsproj` isn't committed yet; once the XAR
|
||||||
|
VM is up + a project with the required state exists, export via
|
||||||
|
File → Export + drop it here as `OtOpcUaTwinCatFixture.tsproj` + its
|
||||||
|
PLC `.library` / `.plcproj` companions.
|
||||||
|
|
||||||
|
## Why `.tsproj`, not the binary bootproject
|
||||||
|
|
||||||
|
TwinCAT ships two project forms: the XAE `.tsproj` (XML, source of
|
||||||
|
truth) and the compiled bootproject that the XAR runtime actually
|
||||||
|
loads. Ship the `.tsproj` because:
|
||||||
|
|
||||||
|
- Text format — reviewable in PR diffs, diffable in git
|
||||||
|
- Rebuildable across TC3 engineering versions (the XAE tool rebuilds
|
||||||
|
the bootproject from `.tsproj` on "Activate Configuration")
|
||||||
|
- Doesn't carry per-install state (target AmsNetId, source licensing)
|
||||||
|
|
||||||
|
Reconstruction workflow on the VM:
|
||||||
|
|
||||||
|
1. Open TC3 XAE (Visual Studio shell)
|
||||||
|
2. File → Open → `OtOpcUaTwinCatFixture.tsproj`
|
||||||
|
3. Target system → the VM's AmsNetId (set in System Manager → Routes)
|
||||||
|
4. Build → Build Solution (produces the bootproject)
|
||||||
|
5. Activate Configuration → Run Mode (deploys to XAR + starts the
|
||||||
|
runtime)
|
||||||
|
|
||||||
|
## Required project state
|
||||||
|
|
||||||
|
The smoke tests in `TwinCAT3SmokeTests.cs` depend on this exact GVL +
|
||||||
|
PLC setup. Missing or renamed symbols surface as ADS `DeviceSymbolNotFound`
|
||||||
|
or wrong-type read failures, not silent skips.
|
||||||
|
|
||||||
|
### Global Variable List: `GVL_Fixture`
|
||||||
|
|
||||||
|
```st
|
||||||
|
VAR_GLOBAL
|
||||||
|
// Monotonically-increasing counter; MAIN increments each cycle.
|
||||||
|
// Seed value 1234 picked so the smoke test can assert ">= 1234" without
|
||||||
|
// synchronising with the initial cycle.
|
||||||
|
nCounter : DINT := 1234;
|
||||||
|
|
||||||
|
// Scratch REAL for write-then-read round-trip test. Smoke test writes
|
||||||
|
// 42.5 + reads back.
|
||||||
|
rSetpoint : REAL := 0.0;
|
||||||
|
|
||||||
|
// Readable boolean with seed value TRUE. Reserved for future
|
||||||
|
// expansion (e.g. discovery / symbol-browse tests).
|
||||||
|
bFlag : BOOL := TRUE;
|
||||||
|
END_VAR
|
||||||
|
```
|
||||||
|
|
||||||
|
### PLC program: `MAIN`
|
||||||
|
|
||||||
|
```st
|
||||||
|
PROGRAM MAIN
|
||||||
|
VAR
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
// One-line program: increment the fixture counter every cycle.
|
||||||
|
// The native-notification smoke test subscribes to GVL_Fixture.nCounter
|
||||||
|
// + observes the monotonic changes without a write path.
|
||||||
|
GVL_Fixture.nCounter := GVL_Fixture.nCounter + 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task
|
||||||
|
|
||||||
|
- `PlcTask` — cyclic, 10 ms interval, priority 20
|
||||||
|
- Assigned to `MAIN`
|
||||||
|
|
||||||
|
### Runtime ID
|
||||||
|
|
||||||
|
- TC3 PLC runtime 1 (AMS port `851`) — the smoke-test fixture defaults
|
||||||
|
to this. Use runtime 2 / port `852` only if the single runtime is
|
||||||
|
already taken by another project on the same VM.
|
||||||
|
|
||||||
|
## XAR VM setup (one-time)
|
||||||
|
|
||||||
|
Full bootstrap lives in `docs/v2/dev-environment.md`. The TwinCAT-specific
|
||||||
|
steps:
|
||||||
|
|
||||||
|
1. **Create the Hyper-V VM** — Gen 2, Windows 10/11 64-bit, 4 GB RAM,
|
||||||
|
2 CPUs. External virtual switch so the dev box can reach
|
||||||
|
`<vm-ip>:48898`.
|
||||||
|
2. **Install TwinCAT 3 XAE + XAR** — free download from Beckhoff
|
||||||
|
(`www.beckhoff.com/en-en/products/automation/twincat/`). Activate the
|
||||||
|
7-day trial on first boot.
|
||||||
|
3. **Note the VM's AmsNetId** — shown in the TwinCAT system tray icon →
|
||||||
|
Properties → AMS NetId (format like `5.23.91.23.1.1`).
|
||||||
|
4. **Configure bilateral ADS route**:
|
||||||
|
- On the VM: System Manager → Routes → Add Route → dev box's
|
||||||
|
AmsNetId + IP
|
||||||
|
- On the dev box: edit `%TC_INSTALLPATH%\Target\StaticRoutes.xml` (or
|
||||||
|
use the dev box's own TwinCAT System Manager if installed) to add
|
||||||
|
the VM's AmsNetId + IP
|
||||||
|
5. **Import this project** per the reconstruction workflow above.
|
||||||
|
6. **Hit Activate Configuration + Run Mode**. The runtime starts; the
|
||||||
|
system tray icon goes green; port `48898` is live.
|
||||||
|
|
||||||
|
## License rotation
|
||||||
|
|
||||||
|
The XAR trial expires every 7 days. When it lapses:
|
||||||
|
|
||||||
|
1. The runtime goes silent (red tray icon, ADS port `48898` stops
|
||||||
|
responding to new connections).
|
||||||
|
2. Integration tests skip with the reason message pointing at this
|
||||||
|
folder's README.
|
||||||
|
3. Operator runs `C:\TwinCAT\3.1\Target\StartUp\TcActivate.exe /reactivate`
|
||||||
|
on the VM console (not RDP — the trial activation wants the
|
||||||
|
interactive-login desktop).
|
||||||
|
|
||||||
|
Options to eliminate the manual step:
|
||||||
|
|
||||||
|
- **Scheduled task** that runs the reactivate every 6 days at 02:00 —
|
||||||
|
documented in the Beckhoff forums as working for some TC3 builds,
|
||||||
|
not officially supported.
|
||||||
|
- **Paid runtime license** (~$1k one-time per runtime, per CPU) — kills
|
||||||
|
the rotation permanently, worth it if the integration host is
|
||||||
|
long-lived.
|
||||||
|
|
||||||
|
## How to run the TwinCAT-tier tests
|
||||||
|
|
||||||
|
On the dev box:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:TWINCAT_TARGET_HOST = '10.0.0.42' # replace with the VM IP
|
||||||
|
$env:TWINCAT_TARGET_NETID = '5.23.91.23.1.1' # replace with the VM AmsNetId
|
||||||
|
# $env:TWINCAT_TARGET_PORT = '852' # only if not using PLC runtime 1
|
||||||
|
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests
|
||||||
|
```
|
||||||
|
|
||||||
|
With any of those env vars unset, all three smoke tests skip cleanly via
|
||||||
|
`[TwinCATFact]`; unit suite (`TwinCAT.Tests`) runs unchanged.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [`docs/drivers/TwinCAT-Test-Fixture.md`](../../../docs/drivers/TwinCAT-Test-Fixture.md)
|
||||||
|
— coverage map
|
||||||
|
- [`docs/v2/dev-environment.md`](../../../docs/v2/dev-environment.md)
|
||||||
|
§Integration host — VM + route + license-rotation notes
|
||||||
|
- Beckhoff Information System → TwinCAT 3 → Product overview + ADS +
|
||||||
|
PLC reference (licensed; internal link only)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="TwinCatProject\**\*" CopyToOutputDirectory="PreserveNewest"/>
|
||||||
|
</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,57 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class DriverEquipmentContentRegistryTests
|
||||||
|
{
|
||||||
|
private static readonly EquipmentNamespaceContent EmptyContent =
|
||||||
|
new(Areas: [], Lines: [], Equipment: [], Tags: []);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Get_Returns_Null_For_Unknown_Driver()
|
||||||
|
{
|
||||||
|
var registry = new DriverEquipmentContentRegistry();
|
||||||
|
registry.Get("galaxy-prod").ShouldBeNull();
|
||||||
|
registry.Count.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Set_Then_Get_Returns_Stored_Content()
|
||||||
|
{
|
||||||
|
var registry = new DriverEquipmentContentRegistry();
|
||||||
|
registry.Set("galaxy-prod", EmptyContent);
|
||||||
|
|
||||||
|
registry.Get("galaxy-prod").ShouldBeSameAs(EmptyContent);
|
||||||
|
registry.Count.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Get_Is_Case_Insensitive_For_Driver_Id()
|
||||||
|
{
|
||||||
|
// DriverInstanceId keys are OrdinalIgnoreCase across the codebase (Equipment /
|
||||||
|
// Tag rows, walker grouping). Registry matches that contract so callers don't have
|
||||||
|
// to canonicalize driver ids before lookup.
|
||||||
|
var registry = new DriverEquipmentContentRegistry();
|
||||||
|
registry.Set("Galaxy-Prod", EmptyContent);
|
||||||
|
registry.Get("galaxy-prod").ShouldBeSameAs(EmptyContent);
|
||||||
|
registry.Get("GALAXY-PROD").ShouldBeSameAs(EmptyContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Set_Overwrites_Existing_Content_For_Same_Driver()
|
||||||
|
{
|
||||||
|
var registry = new DriverEquipmentContentRegistry();
|
||||||
|
var first = EmptyContent;
|
||||||
|
var second = new EquipmentNamespaceContent([], [], [], []);
|
||||||
|
|
||||||
|
registry.Set("galaxy-prod", first);
|
||||||
|
registry.Set("galaxy-prod", second);
|
||||||
|
|
||||||
|
registry.Get("galaxy-prod").ShouldBeSameAs(second);
|
||||||
|
registry.Count.ShouldBe(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
using Opc.Ua;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end authz regression test for the ADR-001 Task B close-out of task #195.
|
||||||
|
/// Walks the full dispatch flow for a read against an Equipment / Identification
|
||||||
|
/// property: ScopePathIndexBuilder → NodeScopeResolver → AuthorizationGate → PermissionTrie.
|
||||||
|
/// Proves the contract the IdentificationFolderBuilder docstring promises — a user
|
||||||
|
/// without the Equipment-scope grant gets denied on the Identification sub-folder the
|
||||||
|
/// same way they would be denied on the Equipment node itself, because they share the
|
||||||
|
/// Equipment ScopeId (no new scope level for Identification per the builder's remark
|
||||||
|
/// section).
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class EquipmentIdentificationAuthzTests
|
||||||
|
{
|
||||||
|
private const string Cluster = "c-warsaw";
|
||||||
|
private const string Namespace = "ns-plc";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Authorized_Group_Read_Granted_On_Identification_Property()
|
||||||
|
{
|
||||||
|
var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
|
||||||
|
var scope = resolver.Resolve("plcaddr-manufacturer");
|
||||||
|
|
||||||
|
var identity = new FakeIdentity("alice", ["cn=line-a-operators"]);
|
||||||
|
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unauthorized_Group_Read_Denied_On_Identification_Property()
|
||||||
|
{
|
||||||
|
// The contract in task #195 + the IdentificationFolderBuilder docstring: "a user
|
||||||
|
// without the grant gets BadUserAccessDenied on both the Equipment node + its
|
||||||
|
// Identification variables." This test proves the evaluator side of that contract;
|
||||||
|
// the BadUserAccessDenied surfacing happens in the DriverNodeManager dispatch that
|
||||||
|
// already wires AuthorizationGate.IsAllowed → StatusCodes.BadUserAccessDenied.
|
||||||
|
var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
|
||||||
|
var scope = resolver.Resolve("plcaddr-manufacturer");
|
||||||
|
|
||||||
|
var identity = new FakeIdentity("bob", ["cn=other-team"]);
|
||||||
|
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Equipment_Grant_Cascades_To_Its_Identification_Properties()
|
||||||
|
{
|
||||||
|
// Identification properties share their parent Equipment's ScopeId (no new scope
|
||||||
|
// level). An Equipment-scope grant must therefore read both — the Equipment's tag
|
||||||
|
// AND its Identification properties — via the same evaluator call path.
|
||||||
|
var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
|
||||||
|
|
||||||
|
var tagScope = resolver.Resolve("plcaddr-temperature");
|
||||||
|
var identityScope = resolver.Resolve("plcaddr-manufacturer");
|
||||||
|
|
||||||
|
var identity = new FakeIdentity("alice", ["cn=line-a-operators"]);
|
||||||
|
gate.IsAllowed(identity, OpcUaOperation.Read, tagScope).ShouldBeTrue();
|
||||||
|
gate.IsAllowed(identity, OpcUaOperation.Read, identityScope).ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Different_Equipment_Grant_Does_Not_Leak_Across_Equipment_Boundary()
|
||||||
|
{
|
||||||
|
// Grant on oven-3; test reading a tag on press-7 (different equipment). Must deny
|
||||||
|
// so per-Equipment isolation holds at the dispatch layer — the ADR-001 Task B
|
||||||
|
// motivation for populating the full UNS path at resolve time.
|
||||||
|
var (gate, resolver) = BuildEvaluator(
|
||||||
|
equipmentGrantGroup: "cn=oven-3-operators",
|
||||||
|
equipmentIdForGrant: "eq-oven-3");
|
||||||
|
|
||||||
|
var pressScope = resolver.Resolve("plcaddr-press-7-temp"); // belongs to eq-press-7
|
||||||
|
|
||||||
|
var identity = new FakeIdentity("charlie", ["cn=oven-3-operators"]);
|
||||||
|
gate.IsAllowed(identity, OpcUaOperation.Read, pressScope).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- harness -----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the AuthorizationGate + NodeScopeResolver pair for a fixture with two
|
||||||
|
/// Equipment rows (oven-3 + press-7) under one UNS line, one NodeAcl grant at
|
||||||
|
/// Equipment scope for <paramref name="equipmentGrantGroup"/>, and a ScopePathIndex
|
||||||
|
/// populated via ScopePathIndexBuilder from the same Config-DB row set the
|
||||||
|
/// EquipmentNodeWalker would consume at address-space build.
|
||||||
|
/// </summary>
|
||||||
|
private static (AuthorizationGate Gate, NodeScopeResolver Resolver) BuildEvaluator(
|
||||||
|
string equipmentGrantGroup,
|
||||||
|
string equipmentIdForGrant = "eq-oven-3")
|
||||||
|
{
|
||||||
|
var (content, scopeIndex) = BuildFixture();
|
||||||
|
var resolver = new NodeScopeResolver(Cluster, scopeIndex);
|
||||||
|
|
||||||
|
var aclRow = new NodeAcl
|
||||||
|
{
|
||||||
|
NodeAclRowId = Guid.NewGuid(),
|
||||||
|
NodeAclId = Guid.NewGuid().ToString(),
|
||||||
|
GenerationId = 1,
|
||||||
|
ClusterId = Cluster,
|
||||||
|
LdapGroup = equipmentGrantGroup,
|
||||||
|
ScopeKind = NodeAclScopeKind.Equipment,
|
||||||
|
ScopeId = equipmentIdForGrant,
|
||||||
|
PermissionFlags = NodePermissions.Browse | NodePermissions.Read,
|
||||||
|
};
|
||||||
|
var paths = new Dictionary<string, NodeAclPath>
|
||||||
|
{
|
||||||
|
[equipmentIdForGrant] = new NodeAclPath(new[] { Namespace, "area-1", "line-a", equipmentIdForGrant }),
|
||||||
|
};
|
||||||
|
|
||||||
|
var cache = new PermissionTrieCache();
|
||||||
|
cache.Install(PermissionTrieBuilder.Build(Cluster, 1, [aclRow], paths));
|
||||||
|
var evaluator = new TriePermissionEvaluator(cache);
|
||||||
|
var gate = new AuthorizationGate(evaluator, strictMode: true);
|
||||||
|
|
||||||
|
_ = content;
|
||||||
|
return (gate, resolver);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (EquipmentNamespaceContent, IReadOnlyDictionary<string, NodeScope>) BuildFixture()
|
||||||
|
{
|
||||||
|
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = Cluster, Name = "warsaw", GenerationId = 1 };
|
||||||
|
var line = new UnsLine { UnsLineId = "line-a", UnsAreaId = "area-1", Name = "line-a", GenerationId = 1 };
|
||||||
|
|
||||||
|
var oven = new Equipment
|
||||||
|
{
|
||||||
|
EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
|
||||||
|
EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(),
|
||||||
|
DriverInstanceId = "drv", UnsLineId = "line-a", Name = "oven-3",
|
||||||
|
MachineCode = "MC-oven-3", Manufacturer = "Trumpf",
|
||||||
|
};
|
||||||
|
var press = new Equipment
|
||||||
|
{
|
||||||
|
EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
|
||||||
|
EquipmentId = "eq-press-7", EquipmentUuid = Guid.NewGuid(),
|
||||||
|
DriverInstanceId = "drv", UnsLineId = "line-a", Name = "press-7",
|
||||||
|
MachineCode = "MC-press-7",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Two tags for oven-3, one for press-7. Use Tag.TagConfig as the driver-side full
|
||||||
|
// reference the dispatch layer passes to NodeScopeResolver.Resolve.
|
||||||
|
var tempTag = NewTag("tag-1", "Temperature", "Int32", "plcaddr-temperature", "eq-oven-3");
|
||||||
|
var mfgTag = NewTag("tag-2", "Manufacturer", "String", "plcaddr-manufacturer", "eq-oven-3");
|
||||||
|
var pressTempTag = NewTag("tag-3", "PressTemp", "Int32", "plcaddr-press-7-temp", "eq-press-7");
|
||||||
|
|
||||||
|
var content = new EquipmentNamespaceContent(
|
||||||
|
Areas: [area],
|
||||||
|
Lines: [line],
|
||||||
|
Equipment: [oven, press],
|
||||||
|
Tags: [tempTag, mfgTag, pressTempTag]);
|
||||||
|
|
||||||
|
var index = ScopePathIndexBuilder.Build(Cluster, Namespace, content);
|
||||||
|
return (content, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = TagAccessLevel.ReadWrite, TagConfig = address,
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class FakeIdentity : UserIdentity, ILdapGroupsBearer
|
||||||
|
{
|
||||||
|
public FakeIdentity(string name, IReadOnlyList<string> groups)
|
||||||
|
{
|
||||||
|
DisplayName = name;
|
||||||
|
LdapGroups = groups;
|
||||||
|
}
|
||||||
|
public new string DisplayName { get; }
|
||||||
|
public IReadOnlyList<string> LdapGroups { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class EquipmentNamespaceContentLoaderTests : IDisposable
|
||||||
|
{
|
||||||
|
private const string DriverId = "galaxy-prod";
|
||||||
|
private const string OtherDriverId = "galaxy-dev";
|
||||||
|
private const long Gen = 5;
|
||||||
|
|
||||||
|
private readonly OtOpcUaConfigDbContext _db;
|
||||||
|
private readonly EquipmentNamespaceContentLoader _loader;
|
||||||
|
|
||||||
|
public EquipmentNamespaceContentLoaderTests()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||||
|
.UseInMemoryDatabase($"eq-content-loader-{Guid.NewGuid():N}")
|
||||||
|
.Options;
|
||||||
|
_db = new OtOpcUaConfigDbContext(options);
|
||||||
|
_loader = new EquipmentNamespaceContentLoader(_db);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _db.Dispose();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Returns_Null_When_Driver_Has_No_Equipment_At_Generation()
|
||||||
|
{
|
||||||
|
var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
|
||||||
|
result.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Loads_Areas_Lines_Equipment_Tags_For_Driver_At_Generation()
|
||||||
|
{
|
||||||
|
SeedBaseline();
|
||||||
|
|
||||||
|
var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
|
||||||
|
|
||||||
|
result.ShouldNotBeNull();
|
||||||
|
result!.Areas.ShouldHaveSingleItem().UnsAreaId.ShouldBe("area-1");
|
||||||
|
result.Lines.ShouldHaveSingleItem().UnsLineId.ShouldBe("line-a");
|
||||||
|
result.Equipment.Count.ShouldBe(2);
|
||||||
|
result.Equipment.ShouldContain(e => e.EquipmentId == "eq-oven-3");
|
||||||
|
result.Equipment.ShouldContain(e => e.EquipmentId == "eq-press-7");
|
||||||
|
result.Tags.Count.ShouldBe(2);
|
||||||
|
result.Tags.ShouldContain(t => t.TagId == "tag-temp");
|
||||||
|
result.Tags.ShouldContain(t => t.TagId == "tag-press");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Skips_Other_Drivers_Equipment()
|
||||||
|
{
|
||||||
|
SeedBaseline();
|
||||||
|
|
||||||
|
// Equipment + Tag owned by a different driver at the same generation — must not leak.
|
||||||
|
_db.Equipment.Add(new Equipment
|
||||||
|
{
|
||||||
|
EquipmentRowId = Guid.NewGuid(), GenerationId = Gen,
|
||||||
|
EquipmentId = "eq-other", EquipmentUuid = Guid.NewGuid(),
|
||||||
|
DriverInstanceId = OtherDriverId, UnsLineId = "line-a", Name = "other-eq",
|
||||||
|
MachineCode = "MC-other",
|
||||||
|
});
|
||||||
|
_db.Tags.Add(new Tag
|
||||||
|
{
|
||||||
|
TagRowId = Guid.NewGuid(), GenerationId = Gen, TagId = "tag-other",
|
||||||
|
DriverInstanceId = OtherDriverId, EquipmentId = "eq-other",
|
||||||
|
Name = "OtherTag", DataType = "Int32",
|
||||||
|
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-other",
|
||||||
|
});
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
|
||||||
|
|
||||||
|
result.ShouldNotBeNull();
|
||||||
|
result!.Equipment.ShouldNotContain(e => e.EquipmentId == "eq-other");
|
||||||
|
result.Tags.ShouldNotContain(t => t.TagId == "tag-other");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Skips_Other_Generations()
|
||||||
|
{
|
||||||
|
SeedBaseline();
|
||||||
|
|
||||||
|
// Same driver, different generation — must not leak in. Walker consumes one sealed
|
||||||
|
// generation per bootstrap per decision #148.
|
||||||
|
_db.Equipment.Add(new Equipment
|
||||||
|
{
|
||||||
|
EquipmentRowId = Guid.NewGuid(), GenerationId = 99,
|
||||||
|
EquipmentId = "eq-futuristic", EquipmentUuid = Guid.NewGuid(),
|
||||||
|
DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "futuristic",
|
||||||
|
MachineCode = "MC-fut",
|
||||||
|
});
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
|
||||||
|
|
||||||
|
result.ShouldNotBeNull();
|
||||||
|
result!.Equipment.ShouldNotContain(e => e.EquipmentId == "eq-futuristic");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Skips_Disabled_Equipment()
|
||||||
|
{
|
||||||
|
SeedBaseline();
|
||||||
|
|
||||||
|
_db.Equipment.Add(new Equipment
|
||||||
|
{
|
||||||
|
EquipmentRowId = Guid.NewGuid(), GenerationId = Gen,
|
||||||
|
EquipmentId = "eq-disabled", EquipmentUuid = Guid.NewGuid(),
|
||||||
|
DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "disabled-eq",
|
||||||
|
MachineCode = "MC-dis", Enabled = false,
|
||||||
|
});
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
|
||||||
|
|
||||||
|
result.ShouldNotBeNull();
|
||||||
|
result!.Equipment.ShouldNotContain(e => e.EquipmentId == "eq-disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SeedBaseline()
|
||||||
|
{
|
||||||
|
_db.UnsAreas.Add(new UnsArea
|
||||||
|
{
|
||||||
|
UnsAreaRowId = Guid.NewGuid(), UnsAreaId = "area-1", ClusterId = "c-warsaw",
|
||||||
|
Name = "warsaw", GenerationId = Gen,
|
||||||
|
});
|
||||||
|
_db.UnsLines.Add(new UnsLine
|
||||||
|
{
|
||||||
|
UnsLineRowId = Guid.NewGuid(), UnsLineId = "line-a", UnsAreaId = "area-1",
|
||||||
|
Name = "line-a", GenerationId = Gen,
|
||||||
|
});
|
||||||
|
_db.Equipment.AddRange(
|
||||||
|
new Equipment
|
||||||
|
{
|
||||||
|
EquipmentRowId = Guid.NewGuid(), GenerationId = Gen,
|
||||||
|
EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(),
|
||||||
|
DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "oven-3",
|
||||||
|
MachineCode = "MC-oven-3",
|
||||||
|
},
|
||||||
|
new Equipment
|
||||||
|
{
|
||||||
|
EquipmentRowId = Guid.NewGuid(), GenerationId = Gen,
|
||||||
|
EquipmentId = "eq-press-7", EquipmentUuid = Guid.NewGuid(),
|
||||||
|
DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "press-7",
|
||||||
|
MachineCode = "MC-press-7",
|
||||||
|
});
|
||||||
|
_db.Tags.AddRange(
|
||||||
|
new Tag
|
||||||
|
{
|
||||||
|
TagRowId = Guid.NewGuid(), GenerationId = Gen, TagId = "tag-temp",
|
||||||
|
DriverInstanceId = DriverId, EquipmentId = "eq-oven-3",
|
||||||
|
Name = "Temperature", DataType = "Int32",
|
||||||
|
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-temperature",
|
||||||
|
},
|
||||||
|
new Tag
|
||||||
|
{
|
||||||
|
TagRowId = Guid.NewGuid(), GenerationId = Gen, TagId = "tag-press",
|
||||||
|
DriverInstanceId = DriverId, EquipmentId = "eq-press-7",
|
||||||
|
Name = "PressTemp", DataType = "Int32",
|
||||||
|
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-press-temp",
|
||||||
|
});
|
||||||
|
_db.SaveChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,19 +21,59 @@ public sealed class NodeScopeResolverTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Resolve_Leaves_UnsPath_Null_For_Phase1()
|
public void Resolve_Leaves_UnsPath_Null_When_NoIndexSupplied()
|
||||||
{
|
{
|
||||||
var resolver = new NodeScopeResolver("c-1");
|
var resolver = new NodeScopeResolver("c-1");
|
||||||
|
|
||||||
var scope = resolver.Resolve("tag-1");
|
var scope = resolver.Resolve("tag-1");
|
||||||
|
|
||||||
// Phase 1 flat scope — finer resolution tracked as Stream C.12 follow-up.
|
// Cluster-only fallback path — used pre-ADR-001 and still the active path for
|
||||||
|
// unindexed references (e.g. driver-discovered tags that have no Tag row yet).
|
||||||
scope.NamespaceId.ShouldBeNull();
|
scope.NamespaceId.ShouldBeNull();
|
||||||
scope.UnsAreaId.ShouldBeNull();
|
scope.UnsAreaId.ShouldBeNull();
|
||||||
scope.UnsLineId.ShouldBeNull();
|
scope.UnsLineId.ShouldBeNull();
|
||||||
scope.EquipmentId.ShouldBeNull();
|
scope.EquipmentId.ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_Returns_IndexedScope_When_FullReferenceFound()
|
||||||
|
{
|
||||||
|
var index = new Dictionary<string, NodeScope>
|
||||||
|
{
|
||||||
|
["plcaddr-01"] = new NodeScope
|
||||||
|
{
|
||||||
|
ClusterId = "c-1", NamespaceId = "ns-plc", UnsAreaId = "area-1",
|
||||||
|
UnsLineId = "line-a", EquipmentId = "eq-oven-3", TagId = "plcaddr-01",
|
||||||
|
Kind = NodeHierarchyKind.Equipment,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var resolver = new NodeScopeResolver("c-1", index);
|
||||||
|
|
||||||
|
var scope = resolver.Resolve("plcaddr-01");
|
||||||
|
|
||||||
|
scope.UnsAreaId.ShouldBe("area-1");
|
||||||
|
scope.UnsLineId.ShouldBe("line-a");
|
||||||
|
scope.EquipmentId.ShouldBe("eq-oven-3");
|
||||||
|
scope.TagId.ShouldBe("plcaddr-01");
|
||||||
|
scope.NamespaceId.ShouldBe("ns-plc");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_FallsBack_To_ClusterOnly_When_Reference_NotIndexed()
|
||||||
|
{
|
||||||
|
var index = new Dictionary<string, NodeScope>
|
||||||
|
{
|
||||||
|
["plcaddr-01"] = new NodeScope { ClusterId = "c-1", TagId = "plcaddr-01", Kind = NodeHierarchyKind.Equipment },
|
||||||
|
};
|
||||||
|
var resolver = new NodeScopeResolver("c-1", index);
|
||||||
|
|
||||||
|
var scope = resolver.Resolve("not-in-index");
|
||||||
|
|
||||||
|
scope.ClusterId.ShouldBe("c-1");
|
||||||
|
scope.TagId.ShouldBe("not-in-index");
|
||||||
|
scope.EquipmentId.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Resolve_Throws_OnEmptyFullReference()
|
public void Resolve_Throws_OnEmptyFullReference()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Opc.Ua;
|
||||||
|
using Opc.Ua.Client;
|
||||||
|
using Opc.Ua.Configuration;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end proof that ADR-001 Option A wire-in (#212) flows: when
|
||||||
|
/// <see cref="OpcUaApplicationHost"/> is given an <c>equipmentContentLookup</c> that
|
||||||
|
/// returns a non-null <see cref="EquipmentNamespaceContent"/>, the walker runs BEFORE
|
||||||
|
/// the driver's DiscoverAsync + the UNS folder skeleton (Area → Line → Equipment) +
|
||||||
|
/// identifier properties are materialized into the driver's namespace + visible to an
|
||||||
|
/// OPC UA client via standard browse.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public sealed class OpcUaEquipmentWalkerIntegrationTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private static readonly int Port = 48500 + Random.Shared.Next(0, 99);
|
||||||
|
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaWalkerTest";
|
||||||
|
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-walker-{Guid.NewGuid():N}");
|
||||||
|
private const string DriverId = "galaxy-prod";
|
||||||
|
|
||||||
|
private DriverHost _driverHost = null!;
|
||||||
|
private OpcUaApplicationHost _server = null!;
|
||||||
|
|
||||||
|
public async ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
_driverHost = new DriverHost();
|
||||||
|
await _driverHost.RegisterAsync(new EmptyDriver(DriverId), "{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var content = BuildFixture();
|
||||||
|
|
||||||
|
var options = new OpcUaServerOptions
|
||||||
|
{
|
||||||
|
EndpointUrl = _endpoint,
|
||||||
|
ApplicationName = "OtOpcUaWalkerTest",
|
||||||
|
ApplicationUri = "urn:OtOpcUa:Server:WalkerTest",
|
||||||
|
PkiStoreRoot = _pkiRoot,
|
||||||
|
AutoAcceptUntrustedClientCertificates = true,
|
||||||
|
HealthEndpointsEnabled = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
_server = new OpcUaApplicationHost(
|
||||||
|
options, _driverHost, new DenyAllUserAuthenticator(),
|
||||||
|
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance,
|
||||||
|
equipmentContentLookup: id => id == DriverId ? content : null);
|
||||||
|
|
||||||
|
await _server.StartAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _server.DisposeAsync();
|
||||||
|
await _driverHost.DisposeAsync();
|
||||||
|
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Walker_Materializes_Area_Line_Equipment_Folders_Visible_Via_Browse()
|
||||||
|
{
|
||||||
|
using var session = await OpenSessionAsync();
|
||||||
|
var nsIndex = (ushort)session.NamespaceUris.GetIndex($"urn:OtOpcUa:{DriverId}");
|
||||||
|
|
||||||
|
var areaFolder = new NodeId($"{DriverId}/warsaw", nsIndex);
|
||||||
|
var lineFolder = new NodeId($"{DriverId}/warsaw/line-a", nsIndex);
|
||||||
|
var equipmentFolder = new NodeId($"{DriverId}/warsaw/line-a/oven-3", nsIndex);
|
||||||
|
|
||||||
|
BrowseChildren(session, areaFolder).ShouldContain(r => r.BrowseName.Name == "line-a");
|
||||||
|
BrowseChildren(session, lineFolder).ShouldContain(r => r.BrowseName.Name == "oven-3");
|
||||||
|
|
||||||
|
var equipmentChildren = BrowseChildren(session, equipmentFolder);
|
||||||
|
equipmentChildren.ShouldContain(r => r.BrowseName.Name == "EquipmentId");
|
||||||
|
equipmentChildren.ShouldContain(r => r.BrowseName.Name == "EquipmentUuid");
|
||||||
|
equipmentChildren.ShouldContain(r => r.BrowseName.Name == "MachineCode");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Walker_Emits_Tag_Variable_Under_Equipment_Readable_By_Client()
|
||||||
|
{
|
||||||
|
using var session = await OpenSessionAsync();
|
||||||
|
var nsIndex = (ushort)session.NamespaceUris.GetIndex($"urn:OtOpcUa:{DriverId}");
|
||||||
|
|
||||||
|
var tagNode = new NodeId("plcaddr-temperature", nsIndex);
|
||||||
|
var equipmentFolder = new NodeId($"{DriverId}/warsaw/line-a/oven-3", nsIndex);
|
||||||
|
|
||||||
|
BrowseChildren(session, equipmentFolder).ShouldContain(r => r.BrowseName.Name == "Temperature");
|
||||||
|
|
||||||
|
var dv = session.ReadValue(tagNode);
|
||||||
|
dv.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReferenceDescriptionCollection BrowseChildren(ISession session, NodeId node)
|
||||||
|
{
|
||||||
|
session.Browse(null, null, node, 0, BrowseDirection.Forward,
|
||||||
|
ReferenceTypeIds.HierarchicalReferences, true,
|
||||||
|
(uint)NodeClass.Object | (uint)NodeClass.Variable,
|
||||||
|
out _, out var refs);
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EquipmentNamespaceContent BuildFixture()
|
||||||
|
{
|
||||||
|
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c-local", Name = "warsaw", GenerationId = 1 };
|
||||||
|
var line = new UnsLine { UnsLineId = "line-a", UnsAreaId = "area-1", Name = "line-a", GenerationId = 1 };
|
||||||
|
var oven = new Equipment
|
||||||
|
{
|
||||||
|
EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
|
||||||
|
EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(),
|
||||||
|
DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "oven-3",
|
||||||
|
MachineCode = "MC-oven-3",
|
||||||
|
};
|
||||||
|
var tempTag = new Tag
|
||||||
|
{
|
||||||
|
TagRowId = Guid.NewGuid(), GenerationId = 1, TagId = "tag-1",
|
||||||
|
DriverInstanceId = DriverId, EquipmentId = "eq-oven-3",
|
||||||
|
Name = "Temperature", DataType = "Int32",
|
||||||
|
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-temperature",
|
||||||
|
};
|
||||||
|
|
||||||
|
return new EquipmentNamespaceContent(
|
||||||
|
Areas: new[] { area },
|
||||||
|
Lines: new[] { line },
|
||||||
|
Equipment: new[] { oven },
|
||||||
|
Tags: new[] { tempTag });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ISession> OpenSessionAsync()
|
||||||
|
{
|
||||||
|
var cfg = new ApplicationConfiguration
|
||||||
|
{
|
||||||
|
ApplicationName = "OtOpcUaWalkerTestClient",
|
||||||
|
ApplicationUri = "urn:OtOpcUa:WalkerTestClient",
|
||||||
|
ApplicationType = ApplicationType.Client,
|
||||||
|
SecurityConfiguration = new SecurityConfiguration
|
||||||
|
{
|
||||||
|
ApplicationCertificate = new CertificateIdentifier
|
||||||
|
{
|
||||||
|
StoreType = CertificateStoreType.Directory,
|
||||||
|
StorePath = Path.Combine(_pkiRoot, "client-own"),
|
||||||
|
SubjectName = "CN=OtOpcUaWalkerTestClient",
|
||||||
|
},
|
||||||
|
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
|
||||||
|
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
|
||||||
|
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
|
||||||
|
AutoAcceptUntrustedCertificates = true,
|
||||||
|
AddAppCertToTrustedStore = true,
|
||||||
|
},
|
||||||
|
TransportConfigurations = new TransportConfigurationCollection(),
|
||||||
|
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
|
||||||
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
||||||
|
};
|
||||||
|
await cfg.Validate(ApplicationType.Client);
|
||||||
|
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||||
|
|
||||||
|
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
|
||||||
|
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
|
||||||
|
|
||||||
|
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
|
||||||
|
var endpointConfig = EndpointConfiguration.Create(cfg);
|
||||||
|
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||||
|
|
||||||
|
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaWalkerTestClientSession", 60000,
|
||||||
|
new UserIdentity(new AnonymousIdentityToken()), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver that registers into the host + implements DiscoverAsync as a no-op. The
|
||||||
|
/// walker is the sole source of address-space content; if the UNS folders appear
|
||||||
|
/// under browse, they came from the wire-in (not from the driver's own discovery).
|
||||||
|
/// </summary>
|
||||||
|
private sealed class EmptyDriver : IDriver, ITagDiscovery, IReadable
|
||||||
|
{
|
||||||
|
public EmptyDriver(string id) { DriverInstanceId = id; }
|
||||||
|
public string DriverInstanceId { get; }
|
||||||
|
public string DriverType => "EmptyForWalkerTest";
|
||||||
|
|
||||||
|
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
|
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||||
|
public long GetMemoryFootprint() => 0;
|
||||||
|
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||||
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
IReadOnlyList<DataValueSnapshot> result =
|
||||||
|
fullReferences.Select(_ => new DataValueSnapshot(0, 0u, now, now)).ToArray();
|
||||||
|
return Task.FromResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user