task-galaxy-e2e branch — non-FOCAS work-in-progress snapshot
Catch-all commit for pending work on the task-galaxy-e2e branch that
wasn't part of the FOCAS migration. Grouping by topic so future per-topic
commits can be cherry-picked if needed.
TwinCAT
- src/.../Driver.TwinCAT/AdsTwinCATClient.cs + TwinCATDriverFactoryExtensions.cs:
factory-registration extensions + ADS client refinements.
- src/.../Driver.TwinCAT.Cli/Commands/BrowseCommand.cs: new browse command
for the TwinCAT test-client CLI.
- tests/.../Driver.TwinCAT.IntegrationTests/TwinCAT3SmokeTests.cs + TwinCatProject/:
fixture scaffold with a minimal POU + README pointing at the TCBSD/ESXi
VM for e2e.
- docs/Driver.TwinCAT.Cli.md + docs/drivers/TwinCAT-Test-Fixture.md:
documentation for the above.
- docs/v3/twincat-backlog.md: forward-looking backlog seed.
Admin UI + fleet status
- src/.../Admin/Components/Pages/Clusters/DriversTab.razor + Hosts.razor:
UI refresh for fleet-status rendering.
- src/.../Admin/Hubs/FleetStatusHub.cs + FleetStatusPoller.cs +
Admin/Program.cs: SignalR hub + poller plumbing for live fleet data.
- tests/.../Admin.Tests/FleetStatusPollerTests.cs: poller coverage.
Server + redundancy runtime (Phase 6.3 follow-ups)
- src/.../Server/Hosting/RedundancyPublisherHostedService.cs: HostedService
that owns the RedundancyStatePublisher lifecycle + wires peer reachability.
- src/.../Server/Redundancy/ServerRedundancyNodeWriter.cs: OPC UA
variable-node writer binding ServiceLevel + ServerUriArray to the
publisher's events.
- src/.../Server/Program.cs + Server.csproj: hosted-service registration.
- tests/.../Server.Tests/ServerRedundancyNodeWriterTests.cs +
Server.Tests.csproj: coverage for the above.
Configuration
- src/.../Configuration/Validation/DraftValidator.cs +
tests/.../Configuration.Tests/DraftValidatorTests.cs: draft-validation
refinements.
E2E scripts (shared infrastructure)
- scripts/e2e/README.md + _common.ps1 + test-all.ps1: shared helpers + the
all-drivers test-all runner.
- scripts/e2e/test-opcuaclient.ps1: OPC UA Client e2e runner.
Docs
- docs/v2/implementation/phase-6-{1,2,3,4}*.md + exit-gate-phase-{3,7}.md:
phase-gate + implementation doc updates.
- docs/v2/plan.md: top-level plan refresh.
- docs/v2/redundancy-interop-playbook.md: client interop playbook for the
Phase 6.3 redundancy-runtime work.
Two orphan FOCAS docs remain on disk but deliberately unstaged —
docs/v2/focas-deployment.md and docs/v2/implementation/focas-simulator-plan.md
describe the now-retired Tier-C topology and should either be rewritten
or deleted in a follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
# `otopcua-twincat-cli` — Beckhoff TwinCAT test client
|
||||
|
||||
Ad-hoc probe / read / write / subscribe tool for Beckhoff TwinCAT 2 / TwinCAT 3
|
||||
runtimes via ADS. Uses the **same** `TwinCATDriver` the OtOpcUa server does
|
||||
(`Beckhoff.TwinCAT.Ads` package). Native ADS notifications by default;
|
||||
`--poll-only` falls back to the shared `PollGroupEngine`.
|
||||
Ad-hoc probe / read / write / subscribe / browse tool for Beckhoff TwinCAT 2 /
|
||||
TwinCAT 3 runtimes via ADS. Uses the **same** `TwinCATDriver` the OtOpcUa
|
||||
server does (`Beckhoff.TwinCAT.Ads` package). Native ADS notifications by
|
||||
default; `--poll-only` falls back to the shared `PollGroupEngine`.
|
||||
|
||||
Fifth (final) of the driver test-client CLIs.
|
||||
|
||||
@@ -50,6 +50,13 @@ caller interpret semantics.
|
||||
|
||||
### `probe`
|
||||
|
||||
Per-command flags:
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `-s` / `--symbol` | **required** | Symbol path to probe (e.g. `MAIN.bRunning`) |
|
||||
| `--type` | `DInt` | Declared data type — see the [Data types](#data-types) list |
|
||||
|
||||
```powershell
|
||||
# Local TwinCAT 3, probe a canonical global
|
||||
otopcua-twincat-cli probe -n 127.0.0.1.1.1 -s "TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt"
|
||||
@@ -89,6 +96,14 @@ Structure writes refused — drop to driver config JSON for those.
|
||||
|
||||
### `subscribe`
|
||||
|
||||
Per-command flags:
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `-s` / `--symbol` | **required** | Symbol path — same format as `read` |
|
||||
| `-t` / `--type` | `DInt` | Declared data type |
|
||||
| `-i` / `--interval-ms` | `1000` | Publishing interval in **milliseconds** — native mode passes this as the ADS `NotificationSettings.CycleTime` |
|
||||
|
||||
```powershell
|
||||
# Native ADS notifications (default) — PLC pushes on its own cycle
|
||||
otopcua-twincat-cli subscribe -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -i 500
|
||||
@@ -99,3 +114,23 @@ otopcua-twincat-cli subscribe -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -i 500
|
||||
|
||||
The subscribe banner announces which mechanism is in play — "ADS notification"
|
||||
or "polling" — so it's obvious in screen-recorded bug reports.
|
||||
|
||||
### `browse`
|
||||
|
||||
Walks the controller's symbol table via ADS `SymbolLoaderFactory` (same path
|
||||
`TwinCATDriver.DiscoverAsync` takes when `EnableControllerBrowse = true`).
|
||||
Output filters to symbols whose type maps onto the driver's atomic surface —
|
||||
UDTs / function-block instances don't appear.
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--prefix` | _(none)_ | Case-sensitive instance-path prefix filter (e.g. `GVL_Fixture`) |
|
||||
| `--max` | `500` | Max symbols to print. `0` = unbounded |
|
||||
|
||||
```powershell
|
||||
# Everything under a single GVL
|
||||
otopcua-twincat-cli browse -n 192.168.1.40.1.1 --prefix GVL_Fixture
|
||||
|
||||
# Full dump (beware: flat-mode walks on a real controller can top 10k symbols)
|
||||
otopcua-twincat-cli browse -n 192.168.1.40.1.1 --max 0
|
||||
```
|
||||
|
||||
@@ -2,60 +2,85 @@
|
||||
|
||||
Coverage map + gap inventory for the Beckhoff TwinCAT ADS driver.
|
||||
|
||||
**TL;DR:** Integration-test scaffolding lives at
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/` (task #221).
|
||||
`TwinCATXarFixture` probes TCP 48898 on an operator-supplied VM; three
|
||||
smoke tests (read / write / native notification) run end-to-end through
|
||||
the real ADS stack when the VM is reachable, skip cleanly otherwise.
|
||||
**Remaining operational work**: stand up a TwinCAT 3 XAR runtime in a
|
||||
Hyper-V VM, author the `.tsproj` project documented at
|
||||
`TwinCatProject/README.md`, rotate the 7-day trial license (or buy a
|
||||
paid runtime). Unit tests via `FakeTwinCATClient` still carry the
|
||||
exhaustive contract coverage.
|
||||
**TL;DR:** Integration-test suite lives at
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/`. `TwinCATXarFixture`
|
||||
probes TCP 48898 on an operator-supplied runtime; the suite runs **14
|
||||
`[TwinCATFact]` methods + one 16-case `[TwinCATTheory]` = 30 test cases** end-to-end
|
||||
through the real ADS stack when the runtime is reachable, skips cleanly
|
||||
otherwise. The runtime can be a Hyper-V XAR VM or a TCBSD VM
|
||||
(`TwinCatProject/README.md` covers both). Unit tests via `FakeTwinCATClient`
|
||||
still carry the exhaustive contract coverage alongside.
|
||||
|
||||
TwinCAT is the only driver outside Galaxy that uses **native
|
||||
notifications** (no polling) for `ISubscribable`, and the fake exposes a
|
||||
fire-event harness so notification routing is contract-tested rigorously
|
||||
at the unit layer.
|
||||
TwinCAT is the only driver outside Galaxy that uses **native notifications**
|
||||
(no polling) for `ISubscribable`. The integration suite verifies that path on
|
||||
the wire; the fake exposes a fire-event harness so notification routing is
|
||||
also contract-tested rigorously at the unit layer.
|
||||
|
||||
## What the fixture is
|
||||
|
||||
**Integration layer** (task #221, scaffolded):
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/` —
|
||||
`TwinCATXarFixture` TCP-probes ADS port 48898 on the host specified by
|
||||
`TWINCAT_TARGET_HOST` + requires `TWINCAT_TARGET_NETID` (AmsNetId of the
|
||||
VM). No fixture-owned lifecycle — XAR can't run in Docker because it
|
||||
bypasses the Windows kernel scheduler, so the VM stays
|
||||
operator-managed. `TwinCatProject/README.md` documents the required
|
||||
`.tsproj` project state; the file itself lands once the XAR VM is up +
|
||||
the project is authored. Three smoke tests:
|
||||
`Driver_reads_seeded_DINT_through_real_ADS`,
|
||||
`Driver_write_then_read_round_trip_on_scratch_REAL`, and
|
||||
`Driver_subscribe_receives_native_ADS_notifications_on_counter_changes`
|
||||
— all skip cleanly via `[TwinCATFact]` when the runtime isn't
|
||||
reachable.
|
||||
**Integration layer**: `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/`
|
||||
— `TwinCATXarFixture` TCP-probes ADS port 48898 on the host supplied by
|
||||
`TWINCAT_TARGET_HOST` (defaults to `localhost`) + requires
|
||||
`TWINCAT_TARGET_NETID` (AmsNetId of the runtime). Optionally takes
|
||||
`TWINCAT_TARGET_PORT` (default `851` = TC3 PLC runtime 1). No fixture-owned
|
||||
lifecycle — XAR / TCBSD can't run in Docker because they bypass the host
|
||||
kernel scheduler, so the runtime stays operator-managed.
|
||||
`TwinCatProject/README.md` documents the required project state; the tests
|
||||
gate on `[TwinCATFact]` / `[TwinCATTheory]` and skip cleanly when
|
||||
`TWINCAT_TARGET_NETID` is unset or the probe fails.
|
||||
|
||||
**Unit layer**: `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` is
|
||||
still the primary coverage. `FakeTwinCATClient` also fakes the
|
||||
`AddDeviceNotification` flow so tests can trigger callbacks without a
|
||||
running runtime.
|
||||
**Unit layer**: `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` remains the
|
||||
primary contract coverage. `FakeTwinCATClient` fakes the
|
||||
`AddDeviceNotification` flow so tests can trigger callbacks without a running
|
||||
runtime.
|
||||
|
||||
## What it actually covers
|
||||
|
||||
### Integration (XAR VM, task #221 — code scaffolded, needs VM + project)
|
||||
### Integration (live runtime)
|
||||
|
||||
- `TwinCAT3SmokeTests.Driver_reads_seeded_DINT_through_real_ADS` — real AMS
|
||||
handshake + ADS read of `GVL_Fixture.nCounter` (seeded at 1234, MAIN
|
||||
increments each cycle)
|
||||
- `TwinCAT3SmokeTests.Driver_write_then_read_round_trip_on_scratch_REAL` —
|
||||
real ADS write + read on `GVL_Fixture.rSetpoint`
|
||||
- `TwinCAT3SmokeTests.Driver_subscribe_receives_native_ADS_notifications_on_counter_changes`
|
||||
— real `AddDeviceNotification` against the cycle-incrementing counter;
|
||||
observes `OnDataChange` firing within 3 s of subscribe
|
||||
Every capability the driver implements is exercised on the wire:
|
||||
|
||||
All three gated on `TWINCAT_TARGET_HOST` + `TWINCAT_TARGET_NETID` env
|
||||
vars; skip cleanly via `[TwinCATFact]` when the VM isn't reachable or
|
||||
vars are unset.
|
||||
- **Read** — `Driver_reads_seeded_DINT_through_real_ADS` (AMS handshake +
|
||||
symbolic read of `GVL_Fixture.nCounter`)
|
||||
- **Write + read round-trip** — `Driver_write_then_read_round_trip_on_scratch_REAL`
|
||||
on `GVL_Fixture.rSetpoint`
|
||||
- **Array element round-trip** — `Driver_round_trips_array_element_write_and_read`
|
||||
on `GVL_Arrays.aReal1D[5]` (exercises `TwinCATSymbolPath` subscript
|
||||
rendering)
|
||||
- **Subscribe (native ADS notifications)** —
|
||||
`Driver_subscribe_receives_native_ADS_notifications_on_counter_changes`;
|
||||
observes `OnDataChange` firing within 10 s of subscribe
|
||||
- **Symbol browse (direct client path)** —
|
||||
`Driver_browses_committed_symbol_hierarchy_via_real_ADS` via
|
||||
`ITwinCATClient.BrowseSymbolsAsync`
|
||||
- **Symbol browse (through DiscoverAsync + `IAddressSpaceBuilder` pipeline)** —
|
||||
`DiscoverAsync_renders_declared_tags_and_controller_browse_hits_address_space_builder`
|
||||
verifies the real `TwinCAT/ → device/ → Discovered/` folder tree
|
||||
- **Auto-reconnect** — `Driver_auto_reconnects_after_underlying_client_is_disposed`
|
||||
disposes the `AdsClient` mid-flight; next read must re-establish
|
||||
- **Primitive type coverage** — `Driver_reads_every_primitive_type_with_correct_mapping`
|
||||
runs as a `[Theory]` against the 16 primitives in `GVL_Primitives`
|
||||
(Bool, SInt, USInt, Int, UInt, DInt, UDInt, LInt, ULInt, Real, LReal,
|
||||
String, Time, TimeOfDay, Date, DateTime) — asserts status + CLR type +
|
||||
seed value where ergonomic
|
||||
- **Bit-indexed BOOL** — `Driver_reads_bit_indexed_BOOL_from_word` against
|
||||
`GVL_Primitives.vWord.3` + `.4` (bits of `0xBEEF`)
|
||||
- **Nested UDT navigation** — `Driver_reads_deeply_nested_UDT_path` reads
|
||||
`GVL_Plant.Line1.Stations[1].Axes[1].Motor.Temperature` (LREAL) + `.Running` (BOOL)
|
||||
- **Multi-device routing + isolation** —
|
||||
`Driver_routes_reads_per_device_and_isolates_unreachable_peers` pairs the
|
||||
real runtime with a bogus AmsNetId; healthy device reads still succeed
|
||||
- **Probe loop + `IHostConnectivityProbe`** —
|
||||
`Probe_loop_raises_host_status_transition_to_Running_on_reachable_target`
|
||||
asserts `OnHostStatusChanged → Running` and snapshot parity
|
||||
- **Negative error mappings** —
|
||||
`Driver_reports_errors_for_unknown_tag_and_nonexistent_symbol_and_readonly_write`
|
||||
covers `BadNodeIdUnknown`, ghost-symbol communication errors, and the
|
||||
`BadNotWritable` short-circuit
|
||||
|
||||
All tests gate on `TWINCAT_TARGET_NETID` (required) via `[TwinCATFact]` /
|
||||
`[TwinCATTheory]`; `TWINCAT_TARGET_HOST` (default `localhost`) and
|
||||
`TWINCAT_TARGET_PORT` (default `851`) are optional overrides.
|
||||
|
||||
### Unit
|
||||
|
||||
@@ -65,54 +90,69 @@ vars are unset.
|
||||
- `TwinCATReadWriteTests` — read + write through the fake, status mapping
|
||||
- `TwinCATSymbolPathTests` — symbol-path routing for nested struct members
|
||||
- `TwinCATSymbolBrowserTests` — `ITagDiscovery.DiscoverAsync` via
|
||||
`ReadSymbolsAsync` (#188) + system-symbol filtering
|
||||
- `TwinCATNativeNotificationTests` — `AddDeviceNotification` (#189)
|
||||
registration, callback-delivery-to-`OnDataChange` wiring, unregister on
|
||||
unsubscribe
|
||||
`BrowseSymbolsAsync` + system-symbol filtering
|
||||
- `TwinCATNativeNotificationTests` — `AddDeviceNotification` registration,
|
||||
callback-delivery-to-`OnDataChange` wiring, unregister on unsubscribe
|
||||
- `TwinCATDriverTests` — `IDriver` lifecycle
|
||||
|
||||
Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
|
||||
`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
|
||||
`IPerCallHostResolver`.
|
||||
Capability surfaces whose contract is verified at the unit layer: `IDriver`,
|
||||
`IReadable`, `IWritable`, `ITagDiscovery`, `ISubscribable`,
|
||||
`IHostConnectivityProbe`, `IPerCallHostResolver`. The integration suite now
|
||||
verifies `ITagDiscovery` + `IHostConnectivityProbe` on the wire as well.
|
||||
|
||||
## Bugs caught by live runs
|
||||
|
||||
The integration suite surfaced three driver defects that `FakeTwinCATClient`
|
||||
couldn't, since each lived below the abstraction boundary:
|
||||
|
||||
1. **Notification cycle time unit** — `NotificationSettings(cycleTime, maxDelay)`
|
||||
takes **milliseconds** per Beckhoff InfoSys
|
||||
(`tcadsnetref/7313319051`), but the driver was multiplying by `10_000`
|
||||
under a "100 ns units" assumption. A requested 250 ms cycle was being set
|
||||
to ~41 minutes — subscribe never fired. Fix in `AdsTwinCATClient.AddNotificationAsync`.
|
||||
2. **`STRING(N)` / `WSTRING(N)` type mapper** — `MapSymbolTypeName` only
|
||||
matched bare `"STRING"` / `"WSTRING"`, so sized strings (the common case)
|
||||
fell off `BrowseSymbolsAsync` entirely. Fix: strip the `(…)` bound before
|
||||
the switch.
|
||||
3. **Bit-indexed BOOL path** — driver was sending `"GVL.vWord.3"` to ADS as
|
||||
a BOOL read. TwinCAT's symbol table doesn't expose bit-access paths; the
|
||||
read returned `DeviceSymbolNotFound`. Fix: strip the `.N` suffix, read
|
||||
the parent word as `uint`, extract the bit locally via `ExtractBit`.
|
||||
|
||||
All three paths are now pinned by live-wire tests.
|
||||
|
||||
## What it does NOT cover
|
||||
|
||||
### 1. AMS / ADS wire traffic
|
||||
### 1. AMS / ADS wire framing
|
||||
|
||||
No real AMS router frame is exchanged. Beckhoff's `TwinCAT.Ads` NuGet (their
|
||||
own .NET SDK, not libplctag-style OSS) has no in-process fake; tests stub
|
||||
the `ITwinCATClient` abstraction above it.
|
||||
No raw AMS packet is inspected. Beckhoff's `TwinCAT.Ads` NuGet (their own
|
||||
.NET SDK, not libplctag-style OSS) has no in-process fake at the frame
|
||||
level; tests run against a real router.
|
||||
|
||||
### 2. Multi-route AMS
|
||||
|
||||
ADS supports chained routes (`<localNetId> → <routerNetId> → <targetNetId>`)
|
||||
for PLCs behind an EC master / IPC gateway. Parse coverage exists; wire-path
|
||||
coverage doesn't.
|
||||
coverage is single-hop only.
|
||||
|
||||
### 3. Notification reliability under jitter
|
||||
### 3. Notification coalescing under jitter
|
||||
|
||||
`AddDeviceNotification` delivers at the runtime's cycle boundary; under high
|
||||
CPU load or network jitter real notifications can coalesce. The fake fires
|
||||
one callback per test invocation — real callback-coalescing behavior is
|
||||
untested.
|
||||
`AddDeviceNotification` delivers at the runtime's cycle boundary; under
|
||||
sustained CPU load or network jitter real notifications can coalesce. The
|
||||
live test only asserts at-least-one delivery within a generous window —
|
||||
coalescing behavior under stress isn't verified.
|
||||
|
||||
### 4. TC2 vs TC3 variant handling
|
||||
|
||||
TwinCAT 2 (ADS v1) and TwinCAT 3 (ADS v2) have subtly different
|
||||
`GetSymbolInfoByName` semantics + symbol-table layouts. Driver targets TC3;
|
||||
TC2 compatibility is not exercised.
|
||||
`GetSymbolInfoByName` semantics + symbol-table layouts. Driver + tests target
|
||||
TC3; TC2 compatibility is not exercised.
|
||||
|
||||
### 5. Cycle-time alignment for `ISubscribable`
|
||||
### 5. Alarms / history
|
||||
|
||||
Native ADS notifications fire on the PLC cycle boundary. The fake test
|
||||
harness assumes notifications fire on a timer the test controls;
|
||||
cycle-aligned firing under real PLC control is not verified.
|
||||
|
||||
### 6. Alarms / history
|
||||
|
||||
Driver doesn't implement `IAlarmSource` or `IHistoryProvider` — not in
|
||||
scope for this driver family. TwinCAT 3's TcEventLogger could theoretically
|
||||
back an `IAlarmSource`, but shipping that is a separate feature.
|
||||
Driver doesn't implement `IAlarmSource` or `IHistoryProvider` — not in scope
|
||||
for this driver family. TwinCAT 3's TcEventLogger could theoretically back
|
||||
an `IAlarmSource`, but shipping that is a separate feature.
|
||||
|
||||
## When to trust TwinCAT tests, when to reach for a rig
|
||||
|
||||
@@ -122,37 +162,25 @@ back an `IAlarmSource`, but shipping that is a separate feature.
|
||||
| "Does notification → `OnDataChange` wire correctly?" | yes (contract) | yes |
|
||||
| "Does symbol browsing filter TwinCAT internals?" | yes | yes |
|
||||
| "Does a real ADS read return correct bytes?" | no | yes (required) |
|
||||
| "Do notifications coalesce under load?" | no | yes (required) |
|
||||
| "Does auto-reconnect work on router restart?" | no (contract only) | yes (required) |
|
||||
| "Do notifications coalesce under sustained load?" | no | yes (required) |
|
||||
| "Does a TC2 PLC work the same as TC3?" | no | yes (required) |
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
1. **XAR VM live-population** — scaffolding is in place (this PR); the
|
||||
remaining work is operational: stand up the Hyper-V VM, install XAR,
|
||||
author the `.tsproj` per `TwinCatProject/README.md`, configure the
|
||||
bilateral ADS route, set `TWINCAT_TARGET_HOST` + `TWINCAT_TARGET_NETID`
|
||||
on the dev box. Then the three smoke tests transition skip → pass.
|
||||
Tracked as #221.
|
||||
2. **License-rotation automation** — XAR's 7-day trial expires on
|
||||
schedule. Either automate `TcActivate.exe /reactivate` via a
|
||||
scheduled task on the VM (not officially supported; reportedly works
|
||||
for some TC3 builds), or buy a paid runtime license (~$1k one-time
|
||||
per runtime per CPU) to kill the rotation. The doc at
|
||||
`TwinCatProject/README.md` §License rotation walks through both.
|
||||
3. **Lab rig** — cheapest IPC (CX7000 / CX9020) on a dedicated network;
|
||||
the only route that covers TC2 + real EtherCAT I/O timing + cycle
|
||||
jitter under CPU load.
|
||||
Deferred to v3 — see [`docs/v3/twincat-backlog.md`](../v3/twincat-backlog.md).
|
||||
Covers TC2 coverage, notification-coalescing-under-load, multi-hop AMS,
|
||||
license-rotation automation, and a dedicated lab IPC.
|
||||
|
||||
## Key fixture / config files
|
||||
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATXarFixture.cs`
|
||||
— TCP probe + skip-attributes + env-var parsing
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCAT3SmokeTests.cs`
|
||||
— three wire-level smoke tests
|
||||
— wire-level test suite (14 `[TwinCATFact]` + 16-case `[TwinCATTheory]`)
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md`
|
||||
— project spec + VM setup + license-rotation notes
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs` —
|
||||
in-process fake with the notification-fire harness used by
|
||||
`TwinCATNativeNotificationTests`
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs` — ctor takes
|
||||
`ITwinCATClientFactory`
|
||||
in-process fake with the notification-fire harness
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs` — ctor is
|
||||
`(TwinCATDriverOptions, string driverInstanceId, ITwinCATClientFactory? = null)`
|
||||
|
||||
129
docs/v2/implementation/exit-gate-phase-3.md
Normal file
129
docs/v2/implementation/exit-gate-phase-3.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Phase 3 Exit Gate — Driver Fleet (reconstructed retroactively)
|
||||
|
||||
> **Status**: **CLOSED (reconstructed 2026-04-23)**. The original plan split the
|
||||
> driver work across Phases 3 / 4 / 5 (Modbus alone → four PLC drivers → two
|
||||
> specialty drivers). In execution, all seven non-Galaxy drivers shipped under
|
||||
> one umbrella against `Core.Abstractions` + `Core`'s generic driver-hosting
|
||||
> machinery. This doc captures the closure retroactively; no forward work
|
||||
> remains under these three original phase numbers.
|
||||
>
|
||||
> **Plan doc**: none — phases 3/4/5 were intentionally not split out into
|
||||
> separate plan docs once it was clear the capability-interface contract
|
||||
> introduced in Phase 1 (`Core.Abstractions` — plan decision #4) was stable
|
||||
> enough that each driver could land as its own stream rather than as a
|
||||
> gated mini-phase. See `docs/v2/plan.md` §6 for the now-consolidated
|
||||
> migration strategy.
|
||||
|
||||
## Scope
|
||||
|
||||
All seven drivers in the v2 target list (Decision #5) minus Galaxy (closed
|
||||
separately under Phase 2). The Galaxy Proxy+Host+Shared split exited under
|
||||
`exit-gate-phase-2-final.md`; this gate does not re-cover it.
|
||||
|
||||
## What shipped
|
||||
|
||||
### Drivers
|
||||
|
||||
| Driver | Project | Capability surface | Test projects |
|
||||
|---|---|---|---|
|
||||
| Modbus TCP | `Driver.Modbus` + `Driver.Modbus.Cli` | `IDriver` + `ITagDiscovery` + `IReadable` + `IWritable` + `ISubscribable` + `IHostConnectivityProbe` | `Tests`, `IntegrationTests`, `Cli.Tests` |
|
||||
| AB CIP | `Driver.AbCip` + `Driver.AbCip.Cli` | all of the above + `IPerCallHostResolver` + `IAlarmSource` | `Tests`, `IntegrationTests`, `Cli.Tests` |
|
||||
| AB Legacy (PCCC / DF1) | `Driver.AbLegacy` + `Driver.AbLegacy.Cli` | `IDriver` + `IReadable` + `IWritable` + `ITagDiscovery` + `ISubscribable` + `IHostConnectivityProbe` + `IPerCallHostResolver` | `Tests`, `IntegrationTests`, `Cli.Tests` |
|
||||
| Siemens S7 | `Driver.S7` + `Driver.S7.Cli` | `IDriver` + `ITagDiscovery` + `IReadable` + `IWritable` + `ISubscribable` + `IHostConnectivityProbe` | `Tests`, `IntegrationTests`, `Cli.Tests` |
|
||||
| Beckhoff TwinCAT (ADS) | `Driver.TwinCAT` + `Driver.TwinCAT.Cli` | `IDriver` + `IReadable` + `IWritable` + `ITagDiscovery` + `ISubscribable` + `IHostConnectivityProbe` + `IPerCallHostResolver` | `Tests`, `IntegrationTests`, `Cli.Tests` |
|
||||
| FANUC FOCAS | `Driver.FOCAS` + `Driver.FOCAS.Host` + `Driver.FOCAS.Shared` + `Driver.FOCAS.Cli` | `IDriver` + `IReadable` + `IWritable` + `ITagDiscovery` + `ISubscribable` + `IHostConnectivityProbe` + `IPerCallHostResolver`; Tier-C out-of-process backend mirrors the Galaxy Proxy/Host split. `Fwlib64FocasBackend` shipped 2026-04-23 as the production backend (P/Invoke against `Fwlib64.dll`); Host retargeted from net48 x86 to net10.0-windows x64 at the same time. | `Tests`, `Host.Tests`, `Shared.Tests`, `Cli.Tests` |
|
||||
| OPC UA Client (gateway) | `Driver.OpcUaClient` | `IDriver` + `ITagDiscovery` + `IReadable` + `IWritable` + `ISubscribable` + `IHostConnectivityProbe` + `IAlarmSource` + `IHistoryProvider` (richest surface in the fleet — it's bridging another UA server) | `Tests`, `IntegrationTests` |
|
||||
|
||||
### Supporting infrastructure
|
||||
|
||||
| PR / Task | Summary |
|
||||
|---|---|
|
||||
| #248 | `DriverFactoryRegistry` + `DriverInstanceBootstrapper` — central DB `DriverInstance` rows materialise into live `IDriver` instances at server startup. |
|
||||
| #210 | Modbus server-side factory + seed SQL (closed first child of umbrella #209). |
|
||||
| #211 #212 #213 | AB CIP / S7 / AB Legacy server-side factories + seed SQL. |
|
||||
| #220 (FOCAS) | FOCAS factory wired into the bootstrap pipeline; Tier-C split (`Driver.FOCAS.Host` process launcher, named-pipe IPC, NSSM install scripts, post-mortem MMF) shipped across the five-PR series. |
|
||||
| (this session) | TwinCAT factory wired in + Server project reference added; all seven driver factories now register uniformly in `Server/Program.cs`. |
|
||||
| #249 #250 #251 | Per-driver test-client CLI suite (`otopcua-<driver>-cli`) — shared lib + one CLI per driver for direct-to-PLC smoke testing independent of the server. |
|
||||
| #253 + follow-ups | E2E CLI test scripts (`scripts/e2e/test-<driver>.ps1`) — five-stage bidirectional bridge + subscribe-sees-change assertions per driver, plus `test-all.ps1` matrix runner. |
|
||||
| (this session) | OPC UA Client e2e script shipped (`test-opcuaclient.ps1`, 8 stages) — the only driver that was missing an e2e script. |
|
||||
|
||||
### Docs
|
||||
|
||||
Per-driver test-fixture documentation:
|
||||
- `docs/drivers/Modbus-Test-Fixture.md`
|
||||
- `docs/drivers/AbServer-Test-Fixture.md` (covers AB CIP fixture)
|
||||
- `docs/drivers/AbLegacy-Test-Fixture.md`
|
||||
- `docs/drivers/S7-Test-Fixture.md`
|
||||
- `docs/drivers/TwinCAT-Test-Fixture.md`
|
||||
- `docs/drivers/FOCAS-Test-Fixture.md`
|
||||
- `docs/drivers/OpcUaClient-Test-Fixture.md`
|
||||
|
||||
Driver-level ops docs:
|
||||
- `docs/Driver.Modbus.Cli.md`, `docs/Driver.AbCip.Cli.md`, `docs/Driver.AbLegacy.Cli.md`, `docs/Driver.S7.Cli.md`, `docs/Driver.TwinCAT.Cli.md`, `docs/Driver.FOCAS.Cli.md`
|
||||
- `docs/v2/driver-specs.md` — unified capability-matrix spec for all eight drivers (Galaxy + seven).
|
||||
|
||||
## Compliance evidence
|
||||
|
||||
No dedicated `phase-3-compliance.ps1` exists — scope was too broad to fit the
|
||||
single-script pattern that worked for Phases 6.x and 7. Verification instead
|
||||
takes the form of the per-driver test suites + e2e scripts:
|
||||
|
||||
- [x] **Unit tests** — every driver has a `Tests` project with capability-interface contract tests; `dotnet test tests/ZB.MOM.WW.OtOpcUa.Driver.*.Tests` is green.
|
||||
- [x] **Integration tests** — `Driver.*.IntegrationTests` stands up Docker-hosted simulators (pymodbus, ab_server, python-snap7, opc-plc) at collection init and exercises real wire-level read/write/subscribe/probe per driver.
|
||||
- [x] **CLI tests** — `Driver.*.Cli.Tests` covers the per-driver test-client CLIs (#249–#251).
|
||||
- [x] **E2E scripts** — `scripts/e2e/test-<driver>.ps1` covers the driver-CLI → PLC → OtOpcUa server → OPC UA client round-trip for all seven drivers + Galaxy; `test-all.ps1` aggregates; README status section (rewritten this session) summarises live-boot evidence.
|
||||
- [x] **Factory registration** — all seven factories plus Galaxy register in `src/ZB.MOM.WW.OtOpcUa.Server/Program.cs` inside the `DriverFactoryRegistry` composition; the `DriverInstanceBootstrapper` can materialise any configured row.
|
||||
- [x] **Seed SQL** — #210–#213 provide per-driver Config DB seed scripts so a fresh Config DB is populatable without Admin UI interaction.
|
||||
|
||||
### Live-boot verification
|
||||
|
||||
Recorded across the session-level tracking tasks:
|
||||
|
||||
| Driver | Fixture | Stages | Tracking |
|
||||
|---|---|---|---|
|
||||
| Modbus | pymodbus (dl205 profile) | 5/5 | #209 exit gate; bidirectional + subscribe-sees-change added in #253 follow-ups |
|
||||
| AB CIP | `ab_server` ControlLogix | 5/5 | #220 |
|
||||
| S7 | python-snap7 | 5/5 | #220 |
|
||||
| AB Legacy | `ab_server` SLC500 / MicroLogix / PLC-5 (requires `/1,0` cip-path for Docker fixture) | 5/5 | #222 partial |
|
||||
| OPC UA Client | opc-plc Docker fixture | 5/8 (probe, remote read, forward bridge, subscribe, browse) | (this session) |
|
||||
| TwinCAT | TCBSD VM @ 10.100.0.128 (AmsNetId `41.169.163.43.1.1`) — real TwinCAT runtime under FreeBSD on ESXi; bypasses the Hyper-V/RTIME conflict that blocks XAR on this dev box | features validated | fixture is the TCBSD VM; `TWINCAT_TRUST_WIRE=1` still gates the e2e script by default so unintentional runs against cold fixtures don't false-pass |
|
||||
| FOCAS | Lab-rig CNC + `Fwlib64.dll` | — | **deferred** — `Fwlib64FocasBackend` shipped 2026-04-23; wire-level live-boot gated `FOCAS_TRUST_WIRE=1`, lab rig tracked under #222 follow-up |
|
||||
| Galaxy | Live Galaxy + `OtOpcUaGalaxyHost` (this dev box) | 7/7 (read / write / subscribe / alarms / history) | closed under Phase 2 |
|
||||
|
||||
## Deferred to post-gate follow-ups
|
||||
|
||||
Items intentionally not blocking closure of this umbrella — each is hardware-
|
||||
dependent and tracked separately:
|
||||
|
||||
- [ ] **FOCAS wire-level live-boot** — `test-focas.ps1` against a real CNC once `Fwlib64.dll` is on PATH and `FOCAS_TRUST_WIRE=1` (#222 follow-up). The `Fwlib64FocasBackend` shipped 2026-04-23 — code exists, unit-tests green; only the live-CNC smoke test remains.
|
||||
- [x] **FOCAS `Fwlib64FocasBackend`** — **CLOSED 2026-04-23**. The production backend in `src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Host/Backend/Fwlib64FocasBackend.cs` wraps `FwlibFocasClient` to fulfil `IFocasBackend` against the licensed `Fwlib64.dll`. Host project retargeted to `net10.0-windows` x64. Default when `OTOPCUA_FOCAS_BACKEND` is unset. 6 new backend tests green. Only wire-level live-boot against real hardware remains — see item above.
|
||||
- [ ] **OPC UA Client stages 5/7/8** — reverse-bridge, alarm, history stages are opt-in via sidecar NodeId params because opc-plc's default image has no writable nodes and doesn't historize. Against a richer upstream (Prosys, UA Expert sample server) all eight stages can run.
|
||||
|
||||
## Completion checklist
|
||||
|
||||
- [x] Modbus driver shipped + unit + integration + CLI tests green
|
||||
- [x] AB CIP driver shipped + tests green + live-boot 5/5
|
||||
- [x] AB Legacy driver shipped + tests green + live-boot 5/5
|
||||
- [x] S7 driver shipped + tests green + live-boot 5/5
|
||||
- [x] TwinCAT driver shipped + tests green + features validated against the TCBSD VM virtual-PLC fixture
|
||||
- [x] FOCAS driver shipped (Tier-C split) + tests green (wire-live deferred)
|
||||
- [x] OPC UA Client driver shipped + tests green + live-boot 5/8
|
||||
- [x] `DriverFactoryRegistry` + `DriverInstanceBootstrapper` shipped
|
||||
- [x] All seven factories registered in `Server/Program.cs`
|
||||
- [x] Per-driver test-client CLI suite shipped
|
||||
- [x] E2E test scripts shipped + `test-all.ps1` aggregator green
|
||||
- [x] Per-driver test-fixture docs present
|
||||
- [x] `docs/v2/driver-specs.md` unified capability spec present
|
||||
- [x] `scripts/e2e/README.md` status section reflects current live-boot matrix
|
||||
- [x] Exit gate doc checked in (this file)
|
||||
- [x] TwinCAT validated against the TCBSD VM virtual-PLC fixture — `TWINCAT_TRUST_WIRE=1` + e2e script still gated by default to prevent false-pass against cold fixtures
|
||||
- [ ] FOCAS lab-rig follow-up filed + tracked (#222)
|
||||
|
||||
## Why no compliance script
|
||||
|
||||
The Phases 6.1/6.2/6.3/6.4/7 pattern of a single `phase-N-compliance.ps1`
|
||||
worked because each of those phases touched a narrow slice of server-side
|
||||
runtime. A "phase-3-compliance.ps1" would have had to boot seven simulators,
|
||||
configure seven DriverInstance rows, and run seven e2e scripts — which is
|
||||
exactly what `scripts/e2e/test-all.ps1` already does. The aggregate runner
|
||||
+ its README is the compliance artefact for this umbrella.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Phase 7 Exit Gate — Scripting, Virtual Tags, Scripted Alarms, Historian Sink
|
||||
|
||||
> **Status**: Open. Closed when every compliance check passes + every deferred item either ships or is filed as a post-v2-release follow-up.
|
||||
> **Status**: **FULLY CLOSED** 2026-04-23 audit — the three original follow-ups (#239 / #240 / #241) were all shipped under later branches but this exit-gate doc wasn't updated at the time. All three verified against the repo + tests green.
|
||||
>
|
||||
> **Compliance script**: `scripts/compliance/phase-7-compliance.ps1`
|
||||
> **Plan doc**: `docs/v2/implementation/phase-7-scripting-and-alarming.md`
|
||||
@@ -45,13 +45,13 @@ Covered by `scripts/compliance/phase-7-compliance.ps1`:
|
||||
- [x] Walker emits `NodeSourceKind.Virtual` + `NodeSourceKind.ScriptedAlarm` variables
|
||||
- [x] `DriverNodeManager` dispatch routes Reads by source; Writes to non-Driver rejected with `BadUserAccessDenied` (plan #6)
|
||||
|
||||
## Deferred to Post-Gate Follow-ups
|
||||
## Deferred to Post-Gate Follow-ups (all closed as of 2026-04-23 audit)
|
||||
|
||||
Kept out of the capstone so the gate can close cleanly while the less-critical wiring lands in targeted PRs:
|
||||
Originally kept out of the capstone so the gate could close cleanly. Each landed as a targeted follow-up PR; audit this session verified them against the repo:
|
||||
|
||||
- [ ] **SealedBootstrap composition root** (task #239) — instantiate `VirtualTagEngine` + `ScriptedAlarmEngine` + `SqliteStoreAndForwardSink` in `Program.cs`; pass `VirtualTagSource` + `ScriptedAlarmSource` as the new `IReadable` parameters on `DriverNodeManager`. Without this, the engines are dormant in production even though every piece is tested.
|
||||
- [ ] **Live OPC UA end-to-end smoke** (task #240) — Client.CLI browse + read a virtual tag computed by Roslyn; Client.CLI acknowledge a scripted alarm via the Part 9 method node; historian-disabled deployment returns `BadNotFound` for virtual nodes rather than silent failure.
|
||||
- [ ] **sp_ComputeGenerationDiff extension** (task #241) — emit Script / VirtualTag / ScriptedAlarm sections alongside the existing Namespace/DriverInstance/Equipment/Tag/NodeAcl rows so the Admin DiffViewer shows Phase 7 changes between generations.
|
||||
- [x] **SealedBootstrap composition root** (task #239) — **CLOSED**. `src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs` instantiates `VirtualTagEngine` + `ScriptedAlarmEngine` via `Phase7EngineComposer.Compose`, and `SqliteStoreAndForwardSink` in `ResolveHistorianSink` when a registered driver provides `IAlarmHistorianWriter` (today: `GalaxyProxyDriver`). `OpcUaServerService.ExecuteAsync` calls `Phase7Composer.PrepareAsync` then `OpcUaApplicationHost.SetPhase7Sources` **before** `applicationHost.StartAsync` so `OtOpcUaServer` + `DriverNodeManager` capture the `VirtualReadable` / `ScriptedAlarmReadable` at construction. 38 tests green under `tests/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/` + `SealedBootstrapIntegrationTests`. The work landed under the label "Phase 7 follow-up #246" and was never re-labelled against #239.
|
||||
- [x] **Live OPC UA end-to-end smoke** (task #240) — **CLOSED**. `scripts/e2e/test-phase7-virtualtags.ps1` drives a full Client.CLI read of a driver-sourced input, reads the VirtualTag computed off it, triggers a scripted alarm by writing the trigger value, and subscribes to the alarm condition — all through a running OtOpcUa server. Covered in `scripts/e2e/test-all.ps1` + `scripts/e2e/README.md` matrix.
|
||||
- [x] **sp_ComputeGenerationDiff extension** (task #241) — **CLOSED**. Migration `20260420232000_ExtendComputeGenerationDiffWithPhase7.cs` extends the stored proc to emit Script / VirtualTag / ScriptedAlarm sections alongside the existing NodeAcl / Tag / Equipment / DriverInstance / Namespace output. Admin DiffViewer picks them up through its existing section-plugin architecture (Phase 6.4 Stream C).
|
||||
|
||||
## Completion Checklist
|
||||
|
||||
@@ -66,9 +66,9 @@ Kept out of the capstone so the gate can close cleanly while the less-critical w
|
||||
- [x] `phase-7-compliance.ps1` present and passes
|
||||
- [x] Full solution `dotnet test` passes (no new failures beyond pre-existing tolerated CLI flake)
|
||||
- [x] Exit-gate doc checked in
|
||||
- [ ] `SealedBootstrap` composition follow-up filed + tracked
|
||||
- [ ] Live end-to-end smoke follow-up filed + tracked
|
||||
- [ ] `sp_ComputeGenerationDiff` extension follow-up filed + tracked
|
||||
- [x] `SealedBootstrap` composition follow-up shipped (#239 / Phase 7 follow-up #246)
|
||||
- [x] Live end-to-end smoke follow-up shipped (#240 — `scripts/e2e/test-phase7-virtualtags.ps1`)
|
||||
- [x] `sp_ComputeGenerationDiff` extension follow-up shipped (#241 — migration `ExtendComputeGenerationDiffWithPhase7`)
|
||||
|
||||
## How to run
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Phase 6.1 — Resilience & Observability Runtime
|
||||
|
||||
> **Status**: **SHIPPED** 2026-04-19 — Streams A/B/C/D + E data layer merged to `v2` across PRs #78-82. Final exit-gate PR #83 turns the compliance script into real checks (all pass) and records this status update. One deferred piece: Stream E.2/E.3 SignalR hub + Blazor `/hosts` column refresh lands in a visual-compliance follow-up PR on the Phase 6.4 Admin UI branch.
|
||||
> **Status**: **SHIPPED** 2026-04-19 — Streams A/B/C/D + E data layer merged to `v2` across PRs #78-82. Final exit-gate PR #83 turns the compliance script into real checks (all pass) and records this status update.
|
||||
>
|
||||
> **Stream E.2/E.3 closed 2026-04-23** — `FleetStatusPoller` now polls `DriverInstanceResilienceStatus`, detects per-`(DriverInstanceId, HostName)` deltas, and pushes `ResilienceStatusChangedMessage` via `FleetStatusHub` on the fleet group. Admin `/hosts` page subscribes on load and upserts the matching `HostStatusRow` in-memory on receipt, so operator-visible resilience state now reflects the runtime within one poller tick (~5 s) instead of the Admin page's own 10-second refresh. `FleetStatusPollerTests.Poller_pushes_ResilienceStatusChanged_on_delta` covers the first-observation push, the no-delta-no-push invariant, and the mutated-row re-push.
|
||||
>
|
||||
> Baseline: 906 solution tests → post-Phase-6.1: 1042 passing (+136 net). One pre-existing Client.CLI Subscribe flake unchanged.
|
||||
>
|
||||
@@ -129,7 +131,7 @@ Closes these gaps flagged in the 2026-04-19 audit:
|
||||
- [ ] Stream B: Tier registry + generalised watchdog + scheduled recycle + wedge detector
|
||||
- [ ] Stream C: `/healthz` + `/readyz` + structured logging + JSON Serilog sink
|
||||
- [ ] Stream D: LiteDB cache + Polly fallback in Configuration
|
||||
- [ ] Stream E: Admin `/hosts` page refresh
|
||||
- [x] Stream E: Admin `/hosts` page refresh (E.1 in PRs #78-82 with the data layer; E.2/E.3 closed 2026-04-23)
|
||||
- [ ] Cross-cutting: `phase-6-1-compliance.ps1` exits 0; full solution `dotnet test` passes; exit-gate doc recorded
|
||||
|
||||
## Adversarial Review — 2026-04-19 (Codex, thread `019da489-e317-7aa1-ab1f-6335e0be2447`)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
# Phase 6.2 — Authorization Runtime (ACL + LDAP grants)
|
||||
|
||||
> **Status**: **SHIPPED (core)** 2026-04-19 — Streams A, B, C (foundation), D (data layer) merged to `v2` across PRs #84-87. Final exit-gate PR #88 turns the compliance stub into real checks (all pass, 2 deferred surfaces tracked).
|
||||
> **Status**: **FULLY SHIPPED** (updated 2026-04-23 audit). Streams A-D core merged to `v2` across PRs #84-87 + exit-gate PR #88 on 2026-04-19; both named deferrals landed separately and were confirmed against the repo this session:
|
||||
>
|
||||
> Deferred follow-ups (tracked separately):
|
||||
> - Stream C dispatch wiring on the 11 OPC UA operation surfaces (task #143).
|
||||
> - Stream D Admin UI — RoleGrantsTab, AclsTab Probe-this-permission, SignalR invalidation, draft-diff ACL section + visual-compliance reviewer signoff (task #144).
|
||||
> - **Task #143 Stream C dispatch wiring** — `DriverNodeManager` calls `AuthorizationGate.IsAllowed(context.UserIdentity, OpcUaOperation.<Op>, scope)` on Read (line 249), Write (line 536) with per-classification `OpcUaOperation.WriteOperate` / `WriteTune` / `WriteConfigure` routed via `WriteAuthzPolicy`, and HistoryRead (4 call sites). `TriePermissionEvaluator` + `PermissionTrieCache` back the gate.
|
||||
> - **Task #144 Stream D Admin UI** — `RoleGrants.razor` (LDAP group → Admin role mapping) + `AclsTab.razor` (per-cluster node-ACL editor with a probe-this-permission surface via `PermissionProbeService`) + `AclChangeNotifier` SignalR hub for cache invalidation all present and wired.
|
||||
>
|
||||
> Baseline pre-Phase-6.2: 1042 solution tests → post-Phase-6.2 core: 1097 passing (+55 net). One pre-existing Client.CLI Subscribe flake unchanged.
|
||||
>
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
# Phase 6.3 — Redundancy Runtime
|
||||
|
||||
> **Status**: **SHIPPED (core)** 2026-04-19 — Streams B (ServiceLevelCalculator + RecoveryStateManager) and D core (ApplyLeaseRegistry) merged to `v2` in PR #89. Exit gate in PR #90.
|
||||
> **Status**: **SHIPPED (core + Stream C)** — original body merged 2026-04-19; audit 2026-04-23 promoted **Stream C (task #147)** into shipped state.
|
||||
>
|
||||
> Deferred follow-ups (tracked separately):
|
||||
> - Stream A — RedundancyCoordinator cluster-topology loader (task #145).
|
||||
> - Stream C — OPC UA node wiring: ServiceLevel + ServerUriArray + RedundancySupport (task #147).
|
||||
> - Stream E — Admin UI RedundancyTab + OpenTelemetry metrics + SignalR (task #149).
|
||||
> - Stream F — client interop matrix + Galaxy MXAccess failover test (task #150).
|
||||
> - sp_PublishGeneration pre-publish validator rejecting unsupported RedundancyMode values (task #148 part 2 — SQL-side).
|
||||
> **In** (verified in repo):
|
||||
> - Stream A — `ClusterTopologyLoader`, `RedundancyCoordinator`, `RedundancyTopology`, `PeerReachability` all present under `src/ZB.MOM.WW.OtOpcUa.Server/Redundancy/`. Coordinator is now also hosted by `Program.cs` via the new `RedundancyPublisherHostedService`, which calls `RefreshAsync` on startup.
|
||||
> - Stream B — `ServiceLevelCalculator` + `RecoveryStateManager`.
|
||||
> - **Stream C (task #147) — OPC UA node wiring**. `ServerRedundancyNodeWriter` maintains `Server.ServiceLevel` (i=2267), `Server.ServerRedundancy.RedundancySupport` (i=2994), and `Server.ServerRedundancy.ServerUriArray` (non-transparent subtype) by writing the `PropertyState.Value` + calling `ClearChangeMasks`. `RedundancyPublisherHostedService` drives the publisher on a 1 s tick and fans `OnStateChanged` / `OnServerUriArrayChanged` into the writer. Mapping of `Configuration.RedundancyMode` → Part 4 `RedundancySupport` is Warm/Hot/None (v2 clusters don't enumerate Cold / HotAndMirrored per decision #85). Idempotent per-value dedupe prevents spurious OPC UA notifications. Unit coverage: `ServerRedundancyNodeWriterTests` (4 tests, green).
|
||||
> - Stream D — `ApplyLeaseRegistry`.
|
||||
> - Stream E — `RedundancyTab.razor` with SignalR `RoleChanged` wiring (via `FleetStatusPoller` + `FleetStatusHub`) — stale-flag + role-swap banner.
|
||||
>
|
||||
> **Closed this session (2026-04-23)**:
|
||||
> - **Task #148 part 2** — `DraftValidator.ValidateClusterTopology(cluster, nodes)` now catches three pre-publish invariants the SQL CHECK can't see: (a) unsupported `NodeCount`/`RedundancyMode` pairs; (b) `Enabled`-node count vs. declared `NodeCount` mismatch (catches disabled-node drift with mode still Hot/Warm); (c) multiple-Primary per decision #84. Returns every failure in one pass — same shape as `Validate`. 8 new tests in `DraftValidatorTests` green.
|
||||
> - **Task #150 Stream F** — `docs/v2/redundancy-interop-playbook.md` captures the manual validation matrix against UaExpert + Kepware + AVEVA MXAccess failover. Automating these closed-source GUI clients in PR-CI is out of scope; the automatable half is already covered by `ServiceLevelCalculatorTests` / `RedundancyStatePublisherTests` / `ClusterTopologyLoaderTests` / `ServerRedundancyNodeWriterTests`.
|
||||
>
|
||||
> **Remaining (documented limitation, not blocking v2.0)**:
|
||||
> - Non-transparent redundancy-state node upgrade — the SDK's default `Server.ServerRedundancy` object is the base `ServerRedundancyState`, so `ApplyServerUriArray` currently logs-and-skips. Operators on the rare deployment that needs `ServerUriArray` read-back get a clear warning with the upgrade path. Documented in the interop playbook's "Known limitations" section.
|
||||
>
|
||||
> Baseline pre-Phase-6.3: 1097 solution tests → post-Phase-6.3 core: 1137 passing (+40 net).
|
||||
>
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
# Phase 6.4 — Admin UI Completion
|
||||
|
||||
> **Status**: **SHIPPED (data layer)** 2026-04-19 — Stream A.2 (UnsImpactAnalyzer + DraftRevisionToken) and Stream B.1 (EquipmentCsvImporter parser) merged to `v2` in PR #91. Exit gate in PR #92.
|
||||
> **Status**: **SHIPPED (mostly)** 2026-04-19; audit 2026-04-23 confirms what landed separately after the data-layer PR #91:
|
||||
>
|
||||
> Deferred follow-ups (Blazor UI + staging tables + address-space wiring):
|
||||
> - Stream A UI — UnsTab MudBlazor drag/drop + 409 concurrent-edit modal + Playwright smoke (task #153).
|
||||
> - Stream B follow-up — EquipmentImportBatch staging + FinaliseImportBatch transaction + CSV import UI (task #155).
|
||||
> - Stream C — DiffViewer refactor into base + 6 section plugins + 1000-row cap + SignalR paging (task #156).
|
||||
> - Stream D — IdentificationFields.razor + DriverNodeManager OPC 40010 sub-folder exposure (task #157).
|
||||
> **In** (verified in repo):
|
||||
> - **Task #153 Stream A UI** — `UnsTab.razor` with drag/drop handlers + concurrent-edit via `DraftRevisionToken` + `UnsImpactAnalyzer`; Playwright smoke test in `tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/UnsTabDragDropE2ETests.cs`.
|
||||
> - **Task #155 Stream B** — `EquipmentImportBatch` entity + migration, `EquipmentImportBatchService.CreateBatchAsync` / `FinaliseBatchAsync` / `DropBatchAsync` / `ListByUserAsync`, `ImportEquipment.razor` UI.
|
||||
> - **Task #156 Stream C** — `DiffViewer.razor` + `DiffSection.razor` refactor in place.
|
||||
> - Admin UI `IdentificationFields.razor` surface shipped (part of #157).
|
||||
>
|
||||
> **Closed this session (2026-04-23)**:
|
||||
> - **Task #157 Stream D server-side half** was a stale audit claim. `src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/IdentificationFolderBuilder.cs` ships the OPC 40010 Identification sub-folder materializer (Manufacturer / Model / SerialNumber / HardwareRevision / SoftwareRevision / YearOfConstruction / AssetLocation / ManufacturerUri / DeviceManualUri); `EquipmentNodeWalker.Walk` calls it per equipment; `IdentificationFolderBuilderTests` (158 lines) + two walker-level tests (`Walk_Materializes_Identification_Subfolder_When_AnyFieldPresent`, `Walk_Omits_Identification_Subfolder_When_AllFieldsNull`) cover the null-handling branches. The initial audit grepped only `src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/`; the builder lives in `Core/OpcUa/`.
|
||||
>
|
||||
> **Phase 6.4 is now FULLY SHIPPED — no deferred surfaces remain.**
|
||||
>
|
||||
> Baseline pre-Phase-6.4: 1137 solution tests → post-Phase-6.4 data layer: 1159 passing (+22).
|
||||
>
|
||||
|
||||
@@ -689,7 +689,7 @@ Galaxy.Proxy ──→ Galaxy.Shared ←── Galaxy.Host
|
||||
|
||||
**Decided:**
|
||||
- Mono-repo (Decision #31 above).
|
||||
- `Core.Abstractions` is **internal-only for now** — no standalone NuGet. Keep the contract mutable while the first 8 drivers are being built; revisit publishing after Phase 5 when the shape has stabilized. Design the contract *as if* it will eventually be public (no leaky types, stable names) to minimize churn later.
|
||||
- `Core.Abstractions` is **internal-only for now** — no standalone NuGet. Keep the contract mutable while the first 8 drivers are being built; revisit publishing after the driver fleet (originally Phase 5, folded into the Phase 3 umbrella — see exit gate) once the shape has stabilized. Design the contract *as if* it will eventually be public (no leaky types, stable names) to minimize churn later.
|
||||
|
||||
---
|
||||
|
||||
@@ -742,24 +742,30 @@ Each step leaves the system runnable. The generic extraction is effectively free
|
||||
10. **Build `Galaxy.Proxy`** — .NET 10 in-process proxy implementing IDriver interfaces, forwarding over IPC
|
||||
11. **Validate parity** — v2 Galaxy driver must pass the same integration tests as v1
|
||||
|
||||
**Phase 3 — Modbus TCP driver (prove the abstraction)**
|
||||
12. **Build `Driver.ModbusTcp`** — NModbus, config-driven tags from central DB, internal poll loop, device-as-folder hierarchy
|
||||
13. **Add Modbus config screens to Admin** (first driver-specific config UI)
|
||||
**Phase 3 — Driver fleet (all seven non-Galaxy drivers) — ✅ CLOSED 2026-04-23** (see [`implementation/exit-gate-phase-3.md`](implementation/exit-gate-phase-3.md))
|
||||
|
||||
**Phase 4 — PLC drivers**
|
||||
14. **Build `Driver.AbCip`** — libplctag, ControlLogix/CompactLogix symbolic tags + Admin config screens
|
||||
15. **Build `Driver.AbLegacy`** — libplctag, SLC 500/MicroLogix file-based addressing + Admin config screens
|
||||
16. **Build `Driver.S7`** — S7netplus, Siemens S7-300/400/1200/1500 + Admin config screens
|
||||
17. **Build `Driver.TwinCat`** — Beckhoff.TwinCAT.Ads v6, native ADS notifications, symbol upload + Admin config screens
|
||||
Originally split across Phase 3 (Modbus alone), Phase 4 (PLC drivers), and
|
||||
Phase 5 (specialty drivers). In execution, once `Core.Abstractions` had
|
||||
stabilised under Phase 1 + Phase 2, each driver landed as its own stream
|
||||
rather than as a gated mini-phase; the phase numbers were folded into a
|
||||
single umbrella. Shipped:
|
||||
|
||||
**Phase 5 — Specialty drivers**
|
||||
18. **Build `Driver.Focas`** — FANUC FOCAS2 P/Invoke, pre-defined CNC tag set, PMC/macro config + Admin config screens
|
||||
19. **Build `Driver.OpcUaClient`** — OPC UA client gateway/aggregation, namespace remapping, subscription proxying + Admin config screens
|
||||
12. **`Driver.Modbus`** — NModbus, config-driven tags, internal poll loop, device-as-folder hierarchy (umbrella closure #210)
|
||||
13. **`Driver.AbCip`** — libplctag, ControlLogix/CompactLogix symbolic tags (#211, live-booted under #220)
|
||||
14. **`Driver.AbLegacy`** — libplctag, SLC 500 / MicroLogix / PLC-5 file-based addressing (#213, live-booted under #222)
|
||||
15. **`Driver.S7`** — S7netplus, Siemens S7-300/400/1200/1500 (#212, live-booted under #220)
|
||||
16. **`Driver.TwinCAT`** — Beckhoff.TwinCAT.Ads v7, native ADS notifications, symbol upload (factory wired 2026-04-23; wire-live deferred, #221)
|
||||
17. **`Driver.FOCAS`** — FANUC FOCAS2 P/Invoke via Tier-C out-of-process `Driver.FOCAS.Host` (#220 five-PR split; wire-live deferred, #222 follow-up)
|
||||
18. **`Driver.OpcUaClient`** — OPC UA client gateway / aggregation, namespace remapping, subscription proxying (scaffold #66; live-boot 5/8 stages via `test-opcuaclient.ps1`)
|
||||
|
||||
Supporting infrastructure: `DriverFactoryRegistry` + `DriverInstanceBootstrapper`
|
||||
(#248); per-driver test-client CLI suite (#249–#251); e2e test scripts with
|
||||
aggregate runner (#253); server-side factory + seed SQL per driver (#210–#213).
|
||||
|
||||
**Decided:**
|
||||
- **Parity test for Galaxy**: existing v1 IntegrationTests suite + scripted Client.CLI walkthrough (see Section 4 above).
|
||||
- **Timeline**: no hard deadline. Each phase ships when it's right — tests passing, Galaxy parity bar met. Quality cadence over calendar cadence.
|
||||
- **FOCAS SDK**: license already secured. Phase 5 can proceed as scheduled; `Fwlib64.dll` available for P/Invoke.
|
||||
- **FOCAS SDK**: license already secured. FOCAS driver shipped as part of the Phase 3 umbrella with Tier-C host; `Fwlib64.dll` available for P/Invoke (wire-level live-boot gated on lab rig, #222 follow-up).
|
||||
|
||||
---
|
||||
|
||||
|
||||
128
docs/v2/redundancy-interop-playbook.md
Normal file
128
docs/v2/redundancy-interop-playbook.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Redundancy Interop Playbook (Phase 6.3 Stream F — task #150)
|
||||
|
||||
> **Scope**: manual validation that third-party OPC UA clients + AVEVA MXAccess
|
||||
> observe our non-transparent redundancy signals (ServiceLevel, ServerUriArray,
|
||||
> RedundancySupport) and fail over to the Backup node when the Primary drops.
|
||||
>
|
||||
> **Why manual**: the third-party clients named here are Windows-GUI binaries
|
||||
> (UaExpert, Kepware QuickClient) or embedded inside AVEVA System Platform.
|
||||
> Automating any of them into PR-CI is out of scope for v2. This playbook
|
||||
> captures the minimal dev-box-plus-VM setup and the expected pass criteria so
|
||||
> the work can be executed repeatably at v2 release readiness and after any
|
||||
> Phase 6.3 follow-up change.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Two `OtOpcUa.Server` nodes in one `ServerCluster`:
|
||||
- Declared as `NodeCount = 2`, `RedundancyMode = Hot` (or `Warm`).
|
||||
- Each with a distinct `ApplicationUri` (enforced by unique index per
|
||||
decision #86).
|
||||
- Each node's `StaticRoutes.xml` points at the other (`ServerCluster.Node[].Host`).
|
||||
2. `scripts/install/Install-Services.ps1` applied on each node so the
|
||||
`RedundancyPublisherHostedService` is running.
|
||||
3. At least one `DriverInstance` with a reachable simulator or PLC so both
|
||||
servers have a non-empty address space to browse.
|
||||
4. On the client host:
|
||||
- `UaExpert` ≥ 1.7 installed
|
||||
- Kepware `ClientAce QuickClient` (or equivalent) — optional, for a second
|
||||
client
|
||||
5. For the AVEVA leg: a `Galaxy.Host` running against an MXAccess deployment
|
||||
with an external OPC UA client object pointed at the cluster (not at a
|
||||
single node).
|
||||
|
||||
## Expected signals on a running cluster
|
||||
|
||||
| Node | `ServiceLevel` | `RedundancySupport` | `ServerUriArray` |
|
||||
|---|---|---|---|
|
||||
| Primary, healthy, peer reachable | 200 | `Hot` (or `Warm`) | self + peer |
|
||||
| Primary, mid-apply | 75 (`PrimaryMidApply`) | same | same |
|
||||
| Primary, peer UNreachable | 150 (`PrimaryPeerDown`) | same | same |
|
||||
| Backup, healthy | 100 (`Secondary`) | same | same |
|
||||
| Either, dwelling in recovery | 50 (`Recovering`) | same | same |
|
||||
| Either, invariant violation (two Primary, disabled-node mismatch) | 2 (`InvalidTopology`) | same | same |
|
||||
|
||||
(The band constants live in `ServiceLevelCalculator.Classify`.)
|
||||
|
||||
## Test matrix
|
||||
|
||||
Each row is one manual run; pass criterion in the right column.
|
||||
|
||||
### Block A — UA protocol signals (UaExpert)
|
||||
|
||||
| # | Scenario | Procedure | Pass criterion |
|
||||
|---|---|---|---|
|
||||
| A1 | ServiceLevel published | Connect UaExpert to Primary. Browse to `Server.ServerStatus.ServiceLevel`. | Value = 200 (or the expected Band byte per table above) |
|
||||
| A2 | ServiceLevel updates on peer down | Connect to Primary. Stop Backup (`sc stop OtOpcUa`). Watch `ServiceLevel`. | Transitions 200 → 150 within ~2 s of peer probe timeout |
|
||||
| A3 | RedundancySupport | Browse to `Server.ServerRedundancy.RedundancySupport`. | Value matches the declared `RedundancyMode` (Warm / Hot / None) |
|
||||
| A4 | ServerUriArray (non-transparent upgrade) | Requires a redundancy-object-type upgrade follow-up. | When upgrade lands: `ServerUriArray` reports both ApplicationUris, self first |
|
||||
| A5 | Mid-apply dip | On Primary trigger a `sp_PublishGeneration` apply. | `ServiceLevel` drops to 75 for the apply duration + dwell |
|
||||
|
||||
### Block B — Client failover
|
||||
|
||||
| # | Scenario | Procedure | Pass criterion |
|
||||
|---|---|---|---|
|
||||
| B1 | UaExpert picks Primary by ServiceLevel | In UaExpert configure a Redundancy Group with both endpoint URLs. | Client picks the Primary URL (higher ServiceLevel) |
|
||||
| B2 | UaExpert cuts over on Primary kill | Kill the Primary's `OtOpcUa` service. | Client session reconnects to Backup within UaExpert's reconnect timeout (default 5 s). Data-change monitored items resume. |
|
||||
| B3 | UaExpert cuts back when Primary returns | Start the Primary service. Wait ≥ recovery dwell (see `RecoveryStateManager.DwellTime`). | `ServiceLevel` on returning Primary goes through 50 (Recovering) → 200; UaExpert may or may not switch back (client-policy dependent; both are accepted outcomes) |
|
||||
| B4 | Kepware QuickClient failover | Repeat B1–B3 with Kepware in place of UaExpert. | Same pass criteria; establishes we're not UaExpert-specific |
|
||||
|
||||
### Block C — Galaxy MXAccess failover
|
||||
|
||||
This block validates that an AVEVA System Platform app consuming our cluster
|
||||
via MXAccess tolerates a Primary drop the same way a native OPC UA client does.
|
||||
The MXAccess toolkit internally wraps the OPC UA Client and does its own
|
||||
redundancy negotiation; we're asserting that negotiation honors our
|
||||
`ServiceLevel` signal.
|
||||
|
||||
| # | Scenario | Procedure | Pass criterion |
|
||||
|---|---|---|---|
|
||||
| C1 | Galaxy binds to Primary on first connect | Bring the cluster up. Start a Galaxy `$MxAccessClient` object pointed at the cluster with both node URLs. | Galaxy reports `QUALITY = Good` + initial values from the Primary |
|
||||
| C2 | Galaxy redirects on Primary drop | Stop the Primary. | Galaxy's `QUALITY` briefly goes `Uncertain`, then back to `Good`; values continue streaming from the Backup within MXAccess's `ReconnectInterval` (default 20 s) |
|
||||
| C3 | Galaxy handles mid-apply dip | Trigger a generation apply on the Primary. | Galaxy continues reading — the mid-apply dip is advertisory (ServiceLevel 75), not a session drop; MXAccess should stay bound |
|
||||
|
||||
## Recording results
|
||||
|
||||
Copy the tables above into a tracking doc per run. The tracking doc shape:
|
||||
|
||||
```
|
||||
Run date: 2026-MM-DD
|
||||
Cluster: <id> Primary: <node> Backup: <node> Release: <sha>
|
||||
A1: PASS evidence: UaExpert screenshot uaexpert-a1.png
|
||||
A2: PASS evidence: ServiceLevel trend grafana-a2.png
|
||||
…
|
||||
```
|
||||
|
||||
One pass of every row is the acceptance criterion. Re-run after any Phase 6.3
|
||||
follow-up ships (especially the non-transparent redundancy-type upgrade, which
|
||||
flips A4 from "deferred" to "expected pass").
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **A4 pending**: `Server.ServerRedundancy` on our current SDK build lands as
|
||||
the base `ServerRedundancyState`, which has no `ServerUriArray` child.
|
||||
`ServerRedundancyNodeWriter.ApplyServerUriArray` logs-and-skips until the
|
||||
redundancy-object-type upgrade follow-up lands.
|
||||
- **Recovery dwell default**: `RecoveryStateManager.DwellTime` defaults to 60 s
|
||||
in `Program.cs`. Adjust via future config knob if B3 takes too long to
|
||||
observe.
|
||||
- **C-block external dependency**: The `Galaxy.Host` side of the redundancy
|
||||
story is largely out of our code — it's MXAccess's own client-redundancy
|
||||
policy talking to our published ServiceLevel. A negative result on C1-C3
|
||||
does not necessarily indicate an OtOpcUa bug; cross-check with UaExpert
|
||||
(Block A / B) first.
|
||||
|
||||
## Automation notes (why this is a playbook, not a test)
|
||||
|
||||
- UaExpert and Kepware binaries are closed-source Windows GUIs; they don't
|
||||
ship headless CLIs for the browse/connect/subscribe flows.
|
||||
- The OPC Foundation reference SDK *can* drive every scenario, but our own
|
||||
`Driver.OpcUaClient` tests already cover that client's behaviour; Block B
|
||||
adds value specifically because these two clients have independent
|
||||
redundancy implementations we don't control.
|
||||
- For the sub-set of scenarios that *can* be automated — the self-loopback
|
||||
case where our own `otopcua-cli` drives Primary + Backup — the existing
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Server.Tests/RedundancyStatePublisherTests` +
|
||||
`ServiceLevelCalculatorTests` (unit) + `ClusterTopologyLoaderTests`
|
||||
(integration) already cover the math + data path. The wire-level assertion
|
||||
that the values actually land on the right OPC UA nodes is covered by
|
||||
`ServerRedundancyNodeWriterTests`.
|
||||
31
docs/v3/twincat-backlog.md
Normal file
31
docs/v3/twincat-backlog.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# TwinCAT driver — v3 backlog
|
||||
|
||||
The v2 TwinCAT driver is considered solid: 28 integration tests (14 `[TwinCATFact]` +
|
||||
16-case `[TwinCATTheory]`) running live against the TCBSD fixture, 110 unit tests,
|
||||
three latent driver bugs shaken out (notification cycle units, `STRING(N)` mapper,
|
||||
bit-indexed BOOL path). Further work is deferred.
|
||||
|
||||
Archived from `docs/drivers/TwinCAT-Test-Fixture.md` § Follow-up candidates.
|
||||
|
||||
## Deferred items
|
||||
|
||||
1. **TC2 coverage** — spin up a TC2 runtime (Windows CE IPC or legacy XAR)
|
||||
and run the same suite; any delta surfaces. Blocked on hardware.
|
||||
|
||||
2. **Notification coalescing under load** — run the subscribe test while
|
||||
the PLC cycle is saturated (bump `lineSim` complexity, watch for
|
||||
dropped notifications). Doable on current rig; deferred as lower
|
||||
priority than v3 feature work.
|
||||
|
||||
3. **Multi-hop AMS route** — add a test behind an IPC gateway with a
|
||||
chained route entry. Blocked on hardware (gateway IPC).
|
||||
|
||||
4. **License-rotation automation** — XAR's 7-day trial expires on
|
||||
schedule. Either automate `TcActivate.exe /reactivate` via a scheduled
|
||||
task on the VM (not officially supported; reportedly works for some
|
||||
TC3 builds), or buy a paid runtime license (~$1k one-time per runtime
|
||||
per CPU) to kill the rotation. Ops item, not code.
|
||||
|
||||
5. **Lab rig** — cheapest IPC (CX7000 / CX9020) on a dedicated network;
|
||||
the only route that covers TC2 + real EtherCAT I/O timing + cycle
|
||||
jitter under CPU load. Blocked on hardware + budget.
|
||||
@@ -53,27 +53,47 @@ read-only tag.
|
||||
|
||||
## Status
|
||||
|
||||
Stages 1 + 2 (driver-side probe + loopback) are verified end-to-end
|
||||
against the pymodbus / ab_server / python-snap7 fixtures. Stages 3-5
|
||||
(anything crossing the OtOpcUa server) are **blocked** on server-side
|
||||
driver factory wiring:
|
||||
All seven driver factories are registered in
|
||||
`src/ZB.MOM.WW.OtOpcUa.Server/Program.cs` — Galaxy, FOCAS, Modbus,
|
||||
AB CIP, AB Legacy, S7, TwinCAT. `DriverInstanceBootstrapper` can
|
||||
materialise any `DriverType` row from the central Config DB into a
|
||||
live driver. The factory-wiring block that originally gated stages
|
||||
3-5 is closed.
|
||||
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Server/Program.cs` only registers Galaxy +
|
||||
FOCAS factories. `DriverInstanceBootstrapper` skips any `DriverType`
|
||||
without a registered factory — so Modbus / AB CIP / AB Legacy / S7 /
|
||||
TwinCAT rows in the Config DB are silently no-op'd even when the seed
|
||||
is perfect.
|
||||
- No Config DB seed script exists for non-Galaxy drivers; Admin UI is
|
||||
currently the only path to author one.
|
||||
Live-boot verification:
|
||||
|
||||
Tracking: **#209** (umbrella) → #210 (Modbus), #211 (AB CIP), #212 (S7),
|
||||
#213 (AB Legacy, also hardware-gated — #222). Each child issue lists
|
||||
the factory class to write + the seed SQL shape + the verification
|
||||
command.
|
||||
- **Galaxy** — 7/7 stages (read / write / subscribe / alarms / history)
|
||||
against a real Galaxy + `OtOpcUaGalaxyHost` on this dev box.
|
||||
- **AB CIP, S7** — 5/5 stages each under task #220 against the
|
||||
`ab_server` + `python-snap7` fixtures.
|
||||
- **AB Legacy** — 5/5 stages under task #222 against `ab_server` SLC500
|
||||
/ MicroLogix / PLC-5 profiles (requires the `cip-path /1,0` workaround
|
||||
for the Docker fixture).
|
||||
- **Modbus** — 5/5 stages against the `pymodbus` + dl205 profile,
|
||||
including HR[200] scratch register + per-protocol bidirectional +
|
||||
subscribe-sees-change stages.
|
||||
- **TwinCAT** — factory registered; driver features validated against the
|
||||
TCBSD VM virtual-PLC fixture (FreeBSD + TwinCAT/BSD runtime on ESXi —
|
||||
bypasses the Hyper-V/RTIME conflict that blocks XAR on the dev box).
|
||||
`TWINCAT_TRUST_WIRE=1` is still required to run the script —
|
||||
false-pass-prevention belt, not an "unverified" flag.
|
||||
- **FOCAS** — factory registered; gated by `FOCAS_TRUST_WIRE=1` pending
|
||||
the lab-rig CNC (task #222 follow-up).
|
||||
- **OpcUaClient (gateway)** — eight-stage script (`test-opcuaclient.ps1`)
|
||||
covers probe / remote read / forward bridge / subscribe / reverse
|
||||
bridge / browse mirror / alarm / history against the opc-plc Docker
|
||||
fixture at `opc.tcp://localhost:50000`. Reverse-bridge / alarm /
|
||||
history stages are opt-in per the parameter docs (opc-plc's default
|
||||
image has no writable nodes and does not historize).
|
||||
|
||||
Until those ship, stages 3-5 will fail with "read failed" (nothing
|
||||
published at that NodeId) and `[FAIL]` the suite even on a running
|
||||
server.
|
||||
Remaining work is **per-protocol seed authoring**: each dev fills in
|
||||
the NodeIds their server publishes under `e2e-config.json` (sidecar
|
||||
is `.gitignore`-d; see `e2e-config.sample.json` for the shape). Admin
|
||||
UI remains the supported path for authoring the matching driver
|
||||
instance rows in the Config DB.
|
||||
|
||||
Tracking: umbrella #209 is closed; remaining TwinCAT / FOCAS work
|
||||
tracks under their hardware-fixture tasks (#221 / #222).
|
||||
|
||||
## Prereqs
|
||||
|
||||
@@ -85,7 +105,9 @@ server.
|
||||
for the simulator matrix — pymodbus / ab_server / python-snap7 /
|
||||
opc-plc cover Modbus / AB / S7 / OPC UA Client. FOCAS and TwinCAT
|
||||
have no public simulator; they are gated with env-var skip flags
|
||||
below.
|
||||
below. For OpcUaClient, `docker compose -f
|
||||
tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker/
|
||||
docker-compose.yml up -d` brings up `opc-plc` on port 50000.
|
||||
3. **PowerShell 7+**. The runner uses null-coalescing + `Set-StrictMode`;
|
||||
the Windows-PowerShell-5.1 shell will not parse `test-all.ps1`.
|
||||
4. **.NET 10 SDK**. Each script either runs `dotnet run --project
|
||||
@@ -136,7 +158,8 @@ section to skip it.
|
||||
| Galaxy | — | **PASS** (requires OtOpcUaGalaxyHost + a live Galaxy; 7 stages including alarms + history) |
|
||||
| S7 | — | **PASS** (python-snap7 fixture) |
|
||||
| FOCAS | `FOCAS_TRUST_WIRE=1` | **SKIP** (no public simulator — task #222 lab rig) |
|
||||
| TwinCAT | `TWINCAT_TRUST_WIRE=1` | **SKIP** (needs XAR or standalone Router — task #221) |
|
||||
| TwinCAT | `TWINCAT_TRUST_WIRE=1` | **SKIP** by default; features **validated** against the TCBSD VM fixture — set the env var to run |
|
||||
| OpcUaClient | — | **PASS** stages 1-4 + browse (opc-plc Docker fixture); stages 5/7/8 are opt-in (require writable / alarm / historizing upstream) |
|
||||
| Phase 7 | — | **PASS** if the Modbus instance seeds a `VT_DoubledHR100` virtual tag + `AlarmHigh` scripted alarm |
|
||||
|
||||
Set the `*_TRUST_WIRE` env vars to `1` when you've pointed the script at
|
||||
|
||||
@@ -422,8 +422,11 @@ function Write-Summary {
|
||||
[Parameter(Mandatory)] [string]$Title,
|
||||
[Parameter(Mandatory)] [array]$Results
|
||||
)
|
||||
$passed = ($Results | Where-Object { $_.Passed }).Count
|
||||
$failed = ($Results | Where-Object { -not $_.Passed }).Count
|
||||
# @(...) forces an array even when Where-Object matches 0 or 1 items,
|
||||
# otherwise .Count trips Set-StrictMode -Version 3.0 ("property 'Count'
|
||||
# cannot be found on this object") on $null or on a single hashtable.
|
||||
$passed = @($Results | Where-Object { $_.Passed }).Count
|
||||
$failed = @($Results | Where-Object { -not $_.Passed }).Count
|
||||
Write-Host ""
|
||||
Write-Host "=== $Title summary: $passed/$($Results.Count) passed ===" `
|
||||
-ForegroundColor $(if ($failed -eq 0) { "Green" } else { "Red" })
|
||||
|
||||
@@ -189,6 +189,33 @@ if ($galaxy) {
|
||||
}
|
||||
else { $summary["galaxy"] = "SKIP (no config entry)" }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OPC UA Client (gateway driver)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$opcuaclient = Get-Or $config "opcuaclient"
|
||||
if ($opcuaclient) {
|
||||
Write-Header "== OPC UA CLIENT =="
|
||||
Run-Suite "opcuaclient" {
|
||||
& "$PSScriptRoot/test-opcuaclient.ps1" `
|
||||
-RemoteUrl (Get-Or $opcuaclient "remoteUrl" "opc.tcp://localhost:50000") `
|
||||
-OpcUaUrl (Get-Or $opcuaclient "opcUaUrl" $OpcUaUrl) `
|
||||
-RemoteNodeId (Get-Or $opcuaclient "remoteNodeId" "ns=3;s=FastUInt1") `
|
||||
-BridgeNodeId $opcuaclient["bridgeNodeId"] `
|
||||
-WritableRemoteNodeId (Get-Or $opcuaclient "writableRemoteNodeId" "") `
|
||||
-WritableBridgeNodeId (Get-Or $opcuaclient "writableBridgeNodeId" "") `
|
||||
-BridgeRootNodeId (Get-Or $opcuaclient "bridgeRootNodeId" "i=85") `
|
||||
-BrowseDepth (Get-Or $opcuaclient "browseDepth" 3) `
|
||||
-BrowseMinNodes (Get-Or $opcuaclient "browseMinNodes" 5) `
|
||||
-AlarmNodeId (Get-Or $opcuaclient "alarmNodeId" "") `
|
||||
-AlarmWaitSec (Get-Or $opcuaclient "alarmWaitSec" 15) `
|
||||
-HistoryNodeId (Get-Or $opcuaclient "historyNodeId" "") `
|
||||
-HistoryLookbackSec (Get-Or $opcuaclient "historyLookbackSec" 3600) `
|
||||
-ChangeWaitSec (Get-Or $opcuaclient "changeWaitSec" 8)
|
||||
}
|
||||
}
|
||||
else { $summary["opcuaclient"] = "SKIP (no config entry)" }
|
||||
|
||||
$phase7 = Get-Or $config "phase7"
|
||||
if ($phase7) {
|
||||
Write-Header "== PHASE 7 virtual tags + scripted alarms =="
|
||||
@@ -220,7 +247,9 @@ $summary.GetEnumerator() | ForEach-Object {
|
||||
Write-Host (" {0,-10} {1}" -f $_.Key, $_.Value) -ForegroundColor $color
|
||||
}
|
||||
|
||||
$failed = ($summary.Values | Where-Object { $_ -eq "FAIL" }).Count
|
||||
# @() wrap — Where-Object returns $null / a single scalar for 0-or-1 matches,
|
||||
# and .Count on either trips Set-StrictMode -Version 3.0.
|
||||
$failed = @($summary.Values | Where-Object { $_ -eq "FAIL" }).Count
|
||||
if ($failed -gt 0) {
|
||||
Write-Host "$failed suite(s) failed." -ForegroundColor Red
|
||||
exit 1
|
||||
|
||||
392
scripts/e2e/test-opcuaclient.ps1
Normal file
392
scripts/e2e/test-opcuaclient.ps1
Normal file
@@ -0,0 +1,392 @@
|
||||
#Requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
End-to-end CLI test for the OPC UA Client (gateway) driver bridged through
|
||||
the OtOpcUa server.
|
||||
|
||||
.DESCRIPTION
|
||||
The OpcUaClient driver is unique in the fleet — it's a gateway that connects
|
||||
to ANOTHER OPC UA server and re-exposes its address space through the local
|
||||
OtOpcUa server. So there's no protocol-specific driver CLI; both directions
|
||||
of this test use `otopcua-cli` against two different endpoints:
|
||||
|
||||
remote = the upstream OPC UA server the driver connects to (opc-plc fixture
|
||||
by default, opc.tcp://localhost:50000)
|
||||
local = the OtOpcUa server itself, which mirrors remote nodes through the
|
||||
OpcUaClient driver instance (opc.tcp://localhost:4840)
|
||||
|
||||
Eight stages cover the driver's full capability surface:
|
||||
|
||||
1. Remote probe — otopcua-cli connect to the upstream. Confirms the
|
||||
simulator / target server is reachable and
|
||||
speaking UA Secure Channel.
|
||||
2. Remote read — otopcua-cli read of -RemoteNodeId on the upstream.
|
||||
Captures the current value + confirms the node
|
||||
exists. Baseline for the forward-bridge stage.
|
||||
3. Forward bridge — otopcua-cli read of -BridgeNodeId on the LOCAL
|
||||
server. Proves the driver discovered + mirrored
|
||||
the remote node into the local address space and
|
||||
the read path is live (IReadable via session).
|
||||
4. Subscribe-sees-change — subscribe on local -BridgeNodeId in the
|
||||
background. opc-plc's tickers (FastUInt1, StepUp)
|
||||
mutate autonomously, so no driver poke is needed
|
||||
— a data-change event should arrive within the
|
||||
subscription window. Covers ISubscribable +
|
||||
upstream subscription transfer.
|
||||
5. Reverse bridge — otopcua-cli write to local -WritableBridgeNodeId,
|
||||
then otopcua-cli read of -WritableRemoteNodeId
|
||||
directly on the upstream. Confirms writes flow
|
||||
through the driver to the remote (IWritable). Opt-
|
||||
in — opc-plc default image has no writable nodes
|
||||
without `--sn`; pass -WritableBridgeNodeId AND
|
||||
-WritableRemoteNodeId to enable.
|
||||
6. Browse mirror — otopcua-cli browse of the local -BridgeRootNodeId
|
||||
at depth -BrowseDepth. Asserts at least
|
||||
-BrowseMinNodes descendants appear. Covers
|
||||
ITagDiscovery → local-namespace projection.
|
||||
7. Alarm fires — otopcua-cli alarms subscription on local
|
||||
-AlarmNodeId. opc-plc with `--alm` cycles a
|
||||
TripAlarm autonomously; assert an Active alarm
|
||||
event surfaces. Covers IAlarmSource → OPC UA A&E
|
||||
projection. Opt-in via -AlarmNodeId.
|
||||
8. History read — historyread on local -HistoryNodeId over a
|
||||
lookback window. Covers IHistoryProvider →
|
||||
upstream HistoryRead dispatch. Opt-in via
|
||||
-HistoryNodeId. Note: opc-plc's default image
|
||||
does not historize — a historizing upstream
|
||||
(Prosys, UaExpert sample server) is required.
|
||||
|
||||
Prereqs:
|
||||
|
||||
1. Upstream OPC UA server reachable at -RemoteUrl. Default expects the
|
||||
opc-plc Docker fixture (`tests/.../Driver.OpcUaClient.IntegrationTests/
|
||||
Docker/docker-compose.yml`): `docker compose up -d` before running.
|
||||
2. OtOpcUa server running at -OpcUaUrl with an OpcUaClient DriverInstance
|
||||
in its Config DB whose EndpointUrl = -RemoteUrl. The server's
|
||||
DiscoverAsync populates the mirrored namespace at startup; the
|
||||
-BridgeNodeId / -BridgeRootNodeId you pass must correspond to whatever
|
||||
NodeIds that discovery produced on your local server.
|
||||
3. To exercise stages 5 / 7 / 8, the upstream must expose writable nodes /
|
||||
alarm conditions / history. opc-plc alone doesn't cover all three — see
|
||||
parameter docs below for the combinations that work with opc-plc.
|
||||
|
||||
.PARAMETER RemoteUrl
|
||||
Upstream OPC UA server endpoint (the server the driver connects to).
|
||||
Default matches the opc-plc Docker fixture — opc.tcp://localhost:50000.
|
||||
|
||||
.PARAMETER OpcUaUrl
|
||||
Local OtOpcUa server endpoint. Default opc.tcp://localhost:4840.
|
||||
|
||||
.PARAMETER RemoteNodeId
|
||||
NodeId on the upstream used for stages 1-2. Default ns=3;s=FastUInt1 — opc-plc
|
||||
ticker that increments every 100 ms.
|
||||
|
||||
.PARAMETER BridgeNodeId
|
||||
NodeId on the LOCAL server that mirrors -RemoteNodeId after the OpcUaClient
|
||||
driver discovers it. Dev-specific — whatever the local DiscoverAsync produced
|
||||
for the upstream node. No default; mandatory for stages 3-4.
|
||||
|
||||
.PARAMETER WritableRemoteNodeId
|
||||
Writable NodeId on the upstream for the reverse-bridge stage. opc-plc's
|
||||
default image has no writable nodes; add `--sn=1` to the compose command to
|
||||
expose `ns=3;s=SlowUInt1` as writable (or similar per opc-plc docs). Omit to
|
||||
skip stage 5.
|
||||
|
||||
.PARAMETER WritableBridgeNodeId
|
||||
Matching local mirror of -WritableRemoteNodeId. Omit to skip stage 5.
|
||||
|
||||
.PARAMETER BridgeRootNodeId
|
||||
Root NodeId on the local server under which the mirrored upstream sits. The
|
||||
browse stage walks from this node down to -BrowseDepth. Default i=85
|
||||
(ObjectsFolder) — works but produces a lot of output; pass a narrower root
|
||||
for faster / more targeted coverage.
|
||||
|
||||
.PARAMETER BrowseDepth
|
||||
Max depth for the browse stage. Default 3.
|
||||
|
||||
.PARAMETER BrowseMinNodes
|
||||
Minimum number of descendants expected under -BridgeRootNodeId. Default 5.
|
||||
|
||||
.PARAMETER AlarmNodeId
|
||||
NodeId of the ConditionType on the local server for the alarm-fires stage.
|
||||
opc-plc with `--alm` exposes e.g. TripAlarm conditions; the local mirror path
|
||||
of that condition goes here. Omit to skip stage 7.
|
||||
|
||||
.PARAMETER AlarmWaitSec
|
||||
Seconds to wait for the alarm to cycle. opc-plc's TripAlarm fires on its own
|
||||
cadence; 15 s usually covers one cycle. Default 15.
|
||||
|
||||
.PARAMETER HistoryNodeId
|
||||
NodeId on the local server whose history to query. Omit to skip stage 8.
|
||||
|
||||
.PARAMETER HistoryLookbackSec
|
||||
Seconds back from now to query history. Default 3600.
|
||||
|
||||
.PARAMETER ChangeWaitSec
|
||||
Seconds the subscribe-sees-change stage waits for a natural ticker update.
|
||||
opc-plc's FastUInt1 ticks every 100 ms so a short window suffices. Default 8.
|
||||
|
||||
.EXAMPLE
|
||||
# Bare-minimum: stages 1-4 + browse, against the opc-plc compose fixture.
|
||||
# Requires the local OtOpcUa server to have discovered opc-plc and placed
|
||||
# FastUInt1 under (for example) ns=2;s=OpcUaClient/FastUInt1.
|
||||
./scripts/e2e/test-opcuaclient.ps1 -BridgeNodeId "ns=2;s=OpcUaClient/FastUInt1"
|
||||
|
||||
.EXAMPLE
|
||||
# Full matrix — all eight stages. Requires an opc-plc image with --sn (for
|
||||
# writable) + --alm (for alarms; default compose has this) + a historizing
|
||||
# upstream (opc-plc does not; Prosys does).
|
||||
./scripts/e2e/test-opcuaclient.ps1 `
|
||||
-BridgeNodeId "ns=2;s=OpcUaClient/FastUInt1" `
|
||||
-WritableRemoteNodeId "ns=3;s=SlowUInt1" `
|
||||
-WritableBridgeNodeId "ns=2;s=OpcUaClient/SlowUInt1" `
|
||||
-BridgeRootNodeId "ns=2;s=OpcUaClient" `
|
||||
-AlarmNodeId "ns=2;s=OpcUaClient/TripAlarm" `
|
||||
-HistoryNodeId "ns=2;s=OpcUaClient/StepUp"
|
||||
#>
|
||||
|
||||
param(
|
||||
[string]$RemoteUrl = "opc.tcp://localhost:50000",
|
||||
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||
[string]$RemoteNodeId = "ns=3;s=FastUInt1",
|
||||
[Parameter(Mandatory)] [string]$BridgeNodeId,
|
||||
[string]$WritableRemoteNodeId = "",
|
||||
[string]$WritableBridgeNodeId = "",
|
||||
[string]$BridgeRootNodeId = "i=85",
|
||||
[int]$BrowseDepth = 3,
|
||||
[int]$BrowseMinNodes = 5,
|
||||
[string]$AlarmNodeId = "",
|
||||
[int]$AlarmWaitSec = 15,
|
||||
[string]$HistoryNodeId = "",
|
||||
[int]$HistoryLookbackSec = 3600,
|
||||
[int]$ChangeWaitSec = 8
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. "$PSScriptRoot/_common.ps1"
|
||||
|
||||
$opcUaCli = Get-CliInvocation `
|
||||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||
-ExeName "otopcua-cli"
|
||||
|
||||
$results = @()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1 — Remote probe. `otopcua-cli connect` exits 0 when the Secure Channel
|
||||
# + Session handshake to the upstream complete cleanly. A failure here means
|
||||
# opc-plc isn't running or the endpoint is unreachable — nothing downstream is
|
||||
# worth trying.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Header "Remote probe"
|
||||
$probe = Invoke-Cli -Cli $opcUaCli -Args @("connect", "-u", $RemoteUrl)
|
||||
if ($probe.ExitCode -eq 0 -and $probe.Output -match "Connection successful") {
|
||||
Write-Pass "upstream $RemoteUrl reachable + speaks UA"
|
||||
$results += @{ Passed = $true }
|
||||
} else {
|
||||
Write-Fail "upstream connect failed (exit=$($probe.ExitCode))"
|
||||
Write-Host $probe.Output
|
||||
$results += @{ Passed = $false; Reason = "remote probe failed" }
|
||||
# Fail fast: if the upstream is down every other stage will cascade.
|
||||
Write-Summary -Title "OpcUaClient e2e" -Results $results
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2 — Remote read. Pulls the current value of -RemoteNodeId directly from
|
||||
# the upstream. Recorded for later stages to compare against, and confirms the
|
||||
# chosen NodeId actually exists on this upstream.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Header "Remote read"
|
||||
$remoteRead = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $RemoteUrl, "-n", $RemoteNodeId)
|
||||
$remoteValue = $null
|
||||
if ($remoteRead.ExitCode -eq 0 -and $remoteRead.Output -match "Value:\s+([^\r\n]+)") {
|
||||
$remoteValue = $Matches[1].Trim()
|
||||
Write-Pass "remote $RemoteNodeId = $remoteValue"
|
||||
$results += @{ Passed = $true }
|
||||
} else {
|
||||
Write-Fail "remote read of $RemoteNodeId failed"
|
||||
Write-Host $remoteRead.Output
|
||||
$results += @{ Passed = $false; Reason = "remote read failed" }
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 3 — Forward bridge. Read -BridgeNodeId on the LOCAL server. If the
|
||||
# OpcUaClient driver is live + its discovery mapped -RemoteNodeId into the
|
||||
# local namespace, this should return a Good value. For ticker nodes like
|
||||
# FastUInt1 we don't require exact equality with stage 2 (the ticker has
|
||||
# likely advanced between reads); a Good-status read is the real signal.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Header "Forward bridge (remote → local)"
|
||||
$localRead = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $BridgeNodeId)
|
||||
if ($localRead.ExitCode -eq 0 -and $localRead.Output -match "Status:\s+0x00000000" -and $localRead.Output -match "Value:\s+([^\r\n]+)") {
|
||||
$localValue = $Matches[1].Trim()
|
||||
Write-Pass "local bridge $BridgeNodeId = $localValue (remote was $remoteValue)"
|
||||
$results += @{ Passed = $true }
|
||||
} else {
|
||||
Write-Fail "local bridge read failed — driver instance may not be configured or discovery hasn't run"
|
||||
Write-Host $localRead.Output
|
||||
$results += @{ Passed = $false; Reason = "forward bridge failed" }
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 4 — Subscribe sees change. opc-plc's FastUInt1 ticks autonomously so we
|
||||
# don't need to drive a write. A properly wired OpcUaClient driver forwards
|
||||
# remote MonitoredItem data-change callbacks to the local server, which then
|
||||
# publishes them to our subscribe client. If nothing arrives within the
|
||||
# window, either the remote node isn't a ticker OR the upstream subscription
|
||||
# chain is broken (probe state, keep-alive, SDK publish queue).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Header "Subscribe sees change"
|
||||
$stdout = New-TemporaryFile
|
||||
$stderr = New-TemporaryFile
|
||||
$subArgs = @($opcUaCli.PrefixArgs) + @(
|
||||
"subscribe", "-u", $OpcUaUrl, "-n", $BridgeNodeId,
|
||||
"-i", "200", "--duration", "$ChangeWaitSec")
|
||||
$subProc = Start-Process -FilePath $opcUaCli.File `
|
||||
-ArgumentList $subArgs -NoNewWindow -PassThru `
|
||||
-RedirectStandardOutput $stdout.FullName `
|
||||
-RedirectStandardError $stderr.FullName
|
||||
Write-Info "subscription started (pid $($subProc.Id)) for ${ChangeWaitSec}s"
|
||||
$subProc.WaitForExit(($ChangeWaitSec + 5) * 1000) | Out-Null
|
||||
if (-not $subProc.HasExited) { Stop-Process -Id $subProc.Id -Force }
|
||||
$subOut = (Get-Content $stdout.FullName -Raw) + (Get-Content $stderr.FullName -Raw)
|
||||
Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue
|
||||
|
||||
# SubscribeCommand prints `[timestamp] <NodeId> = <value> (0xNNNNNNNN)` per
|
||||
# data-change event. 0x00000000 == Good; anything else is a non-Good status
|
||||
# we intentionally don't count (a quality drop isn't a "saw the change").
|
||||
$changeLines = @(($subOut -split "`n") | Where-Object { $_ -match "=\s+\S.*\(0x00000000\)" })
|
||||
if ($changeLines.Count -gt 0) {
|
||||
Write-Pass "$($changeLines.Count) data-change events observed on bridge"
|
||||
$results += @{ Passed = $true }
|
||||
} else {
|
||||
Write-Fail "no data-change events in ${ChangeWaitSec}s — upstream node may be static, or subscription chain broken"
|
||||
Write-Host $subOut
|
||||
$results += @{ Passed = $false; Reason = "no data-change" }
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 5 — Reverse bridge. Only runs when both writable NodeIds are supplied.
|
||||
# Writes on the local bridge side, reads directly on the upstream to verify
|
||||
# the write crossed the driver. 2s settle accounts for the driver's next poll
|
||||
# (non-idempotent writes on upstream side may take a tick to propagate).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if ([string]::IsNullOrEmpty($WritableBridgeNodeId) -or [string]::IsNullOrEmpty($WritableRemoteNodeId)) {
|
||||
Write-Header "Reverse bridge (local → remote)"
|
||||
Write-Skip "WritableBridgeNodeId / WritableRemoteNodeId not supplied — opc-plc default has no writable nodes. Add --sn=N to the compose and re-run with both params set."
|
||||
} else {
|
||||
Write-Header "Reverse bridge (local → remote)"
|
||||
$writeValue = Get-Random -Minimum 1 -Maximum 9999
|
||||
$w = Invoke-Cli -Cli $opcUaCli -Args @(
|
||||
"write", "-u", $OpcUaUrl, "-n", $WritableBridgeNodeId, "-v", "$writeValue")
|
||||
if ($w.ExitCode -ne 0 -or $w.Output -notmatch "Write successful") {
|
||||
Write-Fail "local-side write failed"
|
||||
Write-Host $w.Output
|
||||
$results += @{ Passed = $false; Reason = "reverse-bridge write failed" }
|
||||
} else {
|
||||
Write-Info "local write ok, waiting 2s for driver propagate"
|
||||
Start-Sleep -Seconds 2
|
||||
$r = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $RemoteUrl, "-n", $WritableRemoteNodeId)
|
||||
if ($r.ExitCode -eq 0 -and $r.Output -match "Value:\s+$([Regex]::Escape("$writeValue"))\b") {
|
||||
Write-Pass "remote reads back $writeValue"
|
||||
$results += @{ Passed = $true }
|
||||
} else {
|
||||
Write-Fail "remote value did not reflect $writeValue"
|
||||
Write-Host $r.Output
|
||||
$results += @{ Passed = $false; Reason = "reverse-bridge readback mismatch" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 6 — Browse mirror. Walks -BridgeRootNodeId to -BrowseDepth levels. The
|
||||
# BrowseCommand emits one line per encountered node; we count non-empty lines
|
||||
# minus the root-summary line and compare against -BrowseMinNodes. A naked
|
||||
# i=85 root always has something; a narrower dev-specific root is stricter.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Header "Browse mirror"
|
||||
$br = Invoke-Cli -Cli $opcUaCli -Args @(
|
||||
"browse", "-u", $OpcUaUrl, "-n", $BridgeRootNodeId,
|
||||
"-r", "-d", "$BrowseDepth")
|
||||
if ($br.ExitCode -ne 0) {
|
||||
Write-Fail "browse failed (exit=$($br.ExitCode))"
|
||||
Write-Host $br.Output
|
||||
$results += @{ Passed = $false; Reason = "browse failed" }
|
||||
} else {
|
||||
# BrowseCommand prints one line per node: `[Type] Name (NodeId: xxx)` with
|
||||
# indentation for depth. Count every line carrying a NodeId marker.
|
||||
$nodeLines = @(($br.Output -split "`n") | Where-Object { $_ -match "\(NodeId:" })
|
||||
$count = $nodeLines.Count
|
||||
if ($count -ge $BrowseMinNodes) {
|
||||
Write-Pass "$count descendants under $BridgeRootNodeId (>= $BrowseMinNodes)"
|
||||
$results += @{ Passed = $true }
|
||||
} else {
|
||||
Write-Fail "only $count descendants — expected >= $BrowseMinNodes"
|
||||
Write-Host $br.Output
|
||||
$results += @{ Passed = $false; Reason = "browse under-populated" }
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 7 — Alarm fires. opc-plc with --alm (set in the compose) cycles a
|
||||
# TripAlarm Condition autonomously. The local alarm subscription should
|
||||
# surface at least one Active transition within the wait window. Opt-in:
|
||||
# requires the user to know the local mirror of the upstream alarm Condition.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if ([string]::IsNullOrEmpty($AlarmNodeId)) {
|
||||
Write-Header "Alarm fires"
|
||||
Write-Skip "AlarmNodeId not supplied — skipping alarm stage"
|
||||
} else {
|
||||
Write-Header "Alarm fires"
|
||||
$stdout = New-TemporaryFile
|
||||
$stderr = New-TemporaryFile
|
||||
$allArgs = @($opcUaCli.PrefixArgs) + @(
|
||||
"alarms", "-u", $OpcUaUrl, "-n", $AlarmNodeId, "-i", "500", "--refresh")
|
||||
$proc = Start-Process -FilePath $opcUaCli.File `
|
||||
-ArgumentList $allArgs -NoNewWindow -PassThru `
|
||||
-RedirectStandardOutput $stdout.FullName `
|
||||
-RedirectStandardError $stderr.FullName
|
||||
Write-Info "alarm subscription started (pid $($proc.Id)), waiting ${AlarmWaitSec}s for opc-plc alarm cycle"
|
||||
Start-Sleep -Seconds $AlarmWaitSec
|
||||
if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force }
|
||||
$out = (Get-Content $stdout.FullName -Raw) + (Get-Content $stderr.FullName -Raw)
|
||||
Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue
|
||||
|
||||
if ($out -match "ALARM\b" -and $out -match "Active\b") {
|
||||
Write-Pass "alarm condition fired with Active state"
|
||||
$results += @{ Passed = $true }
|
||||
} else {
|
||||
Write-Fail "no Active alarm event observed in ${AlarmWaitSec}s — check opc-plc compose has --alm + the AlarmNodeId is the local mirror of the upstream Condition"
|
||||
Write-Host $out
|
||||
$results += @{ Passed = $false; Reason = "no alarm event" }
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 8 — History read. IHistoryProvider dispatch to the upstream's
|
||||
# HistoryRead service. opc-plc does NOT historize by default, so this stage
|
||||
# SKIPs when -HistoryNodeId is empty. Against a historizing upstream (Prosys,
|
||||
# UA Expert sample server, AVEVA Historian) point -HistoryNodeId at the local
|
||||
# mirror of a historized node.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if ([string]::IsNullOrEmpty($HistoryNodeId)) {
|
||||
Write-Header "History read"
|
||||
Write-Skip "HistoryNodeId not supplied — opc-plc default does not historize; supply a historized-upstream mirror NodeId to enable."
|
||||
} else {
|
||||
$results += Test-HistoryHasSamples `
|
||||
-OpcUaCli $opcUaCli `
|
||||
-OpcUaUrl $OpcUaUrl `
|
||||
-NodeId $HistoryNodeId `
|
||||
-LookbackSec $HistoryLookbackSec
|
||||
}
|
||||
|
||||
Write-Summary -Title "OpcUaClient e2e" -Results $results
|
||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||
@@ -17,7 +17,21 @@ else
|
||||
<tbody>
|
||||
@foreach (var d in _drivers)
|
||||
{
|
||||
<tr><td><code>@d.DriverInstanceId</code></td><td>@d.Name</td><td>@d.DriverType</td><td><code>@d.NamespaceId</code></td></tr>
|
||||
<tr>
|
||||
<td><code>@d.DriverInstanceId</code></td>
|
||||
<td>@d.Name</td>
|
||||
<td>
|
||||
@if (string.Equals(d.DriverType, "Focas", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
<a href="/drivers/focas/@d.DriverInstanceId">@d.DriverType</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
@d.DriverType
|
||||
}
|
||||
</td>
|
||||
<td><code>@d.NamespaceId</code></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
@page "/hosts"
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject IServiceScopeFactory ScopeFactory
|
||||
@implements IDisposable
|
||||
@inject NavigationManager Nav
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<h1 class="mb-4">Driver host status</h1>
|
||||
|
||||
@@ -128,6 +131,7 @@ else
|
||||
private bool _refreshing;
|
||||
private DateTime? _lastRefreshUtc;
|
||||
private Timer? _timer;
|
||||
private HubConnection? _hub;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -136,6 +140,44 @@ else
|
||||
state: null,
|
||||
dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds),
|
||||
period: TimeSpan.FromSeconds(RefreshIntervalSeconds));
|
||||
await ConnectHubAsync();
|
||||
}
|
||||
|
||||
// Phase 6.1 Stream E.2 — subscribe to FleetStatusHub so resilience deltas upsert the
|
||||
// matching row without waiting for the next RefreshIntervalSeconds tick. The 10 s
|
||||
// poll stays as a safety net in case the hub connection is down.
|
||||
private async Task ConnectHubAsync()
|
||||
{
|
||||
var hubUrl = Nav.ToAbsoluteUri("/hubs/fleet");
|
||||
_hub = new HubConnectionBuilder().WithUrl(hubUrl).WithAutomaticReconnect().Build();
|
||||
_hub.On<ResilienceStatusChangedMessage>("ResilienceStatusChanged", OnResilienceChanged);
|
||||
try
|
||||
{
|
||||
await _hub.StartAsync();
|
||||
await _hub.SendAsync("SubscribeFleet");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Hub is best-effort; polling refresh is the fallback. Swallow connect errors
|
||||
// so the page still renders against the initial RefreshAsync pass.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnResilienceChanged(ResilienceStatusChangedMessage msg)
|
||||
{
|
||||
if (_rows is null) return;
|
||||
var idx = _rows.FindIndex(r =>
|
||||
r.DriverInstanceId == msg.DriverInstanceId && r.HostName == msg.HostName);
|
||||
if (idx < 0) return;
|
||||
var prior = _rows[idx];
|
||||
_rows[idx] = prior with
|
||||
{
|
||||
ConsecutiveFailures = msg.ConsecutiveFailures,
|
||||
LastCircuitBreakerOpenUtc = msg.LastCircuitBreakerOpenUtc,
|
||||
CurrentBulkheadDepth = msg.CurrentBulkheadDepth,
|
||||
LastRecycleUtc = msg.LastRecycleUtc,
|
||||
};
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
@@ -180,5 +222,12 @@ else
|
||||
return t.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
}
|
||||
|
||||
public void Dispose() => _timer?.Dispose();
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_timer?.Dispose();
|
||||
if (_hub is not null)
|
||||
{
|
||||
try { await _hub.DisposeAsync(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,3 +37,18 @@ public sealed record NodeStateChangedMessage(
|
||||
string? LastAppliedError,
|
||||
DateTime? LastAppliedAt,
|
||||
DateTime? LastSeenAt);
|
||||
|
||||
/// <summary>
|
||||
/// Pushed by <c>FleetStatusPoller</c> when it observes a change in a
|
||||
/// <c>DriverInstanceResilienceStatus</c> row. Closes the last Phase 6.1 Stream E.2/E.3
|
||||
/// deferral — lets the Admin <c>/hosts</c> page upsert the matching row without the
|
||||
/// 10-second polling round-trip. Keyed on (DriverInstanceId, HostName); the client
|
||||
/// fan-outs to the matching row by matching both.
|
||||
/// </summary>
|
||||
public sealed record ResilienceStatusChangedMessage(
|
||||
string DriverInstanceId,
|
||||
string HostName,
|
||||
int ConsecutiveFailures,
|
||||
DateTime? LastCircuitBreakerOpenUtc,
|
||||
int CurrentBulkheadDepth,
|
||||
DateTime? LastRecycleUtc);
|
||||
|
||||
Binary file not shown.
@@ -44,6 +44,7 @@ builder.Services.AddScoped<EquipmentService>();
|
||||
builder.Services.AddScoped<UnsService>();
|
||||
builder.Services.AddScoped<NamespaceService>();
|
||||
builder.Services.AddScoped<DriverInstanceService>();
|
||||
builder.Services.AddScoped<FocasDriverDetailService>();
|
||||
builder.Services.AddScoped<NodeAclService>();
|
||||
builder.Services.AddScoped<PermissionProbeService>();
|
||||
builder.Services.AddScoped<AclChangeNotifier>();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
@@ -173,4 +174,65 @@ public static class DraftValidator
|
||||
di.DriverInstanceId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.3 Stream A.2 + task #148 part 2 — managed pre-publish guard for cluster
|
||||
/// topology vs. <see cref="ServerCluster.RedundancyMode"/>. The SQL
|
||||
/// <c>CK_ServerCluster_RedundancyMode_NodeCount</c> CHECK already enforces the
|
||||
/// (NodeCount, RedundancyMode) pair on the row itself, but it cannot see the
|
||||
/// <see cref="ClusterNode.Enabled"/> flag on child nodes — an operator can toggle
|
||||
/// nodes off (effective count = 1) while leaving RedundancyMode at Hot and the
|
||||
/// constraint stays green. This check catches that drift before publish so the
|
||||
/// runtime doesn't boot into a topology the <see cref="Enums.RedundancyMode"/> claims
|
||||
/// is invalid.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Called from the publish pipeline separately from <see cref="Validate"/> because the
|
||||
/// cluster/nodes rows aren't generation-versioned — they don't belong on
|
||||
/// <see cref="DraftSnapshot"/>. Returns every failing rule in one pass, same shape as
|
||||
/// <see cref="Validate"/>.
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<ValidationError> ValidateClusterTopology(
|
||||
ServerCluster cluster,
|
||||
IReadOnlyList<ClusterNode> clusterNodes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cluster);
|
||||
ArgumentNullException.ThrowIfNull(clusterNodes);
|
||||
|
||||
var errors = new List<ValidationError>();
|
||||
var enabledNodes = clusterNodes.Count(n => n.Enabled);
|
||||
|
||||
// Declared count must match declared mode (belt around the SQL CHECK).
|
||||
var declaredOk = (cluster.NodeCount, cluster.RedundancyMode) switch
|
||||
{
|
||||
(1, RedundancyMode.None) => true,
|
||||
(2, RedundancyMode.Warm) => true,
|
||||
(2, RedundancyMode.Hot) => true,
|
||||
_ => false,
|
||||
};
|
||||
if (!declaredOk)
|
||||
errors.Add(new("ClusterRedundancyModeInvalid",
|
||||
$"Cluster '{cluster.ClusterId}' declares NodeCount={cluster.NodeCount} + RedundancyMode={cluster.RedundancyMode}. " +
|
||||
$"Supported combinations: (1, None), (2, Warm), (2, Hot).",
|
||||
cluster.ClusterId));
|
||||
|
||||
// Enabled-node count must match declared count. Disabling a node to 1 while leaving
|
||||
// mode at Hot/Warm would boot the runtime into InvalidTopology band.
|
||||
if (enabledNodes != cluster.NodeCount)
|
||||
errors.Add(new("ClusterEnabledNodeCountMismatch",
|
||||
$"Cluster '{cluster.ClusterId}' declares NodeCount={cluster.NodeCount} but has {enabledNodes} Enabled nodes. " +
|
||||
$"Toggle the missing node(s) back on or change RedundancyMode/NodeCount to match.",
|
||||
cluster.ClusterId));
|
||||
|
||||
// Primary uniqueness — decision #84. Two Primary nodes is always an invariant violation
|
||||
// regardless of mode; catch it here so publish fails loud rather than the runtime
|
||||
// demoting both to ServiceLevelBand.InvalidTopology at boot.
|
||||
var primaryCount = clusterNodes.Count(n => n.Enabled && n.RedundancyRole == RedundancyRole.Primary);
|
||||
if (primaryCount > 1)
|
||||
errors.Add(new("ClusterMultiplePrimary",
|
||||
$"Cluster '{cluster.ClusterId}' has {primaryCount} Enabled Primary nodes. At most one Primary per cluster.",
|
||||
cluster.ClusterId));
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Walk the target's symbol table (ADS <c>SymbolLoaderFactory</c>, flat mode) and print every
|
||||
/// symbol the driver's atomic-type mapper recognizes. Same path <c>DiscoverAsync</c> takes
|
||||
/// when <c>EnableControllerBrowse = true</c> — structured UDTs / function-block instances
|
||||
/// won't appear because the driver filters to the supported primitive surface.
|
||||
/// </summary>
|
||||
[Command("browse", Description = "Enumerate controller symbols via the driver's DiscoverAsync walk.")]
|
||||
public sealed class BrowseCommand : TwinCATCommandBase
|
||||
{
|
||||
[CommandOption("prefix", Description =
|
||||
"Case-sensitive instance-path prefix to filter on (e.g. 'GVL_Fixture' or " +
|
||||
"'MAIN.'). Empty (default) prints everything.")]
|
||||
public string? Prefix { get; init; }
|
||||
|
||||
[CommandOption("max", Description =
|
||||
"Maximum number of symbols to print. 0 = unbounded (default 500 for large " +
|
||||
"controllers — flat-mode symbol counts easily top 10k).")]
|
||||
public int Max { get; init; } = 500;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
// Browse-only — no declared tags. EnableControllerBrowse=true flips DiscoverAsync's
|
||||
// symbol-walk on so every recognized primitive surfaces through the builder.
|
||||
var options = new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(Gateway, $"cli-{AmsNetId}:{AmsPort}")],
|
||||
Tags = [],
|
||||
Timeout = Timeout,
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
UseNativeNotifications = !PollOnly,
|
||||
EnableControllerBrowse = true,
|
||||
};
|
||||
|
||||
await using var driver = new TwinCATDriver(options, DriverInstanceId);
|
||||
var builder = new CollectingAddressSpaceBuilder();
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
await driver.DiscoverAsync(builder, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var matched = builder.Variables
|
||||
.Where(v => string.IsNullOrEmpty(Prefix) || v.BrowseName.StartsWith(Prefix, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
var printLimit = Max <= 0 ? matched.Count : Math.Min(Max, matched.Count);
|
||||
|
||||
await console.Output.WriteLineAsync($"AMS: {AmsNetId}:{AmsPort}");
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Symbols: {matched.Count} matched ({builder.Variables.Count} total), showing {printLimit}");
|
||||
await console.Output.WriteLineAsync();
|
||||
|
||||
foreach (var v in matched.Take(printLimit))
|
||||
{
|
||||
var access = v.Info.SecurityClass == SecurityClassification.ViewOnly ? "RO" : "RW";
|
||||
await console.Output.WriteLineAsync($" [{access}] {v.Info.DriverDataType,-8} {v.BrowseName}");
|
||||
}
|
||||
|
||||
if (matched.Count > printLimit)
|
||||
await console.Output.WriteLineAsync(
|
||||
$" … {matched.Count - printLimit} more — raise --max or tighten --prefix");
|
||||
}
|
||||
|
||||
private sealed class CollectingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{
|
||||
Variables.Add((browseName, info));
|
||||
return new Handle(info.FullName);
|
||||
}
|
||||
|
||||
public void AddProperty(string name, DriverDataType type, object? value) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
|
||||
private sealed class NullSink : IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,22 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
{
|
||||
try
|
||||
{
|
||||
// Bit-indexed BOOL — TwinCAT's symbol table doesn't expose "WordVar.N" as its
|
||||
// own symbolic entry (ADS returns DeviceSymbolNotFound), so we read the parent
|
||||
// container as its widest unsigned primitive and extract the bit locally. The
|
||||
// .N suffix added by TwinCATSymbolPath.ToAdsSymbolName needs to come back off
|
||||
// first. uint covers WORD / DWORD containers; BYTE-sized bit containers are
|
||||
// rare in real code and promoting to uint is harmless for them.
|
||||
if (bitIndex is int bit && type == TwinCATDataType.Bool)
|
||||
{
|
||||
var parent = StripBitSuffix(symbolPath);
|
||||
var parentResult = await _client.ReadValueAsync(parent, typeof(uint), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (parentResult.ErrorCode != AdsErrorCode.NoError)
|
||||
return (null, TwinCATStatusMapper.MapAdsError((uint)parentResult.ErrorCode));
|
||||
return (ExtractBit(parentResult.Value, bit), TwinCATStatusMapper.Good);
|
||||
}
|
||||
|
||||
var clrType = MapToClrType(type);
|
||||
var result = await _client.ReadValueAsync(symbolPath, clrType, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -55,11 +71,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
if (result.ErrorCode != AdsErrorCode.NoError)
|
||||
return (null, TwinCATStatusMapper.MapAdsError((uint)result.ErrorCode));
|
||||
|
||||
var value = result.Value;
|
||||
if (bitIndex is int bit && type == TwinCATDataType.Bool && value is not bool)
|
||||
value = ExtractBit(value, bit);
|
||||
|
||||
return (value, TwinCATStatusMapper.Good);
|
||||
return (result.Value, TwinCATStatusMapper.Good);
|
||||
}
|
||||
catch (AdsErrorException ex)
|
||||
{
|
||||
@@ -67,6 +79,15 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
}
|
||||
}
|
||||
|
||||
private static string StripBitSuffix(string symbolPath)
|
||||
{
|
||||
var lastDot = symbolPath.LastIndexOf('.');
|
||||
if (lastDot < 0) return symbolPath;
|
||||
return int.TryParse(symbolPath.AsSpan(lastDot + 1), out _)
|
||||
? symbolPath[..lastDot]
|
||||
: symbolPath;
|
||||
}
|
||||
|
||||
public async Task<uint> WriteValueAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
@@ -115,12 +136,13 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var clrType = MapToClrType(type);
|
||||
// NotificationSettings takes cycle + max-delay in 100ns units. AdsTransMode.OnChange
|
||||
// fires when the value differs; OnCycle fires every cycle. OnChange is the right default
|
||||
// for OPC UA data-change semantics — the PLC already has the best view of "has this
|
||||
// NotificationSettings takes cycle + max-delay in milliseconds (Beckhoff InfoSys
|
||||
// tcadsnetref/7313319051 — "The unit is 1ms"). AdsTransMode.OnChange fires when
|
||||
// the value differs; OnCycle fires every cycle. OnChange is the right default for
|
||||
// OPC UA data-change semantics — the PLC already has the best view of "has this
|
||||
// changed" so we let it decide.
|
||||
var cycleTicks = (uint)Math.Max(1, cycleTime.Ticks / TimeSpan.TicksPerMillisecond * 10_000);
|
||||
var settings = new NotificationSettings(AdsTransMode.OnChange, (int)cycleTicks, 0);
|
||||
var cycleMs = (int)Math.Max(1, cycleTime.TotalMilliseconds);
|
||||
var settings = new NotificationSettings(AdsTransMode.OnChange, cycleMs, 0);
|
||||
|
||||
// AddDeviceNotificationExAsync returns Task<ResultHandle>; AdsNotificationEx fires
|
||||
// with the handle as part of the event args so we use the handle as the correlation
|
||||
@@ -172,27 +194,36 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
}
|
||||
}
|
||||
|
||||
private static TwinCATDataType? MapSymbolTypeName(string? typeName) => typeName switch
|
||||
private static TwinCATDataType? MapSymbolTypeName(string? typeName)
|
||||
{
|
||||
"BOOL" or "BIT" => TwinCATDataType.Bool,
|
||||
"SINT" or "BYTE" => TwinCATDataType.SInt,
|
||||
"USINT" => TwinCATDataType.USInt,
|
||||
"INT" or "WORD" => TwinCATDataType.Int,
|
||||
"UINT" => TwinCATDataType.UInt,
|
||||
"DINT" or "DWORD" => TwinCATDataType.DInt,
|
||||
"UDINT" => TwinCATDataType.UDInt,
|
||||
"LINT" or "LWORD" => TwinCATDataType.LInt,
|
||||
"ULINT" => TwinCATDataType.ULInt,
|
||||
"REAL" => TwinCATDataType.Real,
|
||||
"LREAL" => TwinCATDataType.LReal,
|
||||
"STRING" => TwinCATDataType.String,
|
||||
"WSTRING" => TwinCATDataType.WString,
|
||||
"TIME" => TwinCATDataType.Time,
|
||||
"DATE" => TwinCATDataType.Date,
|
||||
"DT" or "DATE_AND_TIME" => TwinCATDataType.DateTime,
|
||||
"TOD" or "TIME_OF_DAY" => TwinCATDataType.TimeOfDay,
|
||||
_ => null, // UDTs / FB instances / arrays / pointers — out of atomic scope
|
||||
};
|
||||
if (typeName is null) return null;
|
||||
// SymbolLoader emits STRING(80) / WSTRING(80) with the declared bound baked into
|
||||
// the type name — strip the "(...)" suffix so sized strings map onto the bare
|
||||
// String/WString atom the driver speaks.
|
||||
var paren = typeName.IndexOf('(');
|
||||
var bare = paren > 0 ? typeName[..paren] : typeName;
|
||||
return bare switch
|
||||
{
|
||||
"BOOL" or "BIT" => TwinCATDataType.Bool,
|
||||
"SINT" or "BYTE" => TwinCATDataType.SInt,
|
||||
"USINT" => TwinCATDataType.USInt,
|
||||
"INT" or "WORD" => TwinCATDataType.Int,
|
||||
"UINT" => TwinCATDataType.UInt,
|
||||
"DINT" or "DWORD" => TwinCATDataType.DInt,
|
||||
"UDINT" => TwinCATDataType.UDInt,
|
||||
"LINT" or "LWORD" => TwinCATDataType.LInt,
|
||||
"ULINT" => TwinCATDataType.ULInt,
|
||||
"REAL" => TwinCATDataType.Real,
|
||||
"LREAL" => TwinCATDataType.LReal,
|
||||
"STRING" => TwinCATDataType.String,
|
||||
"WSTRING" => TwinCATDataType.WString,
|
||||
"TIME" => TwinCATDataType.Time,
|
||||
"DATE" => TwinCATDataType.Date,
|
||||
"DT" or "DATE_AND_TIME" => TwinCATDataType.DateTime,
|
||||
"TOD" or "TIME_OF_DAY" => TwinCATDataType.TimeOfDay,
|
||||
_ => null, // UDTs / FB instances / arrays / pointers — out of atomic scope
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsSymbolWritable(ISymbol symbol)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// Static factory registration helper for <see cref="TwinCATDriver"/>. Server's Program.cs
|
||||
/// calls <see cref="Register"/> once at startup; the bootstrapper materialises TwinCAT
|
||||
/// DriverInstance rows from the central config DB into live driver instances. Mirrors
|
||||
/// <c>S7DriverFactoryExtensions</c> / <c>AbCipDriverFactoryExtensions</c>.
|
||||
/// </summary>
|
||||
public static class TwinCATDriverFactoryExtensions
|
||||
{
|
||||
public const string DriverTypeName = "TwinCAT";
|
||||
|
||||
public static void Register(DriverFactoryRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, CreateInstance);
|
||||
}
|
||||
|
||||
internal static TwinCATDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
var dto = JsonSerializer.Deserialize<TwinCATDriverConfigDto>(driverConfigJson, JsonOptions)
|
||||
?? throw new InvalidOperationException(
|
||||
$"TwinCAT driver config for '{driverInstanceId}' deserialised to null");
|
||||
|
||||
var options = new TwinCATDriverOptions
|
||||
{
|
||||
Devices = dto.Devices is { Count: > 0 }
|
||||
? [.. dto.Devices.Select(d => new TwinCATDeviceOptions(
|
||||
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
|
||||
$"TwinCAT config for '{driverInstanceId}' has a device missing HostAddress"),
|
||||
DeviceName: d.DeviceName))]
|
||||
: [],
|
||||
Tags = dto.Tags is { Count: > 0 }
|
||||
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
|
||||
: [],
|
||||
Probe = new TwinCATProbeOptions
|
||||
{
|
||||
Enabled = dto.Probe?.Enabled ?? true,
|
||||
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
|
||||
},
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
|
||||
UseNativeNotifications = dto.UseNativeNotifications ?? true,
|
||||
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
|
||||
};
|
||||
|
||||
return new TwinCATDriver(options, driverInstanceId);
|
||||
}
|
||||
|
||||
private static TwinCATTagDefinition BuildTag(TwinCATTagDto t, string driverInstanceId) =>
|
||||
new(
|
||||
Name: t.Name ?? throw new InvalidOperationException(
|
||||
$"TwinCAT config for '{driverInstanceId}' has a tag missing Name"),
|
||||
DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
|
||||
$"TwinCAT tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
|
||||
SymbolPath: t.SymbolPath ?? throw new InvalidOperationException(
|
||||
$"TwinCAT tag '{t.Name}' in '{driverInstanceId}' missing SymbolPath"),
|
||||
DataType: ParseEnum<TwinCATDataType>(t.DataType, t.Name, driverInstanceId, "DataType"),
|
||||
Writable: t.Writable ?? true,
|
||||
WriteIdempotent: t.WriteIdempotent ?? false);
|
||||
|
||||
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field)
|
||||
where T : struct, Enum
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
throw new InvalidOperationException(
|
||||
$"TwinCAT tag '{tagName ?? "<unnamed>"}' in '{driverInstanceId}' missing {field}");
|
||||
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
|
||||
? v
|
||||
: throw new InvalidOperationException(
|
||||
$"TwinCAT tag '{tagName}' has unknown {field} '{raw}'. " +
|
||||
$"Expected one of {string.Join(", ", Enum.GetNames<T>())}");
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
internal sealed class TwinCATDriverConfigDto
|
||||
{
|
||||
public int? TimeoutMs { get; init; }
|
||||
public bool? UseNativeNotifications { get; init; }
|
||||
public bool? EnableControllerBrowse { get; init; }
|
||||
public List<TwinCATDeviceDto>? Devices { get; init; }
|
||||
public List<TwinCATTagDto>? Tags { get; init; }
|
||||
public TwinCATProbeDto? Probe { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class TwinCATDeviceDto
|
||||
{
|
||||
public string? HostAddress { get; init; }
|
||||
public string? DeviceName { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class TwinCATTagDto
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? DeviceHostAddress { get; init; }
|
||||
public string? SymbolPath { get; init; }
|
||||
public string? DataType { get; init; }
|
||||
public bool? Writable { get; init; }
|
||||
public bool? WriteIdempotent { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class TwinCATProbeDto
|
||||
{
|
||||
public bool? Enabled { get; init; }
|
||||
public int? IntervalMs { get; init; }
|
||||
public int? TimeoutMs { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -26,6 +27,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.3 Stream C (task #147) glue — drives <see cref="RedundancyStatePublisher"/> on
|
||||
/// a periodic tick and pushes the resulting ServiceLevel / ServerUriArray /
|
||||
/// RedundancySupport values onto the OPC UA Server node via
|
||||
/// <see cref="ServerRedundancyNodeWriter"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The OPC UA <c>ServerObject</c> exists only after <c>StandardServer.OnServerStarted</c>
|
||||
/// has run, which is inside <see cref="OpcUaApplicationHost.StartAsync"/>. This hosted
|
||||
/// service polls for <c>host.Server?.CurrentInstance</c> to become non-null before
|
||||
/// binding the writer — the server boot sequence doesn't expose a "ready" event.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Tick cadence is 1 s by default. The publisher is edge-triggered internally so a
|
||||
/// no-change tick is cheap; the writer is also idempotent so we can safely apply the
|
||||
/// same values every tick without generating spurious OPC UA notifications.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RedundancyPublisherHostedService(
|
||||
OpcUaApplicationHost host,
|
||||
RedundancyStatePublisher publisher,
|
||||
RedundancyCoordinator coordinator,
|
||||
ILogger<RedundancyPublisherHostedService> logger,
|
||||
ILoggerFactory loggerFactory) : BackgroundService
|
||||
{
|
||||
public TimeSpan TickInterval { get; init; } = TimeSpan.FromSeconds(1);
|
||||
public TimeSpan ServerReadyPollInterval { get; init; } = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// 0. Load topology from the shared config DB. RefreshAsync (not InitializeAsync)
|
||||
// so an invariant violation degrades to ServiceLevelBand.InvalidTopology rather
|
||||
// than crashing the hosted service — operator visibility beats fail-fast here.
|
||||
await coordinator.RefreshAsync(stoppingToken).ConfigureAwait(false);
|
||||
|
||||
// 1. Wait for OPC UA server's ServerObject to materialize.
|
||||
var writer = await WaitForServerReadyAsync(stoppingToken).ConfigureAwait(false);
|
||||
if (writer is null) return; // cancelled before startup completed
|
||||
|
||||
// 2. Subscribe writer to publisher events — edge-triggered ServiceLevel +
|
||||
// ServerUriArray updates from the publisher fan out onto the Server node.
|
||||
publisher.OnStateChanged += OnServiceLevelChanged;
|
||||
publisher.OnServerUriArrayChanged += OnServerUriArrayChanged;
|
||||
|
||||
// 3. One-time RedundancySupport from the coordinator's current topology. If the
|
||||
// topology isn't loaded yet, we'll retry on the first compute-publish tick.
|
||||
ApplyRedundancySupportIfKnown(writer);
|
||||
|
||||
logger.LogInformation(
|
||||
"RedundancyPublisherHostedService running — tick every {Tick}ms",
|
||||
TickInterval.TotalMilliseconds);
|
||||
|
||||
try
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
publisher.ComputeAndPublish();
|
||||
ApplyRedundancySupportIfKnown(writer); // cheap + idempotent
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "RedundancyStatePublisher tick failed");
|
||||
}
|
||||
|
||||
try { await Task.Delay(TickInterval, stoppingToken).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
publisher.OnStateChanged -= OnServiceLevelChanged;
|
||||
publisher.OnServerUriArrayChanged -= OnServerUriArrayChanged;
|
||||
}
|
||||
|
||||
void OnServiceLevelChanged(ServiceLevelSnapshot snap) => writer.ApplyServiceLevel(snap.Value);
|
||||
void OnServerUriArrayChanged(IReadOnlyList<string> uris) => writer.ApplyServerUriArray(uris);
|
||||
}
|
||||
|
||||
private async Task<ServerRedundancyNodeWriter?> WaitForServerReadyAsync(CancellationToken ct)
|
||||
{
|
||||
// Bounded retry so a genuine failure to start doesn't pin the hosted service forever.
|
||||
// 60s is generous — production boot is ~2s on this box; cert PKI + certificate-creation
|
||||
// cases have been observed to take up to 15s cold.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(60);
|
||||
while (!ct.IsCancellationRequested && DateTime.UtcNow < deadline)
|
||||
{
|
||||
var serverInternal = host.Server?.CurrentInstance;
|
||||
if (serverInternal?.ServerObject is not null)
|
||||
{
|
||||
var writerLogger = loggerFactory.CreateLogger<ServerRedundancyNodeWriter>();
|
||||
return new ServerRedundancyNodeWriter(serverInternal, writerLogger);
|
||||
}
|
||||
|
||||
try { await Task.Delay(ServerReadyPollInterval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return null; }
|
||||
}
|
||||
|
||||
if (!ct.IsCancellationRequested)
|
||||
logger.LogError("OPC UA ServerObject did not materialize within 60s — Phase 6.3 Stream C wiring is inactive");
|
||||
return null;
|
||||
}
|
||||
|
||||
private void ApplyRedundancySupportIfKnown(ServerRedundancyNodeWriter writer)
|
||||
{
|
||||
var topology = coordinator.Current;
|
||||
if (topology is null) return;
|
||||
writer.ApplyRedundancySupport(topology.Mode);
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,12 @@ using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
using ZB.MOM.WW.OtOpcUa.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
@@ -109,6 +112,7 @@ builder.Services.AddSingleton<DriverFactoryRegistry>(_ =>
|
||||
AbCipDriverFactoryExtensions.Register(registry);
|
||||
AbLegacyDriverFactoryExtensions.Register(registry);
|
||||
S7DriverFactoryExtensions.Register(registry);
|
||||
TwinCATDriverFactoryExtensions.Register(registry);
|
||||
return registry;
|
||||
});
|
||||
builder.Services.AddSingleton<DriverInstanceBootstrapper>();
|
||||
@@ -137,8 +141,29 @@ builder.Services.AddHostedService<OpcUaServerService>();
|
||||
// so per-heartbeat change-tracking stays isolated; publisher opens one scope per tick.
|
||||
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
|
||||
opt.UseSqlServer(options.ConfigDbConnectionString));
|
||||
// Additional pooled factory so Phase 6.3 RedundancyCoordinator (singleton) can create its
|
||||
// own scoped DbContext for topology loading without fighting the scoped HostStatusPublisher.
|
||||
builder.Services.AddDbContextFactory<OtOpcUaConfigDbContext>(opt =>
|
||||
opt.UseSqlServer(options.ConfigDbConnectionString));
|
||||
builder.Services.AddHostedService<HostStatusPublisher>();
|
||||
|
||||
// Phase 6.3 Stream C (task #147) — ServiceLevel + ServerUriArray + RedundancySupport node
|
||||
// wiring. Coordinator holds topology; publisher computes ServiceLevel byte + ServerUriArray;
|
||||
// hosted service ticks publisher + pushes values onto the Server object via the node writer.
|
||||
builder.Services.AddSingleton(sp => new RedundancyCoordinator(
|
||||
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>(),
|
||||
sp.GetRequiredService<ILogger<RedundancyCoordinator>>(),
|
||||
options.NodeId, options.ClusterId));
|
||||
builder.Services.AddSingleton<ApplyLeaseRegistry>();
|
||||
builder.Services.AddSingleton<RecoveryStateManager>();
|
||||
builder.Services.AddSingleton<PeerReachabilityTracker>();
|
||||
builder.Services.AddSingleton(sp => new RedundancyStatePublisher(
|
||||
sp.GetRequiredService<RedundancyCoordinator>(),
|
||||
sp.GetRequiredService<ApplyLeaseRegistry>(),
|
||||
sp.GetRequiredService<RecoveryStateManager>(),
|
||||
sp.GetRequiredService<PeerReachabilityTracker>()));
|
||||
builder.Services.AddHostedService<RedundancyPublisherHostedService>();
|
||||
|
||||
// Phase 7 follow-up #246 — historian sink + engine composer. NullAlarmHistorianSink
|
||||
// is the default until the Galaxy.Host SqliteStoreAndForwardSink writer adapter
|
||||
// lands (task #248). The composer reads Script/VirtualTag/ScriptedAlarm rows on
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ConfigRedundancyMode = ZB.MOM.WW.OtOpcUa.Configuration.Enums.RedundancyMode;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Redundancy;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.3 Stream C (task #147) — the seam that carries the
|
||||
/// <see cref="RedundancyStatePublisher"/>'s computed values onto the standard OPC UA
|
||||
/// Server object nodes:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Server.ServiceLevel</c> (<see cref="VariableIds.Server_ServiceLevel"/>)
|
||||
/// — Byte (0..255), Part 5 §6.3.34. Clients poll to pick the healthiest peer.</item>
|
||||
/// <item><c>Server.ServerRedundancy.RedundancySupport</c>
|
||||
/// (<see cref="VariableIds.Server_ServerRedundancy_RedundancySupport"/>)
|
||||
/// — advertises Warm / Hot / Cold / None per Part 4 §6.6.2.</item>
|
||||
/// <item><c>Server.ServerRedundancy.ServerUriArray</c>
|
||||
/// (<see cref="VariableIds.NonTransparentRedundancyType_ServerUriArray"/>
|
||||
/// when the redundancy node is upgraded to non-transparent)
|
||||
/// — ApplicationUri of every node in the pair, self first.</item>
|
||||
/// </list>
|
||||
/// The writer is constructed once during the <c>OtOpcUaServer.OnServerStarted</c> hook;
|
||||
/// callers invoke <see cref="ApplyServiceLevel"/> / <see cref="ApplyServerUriArray"/> /
|
||||
/// <see cref="ApplyRedundancySupport"/> on publisher events. Each setter updates the
|
||||
/// underlying <see cref="BaseVariableState.Value"/> then calls
|
||||
/// <see cref="NodeState.ClearChangeMasks"/> to flush the change to subscribers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The writer is defensive: if the expected node shape isn't present on this particular
|
||||
/// SDK build (e.g. <c>ServerUriArray</c> only exists on the
|
||||
/// <c>NonTransparentRedundancyType</c> subtype and the ServerObject's default
|
||||
/// <c>ServerRedundancy</c> property is the base type) the writer logs a warning once and
|
||||
/// skips that specific update rather than throwing — matches the SDK's own tolerance
|
||||
/// for optional address-space shape.
|
||||
/// </remarks>
|
||||
public sealed class ServerRedundancyNodeWriter
|
||||
{
|
||||
private readonly IServerInternal _server;
|
||||
private readonly ILogger<ServerRedundancyNodeWriter> _logger;
|
||||
private readonly object _gate = new();
|
||||
|
||||
private bool _warnedMissingServerUriArray;
|
||||
private byte? _lastServiceLevel;
|
||||
private RedundancySupport? _lastRedundancySupport;
|
||||
private IReadOnlyList<string>? _lastServerUriArray;
|
||||
|
||||
public ServerRedundancyNodeWriter(IServerInternal server, ILogger<ServerRedundancyNodeWriter> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(server);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_server = server;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Push a new Byte value onto <c>Server.ServiceLevel</c> + notify subscribers.</summary>
|
||||
public void ApplyServiceLevel(byte value)
|
||||
{
|
||||
var serverObject = _server.ServerObject;
|
||||
if (serverObject?.ServiceLevel is not { } node) return;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_lastServiceLevel == value) return;
|
||||
_lastServiceLevel = value;
|
||||
node.Value = value;
|
||||
node.Timestamp = DateTime.UtcNow;
|
||||
node.ClearChangeMasks(_server.DefaultSystemContext, includeChildren: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map the Configuration-side <see cref="ConfigRedundancyMode"/> to OPC UA's
|
||||
/// <see cref="RedundancySupport"/> enum + apply to
|
||||
/// <c>Server.ServerRedundancy.RedundancySupport</c>. Called once at
|
||||
/// the <c>OtOpcUaServer.OnServerStarted</c> hook — the value is effectively static per
|
||||
/// deployment.
|
||||
/// </summary>
|
||||
public void ApplyRedundancySupport(ConfigRedundancyMode mode)
|
||||
{
|
||||
var serverObject = _server.ServerObject;
|
||||
if (serverObject?.ServerRedundancy?.RedundancySupport is not { } node) return;
|
||||
|
||||
// RedundancyMode only declares None / Warm / Hot in v2.0 (non-transparent only per
|
||||
// decision #85). OPC UA's RedundancySupport has more states — clamp to the three we
|
||||
// support and let config-DB CHECK constraints prevent surprises.
|
||||
var support = mode switch
|
||||
{
|
||||
ConfigRedundancyMode.Warm => RedundancySupport.Warm,
|
||||
ConfigRedundancyMode.Hot => RedundancySupport.Hot,
|
||||
_ => RedundancySupport.None,
|
||||
};
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_lastRedundancySupport == support) return;
|
||||
_lastRedundancySupport = support;
|
||||
node.Value = support;
|
||||
node.Timestamp = DateTime.UtcNow;
|
||||
node.ClearChangeMasks(_server.DefaultSystemContext, includeChildren: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Push the self-first peer-URI list onto
|
||||
/// <c>Server.ServerRedundancy.ServerUriArray</c>. Only applies when the SDK created
|
||||
/// <c>ServerRedundancy</c> as <see cref="NonTransparentRedundancyState"/>; on the
|
||||
/// base <see cref="ServerRedundancyState"/> the child is absent and we log-and-skip.
|
||||
/// </summary>
|
||||
public void ApplyServerUriArray(IReadOnlyList<string> serverUris)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serverUris);
|
||||
var serverObject = _server.ServerObject;
|
||||
if (serverObject?.ServerRedundancy is not NonTransparentRedundancyState ntr
|
||||
|| ntr.ServerUriArray is not { } node)
|
||||
{
|
||||
if (!_warnedMissingServerUriArray)
|
||||
{
|
||||
_warnedMissingServerUriArray = true;
|
||||
_logger.LogWarning(
|
||||
"Server.ServerRedundancy is not NonTransparentRedundancyState — ServerUriArray " +
|
||||
"cannot be published on this server instance. Clients will not see peer URIs " +
|
||||
"on the Part 4 §6.6.2 redundancy node until the redundancy-object type is upgraded.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_lastServerUriArray is not null && _lastServerUriArray.SequenceEqual(serverUris, StringComparer.Ordinal))
|
||||
return;
|
||||
_lastServerUriArray = [.. serverUris];
|
||||
node.Value = [.. serverUris];
|
||||
node.Timestamp = DateTime.UtcNow;
|
||||
node.ClearChangeMasks(_server.DefaultSystemContext, includeChildren: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Analyzers\ZB.MOM.WW.OtOpcUa.Analyzers.csproj"
|
||||
OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -153,4 +153,62 @@ END";
|
||||
m.Args[0] is AlertMessage alert && alert.NodeId == "p-2-a" && alert.Severity == "error");
|
||||
alertMatch.ShouldNotBeNull("poller should have raised AlertRaised for p-2-a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Poller_pushes_ResilienceStatusChanged_on_delta()
|
||||
{
|
||||
// Phase 6.1 Stream E.2 — DriverInstanceResilienceStatus row changes should surface
|
||||
// on the fleet hub so /hosts updates without waiting for the 10s poll.
|
||||
using (var scope = _sp.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
db.DriverInstanceResilienceStatuses.Add(new DriverInstanceResilienceStatus
|
||||
{
|
||||
DriverInstanceId = "drv-1", HostName = "plc.example.com",
|
||||
ConsecutiveFailures = 2, CurrentBulkheadDepth = 1,
|
||||
LastSampledUtc = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var recorder = new RecordingHubClients();
|
||||
var fleetHub = new RecordingHubContext<FleetStatusHub>(recorder);
|
||||
var alertHub = new RecordingHubContext<AlertHub>(new RecordingHubClients());
|
||||
|
||||
var poller = new FleetStatusPoller(
|
||||
_sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
fleetHub, alertHub, NullLogger<FleetStatusPoller>.Instance, new RedundancyMetrics());
|
||||
|
||||
await poller.PollOnceAsync(CancellationToken.None);
|
||||
|
||||
var match = recorder.SentMessages.FirstOrDefault(m =>
|
||||
m.Method == "ResilienceStatusChanged" &&
|
||||
m.Args.Length > 0 &&
|
||||
m.Args[0] is ResilienceStatusChangedMessage r &&
|
||||
r.DriverInstanceId == "drv-1" && r.HostName == "plc.example.com");
|
||||
match.ShouldNotBeNull("poller should have pushed ResilienceStatusChanged on first observation");
|
||||
|
||||
// Same snapshot on the next tick — should NOT push again (delta-only push).
|
||||
recorder.SentMessages.Clear();
|
||||
await poller.PollOnceAsync(CancellationToken.None);
|
||||
recorder.SentMessages.Any(m => m.Method == "ResilienceStatusChanged")
|
||||
.ShouldBeFalse("unchanged snapshot must not fire another push");
|
||||
|
||||
// Mutate the row — delta should fire again.
|
||||
using (var scope = _sp.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
var row = await db.DriverInstanceResilienceStatuses.SingleAsync();
|
||||
row.ConsecutiveFailures = 5;
|
||||
row.LastCircuitBreakerOpenUtc = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await poller.PollOnceAsync(CancellationToken.None);
|
||||
var mutatedMatch = recorder.SentMessages.FirstOrDefault(m =>
|
||||
m.Method == "ResilienceStatusChanged" &&
|
||||
m.Args.Length > 0 &&
|
||||
m.Args[0] is ResilienceStatusChangedMessage r2 && r2.ConsecutiveFailures == 5);
|
||||
mutatedMatch.ShouldNotBeNull("mutated row should produce a second ResilienceStatusChanged");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,4 +145,91 @@ public sealed class DraftValidatorTests
|
||||
errors.ShouldContain(e => e.Code == "EquipmentIdNotDerived");
|
||||
errors.ShouldContain(e => e.Code == "UnsSegmentInvalid");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------
|
||||
// Phase 6.3 task #148 part 2 — ValidateClusterTopology
|
||||
// ------------------------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, RedundancyMode.None, 1, 0)] // single-node standalone — ok
|
||||
[InlineData(2, RedundancyMode.Warm, 2, 0)] // 2-node warm — ok
|
||||
[InlineData(2, RedundancyMode.Hot, 2, 0)] // 2-node hot — ok
|
||||
[InlineData(1, RedundancyMode.Warm, 1, 1)] // declared mismatch — should flag
|
||||
[InlineData(2, RedundancyMode.None, 2, 1)] // None with 2 nodes — should flag
|
||||
public void ValidateClusterTopology_checks_declared_pair(
|
||||
byte nodeCount, RedundancyMode mode, int enabledNodes, int expectedDeclaredErrors)
|
||||
{
|
||||
var cluster = BuildCluster(nodeCount: nodeCount, mode: mode);
|
||||
var nodes = Enumerable.Range(0, enabledNodes)
|
||||
.Select(i => BuildNode($"n-{i}", enabled: true, role: i == 0 ? RedundancyRole.Primary : RedundancyRole.Secondary))
|
||||
.ToList();
|
||||
|
||||
var errors = DraftValidator.ValidateClusterTopology(cluster, nodes);
|
||||
errors.Count(e => e.Code == "ClusterRedundancyModeInvalid").ShouldBe(expectedDeclaredErrors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateClusterTopology_flags_disabled_node_mismatch()
|
||||
{
|
||||
// Declared 2 + Hot, but one node disabled — runtime would boot InvalidTopology.
|
||||
var cluster = BuildCluster(nodeCount: 2, mode: RedundancyMode.Hot);
|
||||
var nodes = new[]
|
||||
{
|
||||
BuildNode("primary", enabled: true, role: RedundancyRole.Primary),
|
||||
BuildNode("backup", enabled: false, role: RedundancyRole.Secondary),
|
||||
};
|
||||
|
||||
var errors = DraftValidator.ValidateClusterTopology(cluster, nodes);
|
||||
errors.ShouldContain(e => e.Code == "ClusterEnabledNodeCountMismatch");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateClusterTopology_flags_multiple_Primary()
|
||||
{
|
||||
var cluster = BuildCluster(nodeCount: 2, mode: RedundancyMode.Hot);
|
||||
var nodes = new[]
|
||||
{
|
||||
BuildNode("a", enabled: true, role: RedundancyRole.Primary),
|
||||
BuildNode("b", enabled: true, role: RedundancyRole.Primary),
|
||||
};
|
||||
|
||||
var errors = DraftValidator.ValidateClusterTopology(cluster, nodes);
|
||||
errors.ShouldContain(e => e.Code == "ClusterMultiplePrimary");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateClusterTopology_returns_no_errors_on_valid_standalone()
|
||||
{
|
||||
var cluster = BuildCluster(nodeCount: 1, mode: RedundancyMode.None);
|
||||
var nodes = new[] { BuildNode("only", enabled: true, role: RedundancyRole.Primary) };
|
||||
|
||||
var errors = DraftValidator.ValidateClusterTopology(cluster, nodes);
|
||||
errors.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private static ServerCluster BuildCluster(byte nodeCount, RedundancyMode mode) => new()
|
||||
{
|
||||
ClusterId = "c-test",
|
||||
Name = "Test",
|
||||
Enterprise = "zb",
|
||||
Site = "dev",
|
||||
NodeCount = nodeCount,
|
||||
RedundancyMode = mode,
|
||||
Enabled = true,
|
||||
CreatedBy = "t",
|
||||
};
|
||||
|
||||
private static ClusterNode BuildNode(string id, bool enabled, RedundancyRole role) => new()
|
||||
{
|
||||
NodeId = id,
|
||||
ClusterId = "c-test",
|
||||
RedundancyRole = role,
|
||||
Host = "localhost",
|
||||
OpcUaPort = 4840,
|
||||
DashboardPort = 5001,
|
||||
ApplicationUri = $"urn:{id}",
|
||||
ServiceLevelBase = 200,
|
||||
Enabled = enabled,
|
||||
CreatedBy = "t",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests;
|
||||
/// 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.
|
||||
/// native <c>AddDeviceNotification</c> subscription, array addressing, auto-reconnect,
|
||||
/// full primitive type mapping, and the DiscoverAsync→address-space pipeline 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>
|
||||
@@ -18,6 +20,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests;
|
||||
/// <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>GVL <c>GVL_Primitives</c> with one of every ADS primitive at its seed value.</item>
|
||||
/// <item>GVL <c>GVL_Arrays</c> with <c>aReal1D : ARRAY[0..31] OF REAL</c> (scratch; array
|
||||
/// round-trip test writes element 5).</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>
|
||||
@@ -43,9 +48,10 @@ public sealed class TwinCAT3SmokeTests(TwinCATXarFixture sim)
|
||||
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);
|
||||
// Value is a DINT — we only assert the read path returned an integer. Don't
|
||||
// pin to the 1234 seed: the PlcTask may watchdog-restart and reset counters
|
||||
// to 0, and we care about end-to-end transport here, not PLC uptime.
|
||||
Convert.ToInt32(snapshots[0].Value).ShouldBeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
[TwinCATFact]
|
||||
@@ -89,14 +95,16 @@ public sealed class TwinCAT3SmokeTests(TwinCATXarFixture sim)
|
||||
};
|
||||
|
||||
var handle = await drv.SubscribeAsync(
|
||||
["Counter"], TimeSpan.FromMilliseconds(250),
|
||||
["Counter"], TimeSpan.FromMilliseconds(500),
|
||||
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");
|
||||
// We only assert the transport wires up and at least one notification lands.
|
||||
// Don't pin to PLC cycle time: if PlcTask watchdog-restarts or runs slower than
|
||||
// its nominal 10 ms, increments may be coarse — but the counter still changes,
|
||||
// so any reasonable window catches it. 10 s leaves headroom for transient PLC
|
||||
// restarts without turning into a test hang.
|
||||
var got = await gate.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
got.ShouldBeTrue("native ADS notification on GVL_Fixture.nCounter must fire within 10 s of subscribe");
|
||||
|
||||
int observedCount;
|
||||
lock (observed) observedCount = observed.Count;
|
||||
@@ -105,6 +113,435 @@ public sealed class TwinCAT3SmokeTests(TwinCATXarFixture sim)
|
||||
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[TwinCATFact]
|
||||
public async Task Driver_browses_committed_symbol_hierarchy_via_real_ADS()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
// Goes straight at the wire client rather than TwinCATDriver.DiscoverAsync: the
|
||||
// discover flow funnels symbols into an IAddressSpaceBuilder, which doesn't give
|
||||
// us a flat list to assert against. BrowseSymbolsAsync is what that flow calls
|
||||
// internally, so covering it here covers the same transport path.
|
||||
using var client = new AdsTwinCATClient();
|
||||
await client.ConnectAsync(
|
||||
new TwinCATAmsAddress(sim.TargetNetId!, sim.AmsPort),
|
||||
TimeSpan.FromSeconds(5),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var symbols = new List<TwinCATDiscoveredSymbol>();
|
||||
await foreach (var s in client.BrowseSymbolsAsync(TestContext.Current.CancellationToken))
|
||||
symbols.Add(s);
|
||||
|
||||
symbols.ShouldNotBeEmpty("BrowseSymbolsAsync must yield at least the committed GVL symbols");
|
||||
|
||||
// Spot-check the smoke-test contract: GVL_Fixture.nCounter (DINT, writable).
|
||||
var counter = symbols.FirstOrDefault(s => s.InstancePath == "GVL_Fixture.nCounter");
|
||||
counter.ShouldNotBeNull("GVL_Fixture.nCounter must surface in the symbol table");
|
||||
counter.DataType.ShouldBe(TwinCATDataType.DInt);
|
||||
|
||||
// Primitive coverage — every ADS primitive is committed under GVL_Primitives. Prove
|
||||
// the type mapper catches a couple of representative entries (BOOL, REAL, STRING).
|
||||
symbols.ShouldContain(s => s.InstancePath == "GVL_Primitives.vBool" && s.DataType == TwinCATDataType.Bool);
|
||||
symbols.ShouldContain(s => s.InstancePath == "GVL_Primitives.vReal" && s.DataType == TwinCATDataType.Real);
|
||||
symbols.ShouldContain(s => s.InstancePath == "GVL_Primitives.vString" && s.DataType == TwinCATDataType.String);
|
||||
|
||||
// Array coverage — SymbolLoaderFactory (Flat mode) expands arrays to per-element
|
||||
// paths, so at least one element of the 2-D REAL array should appear.
|
||||
symbols.ShouldContain(s => s.InstancePath.StartsWith("GVL_Arrays.aReal2D", StringComparison.Ordinal));
|
||||
|
||||
// Enum / alias coverage — GVL_Enums roots one of each so we don't have to walk plants.
|
||||
symbols.ShouldContain(s => s.InstancePath == "GVL_Enums.currentAxisState");
|
||||
}
|
||||
|
||||
[TwinCATFact]
|
||||
public async Task Driver_round_trips_array_element_write_and_read()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
// Arrays are a high-traffic PLC pattern but no earlier test hits an indexed symbol
|
||||
// end-to-end. Round-trip into GVL_Arrays.aReal1D[5] — the subscript flows through
|
||||
// TwinCATSymbolPath.TryParse → AdsSymbolName ("GVL_Arrays.aReal1D[5]") which is
|
||||
// the ADS wire syntax the SymbolLoader emits for per-element access.
|
||||
var options = BuildOptions(sim);
|
||||
await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-array");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
const float probe = 73.25f;
|
||||
var writeResults = await drv.WriteAsync(
|
||||
[new WriteRequest("ArrayElem", probe)],
|
||||
TestContext.Current.CancellationToken);
|
||||
writeResults.Count.ShouldBe(1);
|
||||
writeResults[0].StatusCode.ShouldBe(0u, "array-element write must succeed against aReal1D[5]");
|
||||
|
||||
var readResults = await drv.ReadAsync(
|
||||
["ArrayElem"], TestContext.Current.CancellationToken);
|
||||
readResults[0].StatusCode.ShouldBe(0u);
|
||||
Convert.ToSingle(readResults[0].Value).ShouldBe(probe, tolerance: 0.001f);
|
||||
}
|
||||
|
||||
[TwinCATFact]
|
||||
public async Task Driver_auto_reconnects_after_underlying_client_is_disposed()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
// Reconnect is the most operationally important durability property — ADS links
|
||||
// drop (router restarts, VM reboots, TCP resets). EnsureConnectedAsync creates a
|
||||
// fresh AdsClient when the prior one is gone, but until this test the live-wire
|
||||
// recovery path was only unit-tested against FakeTwinCATClient.
|
||||
var options = BuildOptions(sim);
|
||||
await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-reconnect");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var first = await drv.ReadAsync(["Counter"], TestContext.Current.CancellationToken);
|
||||
first[0].StatusCode.ShouldBe(0u);
|
||||
|
||||
// Dispose the underlying client the driver is holding. Next read must reconnect
|
||||
// (via EnsureConnectedAsync → AdsClient.Connect) rather than fail.
|
||||
var hostAddress = $"ads://{sim.TargetNetId}:{sim.AmsPort}";
|
||||
var device = drv.GetDeviceState(hostAddress).ShouldNotBeNull("device state must exist after Initialize");
|
||||
device.DisposeClient();
|
||||
|
||||
var second = await drv.ReadAsync(["Counter"], TestContext.Current.CancellationToken);
|
||||
second[0].StatusCode.ShouldBe(0u, "driver must transparently reconnect on next read");
|
||||
}
|
||||
|
||||
[TwinCATTheory]
|
||||
// vBool's expected value is null — the initial TRUE seed doesn't reliably survive cold
|
||||
// restarts on this deployment, and the point of this theory is round-tripping the type
|
||||
// mapper, not test-data seed persistence. All other primitives re-init on every boot.
|
||||
[InlineData("GVL_Primitives.vBool", TwinCATDataType.Bool, null)]
|
||||
[InlineData("GVL_Primitives.vSInt", TwinCATDataType.SInt, "-42")]
|
||||
[InlineData("GVL_Primitives.vUSInt", TwinCATDataType.USInt, "250")]
|
||||
[InlineData("GVL_Primitives.vInt", TwinCATDataType.Int, "-12345")]
|
||||
[InlineData("GVL_Primitives.vUInt", TwinCATDataType.UInt, "54321")]
|
||||
[InlineData("GVL_Primitives.vDInt", TwinCATDataType.DInt, "-1234567")]
|
||||
[InlineData("GVL_Primitives.vUDInt", TwinCATDataType.UDInt, "4000000000")]
|
||||
[InlineData("GVL_Primitives.vLInt", TwinCATDataType.LInt, "-1234567890123")]
|
||||
[InlineData("GVL_Primitives.vULInt", TwinCATDataType.ULInt, "12345678901234567")]
|
||||
[InlineData("GVL_Primitives.vReal", TwinCATDataType.Real, "3.14159")]
|
||||
[InlineData("GVL_Primitives.vLReal", TwinCATDataType.LReal, "2.7182818284590452")]
|
||||
[InlineData("GVL_Primitives.vString", TwinCATDataType.String, "Hello from TC3")]
|
||||
[InlineData("GVL_Primitives.vTime", TwinCATDataType.Time, null)]
|
||||
[InlineData("GVL_Primitives.vTimeOfDay", TwinCATDataType.TimeOfDay, null)]
|
||||
[InlineData("GVL_Primitives.vDate", TwinCATDataType.Date, null)]
|
||||
[InlineData("GVL_Primitives.vDateTime", TwinCATDataType.DateTime, null)]
|
||||
public async Task Driver_reads_every_primitive_type_with_correct_mapping(
|
||||
string symbolPath, TwinCATDataType type, string? expectedValueInvariant)
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
// One driver per case is ~0.3 s each on the live VM — pragmatic vs a shared-driver
|
||||
// iteration pattern since the point is to exercise options/tag-mapping/ReadAsync
|
||||
// end-to-end per primitive, catching regressions like the STRING(N) mapper bug.
|
||||
var options = new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions($"ads://{sim.TargetNetId}:{sim.AmsPort}", "XAR-VM")],
|
||||
Tags =
|
||||
[
|
||||
new TwinCATTagDefinition(
|
||||
Name: "Primitive",
|
||||
DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
|
||||
SymbolPath: symbolPath,
|
||||
DataType: type),
|
||||
],
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var drv = new TwinCATDriver(options, driverInstanceId: $"tc3-prim-{type}");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var result = await drv.ReadAsync(["Primitive"], TestContext.Current.CancellationToken);
|
||||
result[0].StatusCode.ShouldBe(0u, $"primitive read must succeed for {symbolPath} ({type})");
|
||||
result[0].Value.ShouldNotBeNull();
|
||||
|
||||
// Expected-value check only where the seed is ergonomic to re-encode as a literal —
|
||||
// TIME / TOD / DATE / DT arrive as uint ticks, which is covered by the status+type
|
||||
// assertions above; adding brittle tick-math here adds no signal over that.
|
||||
if (expectedValueInvariant is not null)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case TwinCATDataType.Bool:
|
||||
result[0].Value.ShouldBe(bool.Parse(expectedValueInvariant));
|
||||
break;
|
||||
case TwinCATDataType.String:
|
||||
result[0].Value.ShouldBe(expectedValueInvariant);
|
||||
break;
|
||||
case TwinCATDataType.Real:
|
||||
Convert.ToSingle(result[0].Value).ShouldBe(
|
||||
float.Parse(expectedValueInvariant, System.Globalization.CultureInfo.InvariantCulture),
|
||||
tolerance: 0.0001f);
|
||||
break;
|
||||
case TwinCATDataType.LReal:
|
||||
Convert.ToDouble(result[0].Value).ShouldBe(
|
||||
double.Parse(expectedValueInvariant, System.Globalization.CultureInfo.InvariantCulture),
|
||||
tolerance: 0.0000001);
|
||||
break;
|
||||
default:
|
||||
// Integer primitives: parse against the target CLR type the mapper chose.
|
||||
Convert.ToInt64(result[0].Value).ShouldBe(long.Parse(expectedValueInvariant));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[TwinCATFact]
|
||||
public async Task Driver_reads_bit_indexed_BOOL_from_word()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
// GVL_Primitives.vWord = 0xBEEF = 1011 1110 1110 1111. Bit 3 = 1 (within the low
|
||||
// nibble 'F'). Tags with bitIndex route through ExtractBit → TwinCAT.Ads supports
|
||||
// the .N bit-access suffix natively so the driver's read path relies on that.
|
||||
var options = new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions($"ads://{sim.TargetNetId}:{sim.AmsPort}", "XAR-VM")],
|
||||
Tags =
|
||||
[
|
||||
new TwinCATTagDefinition(
|
||||
Name: "WordBit3",
|
||||
DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
|
||||
SymbolPath: "GVL_Primitives.vWord.3",
|
||||
DataType: TwinCATDataType.Bool),
|
||||
new TwinCATTagDefinition(
|
||||
Name: "WordBit4",
|
||||
DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
|
||||
SymbolPath: "GVL_Primitives.vWord.4",
|
||||
DataType: TwinCATDataType.Bool),
|
||||
],
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-bitbool");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var result = await drv.ReadAsync(["WordBit3", "WordBit4"], TestContext.Current.CancellationToken);
|
||||
|
||||
result[0].StatusCode.ShouldBe(0u);
|
||||
result[0].Value.ShouldBe(true, "bit 3 of 0xBEEF is set");
|
||||
result[1].StatusCode.ShouldBe(0u);
|
||||
result[1].Value.ShouldBe(false, "bit 4 of 0xBEEF is clear");
|
||||
}
|
||||
|
||||
[TwinCATFact]
|
||||
public async Task Driver_reads_deeply_nested_UDT_path()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
// 5-level nested path into the plant hierarchy. FB_LineSim is driving the motor
|
||||
// temperature from a sine of the counter, so the value is alive but bounded.
|
||||
var options = new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions($"ads://{sim.TargetNetId}:{sim.AmsPort}", "XAR-VM")],
|
||||
Tags =
|
||||
[
|
||||
new TwinCATTagDefinition(
|
||||
Name: "MotorTemp",
|
||||
DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
|
||||
SymbolPath: "GVL_Plant.Line1.Stations[1].Axes[1].Motor.Temperature",
|
||||
DataType: TwinCATDataType.LReal),
|
||||
new TwinCATTagDefinition(
|
||||
Name: "MotorRunning",
|
||||
DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
|
||||
SymbolPath: "GVL_Plant.Line1.Stations[1].Axes[1].Motor.Running",
|
||||
DataType: TwinCATDataType.Bool),
|
||||
],
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-udt");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var result = await drv.ReadAsync(["MotorTemp", "MotorRunning"], TestContext.Current.CancellationToken);
|
||||
|
||||
result[0].StatusCode.ShouldBe(0u, "nested UDT LREAL path must round-trip");
|
||||
result[0].Value.ShouldBeOfType<double>();
|
||||
result[1].StatusCode.ShouldBe(0u, "nested UDT BOOL path must round-trip");
|
||||
result[1].Value.ShouldBeOfType<bool>();
|
||||
}
|
||||
|
||||
[TwinCATFact]
|
||||
public async Task Driver_reports_errors_for_unknown_tag_and_nonexistent_symbol_and_readonly_write()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
// Three negative paths in one driver instance — cheaper than spinning three drivers
|
||||
// for what are essentially status-code assertions:
|
||||
// 1. unknown tag name (not in the options map) → BadNodeIdUnknown
|
||||
// 2. known tag pointing at a nonexistent PLC symbol → ADS error mapped to non-zero
|
||||
// 3. write against a Writable=false tag → BadNotWritable
|
||||
var options = new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions($"ads://{sim.TargetNetId}:{sim.AmsPort}", "XAR-VM")],
|
||||
Tags =
|
||||
[
|
||||
new TwinCATTagDefinition(
|
||||
Name: "NonexistentSymbol",
|
||||
DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
|
||||
SymbolPath: "GVL_DoesNotExist.vGhost",
|
||||
DataType: TwinCATDataType.DInt),
|
||||
new TwinCATTagDefinition(
|
||||
Name: "ReadOnlyCounter",
|
||||
DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
|
||||
SymbolPath: "GVL_Fixture.nCounter",
|
||||
DataType: TwinCATDataType.DInt,
|
||||
Writable: false),
|
||||
],
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-errors");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
// (1) Unknown tag — never registered in options, must short-circuit before ADS.
|
||||
var unknownRead = await drv.ReadAsync(["NeverDeclared"], TestContext.Current.CancellationToken);
|
||||
unknownRead[0].StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
|
||||
|
||||
// (2) Known tag, nonexistent PLC symbol — ADS returns SymbolNotFound, driver maps it
|
||||
// to a non-zero OPC UA status. Don't pin to a specific code — the exact mapping
|
||||
// is a driver-internal concern and we only care it surfaces as an error.
|
||||
var ghostRead = await drv.ReadAsync(["NonexistentSymbol"], TestContext.Current.CancellationToken);
|
||||
ghostRead[0].StatusCode.ShouldNotBe(0u, "nonexistent PLC symbol must surface as a non-Good status");
|
||||
|
||||
// (3) Read-only declared tag — write must short-circuit with BadNotWritable before ADS.
|
||||
var roWrite = await drv.WriteAsync(
|
||||
[new WriteRequest("ReadOnlyCounter", 999)],
|
||||
TestContext.Current.CancellationToken);
|
||||
roWrite[0].StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
[TwinCATFact]
|
||||
public async Task Driver_routes_reads_per_device_and_isolates_unreachable_peers()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
// Two devices in one driver: the real VM + a bogus AmsNetId that won't resolve. Tags
|
||||
// routed to the real device must still succeed; tags on the unreachable device must
|
||||
// surface comms errors without poisoning the healthy one — this is the multi-device
|
||||
// routing + per-device connection isolation contract the unit suite can't prove on the wire.
|
||||
var realHost = $"ads://{sim.TargetNetId}:{sim.AmsPort}";
|
||||
var ghostHost = "ads://99.99.99.99.1.1:851";
|
||||
var options = new TwinCATDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new TwinCATDeviceOptions(realHost, "Real-VM"),
|
||||
new TwinCATDeviceOptions(ghostHost, "Ghost-VM"),
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new TwinCATTagDefinition(
|
||||
Name: "RealCounter",
|
||||
DeviceHostAddress: realHost,
|
||||
SymbolPath: "GVL_Fixture.nCounter",
|
||||
DataType: TwinCATDataType.DInt),
|
||||
new TwinCATTagDefinition(
|
||||
Name: "GhostCounter",
|
||||
DeviceHostAddress: ghostHost,
|
||||
SymbolPath: "GVL_Fixture.nCounter",
|
||||
DataType: TwinCATDataType.DInt),
|
||||
],
|
||||
// Shorter timeout so the bogus device fails fast rather than dragging the whole
|
||||
// test; the healthy read shouldn't be slowed down by a peer timeout.
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-multidev");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var real = await drv.ReadAsync(["RealCounter"], TestContext.Current.CancellationToken);
|
||||
real[0].StatusCode.ShouldBe(0u, "healthy device read must succeed alongside unreachable peer");
|
||||
|
||||
var ghost = await drv.ReadAsync(["GhostCounter"], TestContext.Current.CancellationToken);
|
||||
ghost[0].StatusCode.ShouldNotBe(0u, "unreachable device read must surface non-Good status");
|
||||
|
||||
// Per-device host resolver — each tag's resolved host matches the device it was
|
||||
// declared against, regardless of the order reads arrive.
|
||||
drv.ResolveHost("RealCounter").ShouldBe(realHost);
|
||||
drv.ResolveHost("GhostCounter").ShouldBe(ghostHost);
|
||||
}
|
||||
|
||||
[TwinCATFact]
|
||||
public async Task Probe_loop_raises_host_status_transition_to_Running_on_reachable_target()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
// Turn the probe loop on — InitializeAsync kicks off a background task per device
|
||||
// that calls AdsClient.ReadState. On first success the driver fires an
|
||||
// OnHostStatusChanged(Unknown|Stopped → Running). We only need to see one transition
|
||||
// to Running to prove the probe + event wiring is live.
|
||||
var options = new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions($"ads://{sim.TargetNetId}:{sim.AmsPort}", "XAR-VM")],
|
||||
Tags = [],
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new TwinCATProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(250),
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
},
|
||||
};
|
||||
await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-probe");
|
||||
|
||||
var runningSeen = new TaskCompletionSource<bool>();
|
||||
drv.OnHostStatusChanged += (_, e) =>
|
||||
{
|
||||
if (e.NewState == HostState.Running) runningSeen.TrySetResult(true);
|
||||
};
|
||||
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
// 5 s headroom: first probe fires ~250 ms after Initialize, plus ADS connect handshake.
|
||||
var completed = await Task.WhenAny(
|
||||
runningSeen.Task,
|
||||
Task.Delay(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken));
|
||||
completed.ShouldBe(runningSeen.Task, "probe loop must raise a Running transition within 5 s");
|
||||
|
||||
// Snapshot should also reflect Running — proves GetHostStatuses is in sync with the event.
|
||||
var statuses = drv.GetHostStatuses();
|
||||
statuses.Count.ShouldBe(1);
|
||||
statuses[0].State.ShouldBe(HostState.Running);
|
||||
}
|
||||
|
||||
[TwinCATFact]
|
||||
public async Task DiscoverAsync_renders_declared_tags_and_controller_browse_hits_address_space_builder()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
// Hits the DiscoverAsync → IAddressSpaceBuilder pipeline the browse-only test bypasses.
|
||||
// With EnableControllerBrowse=true, the driver must (a) emit the pre-declared tag under
|
||||
// the device folder and (b) drop discovered symbols into a sibling "Discovered/" folder.
|
||||
var baseOptions = BuildOptions(sim);
|
||||
var options = new TwinCATDriverOptions
|
||||
{
|
||||
Devices = baseOptions.Devices,
|
||||
Tags = baseOptions.Tags,
|
||||
UseNativeNotifications = baseOptions.UseNativeNotifications,
|
||||
Timeout = baseOptions.Timeout,
|
||||
Probe = baseOptions.Probe,
|
||||
EnableControllerBrowse = true,
|
||||
};
|
||||
await using var drv = new TwinCATDriver(options, driverInstanceId: "tc3-smoke-discover");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var builder = new RecordingAddressSpaceBuilder();
|
||||
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||
|
||||
// Structural folders in the order the driver emits them: TwinCAT → device → Discovered.
|
||||
builder.FolderBrowseNames.ShouldContain("TwinCAT");
|
||||
builder.FolderBrowseNames.ShouldContain($"ads://{sim.TargetNetId}:{sim.AmsPort}");
|
||||
builder.FolderBrowseNames.ShouldContain("Discovered");
|
||||
|
||||
// Pre-declared tag from TwinCATDriverOptions.Tags — always emitted regardless of browse.
|
||||
builder.Variables.ShouldContain(v => v.BrowseName == "Counter"
|
||||
&& v.Info.DriverDataType == DriverDataType.Int32);
|
||||
|
||||
// Controller-discovered symbol — GVL_Fixture.nCounter lands under Discovered/.
|
||||
builder.Variables.ShouldContain(v => v.BrowseName == "GVL_Fixture.nCounter"
|
||||
&& v.Info.DriverDataType == DriverDataType.Int32);
|
||||
}
|
||||
|
||||
private static TwinCATDriverOptions BuildOptions(TwinCATXarFixture sim) => new()
|
||||
{
|
||||
Devices = [
|
||||
@@ -124,6 +561,12 @@ public sealed class TwinCAT3SmokeTests(TwinCATXarFixture sim)
|
||||
SymbolPath: "GVL_Fixture.rSetpoint",
|
||||
DataType: TwinCATDataType.Real,
|
||||
Writable: true),
|
||||
new TwinCATTagDefinition(
|
||||
Name: "ArrayElem",
|
||||
DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
|
||||
SymbolPath: "GVL_Arrays.aReal1D[5]",
|
||||
DataType: TwinCATDataType.Real,
|
||||
Writable: true),
|
||||
],
|
||||
UseNativeNotifications = true,
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
@@ -133,3 +576,42 @@ public sealed class TwinCAT3SmokeTests(TwinCATXarFixture sim)
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test double that captures every <see cref="IAddressSpaceBuilder.Folder"/> /
|
||||
/// <see cref="IAddressSpaceBuilder.Variable"/> call the driver makes during
|
||||
/// <c>DiscoverAsync</c>. Lets assertions inspect the resulting folder + variable tree
|
||||
/// without materializing an OPC UA node manager.
|
||||
/// </summary>
|
||||
internal sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
|
||||
|
||||
public IEnumerable<string> FolderBrowseNames => Folders.Select(f => f.BrowseName);
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{
|
||||
Folders.Add((browseName, displayName));
|
||||
return this;
|
||||
}
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{
|
||||
Variables.Add((browseName, info));
|
||||
return new Handle(info.FullName);
|
||||
}
|
||||
|
||||
public void AddProperty(string name, DriverDataType type, object? value) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
|
||||
private sealed class NullSink : IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<DUT Name="E_AxisState" Id="{ff675b51-fdf4-4a20-9755-d3a962aa226c}">
|
||||
<Declaration><![CDATA[TYPE E_AxisState :
|
||||
(
|
||||
Idle := 0,
|
||||
Homing := 1,
|
||||
Moving := 2,
|
||||
Stopped := 3,
|
||||
Faulted := 99
|
||||
) DINT;
|
||||
END_TYPE
|
||||
]]></Declaration>
|
||||
</DUT>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<DUT Name="E_Severity" Id="{00ec5016-5d26-4d67-bac9-3fc28b1c92ce}">
|
||||
<Declaration><![CDATA[TYPE E_Severity :
|
||||
(
|
||||
Info := 0,
|
||||
Warning := 1,
|
||||
Critical := 2,
|
||||
Fatal := 3
|
||||
) INT;
|
||||
END_TYPE
|
||||
]]></Declaration>
|
||||
</DUT>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<DUT Name="ST_Alarm" Id="{d6459f8b-1fda-4bad-b787-c0e7e49fd8ec}">
|
||||
<Declaration><![CDATA[TYPE ST_Alarm :
|
||||
STRUCT
|
||||
Active : BOOL;
|
||||
Acknowledged : BOOL;
|
||||
Code : DINT;
|
||||
Severity : E_Severity;
|
||||
RaisedAt : DT;
|
||||
ClearedAt : DT;
|
||||
Message : STRING(80);
|
||||
Source : STRING(40);
|
||||
END_STRUCT
|
||||
END_TYPE
|
||||
]]></Declaration>
|
||||
</DUT>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<DUT Name="ST_Axis" Id="{2e150fb3-4694-4853-bb73-754d225c082a}">
|
||||
<Declaration><![CDATA[TYPE ST_Axis :
|
||||
STRUCT
|
||||
Name : STRING(32);
|
||||
State : E_AxisState;
|
||||
PositionMm : LREAL;
|
||||
VelocityMps : T_MeterPerSec;
|
||||
Accel : REAL;
|
||||
Motor : ST_Motor;
|
||||
Encoder : ST_Encoder;
|
||||
Commands : ST_AxisCommands;
|
||||
TravelLog : ARRAY[1..8] OF LREAL;
|
||||
END_STRUCT
|
||||
END_TYPE
|
||||
]]></Declaration>
|
||||
</DUT>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<DUT Name="ST_AxisCommands" Id="{0aaff3fd-5525-495b-9759-4de792f6e615}">
|
||||
<Declaration><![CDATA[TYPE ST_AxisCommands :
|
||||
STRUCT
|
||||
Enable : BOOL;
|
||||
Home : BOOL;
|
||||
Jog : BOOL;
|
||||
Stop : BOOL;
|
||||
TargetPos : LREAL;
|
||||
TargetVel : T_MeterPerSec;
|
||||
END_STRUCT
|
||||
END_TYPE
|
||||
]]></Declaration>
|
||||
</DUT>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<DUT Name="ST_Encoder" Id="{87f1e7c2-2359-4db5-acf3-46a1dd518acb}">
|
||||
<Declaration><![CDATA[TYPE ST_Encoder :
|
||||
STRUCT
|
||||
RawCounts : DINT;
|
||||
PositionMm : LREAL;
|
||||
VelocityMmPerS : T_MeterPerSec;
|
||||
Homed : BOOL;
|
||||
LastHomedAt : DT;
|
||||
END_STRUCT
|
||||
END_TYPE
|
||||
]]></Declaration>
|
||||
</DUT>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<DUT Name="ST_Line" Id="{3eaaae0a-31e2-43d6-b77c-5065a55c7d07}">
|
||||
<Declaration><![CDATA[TYPE ST_Line :
|
||||
STRUCT
|
||||
Name : STRING(32);
|
||||
Running : BOOL;
|
||||
Stations : ARRAY[1..3] OF ST_Station;
|
||||
Recipe : ST_Recipe;
|
||||
Stats : ST_Stats;
|
||||
END_STRUCT
|
||||
END_TYPE
|
||||
]]></Declaration>
|
||||
</DUT>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<DUT Name="ST_Motor" Id="{9354c15d-7882-42bb-8bce-017f74e49cca}">
|
||||
<Declaration><![CDATA[TYPE ST_Motor :
|
||||
STRUCT
|
||||
Current : REAL;
|
||||
Voltage : REAL;
|
||||
Temperature : T_Temperature;
|
||||
Rpm : DINT;
|
||||
Running : BOOL;
|
||||
Faulted : BOOL;
|
||||
SerialNo : STRING(24);
|
||||
END_STRUCT
|
||||
END_TYPE
|
||||
]]></Declaration>
|
||||
</DUT>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<DUT Name="ST_Recipe" Id="{723636a8-1265-46a5-88b3-fa1a3d77e594}">
|
||||
<Declaration><![CDATA[TYPE ST_Recipe :
|
||||
STRUCT
|
||||
Name : STRING(40);
|
||||
Description : WSTRING(120);
|
||||
Version : UINT;
|
||||
LoadedAt : DT;
|
||||
Steps : ARRAY[1..10] OF ST_RecipeStep;
|
||||
SupportedSkus: ARRAY[1..4] OF STRING(16);
|
||||
END_STRUCT
|
||||
END_TYPE
|
||||
]]></Declaration>
|
||||
</DUT>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<DUT Name="ST_RecipeStep" Id="{64d61cc8-2aba-4adb-9cf1-9345fc6f95b8}">
|
||||
<Declaration><![CDATA[TYPE ST_RecipeStep :
|
||||
STRUCT
|
||||
Enabled : BOOL;
|
||||
StepName : STRING(32);
|
||||
Duration : TIME;
|
||||
Setpoint : LREAL;
|
||||
Tolerance : REAL;
|
||||
Retries : USINT;
|
||||
END_STRUCT
|
||||
END_TYPE
|
||||
]]></Declaration>
|
||||
</DUT>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<DUT Name="ST_Station" Id="{ab0c22da-aee2-42de-be7f-05b8a8c27083}">
|
||||
<Declaration><![CDATA[TYPE ST_Station :
|
||||
STRUCT
|
||||
Name : STRING(32);
|
||||
Online : BOOL;
|
||||
Axes : ARRAY[1..4] OF ST_Axis;
|
||||
IO : ST_StationIO;
|
||||
Alarms : ARRAY[1..16] OF ST_Alarm;
|
||||
Heartbeat: UDINT;
|
||||
END_STRUCT
|
||||
END_TYPE
|
||||
]]></Declaration>
|
||||
</DUT>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<DUT Name="ST_StationIO" Id="{f71903d5-b9ea-4b75-9ca7-977b8374c11a}">
|
||||
<Declaration><![CDATA[TYPE ST_StationIO :
|
||||
STRUCT
|
||||
DigitalInputs : ARRAY[0..31] OF BOOL;
|
||||
DigitalOutputs : ARRAY[0..31] OF BOOL;
|
||||
AnalogInputs : ARRAY[0..7] OF REAL;
|
||||
AnalogOutputs : ARRAY[0..7] OF REAL;
|
||||
CycleCounter : UDINT;
|
||||
LastInputMask : DWORD;
|
||||
LastOutputMask : DWORD;
|
||||
END_STRUCT
|
||||
END_TYPE
|
||||
]]></Declaration>
|
||||
</DUT>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<DUT Name="ST_Stats" Id="{23dffb59-ae06-4ac7-8c98-cba78dddf81d}">
|
||||
<Declaration><![CDATA[TYPE ST_Stats :
|
||||
STRUCT
|
||||
UnitsProduced : UDINT;
|
||||
UnitsRejected : UDINT;
|
||||
UpTimeSeconds : UDINT;
|
||||
LastRejectAt : DT;
|
||||
LastProducedAt : DT;
|
||||
RejectReasons : ARRAY[1..5] OF UDINT;
|
||||
END_STRUCT
|
||||
END_TYPE
|
||||
]]></Declaration>
|
||||
</DUT>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<DUT Name="T_MeterPerSec" Id="{bddf08a2-da6c-4076-9719-13806a9e2438}">
|
||||
<Declaration><![CDATA[TYPE T_MeterPerSec : LREAL;
|
||||
END_TYPE
|
||||
]]></Declaration>
|
||||
</DUT>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<DUT Name="T_Temperature" Id="{5e81fac6-ab58-4311-b798-907495f9ead9}">
|
||||
<Declaration><![CDATA[TYPE T_Temperature : LREAL;
|
||||
END_TYPE
|
||||
]]></Declaration>
|
||||
</DUT>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<GVL Name="GVL_Arrays" Id="{6d45cf94-641a-40eb-a780-4fb3a1f8984f}">
|
||||
<Declaration><![CDATA[{attribute 'qualified_only'}
|
||||
VAR_GLOBAL
|
||||
// 1-D arrays of primitives.
|
||||
aBool1D : ARRAY[0..9] OF BOOL;
|
||||
aInt1D : ARRAY[0..9] OF INT := [10(0)];
|
||||
aDInt1D : ARRAY[1..16] OF DINT;
|
||||
aReal1D : ARRAY[0..31] OF REAL;
|
||||
aLReal1D : ARRAY[0..7] OF LREAL;
|
||||
aString1D : ARRAY[1..4] OF STRING(32);
|
||||
|
||||
// 2-D and 3-D arrays.
|
||||
aReal2D : ARRAY[1..4, 1..4] OF REAL;
|
||||
aDInt3D : ARRAY[0..2, 0..2, 0..2] OF DINT;
|
||||
|
||||
// Array-of-UDT (exercises per-element browse of nested structs).
|
||||
aAxisSnapshots : ARRAY[1..4] OF ST_Axis;
|
||||
END_VAR
|
||||
]]></Declaration>
|
||||
</GVL>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<GVL Name="GVL_Enums" Id="{a0e45f27-3675-4b6f-bc09-ded0723b7029}">
|
||||
<Declaration><![CDATA[{attribute 'qualified_only'}
|
||||
VAR_GLOBAL
|
||||
// Enum + alias coverage — standalone globals so OPC UA browse can assert
|
||||
// on EnumStrings / DataTypeDefinition rendering without walking into the
|
||||
// plant hierarchy.
|
||||
currentAxisState : E_AxisState := E_AxisState.Idle;
|
||||
currentSeverity : E_Severity := E_Severity.Info;
|
||||
severityLog : ARRAY[1..8] OF E_Severity;
|
||||
cabinetTemperature : T_Temperature := 22.5;
|
||||
conveyorSpeed : T_MeterPerSec := 1.5;
|
||||
END_VAR
|
||||
]]></Declaration>
|
||||
</GVL>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<GVL Name="GVL_Fixture" Id="{cdbd99aa-f3c6-48ad-a068-d31a9e1a3b21}">
|
||||
<Declaration><![CDATA[{attribute 'qualified_only'}
|
||||
VAR_GLOBAL
|
||||
// Monotonic counter — MAIN increments every cycle. Seed 1234 is the
|
||||
// floor the smoke tests assert against.
|
||||
nCounter : DINT := 1234;
|
||||
|
||||
// Scratch REAL for write-then-read round-trip.
|
||||
rSetpoint : REAL := 0.0;
|
||||
|
||||
// Reserved for discovery / browse tests.
|
||||
bFlag : BOOL := TRUE;
|
||||
END_VAR
|
||||
]]></Declaration>
|
||||
</GVL>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<GVL Name="GVL_Plant" Id="{00c8ed35-95d3-494a-92d8-db5f2c8ab6d9}">
|
||||
<Declaration><![CDATA[{attribute 'qualified_only'}
|
||||
VAR_GLOBAL
|
||||
// Top-level plant hierarchy exposed to OPC UA browse:
|
||||
// GVL_Plant.Line1.Stations[1..3].Axes[1..4].Motor/Encoder/Commands
|
||||
// .IO
|
||||
// .Alarms[1..16]
|
||||
// .Recipe.Steps[1..10]
|
||||
// .Stats
|
||||
Line1 : ST_Line := (
|
||||
Name := 'Assembly-01',
|
||||
Running := TRUE
|
||||
);
|
||||
END_VAR
|
||||
]]></Declaration>
|
||||
</GVL>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<GVL Name="GVL_Primitives" Id="{2f721507-9717-45cf-bf40-02a9691bb5b4}">
|
||||
<Declaration><![CDATA[{attribute 'qualified_only'}
|
||||
VAR_GLOBAL
|
||||
// One of every primitive ADS type. Exercises the full
|
||||
// TwinCATDataType → OPC UA NodeId mapping.
|
||||
vBool : BOOL := TRUE;
|
||||
vByte : BYTE := 16#A5;
|
||||
vWord : WORD := 16#BEEF;
|
||||
vDWord : DWORD := 16#DEADBEEF;
|
||||
vLWord : LWORD := 16#0123456789ABCDEF;
|
||||
vSInt : SINT := -42;
|
||||
vUSInt : USINT := 250;
|
||||
vInt : INT := -12345;
|
||||
vUInt : UINT := 54321;
|
||||
vDInt : DINT := -1234567;
|
||||
vUDInt : UDINT := 4000000000;
|
||||
vLInt : LINT := -1234567890123;
|
||||
vULInt : ULINT := 12345678901234567;
|
||||
vReal : REAL := 3.14159;
|
||||
vLReal : LREAL := 2.7182818284590452;
|
||||
vString : STRING(80) := 'Hello from TC3';
|
||||
vWString : WSTRING(80) := "unicode ✓";
|
||||
vTime : TIME := T#2h34m17s500ms;
|
||||
vTimeOfDay : TOD := TOD#08:30:00;
|
||||
vDate : DATE := D#2026-04-22;
|
||||
vDateTime : DT := DT#2026-04-22-08:30:00;
|
||||
END_VAR
|
||||
]]></Declaration>
|
||||
</GVL>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<POU Name="FB_AxisSim" Id="{5b3e4e46-5ace-4e6c-a0dc-d55113a89212}" SpecialFunc="None">
|
||||
<Declaration><![CDATA[FUNCTION_BLOCK FB_AxisSim
|
||||
VAR_INPUT
|
||||
Enable : BOOL;
|
||||
END_VAR
|
||||
VAR_IN_OUT
|
||||
Axis : ST_Axis;
|
||||
END_VAR
|
||||
VAR
|
||||
tick : UDINT;
|
||||
END_VAR
|
||||
]]></Declaration>
|
||||
<Implementation>
|
||||
<ST><![CDATA[IF NOT Enable THEN
|
||||
Axis.State := E_AxisState.Idle;
|
||||
RETURN;
|
||||
END_IF
|
||||
|
||||
tick := tick + 1;
|
||||
|
||||
// Ramp position + derive velocity so a subscription sees LREAL churn.
|
||||
Axis.PositionMm := Axis.PositionMm + 0.5;
|
||||
Axis.VelocityMps := 0.5 * SIN(UDINT_TO_LREAL(tick) * 0.05);
|
||||
Axis.Accel := LREAL_TO_REAL(0.1 * COS(UDINT_TO_LREAL(tick) * 0.05));
|
||||
Axis.State := E_AxisState.Moving;
|
||||
|
||||
// Motor block — keep values sane but moving.
|
||||
Axis.Motor.Current := 2.5 + LREAL_TO_REAL(0.5 * SIN(UDINT_TO_LREAL(tick) * 0.1));
|
||||
Axis.Motor.Voltage := 24.0;
|
||||
Axis.Motor.Temperature:= 35.0 + 5.0 * SIN(UDINT_TO_LREAL(tick) * 0.01);
|
||||
Axis.Motor.Rpm := 1500 + LREAL_TO_DINT(200.0 * SIN(UDINT_TO_LREAL(tick) * 0.05));
|
||||
Axis.Motor.Running := TRUE;
|
||||
|
||||
// Encoder
|
||||
Axis.Encoder.RawCounts := Axis.Encoder.RawCounts + 12;
|
||||
Axis.Encoder.PositionMm := Axis.PositionMm;
|
||||
Axis.Encoder.VelocityMmPerS := Axis.VelocityMps;
|
||||
Axis.Encoder.Homed := TRUE;
|
||||
|
||||
// Travel log — rolling window of last 8 positions.
|
||||
Axis.TravelLog[1] := Axis.TravelLog[2];
|
||||
Axis.TravelLog[2] := Axis.TravelLog[3];
|
||||
Axis.TravelLog[3] := Axis.TravelLog[4];
|
||||
Axis.TravelLog[4] := Axis.TravelLog[5];
|
||||
Axis.TravelLog[5] := Axis.TravelLog[6];
|
||||
Axis.TravelLog[6] := Axis.TravelLog[7];
|
||||
Axis.TravelLog[7] := Axis.TravelLog[8];
|
||||
Axis.TravelLog[8] := Axis.PositionMm;
|
||||
]]></ST>
|
||||
</Implementation>
|
||||
</POU>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<POU Name="FB_LineSim" Id="{83bd0d62-650e-4191-9410-a31c2eb07c02}" SpecialFunc="None">
|
||||
<Declaration><![CDATA[FUNCTION_BLOCK FB_LineSim
|
||||
VAR_IN_OUT
|
||||
Line : ST_Line;
|
||||
END_VAR
|
||||
VAR
|
||||
tick : UDINT;
|
||||
axisSim : ARRAY[1..3, 1..4] OF FB_AxisSim;
|
||||
iStation : INT;
|
||||
iAxis : INT;
|
||||
iAlarm : INT;
|
||||
rotatorBits : DWORD;
|
||||
END_VAR
|
||||
]]></Declaration>
|
||||
<Implementation>
|
||||
<ST><![CDATA[tick := tick + 1;
|
||||
|
||||
FOR iStation := 1 TO 3 DO
|
||||
Line.Stations[iStation].Online := TRUE;
|
||||
Line.Stations[iStation].Heartbeat := tick;
|
||||
|
||||
// Axes — delegate to FB_AxisSim for per-cycle motion.
|
||||
FOR iAxis := 1 TO 4 DO
|
||||
axisSim[iStation, iAxis](
|
||||
Enable := Line.Running,
|
||||
Axis := Line.Stations[iStation].Axes[iAxis]
|
||||
);
|
||||
END_FOR
|
||||
|
||||
// Rolling I/O patterns to give subscribers something to watch.
|
||||
rotatorBits := SHL(DWORD#1, (tick MOD 32));
|
||||
Line.Stations[iStation].IO.LastInputMask := rotatorBits;
|
||||
Line.Stations[iStation].IO.LastOutputMask := NOT rotatorBits;
|
||||
Line.Stations[iStation].IO.CycleCounter := tick;
|
||||
|
||||
// Flip a couple of DI/DOs so BOOL arrays see changes.
|
||||
Line.Stations[iStation].IO.DigitalInputs[(tick MOD 32)] := NOT Line.Stations[iStation].IO.DigitalInputs[(tick MOD 32)];
|
||||
Line.Stations[iStation].IO.DigitalOutputs[(tick MOD 32)] := Line.Stations[iStation].IO.DigitalInputs[(tick MOD 32)];
|
||||
|
||||
// Walk an analog channel through a sine.
|
||||
Line.Stations[iStation].IO.AnalogInputs[0] := LREAL_TO_REAL(10.0 + 5.0 * SIN(UDINT_TO_LREAL(tick + UDINT#100 * UINT_TO_UDINT(INT_TO_UINT(iStation))) * 0.05));
|
||||
Line.Stations[iStation].IO.AnalogOutputs[0] := Line.Stations[iStation].IO.AnalogInputs[0] * 2.0;
|
||||
|
||||
// Rotate one alarm per station so IAlarmSource has state transitions.
|
||||
iAlarm := INT_TO_USINT(1 + (DINT_TO_INT(UDINT_TO_DINT(tick)) MOD 16));
|
||||
Line.Stations[iStation].Alarms[iAlarm].Active := (tick MOD 32) >= 16;
|
||||
Line.Stations[iStation].Alarms[iAlarm].Severity := E_Severity.Warning;
|
||||
Line.Stations[iStation].Alarms[iAlarm].Code := 1000 + DINT(iAlarm);
|
||||
Line.Stations[iStation].Alarms[iAlarm].Message := CONCAT('alarm-', TO_STRING(iAlarm));
|
||||
Line.Stations[iStation].Alarms[iAlarm].Source := CONCAT('Station-', TO_STRING(iStation));
|
||||
END_FOR
|
||||
|
||||
// Stats — monotonic production counters.
|
||||
Line.Stats.UnitsProduced := Line.Stats.UnitsProduced + 1;
|
||||
IF (tick MOD 100) = 0 THEN
|
||||
Line.Stats.UnitsRejected := Line.Stats.UnitsRejected + 1;
|
||||
Line.Stats.RejectReasons[1 + ((DINT_TO_INT(UDINT_TO_DINT(tick / 100))) MOD 5)] :=
|
||||
Line.Stats.RejectReasons[1 + ((DINT_TO_INT(UDINT_TO_DINT(tick / 100))) MOD 5)] + 1;
|
||||
END_IF
|
||||
Line.Stats.UpTimeSeconds := tick / 100; // 10 ms task tick -> approx seconds
|
||||
]]></ST>
|
||||
</Implementation>
|
||||
</POU>
|
||||
</TcPlcObject>
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||
<POU Name="MAIN" Id="{eaceb1e4-5544-4368-a12f-3e05dd0be17a}" SpecialFunc="None">
|
||||
<Declaration><![CDATA[PROGRAM MAIN
|
||||
VAR
|
||||
lineSim : FB_LineSim;
|
||||
i : INT;
|
||||
j : INT;
|
||||
k : INT;
|
||||
END_VAR
|
||||
]]></Declaration>
|
||||
<Implementation>
|
||||
<ST><![CDATA[// Smoke-test contract: monotonic counter on GVL_Fixture.nCounter.
|
||||
// See TwinCAT3SmokeTests.cs (Driver_reads_seeded_DINT_through_real_ADS +
|
||||
// Driver_subscribe_receives_native_ADS_notifications_on_counter_changes).
|
||||
GVL_Fixture.nCounter := GVL_Fixture.nCounter + 1;
|
||||
|
||||
// Drive the complex fixture.
|
||||
lineSim(Line := GVL_Plant.Line1);
|
||||
|
||||
// Keep the standalone enum / alias globals churning so browse + subscribe
|
||||
// tests against GVL_Enums see changes without reaching into GVL_Plant.
|
||||
IF (GVL_Fixture.nCounter MOD 10) = 0 THEN
|
||||
GVL_Enums.currentAxisState := GVL_Plant.Line1.Stations[1].Axes[1].State;
|
||||
GVL_Enums.currentSeverity := E_Severity.Info;
|
||||
GVL_Enums.cabinetTemperature := GVL_Plant.Line1.Stations[1].Axes[1].Motor.Temperature;
|
||||
GVL_Enums.conveyorSpeed := GVL_Plant.Line1.Stations[1].Axes[1].VelocityMps;
|
||||
END_IF
|
||||
|
||||
// Stir a 2-D / 3-D array entry so multi-rank subscribes see value churn.
|
||||
i := 1 + (GVL_Fixture.nCounter MOD 4);
|
||||
j := 1 + ((GVL_Fixture.nCounter / 4) MOD 4);
|
||||
GVL_Arrays.aReal2D[i, j] := LREAL_TO_REAL(SIN(DINT_TO_LREAL(GVL_Fixture.nCounter) * 0.01));
|
||||
|
||||
k := GVL_Fixture.nCounter MOD 3;
|
||||
GVL_Arrays.aDInt3D[k, k, k] := GVL_Fixture.nCounter;
|
||||
|
||||
GVL_Arrays.aInt1D[GVL_Fixture.nCounter MOD 10] := DINT_TO_INT(GVL_Fixture.nCounter MOD 10000);
|
||||
]]></ST>
|
||||
</Implementation>
|
||||
</POU>
|
||||
</TcPlcObject>
|
||||
@@ -1,12 +1,15 @@
|
||||
# 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`).
|
||||
This folder holds the TwinCAT 3 XAE project that the XAR VM (or TCBSD
|
||||
target) runs for the integration-tests suite and the broader browse /
|
||||
UDT / array / enum coverage exercised by the OPC UA driver.
|
||||
|
||||
**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.
|
||||
**Status today**: the `.sln` / `.tsproj` / `.plcproj` wrappers still get
|
||||
generated per-workstation in XAE (GUIDs are install-specific), but every
|
||||
PLC object is committed as a standalone `.TcGVL` / `.TcDUT` / `.TcPOU`
|
||||
under `PLC/{GVLs,DUTs,POUs}/`. Reconstruction is "Add Existing Item" of
|
||||
each file into a fresh XAE project — see **Importing the committed PLC
|
||||
objects** below.
|
||||
|
||||
## Why `.tsproj`, not the binary bootproject
|
||||
|
||||
@@ -30,41 +33,89 @@ Reconstruction workflow on the VM:
|
||||
|
||||
## 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.
|
||||
`TwinCAT3SmokeTests.cs` ships 14 `[TwinCATFact]` methods plus a 16-case
|
||||
`[TwinCATTheory]` — the tests depend on the exact shapes below. Missing
|
||||
or renamed symbols surface as ADS `DeviceSymbolNotFound` or wrong-type
|
||||
read failures, not silent skips. Changed seed values will flip specific
|
||||
assertions — the load-bearing ones are called out inline.
|
||||
|
||||
### Global Variable List: `GVL_Fixture`
|
||||
### Integration-test contract
|
||||
|
||||
```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;
|
||||
The dependency surface spans `GVL_Fixture`, `GVL_Primitives`, `GVL_Arrays`,
|
||||
`GVL_Enums`, `GVL_Plant`, and `MAIN`.
|
||||
|
||||
// Scratch REAL for write-then-read round-trip test. Smoke test writes
|
||||
// 42.5 + reads back.
|
||||
rSetpoint : REAL := 0.0;
|
||||
**`GVL_Fixture`** (source: [`PLC/GVLs/GVL_Fixture.TcGVL`](PLC/GVLs/GVL_Fixture.TcGVL))
|
||||
holds exactly three variables — `nCounter : DINT := 1234`,
|
||||
`rSetpoint : REAL := 0.0`, `bFlag : BOOL := TRUE`. `MAIN`
|
||||
(source: [`PLC/POUs/MAIN.TcPOU`](PLC/POUs/MAIN.TcPOU)) increments
|
||||
`nCounter` every cycle so the native-notification test sees monotonic
|
||||
change without writing. Seeded values aren't reliable — PlcTask
|
||||
watchdog restarts reset them — so the read test only asserts a
|
||||
non-negative DINT.
|
||||
|
||||
// Readable boolean with seed value TRUE. Reserved for future
|
||||
// expansion (e.g. discovery / symbol-browse tests).
|
||||
bFlag : BOOL := TRUE;
|
||||
END_VAR
|
||||
**`GVL_Primitives.vWord := 16#BEEF`** — the bit-indexed BOOL test pins
|
||||
to bit 3 (set in `0xBEEF`) and bit 4 (clear). Changing the seed flips
|
||||
that test.
|
||||
|
||||
**`GVL_Primitives` numeric seeds** — the primitive-type theory reads
|
||||
each of `vSInt, vUSInt, vInt, vUInt, vDInt, vUDInt, vLInt, vULInt,
|
||||
vReal, vLReal, vString` and asserts the exact seed value. Matches
|
||||
the declarations in [`PLC/GVLs/GVL_Primitives.TcGVL`](PLC/GVLs/GVL_Primitives.TcGVL).
|
||||
|
||||
**`GVL_Arrays.aReal1D[5]`** — the array round-trip test writes + reads
|
||||
element index 5. The array must be writable; don't apply read-only
|
||||
attributes.
|
||||
|
||||
**`GVL_Plant.Line1.Stations[1].Axes[1].Motor.{Temperature, Running}`** —
|
||||
the nested-UDT test reads both (LREAL + BOOL). `FB_LineSim` must be
|
||||
driving the hierarchy (see `MAIN`) so values are alive.
|
||||
|
||||
**`GVL_Enums.currentAxisState`** — the browse test asserts this symbol
|
||||
surfaces by name.
|
||||
|
||||
### Complex hierarchy (for browse / UDT / array / enum coverage)
|
||||
|
||||
All sources in [`PLC/`](PLC/). Commits are split
|
||||
one-object-per-file so XAE can "Add Existing Item" each into a fresh
|
||||
project (the `.plcproj` wrapper is environment-specific and not
|
||||
committed).
|
||||
|
||||
**Type coverage** — `GVL_Primitives` has one of every ADS primitive:
|
||||
`BOOL, BYTE, WORD, DWORD, LWORD, SINT, USINT, INT, UINT, DINT, UDINT,
|
||||
LINT, ULINT, REAL, LREAL, STRING(80), WSTRING(80), TIME, TOD, DATE, DT`.
|
||||
|
||||
**Array coverage** — `GVL_Arrays` has 1-D primitives, `ARRAY[1..4,1..4]
|
||||
OF REAL`, `ARRAY[0..2,0..2,0..2] OF DINT`, plus `ARRAY[1..4] OF ST_Axis`
|
||||
for per-element nested-struct browse.
|
||||
|
||||
**Enum + alias coverage** — `E_AxisState : DINT`, `E_Severity : INT`,
|
||||
`T_Temperature : LREAL`, `T_MeterPerSec : LREAL`. `GVL_Enums` exposes
|
||||
each at the root so tests can assert `EnumStrings` /
|
||||
`DataTypeDefinition` rendering without walking the plant hierarchy.
|
||||
|
||||
**5-level plant hierarchy** — rooted at `GVL_Plant.Line1 : ST_Line`:
|
||||
|
||||
```
|
||||
GVL_Plant.Line1 (ST_Line)
|
||||
.Stations[1..3] (ARRAY OF ST_Station)
|
||||
.Axes[1..4] (ARRAY OF ST_Axis)
|
||||
.Motor (ST_Motor)
|
||||
.Encoder (ST_Encoder)
|
||||
.Commands (ST_AxisCommands)
|
||||
.TravelLog[1..8] (ARRAY OF LREAL)
|
||||
.IO (ST_StationIO — 32x DI/DO, 8x AI/AO)
|
||||
.Alarms[1..16] (ARRAY OF ST_Alarm)
|
||||
.Recipe (ST_Recipe)
|
||||
.Steps[1..10] (ARRAY OF ST_RecipeStep)
|
||||
.SupportedSkus[1..4] (ARRAY OF STRING)
|
||||
.Stats (ST_Stats)
|
||||
```
|
||||
|
||||
### 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;
|
||||
```
|
||||
**Live value churn** — `FB_LineSim` + `FB_AxisSim` are called from
|
||||
`MAIN` every cycle: axes ramp position + derive velocity/accel, motor
|
||||
current/temperature track a sine, IO masks rotate, one alarm per station
|
||||
toggles each 32 cycles, stats counters increment. Subscription tests
|
||||
see real data-change notifications without an external writer.
|
||||
|
||||
### Task
|
||||
|
||||
@@ -77,6 +128,30 @@ GVL_Fixture.nCounter := GVL_Fixture.nCounter + 1;
|
||||
to this. Use runtime 2 / port `852` only if the single runtime is
|
||||
already taken by another project on the same VM.
|
||||
|
||||
## Importing the committed PLC objects
|
||||
|
||||
On a machine with a working TcXaeShell (or Visual Studio with the TC3
|
||||
XAE integration):
|
||||
|
||||
1. File → New → Project → **TwinCAT XAE Project** → name
|
||||
`OtOpcUaTwinCatFixture`. This lays down the `.sln` + `.tsproj`
|
||||
scaffolding.
|
||||
2. In the Solution Explorer, right-click the `PLC` node →
|
||||
**Add New Item → Standard PLC Project** → name `PLC`. Delete the
|
||||
auto-generated stub `MAIN` — the committed one will replace it.
|
||||
3. Right-click the PLC project's `DUTs` folder → **Add → Existing Item
|
||||
…** → multi-select every file under [`PLC/DUTs/`](PLC/DUTs/) and
|
||||
**Add As Link** (so the repo stays the source of truth). Repeat for
|
||||
`GVLs/` → [`PLC/GVLs/`](PLC/GVLs/) and `POUs/` →
|
||||
[`PLC/POUs/`](PLC/POUs/).
|
||||
4. Assign `MAIN` to `PlcTask` (cyclic, 10 ms, priority 20).
|
||||
5. Set the target system to the TCBSD / XAR VM via the AMS route, then
|
||||
**Build → Build Solution** + **Activate Configuration → Run Mode**.
|
||||
|
||||
If XAE complains about missing references while importing, add the DUTs
|
||||
before the GVLs (the structs are referenced by the plant GVL) and the
|
||||
enums/aliases first within DUTs.
|
||||
|
||||
## XAR VM setup (one-time)
|
||||
|
||||
Full bootstrap lives in `docs/v2/dev-environment.md`. The TwinCAT-specific
|
||||
@@ -126,14 +201,17 @@ Options to eliminate the manual step:
|
||||
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
|
||||
$env:TWINCAT_TARGET_NETID = '5.23.91.23.1.1' # replace with the VM AmsNetId — REQUIRED
|
||||
$env:TWINCAT_TARGET_HOST = '10.0.0.42' # replace with the VM IP (defaults to localhost)
|
||||
# $env:TWINCAT_TARGET_PORT = '852' # only if not using PLC runtime 1 (default 851)
|
||||
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.
|
||||
Only `TWINCAT_TARGET_NETID` is required — fixture gates on it specifically.
|
||||
`TWINCAT_TARGET_HOST` defaults to `localhost` when unset; `TWINCAT_TARGET_PORT`
|
||||
defaults to `851`. With the NetID unset, the whole integration suite
|
||||
(~28 cases, both `[TwinCATFact]` and `[TwinCATTheory]`) skips cleanly;
|
||||
unit suite (`TwinCAT.Tests`) runs unchanged.
|
||||
|
||||
## See also
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
|
||||
using ConfigRedundancyMode = ZB.MOM.WW.OtOpcUa.Configuration.Enums.RedundancyMode;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit coverage for <see cref="ServerRedundancyNodeWriter"/>. Uses a <see cref="DispatchProxy"/>
|
||||
/// stand-in for <see cref="IServerInternal"/> — the writer only needs <c>ServerObject</c> +
|
||||
/// <c>DefaultSystemContext</c>, so we stub just those and let every other member return
|
||||
/// null (the writer never touches anything else).
|
||||
/// </summary>
|
||||
public sealed class ServerRedundancyNodeWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public void ApplyServiceLevel_sets_node_value_and_dedupes_unchanged()
|
||||
{
|
||||
var env = BuildEnv();
|
||||
|
||||
env.Writer.ApplyServiceLevel(200);
|
||||
env.ServerObject.ServiceLevel.Value.ShouldBe((byte)200);
|
||||
|
||||
var timestampAfterFirst = env.ServerObject.ServiceLevel.Timestamp;
|
||||
|
||||
// Same value — writer should early-out without touching Timestamp.
|
||||
Thread.Sleep(5);
|
||||
env.Writer.ApplyServiceLevel(200);
|
||||
env.ServerObject.ServiceLevel.Timestamp.ShouldBe(timestampAfterFirst);
|
||||
|
||||
env.Writer.ApplyServiceLevel(150);
|
||||
env.ServerObject.ServiceLevel.Value.ShouldBe((byte)150);
|
||||
env.ServerObject.ServiceLevel.Timestamp.ShouldBeGreaterThan(timestampAfterFirst);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyRedundancySupport_maps_config_enum()
|
||||
{
|
||||
var env = BuildEnv();
|
||||
|
||||
env.Writer.ApplyRedundancySupport(ConfigRedundancyMode.Warm);
|
||||
env.ServerObject.ServerRedundancy.RedundancySupport.Value.ShouldBe(RedundancySupport.Warm);
|
||||
|
||||
env.Writer.ApplyRedundancySupport(ConfigRedundancyMode.Hot);
|
||||
env.ServerObject.ServerRedundancy.RedundancySupport.Value.ShouldBe(RedundancySupport.Hot);
|
||||
|
||||
env.Writer.ApplyRedundancySupport(ConfigRedundancyMode.None);
|
||||
env.ServerObject.ServerRedundancy.RedundancySupport.Value.ShouldBe(RedundancySupport.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyServerUriArray_writes_when_non_transparent_state_present()
|
||||
{
|
||||
var env = BuildEnv(nonTransparent: true);
|
||||
|
||||
env.Writer.ApplyServerUriArray(["urn:self", "urn:peer"]);
|
||||
var ntr = (NonTransparentRedundancyState)env.ServerObject.ServerRedundancy;
|
||||
ntr.ServerUriArray.Value.ShouldBe(new[] { "urn:self", "urn:peer" });
|
||||
|
||||
var ts = ntr.ServerUriArray.Timestamp;
|
||||
Thread.Sleep(5);
|
||||
env.Writer.ApplyServerUriArray(["urn:self", "urn:peer"]); // dedupe
|
||||
ntr.ServerUriArray.Timestamp.ShouldBe(ts);
|
||||
|
||||
env.Writer.ApplyServerUriArray(["urn:self", "urn:peer", "urn:peer2"]);
|
||||
ntr.ServerUriArray.Value.Length.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyServerUriArray_skips_silently_on_base_redundancy_type()
|
||||
{
|
||||
var env = BuildEnv(nonTransparent: false);
|
||||
Should.NotThrow(() => env.Writer.ApplyServerUriArray(["urn:self"]));
|
||||
env.ServerObject.ServerRedundancy.ShouldBeOfType<ServerRedundancyState>();
|
||||
}
|
||||
|
||||
private static Env BuildEnv(bool nonTransparent = false)
|
||||
{
|
||||
var serverObject = new ServerObjectState(parent: null)
|
||||
{
|
||||
ServiceLevel = new PropertyState<byte>(null),
|
||||
};
|
||||
serverObject.ServerRedundancy = nonTransparent
|
||||
? new NonTransparentRedundancyState(serverObject)
|
||||
{
|
||||
RedundancySupport = new PropertyState<RedundancySupport>(null),
|
||||
ServerUriArray = new PropertyState<string[]>(null),
|
||||
}
|
||||
: new ServerRedundancyState(serverObject)
|
||||
{
|
||||
RedundancySupport = new PropertyState<RedundancySupport>(null),
|
||||
};
|
||||
|
||||
var proxy = DispatchProxy.Create<IServerInternal, FakeServerInternalProxy>();
|
||||
var fake = (FakeServerInternalProxy)(object)proxy;
|
||||
fake.ServerObjectValue = serverObject;
|
||||
fake.DefaultSystemContextValue = new ServerSystemContext(proxy);
|
||||
|
||||
var writer = new ServerRedundancyNodeWriter(proxy, NullLogger<ServerRedundancyNodeWriter>.Instance);
|
||||
return new Env(proxy, serverObject, writer);
|
||||
}
|
||||
|
||||
private sealed record Env(
|
||||
IServerInternal Server,
|
||||
ServerObjectState ServerObject,
|
||||
ServerRedundancyNodeWriter Writer);
|
||||
|
||||
public class FakeServerInternalProxy : DispatchProxy
|
||||
{
|
||||
public ServerObjectState? ServerObjectValue;
|
||||
public ISystemContext? DefaultSystemContextValue;
|
||||
|
||||
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) =>
|
||||
targetMethod?.Name switch
|
||||
{
|
||||
"get_ServerObject" => ServerObjectValue,
|
||||
"get_DefaultSystemContext" => DefaultSystemContextValue,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
<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="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.3"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0"/>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.374.126"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
|
||||
Reference in New Issue
Block a user