Compare commits
246 Commits
phase-7-st
...
auto/drive
| Author | SHA1 | Date | |
|---|---|---|---|
| f7e0d9a9e7 | |||
|
|
705c98ad98 | ||
| 35d733d73b | |||
|
|
0adc5adb59 | ||
| 7cbc566db9 | |||
|
|
c36903d6a0 | ||
| 2ee61c0999 | |||
|
|
e3d7c65f61 | ||
| 45770e8d90 | |||
|
|
399257377b | ||
| 08a4db2952 | |||
|
|
1e3053c0d8 | ||
| 8ee65a75d2 | |||
|
|
9e157fc8a4 | ||
| 258ce8e937 | |||
|
|
561b0f9ea9 | ||
| 349aa5c6f4 | |||
|
|
0444cb699d | ||
| da6e19d07d | |||
|
|
baf1d65875 | ||
| c9e28b881e | |||
|
|
5f8d84db43 | ||
| 7e62a1158f | |||
|
|
a908dff7b5 | ||
| ac3fd45cc6 | |||
|
|
5c72deb839 | ||
| 9a3bc08e1c | |||
|
|
86f3fc2733 | ||
| d676b4056d | |||
|
|
54c09d4d5d | ||
| 0c967af645 | |||
|
|
f48f31cfc7 | ||
| 71af554497 | |||
|
|
1bfe8fba0e | ||
| 6f1657b1c0 | |||
|
|
4e8df38bb2 | ||
| 4fdeef7a6c | |||
|
|
42472b5549 | ||
| 14876ea210 | |||
|
|
c292dcc1db | ||
| 4ff1537d8a | |||
|
|
e0e5e04e48 | ||
| e46e4de31f | |||
|
|
901a5b9b21 | ||
| 9c108cd00a | |||
|
|
da9936f7f0 | ||
| 9202ebe5ef | |||
|
|
b45713622f | ||
| e5c38a5a0e | |||
|
|
24a3cda56a | ||
| 30e39a752a | |||
|
|
fb57717f6f | ||
| 621de94126 | |||
|
|
64a11ef285 | ||
| 4bc8aa2478 | |||
|
|
06b39a28fa | ||
| 8909302929 | |||
|
|
162c82b8d9 | ||
| ca3d4bf581 | |||
|
|
3b98e4d366 | ||
| bcf83bf39b | |||
|
|
6540bbe1ef | ||
| f469cf7e0d | |||
|
|
ab3ed6b6a3 | ||
| eed5857aa9 | |||
|
|
7f9d6a778e | ||
| 1922b93bd5 | |||
|
|
eb5286148e | ||
| 69069aa3be | |||
|
|
c689ac58b1 | ||
| 05528bf71c | |||
|
|
01f4ee6b53 | ||
| 8a8dc1ee5a | |||
|
|
0c6a0d6e50 | ||
| 73ff10b595 | |||
|
|
f6c26db609 | ||
| 7cbddd4b4a | |||
|
|
4098d72bbb | ||
| 569001364f | |||
|
|
b67eb6c8d0 | ||
| 4a071b6d5a | |||
|
|
931049b5a7 | ||
| fa2fbb404d | |||
|
|
17faf76ea7 | ||
| 5432c49364 | |||
|
|
d7633fe36f | ||
| 69d9a6fbb5 | |||
|
|
07abee5f6d | ||
| 0f3abed4c7 | |||
|
|
cc21281cbb | ||
| 5e164dc965 | |||
|
|
02d1c85190 | ||
| 1d3e9a3237 | |||
|
|
0f509fbd3a | ||
| e879b3ae90 | |||
|
|
4d3ee47235 | ||
| 9ebe5bd523 | |||
|
|
63099115bf | ||
| 7042b11f34 | |||
|
|
2f3eeecd17 | ||
| 3b82f4f5fb | |||
|
|
451b37a632 | ||
| 6743d51db8 | |||
|
|
0044603902 | ||
| 2fc71d288e | |||
|
|
286ab3ba41 | ||
| 5ca2ad83cd | |||
|
|
e3c0750f7d | ||
| 177d75784b | |||
|
|
6e244e0c01 | ||
| 27878d0faf | |||
|
|
08d8a104bb | ||
| 7ee0cbc3f4 | |||
|
|
e5299cda5a | ||
| e5b192fcb3 | |||
|
|
cfcaf5c1d3 | ||
| 2731318c81 | |||
|
|
86407e6ca2 | ||
| 2266dd9ad5 | |||
|
|
0df14ab94a | ||
| 448a97d67f | |||
|
|
b699052324 | ||
| e6a55add20 | |||
|
|
fcf89618cd | ||
| f83c467647 | |||
|
|
80b2d7f8c3 | ||
| 8286255ae5 | |||
|
|
615ab25680 | ||
| 545cc74ec8 | |||
|
|
e5122c546b | ||
| 6737edbad2 | |||
|
|
ce98c2ada3 | ||
| 676eebd5e4 | |||
|
|
2b66cec582 | ||
| b751c1c096 | |||
|
|
316f820eff | ||
| 38eb909f69 | |||
|
|
d1699af609 | ||
| c6c694b69e | |||
|
|
4a3860ae92 | ||
| d57e24a7fa | |||
|
|
bb1ab47b68 | ||
| a04ba2af7a | |||
|
|
494fdf2358 | ||
| 9f1e033e83 | |||
|
|
fae00749ca | ||
| bf200e813e | |||
|
|
7209364c35 | ||
| 8314c273e7 | |||
|
|
1abf743a9f | ||
| 63a79791cd | |||
|
|
cc757855e6 | ||
| 84913638b1 | |||
|
|
9ec92a9082 | ||
| 49fc23adc6 | |||
|
|
3c2c4f29ea | ||
| ae7cc15178 | |||
|
|
3d9697b918 | ||
| 329e222aa2 | |||
|
|
551494d223 | ||
| 5b4925e61a | |||
|
|
4ff4cc5899 | ||
| b95eaacc05 | |||
|
|
c89f5bb3b9 | ||
| 07235d3b66 | |||
|
|
f2bc36349e | ||
| ccf2e3a9c0 | |||
|
|
8f7265186d | ||
| 651d6c005c | |||
|
|
36b2929780 | ||
| 345ac97c43 | |||
|
|
767ac4aec5 | ||
| 29edd835a3 | |||
|
|
d78a471e90 | ||
| 1d9e40236b | |||
|
|
2e6228a243 | ||
|
|
21e0fdd4cd | ||
|
|
5fc596a9a1 | ||
| 05d2a7fd00 | |||
|
|
95c7e0b490 | ||
| e1f172c053 | |||
|
|
6d290adb37 | ||
| cc8a6c9ec1 | |||
|
|
2ec6aa480e | ||
| 682c1c5e75 | |||
|
|
e8172f9452 | ||
| 3af746c4b6 | |||
|
|
7ba783de77 | ||
| 35d24c2f80 | |||
|
|
55245a962e | ||
| 16d9592a8a | |||
|
|
2666a598ae | ||
| 5834d62906 | |||
|
|
fe981b0b7f | ||
| 7b1c910806 | |||
|
|
a9b585ac5b | ||
| 097f92fdb8 | |||
|
|
8d92e00e38 | ||
| 1507486b45 | |||
|
|
adce4e7727 | ||
| 4446a3ce5b | |||
|
|
4dc685a365 | ||
| ff50aac59f | |||
|
|
b2065f8730 | ||
| 9020b5854c | |||
|
|
5dac2e9375 | ||
| b644b26310 | |||
|
|
012c6a4e7a | ||
| ae07fea630 | |||
|
|
c41831794a | ||
| 3e3c7206dd | |||
|
|
4e96f228b2 | ||
| 443474f58f | |||
|
|
dfe3731c73 | ||
| 6863cc4652 | |||
|
|
8221fac8c1 | ||
| bc44711dca | |||
|
|
acf31fd943 | ||
| 7e143e293b | |||
|
|
2cb22598d6 | ||
|
|
3d78033ea4 | ||
| 48a43ac96e | |||
|
|
98a8031772 | ||
| efdf04320a | |||
|
|
bb10ba7108 | ||
| 42f3b17c4a | |||
|
|
7352db28a6 | ||
| 8388ddc033 | |||
|
|
e11350cf80 | ||
| a5bd60768d | |||
|
|
d6a8bb1064 | ||
| f3053580a0 | |||
|
|
f64a8049d8 | ||
| c7f0855427 | |||
|
|
63b31e240e | ||
| 78f388b761 | |||
|
|
d78741cfdf | ||
| c08ae0d032 | |||
|
|
82e4e8c8de | ||
| 4e41f196b2 | |||
|
|
f0851af6b5 | ||
| 6df069b083 | |||
|
|
0687bb2e2d | ||
| 4d4f08af0d | |||
|
|
f1f53e1789 | ||
| e97db2d108 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -30,3 +30,10 @@ packages/
|
|||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
.local/
|
.local/
|
||||||
|
|
||||||
|
# LiteDB local config cache (Phase 6.1 Stream D — runtime artifact, not source)
|
||||||
|
src/ZB.MOM.WW.OtOpcUa.Server/config_cache.db
|
||||||
|
|
||||||
|
# E2E sidecar config — NodeIds are specific to each dev's local seed (see scripts/e2e/README.md)
|
||||||
|
scripts/e2e/e2e-config.json
|
||||||
|
config_cache*.db
|
||||||
|
|||||||
@@ -24,6 +24,13 @@
|
|||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.csproj"/>
|
||||||
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.csproj"/>
|
||||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Analyzers/ZB.MOM.WW.OtOpcUa.Analyzers.csproj"/>
|
<Project Path="src/ZB.MOM.WW.OtOpcUa.Analyzers/ZB.MOM.WW.OtOpcUa.Analyzers.csproj"/>
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
@@ -36,6 +43,7 @@
|
|||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/>
|
||||||
@@ -43,6 +51,12 @@
|
|||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests.csproj"/>
|
||||||
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj"/>
|
||||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj"/>
|
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj"/>
|
||||||
|
|||||||
@@ -67,6 +67,53 @@ Drivers that want hierarchical alarm subscriptions propagate `EventNotifier.Subs
|
|||||||
|
|
||||||
The OPC UA `ConditionRefresh` service queues the current state of every retained condition back to the requesting monitored items. `DriverNodeManager` iterates the node manager's `AlarmConditionState` collection and queues each condition whose `Retain.Value == true` — matching the Part 9 requirement.
|
The OPC UA `ConditionRefresh` service queues the current state of every retained condition back to the requesting monitored items. `DriverNodeManager` iterates the node manager's `AlarmConditionState` collection and queues each condition whose `Retain.Value == true` — matching the Part 9 requirement.
|
||||||
|
|
||||||
|
## Alarm historian sink
|
||||||
|
|
||||||
|
Distinct from the live `IAlarmSource` stream and the Part 9 `AlarmConditionState` materialization above, qualifying alarm transitions are **also** persisted to a durable event log for downstream AVEVA Historian ingestion. This is a separate subsystem from the `IHistoryProvider` capability used by `HistoryReadEvents` (see [HistoricalDataAccess.md](HistoricalDataAccess.md#alarm-event-history-vs-ihistoryprovider)): the sink is a *producer* path (server → Historian) that runs independently of any client HistoryRead call.
|
||||||
|
|
||||||
|
### `IAlarmHistorianSink`
|
||||||
|
|
||||||
|
`src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/IAlarmHistorianSink.cs` defines the intake contract:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken);
|
||||||
|
HistorianSinkStatus GetStatus();
|
||||||
|
```
|
||||||
|
|
||||||
|
`EnqueueAsync` is fire-and-forget from the producer's perspective — it must never block the emitting thread. The event payload (`AlarmHistorianEvent` — same file) is source-agnostic: `AlarmId`, `EquipmentPath`, `AlarmName`, `AlarmTypeName` (Part 9 subtype name), `Severity`, `EventKind` (free-form transition string — `Activated` / `Cleared` / `Acknowledged` / `Confirmed` / `Shelved` / …), `Message`, `User`, `Comment`, `TimestampUtc`.
|
||||||
|
|
||||||
|
The sink scope is defined to span every alarm source (plan decision #15: scripted, Galaxy-native, AB CIP ALMD, any future `IAlarmSource`), gated per-alarm by a `HistorizeToAveva` toggle on the producer. Today only `Phase7EngineComposer.RouteToHistorianAsync` (`src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs`) is wired — it subscribes to `ScriptedAlarmEngine.OnEvent` and marshals each emission into `AlarmHistorianEvent`. Galaxy-native alarms continue to reach AVEVA Historian via the driver's direct `aahClientManaged` path and do not flow through the sink; the AB CIP ALMD path remains unwired pending a producer-side integration.
|
||||||
|
|
||||||
|
### `SqliteStoreAndForwardSink`
|
||||||
|
|
||||||
|
Default production implementation (`src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs`). A local SQLite queue absorbs every `EnqueueAsync` synchronously; a background `Timer` drains batches asynchronously to an `IAlarmHistorianWriter` so operator actions are never blocked on historian reachability.
|
||||||
|
|
||||||
|
Queue schema (single table `Queue`): `RowId PK autoincrement`, `AlarmId`, `EnqueuedUtc`, `PayloadJson` (serialized `AlarmHistorianEvent`), `AttemptCount`, `LastAttemptUtc`, `LastError`, `DeadLettered` (bool), plus `IX_Queue_Drain (DeadLettered, RowId)`. Default capacity `1_000_000` non-dead-lettered rows; oldest rows evict with a WARN log past the cap.
|
||||||
|
|
||||||
|
Drain cadence: `StartDrainLoop(tickInterval)` arms a periodic timer. `DrainOnceAsync` reads up to `batchSize` rows (default 100) in `RowId` order and forwards them through `IAlarmHistorianWriter.WriteBatchAsync`, which returns one `HistorianWriteOutcome` per row:
|
||||||
|
|
||||||
|
| Outcome | Action |
|
||||||
|
|---|---|
|
||||||
|
| `Ack` | Row deleted. |
|
||||||
|
| `PermanentFail` | Row flipped to `DeadLettered = 1` with reason. Peers in the batch retry independently. |
|
||||||
|
| `RetryPlease` | `AttemptCount` bumped; row stays queued. Drain worker enters `BackingOff`. |
|
||||||
|
|
||||||
|
Writer-side exceptions treat the whole batch as `RetryPlease`.
|
||||||
|
|
||||||
|
Backoff ladder on `RetryPlease` (hard-coded): 1s → 2s → 5s → 15s → 60s cap. Reset to 0 on any batch with no retries. `CurrentBackoff` exposes the current step for instrumentation; the drain timer itself fires on `tickInterval`, so the ladder governs write cadence rather than timer period.
|
||||||
|
|
||||||
|
Dead-letter retention defaults to 30 days (plan decision #21). `PurgeAgedDeadLetters` runs each drain pass and deletes rows whose `LastAttemptUtc` is past the cutoff. `RetryDeadLettered()` is an operator action that clears `DeadLettered` + resets `AttemptCount` on every dead-lettered row so they rejoin the main queue.
|
||||||
|
|
||||||
|
### Composition and writer resolution
|
||||||
|
|
||||||
|
`Phase7Composer.ResolveHistorianSink` (`src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs`) scans the registered drivers for one that implements `IAlarmHistorianWriter`. Today that is `GalaxyProxyDriver` via `GalaxyHistorianWriter` (`src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Ipc/GalaxyHistorianWriter.cs`), which forwards batches over the Galaxy.Host pipe to the `aahClientManaged` alarm schema. When a writer is found, a `SqliteStoreAndForwardSink` is instantiated against `%ProgramData%/OtOpcUa/alarm-historian-queue.db` with a 2 s drain tick and the writer attached. When no driver provides a writer the fallback is the DI-registered `NullAlarmHistorianSink` (`src/ZB.MOM.WW.OtOpcUa.Server/Program.cs`), which silently discards and reports `HistorianDrainState.Disabled`.
|
||||||
|
|
||||||
|
### Status and observability
|
||||||
|
|
||||||
|
`GetStatus()` returns `HistorianSinkStatus(QueueDepth, DeadLetterDepth, LastDrainUtc, LastSuccessUtc, LastError, DrainState)` — two `COUNT(*)` scalars plus last-drain telemetry. `DrainState` is one of `Disabled` / `Idle` / `Draining` / `BackingOff`.
|
||||||
|
|
||||||
|
The Admin UI `/alarms/historian` page surfaces this through `HistorianDiagnosticsService` (`src/ZB.MOM.WW.OtOpcUa.Admin/Services/HistorianDiagnosticsService.cs`), which also exposes `TryRetryDeadLettered` — it calls through to `SqliteStoreAndForwardSink.RetryDeadLettered` when the live sink is the SQLite implementation and returns 0 otherwise.
|
||||||
|
|
||||||
## Key source files
|
## Key source files
|
||||||
|
|
||||||
- `src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs` — capability contract + `AlarmEventArgs`
|
- `src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs` — capability contract + `AlarmEventArgs`
|
||||||
@@ -74,3 +121,8 @@ The OPC UA `ConditionRefresh` service queues the current state of every retained
|
|||||||
- `src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — `CapturingBuilder` + alarm forwarder
|
- `src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — `CapturingBuilder` + alarm forwarder
|
||||||
- `src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs` — `VariableHandle.MarkAsAlarmCondition` + `ConditionSink`
|
- `src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs` — `VariableHandle.MarkAsAlarmCondition` + `ConditionSink`
|
||||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Alarms/GalaxyAlarmTracker.cs` — Galaxy-specific alarm-event production
|
- `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Alarms/GalaxyAlarmTracker.cs` — Galaxy-specific alarm-event production
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/IAlarmHistorianSink.cs` — historian sink intake contract + `AlarmHistorianEvent` + `HistorianSinkStatus` + `IAlarmHistorianWriter`
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs` — durable queue + drain worker + backoff ladder + dead-letter retention
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs` — `RouteToHistorianAsync` wires scripted-alarm emissions into the sink
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs` — `ResolveHistorianSink` selects `SqliteStoreAndForwardSink` vs `NullAlarmHistorianSink`
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Admin/Services/HistorianDiagnosticsService.cs` — Admin UI `/alarms/historian` status + retry-dead-lettered operator action
|
||||||
|
|||||||
@@ -183,19 +183,46 @@ otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
|
|||||||
| `--start` | Start time, ISO 8601 or date string (default: 24 hours ago) |
|
| `--start` | Start time, ISO 8601 or date string (default: 24 hours ago) |
|
||||||
| `--end` | End time, ISO 8601 or date string (default: now) |
|
| `--end` | End time, ISO 8601 or date string (default: now) |
|
||||||
| `--max` | Maximum number of values (default: 1000) |
|
| `--max` | Maximum number of values (default: 1000) |
|
||||||
| `--aggregate` | Aggregate function: Average, Minimum, Maximum, Count, Start, End |
|
| `--aggregate` | Aggregate function name (see catalog below). Case-insensitive. |
|
||||||
| `--interval` | Processing interval in milliseconds for aggregates (default: 3600000) |
|
| `--interval` | Processing interval in milliseconds for aggregates (default: 3600000) |
|
||||||
|
|
||||||
#### Aggregate mapping
|
#### Aggregate mapping
|
||||||
|
|
||||||
|
The CLI accepts the seven aggregates listed below — these are the
|
||||||
|
human-driven set the operator typically asks for from the command line.
|
||||||
|
|
||||||
| Name | OPC UA Node ID |
|
| Name | OPC UA Node ID |
|
||||||
|------|---------------|
|
|------|---------------|
|
||||||
| `Average` | `AggregateFunction_Average` |
|
| `Average` (or `avg`) | `AggregateFunction_Average` |
|
||||||
| `Minimum` | `AggregateFunction_Minimum` |
|
| `Minimum` (or `min`) | `AggregateFunction_Minimum` |
|
||||||
| `Maximum` | `AggregateFunction_Maximum` |
|
| `Maximum` (or `max`) | `AggregateFunction_Maximum` |
|
||||||
| `Count` | `AggregateFunction_Count` |
|
| `Count` | `AggregateFunction_Count` |
|
||||||
| `Start` | `AggregateFunction_Start` |
|
| `Start` (or `first`) | `AggregateFunction_Start` |
|
||||||
| `End` | `AggregateFunction_End` |
|
| `End` (or `last`) | `AggregateFunction_End` |
|
||||||
|
| `StandardDeviation` (or `stddev` / `stdev`) | `AggregateFunction_StandardDeviationSample` |
|
||||||
|
|
||||||
|
The driver-side `IHistoryProvider.ReadProcessedAsync` API (used by the
|
||||||
|
OtOpcUa server's HistoryRead facade) supports the full OPC UA Part 13 §5
|
||||||
|
catalog — ~30 aggregates including `TimeAverage`, `Interpolative`, `Range`,
|
||||||
|
`PercentGood`, `Delta`, etc. See
|
||||||
|
[`docs/drivers/OpcUaClient.md`](drivers/OpcUaClient.md#historyread-aggregates-part-13-catalog)
|
||||||
|
for the full list. Adding a new CLI shorthand is a one-line change in
|
||||||
|
`HistoryReadCommand.ParseAggregateType` — file an issue if you need one
|
||||||
|
exposed.
|
||||||
|
|
||||||
|
#### Event-mode coverage
|
||||||
|
|
||||||
|
Drivers that implement the filter-aware
|
||||||
|
`IHistoryProvider.ReadEventsAsync(fullReference, EventHistoryRequest, ct)`
|
||||||
|
overload (currently the OPC UA Client gateway driver — Galaxy keeps the
|
||||||
|
fixed-field fallback) honour `EventFilter` SelectClauses and a `WhereClause`
|
||||||
|
when the server-side history facade forwards them. The CLI does not yet
|
||||||
|
expose a dedicated `--events` flag — clients that need filter-aware event
|
||||||
|
history call `HistoryReadEvents` through their own SDK; the CLI's
|
||||||
|
`historyread` command stays focused on the data-history (Raw / Processed /
|
||||||
|
AtTime) path. Adding `--events` is tracked as a follow-up — the wire path
|
||||||
|
on the driver side is in place (see
|
||||||
|
[`docs/drivers/OpcUaClient.md`](drivers/OpcUaClient.md#historyread-events)).
|
||||||
|
|
||||||
### alarms
|
### alarms
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ The driver's mapping is authoritative — when a field type is ambiguous (a `LRE
|
|||||||
|
|
||||||
## SecurityClassification — metadata, not ACL
|
## SecurityClassification — metadata, not ACL
|
||||||
|
|
||||||
`SecurityClassification` is driver-reported metadata only. Drivers never enforce write permissions themselves — the classification flows into the Server project where `WriteAuthzPolicy.IsAllowed(classification, userRoles)` (`src/ZB.MOM.WW.OtOpcUa.Server/Security/WriteAuthzPolicy.cs`) gates the write against the session's LDAP-derived roles, and (Phase 6.2) the `AuthorizationGate` + permission trie apply on top. This is the "ACL at server layer" invariant recorded in `feedback_acl_at_server_layer.md`.
|
`SecurityClassification` is driver-reported metadata only. Drivers never enforce write permissions themselves — the classification flows into the Server project where `WriteAuthzPolicy.IsAllowed(classification, userRoles)` (`src/ZB.MOM.WW.OtOpcUa.Server/Security/WriteAuthzPolicy.cs`) gates the write against the session's LDAP-derived roles, and (Phase 6.2) the `AuthorizationGate` + permission trie apply on top. This is the "ACL at server layer" invariant documented in `docs/security.md`.
|
||||||
|
|
||||||
The classification values mirror the v1 Galaxy model so existing Galaxy galaxies keep their published semantics:
|
The classification values mirror the v1 Galaxy model so existing Galaxy galaxies keep their published semantics:
|
||||||
|
|
||||||
|
|||||||
192
docs/Driver.AbCip.Cli.md
Normal file
192
docs/Driver.AbCip.Cli.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# `otopcua-abcip-cli` — AB CIP test client
|
||||||
|
|
||||||
|
Ad-hoc probe / read / write / subscribe tool for ControlLogix / CompactLogix /
|
||||||
|
Micro800 / GuardLogix PLCs, talking to the **same** `AbCipDriver` the OtOpcUa
|
||||||
|
server uses (libplctag under the hood).
|
||||||
|
|
||||||
|
Second of four driver test-client CLIs (Modbus → AB CIP → AB Legacy → S7 →
|
||||||
|
TwinCAT). Shares `Driver.Cli.Common` with the others.
|
||||||
|
|
||||||
|
## Build + run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common flags
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `-g` / `--gateway` | **required** | Canonical `ab://host[:port]/cip-path` |
|
||||||
|
| `-f` / `--family` | `ControlLogix` | ControlLogix / CompactLogix / Micro800 / GuardLogix |
|
||||||
|
| `--timeout-ms` | `5000` | Per-operation timeout |
|
||||||
|
| `--addressing-mode` | `Auto` | `Auto` / `Symbolic` / `Logical` — see [AbCip-Performance §Addressing mode](drivers/AbCip-Performance.md#addressing-mode). `Logical` against Micro800 silently falls back to Symbolic with a warning. |
|
||||||
|
| `--partner` | _(unset)_ | PR abcip-5.1 — partner gateway URI for a ControlLogix HSBY pair (e.g. `ab://10.0.0.6/1,0`). When set, the driver runs a second role-probe loop against the partner and the [`hsby-status`](#hsby-status--which-chassis-is-active-now) command can surface which chassis is currently Active. See [AbCip-HSBY.md](drivers/AbCip-HSBY.md) for the full guide. |
|
||||||
|
| `--verbose` | off | Serilog debug output |
|
||||||
|
|
||||||
|
Family ↔ CIP-path cheat sheet:
|
||||||
|
- **ControlLogix / CompactLogix / GuardLogix** — `1,0` (slot 0 of chassis)
|
||||||
|
- **Micro800** — empty path, just `ab://host/`
|
||||||
|
- **Sub-slot Logix** (rare) — `1,3` for slot 3
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `probe` — is the PLC up?
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# ControlLogix — read the canonical libplctag system tag
|
||||||
|
otopcua-abcip-cli probe -g ab://10.0.0.5/1,0 -t @raw_cpu_type --type DInt
|
||||||
|
|
||||||
|
# Micro800 — point at a user-supplied global
|
||||||
|
otopcua-abcip-cli probe -g ab://10.0.0.6/ -f Micro800 -t _SYSVA_CLOCK_HOUR --type DInt
|
||||||
|
```
|
||||||
|
|
||||||
|
### `read` — single Logix tag
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Controller scope
|
||||||
|
otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t Motor01_Speed --type Real
|
||||||
|
|
||||||
|
# Program scope
|
||||||
|
otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t "Program:Main.Counter" --type DInt
|
||||||
|
|
||||||
|
# Array element
|
||||||
|
otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t "Recipe[3]" --type Real
|
||||||
|
|
||||||
|
# UDT member (dotted path)
|
||||||
|
otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t "Motor01.Speed" --type Real
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Diagnostic / system tags
|
||||||
|
|
||||||
|
PR abcip-4.3 exposes five read-only diagnostic variables per device under
|
||||||
|
`AbCip/<device>/_System/` in the OPC UA address space (see
|
||||||
|
[AbCip-Operability §System tags](drivers/AbCip-Operability.md#system-tags--_system-folder)
|
||||||
|
for the full table). These are not reachable through the AB CIP CLI — they
|
||||||
|
live on the OPC UA server side, not the libplctag wire — so to read one,
|
||||||
|
point the **OPC UA client** CLI at the running OtOpcUa server:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Read _ConnectionStatus for one device through the OPC UA server
|
||||||
|
otopcua-client-cli read -u opc.tcp://localhost:4840 \
|
||||||
|
-n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System/_ConnectionStatus"
|
||||||
|
```
|
||||||
|
|
||||||
|
### `write` — single Logix tag
|
||||||
|
|
||||||
|
Same shape as `read` plus `-v`. Values parse per `--type` using invariant
|
||||||
|
culture. Booleans accept `true`/`false`/`1`/`0`/`yes`/`no`/`on`/`off`.
|
||||||
|
Structure (UDT) writes need the member layout declared in a real driver config
|
||||||
|
and are refused by the CLI.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-abcip-cli write -g ab://10.0.0.5/1,0 -t Motor01_Speed --type Real -v 3.14
|
||||||
|
otopcua-abcip-cli write -g ab://10.0.0.5/1,0 -t StartCommand --type Bool -v true
|
||||||
|
```
|
||||||
|
|
||||||
|
### `subscribe` — watch a tag until Ctrl+C
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-abcip-cli subscribe -g ab://10.0.0.5/1,0 -t Motor01_Speed --type Real -i 500
|
||||||
|
```
|
||||||
|
|
||||||
|
### `hsby-status` — which chassis is Active now?
|
||||||
|
|
||||||
|
PR abcip-5.1 — read the role tag (`WallClockTime.SyncStatus` by default,
|
||||||
|
`S:34` for legacy SLC500 / PLC-5 fronts) on a ControlLogix HSBY pair and
|
||||||
|
print which chassis is currently Active. Requires `--partner`.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-abcip-cli hsby-status -g ab://10.0.0.5/1,0 --partner ab://10.0.0.6/1,0
|
||||||
|
|
||||||
|
# Custom role tag (legacy fronts) and more samples
|
||||||
|
otopcua-abcip-cli hsby-status -g ab://10.0.0.5/1,0 --partner ab://10.0.0.6/1,0 \
|
||||||
|
--role-tag S:34 --samples 5
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `--role-tag` | `WallClockTime.SyncStatus` | Address of the role tag. Use `S:34` for SLC500 / PLC-5. |
|
||||||
|
| `--samples` | `3` | Number of role-probe ticks to wait for before printing. |
|
||||||
|
|
||||||
|
The output prints the resolved roles + the address of whichever chassis the
|
||||||
|
driver currently considers Active. PR abcip-5.1 only **reports** the role —
|
||||||
|
PR abcip-5.2 will land the routing change so reads / writes flow to the
|
||||||
|
Active chassis automatically.
|
||||||
|
|
||||||
|
See [AbCip-HSBY.md](drivers/AbCip-HSBY.md) for the role-tag detection matrix
|
||||||
|
+ active-resolution rules + the feature-flag gate.
|
||||||
|
|
||||||
|
### `rebrowse` — force a controller-side `@tags` re-walk
|
||||||
|
|
||||||
|
PR abcip-2.5 (issue #233) added `RebrowseAsync` to drop the cached UDT
|
||||||
|
template shapes and re-run the symbol-table enumerator without restarting
|
||||||
|
the driver. The CLI variant builds a transient driver against the supplied
|
||||||
|
gateway, runs the rebrowse, and prints the freshly discovered tag names —
|
||||||
|
useful after a controller program-download to confirm the new tags are
|
||||||
|
visible on the wire before wiring them through the OtOpcUa server.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-abcip-cli rebrowse -g ab://10.0.0.5/1,0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Refreshing the tag DB
|
||||||
|
|
||||||
|
Two operator-facing surfaces drive the same `RebrowseAsync` plumbing — pick
|
||||||
|
the one that matches your context:
|
||||||
|
|
||||||
|
| Surface | When to use | Command |
|
||||||
|
|---|---|---|
|
||||||
|
| **CLI `rebrowse`** | Off-server validation. Spins up a transient driver against the gateway, prints the discovered tag list, no shared state with the live OtOpcUa server. | `otopcua-abcip-cli rebrowse -g ab://10.0.0.5/1,0` |
|
||||||
|
| **OPC UA write to `_RefreshTagDb`** | Production / Admin-UI button (PR abcip-4.4). Forces the **live** driver to re-walk + clear its template cache. The `AbCip.RefreshTriggers` driver-diagnostics counter increments per truthy write. | `otopcua-client-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System/_RefreshTagDb" -v true --type Boolean` |
|
||||||
|
|
||||||
|
Read-back semantics: `_RefreshTagDb` always reads back as `false` (Kepware-
|
||||||
|
style "latches to idle the moment the dispatch returns") so a subscribed
|
||||||
|
client sees a stable shape regardless of how many refreshes have fired.
|
||||||
|
Falsy / unparseable writes are no-ops that still report `Good` so a UI
|
||||||
|
template that resets the trigger flag after firing it doesn't see a phantom
|
||||||
|
error. See
|
||||||
|
[AbCip-Operability §System tags](drivers/AbCip-Operability.md#refreshing-the-tag-db-via-opc-ua-write)
|
||||||
|
for the full semantics + the diagnostics counter wiring.
|
||||||
|
|
||||||
|
## Typical workflows
|
||||||
|
|
||||||
|
- **"Is the PLC reachable?"** → `probe`.
|
||||||
|
- **"Did my recipe write land?"** → `write` + `read` back.
|
||||||
|
- **"Why is tag X flipping?"** → `subscribe`.
|
||||||
|
- **"Is this GuardLogix safety tag writable from non-safety?"** → `write` and
|
||||||
|
read the status code — safety tags surface `BadNotWritable` / CIP errors,
|
||||||
|
non-safety tags surface `Good`.
|
||||||
|
- **"Did my program download show up in the address space?"** → `rebrowse`
|
||||||
|
(off-server) or write `true` to the live server's `_RefreshTagDb` system
|
||||||
|
tag (in-server, PR abcip-4.4) — both drop the template cache + force a
|
||||||
|
fresh `@tags` walk.
|
||||||
|
|
||||||
|
## Connection Size
|
||||||
|
|
||||||
|
PR abcip-3.1 introduced a per-device `ConnectionSize` override on the driver
|
||||||
|
side (`AbCipDeviceOptions.ConnectionSize`, range `500..4002`). The CLI does
|
||||||
|
not expose a flag for it — every CLI invocation uses the family-default
|
||||||
|
Connection Size (4002 / 504 / 488 depending on `--family`). When a Forward
|
||||||
|
Open is rejected with a CIP error like `0x01/0x113` ("connection request
|
||||||
|
size invalid"), the symptom is almost always a **mismatch between the chosen
|
||||||
|
family default and the controller firmware**:
|
||||||
|
|
||||||
|
- **v19-and-earlier ControlLogix** caps at 504 — pick `--family CompactLogix`
|
||||||
|
on the CLI to fall back to that narrower default.
|
||||||
|
- **5069-L1/L2/L3 CompactLogix** narrow-buffer parts also cap at 504, which
|
||||||
|
is the family default already.
|
||||||
|
- **FW20+ ControlLogix** accepts the full 4002.
|
||||||
|
|
||||||
|
For the warning *"AbCip device 'X' family 'Y' uses a narrow-buffer profile
|
||||||
|
(default ConnectionSize Z); the configured ConnectionSize N exceeds the
|
||||||
|
511-byte legacy-firmware cap..."* see
|
||||||
|
[`docs/drivers/AbCip-Performance.md`](drivers/AbCip-Performance.md) — that
|
||||||
|
warning is fired by the driver host, not the CLI.
|
||||||
|
|
||||||
|
## Related operability knobs
|
||||||
|
|
||||||
|
- [`docs/drivers/AbCip-Operability.md`](drivers/AbCip-Operability.md) — Phase 4
|
||||||
|
per-tag knobs (per-tag scan rate, deadband, etc). The CLI does not expose
|
||||||
|
these knobs directly; they're set in driver config JSON and consumed by the
|
||||||
|
driver at subscribe time.
|
||||||
297
docs/Driver.AbLegacy.Cli.md
Normal file
297
docs/Driver.AbLegacy.Cli.md
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
# `otopcua-ablegacy-cli` — AB Legacy (PCCC) test client
|
||||||
|
|
||||||
|
Ad-hoc probe / read / write / subscribe tool for SLC 500 / MicroLogix 1100 /
|
||||||
|
MicroLogix 1400 / PLC-5 devices, talking to the **same** `AbLegacyDriver` the
|
||||||
|
OtOpcUa server uses (libplctag PCCC back-end).
|
||||||
|
|
||||||
|
Third of four driver test-client CLIs. Shares `Driver.Cli.Common` with the
|
||||||
|
others.
|
||||||
|
|
||||||
|
## Build + run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common flags
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `-g` / `--gateway` | **required** | Canonical `ab://host[:port]/cip-path` |
|
||||||
|
| `-P` / `--plc-type` | `Slc500` | Slc500 / MicroLogix / Plc5 / LogixPccc |
|
||||||
|
| `--timeout-ms` | `5000` | Per-operation timeout — see precedence note below |
|
||||||
|
| `--retries` | `0` | Retry count on transient `BadCommunicationError` (PR 9 / #252) |
|
||||||
|
| `--demote-failure-threshold` | `3` | **PR ablegacy-12 / #255** — consecutive comm failures before the device is auto-demoted |
|
||||||
|
| `--demote-for-ms` | `30000` | **PR ablegacy-12 / #255** — auto-demote cool-down window in ms |
|
||||||
|
| `--no-demote` | off | **PR ablegacy-12 / #255** — disable auto-demote entirely (counters still tick) |
|
||||||
|
| `--verbose` | off | Serilog debug output |
|
||||||
|
|
||||||
|
Family ↔ CIP-path cheat sheet:
|
||||||
|
- **SLC 5/05 / PLC-5** — `1,0`
|
||||||
|
- **MicroLogix 1100 / 1400** — empty path (`ab://host/`) — they use direct EIP
|
||||||
|
with no backplane
|
||||||
|
- **LogixPccc** — `1,0` (Logix controller accessed via the PCCC compatibility
|
||||||
|
layer; rare)
|
||||||
|
- **PLC-5 via 1756-DHRIO bridge** — `1,<slot>,2,<station-octal>` (PLC-5 only).
|
||||||
|
See [drivers/AbLegacy-DH-Bridging.md](drivers/AbLegacy-DH-Bridging.md) for
|
||||||
|
the full DH+ syntax, octal-station reference (00..77 = 0..63), and manual
|
||||||
|
hardware smoke procedure.
|
||||||
|
|
||||||
|
#### DHRIO worked example (PR ablegacy-13 / #256)
|
||||||
|
|
||||||
|
PLC-5 on DH+ node 7 (octal 07), DHRIO module in chassis slot 3,
|
||||||
|
EtherNet/IP gateway 192.168.1.10:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-ablegacy-cli read `
|
||||||
|
-g ab://192.168.1.10/1,3,2,07 `
|
||||||
|
-P Plc5 -a N7:10 -t Int
|
||||||
|
```
|
||||||
|
|
||||||
|
The parser validates `1,<slot>,2,<station>`: port-1 must be the backplane,
|
||||||
|
slot must be 0..16, port-3 must be `2` (DH+), station must be octal 0..77 (so
|
||||||
|
`80`, `90`, etc. are rejected). Combining a DH+ bridge path with a non-PLC-5
|
||||||
|
family at startup throws `InvalidOperationException("DHRIO bridging is
|
||||||
|
PLC-5-only")`.
|
||||||
|
|
||||||
|
### Per-device timeout / retry tuning (#252, PR 9)
|
||||||
|
|
||||||
|
The CLI's `--timeout-ms` is the **driver-wide default** when launched as a
|
||||||
|
one-shot test client. In production (server-side, multi-device deployment)
|
||||||
|
each `AbLegacyDeviceOptions` row carries its own optional `Timeout` /
|
||||||
|
`Retries` that override the driver-wide value.
|
||||||
|
|
||||||
|
Precedence (highest → lowest): per-device override → driver-wide default →
|
||||||
|
hard-coded fallback (2000 ms / 0 retries).
|
||||||
|
|
||||||
|
Tuning cheat sheet — start here, measure, then trim:
|
||||||
|
|
||||||
|
| Family | Recommended `Timeout` | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| SLC 5/01 (RS-232 / DH+ bridge) | **5000 ms** | Slowest of the bunch; serial round-trip plus DH+ hop |
|
||||||
|
| SLC 5/02 / 5/03 (DH+) | 3000 ms | Bridged Ethernet → DH+ adds ~1 s |
|
||||||
|
| **SLC 5/04 / 5/05** (Ethernet) | **2000 ms** | Fastest of the SLC family — direct EIP/PCCC |
|
||||||
|
| MicroLogix 1100 / 1400 | **3000 ms** | Single-CPU, slow scan; no backplane |
|
||||||
|
| PLC-5 (Ethernet I/F) | 2500 ms | Comparable to SLC 5/05 over EIP |
|
||||||
|
| LogixPccc compat layer | 2000 ms | Logix CPU is fast; PCCC layer is the floor |
|
||||||
|
|
||||||
|
A small `--retries 1` (or `2` for slow chassis) is generally safe — the retry
|
||||||
|
loop only fires on transient `BadCommunicationError`; terminal errors
|
||||||
|
(`BadNodeIdUnknown`, `BadTypeMismatch`, …) surface on the first attempt.
|
||||||
|
|
||||||
|
## PCCC address primer
|
||||||
|
|
||||||
|
File letters imply data type; type flag still required so the CLI knows how to
|
||||||
|
parse your `--value`.
|
||||||
|
|
||||||
|
| File | Type | CLI `--type` |
|
||||||
|
|---|---|---|
|
||||||
|
| `N` | signed int16 | `Int` |
|
||||||
|
| `F` | float32 | `Float` |
|
||||||
|
| `B` | bit-packed (`B3:0/3` addresses bit 3 of word 0) | `Bit` |
|
||||||
|
| `L` | long int32 (SLC 5/05+ only) | `Long` |
|
||||||
|
| `A` | analog int (semantically like N) | `AnalogInt` |
|
||||||
|
| `ST` | ASCII string (82-byte + length header) | `String` |
|
||||||
|
| `T` | timer sub-element (`T4:0.ACC` / `.PRE` / `.EN` / `.DN`) | `TimerElement` |
|
||||||
|
| `C` | counter sub-element (`C5:0.ACC` / `.PRE` / `.CU` / `.CD` / `.DN`) | `CounterElement` |
|
||||||
|
| `R` | control sub-element (`R6:0.LEN` / `.POS` / `.EN` / `.DN` / `.ER`) | `ControlElement` |
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `probe`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# SLC 5/05 — default probe address N7:0
|
||||||
|
otopcua-ablegacy-cli probe -g ab://192.168.1.20/1,0
|
||||||
|
|
||||||
|
# MicroLogix 1100 — status file first word
|
||||||
|
otopcua-ablegacy-cli probe -g ab://192.168.1.30/ -P MicroLogix -a S:0
|
||||||
|
```
|
||||||
|
|
||||||
|
`probe` output (PR ablegacy-12 / #255) reports both `Health` (driver health
|
||||||
|
state) and `Host state`. The latter is sourced from `IHostConnectivityProbe`
|
||||||
|
and surfaces `Demoted` when the auto-demote threshold has tripped — a fast
|
||||||
|
visual signal that the CLI is short-circuiting future reads against this
|
||||||
|
device until the cool-down expires:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Gateway: ab://192.168.1.20/1,0
|
||||||
|
PLC type: Slc500
|
||||||
|
Health: Degraded
|
||||||
|
Host state: Demoted
|
||||||
|
Last error: libplctag status -33 reading N7:0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auto-demote knobs
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Trip after just one comm failure, hold for 60s.
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a N7:0 -t Int `
|
||||||
|
--demote-failure-threshold 1 --demote-for-ms 60000
|
||||||
|
|
||||||
|
# Opt out of auto-demote — stresses the link without short-circuiting.
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a N7:0 -t Int --no-demote
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI is a one-shot test client — auto-demote primarily matters in the
|
||||||
|
server-side multi-device deployment, where a single demoted PLC can no
|
||||||
|
longer block reads against its healthy peers. Use the CLI flags to
|
||||||
|
reproduce a flapping-link scenario locally before tuning the server-side
|
||||||
|
`appsettings.json` `Demote` block.
|
||||||
|
|
||||||
|
### `read`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Integer
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a N7:10 -t Int
|
||||||
|
|
||||||
|
# Float
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a F8:0 -t Float
|
||||||
|
|
||||||
|
# Bit-within-word
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a B3:0/3 -t Bit
|
||||||
|
|
||||||
|
# Long (SLC 5/05+)
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a L19:0 -t Long
|
||||||
|
|
||||||
|
# Timer ACC
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a T4:0.ACC -t TimerElement
|
||||||
|
|
||||||
|
# Diagnostic counter (PR ablegacy-10 / #253). The seven _Diagnostics/<name>
|
||||||
|
# addresses live alongside user tags — short-circuit serves them straight from
|
||||||
|
# the in-process counter store, so no PCCC frame is sent to the PLC.
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 --address _Diagnostics/RequestCount
|
||||||
|
```
|
||||||
|
|
||||||
|
The diagnostic surface auto-emits per device — no config required. See
|
||||||
|
`docs/drivers/AbLegacy-Diagnostics.md` for the full counter table + reset
|
||||||
|
semantics + collision-rejection rules.
|
||||||
|
|
||||||
|
### `write`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-ablegacy-cli write -g ab://192.168.1.20/1,0 -a N7:10 -t Int -v 42
|
||||||
|
otopcua-ablegacy-cli write -g ab://192.168.1.20/1,0 -a F8:0 -t Float -v 3.14
|
||||||
|
otopcua-ablegacy-cli write -g ab://192.168.1.20/1,0 -a B3:0/3 -t Bit -v on
|
||||||
|
```
|
||||||
|
|
||||||
|
Writes to timer / counter / control sub-elements land at the wire level but
|
||||||
|
the PLC's runtime semantics (EN/DN edge-triggering, preset reload) are
|
||||||
|
PLC-managed — use with caution.
|
||||||
|
|
||||||
|
### `subscribe`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a N7:10 -t Int -i 500
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Deadband
|
||||||
|
|
||||||
|
PR 8 — per-tag absolute / percent change filter on top of the polled subscription. The driver
|
||||||
|
caches the last *published* value per tag and suppresses `OnDataChange` notifications until the
|
||||||
|
new sample crosses the configured threshold.
|
||||||
|
|
||||||
|
| Flag | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `--deadband-absolute <value>` | Suppress until `|new - prev| >= value`. |
|
||||||
|
| `--deadband-percent <value>` | Suppress until `|new - prev| >= |prev * value / 100|`. `prev == 0` always publishes (avoids div-by-zero). |
|
||||||
|
|
||||||
|
Booleans bypass the filter entirely (every transition publishes); strings + status changes
|
||||||
|
always publish; first-seen always publishes; both flags set → either passing triggers a
|
||||||
|
publish (Kepware-style logical OR).
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Float — drop sub-0.5 jitter from the noisy load-cell address.
|
||||||
|
otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a F8:0 -t Float -i 500 `
|
||||||
|
--deadband-absolute 0.5
|
||||||
|
|
||||||
|
# Integer — only fire on >= 5% deviation from the last reported value.
|
||||||
|
otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a N7:10 -t Int -i 500 `
|
||||||
|
--deadband-percent 5
|
||||||
|
```
|
||||||
|
|
||||||
|
## Array reads
|
||||||
|
|
||||||
|
PR 7 — one PCCC frame can carry up to ~120 words. Address an array tag with either the
|
||||||
|
Rockwell-native `,N` suffix or the libplctag-native `[N]` suffix on the word number; both
|
||||||
|
forms canonicalise to `[N]` when the driver hands the tag to libplctag, and the parser
|
||||||
|
caps `N` at 120.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Rockwell `,N` form — "10 consecutive words starting at N7:0"
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "N7:0,10" -t Int
|
||||||
|
|
||||||
|
# libplctag `[N]` form — same wire result
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "N7:0[10]" -t Int
|
||||||
|
|
||||||
|
# Float / Long arrays — same suffix syntax, narrower frame ceiling on Float (~60 elements)
|
||||||
|
# and Long (~60 elements) because each element is 4 bytes vs Int's 2.
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "F8:0,4" -t Float
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "L19:0,4" -t Long
|
||||||
|
|
||||||
|
# --array-length override — pin the element count from config rather than the address
|
||||||
|
# suffix. Wins over the parsed `,N` / `[N]` value when both are set; useful for keeping the
|
||||||
|
# address string compact while bumping the element count from a tags config file.
|
||||||
|
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "N7:0" --array-length 10 -t Int
|
||||||
|
```
|
||||||
|
|
||||||
|
Array tags reject sub-element references (`T4:0,5.ACC`) and bit suffixes (`N7:0,10/3`) at
|
||||||
|
parse time — both combinations are semantically meaningless against a contiguous block.
|
||||||
|
|
||||||
|
For `B`-files the Rockwell convention is "one BOOL per word, not per bit": `B3:0,10`
|
||||||
|
returns `bool[10]` (one per word's non-zero state), not `bool[160]`.
|
||||||
|
|
||||||
|
### `import-rslogix`
|
||||||
|
|
||||||
|
ablegacy-11 / [#254](https://github.com/dohertj2/lmxopcua/issues/254) — bulk-import RSLogix
|
||||||
|
500 / 5 CSV symbol exports into an `appsettings.json` tag fragment. Avoids hand-typing every
|
||||||
|
`N7:0` / `F8:12` / `B3:0/5` row of a several-hundred-tag PLC. Binary `.RSS` / `.RSP` project
|
||||||
|
files are out of scope; export to CSV first.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Default: emit JSON fragment to stdout
|
||||||
|
otopcua-ablegacy-cli import-rslogix `
|
||||||
|
--file C:\plc\plc-export.csv `
|
||||||
|
--device ab://192.168.1.20/1,0
|
||||||
|
|
||||||
|
# Write the fragment to a file + print a summary line to stdout
|
||||||
|
otopcua-ablegacy-cli import-rslogix `
|
||||||
|
--file C:\plc\plc-export.csv `
|
||||||
|
--device ab://192.168.1.20/1,0 `
|
||||||
|
--output tags.json
|
||||||
|
|
||||||
|
# Filter by Scope column — only import Local:1 program-scoped tags
|
||||||
|
otopcua-ablegacy-cli import-rslogix `
|
||||||
|
--file C:\plc\plc-export.csv `
|
||||||
|
--device ab://192.168.1.20/1,0 `
|
||||||
|
--scope Local:1
|
||||||
|
|
||||||
|
# Summary mode — one-line counter for CI / health checks
|
||||||
|
otopcua-ablegacy-cli import-rslogix `
|
||||||
|
--file C:\plc\plc-export.csv `
|
||||||
|
--device ab://192.168.1.20/1,0 `
|
||||||
|
--emit summary
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `-f` / `--file` | **required** | RSLogix CSV path |
|
||||||
|
| `-d` / `--device` | **required** | `ab://host[:port]/cip-path` every imported tag binds to |
|
||||||
|
| `--emit` | `appsettings-fragment` | `appsettings-fragment` (JSON) or `summary` (one-line counter) |
|
||||||
|
| `-o` / `--output` | stdout | Optional output file path |
|
||||||
|
| `--scope` | none | Scope filter — `Global` / `Local:N` (case-insensitive); empty Scope counts as Global |
|
||||||
|
| `--max-rows` | unlimited | Defensive cap on rows imported |
|
||||||
|
| `--strict` | off | Fail-fast on first malformed row (default permissive: skip + log) |
|
||||||
|
|
||||||
|
See [drivers/AbLegacy-RSLogix-Import.md](drivers/AbLegacy-RSLogix-Import.md) for the full
|
||||||
|
column reference, file-letter → `AbLegacyDataType` mapping, and the API surface
|
||||||
|
(`IRsLogixImporter`, `AbLegacyDriverOptions.AddRsLogixImport`).
|
||||||
|
|
||||||
|
## Known caveat — ab_server upstream gap
|
||||||
|
|
||||||
|
The integration-fixture `ab_server` Docker container accepts TCP but its PCCC
|
||||||
|
dispatcher doesn't actually respond — see
|
||||||
|
[`tests/...AbLegacy.IntegrationTests/Docker/README.md`](../tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md).
|
||||||
|
Point `--gateway` at real hardware or an RSEmulate 500 box for end-to-end
|
||||||
|
wire-level validation. The CLI itself is correct regardless of which endpoint
|
||||||
|
you target.
|
||||||
217
docs/Driver.FOCAS.Cli.md
Normal file
217
docs/Driver.FOCAS.Cli.md
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
# `otopcua-focas-cli` — Fanuc FOCAS test client
|
||||||
|
|
||||||
|
Ad-hoc probe / read / write / subscribe tool for Fanuc CNCs via the FOCAS/2
|
||||||
|
protocol. Uses the **same** `FocasDriver` the OtOpcUa server does — PMC R/G/F
|
||||||
|
file registers, axis bits, parameters, and macro variables — all through
|
||||||
|
`FocasAddressParser` syntax.
|
||||||
|
|
||||||
|
Sixth of the driver test-client CLIs, added alongside the Tier-C isolation
|
||||||
|
work tracked in task #220.
|
||||||
|
|
||||||
|
## Architecture note
|
||||||
|
|
||||||
|
FOCAS is a Tier-C driver: `Fwlib32.dll` is a proprietary 32-bit Fanuc library
|
||||||
|
with a documented habit of crashing its hosting process on network errors.
|
||||||
|
The target runtime deployment splits the driver into an in-process
|
||||||
|
`FocasProxyDriver` (.NET 10 x64) and an out-of-process `Driver.FOCAS.Host`
|
||||||
|
(.NET 4.8 x86 Windows service) that owns the DLL — see
|
||||||
|
[v2/implementation/focas-isolation-plan.md](v2/implementation/focas-isolation-plan.md)
|
||||||
|
and
|
||||||
|
[v2/implementation/phase-6-1-resilience-and-observability.md](v2/implementation/phase-6-1-resilience-and-observability.md)
|
||||||
|
for topology + supervisor / respawn / back-pressure design.
|
||||||
|
|
||||||
|
The CLI skips the proxy and loads `FocasDriver` directly (via
|
||||||
|
`FwlibFocasClientFactory`, which P/Invokes `Fwlib32.dll` in the CLI's own
|
||||||
|
process). There is **no public simulator** for FOCAS; a meaningful probe
|
||||||
|
requires a real CNC + a licensed `Fwlib32.dll` on `PATH` (or next to the
|
||||||
|
executable). On a dev box without the DLL, every wire call surfaces as
|
||||||
|
`BadCommunicationError` — still useful as a "CLI wire-up is correct" signal.
|
||||||
|
|
||||||
|
## Build + run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli
|
||||||
|
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Or publish a self-contained binary:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet publish src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli -c Release -o publish/focas-cli
|
||||||
|
publish/focas-cli/otopcua-focas-cli.exe --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common flags
|
||||||
|
|
||||||
|
Every command accepts:
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `-h` / `--cnc-host` | **required** | CNC IP address or hostname |
|
||||||
|
| `-p` / `--cnc-port` | `8193` | FOCAS TCP port (FOCAS-over-EIP default) |
|
||||||
|
| `-s` / `--series` | `Unknown` | CNC series — `Unknown` / `Zero_i_D` / `Zero_i_F` / `Zero_i_MF` / `Zero_i_TF` / `Sixteen_i` / `Thirty_i` / `ThirtyOne_i` / `ThirtyTwo_i` / `PowerMotion_i` |
|
||||||
|
| `--timeout-ms` | `2000` | Per-operation timeout |
|
||||||
|
| `--cnc-password` | (none) | **F4-d (issue #271)** — optional CNC connection-level password emitted via `cnc_wrunlockparam` on connect. Required only by controllers that gate parameter writes / selected reads behind a password switch (16i + some 30i firmwares with parameter-protect on). **PASSWORD INVARIANT: never logged.** The CLI's Serilog config does not destructure this flag and `FocasDeviceOptions.ToString` redacts the value. See [`v2/focas-deployment.md`](v2/focas-deployment.md) § "FOCAS password handling". |
|
||||||
|
| `--verbose` | off | Serilog debug output |
|
||||||
|
|
||||||
|
## Addressing
|
||||||
|
|
||||||
|
`FocasAddressParser` syntax — the same format the server + `FocasTagDefinition`
|
||||||
|
use. Common shapes:
|
||||||
|
|
||||||
|
| Address | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `R100` | PMC R-file word register 100 |
|
||||||
|
| `X0.0` | PMC X-file bit 0 of byte 0 |
|
||||||
|
| `G50.3` | PMC G-file bit 3 of byte 50 |
|
||||||
|
| `F1.4` | PMC F-file bit 4 of byte 1 |
|
||||||
|
| `PARAM:1815/0` | Parameter 1815, axis 0 |
|
||||||
|
| `MACRO:500` | Macro variable 500 |
|
||||||
|
|
||||||
|
## Data types
|
||||||
|
|
||||||
|
`Bit`, `Byte`, `Int16`, `Int32`, `Float32`, `Float64`, `String`. Default is
|
||||||
|
`Int16` (matches PMC R-file word width).
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `probe` — is the CNC reachable?
|
||||||
|
|
||||||
|
Opens a FOCAS session, reads one sample address, prints driver health.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Default: read R100 as Int16
|
||||||
|
otopcua-focas-cli probe -h 192.168.1.50
|
||||||
|
|
||||||
|
# Explicit series + address
|
||||||
|
otopcua-focas-cli probe -h 192.168.1.50 -s ThirtyOne_i --address R200 --type Int16
|
||||||
|
```
|
||||||
|
|
||||||
|
### `read` — single address
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# PMC R-file word
|
||||||
|
otopcua-focas-cli read -h 192.168.1.50 -a R100 -t Int16
|
||||||
|
|
||||||
|
# PMC X-bit
|
||||||
|
otopcua-focas-cli read -h 192.168.1.50 -a X0.0 -t Bit
|
||||||
|
|
||||||
|
# Parameter (axis 0)
|
||||||
|
otopcua-focas-cli read -h 192.168.1.50 -a PARAM:1815/0 -t Int32
|
||||||
|
|
||||||
|
# Macro variable
|
||||||
|
otopcua-focas-cli read -h 192.168.1.50 -a MACRO:500 -t Float64
|
||||||
|
```
|
||||||
|
|
||||||
|
### `write` — single value
|
||||||
|
|
||||||
|
Values parse per `--type` with invariant culture. Booleans accept
|
||||||
|
`true` / `false` / `1` / `0` / `yes` / `no` / `on` / `off`.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-focas-cli write -h 192.168.1.50 -a R100 -t Int16 -v 42
|
||||||
|
otopcua-focas-cli write -h 192.168.1.50 -a G50.3 -t Bit -v on
|
||||||
|
|
||||||
|
# MACRO: write — recipe / setpoint surface (server-side WriteOperate ACL)
|
||||||
|
otopcua-focas-cli write -h 192.168.1.50 -a MACRO:500 -t Int32 -v 42
|
||||||
|
|
||||||
|
# PARAM: write — commissioning surface (server-side WriteConfigure ACL,
|
||||||
|
# CNC must be in MDI mode + parameter-write switch enabled, else EW_PASSWD
|
||||||
|
# surfaces as BadUserAccessDenied)
|
||||||
|
otopcua-focas-cli write -h 192.168.1.50 -a PARAM:1815 -t Int32 -v 100
|
||||||
|
```
|
||||||
|
|
||||||
|
> **WARNING — `write -a G50.3 -t Bit -v on` is a read-modify-write.**
|
||||||
|
> The wire call `pmc_wrpmcrng` is byte-addressed; the driver reads the
|
||||||
|
> parent byte at `G50` first, sets bit 3, and writes the byte back. Other
|
||||||
|
> bits in `G50` that the ladder is concurrently updating may be clobbered
|
||||||
|
> by the byte we read a millisecond ago. Coordinate via a ladder-side
|
||||||
|
> handshake when this matters. **PMC writes also bypass the ladder's
|
||||||
|
> normal MDI-mode protection** — a misdirected bit can move motion or
|
||||||
|
> latch a feedhold the moment it lands. Verify e-stop is live and the
|
||||||
|
> machine is in JOG mode before issuing the first PMC write of a
|
||||||
|
> session. See [`docs/drivers/FOCAS.md`](drivers/FOCAS.md) "PMC bit-write
|
||||||
|
> read-modify-write semantics" for the full RMW flow.
|
||||||
|
|
||||||
|
PMC G/R writes land on a running machine — be careful which file you hit.
|
||||||
|
Parameter writes may require the CNC to be in MDI mode with the
|
||||||
|
parameter-write switch enabled.
|
||||||
|
|
||||||
|
#### Server-enforced ACL — issue #269, plan PR F4-b
|
||||||
|
|
||||||
|
When the same write flows through the OtOpcUa server (rather than the CLI's
|
||||||
|
direct-to-CNC path), the server-layer ACL gates by tag kind:
|
||||||
|
|
||||||
|
- `PARAM:` writes require **`WriteConfigure`** group membership — heavier
|
||||||
|
ACL because a misdirected parameter write can put the CNC in a bad
|
||||||
|
state.
|
||||||
|
- `MACRO:` writes require **`WriteOperate`** — matches the standard HMI
|
||||||
|
recipe / setpoint surface.
|
||||||
|
- PMC R/G/F writes require **`WriteOperate`**.
|
||||||
|
|
||||||
|
The classification is declared by the FOCAS driver per tag and enforced by
|
||||||
|
`DriverNodeManager`; the driver itself never inspects user identity. See
|
||||||
|
[`docs/security.md`](security.md) for the full LDAP-group → permission
|
||||||
|
mapping, [`docs/v2/acl-design.md`](v2/acl-design.md) for the design, and
|
||||||
|
[`docs/v2/focas-deployment.md`](v2/focas-deployment.md) "Write safety" for
|
||||||
|
the operator pre-check runbook (MDI mode, parameter-write switch).
|
||||||
|
|
||||||
|
**Writes are non-idempotent by default** — a timeout after the CNC already
|
||||||
|
applied the write will NOT auto-retry (plan decisions #44 + #45).
|
||||||
|
|
||||||
|
#### Server-side `Writes` enforcement (issue #268 F4-a + #269 F4-b + #270 F4-c)
|
||||||
|
|
||||||
|
The OtOpcUa server gates every FOCAS write behind multiple independent
|
||||||
|
opt-ins: `FocasDriverOptions.Writes.Enabled` (driver-level master switch),
|
||||||
|
`Writes.AllowParameter` (PARAM kill switch — F4-b), `Writes.AllowMacro`
|
||||||
|
(MACRO kill switch — F4-b), `Writes.AllowPmc` (PMC kill switch — F4-c),
|
||||||
|
and `FocasTagDefinition.Writable` (per-tag). All default `false`; any one
|
||||||
|
off short-circuits the server-side `WriteAsync` to `BadNotWritable` before
|
||||||
|
the wire client is touched. See [`docs/drivers/FOCAS.md`](drivers/FOCAS.md)
|
||||||
|
"Writes (opt-in, off by default)" subsection +
|
||||||
|
[`docs/v2/decisions.md`](v2/decisions.md) for the decision record.
|
||||||
|
|
||||||
|
**The CLI bypasses the server-side flag.** `otopcua-focas-cli write` is a
|
||||||
|
per-invocation operator tool — it sets `Writes.Enabled = true` locally for
|
||||||
|
the lifetime of one process and creates the synthesised tag with
|
||||||
|
`Writable = true`. This is intentional: the CLI is the operator's
|
||||||
|
direct-to-CNC fallback, not a long-lived process bound to the central
|
||||||
|
config DB. Configuring the server still requires both opt-ins to be set
|
||||||
|
explicitly in the DriverInstance JSON.
|
||||||
|
|
||||||
|
### `subscribe` — watch an address until Ctrl+C
|
||||||
|
|
||||||
|
FOCAS has no push model; the shared `PollGroupEngine` handles the tick
|
||||||
|
loop.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-focas-cli subscribe -h 192.168.1.50 -a R100 -t Int16 -i 500
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
Identical to the other driver CLIs via `SnapshotFormatter`:
|
||||||
|
|
||||||
|
- `probe` / `read` emit a multi-line block: `Tag / Value / Status /
|
||||||
|
Source Time / Server Time`. `probe` prefixes it with `CNC`, `Series`,
|
||||||
|
`Health`, and `Last error` lines.
|
||||||
|
- `write` emits one line: `Write <address>: 0x... (Good |
|
||||||
|
BadCommunicationError | …)`.
|
||||||
|
- `subscribe` emits one line per change: `[HH:mm:ss.fff] <address> =
|
||||||
|
<value> (<status>)`.
|
||||||
|
|
||||||
|
## Typical workflows
|
||||||
|
|
||||||
|
**"Is the CNC alive?"** → `probe`.
|
||||||
|
|
||||||
|
**"Does my parameter write land?"** → `write` + `read` back against the
|
||||||
|
same address. Check the parameter-write switch + MDI mode if the write
|
||||||
|
fails.
|
||||||
|
|
||||||
|
**"Why did this macro flip?"** → `subscribe` to the macro, let the
|
||||||
|
operator reproduce the cycle, watch the HH:mm:ss.fff timeline.
|
||||||
|
|
||||||
|
**"Is the Fwlib32 DLL wired up?"** → `probe` against any host. A
|
||||||
|
`DllNotFoundException` surfacing as `BadCommunicationError` with a
|
||||||
|
matching `Last error` line means the driver is loading but the DLL is
|
||||||
|
missing; anything else means a transport-layer problem.
|
||||||
121
docs/Driver.Modbus.Cli.md
Normal file
121
docs/Driver.Modbus.Cli.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# `otopcua-modbus-cli` — Modbus-TCP test client
|
||||||
|
|
||||||
|
Ad-hoc probe / read / write / subscribe tool for talking to Modbus-TCP devices
|
||||||
|
through the **same** `ModbusDriver` the OtOpcUa server uses. Mirrors the v1
|
||||||
|
OPC UA `otopcua-cli` shape so the muscle memory carries over: drop to a shell,
|
||||||
|
point at a PLC, watch registers move.
|
||||||
|
|
||||||
|
First of four driver test-client CLIs (Modbus → AB CIP → AB Legacy → S7 →
|
||||||
|
TwinCAT). Built on the shared `ZB.MOM.WW.OtOpcUa.Driver.Cli.Common` library
|
||||||
|
so each downstream CLI inherits verbose/log wiring + snapshot formatting
|
||||||
|
without copy-paste.
|
||||||
|
|
||||||
|
## Build + run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli
|
||||||
|
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Or publish a self-contained binary:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet publish src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli -c Release -o publish/modbus-cli
|
||||||
|
publish/modbus-cli/otopcua-modbus-cli.exe --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common flags
|
||||||
|
|
||||||
|
Every command accepts:
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `-h` / `--host` | **required** | Modbus-TCP server hostname or IP |
|
||||||
|
| `-p` / `--port` | `502` | TCP port |
|
||||||
|
| `-U` / `--unit-id` | `1` | Modbus unit / slave ID |
|
||||||
|
| `--timeout-ms` | `2000` | Per-PDU timeout |
|
||||||
|
| `--disable-reconnect` | off | Turn off mid-transaction reconnect-and-retry |
|
||||||
|
| `--verbose` | off | Serilog debug output |
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `probe` — is the PLC up?
|
||||||
|
|
||||||
|
Connects, reads one holding register, prints driver health. Fastest sanity
|
||||||
|
check after swapping a network cable or deploying a new device.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-modbus-cli probe -h 192.168.1.10
|
||||||
|
otopcua-modbus-cli probe -h 192.168.1.10 --probe-address 100 # device locks HR[0]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `read` — single register / coil / string
|
||||||
|
|
||||||
|
Synthesises a one-tag driver config on the fly from `--region` + `--address`
|
||||||
|
+ `--type` flags.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Holding register as UInt16
|
||||||
|
otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 100 -t UInt16
|
||||||
|
|
||||||
|
# Float32 with word-swap (CDAB) — common on Siemens / some AB families
|
||||||
|
otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 200 -t Float32 --byte-order WordSwap
|
||||||
|
|
||||||
|
# Single bit out of a packed holding register
|
||||||
|
otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 10 -t BitInRegister --bit-index 3
|
||||||
|
|
||||||
|
# 40-char ASCII string — DirectLOGIC packs the first char in the low byte
|
||||||
|
otopcua-modbus-cli read -h 192.168.1.10 -r HoldingRegisters -a 300 -t String --string-length 40 --string-byte-order LowByteFirst
|
||||||
|
|
||||||
|
# Discrete input / coil
|
||||||
|
otopcua-modbus-cli read -h 192.168.1.10 -r DiscreteInputs -a 5 -t Bool
|
||||||
|
```
|
||||||
|
|
||||||
|
### `write` — single value
|
||||||
|
|
||||||
|
Same flag shape as `read` plus `-v` / `--value`. Values parse per `--type`
|
||||||
|
using invariant culture (period as decimal separator). Booleans accept
|
||||||
|
`true`/`false`/`1`/`0`/`yes`/`no`/`on`/`off`.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-modbus-cli write -h 192.168.1.10 -r HoldingRegisters -a 100 -t UInt16 -v 42
|
||||||
|
otopcua-modbus-cli write -h 192.168.1.10 -r HoldingRegisters -a 200 -t Float32 -v 3.14
|
||||||
|
otopcua-modbus-cli write -h 192.168.1.10 -r Coils -a 5 -t Bool -v on
|
||||||
|
```
|
||||||
|
|
||||||
|
**Writes are non-idempotent by default** — a timeout after the device
|
||||||
|
already applied the write will NOT auto-retry. This matches the driver's
|
||||||
|
production contract (plan decisions #44 + #45).
|
||||||
|
|
||||||
|
### `subscribe` — watch a register until Ctrl+C
|
||||||
|
|
||||||
|
Uses the driver's `ISubscribable` surface (polling under the hood via
|
||||||
|
`PollGroupEngine`). Prints every data-change event with a timestamp.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-modbus-cli subscribe -h 192.168.1.10 -r HoldingRegisters -a 100 -t Int16 -i 500
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output format
|
||||||
|
|
||||||
|
- `probe` / `read` emit a multi-line per-tag block: `Tag / Value / Status /
|
||||||
|
Source Time / Server Time`.
|
||||||
|
- `write` emits one line: `Write <tag>: 0x... (Good | BadCommunicationError | …)`.
|
||||||
|
- `subscribe` emits one line per change: `[HH:mm:ss.fff] <tag> = <value> (<status>)`.
|
||||||
|
|
||||||
|
Status codes are rendered as `0xXXXXXXXX (Name)` for the OPC UA shortlist
|
||||||
|
(`Good`, `BadCommunicationError`, `BadTimeout`, `BadNodeIdUnknown`,
|
||||||
|
`BadTypeMismatch`, `Uncertain`, …). Unknown codes fall back to bare hex.
|
||||||
|
|
||||||
|
## Typical workflows
|
||||||
|
|
||||||
|
**"Is the PLC alive?"** → `probe`.
|
||||||
|
|
||||||
|
**"Does my recipe write land?"** → `write` + `read` back against the same
|
||||||
|
address.
|
||||||
|
|
||||||
|
**"Why is tag X flipping?"** → `subscribe` + wait for the operator scenario.
|
||||||
|
|
||||||
|
**"What's the right byte order for this family?"** → `read` with
|
||||||
|
`--byte-order BigEndian`, then with `--byte-order WordSwap`. The one that
|
||||||
|
gives plausible values is the correct one for that device.
|
||||||
177
docs/Driver.S7.Cli.md
Normal file
177
docs/Driver.S7.Cli.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# `otopcua-s7-cli` — Siemens S7 test client
|
||||||
|
|
||||||
|
Ad-hoc probe / read / write / subscribe tool for Siemens S7-300 / S7-400 /
|
||||||
|
S7-1200 / S7-1500 (and compatible soft-PLCs) over S7comm / ISO-on-TCP port 102.
|
||||||
|
Uses the **same** `S7Driver` the OtOpcUa server does (S7.Net under the hood).
|
||||||
|
|
||||||
|
Fourth of four driver test-client CLIs.
|
||||||
|
|
||||||
|
## Build + run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common flags
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `-h` / `--host` | **required** | PLC IP or hostname |
|
||||||
|
| `-p` / `--port` | `102` | ISO-on-TCP port (rarely changes) |
|
||||||
|
| `-c` / `--cpu` | `S71500` | S7200 / S7200Smart / S7300 / S7400 / S71200 / S71500 |
|
||||||
|
| `--rack` | `0` | Hardware rack (S7-400 distributed setups only) |
|
||||||
|
| `--slot` | `0` | CPU slot (S7-300 = 2, S7-400 = 2 or 3, S7-1200/1500 = 0) |
|
||||||
|
| `--timeout-ms` | `5000` | Per-operation timeout |
|
||||||
|
| `--tsap-mode` | `Auto` | ISO-on-TCP connection class: `Auto` / `Pg` / `Op` / `S7Basic` / `Other`. Hardened S7-1500 / ET 200SP CPUs may require `Op` or `S7Basic`. See [s7.md TSAP / Connection Type](v2/s7.md#tsap--connection-type). |
|
||||||
|
| `--local-tsap` | (unset) | Optional 16-bit local TSAP override (e.g. `0x0200`). Required when `--tsap-mode Other`; wins over class default under Pg/Op/S7Basic. |
|
||||||
|
| `--remote-tsap` | (unset) | Optional 16-bit remote TSAP override. Required when `--tsap-mode Other`; wins over class default under Pg/Op/S7Basic. |
|
||||||
|
| `--verbose` | off | Serilog debug output |
|
||||||
|
|
||||||
|
## PUT/GET must be enabled
|
||||||
|
|
||||||
|
S7-1200 / S7-1500 ship with PUT/GET communication **disabled** by default.
|
||||||
|
Enable it in TIA Portal: *Device config → Protection & Security → Connection
|
||||||
|
mechanisms → "Permit access with PUT/GET communication from remote partner"*.
|
||||||
|
Without it the CLI's first read will surface `BadNotSupported`.
|
||||||
|
|
||||||
|
### Pre-flight PUT/GET enablement (PR-S7-C5)
|
||||||
|
|
||||||
|
The driver issues a tiny 2-byte read against `Probe.ProbeAddress` (default
|
||||||
|
`MW0`) immediately after `OpenAsync` and **fails `InitializeAsync` with a
|
||||||
|
typed `S7PutGetDisabledException`** when the PLC rejects the read with the
|
||||||
|
wire-level "function not allowed" response. The exception message names the
|
||||||
|
exact TIA Portal toggle to flip — operators see the configuration fix at
|
||||||
|
init time, not after the first per-tag read produces `BadDeviceFailure`.
|
||||||
|
|
||||||
|
Two opt-out knobs on the JSON `Probe` block:
|
||||||
|
|
||||||
|
- `ProbeAddress` — set to `""` (empty string) to skip the pre-flight read
|
||||||
|
entirely. Useful when no fingerprint address has been wired.
|
||||||
|
- `SkipPreflight` — set to `true` to defer the check to runtime while
|
||||||
|
keeping the background liveness loop. Per-tag reads still surface
|
||||||
|
`BadDeviceFailure` until PUT/GET is enabled, but Init succeeds and the
|
||||||
|
driver becomes visible in the Admin UI.
|
||||||
|
|
||||||
|
See [s7.md "Pre-flight PUT/GET enablement"](v2/s7.md#pre-flight-putget-enablement)
|
||||||
|
for the full rationale, classifier behaviour, and the wire-level
|
||||||
|
`ErrorCode` matching.
|
||||||
|
|
||||||
|
## S7 address grammar cheat sheet
|
||||||
|
|
||||||
|
| Form | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `DB1.DBW0` | DB number 1, word offset 0 |
|
||||||
|
| `DB1.DBD4` | DB number 1, dword offset 4 |
|
||||||
|
| `DB1.DBX2.3` | DB number 1, byte 2, bit 3 |
|
||||||
|
| `DB10.STRING[0]` | DB 10 string starting at offset 0 |
|
||||||
|
| `M0.0` | Merker bit 0.0 |
|
||||||
|
| `MW0` / `MD4` | Merker word / dword |
|
||||||
|
| `IW4` | Input word 4 |
|
||||||
|
| `QD8` | Output dword 8 |
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `probe`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# S7-1500 — default probe MW0
|
||||||
|
otopcua-s7-cli probe -h 192.168.1.30
|
||||||
|
|
||||||
|
# S7-300 (slot 2)
|
||||||
|
otopcua-s7-cli probe -h 192.168.1.31 -c S7300 --slot 2 -a DB1.DBW0
|
||||||
|
```
|
||||||
|
|
||||||
|
### `read`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# DB word
|
||||||
|
otopcua-s7-cli read -h 192.168.1.30 -a DB1.DBW0 -t Int16
|
||||||
|
|
||||||
|
# Float32 from DB dword
|
||||||
|
otopcua-s7-cli read -h 192.168.1.30 -a DB1.DBD4 -t Float32
|
||||||
|
|
||||||
|
# Merker bit
|
||||||
|
otopcua-s7-cli read -h 192.168.1.30 -a M0.0 -t Bool
|
||||||
|
|
||||||
|
# 80-char S7 string
|
||||||
|
otopcua-s7-cli read -h 192.168.1.30 -a DB10.STRING[0] -t String --string-length 80
|
||||||
|
```
|
||||||
|
|
||||||
|
### `write`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-s7-cli write -h 192.168.1.30 -a DB1.DBW0 -t Int16 -v 42
|
||||||
|
otopcua-s7-cli write -h 192.168.1.30 -a DB1.DBD4 -t Float32 -v 3.14
|
||||||
|
otopcua-s7-cli write -h 192.168.1.30 -a M0.0 -t Bool -v true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Writes to M / Q are real** — they drive the PLC program. Be careful what you
|
||||||
|
flip on a running machine.
|
||||||
|
|
||||||
|
### Hardened CPU — forcing OP-class TSAP
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Probe a hardened S7-1500 that rejects PG class but accepts OP.
|
||||||
|
otopcua-s7-cli probe -h 10.50.12.30 --tsap-mode Op
|
||||||
|
|
||||||
|
# Read against the same CPU.
|
||||||
|
otopcua-s7-cli read -h 10.50.12.30 --tsap-mode Op -a DB1.DBW0 -t Int16
|
||||||
|
|
||||||
|
# Manual TSAP override (e.g. site with a fixed proprietary TSAP gateway).
|
||||||
|
otopcua-s7-cli probe -h 10.50.12.30 --tsap-mode Other --local-tsap 0x4D57 --remote-tsap 0x4D58
|
||||||
|
```
|
||||||
|
|
||||||
|
Without `--tsap-mode`, the CLI uses S7netplus's CpuType-derived default (PG
|
||||||
|
class for almost everything). The same connection-refused failure shape that a
|
||||||
|
wrong `--slot` produces also shows up when the CPU rejects PG class — try
|
||||||
|
`--tsap-mode Op` first when the handshake is failing on otherwise-correct
|
||||||
|
endpoint config. See [s7.md TSAP / Connection Type](v2/s7.md#tsap--connection-type)
|
||||||
|
for the byte table and motivation.
|
||||||
|
|
||||||
|
### `subscribe`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-s7-cli subscribe -h 192.168.1.30 -a DB1.DBW0 -t Int16 -i 500
|
||||||
|
```
|
||||||
|
|
||||||
|
S7comm has no native push — the CLI polls through `PollGroupEngine` just like
|
||||||
|
Modbus / AB.
|
||||||
|
|
||||||
|
### `import-symbols`
|
||||||
|
|
||||||
|
PR-S7-D1 / [#299](https://github.com/dohertj2/lmxopcua/issues/299) — read a TIA
|
||||||
|
Portal CSV ("Show all tags" export) or STEP 7 Classic `.AWL` file and emit a
|
||||||
|
JSON tag fragment for `appsettings.json`, or a one-line summary. Mirrors the
|
||||||
|
AB Legacy `import-rslogix` CLI in shape.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# TIA Portal CSV — emit JSON fragment to stdout
|
||||||
|
otopcua-s7-cli import-symbols --file plc-export.csv --format tia
|
||||||
|
|
||||||
|
# STEP 7 Classic AWL — emit summary line
|
||||||
|
otopcua-s7-cli import-symbols --file classic.awl --format awl --emit summary
|
||||||
|
|
||||||
|
# DE-locale CSV — auto-detected; output to file
|
||||||
|
otopcua-s7-cli import-symbols `
|
||||||
|
--file plc-de.csv `
|
||||||
|
--format tia `
|
||||||
|
--emit appsettings-fragment `
|
||||||
|
--output tags.json
|
||||||
|
|
||||||
|
# Strict mode — fail-fast on the first malformed row (CI lint)
|
||||||
|
otopcua-s7-cli import-symbols --file plc.csv --format tia --strict
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `-f` / `--file` | **required** | Path to the TIA CSV or `.AWL` file |
|
||||||
|
| `--format` | `tia` | `tia` (CSV) or `awl` (STEP 7 Classic) |
|
||||||
|
| `-d` / `--device` | none | Optional documentation tag (held for symmetry with `import-rslogix`) |
|
||||||
|
| `--emit` | `appsettings-fragment` | `appsettings-fragment` (JSON) or `summary` (one-line counter) |
|
||||||
|
| `-o` / `--output` | stdout | Optional path; when set the JSON fragment is written there + summary line goes to stdout |
|
||||||
|
| `--max-rows` | unlimited | Defensive cap on rows imported |
|
||||||
|
| `--strict` | off | Fail-fast on the first malformed row (default permissive: skip + log) |
|
||||||
|
|
||||||
|
UDT-typed rows import as placeholder tags (data type forced to `Byte`); see
|
||||||
|
[S7-TIA-Import.md](drivers/S7-TIA-Import.md) for the full format reference,
|
||||||
|
locale auto-detection, and AWL position-based addressing rules.
|
||||||
219
docs/Driver.TwinCAT.Cli.md
Normal file
219
docs/Driver.TwinCAT.Cli.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# `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`.
|
||||||
|
|
||||||
|
Fifth (final) of the driver test-client CLIs.
|
||||||
|
|
||||||
|
## Build + run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisite: AMS router
|
||||||
|
|
||||||
|
The `Beckhoff.TwinCAT.Ads` library needs a reachable AMS router to open ADS
|
||||||
|
sessions. Pick one:
|
||||||
|
|
||||||
|
1. **Local TwinCAT XAR** — install the free TwinCAT 3 XAR Engineering install
|
||||||
|
on the machine running the CLI; it ships the router.
|
||||||
|
2. **Beckhoff.TwinCAT.Ads.TcpRouter** — standalone NuGet router. Run in a
|
||||||
|
sidecar process when no XAR is installed.
|
||||||
|
3. **Remote AMS route** — any Windows box with TwinCAT installed, with an AMS
|
||||||
|
route authorised to the CLI host.
|
||||||
|
|
||||||
|
The CLI compiles + runs without a router, but every wire call fails with a
|
||||||
|
transport error until one is reachable.
|
||||||
|
|
||||||
|
## UDT decomposition
|
||||||
|
|
||||||
|
PR 4.1 (issue #315) replaces the old "skip non-atomic symbols" behaviour
|
||||||
|
of `BrowseSymbolsAsync` with a recursive type walker
|
||||||
|
(`TwinCATTypeWalker`). When the OtOpcUa server's TwinCAT driver runs
|
||||||
|
discovery with `EnableControllerBrowse=true`, struct / UDT / function-block
|
||||||
|
typed symbols flatten into one OPC UA variable per atomic leaf. Browse
|
||||||
|
addresses use the same dotted-instance form the PLC exposes:
|
||||||
|
|
||||||
|
| PLC declaration | OPC UA browse paths surfaced |
|
||||||
|
|---|---|
|
||||||
|
| `MAIN.bStart : BOOL` | `MAIN.bStart` |
|
||||||
|
| `GVL.stMotor : ST_Motor` | `GVL.stMotor.bRunning`, `GVL.stMotor.nState`, `GVL.stMotor.rTemperature`, … |
|
||||||
|
| `GVL.aRecipe : ARRAY[1..10] OF DINT` | `GVL.aRecipe[1]` … `GVL.aRecipe[10]` |
|
||||||
|
| `GVL.aPairs : ARRAY[0..2] OF ST_Pair` | `GVL.aPairs[0].nCount`, `GVL.aPairs[0].rValue`, `GVL.aPairs[1].…` |
|
||||||
|
| `GVL.aBig : ARRAY[1..5000] OF DINT` | `GVL.aBig` (single whole-array root — over the cap) |
|
||||||
|
|
||||||
|
The CLI's `read` / `write` / `subscribe` commands take dotted paths
|
||||||
|
directly:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Read a struct member
|
||||||
|
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s GVL.stMotor.rTemperature -t Real
|
||||||
|
|
||||||
|
# Read an array element
|
||||||
|
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s "GVL.aRecipe[3]" -t DInt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Array expansion bound
|
||||||
|
|
||||||
|
`TwinCATDriverOptions.MaxArrayExpansion` (default `1024`) caps how many
|
||||||
|
elements an array contributes to the discovered address space. Arrays
|
||||||
|
whose total element count exceeds the cap surface as a single
|
||||||
|
whole-array root with `IsArrayRoot=true` instead of one variable per
|
||||||
|
element. Raise the bound when operators routinely care about individual
|
||||||
|
elements of large recipe / lookup tables; lower it to keep discovery
|
||||||
|
cheap for symbol tables that ship multi-thousand-element scratch
|
||||||
|
arrays. Pre-declared whole-array tags from the `Tags` config bypass the
|
||||||
|
walker entirely — set `ArrayDimensions` on a `TwinCATTagDefinition` to
|
||||||
|
keep array reads on the existing PR 1.4 read-array path.
|
||||||
|
|
||||||
|
### Cycle / depth guard
|
||||||
|
|
||||||
|
The walker tracks the visited-type set + a hard depth cap of 8 levels
|
||||||
|
so a self-pointer (`POINTER TO ST_Self`) or pathological alias chain
|
||||||
|
terminates rather than spinning. POINTER / REFERENCE members are
|
||||||
|
skipped at the type-graph level — surfacing them would require
|
||||||
|
dereferencing through the AMS routing layer which has its own access
|
||||||
|
patterns.
|
||||||
|
|
||||||
|
## Common flags
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `-n` / `--ams-net-id` | **required** | AMS Net ID (e.g. `192.168.1.40.1.1`) |
|
||||||
|
| `-p` / `--ams-port` | `851` | AMS port (TwinCAT 3 PLC = 851, TwinCAT 2 = 801) |
|
||||||
|
| `--timeout-ms` | `5000` | Per-operation timeout |
|
||||||
|
| `--poll-only` | off | Disable native ADS notifications, use `PollGroupEngine` instead |
|
||||||
|
| `--verbose` | off | Serilog debug output |
|
||||||
|
|
||||||
|
## Data types
|
||||||
|
|
||||||
|
TwinCAT exposes the IEC 61131-3 atomic set: `Bool`, `SInt`, `USInt`, `Int`,
|
||||||
|
`UInt`, `DInt`, `UDInt`, `LInt`, `ULInt`, `Real`, `LReal`, `String`, `WString`,
|
||||||
|
`Time`, `Date`, `DateTime`, `TimeOfDay`. The four IEC time/date variants
|
||||||
|
marshal as `UDINT` on the wire — CLI takes a numeric raw value and lets the
|
||||||
|
caller interpret semantics.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `probe`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Local TwinCAT 3, probe a canonical global
|
||||||
|
otopcua-twincat-cli probe -n 127.0.0.1.1.1 -s "TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt"
|
||||||
|
|
||||||
|
# Remote, probe a project variable
|
||||||
|
otopcua-twincat-cli probe -n 192.168.1.40.1.1 -s MAIN.bRunning --type Bool
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Health probe
|
||||||
|
|
||||||
|
The OtOpcUa server's TwinCAT driver runs an internal probe loop (PR 3.2, issue #314)
|
||||||
|
that — alongside the cheap `ReadStateAsync` reachability check — samples four
|
||||||
|
well-known system symbols once per probe interval and surfaces the result through
|
||||||
|
the cross-driver `driver-diagnostics` RPC (added for Modbus, task #154). The same
|
||||||
|
symbols can be probed directly via the CLI for ad-hoc troubleshooting:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Cycle time (UDINT, 100 ns ticks → ÷10000 for ms)
|
||||||
|
otopcua-twincat-cli probe -n 192.168.1.40.1.1 -s "TwinCAT_SystemInfoVarList._TaskInfo[1].CycleTime" --type UDInt
|
||||||
|
|
||||||
|
# Last task execution wall-clock (UDINT, 100 ns ticks → ÷10000 for ms)
|
||||||
|
otopcua-twincat-cli probe -n 192.168.1.40.1.1 -s "TwinCAT_SystemInfoVarList._TaskInfo[1].LastExecTime" --type UDInt
|
||||||
|
|
||||||
|
# Online-change count — increments on every accepted online change
|
||||||
|
otopcua-twincat-cli probe -n 192.168.1.40.1.1 -s "TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt" --type UDInt
|
||||||
|
|
||||||
|
# Loaded PLC project name (STRING(80))
|
||||||
|
otopcua-twincat-cli probe -n 192.168.1.40.1.1 -s "TwinCAT_SystemInfoVarList._AppInfo.AppName" --type String
|
||||||
|
```
|
||||||
|
|
||||||
|
Within the running OtOpcUa server these four signals land on
|
||||||
|
`DeviceState.LastDiagnostics` as a `TwinCATDeviceDiagnostics` record + are folded
|
||||||
|
into `DriverHealth.Diagnostics` keyed `TwinCAT.CycleTimeMs`, `TwinCAT.LastExecTimeMs`,
|
||||||
|
`TwinCAT.JitterMs` (computed `LastExecTimeMs - CycleTimeMs`),
|
||||||
|
`TwinCAT.OnlineChangeCnt`, and `TwinCAT.OnlineChangeIncrements`. See
|
||||||
|
`docs/drivers/TwinCAT-Test-Fixture.md §Diagnostics` for the full mapping.
|
||||||
|
|
||||||
|
### `read`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Bool symbol
|
||||||
|
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s MAIN.bStart -t Bool
|
||||||
|
|
||||||
|
# Counter
|
||||||
|
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s GVL.Counter -t DInt
|
||||||
|
|
||||||
|
# Nested UDT member
|
||||||
|
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s Motor1.Status.Running -t Bool
|
||||||
|
|
||||||
|
# Array element
|
||||||
|
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s "Recipe[3]" -t Real
|
||||||
|
|
||||||
|
# WString
|
||||||
|
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s GVL.sMessage -t WString
|
||||||
|
```
|
||||||
|
|
||||||
|
ADS variable handles for `read` / `write` symbols are cached transparently
|
||||||
|
inside the CLI's underlying `AdsTwinCATClient`. The first read of a symbol
|
||||||
|
resolves a handle; repeats reuse the cached handle for smaller AMS payloads
|
||||||
|
and skipped name resolution. The cache wipes on reconnect, on
|
||||||
|
`DeviceSymbolVersionInvalid` (with a one-shot retry), and on CLI exit. See
|
||||||
|
`docs/drivers/TwinCAT-Test-Fixture.md §Handle caching` for the full story
|
||||||
|
including the staleness caveat after an online change.
|
||||||
|
|
||||||
|
### `write`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-twincat-cli write -n 192.168.1.40.1.1 -s MAIN.bStart -t Bool -v true
|
||||||
|
otopcua-twincat-cli write -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -v 42
|
||||||
|
otopcua-twincat-cli write -n 192.168.1.40.1.1 -s GVL.sMessage -t WString -v "running"
|
||||||
|
```
|
||||||
|
|
||||||
|
Structure writes refused — drop to driver config JSON for those.
|
||||||
|
|
||||||
|
### `subscribe`
|
||||||
|
|
||||||
|
```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
|
||||||
|
|
||||||
|
# Fall back to polling for runtimes where native notifications are constrained
|
||||||
|
otopcua-twincat-cli subscribe -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -i 500 --poll-only
|
||||||
|
|
||||||
|
# Coalesce bursty changes — runtime buffers up to 500 ms before dispatch
|
||||||
|
otopcua-twincat-cli subscribe -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -i 50 --max-delay-ms 500
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `-s` / `--symbol` | **required** | Symbol path — same format as `read` |
|
||||||
|
| `-t` / `--type` | `DInt` | IEC type (see Data types section) |
|
||||||
|
| `-i` / `--interval-ms` | `1000` | **Cycle time** — minimum interval between change checks the PLC runtime applies |
|
||||||
|
| `--max-delay-ms` | `0` | **Max coalescing window** — upper bound on how long the runtime buffers change events before dispatch. `0` = fire ASAP, no coalescing |
|
||||||
|
| `--poll-only` | off | Disable native notifications, use `PollGroupEngine` instead |
|
||||||
|
|
||||||
|
`-i` / `--interval-ms` and `--max-delay-ms` are different things and both flow
|
||||||
|
into the Beckhoff `NotificationSettings` ctor:
|
||||||
|
|
||||||
|
- **`--interval-ms`** is the *cycle*: the runtime checks for value changes at
|
||||||
|
most this often. Smaller = lower latency, higher CPU.
|
||||||
|
- **`--max-delay-ms`** is the *coalescing ceiling*: once a change is detected,
|
||||||
|
the runtime can hold it for up to this long before dispatching, which lets
|
||||||
|
it batch a burst of changes into a single callback. Default `0` means
|
||||||
|
every detected change fires immediately — same as the pre-PR-3.1 behaviour.
|
||||||
|
|
||||||
|
For high-frequency signals (a counter incrementing every 10 ms PLC cycle),
|
||||||
|
pair a small `-i` (so latency stays bounded) with a non-zero `--max-delay-ms`
|
||||||
|
(so the OPC UA queue downstream doesn't flood). For slow signals just leave
|
||||||
|
`--max-delay-ms` at `0`.
|
||||||
|
|
||||||
|
The subscribe banner announces which mechanism is in play — "ADS notification"
|
||||||
|
or "polling" — and includes the `max-delay` value when set, so it's obvious
|
||||||
|
in screen-recorded bug reports.
|
||||||
|
|
||||||
|
`--poll-only` polls go through the same cached-handle path as `read`, so
|
||||||
|
repeated polls of the same symbol carry only a 4-byte handle on the wire
|
||||||
|
rather than the full symbolic path.
|
||||||
108
docs/DriverClis.md
Normal file
108
docs/DriverClis.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Driver test-client CLIs
|
||||||
|
|
||||||
|
Six shell-level ad-hoc validation tools, one per native-protocol driver family.
|
||||||
|
Each mirrors the v1 `otopcua-cli` shape (probe / read / write / subscribe) against
|
||||||
|
the **same driver** the OtOpcUa server uses — so "does the CLI see it?" and
|
||||||
|
"does the server see it?" are the same question.
|
||||||
|
|
||||||
|
| CLI | Protocol | Docs |
|
||||||
|
|---|---|---|
|
||||||
|
| `otopcua-modbus-cli` | Modbus-TCP | [Driver.Modbus.Cli.md](Driver.Modbus.Cli.md) |
|
||||||
|
| `otopcua-abcip-cli` | CIP / EtherNet-IP (Logix symbolic) | [Driver.AbCip.Cli.md](Driver.AbCip.Cli.md) |
|
||||||
|
| `otopcua-ablegacy-cli` | PCCC (SLC / MicroLogix / PLC-5) | [Driver.AbLegacy.Cli.md](Driver.AbLegacy.Cli.md) |
|
||||||
|
| `otopcua-s7-cli` | S7comm / ISO-on-TCP | [Driver.S7.Cli.md](Driver.S7.Cli.md) |
|
||||||
|
| `otopcua-twincat-cli` | Beckhoff ADS | [Driver.TwinCAT.Cli.md](Driver.TwinCAT.Cli.md) |
|
||||||
|
| `otopcua-focas-cli` | Fanuc FOCAS/2 (CNC) | [Driver.FOCAS.Cli.md](Driver.FOCAS.Cli.md) |
|
||||||
|
|
||||||
|
The OPC UA client CLI lives separately and predates this suite —
|
||||||
|
see [Client.CLI.md](Client.CLI.md) for `otopcua-cli`.
|
||||||
|
|
||||||
|
## Shared commands
|
||||||
|
|
||||||
|
Every driver CLI exposes the same four verbs:
|
||||||
|
|
||||||
|
- **`probe`** — open a session, read one sentinel tag, print driver health.
|
||||||
|
Fastest "is the device talking?" check.
|
||||||
|
- **`read`** — synthesise a one-tag driver config from `--type` / `--address`
|
||||||
|
(or `--tag` / `--symbol`) flags, read once, print the snapshot. No extra
|
||||||
|
config file needed.
|
||||||
|
- **`write`** — same shape plus `--value`. Values parse per `--type` using
|
||||||
|
invariant culture. Booleans accept `true` / `false` / `1` / `0` / `yes` /
|
||||||
|
`no` / `on` / `off`. Writes are **non-idempotent by default** — a timeout
|
||||||
|
after the device already applied the write will not auto-retry (plan
|
||||||
|
decisions #44, #45).
|
||||||
|
- **`subscribe`** — long-running data-change stream until Ctrl+C. Uses native
|
||||||
|
push where available (TwinCAT ADS notifications) and falls back to polling
|
||||||
|
(`PollGroupEngine`) where the protocol has no push (Modbus, AB, S7, FOCAS).
|
||||||
|
|
||||||
|
## Shared infrastructure
|
||||||
|
|
||||||
|
All six CLIs depend on `src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/`:
|
||||||
|
|
||||||
|
- `DriverCommandBase` — `--verbose` + Serilog configuration + the abstract
|
||||||
|
`Timeout` surface every protocol-specific base overrides with its own
|
||||||
|
default.
|
||||||
|
- `SnapshotFormatter` — consistent output across every CLI: tag / value /
|
||||||
|
status / source-time / server-time for single reads, a 4-column table for
|
||||||
|
batches, `Write <tag>: 0x... (Name)` for writes, and one line per change
|
||||||
|
event for subscriptions. OPC UA status codes render as `0xXXXXXXXX (Name)`
|
||||||
|
with a shortlist for `Good` / `Bad*` / `Uncertain`; unknown codes fall
|
||||||
|
back to hex.
|
||||||
|
|
||||||
|
Writing a seventh CLI (hypothetical Galaxy / OPC UA Client) costs roughly
|
||||||
|
150 lines: a `{Family}CommandBase` + four thin command classes that hand
|
||||||
|
their flag values to the already-shipped driver.
|
||||||
|
|
||||||
|
## Typical cross-CLI workflows
|
||||||
|
|
||||||
|
- **Commissioning a new device** — `probe` first, then `read` a known-good
|
||||||
|
tag. If the device is up + talking the protocol, both pass; if the tag is
|
||||||
|
wrong you'll see the read fail with a protocol-specific error.
|
||||||
|
- **Reproducing a production bug** — `subscribe` to the tag the bug report
|
||||||
|
names, then have the operator run the scenario. You get an HH:mm:ss.fff
|
||||||
|
timeline of exactly when each value changed.
|
||||||
|
- **Validating a recipe write** — `write` + `read` back. If the server's
|
||||||
|
write path would have done anything different, the CLI would have too.
|
||||||
|
- **Byte-order / word-swap debugging** — `read` with one `--byte-order`,
|
||||||
|
then the other. The plausible result identifies the correct setting
|
||||||
|
for that device family. (Modbus, S7.)
|
||||||
|
|
||||||
|
## Family-specific commands
|
||||||
|
|
||||||
|
Most drivers ship the four shared verbs and nothing else. AB Legacy adds a
|
||||||
|
fifth family-specific verb for bulk symbol-table import:
|
||||||
|
|
||||||
|
| Driver | Extra verb | Doc |
|
||||||
|
|---|---|---|
|
||||||
|
| AB Legacy | `import-rslogix` — read RSLogix 500/5 CSV symbol exports + emit a JSON tag fragment | [drivers/AbLegacy-RSLogix-Import.md](drivers/AbLegacy-RSLogix-Import.md) |
|
||||||
|
|
||||||
|
Binary RSLogix project files (`.RSS` / `.RSP`) are out of scope for v1 — the
|
||||||
|
format is proprietary and undocumented; no parser ships in libplctag or any
|
||||||
|
community library. Export to CSV first.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- **AB Legacy cip-path quirk** — libplctag's ab_server requires a
|
||||||
|
non-empty CIP routing path before forwarding to the PCCC dispatcher.
|
||||||
|
Pass `--gateway "ab://127.0.0.1:44818/1,0"` against the Docker
|
||||||
|
fixture; real SLC / MicroLogix / PLC-5 hardware accepts an empty
|
||||||
|
path (`ab://host:44818/`). Bit-file writes (`B3:0/5`) still surface
|
||||||
|
`0x803D0000` against ab_server — route operator-critical bit writes
|
||||||
|
to real hardware until upstream fixes this.
|
||||||
|
- **S7 PUT/GET communication** must be enabled in TIA Portal for any
|
||||||
|
S7-1200/1500. See [Driver.S7.Cli.md](Driver.S7.Cli.md).
|
||||||
|
- **TwinCAT AMS router** must be reachable (local XAR, standalone Router
|
||||||
|
NuGet, or authorised remote route). See
|
||||||
|
[Driver.TwinCAT.Cli.md](Driver.TwinCAT.Cli.md).
|
||||||
|
- **Structure / UDT writes** are refused by the AB CIP + TwinCAT CLIs —
|
||||||
|
whole-UDT writes need a declared member layout that belongs in a real
|
||||||
|
driver config, not a one-shot flag.
|
||||||
|
|
||||||
|
## Tracking
|
||||||
|
|
||||||
|
Tasks #249 / #250 / #251 shipped the original five. The FOCAS CLI followed
|
||||||
|
alongside the Tier-C isolation work on task #220 — no CLI-level test
|
||||||
|
project (hardware-gated). 122 unit tests cumulative across the first five
|
||||||
|
(16 shared-lib + 106 CLI-specific) — run
|
||||||
|
`dotnet test tests/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.Tests` +
|
||||||
|
`tests/ZB.MOM.WW.OtOpcUa.Driver.*.Cli.Tests` to re-verify.
|
||||||
@@ -22,6 +22,12 @@ Supporting DTOs live alongside the interface in `Core.Abstractions`:
|
|||||||
- `HistoricalEvent(EventId, SourceName?, EventTimeUtc, ReceivedTimeUtc, Message?, Severity)`
|
- `HistoricalEvent(EventId, SourceName?, EventTimeUtc, ReceivedTimeUtc, Message?, Severity)`
|
||||||
- `HistoricalEventsResult(IReadOnlyList<HistoricalEvent> Events, byte[]? ContinuationPoint)`
|
- `HistoricalEventsResult(IReadOnlyList<HistoricalEvent> Events, byte[]? ContinuationPoint)`
|
||||||
|
|
||||||
|
## Alarm event history vs. `IHistoryProvider`
|
||||||
|
|
||||||
|
`IHistoryProvider.ReadEventsAsync` is the **pull** path: an OPC UA client calls `HistoryReadEvents` against a notifier node and the driver walks its own backend event store to satisfy the request. The Galaxy driver's implementation reads from AVEVA Historian's event schema via `aahClientManaged`; every other driver leaves the default `NotSupportedException` in place.
|
||||||
|
|
||||||
|
There is also a separate **push** path for persisting alarm transitions from any `IAlarmSource` (and the Phase 7 scripted-alarm engine) into a durable event log, independent of any client HistoryRead call. That path is covered by `IAlarmHistorianSink` + `SqliteStoreAndForwardSink` in `src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/` and is documented in [AlarmTracking.md#alarm-historian-sink](AlarmTracking.md#alarm-historian-sink). The two paths are complementary — the sink populates an external historian's alarm schema; `ReadEventsAsync` reads from whatever event store the driver owns — and share neither interface nor dispatch.
|
||||||
|
|
||||||
## Dispatch through `CapabilityInvoker`
|
## Dispatch through `CapabilityInvoker`
|
||||||
|
|
||||||
All four HistoryRead surfaces are wrapped by `CapabilityInvoker` (`Core/Resilience/CapabilityInvoker.cs`) with `DriverCapability.HistoryRead`. The Polly pipeline keyed on `(DriverInstanceId, HostName, DriverCapability.HistoryRead)` provides timeout, circuit-breaker, and bulkhead defaults per the driver's stability tier (see [docs/v2/driver-stability.md](v2/driver-stability.md)).
|
All four HistoryRead surfaces are wrapped by `CapabilityInvoker` (`Core/Resilience/CapabilityInvoker.cs`) with `DriverCapability.HistoryRead`. The Polly pipeline keyed on `(DriverInstanceId, HostName, DriverCapability.HistoryRead)` provides timeout, circuit-breaker, and bulkhead defaults per the driver's stability tier (see [docs/v2/driver-stability.md](v2/driver-stability.md)).
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ Exceptions during teardown are swallowed per decision #12 — a driver throw mus
|
|||||||
|
|
||||||
When `RediscoveryEventArgs.ScopeHint` is non-null (e.g. a folder path), Core restricts the diff to that subtree. This matters for Galaxy Platform-scoped deployments where a `time_of_last_deploy` advance may only affect one platform's subtree, and for OPC UA Client where an upstream change may be localized. Null scope falls back to a full-tree diff.
|
When `RediscoveryEventArgs.ScopeHint` is non-null (e.g. a folder path), Core restricts the diff to that subtree. This matters for Galaxy Platform-scoped deployments where a `time_of_last_deploy` advance may only affect one platform's subtree, and for OPC UA Client where an upstream change may be localized. Null scope falls back to a full-tree diff.
|
||||||
|
|
||||||
|
## Virtual tags in the rebuild
|
||||||
|
|
||||||
|
Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md), virtual (scripted) tags live in the same address space as driver tags and flow through the same rebuild. `EquipmentNodeWalker` (`src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs`) emits virtual-tag children alongside driver-tag children with `DriverAttributeInfo.Source = NodeSourceKind.Virtual`, and `DriverNodeManager` registers each variable's source in `_sourceByFullRef` so the dispatch branches correctly after rebuild. Virtual-tag script changes published from the Admin UI land through the same generation-publish path — the `VirtualTagEngine` recompiles its script bundle when its config row changes and `DriverNodeManager` re-registers any added/removed virtual variables through the standard diff path. Subscription restoration after rebuild runs through each source's `ISubscribable` — either the driver's or `VirtualTagSource` — without special-casing.
|
||||||
|
|
||||||
## Active subscriptions survive rebuild
|
## Active subscriptions survive rebuild
|
||||||
|
|
||||||
Subscriptions for unchanged references stay live across rebuilds — their ref-count map is not disturbed. Clients monitoring a stable tag never see a data-change gap during a deploy, only clients monitoring a tag that was genuinely removed see the subscription drop.
|
Subscriptions for unchanged references stay live across rebuilds — their ref-count map is not disturbed. Clients monitoring a stable tag never see a data-change gap during a deploy, only clients monitoring a tag that was genuinely removed see the subscription drop.
|
||||||
|
|||||||
@@ -29,12 +29,21 @@ The project was originally called **LmxOpcUa** (a single-driver Galaxy/MXAccess
|
|||||||
| [DataTypeMapping.md](DataTypeMapping.md) | Per-driver `DriverAttributeInfo` → OPC UA variable types |
|
| [DataTypeMapping.md](DataTypeMapping.md) | Per-driver `DriverAttributeInfo` → OPC UA variable types |
|
||||||
| [IncrementalSync.md](IncrementalSync.md) | Address-space rebuild on redeploy + `sp_ComputeGenerationDiff` |
|
| [IncrementalSync.md](IncrementalSync.md) | Address-space rebuild on redeploy + `sp_ComputeGenerationDiff` |
|
||||||
| [HistoricalDataAccess.md](HistoricalDataAccess.md) | `IHistoryProvider` as a per-driver optional capability |
|
| [HistoricalDataAccess.md](HistoricalDataAccess.md) | `IHistoryProvider` as a per-driver optional capability |
|
||||||
|
| [VirtualTags.md](VirtualTags.md) | `Core.Scripting` + `Core.VirtualTags` — Roslyn script sandbox, engine, dispatch alongside driver tags |
|
||||||
|
| [ScriptedAlarms.md](ScriptedAlarms.md) | `Core.ScriptedAlarms` — script-predicate `IAlarmSource` + Part 9 state machine |
|
||||||
|
|
||||||
|
Two Core subsystems are shipped without a dedicated top-level doc; see the section in the linked doc:
|
||||||
|
|
||||||
|
| Project | See |
|
||||||
|
|---------|-----|
|
||||||
|
| `Core.AlarmHistorian` | [AlarmTracking.md](AlarmTracking.md) § Alarm historian sink |
|
||||||
|
| `Analyzers` (Roslyn OTOPCUA0001) | [security.md](security.md) § OTOPCUA0001 Analyzer |
|
||||||
|
|
||||||
### Drivers
|
### Drivers
|
||||||
|
|
||||||
| Doc | Covers |
|
| Doc | Covers |
|
||||||
|-----|--------|
|
|-----|--------|
|
||||||
| [drivers/README.md](drivers/README.md) | Index of the seven shipped drivers + capability matrix |
|
| [drivers/README.md](drivers/README.md) | Index of the eight shipped drivers + capability matrix |
|
||||||
| [drivers/Galaxy.md](drivers/Galaxy.md) | Galaxy driver — MXAccess bridge, Host/Proxy split, named-pipe IPC |
|
| [drivers/Galaxy.md](drivers/Galaxy.md) | Galaxy driver — MXAccess bridge, Host/Proxy split, named-pipe IPC |
|
||||||
| [drivers/Galaxy-Repository.md](drivers/Galaxy-Repository.md) | Galaxy-specific discovery via the ZB SQL database |
|
| [drivers/Galaxy-Repository.md](drivers/Galaxy-Repository.md) | Galaxy-specific discovery via the ZB SQL database |
|
||||||
|
|
||||||
@@ -54,8 +63,15 @@ For Modbus / S7 / AB CIP / AB Legacy / TwinCAT / FOCAS / OPC UA Client specifics
|
|||||||
|
|
||||||
| Doc | Covers |
|
| Doc | Covers |
|
||||||
|-----|--------|
|
|-----|--------|
|
||||||
| [Client.CLI.md](Client.CLI.md) | `lmxopcua-cli` — command-line client |
|
| [Client.CLI.md](Client.CLI.md) | `otopcua-cli` — OPC UA command-line client |
|
||||||
| [Client.UI.md](Client.UI.md) | Avalonia desktop client |
|
| [Client.UI.md](Client.UI.md) | Avalonia desktop client |
|
||||||
|
| [DriverClis.md](DriverClis.md) | Driver test-client CLIs — index + shared commands |
|
||||||
|
| [Driver.Modbus.Cli.md](Driver.Modbus.Cli.md) | `otopcua-modbus-cli` — Modbus-TCP |
|
||||||
|
| [Driver.AbCip.Cli.md](Driver.AbCip.Cli.md) | `otopcua-abcip-cli` — ControlLogix / CompactLogix / Micro800 / GuardLogix |
|
||||||
|
| [Driver.AbLegacy.Cli.md](Driver.AbLegacy.Cli.md) | `otopcua-ablegacy-cli` — SLC / MicroLogix / PLC-5 (PCCC) |
|
||||||
|
| [Driver.S7.Cli.md](Driver.S7.Cli.md) | `otopcua-s7-cli` — Siemens S7-300 / S7-400 / S7-1200 / S7-1500 |
|
||||||
|
| [Driver.TwinCAT.Cli.md](Driver.TwinCAT.Cli.md) | `otopcua-twincat-cli` — Beckhoff TwinCAT 2/3 ADS |
|
||||||
|
| [Driver.FOCAS.Cli.md](Driver.FOCAS.Cli.md) | `otopcua-focas-cli` — Fanuc FOCAS/2 CNC |
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
`DriverNodeManager` (`src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs`) wires the OPC UA stack's per-variable `OnReadValue` and `OnWriteValue` hooks to each driver's `IReadable` and `IWritable` capabilities. Every dispatch flows through `CapabilityInvoker` so the Polly pipeline (retry / timeout / breaker / bulkhead) applies uniformly across Galaxy, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client drivers.
|
`DriverNodeManager` (`src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs`) wires the OPC UA stack's per-variable `OnReadValue` and `OnWriteValue` hooks to each driver's `IReadable` and `IWritable` capabilities. Every dispatch flows through `CapabilityInvoker` so the Polly pipeline (retry / timeout / breaker / bulkhead) applies uniformly across Galaxy, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, and OPC UA Client drivers.
|
||||||
|
|
||||||
|
## Driver vs virtual dispatch
|
||||||
|
|
||||||
|
Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md), a single `DriverNodeManager` routes reads and writes across both driver-sourced and virtual (scripted) tags. At discovery time each variable registers a `NodeSourceKind` (`src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs`) in the manager's `_sourceByFullRef` lookup; the read/write hooks pattern-match on that value to pick the backend:
|
||||||
|
|
||||||
|
- `NodeSourceKind.Driver` — dispatches to the driver's `IReadable` / `IWritable` through `CapabilityInvoker` (the rest of this doc).
|
||||||
|
- `NodeSourceKind.Virtual` — dispatches to `VirtualTagSource` (`src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs`), which wraps `VirtualTagEngine`. Writes are rejected with `BadUserAccessDenied` before the branch per Phase 7 decision #6 — scripts are the only write path into virtual tags.
|
||||||
|
- `NodeSourceKind.ScriptedAlarm` — dispatches to the Phase 7 `ScriptedAlarmReadable` shim.
|
||||||
|
|
||||||
|
ACL enforcement (`WriteAuthzPolicy` + `AuthorizationGate`) runs before the source branch, so the gates below apply uniformly to all three source kinds.
|
||||||
|
|
||||||
## OnReadValue
|
## OnReadValue
|
||||||
|
|
||||||
The hook is registered on every `BaseDataVariableState` created by the `IAddressSpaceBuilder.Variable(...)` call during discovery. When the stack dispatches a Read for a node in this namespace:
|
The hook is registered on every `BaseDataVariableState` created by the `IAddressSpaceBuilder.Variable(...)` call during discovery. When the stack dispatches a Read for a node in this namespace:
|
||||||
@@ -20,7 +30,7 @@ The hook is synchronous — the async invoker call is bridged with `AsTask().Get
|
|||||||
|
|
||||||
### Authorization (two layers)
|
### Authorization (two layers)
|
||||||
|
|
||||||
1. **SecurityClassification gate.** Every variable stores its `SecurityClassification` in `_securityByFullRef` at registration time (populated from `DriverAttributeInfo.SecurityClass`). `WriteAuthzPolicy.IsAllowed(classification, userRoles)` runs first, consulting the session's roles via `context.UserIdentity is IRoleBearer`. `FreeAccess` passes anonymously, `ViewOnly` denies everyone, and `Operate / Tune / Configure / SecuredWrite / VerifiedWrite` require `WriteOperate / WriteTune / WriteConfigure` roles respectively. Denial returns `BadUserAccessDenied` without consulting the driver — drivers never enforce ACLs themselves; they only report classification as discovery metadata (feedback `feedback_acl_at_server_layer.md`).
|
1. **SecurityClassification gate.** Every variable stores its `SecurityClassification` in `_securityByFullRef` at registration time (populated from `DriverAttributeInfo.SecurityClass`). `WriteAuthzPolicy.IsAllowed(classification, userRoles)` runs first, consulting the session's roles via `context.UserIdentity is IRoleBearer`. `FreeAccess` passes anonymously, `ViewOnly` denies everyone, and `Operate / Tune / Configure / SecuredWrite / VerifiedWrite` require `WriteOperate / WriteTune / WriteConfigure` roles respectively. Denial returns `BadUserAccessDenied` without consulting the driver — drivers never enforce ACLs themselves; they only report classification as discovery metadata (see `docs/security.md`).
|
||||||
2. **Phase 6.2 permission-trie gate.** When `AuthorizationGate` is wired, it re-runs with the operation derived from `WriteAuthzPolicy.ToOpcUaOperation(classification)`. The gate consults the per-cluster permission trie loaded from `NodeAcl` rows, enforcing fine-grained per-tag ACLs on top of the role-based classification policy. See `docs/v2/acl-design.md`.
|
2. **Phase 6.2 permission-trie gate.** When `AuthorizationGate` is wired, it re-runs with the operation derived from `WriteAuthzPolicy.ToOpcUaOperation(classification)`. The gate consults the per-cluster permission trie loaded from `NodeAcl` rows, enforcing fine-grained per-tag ACLs on top of the role-based classification policy. See `docs/v2/acl-design.md`.
|
||||||
|
|
||||||
### Dispatch
|
### Dispatch
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ Role swaps, stand-alone promotions, and base-level adjustments all happen throug
|
|||||||
|
|
||||||
The OtOpcUa Client CLI at `src/ZB.MOM.WW.OtOpcUa.Client.CLI` supports `-F` / `--failover-urls` for automatic client-side failover; for long-running subscriptions the CLI monitors session KeepAlive and reconnects to the next available server, recreating the subscription on the new endpoint. See [`Client.CLI.md`](Client.CLI.md) for the command reference.
|
The OtOpcUa Client CLI at `src/ZB.MOM.WW.OtOpcUa.Client.CLI` supports `-F` / `--failover-urls` for automatic client-side failover; for long-running subscriptions the CLI monitors session KeepAlive and reconnects to the next available server, recreating the subscription on the new endpoint. See [`Client.CLI.md`](Client.CLI.md) for the command reference.
|
||||||
|
|
||||||
|
## vs. upstream-side redundancy
|
||||||
|
|
||||||
|
The mechanics on this page describe **OtOpcUa as a redundant server** — two of our instances clustered behind one OPC UA address space, exposing `ServerUriArray` + dynamic `ServiceLevel` to downstream clients. The mirror-image scenario — **the OPC UA Client driver consuming an upstream redundant pair** — is documented separately in [`drivers/OpcUaClient.md` § Upstream redundancy](drivers/OpcUaClient.md#upstream-redundancy-serverarray). Both rely on the same OPC UA Part 4 § 6.6.2 model (non-transparent warm/hot via `RedundancySupport` + `ServerUriArray` + `ServiceLevel`); they sit at opposite ends of the gateway pipeline. A deployment can wire either, both, or neither.
|
||||||
|
|
||||||
## Depth reference
|
## Depth reference
|
||||||
|
|
||||||
For the full decision trail and implementation plan — topology invariants, peer-probe cadence, recovery-dwell policy, compliance-script guard against enum-value drift — see `docs/v2/plan.md` §Phase 6.3.
|
For the full decision trail and implementation plan — topology invariants, peer-probe cadence, recovery-dwell policy, compliance-script guard against enum-value drift — see `docs/v2/plan.md` §Phase 6.3.
|
||||||
|
|||||||
125
docs/ScriptedAlarms.md
Normal file
125
docs/ScriptedAlarms.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# Scripted Alarms
|
||||||
|
|
||||||
|
`Core.ScriptedAlarms` is the Phase 7 subsystem that raises OPC UA Part 9 alarms from operator-authored C# predicates rather than from driver-native alarm streams. Scripted alarms are additive: Galaxy, AB CIP, FOCAS, and OPC UA Client drivers keep their native `IAlarmSource` implementations unchanged, and a `ScriptedAlarmSource` simply registers as another source in the same fan-out. Predicates read tags from any source (driver tags or virtual tags) through the shared `ITagUpstreamSource` and emit condition transitions through the engine's Part 9 state machine.
|
||||||
|
|
||||||
|
This file covers the engine internals — predicate evaluation, state machine, persistence, and the engine-to-`IAlarmSource` adapter. The server-side plumbing that turns those emissions into OPC UA `AlarmConditionState` nodes, applies retries, persists alarm transitions to the Historian, and routes operator acks through the session's `AlarmAck` permission lives in [AlarmTracking.md](AlarmTracking.md) and is not repeated here.
|
||||||
|
|
||||||
|
## Definition shape
|
||||||
|
|
||||||
|
`ScriptedAlarmDefinition` (`src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmDefinition.cs`) is the runtime contract the engine consumes. The generation-publish path materialises these from the `ScriptedAlarm` + `Script` config tables via `Phase7EngineComposer.ProjectScriptedAlarms`.
|
||||||
|
|
||||||
|
| Field | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `AlarmId` | Stable identity. Also the OPC UA `ConditionId` and the key in `IAlarmStateStore`. Convention: `{EquipmentPath}::{AlarmName}`. |
|
||||||
|
| `EquipmentPath` | UNS path the alarm hangs under in the address space. ACL scope inherits from the equipment node. |
|
||||||
|
| `AlarmName` | Browse-tree display name. |
|
||||||
|
| `Kind` | `AlarmKind` — `AlarmCondition`, `LimitAlarm`, `DiscreteAlarm`, or `OffNormalAlarm`. Controls only the OPC UA ObjectType the node surfaces as; the internal state machine is identical for all four. |
|
||||||
|
| `Severity` | `AlarmSeverity` enum (`Low` / `Medium` / `High` / `Critical`). Static per decision #13 — the predicate does not compute severity. The DB column is an OPC UA Part 9 1..1000 integer; `Phase7EngineComposer.MapSeverity` bands it into the four-value enum. |
|
||||||
|
| `MessageTemplate` | String with `{TagPath}` placeholders, resolved at emission time. See below. |
|
||||||
|
| `PredicateScriptSource` | Roslyn C# script returning `bool`. `true` = condition active; `false` = cleared. |
|
||||||
|
| `HistorizeToAveva` | When true, every emission is enqueued to `IAlarmHistorianSink`. Default true. Galaxy-native alarms default false since Galaxy historises them directly. |
|
||||||
|
| `Retain` | Part 9 retain flag — keep the condition visible after clear while un-acked/un-confirmed transitions remain. Default true. |
|
||||||
|
|
||||||
|
Illustrative definition:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
new ScriptedAlarmDefinition(
|
||||||
|
AlarmId: "Plant/Line1/Oven::OverTemp",
|
||||||
|
EquipmentPath: "Plant/Line1/Oven",
|
||||||
|
AlarmName: "OverTemp",
|
||||||
|
Kind: AlarmKind.LimitAlarm,
|
||||||
|
Severity: AlarmSeverity.High,
|
||||||
|
MessageTemplate: "Oven {Plant/Line1/Oven/Temp} exceeds limit {Plant/Line1/Oven/TempLimit}",
|
||||||
|
PredicateScriptSource: "return GetTag(\"Plant/Line1/Oven/Temp\").AsDouble() > GetTag(\"Plant/Line1/Oven/TempLimit\").AsDouble();");
|
||||||
|
```
|
||||||
|
|
||||||
|
## Predicate evaluation
|
||||||
|
|
||||||
|
Alarm predicates reuse the same Roslyn sandbox as virtual tags — `ScriptEvaluator<AlarmPredicateContext, bool>` compiles the source, `TimedScriptEvaluator` wraps it with the configured timeout (default from `TimedScriptEvaluator.DefaultTimeout`), and `DependencyExtractor` statically harvests the tag paths the script reads. The sandbox rules (forbidden types, cancellation, logging sinks) are documented in [VirtualTags.md](VirtualTags.md); ScriptedAlarms does not redefine them.
|
||||||
|
|
||||||
|
`AlarmPredicateContext` (`AlarmPredicateContext.cs`) is the script's `ScriptContext` subclass:
|
||||||
|
|
||||||
|
- `GetTag(path)` returns a `DataValueSnapshot` from the engine-maintained read cache. Missing path → `DataValueSnapshot(null, 0x80340000u, null, now)` (`BadNodeIdUnknown`). An empty path returns the same.
|
||||||
|
- `SetVirtualTag(path, value)` throws `InvalidOperationException`. Predicates must be side-effect free per plan decision #6; writes would couple alarm state to virtual-tag state in ways that are near-impossible to reason about. Operators see the rejection in `scripts-*.log`.
|
||||||
|
- `Now` and `Logger` are provided by the engine.
|
||||||
|
|
||||||
|
Evaluation cadence:
|
||||||
|
|
||||||
|
- On every upstream tag change that any alarm's input set references (`OnUpstreamChange` → `ReevaluateAsync`). The engine maintains an inverse index `tag path → alarm ids` (`_alarmsReferencing`); only affected alarms re-run.
|
||||||
|
- On a 5-second shelving-check timer (`_shelvingTimer`) for timed-shelve expiry.
|
||||||
|
- At `LoadAsync` for every alarm, to re-derive `ActiveState` per plan decision #14 (startup recovery).
|
||||||
|
|
||||||
|
If a predicate throws or times out, the engine logs the failure and leaves the prior `ActiveState` intact — it does not synthesise a clear. Operators investigating a broken predicate should never see a phantom clear preceding the error.
|
||||||
|
|
||||||
|
## Part 9 state machine
|
||||||
|
|
||||||
|
`Part9StateMachine` (`Part9StateMachine.cs`) is a pure `static` function set. Every transition takes the current `AlarmConditionState` plus the event, returns a new record and an `EmissionKind`. No I/O, no mutation, trivially unit-testable. Transitions map to OPC UA Part 9:
|
||||||
|
|
||||||
|
- `ApplyPredicate(current, predicateTrue, nowUtc)` — predicate re-evaluation. `Inactive → Active` sets `Acked = Unacknowledged` and `Confirmed = Unconfirmed`; `Active → Inactive` updates `LastClearedUtc` and consumes `OneShot` shelving. Disabled alarms no-op.
|
||||||
|
- `ApplyAcknowledge` / `ApplyConfirm` — operator ack/confirm. Require a non-empty user string (audit requirement). Each appends an `AlarmComment` with `Kind = "Acknowledge"` / `"Confirm"`.
|
||||||
|
- `ApplyOneShotShelve` / `ApplyTimedShelve(unshelveAtUtc)` / `ApplyUnshelve` — shelving transitions. `Timed` requires `unshelveAtUtc > nowUtc`.
|
||||||
|
- `ApplyEnable` / `ApplyDisable` — operator enable/disable. Disabled alarms ignore predicate results until re-enabled; on enable, `ActiveState` is re-derived from the next evaluation.
|
||||||
|
- `ApplyAddComment(text)` — append-only audit entry, no state change.
|
||||||
|
- `ApplyShelvingCheck(nowUtc)` — called by the 5s timer; promotes expired `Timed` shelving to `Unshelved` with a `system / AutoUnshelve` audit entry.
|
||||||
|
|
||||||
|
Two invariants the machine enforces:
|
||||||
|
|
||||||
|
1. **Disabled** alarms ignore every predicate evaluation — they never transition `ActiveState` / `AckedState` / `ConfirmedState` until re-enabled.
|
||||||
|
2. **Shelved** alarms still advance their internal state but emit `EmissionKind.Suppressed` instead of `Activated` / `Cleared`. The engine advances the state record (so startup recovery reflects reality) but `ScriptedAlarmSource` does not publish the suppressed transition to subscribers. `OneShot` expires on the next clear; `Timed` expires at `ShelvingState.UnshelveAtUtc`.
|
||||||
|
|
||||||
|
`EmissionKind` values: `None`, `Suppressed`, `Activated`, `Cleared`, `Acknowledged`, `Confirmed`, `Shelved`, `Unshelved`, `Enabled`, `Disabled`, `CommentAdded`.
|
||||||
|
|
||||||
|
## Message templates
|
||||||
|
|
||||||
|
`MessageTemplate` (`MessageTemplate.cs`) resolves `{path}` placeholders in the configured message at emission time. Syntax:
|
||||||
|
|
||||||
|
- `{path/with/slashes}` — brace-stripped contents are looked up via the engine's tag cache.
|
||||||
|
- No escaping. Literal braces in messages are not currently supported.
|
||||||
|
- `ExtractTokenPaths(template)` is called at `LoadAsync` so the engine subscribes to every referenced path (ensuring the value cache is populated before the first resolve).
|
||||||
|
|
||||||
|
Fallback rules: a resolved `DataValueSnapshot` with a non-zero `StatusCode`, a `null` `Value`, or an unknown path becomes `{?}`. The event still fires — the operator sees where the reference broke rather than having the alarm swallowed.
|
||||||
|
|
||||||
|
## State persistence
|
||||||
|
|
||||||
|
`IAlarmStateStore` (`IAlarmStateStore.cs`) is the persistence contract: `LoadAsync(alarmId)`, `LoadAllAsync`, `SaveAsync(state)`, `RemoveAsync(alarmId)`. `InMemoryAlarmStateStore` in the same file is the default for tests and dev deployments without a SQL backend. Stream E wires the production implementation against the `ScriptedAlarmState` config-DB table with audit logging through `Core.Abstractions.IAuditLogger`.
|
||||||
|
|
||||||
|
Persisted scope per plan decision #14: `Enabled`, `Acked`, `Confirmed`, `Shelving`, `LastTransitionUtc`, the `LastAck*` / `LastConfirm*` audit fields, and the append-only `Comments` list. `Active` is **not** trusted across restart — the engine re-runs the predicate at `LoadAsync` so operators never re-ack an alarm that was already acknowledged before an outage, and alarms whose condition cleared during downtime settle to `Inactive` without a spurious clear-event.
|
||||||
|
|
||||||
|
Every mutation the state machine produces is immediately persisted inside the engine's `_evalGate` semaphore, so the store's view is always consistent with the in-memory state.
|
||||||
|
|
||||||
|
## Source integration
|
||||||
|
|
||||||
|
`ScriptedAlarmSource` (`ScriptedAlarmSource.cs`) adapts the engine to the driver-agnostic `IAlarmSource` interface. The existing `AlarmSurfaceInvoker` + `GenericDriverNodeManager` fan-out consumes it the same way it consumes Galaxy / AB CIP / FOCAS sources — there is no scripted-alarm-specific code path in the server plumbing. From that point on, the flow into `AlarmConditionState` nodes, the `AlarmAck` session check, and the Historian sink is shared — see [AlarmTracking.md](AlarmTracking.md).
|
||||||
|
|
||||||
|
Two mapping notes specific to this adapter:
|
||||||
|
|
||||||
|
- `SubscribeAlarmsAsync` accepts a list of source-node-id filters, interpreted as Equipment-path prefixes. Empty list matches every alarm. Each emission is matched against every live subscription — the adapter keeps no per-subscription cursor.
|
||||||
|
- `IAlarmSource.AcknowledgeAsync` does not carry a user identity. The adapter defaults the audit user to `"opcua-client"` so callers using the base interface still produce an audit entry. The server's Part 9 method handlers (Stream G) call the engine's richer `AcknowledgeAsync` / `ConfirmAsync` / `OneShotShelveAsync` / `TimedShelveAsync` / `UnshelveAsync` / `AddCommentAsync` directly with the authenticated principal instead.
|
||||||
|
|
||||||
|
Emissions map into `AlarmEventArgs` as `AlarmType = Kind.ToString()`, `SourceNodeId = EquipmentPath`, `ConditionId = AlarmId`, `Message = resolved template string`, `Severity` carried verbatim, `SourceTimestampUtc = emission time`.
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
`Phase7EngineComposer.Compose` (`src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs`) is the single call site that instantiates the engine. It takes the generation's `Script` / `VirtualTag` / `ScriptedAlarm` rows, the shared `CachedTagUpstreamSource`, an `IAlarmStateStore`, and an `IAlarmHistorianSink`, and returns a `Phase7ComposedSources` the caller owns. When `scriptedAlarms.Count > 0`:
|
||||||
|
|
||||||
|
1. `ProjectScriptedAlarms` resolves each row's `PredicateScriptId` against the script dictionary and produces a `ScriptedAlarmDefinition` list. Unknown or disabled scripts throw immediately — the DB publish guarantees referential integrity but this is a belt-and-braces check.
|
||||||
|
2. A `ScriptedAlarmEngine` is constructed with the upstream source, the store, a shared `ScriptLoggerFactory` keyed to `scripts-*.log`, and the root Serilog logger.
|
||||||
|
3. `alarmEngine.OnEvent` is wired to `RouteToHistorianAsync`, which projects each emission into an `AlarmHistorianEvent` and enqueues it on the sink. Fire-and-forget — the SQLite store-and-forward sink is already non-blocking.
|
||||||
|
4. `LoadAsync(alarmDefs)` runs synchronously on the startup thread: it compiles every predicate, subscribes to the union of predicate inputs and message-template tokens, seeds the value cache, loads persisted state, re-derives `ActiveState` from a fresh predicate evaluation, and starts the 5s shelving timer. Compile failures are aggregated into one `InvalidOperationException` so operators see every bad predicate in one startup log line rather than one at a time.
|
||||||
|
5. A `ScriptedAlarmSource` is created for the event stream, and a `ScriptedAlarmReadable` (`src/ZB.MOM.WW.OtOpcUa.Server/Phase7/ScriptedAlarmReadable.cs`) is created for OPC UA variable reads on the alarm's active-state node (task #245) — unknown alarm ids return `BadNodeIdUnknown` rather than silently reading `false`.
|
||||||
|
|
||||||
|
Both engine and source are added to `Phase7ComposedSources.Disposables`, which `Phase7Composer` disposes on server shutdown.
|
||||||
|
|
||||||
|
## Key source files
|
||||||
|
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs` — orchestrator, cascade wiring, shelving timer, `OnEvent` emission
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs` — `IAlarmSource` adapter over the engine
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmDefinition.cs` — runtime definition record
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs` — pure-function state machine + `TransitionResult` / `EmissionKind`
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmConditionState.cs` — persisted state record + `AlarmComment` audit entry + `ShelvingState`
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmPredicateContext.cs` — script-side `ScriptContext` (read-only, write rejected)
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmTypes.cs` — `AlarmKind` + the four Part 9 enums
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs` — `{path}` placeholder resolver
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/IAlarmStateStore.cs` — persistence contract + `InMemoryAlarmStateStore` default
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs` — composition, config-row projection, historian routing
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Server/Phase7/ScriptedAlarmReadable.cs` — `IReadable` adapter exposing `ActiveState` to OPC UA variable reads
|
||||||
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
Driver-side data-change subscriptions live behind `ISubscribable` (`src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ISubscribable.cs`). The interface is deliberately mechanism-agnostic: it covers native subscriptions (Galaxy MXAccess advisory, OPC UA monitored items on an upstream server, TwinCAT ADS notifications) and driver-internal polled subscriptions (Modbus, AB CIP, S7, FOCAS). Core sees the same event shape regardless — drivers fire `OnDataChange` and Core dispatches to the matching OPC UA monitored items.
|
Driver-side data-change subscriptions live behind `ISubscribable` (`src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ISubscribable.cs`). The interface is deliberately mechanism-agnostic: it covers native subscriptions (Galaxy MXAccess advisory, OPC UA monitored items on an upstream server, TwinCAT ADS notifications) and driver-internal polled subscriptions (Modbus, AB CIP, S7, FOCAS). Core sees the same event shape regardless — drivers fire `OnDataChange` and Core dispatches to the matching OPC UA monitored items.
|
||||||
|
|
||||||
|
## Driver vs virtual dispatch
|
||||||
|
|
||||||
|
Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md), `DriverNodeManager` routes subscriptions across both driver tags and virtual (scripted) tags through the same `ISubscribable` contract. The per-variable `NodeSourceKind` (registered from `DriverAttributeInfo` at discovery) selects the backend:
|
||||||
|
|
||||||
|
- `NodeSourceKind.Driver` — subscribes via the driver's `ISubscribable`, wrapped by `CapabilityInvoker` (the rest of this doc).
|
||||||
|
- `NodeSourceKind.Virtual` — subscribes via `VirtualTagSource` (`src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs`), which forwards change events emitted by `VirtualTagEngine` as `OnDataChange`. The ref-counting, initial-value, and transfer-restoration behaviour below applies identically.
|
||||||
|
|
||||||
|
Because both kinds expose `ISubscribable`, Core's dispatch, ref-count map, and monitored-item fan-out are unchanged across the source branch.
|
||||||
|
|
||||||
## ISubscribable surface
|
## ISubscribable surface
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
|
|||||||
142
docs/VirtualTags.md
Normal file
142
docs/VirtualTags.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# Virtual Tags
|
||||||
|
|
||||||
|
Virtual tags are OPC UA variable nodes whose values are computed by operator-authored C# scripts against other tags (driver or virtual). They live in the Equipment browse tree alongside driver-sourced variables: a client browsing `Enterprise/Site/Area/Line/Equipment/` sees one flat child list that mixes both kinds, and a read / subscribe on a virtual node looks identical to one on a driver node from the wire. The separation is server-side — `NodeScopeResolver` tags each variable's `NodeSource` (`Driver` / `Virtual` / `ScriptedAlarm`), and `DriverNodeManager` dispatches reads to different backends accordingly. See [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md) for the dispatch decision.
|
||||||
|
|
||||||
|
The runtime is split across two projects: `Core.Scripting` holds the Roslyn sandbox + evaluator primitives that are reused by both virtual tags and scripted alarms; `Core.VirtualTags` holds the engine that owns the dependency graph, the evaluation pipeline, and the `ISubscribable` adapter the server dispatches to.
|
||||||
|
|
||||||
|
## Roslyn script sandbox (`Core.Scripting`)
|
||||||
|
|
||||||
|
User scripts are compiled via `Microsoft.CodeAnalysis.CSharp.Scripting` against a `ScriptContext` subclass. `ScriptGlobals<TContext>` exposes the context as a field named `ctx`, so scripts read `ctx.GetTag("...")` / `ctx.SetVirtualTag("...", ...)` / `ctx.Now` / `ctx.Logger` and return a value.
|
||||||
|
|
||||||
|
### Compile pipeline (`ScriptEvaluator<TContext, TResult>`)
|
||||||
|
|
||||||
|
`ScriptEvaluator.Compile(source)` is a three-step gate:
|
||||||
|
|
||||||
|
1. **Roslyn compile** against `ScriptSandbox.Build(contextType)`. Throws `CompilationErrorException` on syntax / type errors.
|
||||||
|
2. **`ForbiddenTypeAnalyzer.Analyze`** walks the syntax tree post-compile and resolves every referenced symbol against the deny-list. Throws `ScriptSandboxViolationException` with every offending source span attached. This is defence-in-depth: `ScriptOptions` alone cannot block every BCL namespace because .NET type forwarding routes types through assemblies the allow-list does permit.
|
||||||
|
3. **Delegate materialization** — `script.CreateDelegate()`. Failures here are Roslyn-internal; user scripts don't reach this step.
|
||||||
|
|
||||||
|
`ScriptSandbox.Build` allow-lists exactly: `System.Private.CoreLib` (primitives + `Math` + `Convert`), `System.Linq`, `Core.Abstractions` (for `DataValueSnapshot` / `DriverDataType`), `Core.Scripting` (for `ScriptContext` + `Deadband`), `Serilog` (for `ILogger`), and the concrete context type's assembly. Pre-imported namespaces: `System`, `System.Linq`, `ZB.MOM.WW.OtOpcUa.Core.Abstractions`, `ZB.MOM.WW.OtOpcUa.Core.Scripting`.
|
||||||
|
|
||||||
|
`ForbiddenTypeAnalyzer.ForbiddenNamespacePrefixes` currently denies `System.IO`, `System.Net`, `System.Diagnostics`, `System.Reflection`, `System.Threading.Thread`, `System.Runtime.InteropServices`, `Microsoft.Win32`. Matching is by prefix against the resolved symbol's containing namespace, so `System.Net` catches `System.Net.Http.HttpClient` and every subnamespace. `System.Environment` is explicitly allowed.
|
||||||
|
|
||||||
|
### Compile cache (`CompiledScriptCache<TContext, TResult>`)
|
||||||
|
|
||||||
|
`ConcurrentDictionary<string, Lazy<ScriptEvaluator<...>>>` keyed on `SHA-256(UTF8(source))` rendered to hex. `Lazy<T>` with `ExecutionAndPublication` mode means two threads racing a miss compile exactly once. Failed compiles evict the entry so a corrected retry can succeed (used during Admin UI authoring). No capacity bound — scripts are operator-authored and bounded by the config DB. Whitespace changes miss the cache on purpose. `Clear()` is called on config-publish.
|
||||||
|
|
||||||
|
### Per-evaluation timeout (`TimedScriptEvaluator<TContext, TResult>`)
|
||||||
|
|
||||||
|
Wraps `ScriptEvaluator` with a wall-clock budget. Default `DefaultTimeout = 250ms`. Implementation pushes the inner `RunAsync` onto `Task.Run` (so a CPU-bound script can't hog the calling thread before `WaitAsync` registers its timeout) then awaits `runTask.WaitAsync(Timeout, ct)`. A `TimeoutException` from `WaitAsync` is wrapped as `ScriptTimeoutException`. Caller-supplied `CancellationToken` cancellation wins over the timeout and propagates as `OperationCanceledException` — so a shutdown cancel is not misclassified. **Known leak:** when a CPU-bound script times out, the underlying `ScriptRunner` keeps running on its thread-pool thread until the Roslyn runtime returns (documented trade-off; out-of-process evaluation is a v3 concern).
|
||||||
|
|
||||||
|
### Script logger plumbing
|
||||||
|
|
||||||
|
`ScriptLoggerFactory.Create(scriptName)` returns a per-script Serilog logger with the `ScriptName` structured property bound (constant `ScriptLoggerFactory.ScriptNameProperty`). The root script logger is typically a rolling file sink to `scripts-*.log`. `ScriptLogCompanionSink` is attached to the root pipeline and mirrors script events at `Error` or higher into the main `opcua-*.log` at `Warning` level — operators see script errors in the primary log without drowning it in script-authored Info/Debug noise. Exceptions and the `ScriptName` property are preserved in the mirror.
|
||||||
|
|
||||||
|
### Static dependency extraction (`DependencyExtractor`)
|
||||||
|
|
||||||
|
Parses the script source with `CSharpSyntaxTree.ParseText` (script kind), walks invocation expressions, and records every `ctx.GetTag("literal")` and `ctx.SetVirtualTag("literal", ...)` call. The first argument **must** be a string literal — variables, concatenation, interpolation, and method-returned strings are rejected at publish with a `DependencyRejection` carrying the exact `TextSpan`. This is how the engine builds its change-trigger graph statically; scripts cannot smuggle a dependency past the extractor.
|
||||||
|
|
||||||
|
## Virtual tag engine (`Core.VirtualTags`)
|
||||||
|
|
||||||
|
### `VirtualTagDefinition`
|
||||||
|
|
||||||
|
One row per operator-authored tag. Fields: `Path` (UNS browse path; also the engine's internal id), `DataType` (`DriverDataType` enum; the evaluator coerces the script's return value to this and mismatch surfaces as `BadTypeMismatch`), `ScriptSource` (Roslyn C# script text), `ChangeTriggered` (re-evaluate on any input delta), `TimerInterval` (optional periodic cadence; null disables), `Historize` (route every evaluation result to `IHistoryWriter`). Change-trigger and timer are independent — a tag can be either, both, or neither.
|
||||||
|
|
||||||
|
### `VirtualTagContext`
|
||||||
|
|
||||||
|
Subclass of `ScriptContext`. Constructed fresh per evaluation over a per-run read cache — scripts cannot stash mutable state across runs on `ctx`. `GetTag(path)` serves from the cache; missing-path reads return a `BadNodeIdUnknown`-quality snapshot. `SetVirtualTag(path, value)` routes through the engine's `OnScriptSetVirtualTag` callback so cross-tag writes still participate in change-trigger cascades (writes to non-virtual / non-registered paths log a warning and drop). `Now` is an injectable clock; production wires `DateTime.UtcNow`, tests pin it.
|
||||||
|
|
||||||
|
### `DependencyGraph`
|
||||||
|
|
||||||
|
Directed graph of tag paths. Edges run from a virtual tag to each path it reads. Unregistered paths (driver tags) are implicit leaves; leaf validity is checked elsewhere against the authoritative catalog. Two operations:
|
||||||
|
|
||||||
|
- **`TopologicalSort()`** — Kahn's algorithm. Produces evaluation order such that every node appears after its registered (virtual) dependencies. Throws `DependencyCycleException` (with every cycle, not just one) on offense.
|
||||||
|
- **`TransitiveDependentsInOrder(nodeId)`** — DFS collects every reachable dependent of a changed upstream then sorts by topological rank. Used by the cascade dispatcher so a single upstream delta recomputes the full downstream closure in one serial pass without needing a second iteration.
|
||||||
|
|
||||||
|
Cycle detection uses an **iterative** Tarjan's SCC implementation (no recursion, deep graphs cannot stack-overflow). Cycles of length > 1 and self-loops both reject; leaf references cannot form cycles with internal nodes.
|
||||||
|
|
||||||
|
### `VirtualTagEngine` lifecycle
|
||||||
|
|
||||||
|
- **`Load(definitions)`** — clears prior state, compiles every script through `DependencyExtractor.Extract` + `ScriptEvaluator.Compile` (wrapped in `TimedScriptEvaluator`), registers each in `_tags` + `_graph`, runs `TopologicalSort` (cycle check), then for every upstream (non-virtual) path subscribes via `ITagUpstreamSource.SubscribeTag` and seeds `_valueCache` with `ReadTag`. Throws `InvalidOperationException` aggregating every compile failure at once so operators see the whole set; throws `DependencyCycleException` on cycles. Re-entrant — supports config-publish reloads by disposing the prior upstream subscriptions first.
|
||||||
|
- **`EvaluateAllAsync(ct)`** — evaluates every tag once in topological order. Called at startup so virtual tags have a defined initial value before subscriptions start.
|
||||||
|
- **`EvaluateOneAsync(path, ct)`** — single-tag evaluation. Entry point for `TimerTriggerScheduler` + tests.
|
||||||
|
- **`Read(path)`** — synchronous last-known-value lookup from `_valueCache`. Returns `BadNodeIdUnknown`-quality for unregistered paths.
|
||||||
|
- **`Subscribe(path, observer)`** — register a change observer; returns `IDisposable`. Does **not** emit a seed value.
|
||||||
|
- **`OnUpstreamChange(path, value)`** (internal, wired from the upstream subscription) — updates cache, notifies observers, launches `CascadeAsync` fire-and-forget so the driver's dispatcher isn't blocked.
|
||||||
|
|
||||||
|
Evaluations are **serial across all tags** — `_evalGate` is a `SemaphoreSlim(1, 1)` held around every `EvaluateInternalAsync`. Parallelism is deferred (Phase 7 plan decision #19). Rationale: serial execution preserves the "earlier topological nodes computed before later dependents" invariant when two cascades race. Per-tag error isolation: a script exception or timeout sets that tag's quality to `BadInternalError` and logs a structured error; other tags keep evaluating. `OperationCanceledException` is re-thrown (shutdown path).
|
||||||
|
|
||||||
|
Result coercion: `CoerceResult` maps the script's return value to the declared `DriverDataType` via `Convert.ToXxx`. Coercion failure returns null which the outer pipeline maps to `BadInternalError`; `BadTypeMismatch` is documented in the definition shape (`VirtualTagDefinition.DataType` doc) rather than emitted distinctly today.
|
||||||
|
|
||||||
|
`IHistoryWriter.Record` fires per evaluation when `Historize = true`. The default `NullHistoryWriter` drops silently.
|
||||||
|
|
||||||
|
### `TimerTriggerScheduler`
|
||||||
|
|
||||||
|
Groups `VirtualTagDefinition`s by `TimerInterval`, one `System.Threading.Timer` per unique interval. Each tick evaluates the group's paths serially via `VirtualTagEngine.EvaluateOneAsync`. Errors per-tag log and continue. `Dispose()` cancels an internal `CancellationTokenSource` and disposes every timer. Independent of the change-trigger path — a tag with both triggers fires from both scheduling sources.
|
||||||
|
|
||||||
|
### `ITagUpstreamSource`
|
||||||
|
|
||||||
|
What the engine pulls driver-tag values from. Reads are **synchronous** because user scripts call `ctx.GetTag(path)` inline — a blocking wire call per evaluation would kill throughput. Implementations are expected to serve from a last-known-value cache populated by subscription callbacks. The server's production implementation is `CachedTagUpstreamSource` (see Composition below).
|
||||||
|
|
||||||
|
### `IHistoryWriter`
|
||||||
|
|
||||||
|
Fire-and-forget sink for evaluation results when `VirtualTagDefinition.Historize = true`. Implementations must queue internally and drain on their own cadence — a slow historian must not block script evaluation. `NullHistoryWriter.Instance` is the no-op default. Today no production writer is wired into the virtual-tag path; scripted-alarm emissions flow through `Core.AlarmHistorian` via `Phase7EngineComposer.RouteToHistorianAsync` (a separate concern; see [AlarmTracking.md](AlarmTracking.md)).
|
||||||
|
|
||||||
|
## Dispatch integration
|
||||||
|
|
||||||
|
Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md) Option B, there is a single `DriverNodeManager`. `VirtualTagSource` implements `IReadable` + `ISubscribable` over a `VirtualTagEngine`:
|
||||||
|
|
||||||
|
- `ReadAsync` fans each path through `engine.Read(...)`.
|
||||||
|
- `SubscribeAsync` calls `engine.Subscribe` per path and forwards each engine observer callback as an `OnDataChange` event; emits an initial-data callback per OPC UA convention.
|
||||||
|
- `UnsubscribeAsync` disposes every per-path engine subscription it holds.
|
||||||
|
- **`IWritable` is deliberately not implemented.** `DriverNodeManager.IsWriteAllowedBySource` rejects OPC UA client writes to virtual nodes with `BadUserAccessDenied` before any dispatch — scripts are the only write path via `ctx.SetVirtualTag`.
|
||||||
|
|
||||||
|
`DriverNodeManager.SelectReadable(source, ...)` picks the `IReadable` based on `NodeSourceKind`. See [ReadWriteOperations.md](ReadWriteOperations.md) and [Subscriptions.md](Subscriptions.md) for the broader dispatch framing.
|
||||||
|
|
||||||
|
## Upstream reads + history
|
||||||
|
|
||||||
|
`ITagUpstreamSource` and `IHistoryWriter` are the two ports the engine requires from its host. Both live in `Core.VirtualTags`. In the Server process:
|
||||||
|
|
||||||
|
- **`CachedTagUpstreamSource`** (`src/ZB.MOM.WW.OtOpcUa.Server/Phase7/CachedTagUpstreamSource.cs`) implements the interface (and the parallel `Core.ScriptedAlarms.ITagUpstreamSource` — identical shape, distinct namespace). A `ConcurrentDictionary<path, DataValueSnapshot>` cache. `Push(path, snapshot)` updates the cache and fans out synchronously to every observer. Reads of never-pushed paths return `BadNodeIdUnknown` quality (`UpstreamNotConfigured = 0x80340000`).
|
||||||
|
- **`DriverSubscriptionBridge`** (`src/ZB.MOM.WW.OtOpcUa.Server/Phase7/DriverSubscriptionBridge.cs`) feeds the cache. For each registered `ISubscribable` driver it batches a single `SubscribeAsync` for every fullRef the script graph references, installs an `OnDataChange` handler that translates driver-opaque fullRefs back to UNS paths via a reverse map, and pushes each delta into `CachedTagUpstreamSource`. Unsubscribes on dispose. The bridge suppresses `OTOPCUA0001` (the Roslyn analyzer that requires `ISubscribable` callers to go through `CapabilityInvoker`) on the documented basis that this is a lifecycle wiring, not per-evaluation hot path.
|
||||||
|
- **`IHistoryWriter`** — no production implementation is currently wired for virtual tags; `VirtualTagEngine` gets `NullHistoryWriter` by default from `Phase7EngineComposer`.
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
`Phase7Composer` (`src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs`) is an `IAsyncDisposable` injected into `OpcUaServerService`:
|
||||||
|
|
||||||
|
1. `PrepareAsync(generationId, ct)` — called after the bootstrap generation loads and before `OpcUaApplicationHost.StartAsync`. Reads the `Script` / `VirtualTag` / `ScriptedAlarm` rows for that generation from the config DB (`OtOpcUaConfigDbContext`). Empty-config fast path returns `Phase7ComposedSources.Empty`.
|
||||||
|
2. Constructs a `CachedTagUpstreamSource` + hands it to `Phase7EngineComposer.Compose`.
|
||||||
|
3. `Phase7EngineComposer.Compose` projects `VirtualTag` rows into `VirtualTagDefinition`s (joining `Script` rows by `ScriptId`), instantiates `VirtualTagEngine`, calls `Load`, wraps in `VirtualTagSource`.
|
||||||
|
4. Builds a `DriverFeed` per driver by mapping the driver's `EquipmentNamespaceContent` to `UNS path → driver fullRef` (path format `/{area}/{line}/{equipment}/{tag}` matching the `EquipmentNodeWalker` browse tree so script literals match the operator-visible UNS), then starts `DriverSubscriptionBridge`.
|
||||||
|
5. Returns `Phase7ComposedSources` with the `VirtualTagSource` cast as `IReadable`. `OpcUaServerService` passes it to `OpcUaApplicationHost` which threads it into `DriverNodeManager` as `virtualReadable`.
|
||||||
|
|
||||||
|
`DisposeAsync` tears down the bridge first (no more events into the cache), then the engines (cascades + timer ticks stop), then the owned SQLite historian sink if any.
|
||||||
|
|
||||||
|
Definition reload on config publish: `VirtualTagEngine.Load` is re-entrant — a future config-publish handler can call it with a new definition set. That handler is not yet wired; today engine composition happens once per service start against the bootstrapped generation.
|
||||||
|
|
||||||
|
## Key source files
|
||||||
|
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptContext.cs` — abstract `ctx` API scripts see
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptGlobals.cs` — generic globals wrapper naming the field `ctx`
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs` — assembly allow-list + imports
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ForbiddenTypeAnalyzer.cs` — post-compile semantic deny-list
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs` — three-step compile pipeline
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.Scripting/TimedScriptEvaluator.cs` — 250ms default timeout wrapper
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.Scripting/CompiledScriptCache.cs` — SHA-256-keyed compile cache
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs` — static `ctx.GetTag` / `ctx.SetVirtualTag` inference
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLoggerFactory.cs` — per-script Serilog logger
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLogCompanionSink.cs` — error mirror to main log
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagDefinition.cs` — per-tag config record
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagContext.cs` — evaluation-scoped `ctx`
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/DependencyGraph.cs` — Kahn topo-sort + iterative Tarjan SCC
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs` — load / evaluate / cascade pipeline
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/TimerTriggerScheduler.cs` — periodic re-evaluation
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ITagUpstreamSource.cs` — driver-tag read + subscribe port
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/IHistoryWriter.cs` — historize sink port + `NullHistoryWriter`
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs` — `IReadable` + `ISubscribable` adapter
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Server/Phase7/CachedTagUpstreamSource.cs` — production `ITagUpstreamSource`
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Server/Phase7/DriverSubscriptionBridge.cs` — driver `ISubscribable` → cache feed
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs` — row projection + engine instantiation
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7Composer.cs` — lifecycle host: load rows, compose, wire bridge
|
||||||
|
- `src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs` — `SelectReadable` + `IsWriteAllowedBySource` dispatch kernel
|
||||||
332
docs/drivers/AbCip-HSBY.md
Normal file
332
docs/drivers/AbCip-HSBY.md
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
# AbCip — ControlLogix HSBY paired-IP support
|
||||||
|
|
||||||
|
PR abcip-5.1 + 5.2 ship **non-transparent** HSBY (Hot-Standby) awareness
|
||||||
|
to the AB CIP driver. Each device may declare a partner gateway; when both
|
||||||
|
gateways are up the driver concurrently probes a role tag on each chassis,
|
||||||
|
reports which one is currently Active, and routes reads / writes through
|
||||||
|
that chassis automatically.
|
||||||
|
|
||||||
|
- **PR abcip-5.1** — gathers + reports the role of each chassis through
|
||||||
|
driver diagnostics. See [Role-tag detection matrix](#role-tag-detection-matrix)
|
||||||
|
+ [Active-resolution rules](#active-resolution-rules).
|
||||||
|
- **PR abcip-5.2** — wires the resolved active address into
|
||||||
|
`AbCipDriver.ResolveHost` and the runtime-cache lifecycle. See
|
||||||
|
[Failover behaviour](#failover-behaviour-pr-52) +
|
||||||
|
[Failure-mode walkthrough](#failure-mode-walkthrough).
|
||||||
|
|
||||||
|
## When to use HSBY paired IPs
|
||||||
|
|
||||||
|
You have a redundant **ControlLogix** chassis pair (1756-RM redundancy
|
||||||
|
module, two CPUs, one acting + one standby) and the SCADA / OPC UA layer
|
||||||
|
needs to keep talking to *whichever chassis is currently Active* without an
|
||||||
|
operator manually re-pointing the connection.
|
||||||
|
|
||||||
|
Pre-5.1 the driver only knew about a single `HostAddress`. After a
|
||||||
|
hot-standby switch-over, the standby (now Active) carried a **different IP**
|
||||||
|
and the driver kept probing the dead-but-was-Active address until someone
|
||||||
|
edited the config.
|
||||||
|
|
||||||
|
PR abcip-5.1 closes the visibility half of that gap by reading the role tag
|
||||||
|
on both chassis. PR abcip-5.2 closes the routing half by re-pointing
|
||||||
|
`ResolveHost` at the Active address each tick + invalidating the per-tag
|
||||||
|
runtime cache + write-coalescer state on every flip.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"Devices": [
|
||||||
|
{
|
||||||
|
"HostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"PartnerHostAddress": "ab://10.0.0.6/1,0",
|
||||||
|
"Hsby": {
|
||||||
|
"Enabled": true,
|
||||||
|
"RoleTagAddress": "WallClockTime.SyncStatus",
|
||||||
|
"ProbeIntervalMs": 2000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `PartnerHostAddress` | `null` | Canonical `ab://gateway[:port]/cip-path` of the partner chassis. `null` = no HSBY pair; the driver behaves exactly like every pre-5.1 build. |
|
||||||
|
| `Hsby.Enabled` | `false` | Master switch. When `false` (or `Hsby` omitted) no role probing happens, even if `PartnerHostAddress` is set. |
|
||||||
|
| `Hsby.RoleTagAddress` | `WallClockTime.SyncStatus` | Address of the role tag on each chassis. See [role-tag detection matrix](#role-tag-detection-matrix). |
|
||||||
|
| `Hsby.ProbeIntervalMs` | `2000` | How often each chassis is sampled. 2 s is a good default — tight enough to detect a switch-over within one Admin-UI refresh, loose enough to leave headroom for the regular probe loop. |
|
||||||
|
|
||||||
|
## Feature-flag gate (`Redundancy.Hsby.Enabled`)
|
||||||
|
|
||||||
|
`Hsby.Enabled = false` (the default) is the off-switch for the entire
|
||||||
|
feature. The role-probe loop never starts, the diagnostics keys are not
|
||||||
|
emitted, and the driver behaves identically to a pre-5.1 build. This is the
|
||||||
|
gate to flip when an operator wants to roll the feature out cautiously
|
||||||
|
across a fleet — set `Hsby.Enabled = true` per-device in driver config (no
|
||||||
|
build flag, no env var).
|
||||||
|
|
||||||
|
When the gate is on but the partner gateway is unreachable, the role-probe
|
||||||
|
loop reports `HsbyRole.Unknown` for the partner each tick. The primary's
|
||||||
|
role still drives the active-chassis resolution; the operator sees the
|
||||||
|
partner's role as Unknown in the Admin UI / driver diagnostics, which is the
|
||||||
|
correct surface for "we can't reach the standby chassis right now."
|
||||||
|
|
||||||
|
## Role-tag detection matrix
|
||||||
|
|
||||||
|
| Firmware / fronts | Address | Decode |
|
||||||
|
|---|---|---|
|
||||||
|
| **v20 / v24 / v32+ ControlLogix HSBY** | `WallClockTime.SyncStatus` (DINT) | `0` = Standby, `1` = Synchronized / Active, `2` = Disqualified, anything else = Unknown |
|
||||||
|
| **PLC-5 / SLC500 status-byte fallback** | `S:34` Module Status word | bit 0 = "this chassis is Active". Bit set → `Active`; clear → `Standby` |
|
||||||
|
| **Custom user role tag** | any DINT-typed CIP path | Same matrix as `WallClockTime.SyncStatus` (0 / 1 / 2). Out-of-range values → Unknown. |
|
||||||
|
|
||||||
|
`AbCipHsbyRoleProber.MapValueToRole` is the value-to-role mapper; unit tests
|
||||||
|
in `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipHsbyTests.cs` pin every
|
||||||
|
row of the matrix.
|
||||||
|
|
||||||
|
## What gets reported
|
||||||
|
|
||||||
|
The driver surfaces three diagnostics counters per HSBY-enabled device
|
||||||
|
(visible via `driver-diagnostics` RPC + the Admin UI):
|
||||||
|
|
||||||
|
| Counter | Value |
|
||||||
|
|---|---|
|
||||||
|
| `AbCip.HsbyActive` | `1` if primary is Active, `2` if partner is Active, `0` if neither (or HSBY off) |
|
||||||
|
| `AbCip.HsbyPrimaryRole` | `(int)HsbyRole` — `0` = Unknown, `1` = Active, `2` = Standby, `3` = Disqualified |
|
||||||
|
| `AbCip.HsbyPartnerRole` | Same encoding as `HsbyPrimaryRole`, observed on the partner chassis |
|
||||||
|
| `AbCip.HsbyFailoverCount` (PR 5.2) | Total number of `ActiveAddress` transitions the probe loop has observed across every HSBY-enabled device on this driver. Each increment maps to one runtime-cache invalidation + write-coalescer reset. |
|
||||||
|
|
||||||
|
When more than one HSBY pair is configured on the same driver instance the
|
||||||
|
flat keys are scoped per primary host: `AbCip.HsbyActive[ab://10.0.0.5/1,0]`,
|
||||||
|
etc.
|
||||||
|
|
||||||
|
The `DeviceState.ActiveAddress` field (internal; surfaced via
|
||||||
|
`HsbyActive` diagnostics) is the address PR 5.2 routes through
|
||||||
|
`ResolveHost` + uses to scope the per-host bulkhead / breaker key.
|
||||||
|
See [Failover behaviour](#failover-behaviour-pr-52) for the runtime
|
||||||
|
implications.
|
||||||
|
|
||||||
|
### Active-resolution rules
|
||||||
|
|
||||||
|
| Primary role | Partner role | `ActiveAddress` resolution |
|
||||||
|
|---|---|---|
|
||||||
|
| Active | Standby / Disqualified / Unknown | primary |
|
||||||
|
| Standby / Disqualified / Unknown | Active | partner |
|
||||||
|
| Active | Active (split-brain) | **primary wins**, warning logged |
|
||||||
|
| Standby + Standby | Standby + Standby | `null` — PR 5.2's `ResolveHost` falls back to the configured primary; the existing dial flow surfaces `BadCommunicationError` if the primary is also down. See [Both-stuck](#both-stuck-no-chassis-active). |
|
||||||
|
| Unknown + Unknown | Unknown + Unknown | `null` (same fallback as Standby + Standby) |
|
||||||
|
|
||||||
|
Split-brain (both chassis claim Active simultaneously) is a real
|
||||||
|
production failure mode — typically a redundancy-module misconfiguration or
|
||||||
|
a partial network split. The driver picks primary deterministically + emits
|
||||||
|
a warning through `AbCipDriverOptions.OnWarning` so operators see it in the
|
||||||
|
log.
|
||||||
|
|
||||||
|
## CLI flags
|
||||||
|
|
||||||
|
The `otopcua-abcip-cli` tool exposes the HSBY plumbing through two surfaces
|
||||||
|
(see [Driver.AbCip.Cli.md](../Driver.AbCip.Cli.md) for the full CLI guide):
|
||||||
|
|
||||||
|
- `--partner <gateway>` — global flag on every command. Sets
|
||||||
|
`PartnerHostAddress` + auto-enables `Hsby.Enabled = true` so the role
|
||||||
|
probe runs alongside any read / write / subscribe.
|
||||||
|
- `hsby-status` — dedicated command that prints which chassis is
|
||||||
|
currently Active. Reads the role tag on both gateways for a few ticks +
|
||||||
|
prints the `(primary, partner, active)` tuple.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Print which chassis is Active right now
|
||||||
|
otopcua-abcip-cli hsby-status -g ab://10.0.0.5/1,0 --partner ab://10.0.0.6/1,0
|
||||||
|
|
||||||
|
# Subscribe through the active chassis (PR 5.2 follow-up — today the
|
||||||
|
# subscribe stays pointed at the primary; the role probe runs alongside).
|
||||||
|
otopcua-abcip-cli subscribe -g ab://10.0.0.5/1,0 --partner ab://10.0.0.6/1,0 \
|
||||||
|
-t Motor01_Speed --type Real -i 500
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test coverage
|
||||||
|
|
||||||
|
- **Unit** (`tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipHsbyTests.cs`):
|
||||||
|
- Pure `MapValueToRole` matrix (WallClockTime.SyncStatus + S:34 bit
|
||||||
|
mask + Unknown values).
|
||||||
|
- End-to-end driver loop: primary Active / partner Standby resolves to
|
||||||
|
primary; both Active resolves to primary with a warning; both
|
||||||
|
Standby clears `ActiveAddress`; primary read failure routes to
|
||||||
|
partner.
|
||||||
|
- Diagnostics surface (`AbCip.HsbyActive` / `HsbyPrimaryRole` /
|
||||||
|
`HsbyPartnerRole`).
|
||||||
|
- DTO JSON round-trip (`PartnerHostAddress` + `Hsby.{Enabled,
|
||||||
|
RoleTagAddress, ProbeIntervalMs}` survive deserialise → driver →
|
||||||
|
`DeviceState`).
|
||||||
|
- `Hsby.Enabled = false` → no role probing.
|
||||||
|
- **Integration** (`tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/`):
|
||||||
|
- `AbCipHsbyRoleProberTests.cs` (PR 5.1) and
|
||||||
|
`AbCipHsbyFailoverTests.cs` (PR 5.2) — both **skipped by default**
|
||||||
|
(`Assert.Skip`). `ab_server` cannot emulate a ControlLogix HSBY
|
||||||
|
pair (no `WallClockTime.SyncStatus`, no second chassis concept).
|
||||||
|
The Docker `paired` profile (PR 5.1) brings up two `ab_server`
|
||||||
|
instances + a stub `hsby-mux` sidecar so the topology is
|
||||||
|
documented, but a patched `ab_server` image that actually serves
|
||||||
|
the role tag is still on the follow-up list.
|
||||||
|
- Trait `Category=Hsby` so `dotnet test --filter Category=Hsby`
|
||||||
|
finds them once they're promoted.
|
||||||
|
- **End-to-end** (`scripts/e2e/test-abcip-hsby.ps1`, PR 5.2):
|
||||||
|
- Paired-fixture variant of `test-abcip.ps1`. Subscribes to a tag
|
||||||
|
through the OPC UA server, flips the active chassis mid-stream
|
||||||
|
via the `hsby-mux` sidecar's `POST /flip` endpoint, asserts the
|
||||||
|
stream survives + `AbCip.HsbyFailoverCount` increments. Gated
|
||||||
|
on operator-supplied `BridgeNodeId` + a running paired fixture;
|
||||||
|
ships unwired into `test-all.ps1` until the patched `ab_server`
|
||||||
|
lands.
|
||||||
|
|
||||||
|
## Failover behaviour (PR 5.2)
|
||||||
|
|
||||||
|
PR 5.2 wires `DeviceState.ActiveAddress` into the read / write hot path
|
||||||
|
through `AbCipDriver.ResolveHost` and the runtime-cache lifecycle. After
|
||||||
|
the role-probe loop (PR 5.1) detects an active-address transition the
|
||||||
|
driver re-points every wire-level operation at the now-Active chassis
|
||||||
|
without operator intervention.
|
||||||
|
|
||||||
|
### What flips on a failover
|
||||||
|
|
||||||
|
| Aspect | Pre-flip | Post-flip |
|
||||||
|
|---|---|---|
|
||||||
|
| `ResolveHost(tag)` return | primary `HostAddress` | the partner address (when partner is now Active) |
|
||||||
|
| Per-tag libplctag handles in `DeviceState.Runtimes` | created against primary gateway | dropped on flip; lazily re-created against the partner gateway on next read / write |
|
||||||
|
| Parent-DINT RMW handles in `DeviceState.ParentRuntimes` | primary gateway | dropped on flip; same re-create-on-demand path |
|
||||||
|
| `AbCipWriteCoalescer` per-device cache | last-known-written values from the primary | reset; the first write of any value to the partner pays the full round-trip |
|
||||||
|
| `LogicalInstanceMap` (Logical-mode `@tags` walk) | populated for primary | cleared; the next read on a Logical-mode device re-walks `@tags` against the partner |
|
||||||
|
| Per-host bulkhead key (Polly bulkhead + breaker, plan decision #144) | keyed on primary `HostAddress` | keyed on the new active address — the partner gets its own fresh breaker state instead of inheriting a tripped breaker from the now-standby |
|
||||||
|
| `AbCip.HsbyFailoverCount` diagnostic | 0 | incremented by 1 on every transition observed by the probe loop |
|
||||||
|
|
||||||
|
### How the invalidation runs
|
||||||
|
|
||||||
|
PR 5.2 introduces an internal `OnActiveAddressChanged` event raised by
|
||||||
|
`HsbyProbeLoopAsync` on every `DeviceState.ActiveAddress` transition. The
|
||||||
|
driver subscribes to it from its own constructor; the handler
|
||||||
|
(`HandleActiveAddressChanged`) does the cache invalidation in one place:
|
||||||
|
|
||||||
|
1. Disposes every entry in `DeviceState.Runtimes` and
|
||||||
|
`DeviceState.ParentRuntimes`, then clears both dicts. Disposed
|
||||||
|
`IAbCipTagRuntime` instances release their underlying libplctag
|
||||||
|
handles so the native heap doesn't leak.
|
||||||
|
2. Clears `DeviceState.LogicalInstanceMap` and resets
|
||||||
|
`LogicalWalkComplete = false` so the next read on a Logical-mode
|
||||||
|
device re-fires the `@tags` symbol walk against the new chassis.
|
||||||
|
3. Calls `AbCipWriteCoalescer.Reset(deviceHostAddress)` so cached
|
||||||
|
"we already wrote 42" decisions don't stale-suppress the first
|
||||||
|
partner-side write.
|
||||||
|
4. Resets `DeviceState.RuntimesAddress = null` so subsequent
|
||||||
|
diagnostics observers see a fresh stamp on the next runtime
|
||||||
|
creation.
|
||||||
|
5. `Interlocked.Increment` on the driver-wide
|
||||||
|
`AbCip.HsbyFailoverCount` counter.
|
||||||
|
|
||||||
|
The handler is idempotent — a second event for the same address change
|
||||||
|
is harmless because the dicts are already empty + the coalescer reset
|
||||||
|
is itself idempotent.
|
||||||
|
|
||||||
|
### Bulkhead key semantics
|
||||||
|
|
||||||
|
The per-host resilience pipeline (Polly bulkhead + circuit breaker, plan
|
||||||
|
decision #144) keys on whatever `IPerCallHostResolver.ResolveHost`
|
||||||
|
returns. PR 5.2 changes that resolver so an HSBY-failed-over device
|
||||||
|
returns the partner's address, which means:
|
||||||
|
|
||||||
|
- The **device-state lookup** (`_devices.TryGetValue`) keeps using the
|
||||||
|
configured primary `HostAddress` as the dictionary key — that key
|
||||||
|
never changes for the lifetime of a device, so multi-device
|
||||||
|
configurations stay routable.
|
||||||
|
- The **resilience pipeline** (Polly bulkhead, breaker, retry policies)
|
||||||
|
receives the active address as the host-name dimension. The standby
|
||||||
|
chassis's tripped breaker (if its primary went away) doesn't bleed
|
||||||
|
over to the partner; the partner gets fresh limits + a closed
|
||||||
|
breaker.
|
||||||
|
|
||||||
|
When HSBY is disabled (`Hsby.Enabled = false`) `ResolveHost` returns the
|
||||||
|
configured primary `HostAddress` exactly as it always has — pre-5.2
|
||||||
|
behaviour, no double-key risk.
|
||||||
|
|
||||||
|
## Failure-mode walkthrough
|
||||||
|
|
||||||
|
PR 5.2 adds three failover surface areas to reason about. The table
|
||||||
|
below summarises the behaviour the driver reports + how an operator
|
||||||
|
can inspect it.
|
||||||
|
|
||||||
|
### Primary-stuck (primary unreachable, partner Active)
|
||||||
|
|
||||||
|
The primary chassis goes away (network partition, power loss, a stuck
|
||||||
|
Forward Open). The role-probe loop reads `HsbyRole.Unknown` for the
|
||||||
|
primary and `HsbyRole.Active` for the partner.
|
||||||
|
|
||||||
|
| Surface | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `DeviceState.ActiveAddress` | partner address |
|
||||||
|
| `DeviceState.PrimaryRole` | `Unknown` |
|
||||||
|
| `DeviceState.PartnerRole` | `Active` |
|
||||||
|
| `ResolveHost(tag)` | partner address |
|
||||||
|
| Reads / writes | route through partner gateway transparently |
|
||||||
|
| `AbCip.HsbyFailoverCount` | incremented when the address transitioned away from the primary |
|
||||||
|
| `AbCip.HsbyActive` | `2` (partner is the active chassis) |
|
||||||
|
| Operator action | none required for routing; investigate why the primary is unreachable through the connectivity-probe loop's `_System/_ConnectionStatus` for the device |
|
||||||
|
|
||||||
|
### Secondary-stuck (partner unreachable, primary Active)
|
||||||
|
|
||||||
|
The partner chassis goes away (its OPC UA server is down, its IP is
|
||||||
|
unreachable, the redundancy module unhitched it). The probe loop reads
|
||||||
|
`HsbyRole.Active` for the primary and `HsbyRole.Unknown` for the partner.
|
||||||
|
|
||||||
|
| Surface | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `DeviceState.ActiveAddress` | primary address (no transition; this is the steady state) |
|
||||||
|
| `DeviceState.PrimaryRole` | `Active` |
|
||||||
|
| `DeviceState.PartnerRole` | `Unknown` |
|
||||||
|
| `ResolveHost(tag)` | primary address |
|
||||||
|
| Reads / writes | route through primary gateway exactly as in a non-HSBY deployment |
|
||||||
|
| `AbCip.HsbyFailoverCount` | unchanged — no flip happened |
|
||||||
|
| `AbCip.HsbyActive` | `1` (primary is the active chassis) |
|
||||||
|
| Operator action | investigate why the partner is unreachable; the operational risk is that a future primary-side outage has no fall-back |
|
||||||
|
|
||||||
|
### Both-stuck (no chassis Active)
|
||||||
|
|
||||||
|
Both chassis report `Standby` / `Disqualified` / `Unknown` (a
|
||||||
|
redundancy-module misconfiguration, both controllers in Program mode,
|
||||||
|
or both unreachable).
|
||||||
|
|
||||||
|
| Surface | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `DeviceState.ActiveAddress` | `null` |
|
||||||
|
| `ResolveHost(tag)` | falls back to the configured primary `HostAddress` |
|
||||||
|
| Reads / writes | dispatched to the configured primary; a stuck primary surfaces `BadCommunicationError` per the existing dial flow |
|
||||||
|
| `AbCip.HsbyActive` | `0` (no chassis Active) |
|
||||||
|
| `AbCip.HsbyFailoverCount` | incremented when the transition `Active → null` happened |
|
||||||
|
| Operator action | investigate the redundancy module / mode keys; the SCADA layer sees stuck-or-bad-quality reads, not incorrect routing |
|
||||||
|
|
||||||
|
The "fall back to primary on null Active" choice is deliberate. Routing
|
||||||
|
all reads to a deterministic chassis (the configured primary) keeps the
|
||||||
|
breaker key + bulkhead state stable while the operator diagnoses the
|
||||||
|
double-down outage; the alternative (round-robin / partner) would just
|
||||||
|
trip both breakers in turn and obscure which chassis is the real
|
||||||
|
problem.
|
||||||
|
|
||||||
|
## Follow-ups (beyond PR 5.2)
|
||||||
|
|
||||||
|
- **Patched `ab_server` image** — add a writable `WallClockTime.SyncStatus`
|
||||||
|
tag (or a separate Python shim) so the Docker `paired` profile can
|
||||||
|
exercise the wire-level role probe + the
|
||||||
|
`tests/.../IntegrationTests/AbCipHsbyFailoverTests.cs` scaffold can
|
||||||
|
flip its `Assert.Skip` for a real integration assertion.
|
||||||
|
- **`hsby-mux` REST endpoint** — `POST /flip {"active": "primary"}` writes
|
||||||
|
`1` to the chosen chassis + `0` to the other so integration tests +
|
||||||
|
`scripts/e2e/test-abcip-hsby.ps1` can drive switch-overs
|
||||||
|
deterministically.
|
||||||
|
- **GuardLogix HSBY** — same role-tag plumbing applies; verify against a
|
||||||
|
real 1756-L8xS pair when one is on-site.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [`docs/Driver.AbCip.Cli.md`](../Driver.AbCip.Cli.md) — `--partner` flag +
|
||||||
|
`hsby-status` command reference
|
||||||
|
- [`docs/drivers/AbServer-Test-Fixture.md`](AbServer-Test-Fixture.md) §"What
|
||||||
|
it does NOT cover" — HSBY entry
|
||||||
|
- [`docs/Redundancy.md`](../Redundancy.md) — server-level (OPC UA-stack)
|
||||||
|
redundancy; HSBY is the **driver-level** companion
|
||||||
406
docs/drivers/AbCip-Operability.md
Normal file
406
docs/drivers/AbCip-Operability.md
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
# AB CIP — Operability knobs
|
||||||
|
|
||||||
|
Phase 4 of the AB CIP driver plan introduces operator-tunable behaviour that
|
||||||
|
changes how the driver schedules per-tag traffic, deduplicates updates, and
|
||||||
|
surfaces health — knobs that an operator typically reaches for *after* the
|
||||||
|
address space is in place and the deployment is past the green-field phase.
|
||||||
|
The Phase 3 doc (`AbCip-Performance.md`) covers connection-shape and
|
||||||
|
read-strategy knobs; this doc is the home for the per-tag scheduling and
|
||||||
|
operability levers as PRs land.
|
||||||
|
|
||||||
|
PR abcip-4.1 ships the first knob: per-tag **Scan Rate** (Kepware-parity scan
|
||||||
|
classes).
|
||||||
|
|
||||||
|
## Per-tag scan rate
|
||||||
|
|
||||||
|
### What it is
|
||||||
|
|
||||||
|
A per-tag override of the OPC UA subscription's `publishingInterval`. The AB
|
||||||
|
CIP driver mirrors the Galaxy hierarchy as a single OPC UA address space, so
|
||||||
|
every tag served from one driver normally ticks at the publishing interval the
|
||||||
|
client requested when it created the Subscription. This knob lets specific
|
||||||
|
tags publish at a different cadence — fast HMI tags at 100 ms, batch /
|
||||||
|
historian tags at 1–10 s — without forcing the operator to split tags into
|
||||||
|
separate subscriptions or driver instances.
|
||||||
|
|
||||||
|
It is the Kepware "scan classes" model expressed per-tag. The same shape is
|
||||||
|
already shipped in the S7 driver (`S7TagDefinition.ScanGroup`) and the AB
|
||||||
|
Legacy / TwinCAT drivers; AB CIP adopts a leaner per-tag-only form because the
|
||||||
|
CIP single-connection model means the practical knob a deployment reaches for
|
||||||
|
is "this one tag, faster", not "every tag in this group".
|
||||||
|
|
||||||
|
### How it interacts with OPC UA publishingInterval
|
||||||
|
|
||||||
|
OPC UA semantics:
|
||||||
|
|
||||||
|
- The Subscription's `publishingInterval` is the *upper bound* on how often
|
||||||
|
the server publishes a NotificationMessage. Each MonitoredItem also has its
|
||||||
|
own `samplingInterval`; that's where this knob lands.
|
||||||
|
- A per-tag `samplingInterval` shorter than the Subscription's
|
||||||
|
`publishingInterval` means the server samples faster but only publishes at
|
||||||
|
the next Subscription tick — clients may receive multiple values for one
|
||||||
|
tag in a single Publish response.
|
||||||
|
- A per-tag `samplingInterval` longer than the Subscription's
|
||||||
|
`publishingInterval` is legal too — the server simply skips ticks for that
|
||||||
|
tag.
|
||||||
|
|
||||||
|
AB CIP-side: the driver's `SubscribeAsync` receives one `publishingInterval`
|
||||||
|
plus a list of tag references. With per-tag `ScanRateMs` it buckets the input
|
||||||
|
list by resolved interval and registers one `PollGroupEngine` subscription per
|
||||||
|
bucket. Each bucket runs an independent timer, so a 100 ms tag never waits
|
||||||
|
for a 1000 ms tag's `Task.Delay` to expire.
|
||||||
|
|
||||||
|
### Override knob
|
||||||
|
|
||||||
|
`AbCipTagDefinition.ScanRateMs` (`int?`, default `null`). `null` = use the
|
||||||
|
subscription's default `publishingInterval` (legacy behaviour). Bind via
|
||||||
|
driver config JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Tags": [
|
||||||
|
{
|
||||||
|
"Name": "Motor1.Speed",
|
||||||
|
"DeviceHostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"TagPath": "Motor1.Speed",
|
||||||
|
"DataType": "DInt",
|
||||||
|
"ScanRateMs": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Motor1.RunHours",
|
||||||
|
"DeviceHostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"TagPath": "Motor1.RunHours",
|
||||||
|
"DataType": "DInt",
|
||||||
|
"ScanRateMs": 5000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Motor1.NamePlate",
|
||||||
|
"DeviceHostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"TagPath": "Motor1.NamePlate",
|
||||||
|
"DataType": "String"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Result: three buckets — 100 ms, 5000 ms, and the subscription default for
|
||||||
|
`NamePlate`. UDT members inherit the parent tag's `ScanRateMs` at fan-out
|
||||||
|
time, so a UDT declared at 100 ms publishes every member at 100 ms without
|
||||||
|
the operator having to repeat the override on each member.
|
||||||
|
|
||||||
|
### Floor and degenerate cases
|
||||||
|
|
||||||
|
- `PollGroupEngine` floors every bucket at **100 ms** — a `ScanRateMs: 25`
|
||||||
|
is clamped up. The floor matches the Modbus / S7 / TwinCAT floors and
|
||||||
|
protects the wire from sub-mailbox-scan polling.
|
||||||
|
- `ScanRateMs: 0` and negative values are treated as unset — the tag falls
|
||||||
|
back to the subscription default. Mis-typed config degrades, doesn't fault.
|
||||||
|
- A `ScanRateMs` equal to the subscription default collapses into the same
|
||||||
|
bucket as plain tags. The driver doesn't fragment poll loops when the
|
||||||
|
override is redundant.
|
||||||
|
- Tags whose names don't appear in the driver's tag map (typo / discovery
|
||||||
|
miss) fall through to the subscription default — same "config typo
|
||||||
|
degrades" stance as the rest of the driver.
|
||||||
|
|
||||||
|
### Wire impact
|
||||||
|
|
||||||
|
Per-bucket independent timers do **not** parallelise CIP traffic. The driver
|
||||||
|
serializes wire-side reads through its per-device libplctag handles, so a
|
||||||
|
fast bucket and a slow bucket trade off against each other on the wire — the
|
||||||
|
multi-rate split decouples *cadence* (the 100 ms bucket isn't queued behind
|
||||||
|
the 1000 ms bucket's `Task.Delay`), not *throughput*. The wire still moves
|
||||||
|
one CIP request at a time per device.
|
||||||
|
|
||||||
|
If you're reading a large tag set and the slow bucket starves the fast
|
||||||
|
bucket, the lever is `AbCipDeviceOptions.ConnectionSize` (Phase 3) — pack
|
||||||
|
more tags into one CIP RTT so the slow bucket finishes faster. Per-tag scan
|
||||||
|
rate is a *scheduling* knob, not a *throughput* knob.
|
||||||
|
|
||||||
|
### Comparison to Kepware scan classes
|
||||||
|
|
||||||
|
| Kepware concept | AB CIP equivalent |
|
||||||
|
|---|---|
|
||||||
|
| Scan class table (named groups → rate) | implicit: each distinct `ScanRateMs` value is its own bucket |
|
||||||
|
| Default scan class | OPC UA Subscription's `publishingInterval` |
|
||||||
|
| Per-tag scan class assignment | `AbCipTagDefinition.ScanRateMs` |
|
||||||
|
| "Scan mode: Respect client" | always — the OPC UA `publishingInterval` is the default |
|
||||||
|
| "Force write" / "Write through cache" | not exposed — AB CIP writes always go to the wire |
|
||||||
|
|
||||||
|
The leaner shape (per-tag rate, not named groups) keeps the JSON config flat
|
||||||
|
and reflects how operators tend to use the knob in practice — a handful of
|
||||||
|
"this specific tag needs to be fast" overrides on top of a sensible default,
|
||||||
|
rather than a separate tier of scan-class definitions.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
- **Unit**: `AbCipPerTagScanRateTests` (`tests/.../AbCip.Tests`). Asserts
|
||||||
|
bucketing math, default-rate collapse, UDT member inheritance, JSON DTO
|
||||||
|
round-trip, and end-to-end cadence against the in-process fake.
|
||||||
|
- **Integration**: `AbCipPerTagScanRateTests`
|
||||||
|
(`tests/.../AbCip.IntegrationTests`). Drives two tags at 100 ms / 1000 ms
|
||||||
|
against a live `ab_server` and asserts the bucket count + each tag receives
|
||||||
|
the initial-data push.
|
||||||
|
- **E2E**: `scripts/e2e/test-abcip.ps1` — see the *PerTagScanRate* assertion.
|
||||||
|
|
||||||
|
### Cross-references
|
||||||
|
|
||||||
|
- `docs/Driver.AbCip.Cli.md` — there is no CLI surface change for this knob;
|
||||||
|
scan rate is a config-time concern.
|
||||||
|
- `docs/drivers/AbCip-Performance.md` — Phase 3 throughput knobs that pair
|
||||||
|
with per-tag scan rate when a slow bucket starves a fast one.
|
||||||
|
- S7 driver `ScanGroup` model in `src/.../S7DriverOptions.cs` — the
|
||||||
|
named-group form of the same idea.
|
||||||
|
|
||||||
|
## Write deadband / write-on-change
|
||||||
|
|
||||||
|
PR abcip-4.2 ships the second operability knob: per-tag write coalescing,
|
||||||
|
the *write-side* companion to the read-side deadband already shipped at the
|
||||||
|
OPC UA monitored-item layer. The driver remembers the value last
|
||||||
|
successfully written for a tag and can suppress redundant or below-threshold
|
||||||
|
follow-up writes — they return `Good` to the OPC UA client without ever
|
||||||
|
hitting the wire.
|
||||||
|
|
||||||
|
### What it is
|
||||||
|
|
||||||
|
- **`AbCipTagDefinition.WriteDeadband`** (`double?`, default `null`) —
|
||||||
|
numeric absolute-difference threshold. When set, a write whose
|
||||||
|
`|new − last|` is below the deadband is suppressed.
|
||||||
|
- **`AbCipTagDefinition.WriteOnChange`** (`bool`, default `false`) —
|
||||||
|
equality gate. When set, a write whose value equals the last successfully
|
||||||
|
written value is suppressed.
|
||||||
|
|
||||||
|
Both knobs combine on the same tag. For numerics, the deadband path takes
|
||||||
|
priority; the equality fallback covers the cases the deadband doesn't (BOOL
|
||||||
|
setpoints, STRING constants, `WriteDeadband=0`, etc).
|
||||||
|
|
||||||
|
### Worked setpoint-jitter example
|
||||||
|
|
||||||
|
A motor speed setpoint published from an HMI tends to wobble by a few
|
||||||
|
ticks even when the operator hasn't touched it — UI rounding, Modbus
|
||||||
|
gateway re-encoding, RPN script noise. With `WriteDeadband: 0.5`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Tags": [
|
||||||
|
{
|
||||||
|
"Name": "Motor1.Speed.SP",
|
||||||
|
"DeviceHostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"TagPath": "Motor1.Speed.SP",
|
||||||
|
"DataType": "Real",
|
||||||
|
"WriteDeadband": 0.5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Sequence of writes from the HMI (one every 100 ms, no operator input):
|
||||||
|
|
||||||
|
| Time | Value | `\|new − last\|` | Wire? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 ms | 50.0 | n/a (first) | yes |
|
||||||
|
| 100 ms | 50.2 | 0.2 < 0.5 | suppressed |
|
||||||
|
| 200 ms | 50.3 | 0.3 < 0.5 | suppressed |
|
||||||
|
| 300 ms | 50.6 | 0.6 ≥ 0.5 | yes |
|
||||||
|
| 400 ms | 50.6 | 0.0 < 0.5 | suppressed |
|
||||||
|
| 500 ms | 51.5 | 0.9 ≥ 0.5 | yes |
|
||||||
|
|
||||||
|
Three writes hit the wire; three are suppressed. The OPC UA client sees
|
||||||
|
`Good` on every call. The PLC sees only the values that actually crossed
|
||||||
|
the deadband.
|
||||||
|
|
||||||
|
### Combining with WriteOnChange
|
||||||
|
|
||||||
|
A digital reset bit driven by a UI that pulses it at every cycle:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Name": "Conveyor.Reset",
|
||||||
|
"DeviceHostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"TagPath": "Conveyor.Reset",
|
||||||
|
"DataType": "Bool",
|
||||||
|
"WriteOnChange": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Three consecutive `false → false → false` writes from the UI collapse to
|
||||||
|
one wire write (`false`, the first). When the operator clicks the reset
|
||||||
|
button (`true`), that write passes; subsequent `true → true` repeats
|
||||||
|
suppress until the UI clears it back to `false`.
|
||||||
|
|
||||||
|
Numeric tags can also opt into both: `WriteDeadband: 0.5` plus
|
||||||
|
`WriteOnChange: true` is well-defined — the deadband suppresses jitter, the
|
||||||
|
equality gate suppresses exact repeats (which the deadband path also catches
|
||||||
|
because `|0| < 0.5`, but having both set documents the operator's intent).
|
||||||
|
|
||||||
|
### Special cases
|
||||||
|
|
||||||
|
- **First write** always passes through. The coalescer has no prior value
|
||||||
|
to compare against, so the first write of any tag pays the full
|
||||||
|
round-trip and seeds the cache.
|
||||||
|
- **NaN / Infinity** bypass deadband suppression. IEEE-754 comparisons
|
||||||
|
against NaN are undefined and a stale `+Inf` shouldn't silently swallow
|
||||||
|
a real reset; the wire decides. `WriteOnChange` equality on NaN still
|
||||||
|
follows .NET semantics (`Equals(NaN, NaN) == true` for `double` boxed in
|
||||||
|
`object`), so a `WriteOnChange` tag stuck on NaN will suppress repeats
|
||||||
|
until something else writes a real value.
|
||||||
|
- **Failed writes** do *not* seed the cache. If the wire write fails, the
|
||||||
|
next attempt with the same value still hits the wire because the
|
||||||
|
coalescer never recorded a "last successful value" for it.
|
||||||
|
- **Reconnect drops the cache**. The driver's host-state probe transitions
|
||||||
|
`Stopped → Running` after a reconnect; both transitions reset the
|
||||||
|
per-device coalescer cache, so the first post-reconnect write of any
|
||||||
|
value pays the full round-trip. The PLC may have been restarted while
|
||||||
|
the driver was offline and our cached "we already wrote 42" is stale.
|
||||||
|
- **Two devices, same tag address**. The cache is keyed on
|
||||||
|
`(deviceHostAddress, tagAddress)` so two PLCs running the same Logix
|
||||||
|
program keep independent caches — writing 42 to A doesn't suppress
|
||||||
|
writing 42 to B.
|
||||||
|
- **Bit-in-DINT writes** consult the coalescer too, so a UI that pulses
|
||||||
|
`Flags.3` at every cycle benefits from the same `WriteOnChange`
|
||||||
|
suppression as a plain BOOL tag.
|
||||||
|
- **Plain back-compat tags** (no `WriteDeadband`, no `WriteOnChange`)
|
||||||
|
take a fast-path through the coalescer that increments only the
|
||||||
|
`WritesPassedThrough` counter — no dictionary lookup, no allocation. The
|
||||||
|
knobs are zero-overhead opt-in.
|
||||||
|
|
||||||
|
### Diagnostics
|
||||||
|
|
||||||
|
The driver surfaces two counters through `DriverHealth.Diagnostics` (the
|
||||||
|
same path the `driver-diagnostics` RPC + Admin UI render for Modbus / S7 /
|
||||||
|
OPC UA Client):
|
||||||
|
|
||||||
|
- `AbCip.WritesSuppressed` — total writes the coalescer skipped.
|
||||||
|
- `AbCip.WritesPassedThrough` — total writes that hit the wire after
|
||||||
|
consulting the coalescer.
|
||||||
|
|
||||||
|
Their ratio is the "wire savings" headline. A deployment with `0`
|
||||||
|
suppressions either has no tags opted in or has the deadband too tight /
|
||||||
|
the equality threshold too loose; revisit the per-tag config.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
- **Unit**: `AbCipWriteDeadbandTests` (`tests/.../AbCip.Tests`). Asserts
|
||||||
|
the deadband math, the equality fallback, the first-write pass-through,
|
||||||
|
reset-on-reconnect, two-device cache independence, suppressed-Good
|
||||||
|
status, NaN bypass, the back-compat fast path, and DTO round-trip.
|
||||||
|
- **Integration**: `AbCipWriteDeadbandTests`
|
||||||
|
(`tests/.../AbCip.IntegrationTests`). Drives a 5-write jittery sequence
|
||||||
|
with `WriteDeadband: 1.0` against a live `ab_server` and asserts the
|
||||||
|
driver's diagnostics counter matches the expected suppression count.
|
||||||
|
- **E2E**: `scripts/e2e/test-abcip.ps1` — see the *WriteCoalesce*
|
||||||
|
assertion.
|
||||||
|
|
||||||
|
### Cross-references
|
||||||
|
|
||||||
|
- `docs/drivers/AbServer-Test-Fixture.md` §7 — capability surfaces beyond
|
||||||
|
read; mentions write-coalesce coverage.
|
||||||
|
- Modbus driver — read-side deadband in `ModbusDriver` predates this
|
||||||
|
write-side equivalent; the config shape is intentionally similar.
|
||||||
|
- Kepware "Deadband (write)" knob — this is the AB CIP equivalent.
|
||||||
|
|
||||||
|
## System tags / `_System` folder
|
||||||
|
|
||||||
|
PR abcip-4.3 surfaces five read-only diagnostic variables under
|
||||||
|
`AbCip/<device>/_System/` so SCADA / Admin clients can pivot from "is the
|
||||||
|
wire up?" to "what's our scan rate / tag count?" without leaving the OPC UA
|
||||||
|
address space. The values come straight from the live
|
||||||
|
`IHostConnectivityProbe` + `DriverHealth` surfaces — reads bypass libplctag
|
||||||
|
and are served from the in-memory snapshot the probe loop / read loop
|
||||||
|
updates. PR abcip-4.4 added `_RefreshTagDb` as a sixth, writeable entry —
|
||||||
|
the Kepware-style refresh trigger.
|
||||||
|
|
||||||
|
### What it ships
|
||||||
|
|
||||||
|
| Variable | Type | Access | Source | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `_ConnectionStatus` | String | ViewOnly | `HostState` | `Running` / `Stopped` / `Unknown` / `Faulted`. Mirrors what the connectivity probe sees. |
|
||||||
|
| `_ScanRate` | Float64 | ViewOnly | `AbCipProbeOptions.Interval` | Configured probe interval in milliseconds — compare against `_LastScanTimeMs` to spot wire stretch. |
|
||||||
|
| `_TagCount` | Int32 | ViewOnly | `_tagsByName` | Discovered tag count for this device, excluding `_System/*`. |
|
||||||
|
| `_DeviceError` | String | ViewOnly | `DriverHealth.LastError` | Most recent error message; empty when the device is healthy. |
|
||||||
|
| `_LastScanTimeMs` | Float64 | ViewOnly | `ReadAsync` wall-clock | Duration of the most-recent `ReadAsync` iteration on this device. |
|
||||||
|
| `_RefreshTagDb` | Boolean | **Operate** | n/a (write-only trigger) | PR abcip-4.4 — Kepware-style refresh trigger. Reads always return `false`. Writing any truthy value (`true`, non-zero number, `"true"` / `"1"` strings, case-insensitive) dispatches to `RebrowseAsync` against the device's cached `IAddressSpaceBuilder`. Falsy / unparseable writes are no-ops that report `Good` so a UI that resets the trigger flag doesn't see a phantom error. The `AbCip.RefreshTriggers` diagnostic counter increments per truthy write. |
|
||||||
|
|
||||||
|
### When the snapshot updates
|
||||||
|
|
||||||
|
- **Probe transitions** — every `Running ↔ Stopped` flip refreshes the
|
||||||
|
device's snapshot inline, so a client subscribed to
|
||||||
|
`_System/_ConnectionStatus` sees the new state on the next OPC UA
|
||||||
|
publish tick.
|
||||||
|
- **Read iterations** — `ReadAsync` recomputes `_LastScanTimeMs` per
|
||||||
|
device that owned at least one reference in the batch + writes a fresh
|
||||||
|
snapshot before returning.
|
||||||
|
- **Driver init** — every device gets a seeded snapshot
|
||||||
|
(`Unknown` / `0` / `""`) before the probe loop spins up so a read that
|
||||||
|
arrives before the first probe iteration returns a stable shape rather
|
||||||
|
than null.
|
||||||
|
|
||||||
|
### Browse + read example
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Browse the synthetic folder
|
||||||
|
otopcua-client-cli browse -u opc.tcp://localhost:4840 \
|
||||||
|
-n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System"
|
||||||
|
|
||||||
|
# Read the connection status
|
||||||
|
otopcua-client-cli read -u opc.tcp://localhost:4840 \
|
||||||
|
-n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System/_ConnectionStatus"
|
||||||
|
```
|
||||||
|
|
||||||
|
The driver-side reference embeds the device host address (the
|
||||||
|
`_System/<device>/<name>` form) so the dispatcher can route by device
|
||||||
|
without an additional registry. PR abcip-4.4 turned `_RefreshTagDb` into
|
||||||
|
a writeable refresh trigger; the rest of the surface remains `ViewOnly`.
|
||||||
|
|
||||||
|
### Refreshing the tag DB via OPC UA write
|
||||||
|
|
||||||
|
PR abcip-4.4 wires `_RefreshTagDb` to the same `RebrowseAsync` entry point
|
||||||
|
the CLI's `rebrowse` command exercises (issue #233). Operators have two
|
||||||
|
roughly-equivalent ways to force a controller-side `@tags` re-walk after a
|
||||||
|
program download:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Path A — OPC UA write to the system tag (production / Admin UI path)
|
||||||
|
otopcua-client-cli write -u opc.tcp://localhost:4840 \
|
||||||
|
-n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System/_RefreshTagDb" \
|
||||||
|
-v true --type Boolean
|
||||||
|
|
||||||
|
# Path B — direct CLI rebrowse against a transient driver (admin / debug path)
|
||||||
|
otopcua-abcip-cli rebrowse -g ab://10.0.0.5/1,0
|
||||||
|
```
|
||||||
|
|
||||||
|
Both paths drop the UDT template cache + re-run the enumerator walk. Path A
|
||||||
|
is the operator-facing surface (the same `IDriverControl.RebrowseAsync`
|
||||||
|
contract, just dispatched from the OPC UA write surface instead of an
|
||||||
|
in-process call). Path B spins up its own driver instance so it doesn't
|
||||||
|
share the live server's cache, which makes it useful for one-off
|
||||||
|
controller-side validation.
|
||||||
|
|
||||||
|
The `AbCip.RefreshTriggers` driver-diagnostics counter increments per
|
||||||
|
successful truthy write, so the Admin UI / driver-diagnostics RPC can show
|
||||||
|
a "Refreshes since boot" tile that pairs naturally with the existing
|
||||||
|
`WritesSuppressed` / `WritesPassedThrough` write-coalescer counters.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
- **Unit**: `AbCipSystemTagSourceTests`
|
||||||
|
(`tests/.../AbCip.Tests`) — covers snapshot round-trip, two-device
|
||||||
|
isolation, recognised-name lookup, default-shape on unseeded devices,
|
||||||
|
discovery emits the six canonical nodes, and `ReadAsync` dispatches
|
||||||
|
through the source instead of libplctag.
|
||||||
|
- **Unit**: `AbCipRefreshTagDbTests`
|
||||||
|
(`tests/.../AbCip.Tests`) — PR abcip-4.4 — covers discovery emits the
|
||||||
|
trigger as Operate, reads always return `false`, truthy/falsy/null write
|
||||||
|
semantics, the `AbCip.RefreshTriggers` counter, two-device counter
|
||||||
|
independence, defends-in-depth `BadNotWritable` for read-only system
|
||||||
|
variables, no-op-Good when no builder is cached yet, and mixed-batch
|
||||||
|
routing alongside ordinary tag writes.
|
||||||
|
- **Integration**: `AbCipSystemTagDiscoveryTests`
|
||||||
|
(`tests/.../AbCip.IntegrationTests`) — `[AbServerFact]` connects to a
|
||||||
|
real `ab_server`, browses `_System/`, reads each variable, asserts
|
||||||
|
every one returns Good with a non-null value.
|
||||||
|
- **Integration**: `AbCipRefreshTagDbTests`
|
||||||
|
(`tests/.../AbCip.IntegrationTests`) — PR abcip-4.4 — `[AbServerFact]`
|
||||||
|
drives a `_RefreshTagDb` write, asserts the template cache drops + the
|
||||||
|
per-device counter advances against a live `ab_server`.
|
||||||
|
- **E2E**: `scripts/e2e/test-abcip.ps1` — see the *SystemTagBrowse* +
|
||||||
|
*RefreshTagDbWrite* assertions.
|
||||||
405
docs/drivers/AbCip-Performance.md
Normal file
405
docs/drivers/AbCip-Performance.md
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
# AB CIP — Performance knobs
|
||||||
|
|
||||||
|
Phase 3 of the AB CIP driver plan introduces a small set of operator-tunable
|
||||||
|
performance knobs that change how the driver talks to the controller without
|
||||||
|
altering the address space or per-tag semantics. They consolidate decisions
|
||||||
|
that Kepware exposes as a slider / advanced page so deployments running into
|
||||||
|
high-latency PLCs, narrow-CPU CompactLogix parts, or legacy ControlLogix
|
||||||
|
firmware have an explicit lever to pull.
|
||||||
|
|
||||||
|
This document is the home for those knobs as PRs land. PR abcip-3.1 ships the
|
||||||
|
first knob: per-device **CIP Connection Size**.
|
||||||
|
|
||||||
|
## Connection Size
|
||||||
|
|
||||||
|
### What it is
|
||||||
|
|
||||||
|
CIP Connection Size — the byte ceiling on a single Forward Open response
|
||||||
|
fragment, set during the EtherNet/IP Forward Open handshake. Larger
|
||||||
|
connection sizes pack more tags into a single CIP RTT (higher request-packing
|
||||||
|
density, fewer round-trips for the same scan list); smaller connection sizes
|
||||||
|
stay compatible with legacy or narrow-buffer firmware that rejects oversized
|
||||||
|
Forward Open requests.
|
||||||
|
|
||||||
|
### Family defaults
|
||||||
|
|
||||||
|
The driver picks a Connection Size from the per-family profile when the
|
||||||
|
device-level override is unset:
|
||||||
|
|
||||||
|
| Family | Default | Rationale |
|
||||||
|
|---|---:|---|
|
||||||
|
| `ControlLogix` | `4002` | Large Forward Open — FW20+ |
|
||||||
|
| `GuardLogix` | `4002` | Same wire protocol as ControlLogix |
|
||||||
|
| `CompactLogix` | `504` | 5069-L1/L2/L3 narrow-buffer parts (5370 family) |
|
||||||
|
| `Micro800` | `488` | Hard cap on Micro800 firmware |
|
||||||
|
|
||||||
|
These map straight to libplctag's `connection_size` attribute and match the
|
||||||
|
defaults Kepware uses out of the box for the same families.
|
||||||
|
|
||||||
|
### Override knob
|
||||||
|
|
||||||
|
`AbCipDeviceOptions.ConnectionSize` (`int?`, default `null`) overrides the
|
||||||
|
family default for one device. Bind it through driver config JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Devices": [
|
||||||
|
{
|
||||||
|
"HostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"PlcFamily": "ControlLogix",
|
||||||
|
"ConnectionSize": 504
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The override threads through every libplctag handle the driver creates for
|
||||||
|
that device — read tags, write tags, probe tags, UDT-template reads, the
|
||||||
|
`@tags` walker, and BOOL-in-DINT parent runtimes. There is no per-tag
|
||||||
|
override; one Connection Size applies to the whole controller (matches CIP
|
||||||
|
session semantics).
|
||||||
|
|
||||||
|
### Valid range
|
||||||
|
|
||||||
|
`[500..4002]` bytes. This matches the slider Kepware exposes for the same
|
||||||
|
family. Values outside the range fail driver `InitializeAsync` with an
|
||||||
|
`InvalidOperationException` — there's no silent clamp; misconfigured devices
|
||||||
|
fail loudly so operators see the problem at deploy time.
|
||||||
|
|
||||||
|
| Value | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `null` | Use family default (4002 / 504 / 488) |
|
||||||
|
| `499` or below | Driver init fault — out-of-range |
|
||||||
|
| `500..4002` | Threaded through to libplctag |
|
||||||
|
| `4003` or above | Driver init fault — out-of-range |
|
||||||
|
|
||||||
|
### Legacy-firmware caveat
|
||||||
|
|
||||||
|
ControlLogix firmware **v19 and earlier** caps the CIP buffer at **504
|
||||||
|
bytes** — Connection Sizes above that cause the controller to reject the
|
||||||
|
Forward Open with CIP error 0x01/0x113. The 5069-L1/L2/L3 CompactLogix narrow
|
||||||
|
parts are subject to the same cap.
|
||||||
|
|
||||||
|
The driver emits a warning via `AbCipDriverOptions.OnWarning` when the
|
||||||
|
configured Connection Size **exceeds 511** *and* the device's family profile
|
||||||
|
default is also at-or-below the legacy cap (i.e. CompactLogix with default
|
||||||
|
504, or Micro800 with default 488). Production hosting should wire
|
||||||
|
`OnWarning` to the application logger; the unit tests (`AbCipConnectionSizeTests`)
|
||||||
|
collect into a list to assert which warnings fired.
|
||||||
|
|
||||||
|
The warning fires once per device at `InitializeAsync`. It does not block
|
||||||
|
initialisation — operators may need the override anyway when running newer
|
||||||
|
CompactLogix firmware that does support the larger Forward Open. The
|
||||||
|
controller will reject the connection at runtime if it can't honour the size,
|
||||||
|
and that surfaces through the standard `IHostConnectivityProbe` channel.
|
||||||
|
|
||||||
|
### Performance trade-off
|
||||||
|
|
||||||
|
| Larger Connection Size | Smaller Connection Size |
|
||||||
|
|---|---|
|
||||||
|
| More tags per CIP RTT — higher throughput | Compatible with legacy / narrow firmware |
|
||||||
|
| Bigger buffers held by libplctag native (RSS impact) | Lower memory footprint |
|
||||||
|
| Forward Open rejected on FW19- ControlLogix | Always works (assuming ≥500) |
|
||||||
|
| Required for high-density scan lists | Forces more round-trips — higher latency |
|
||||||
|
|
||||||
|
For most FW20+ ControlLogix shops, the default `4002` is correct and the
|
||||||
|
override is unnecessary. The override is mainly useful when:
|
||||||
|
|
||||||
|
1. **Migrating off Kepware** with a controller-specific slider value already
|
||||||
|
tuned in production — set Connection Size to match.
|
||||||
|
2. **Mixed-firmware fleets** where some controllers are still on FW19 — set
|
||||||
|
the legacy controllers explicitly to `504`.
|
||||||
|
3. **CompactLogix L1/L2/L3** running newer firmware that supports a larger
|
||||||
|
Forward Open than the family-default 504 — bump the override up.
|
||||||
|
4. **Micro800** never goes above `488`; the override is for documentation /
|
||||||
|
discoverability rather than capability change.
|
||||||
|
|
||||||
|
### libplctag wrapper limitation
|
||||||
|
|
||||||
|
The libplctag .NET wrapper (1.5.x) does not expose `connection_size` as a
|
||||||
|
public `Tag` property. The driver propagates the value via reflection on the
|
||||||
|
wrapper's internal `NativeTagWrapper.SetIntAttribute("connection_size", N)`
|
||||||
|
after `InitializeAsync` — equivalent to libplctag's
|
||||||
|
`plc_tag_set_int_attribute`. Because libplctag native parses
|
||||||
|
`connection_size` only at create time, this is **best-effort** until either:
|
||||||
|
|
||||||
|
- the libplctag .NET wrapper exposes `ConnectionSize` directly (planned in
|
||||||
|
the upstream backlog), in which case the reflection no-ops cleanly, or
|
||||||
|
- libplctag native gains post-create hot-update for `connection_size`, in
|
||||||
|
which case the call lands as intended.
|
||||||
|
|
||||||
|
In the meantime the value is correctly stored on `DeviceState.ConnectionSize`
|
||||||
|
+ surfaces in every `AbCipTagCreateParams` the driver builds, so the override
|
||||||
|
is observable end-to-end through the public driver surface and unit tests
|
||||||
|
even if the underlying wrapper isn't yet honouring it on the wire.
|
||||||
|
|
||||||
|
Operators who need *guaranteed* Connection Size enforcement against FW19
|
||||||
|
controllers today can pin `libplctag` to a wrapper version that exposes
|
||||||
|
`ConnectionSize` once one is available, or run a libplctag native build
|
||||||
|
patched for runtime updates. Both paths are tracked in the AB CIP plan.
|
||||||
|
|
||||||
|
### See also
|
||||||
|
|
||||||
|
- [`docs/Driver.AbCip.Cli.md`](../Driver.AbCip.Cli.md) — AB CIP CLI uses the
|
||||||
|
family default ConnectionSize on each invocation; per-device overrides only
|
||||||
|
apply through the driver's device-config JSON, not the CLI's command-line.
|
||||||
|
- [`docs/drivers/AbServer-Test-Fixture.md`](AbServer-Test-Fixture.md) §5 —
|
||||||
|
ab_server simulator does not enforce the narrow CompactLogix cap, so
|
||||||
|
Connection Size correctness is verified by unit tests + Emulate-rig live
|
||||||
|
smokes only.
|
||||||
|
- [`PlcFamilies/AbCipPlcFamilyProfile.cs`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs) —
|
||||||
|
per-family default values.
|
||||||
|
- [`AbCipConnectionSize`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipConnectionSize.cs) —
|
||||||
|
range bounds + legacy-firmware threshold constants.
|
||||||
|
|
||||||
|
## Addressing mode
|
||||||
|
|
||||||
|
### What it is
|
||||||
|
|
||||||
|
CIP exposes two equivalent ways to address a Logix tag on the wire:
|
||||||
|
|
||||||
|
1. **Symbolic** — the request carries the tag's ASCII name and the controller
|
||||||
|
parses + resolves the path on every read. This is the libplctag default
|
||||||
|
and what every previous driver build has used.
|
||||||
|
2. **Logical** — the request carries a CIP Symbol Object instance ID (a small
|
||||||
|
integer assigned by the controller when the project was downloaded). The
|
||||||
|
controller skips ASCII parsing entirely; the lookup is a single
|
||||||
|
instance-table dereference.
|
||||||
|
|
||||||
|
Logical addressing is faster on the controller side and produces smaller
|
||||||
|
request frames. The trade-off is that the driver has to learn the
|
||||||
|
name → instance-id mapping once, by reading the `@tags` pseudo-tag at
|
||||||
|
startup, and the resolution step has to repeat after a controller program
|
||||||
|
download (instance IDs are re-assigned).
|
||||||
|
|
||||||
|
### Enum values
|
||||||
|
|
||||||
|
`AbCipDeviceOptions.AddressingMode` (`AddressingMode` enum, default
|
||||||
|
`Auto`) takes one of three values:
|
||||||
|
|
||||||
|
| Value | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `Auto` | Driver picks. **Currently resolves to `Symbolic`** — a future PR will plumb a real auto-detection heuristic (firmware version + symbol-table size). |
|
||||||
|
| `Symbolic` | Force ASCII symbolic addressing on the wire. The historical default. |
|
||||||
|
| `Logical` | Use CIP logical-segment / instance-ID addressing. Triggers a one-time `@tags` walk at the first read; subsequent reads consult the cached map. |
|
||||||
|
|
||||||
|
`Auto` is documented as "Symbolic-for-now" so deployments setting `Auto`
|
||||||
|
explicitly today will silently flip to a real heuristic when one ships,
|
||||||
|
matching the spirit of the toggle. Operators who want to pin the wire
|
||||||
|
behaviour should set `Symbolic` or `Logical` directly.
|
||||||
|
|
||||||
|
### Family compatibility
|
||||||
|
|
||||||
|
Logical addressing depends on the controller implementing CIP Symbol Object
|
||||||
|
class 0x6B with stable instance IDs. Older AB families don't:
|
||||||
|
|
||||||
|
| Family | Logical addressing supported? | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| `ControlLogix` | yes | Native class 0x6B support, FW10+ |
|
||||||
|
| `CompactLogix` | yes | Same wire protocol as ControlLogix |
|
||||||
|
| `GuardLogix` | yes | Same wire protocol; safety partition is tag-level, not addressing-level |
|
||||||
|
| `Micro800` | **no** | Firmware does not implement class 0x6B; instance-ID reads trip CIP "Path Segment Error" 0x04 |
|
||||||
|
| `SLC500` / `PLC5` | **no** | Pre-CIP families; PCCC bridging only — no Symbol Object at all |
|
||||||
|
|
||||||
|
When `AddressingMode = Logical` is set on an unsupported family, the driver
|
||||||
|
**falls back to Symbolic with a warning** (via `OnWarning`) instead of
|
||||||
|
faulting. This keeps mixed-firmware deployments working — operators can ship
|
||||||
|
a uniform "Logical" config across the fleet and let the driver downgrade
|
||||||
|
the families that can't honour it.
|
||||||
|
|
||||||
|
The driver-level decision is exposed via
|
||||||
|
`PlcFamilies.AbCipPlcFamilyProfile.SupportsLogicalAddressing` and resolved at
|
||||||
|
`AbCipDriver.InitializeAsync` time; the resolved mode is stored on
|
||||||
|
`DeviceState.AddressingMode` and threaded through every
|
||||||
|
`AbCipTagCreateParams` from then on.
|
||||||
|
|
||||||
|
### One-time symbol-table walk
|
||||||
|
|
||||||
|
The first read on a Logical-mode device triggers a one-time `@tags` walk via
|
||||||
|
`LibplctagTagEnumerator` (the same component used for opt-in controller
|
||||||
|
browse). The driver caches the resulting name → instance-id map on
|
||||||
|
`DeviceState.LogicalInstanceMap`; subsequent reads consult the cache without
|
||||||
|
issuing another walk. The walk is gated by a per-device `SemaphoreSlim` so
|
||||||
|
parallel first-reads serialise on a single dispatch.
|
||||||
|
|
||||||
|
The walk happens in `AbCipDriver.EnsureLogicalMappingsAsync` and runs only
|
||||||
|
for devices that have actually resolved to `Logical`. Symbolic-mode devices
|
||||||
|
skip the walk entirely. Walk failures are non-fatal: the
|
||||||
|
`LogicalWalkComplete` flag still flips to `true` so the driver does not
|
||||||
|
re-attempt indefinitely, and per-tag handles fall back to Symbolic addressing
|
||||||
|
on the wire (libplctag's default).
|
||||||
|
|
||||||
|
A controller program download invalidates the instance IDs. There is no
|
||||||
|
auto-invalidation today — operators trigger a fresh walk by either
|
||||||
|
restarting the driver or calling `RebrowseAsync` (the same surface that
|
||||||
|
clears the UDT template cache) with logic-mode plumbing extended in a
|
||||||
|
future PR. For now, restart-on-download is the recommended workflow.
|
||||||
|
|
||||||
|
### libplctag wrapper limitation
|
||||||
|
|
||||||
|
The libplctag .NET wrapper (1.5.x) does **not** expose a public knob for
|
||||||
|
instance-ID addressing. The driver translates Logical-mode params into
|
||||||
|
libplctag attributes via reflection on
|
||||||
|
`NativeTagWrapper.SetAttributeString("use_connected_msg", "1")` +
|
||||||
|
`SetAttributeString("cip_addr", "0x6B,N")` — same best-effort fallback
|
||||||
|
pattern as the Connection Size knob.
|
||||||
|
|
||||||
|
This means **Logical mode is observable end-to-end through the public
|
||||||
|
driver surface and unit tests today**, but the actual wire behaviour
|
||||||
|
remains Symbolic until either:
|
||||||
|
|
||||||
|
- the upstream libplctag .NET wrapper exposes the
|
||||||
|
`UseConnectedMessaging` + `CipAddr` properties on `Tag` directly
|
||||||
|
(planned in the upstream backlog), in which case the reflection no-ops
|
||||||
|
cleanly, or
|
||||||
|
- libplctag native gains post-create hot-update for `cip_addr`, in which
|
||||||
|
case the call lands as intended.
|
||||||
|
|
||||||
|
The driver-level bookkeeping (resolved mode, instance-id map, family
|
||||||
|
compatibility, fall-back warning) is fully wired so the upgrade path is
|
||||||
|
purely a wrapper-version bump.
|
||||||
|
|
||||||
|
### Performance trade-off
|
||||||
|
|
||||||
|
| Symbolic addressing | Logical addressing |
|
||||||
|
|---|---|
|
||||||
|
| Works everywhere | Requires Symbol Object class 0x6B |
|
||||||
|
| ASCII parse on every read (controller-side cost) | One-time walk; instance-id lookup thereafter |
|
||||||
|
| No first-read latency | First read on a device pays the `@tags` walk |
|
||||||
|
| Smaller code surface | Stale on program download — restart driver to re-walk |
|
||||||
|
| Best for small / sparse tag sets | Best for >500-tag scans with stable controller |
|
||||||
|
|
||||||
|
For scan lists in the **single-digit-tag** range, the per-poll ASCII parse
|
||||||
|
cost is invisible. For **medium** scan lists (~100 tags) the gain is real
|
||||||
|
but small — typically 5–10% per CIP RTT depending on tag-name length. The
|
||||||
|
break-even point is where the ASCII-parse overhead starts dominating,
|
||||||
|
roughly **>500 tags** in a tight scan loop, which is also where libplctag's
|
||||||
|
own request-packing benefits compound. Large MES / batch projects with
|
||||||
|
many UDT instances are the canonical case.
|
||||||
|
|
||||||
|
### Driver config JSON
|
||||||
|
|
||||||
|
Bind the toggle through the driver-config JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Devices": [
|
||||||
|
{
|
||||||
|
"HostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"PlcFamily": "ControlLogix",
|
||||||
|
"AddressingMode": "Logical"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`"Auto"`, `"Symbolic"`, and `"Logical"` parse case-insensitively. Omitting
|
||||||
|
the field defaults to `"Auto"`.
|
||||||
|
|
||||||
|
### See also
|
||||||
|
|
||||||
|
- [`AbCipDriverOptions.AddressingMode`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs) —
|
||||||
|
enum definition + per-value docstrings.
|
||||||
|
- [`AbCipPlcFamilyProfile.SupportsLogicalAddressing`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs) —
|
||||||
|
family compatibility table source-of-truth.
|
||||||
|
- [`docs/drivers/AbServer-Test-Fixture.md`](AbServer-Test-Fixture.md) §
|
||||||
|
"What it actually covers" — Logical-mode fixture coverage status.
|
||||||
|
- [`AbCipAddressingModeBenchTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipAddressingModeBenchTests.cs) —
|
||||||
|
scaffold for the wall-clock comparison; gated on `[AbServerFact]`.
|
||||||
|
|
||||||
|
## Read strategy (PR abcip-3.3)
|
||||||
|
|
||||||
|
A per-device toggle that controls how multi-member UDT batches are read.
|
||||||
|
The default `Auto` value matches every previous build's behaviour for dense
|
||||||
|
reads but switches to per-member bundling when only a handful of members of
|
||||||
|
a large UDT are subscribed — the canonical "5 of 50" sparse-subscription
|
||||||
|
case where reading the whole UDT buffer just to extract a few fields wastes
|
||||||
|
wire bandwidth.
|
||||||
|
|
||||||
|
### Three modes
|
||||||
|
|
||||||
|
| Mode | When to use |
|
||||||
|
|---|---|
|
||||||
|
| `WholeUdt` | Most members of every subscribed UDT are read together. One libplctag read per parent UDT, members decoded in-memory at their byte offsets. The task #194 default. |
|
||||||
|
| `MultiPacket` | A few members of a large UDT are subscribed at a time. One read per subscribed member, bundled per parent into one CIP Multi-Service Packet. |
|
||||||
|
| `Auto` (default) | Planner picks per-batch from the subscribed-member fraction (see *Sparsity threshold*). |
|
||||||
|
|
||||||
|
### Sparsity threshold
|
||||||
|
|
||||||
|
Auto mode divides `subscribedMembers / totalMembers` for each parent UDT and
|
||||||
|
picks `MultiPacket` when the fraction is **strictly less than** the
|
||||||
|
threshold, else `WholeUdt`. Default threshold `0.25` — a 1/4 subscription is
|
||||||
|
the rough break-even where the wire-cost of one whole-UDT read still beats
|
||||||
|
N member reads on a ControlLogix 4002-byte connection-size buffer; above
|
||||||
|
1/4, the per-member overhead dominates.
|
||||||
|
|
||||||
|
Tune via `AbCipDeviceOptions.MultiPacketSparsityThreshold` (clamped to
|
||||||
|
`[0..1]`). Threshold `0.0` = "never MultiPacket"; `1.0` = "always MultiPacket
|
||||||
|
when any member is subscribed."
|
||||||
|
|
||||||
|
### Family compatibility
|
||||||
|
|
||||||
|
`MultiPacket` requires CIP service `0x0A` (Multi-Service Packet) on the
|
||||||
|
controller. Source of truth is
|
||||||
|
[`AbCipPlcFamilyProfile.SupportsRequestPacking`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs):
|
||||||
|
|
||||||
|
| Family | `SupportsRequestPacking` |
|
||||||
|
|---|---|
|
||||||
|
| ControlLogix | yes |
|
||||||
|
| CompactLogix | yes |
|
||||||
|
| GuardLogix | yes (wire identical to ControlLogix) |
|
||||||
|
| Micro800 | **no** |
|
||||||
|
| SLC500 / PLC5 (when those profiles ship) | **no** |
|
||||||
|
|
||||||
|
User-forced `MultiPacket` against a non-packing family logs a warning at
|
||||||
|
device init and falls back to `WholeUdt`. `Auto` against a non-packing
|
||||||
|
family stays `Auto` at the device level — the per-batch heuristic caps the
|
||||||
|
strategy to `WholeUdt` so the wire never sees a Multi-Service-Packet against
|
||||||
|
a controller that can't decode it.
|
||||||
|
|
||||||
|
### libplctag wrapper limitation
|
||||||
|
|
||||||
|
The libplctag .NET wrapper (1.5.x) does not expose the `0x0A` service as a
|
||||||
|
public knob — same wrapper-version constraint that gates PR abcip-3.1's
|
||||||
|
`connection_size` and PR abcip-3.2's instance-ID addressing. Today's
|
||||||
|
MultiPacket runtime therefore issues N libplctag reads sequentially when
|
||||||
|
the planner picks the strategy; the wire-level bundling lands cleanly when
|
||||||
|
an upstream wrapper release exposes the primitive.
|
||||||
|
|
||||||
|
The driver-level bookkeeping (resolved strategy, per-batch heuristic,
|
||||||
|
family-compat fall-back, per-device dispatch counters) is fully wired so
|
||||||
|
the upgrade path is a wrapper-version bump only — the planner already
|
||||||
|
produces the right plan, and `AbCipMultiPacketReadPlanner.Build` is
|
||||||
|
covered by unit tests that pin the plan shape rather than wire bytes.
|
||||||
|
|
||||||
|
### Driver config JSON
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Devices": [
|
||||||
|
{
|
||||||
|
"HostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"PlcFamily": "ControlLogix",
|
||||||
|
"ReadStrategy": "Auto",
|
||||||
|
"MultiPacketSparsityThreshold": 0.25
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`"Auto"`, `"WholeUdt"`, and `"MultiPacket"` parse case-insensitively.
|
||||||
|
Omitting the field defaults to `"Auto"`. Omitting
|
||||||
|
`MultiPacketSparsityThreshold` defaults to `0.25`.
|
||||||
|
|
||||||
|
### See also
|
||||||
|
|
||||||
|
- [`AbCipDriverOptions.ReadStrategy`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs) —
|
||||||
|
enum definition + per-value docstrings.
|
||||||
|
- [`AbCipMultiPacketReadPlanner`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipMultiPacketReadPlanner.cs) —
|
||||||
|
plan shape + Auto-mode heuristic.
|
||||||
|
- [`AbCipPlcFamilyProfile.SupportsRequestPacking`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs) —
|
||||||
|
family compatibility table source-of-truth.
|
||||||
|
- [`AbCipReadStrategyTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipReadStrategyTests.cs) —
|
||||||
|
device-init resolution, heuristic edges, dispatch counters, DTO round-trip.
|
||||||
|
- [`AbCipEmulateMultiPacketReadTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateMultiPacketReadTests.cs) —
|
||||||
|
golden-box-tier wire-level coverage scaffold; gated on `AB_SERVER_PROFILE=emulate`.
|
||||||
141
docs/drivers/AbLegacy-DH-Bridging.md
Normal file
141
docs/drivers/AbLegacy-DH-Bridging.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# AB Legacy — DH+ via 1756-DHRIO bridging
|
||||||
|
|
||||||
|
PR ablegacy-13 / [#256](https://github.com/dohertj2/lmxopcua/issues/256). The AB
|
||||||
|
Legacy driver can address a PLC-5 sitting on a DH+ link by routing CIP requests
|
||||||
|
through a 1756-DHRIO module installed in a ControlLogix chassis. This is the
|
||||||
|
canonical way to keep an installed-base PLC-5 fleet alive after the chassis-
|
||||||
|
level migration to ControlLogix; the DHRIO module exposes a DH+ "side" that
|
||||||
|
talks to the legacy PLC-5 / SLC-DH+ peers and a backplane "side" that the
|
||||||
|
ControlLogix CPU + Ethernet bridge can route through.
|
||||||
|
|
||||||
|
## Wire layout
|
||||||
|
|
||||||
|
```
|
||||||
|
OtOpcUa server ──EtherNet/IP──► 1756-EN2T (slot 0) ──backplane──► 1756-DHRIO (slot N) ──DH+──► PLC-5
|
||||||
|
```
|
||||||
|
|
||||||
|
Two CIP hops:
|
||||||
|
|
||||||
|
1. **Backplane** — port `1`, slot `<N>` (the slot the DHRIO module lives in).
|
||||||
|
2. **DH+** — port `2`, station `<S>` (the DH+ node address of the target PLC-5,
|
||||||
|
in **octal**).
|
||||||
|
|
||||||
|
Resulting CIP path: `1,<N>,2,<S>`.
|
||||||
|
|
||||||
|
> The first port `1` is always the backplane; port `2` is the DH+ side of the
|
||||||
|
> 1756-DHRIO module. This mirrors the convention Rockwell uses in RSLinx + RSLogix
|
||||||
|
> 5.
|
||||||
|
|
||||||
|
## Octal station number
|
||||||
|
|
||||||
|
The DH+ network was specified with **octal** node addresses. Rockwell tooling
|
||||||
|
displays them in octal too (RSLogix 5 → "DH+ Node Address" field on the
|
||||||
|
controller properties dialog). The driver follows suit — the station segment
|
||||||
|
of the CIP path **must be parsed as octal** (digits 0..7 only; `8`, `9`, and
|
||||||
|
multi-byte garbage are rejected).
|
||||||
|
|
||||||
|
DH+ addresses run `0..77` octal == `0..63` decimal. Quick reference:
|
||||||
|
|
||||||
|
| Octal | Decimal | Octal | Decimal | Octal | Decimal | Octal | Decimal |
|
||||||
|
|------:|--------:|------:|--------:|------:|--------:|------:|--------:|
|
||||||
|
| 00 | 0 | 20 | 16 | 40 | 32 | 60 | 48 |
|
||||||
|
| 01 | 1 | 21 | 17 | 41 | 33 | 61 | 49 |
|
||||||
|
| 02 | 2 | 22 | 18 | 42 | 34 | 62 | 50 |
|
||||||
|
| 03 | 3 | 23 | 19 | 43 | 35 | 63 | 51 |
|
||||||
|
| 04 | 4 | 24 | 20 | 44 | 36 | 64 | 52 |
|
||||||
|
| 05 | 5 | 25 | 21 | 45 | 37 | 65 | 53 |
|
||||||
|
| 06 | 6 | 26 | 22 | 46 | 38 | 66 | 54 |
|
||||||
|
| 07 | 7 | 27 | 23 | 47 | 39 | 67 | 55 |
|
||||||
|
| 10 | 8 | 30 | 24 | 50 | 40 | 70 | 56 |
|
||||||
|
| 11 | 9 | 31 | 25 | 51 | 41 | 71 | 57 |
|
||||||
|
| 12 | 10 | 32 | 26 | 52 | 42 | 72 | 58 |
|
||||||
|
| 13 | 11 | 33 | 27 | 53 | 43 | 73 | 59 |
|
||||||
|
| 14 | 12 | 34 | 28 | 54 | 44 | 74 | 60 |
|
||||||
|
| 15 | 13 | 35 | 29 | 55 | 45 | 75 | 61 |
|
||||||
|
| 16 | 14 | 36 | 30 | 56 | 46 | 76 | 62 |
|
||||||
|
| 17 | 15 | 37 | 31 | 57 | 47 | 77 | 63 |
|
||||||
|
|
||||||
|
Anything past `77` octal (i.e. decimal > 63) is invalid on a real DH+ network
|
||||||
|
and rejected by the parser.
|
||||||
|
|
||||||
|
## PLC-5 only
|
||||||
|
|
||||||
|
DHRIO bridging is **PLC-5-only**. The driver enforces this at
|
||||||
|
`AbLegacyDriver.InitializeAsync` time: a DH+ bridge path combined with
|
||||||
|
`PlcFamily=Slc500 / MicroLogix / LogixPccc` throws
|
||||||
|
`InvalidOperationException("DHRIO bridging is PLC-5-only")` immediately rather
|
||||||
|
than letting reads silently fail with `BadCommunicationError` on the wire.
|
||||||
|
|
||||||
|
Background: the 1756-DHRIO module only speaks DH+ to PLC-5 / SLC-DH+ peers, and
|
||||||
|
libplctag's PCCC stack only exposes the PLC-5 side. SLC 5/04 boxes on DH+
|
||||||
|
**can** be physically reached through a DHRIO module, but the protocol stack
|
||||||
|
needed to drive them isn't exposed by libplctag — out of scope for this driver.
|
||||||
|
|
||||||
|
## CLI worked example
|
||||||
|
|
||||||
|
PLC-5 at DH+ node `07` (octal == 7 decimal), DHRIO module in slot 3, gateway
|
||||||
|
`192.168.1.10`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-ablegacy-cli probe `
|
||||||
|
-g ab://192.168.1.10/1,3,2,07 `
|
||||||
|
-P Plc5 `
|
||||||
|
-a N7:0
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Read N7:10 from the PLC-5 across the DHRIO bridge
|
||||||
|
otopcua-ablegacy-cli read `
|
||||||
|
-g ab://192.168.1.10/1,3,2,07 `
|
||||||
|
-P Plc5 `
|
||||||
|
-a N7:10 `
|
||||||
|
-t Int
|
||||||
|
```
|
||||||
|
|
||||||
|
The driver surfaces the parsed bridge form on the host-address record:
|
||||||
|
`BackplaneSlot=3`, `DhPlusPort=2`, `DhPlusStation=7` (decimal-translated). Use
|
||||||
|
those values when reading driver-diagnostics output to confirm the bridge was
|
||||||
|
recognised — a non-bridge CIP path leaves all three fields null.
|
||||||
|
|
||||||
|
## Manual smoke procedure
|
||||||
|
|
||||||
|
There is no automated end-to-end coverage for DH+ bridging because the only
|
||||||
|
path to wire-level validation is real hardware (libplctag's `ab_server` Docker
|
||||||
|
image doesn't simulate the DHRIO + DH+ + PLC-5 stack). The unit-test layer
|
||||||
|
covers parser positive / negative cases.
|
||||||
|
|
||||||
|
Hardware smoke checklist:
|
||||||
|
|
||||||
|
1. Confirm the 1756-DHRIO module is present in the target ControlLogix chassis.
|
||||||
|
RSLinx Classic should show `DH+, 1` under the chassis tree with the PLC-5
|
||||||
|
nodes enumerated underneath.
|
||||||
|
2. Note the DHRIO module's slot number (the `<N>` in `1,<N>,2,<S>`).
|
||||||
|
3. Note the target PLC-5's DH+ node address — read it off the front-panel switch
|
||||||
|
bank, or the controller properties in RSLogix 5. **Read it as octal**.
|
||||||
|
4. From an OtOpcUa box that can reach the EtherNet/IP gateway:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-ablegacy-cli probe -g ab://<gateway>/1,<slot>,2,<station-octal> -P Plc5 -a S:0
|
||||||
|
```
|
||||||
|
|
||||||
|
`S:0` (status file word 0) is non-destructive and present on every PLC-5.
|
||||||
|
5. If the probe succeeds, exercise an N file read against a known
|
||||||
|
non-zero address. Compare against the value displayed in RSLogix 5 →
|
||||||
|
Online → Data → N7.
|
||||||
|
|
||||||
|
If the probe fails with `BadCommunicationError`:
|
||||||
|
|
||||||
|
- Wrong slot number — re-check via RSLinx.
|
||||||
|
- Wrong octal node — convert from RSLogix 5's display value (already octal); a
|
||||||
|
decimal-thinking conversion mistake is the most common smoke failure.
|
||||||
|
- DHRIO module's DH+ baud rate doesn't match the PLC-5's switch setting (57.6k
|
||||||
|
/ 115.2k / 230.4k) — driver-side problem this can't paper over.
|
||||||
|
- A scanner on the DHRIO is in scheduled-mode and starving unscheduled
|
||||||
|
PCCC traffic — bump the DHRIO's unscheduled-message slice in RSLogix 5000.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [`Driver.AbLegacy.Cli.md`](../Driver.AbLegacy.Cli.md) — the family / CIP-path
|
||||||
|
cheat sheet now carries a DHRIO row.
|
||||||
|
- [`drivers/AbLegacy-Test-Fixture.md`](AbLegacy-Test-Fixture.md) — DH+ bridging
|
||||||
|
is unit-only; no Docker fixture supports it.
|
||||||
188
docs/drivers/AbLegacy-Diagnostics.md
Normal file
188
docs/drivers/AbLegacy-Diagnostics.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# AB Legacy diagnostic counters
|
||||||
|
|
||||||
|
Per-device diagnostic counters surface as auto-generated read-only OPC UA
|
||||||
|
variables under each device's synthetic `_Diagnostics/` folder. HMIs can bind
|
||||||
|
directly without going through a separate diagnostics RPC. Mirrors the AB CIP
|
||||||
|
`_System/` pattern from PR abcip-4.3.
|
||||||
|
|
||||||
|
Closes #253 (PR ablegacy-10).
|
||||||
|
|
||||||
|
## The nine counters
|
||||||
|
|
||||||
|
Each device managed by the `AbLegacyDriver` exposes nine read-only nodes under
|
||||||
|
`AbLegacy/<host>/_Diagnostics/<name>`. The first seven shipped in PR ablegacy-10;
|
||||||
|
`DemoteCount` + `LastDemotedUtc` arrived with PR ablegacy-12 / #255 (auto-demote
|
||||||
|
on comm failure).
|
||||||
|
|
||||||
|
| Name | Type | Semantics |
|
||||||
|
|---|---|---|
|
||||||
|
| `RequestCount` | Int64 | Total `ReadAsync` requests issued against this device. One increment per non-diagnostic reference per call, success or failure. |
|
||||||
|
| `ResponseCount` | Int64 | Successful read responses. |
|
||||||
|
| `ErrorCount` | Int64 | Failed read responses (any non-Good status). |
|
||||||
|
| `RetryCount` | Int64 | Retry attempts beyond the first per the PR 9 retry loop. A single read with two retries adds two. |
|
||||||
|
| `LastErrorCode` | Int32 | Most recent libplctag status code on a failed read; `0` when no error has been seen since the last reset. |
|
||||||
|
| `LastErrorMessage` | String | Most recent libplctag error message on a failed read; empty when no error has been seen since the last reset. |
|
||||||
|
| `CommFailures` | Int64 | Count of read failures mapped to `BadCommunicationError`. Spans transient libplctag throws + retried-out chains so operators see a single "wire fell off" counter. |
|
||||||
|
| `DemoteCount` | Int64 | **PR ablegacy-12** — cumulative auto-demote events for this device. Bumps every time the driver crosses the consecutive-failure threshold and arms a fresh cool-down window. Cumulative across `ReinitializeAsync` (preserved through redeploys) so a flapping link surfaces as a steadily climbing counter. |
|
||||||
|
| `LastDemotedUtc` | String | **PR ablegacy-12** — ISO-8601 UTC timestamp of the most recent auto-demotion. Empty string when this device has never been demoted. |
|
||||||
|
|
||||||
|
**Address shape**: `_Diagnostics/<deviceHostAddress>/<name>` —
|
||||||
|
e.g. `_Diagnostics/ab://10.0.0.5/1,0/RequestCount`.
|
||||||
|
|
||||||
|
The `<deviceHostAddress>` segment is the canonical `ab://host[:port]/cip-path`
|
||||||
|
string from `AbLegacyDeviceOptions.HostAddress`. The browse path looks like
|
||||||
|
`AbLegacy/<deviceHostAddress>/_Diagnostics/<name>` — the same shape as a
|
||||||
|
user-config tag node, just under a reserved sibling folder.
|
||||||
|
|
||||||
|
## Reset behaviour
|
||||||
|
|
||||||
|
| Trigger | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `ReinitializeAsync` | Every counter for every device resets to zero, plus `LastErrorMessage` clears to empty. **PR ablegacy-12 exception:** `DemoteCount` + `LastDemotedUtc` survive the reinit so an operator redeploying mid-incident doesn't lose the flapping-link history. |
|
||||||
|
| `ShutdownAsync` | All counters drop with the device map (including `DemoteCount`). |
|
||||||
|
| Driver process restart | Counters start at zero. |
|
||||||
|
| Probe transition Stopped→Running | **No automatic reset** — counters are cumulative across reconnect events so operators can spot intermittent links by watching `CommFailures` keep climbing. |
|
||||||
|
| Probe transition Demoted→Running | **PR ablegacy-12** — early-clear of the active demote window, but the cumulative `DemoteCount` stays put. |
|
||||||
|
|
||||||
|
There is no in-process "reset" RPC at the time of writing. If you need to
|
||||||
|
clear counters without a redeploy, kick a `ReinitializeAsync` from the Admin
|
||||||
|
RPC surface — the driver re-EnsureDevice's each host so the freshly registered
|
||||||
|
counters start at zero.
|
||||||
|
|
||||||
|
## What does *not* increment counters
|
||||||
|
|
||||||
|
Reads against `_Diagnostics/<host>/<name>` are **driver-local observability**,
|
||||||
|
not field traffic — they short-circuit before the libplctag dispatch and do
|
||||||
|
NOT increment `RequestCount` or any other counter. Otherwise a 1 Hz HMI poll
|
||||||
|
of `RequestCount` would make the counter chase its own tail.
|
||||||
|
|
||||||
|
Writes against `_Diagnostics/*` are rejected with `BadNotWritable` because
|
||||||
|
every diagnostic node is `SecurityClassification.ViewOnly` — a misbehaving
|
||||||
|
SCADA template can't accidentally clobber the diagnostic surface.
|
||||||
|
|
||||||
|
## Collision with user tags
|
||||||
|
|
||||||
|
User-config tags must not shadow the seven reserved diagnostic names and
|
||||||
|
must not live under the synthetic `_Diagnostics/` folder. Both shapes are
|
||||||
|
rejected at `InitializeAsync` time with a clear `InvalidOperationException`:
|
||||||
|
|
||||||
|
- A tag named `RequestCount` (or any of the other six reserved names) is
|
||||||
|
rejected because it would silently never resolve at read time — the
|
||||||
|
diagnostics short-circuit wins.
|
||||||
|
- A tag whose `Address` starts with `_Diagnostics/` is rejected because the
|
||||||
|
whole prefix is owned by the auto-emitted counters.
|
||||||
|
|
||||||
|
Pick a different name (`SiteRequestCount`, `MachineRequestCount`) or a
|
||||||
|
different address path (real PCCC files like `N7:0`).
|
||||||
|
|
||||||
|
## HMI binding examples
|
||||||
|
|
||||||
|
### OPC UA Client CLI
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- read `
|
||||||
|
-u opc.tcp://localhost:4840 `
|
||||||
|
-n "ns=2;s=AbLegacy/ab://10.0.0.5/1,0/_Diagnostics/RequestCount"
|
||||||
|
```
|
||||||
|
|
||||||
|
### AB Legacy CLI (driver-direct, no OPC UA layer)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli -- read `
|
||||||
|
-g "ab://10.0.0.5/1,0" -P Slc500 `
|
||||||
|
--address "_Diagnostics/RequestCount"
|
||||||
|
```
|
||||||
|
|
||||||
|
The driver-direct path lets you sanity-check the counter without standing up
|
||||||
|
an OPC UA server — useful when triaging a wire-level issue on the bench.
|
||||||
|
|
||||||
|
### Subscription pattern
|
||||||
|
|
||||||
|
Subscribe to all seven counters at a slow rate (e.g. 5–10 s) on a long-lived
|
||||||
|
overview dashboard, plus a faster rate (1 s) on `LastErrorMessage` /
|
||||||
|
`LastErrorCode` when actively debugging a flapping link. The diagnostics
|
||||||
|
short-circuit makes every read O(1) — there's no penalty for fast polling
|
||||||
|
of the counter itself, only the OPC UA subscription bookkeeping.
|
||||||
|
|
||||||
|
## Auto-demote on comm failure (PR ablegacy-12 / #255)
|
||||||
|
|
||||||
|
When a device fails N consecutive reads or probes the driver marks it
|
||||||
|
**Demoted** for a configurable cool-down window. Reads against a demoted
|
||||||
|
device short-circuit with `BadCommunicationError` *without invoking
|
||||||
|
libplctag* — that's the whole point of the feature: one slow PLC sharing
|
||||||
|
the driver thread can't starve faster peers reading from healthy hosts on
|
||||||
|
the same `AbLegacyDriver` instance.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Per-device, optional. `null` keeps the documented defaults (auto-demote
|
||||||
|
**enabled** with 3 failures / 30 s).
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"Devices": [
|
||||||
|
{
|
||||||
|
"HostAddress": "ab://10.0.0.5/1,0",
|
||||||
|
"PlcFamily": "Slc500",
|
||||||
|
"Demote": {
|
||||||
|
"FailureThreshold": 3, // default 3
|
||||||
|
"DemoteForMs": 30000, // default 30s
|
||||||
|
"Enabled": true // default true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Knob | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `FailureThreshold` | `3` | Consecutive comm failures before the device is demoted. A successful read or probe resets the tally. Terminal failures (`BadNodeIdUnknown`, `BadTypeMismatch`, …) **do not count** — they're config / decoder mismatches, not field outages. |
|
||||||
|
| `DemoteForMs` | `30000` (30s) | Cool-down window. Reads while this is active short-circuit; a successful probe clears it early. |
|
||||||
|
| `Enabled` | `true` | Set to `false` to keep the diagnostic counters but skip the auto-throttle. The failure tally still ticks but never arms the cool-down. |
|
||||||
|
|
||||||
|
### Recovery
|
||||||
|
|
||||||
|
Three ways out of Demoted, in order of likelihood:
|
||||||
|
|
||||||
|
1. **Probe success** — the per-device probe loop (`Probe.Enabled = true`,
|
||||||
|
default address `S:0`) is the fast path. The next probe iteration after
|
||||||
|
demotion will exercise the wire; on success it clears
|
||||||
|
`DemotedUntilUtc` immediately and transitions the host to `Running`.
|
||||||
|
2. **Window expiry** — once `DemoteForMs` elapses the demote marker
|
||||||
|
clears on the next read attempt. The read goes through; if it fails,
|
||||||
|
the failure tally keeps counting from where it left off (so a
|
||||||
|
permanently-down device re-arms the window after one more consecutive
|
||||||
|
failure rather than having to repeat the full threshold).
|
||||||
|
3. **`ReinitializeAsync`** — clears `ConsecutiveFailures` +
|
||||||
|
`DemotedUntilUtc` outright. Cumulative `DemoteCount` survives.
|
||||||
|
|
||||||
|
### Observability
|
||||||
|
|
||||||
|
`DemoteCount` is the headline counter — it bumps once per demotion event,
|
||||||
|
not per short-circuited read. A device that flaps every hour for a week
|
||||||
|
shows `DemoteCount = ~168` on Friday afternoon, which is the operator
|
||||||
|
signal you actually want.
|
||||||
|
|
||||||
|
`LastDemotedUtc` is the ISO-8601 UTC timestamp of the most recent
|
||||||
|
demotion. Bind it on a per-device tile alongside `DemoteCount` for
|
||||||
|
"flapping link" alerting.
|
||||||
|
|
||||||
|
### Host-state surface
|
||||||
|
|
||||||
|
A demoted device reports `HostState.Demoted` (new in PR ablegacy-12
|
||||||
|
on `Core.Abstractions/IHostConnectivityProbe.cs`). Consumers that
|
||||||
|
predate the new value (the central `HostStatusPublisher`) safely treat
|
||||||
|
it as `Stopped` — no schema migration needed.
|
||||||
|
|
||||||
|
## Cross-references
|
||||||
|
|
||||||
|
- [`AbLegacyDiagnosticTags.cs`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDiagnosticTags.cs)
|
||||||
|
— counter store + read short-circuit
|
||||||
|
- [`AbLegacyDriver.cs`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs)
|
||||||
|
— increment sites in `ReadAsync`, discovery emission in `DiscoverAsync`,
|
||||||
|
auto-demote bookkeeping in `RecordFailureAndMaybeDemote` + `ProbeLoopAsync`
|
||||||
|
- [`AbLegacy-Test-Fixture.md`](AbLegacy-Test-Fixture.md) — `AbLegacyDiagnosticsTests`
|
||||||
|
+ `AbLegacyAutoDemoteTests` + collision-rejection contract
|
||||||
|
- [AB CIP `_System/` parallel](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipSystemTagSource.cs)
|
||||||
|
— same pattern with the CIP-specific six entries (incl. writeable
|
||||||
|
`_RefreshTagDb` trigger)
|
||||||
163
docs/drivers/AbLegacy-RSLogix-Import.md
Normal file
163
docs/drivers/AbLegacy-RSLogix-Import.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# AB Legacy — RSLogix symbol & data-table import
|
||||||
|
|
||||||
|
ablegacy-11 / [#254](https://github.com/dohertj2/lmxopcua/issues/254) — bulk-import
|
||||||
|
RSLogix 500 / 5 symbol exports into the AB Legacy driver. Saves operators from
|
||||||
|
hand-typing every `N7:0` / `F8:12` / `B3:0/5` row of a several-hundred-tag PLC
|
||||||
|
into `appsettings.json`.
|
||||||
|
|
||||||
|
## Supported formats — v1
|
||||||
|
|
||||||
|
| Format | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `.CSV` "Database Export" | **supported** | Header columns `Symbol,Address,Description,DataType,Scope`; quoted fields, doubled-quote escapes, comment lines (`;` / `#`) all honoured |
|
||||||
|
| `.SLC` text export | **supported** | RSLogix 500's "Save As Text" emits the same column shape — point the importer at the file directly |
|
||||||
|
| `.RSS` (RSLogix 500 binary project) | **out of scope** | Proprietary; no parser ships in libplctag or any community project. Export to CSV first |
|
||||||
|
| `.RSP` (RSLogix 5 binary project) | **out of scope** | Same as `.RSS` |
|
||||||
|
|
||||||
|
The binary `.RSS` / `.RSP` non-goal isn't a "we don't have time" decision —
|
||||||
|
Rockwell's binary format is undocumented + tied to RSLogix's internal page
|
||||||
|
layout, and the only known parsers are commercial IDE plugins. v1 ships with
|
||||||
|
text/CSV only and a clean abstraction (`IRsLogixImporter`) so a binary parser
|
||||||
|
can slot in later without reshaping the call sites.
|
||||||
|
|
||||||
|
## CSV column reference
|
||||||
|
|
||||||
|
| Column | Required | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `Symbol` | yes | OPC UA tag name. RSLogix symbols are already stable; the importer uses them verbatim |
|
||||||
|
| `Address` | yes | PCCC address. File letter implies `DataType` (see below); the importer's resolution wins over the CSV's `DataType` column |
|
||||||
|
| `Description` | no | Parsed but currently unused — `AbLegacyTagDefinition` has no `Description` field at the v2 schema layer (see [#248](https://github.com/dohertj2/lmxopcua/issues/248)). Held in the column contract for future schema bumps |
|
||||||
|
| `DataType` | no | RSLogix-supplied (`INT` / `REAL` / `BOOL` / `TIMER` / …). Ignored at import time; the importer derives the type from the file letter |
|
||||||
|
| `Scope` | no | `Global` (default when blank) or `Local:N` for ladder-file-N-scoped tags. Acts as a filter when `--scope` is set on the CLI |
|
||||||
|
|
||||||
|
### File-letter → `AbLegacyDataType` mapping
|
||||||
|
|
||||||
|
| Letter | Example | Maps to | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `N` | `N7:0` | `Int` (signed 16-bit) | |
|
||||||
|
| `F` | `F8:0` | `Float` (32-bit IEEE-754) | |
|
||||||
|
| `B` | `B3:0/0` | `Bit` | Bit-within-word also forces Bit when `BitIndex` is set |
|
||||||
|
| `L` | `L9:0` | `Long` (signed 32-bit) | SLC 5/05+ only |
|
||||||
|
| `ST` | `ST10:0` | `String` | 82-byte fixed-length + length word |
|
||||||
|
| `T` | `T4:0.ACC` | `TimerElement` | Sub-element implied by `.ACC` / `.PRE` / `.EN` / `.DN` |
|
||||||
|
| `C` | `C5:0.ACC` | `CounterElement` | |
|
||||||
|
| `R` | `R6:0.LEN` | `ControlElement` | |
|
||||||
|
| `A` | `A14:0` | `AnalogInt` | Older hardware |
|
||||||
|
| `I` / `O` / `S` | `I:0/0` | `Int` (or `Bit` with bit suffix) | I/O + status files |
|
||||||
|
| `PD` / `MG` / `PLS` / `BT` | `PD9:0` | `PidElement` etc. | Family-gated; PD/MG common on SLC500 + PLC-5; PLS/BT PLC-5 only |
|
||||||
|
| `RTC` / `HSC` / `DLS` / … | `RTC:0.YR` | `MicroLogixFunctionFile` | MicroLogix 1100 / 1400 only |
|
||||||
|
|
||||||
|
A bit suffix (`/N`) on any file letter forces `Bit`, regardless of the file
|
||||||
|
letter's normal classification — `N7:0/3` parses as Bit, not Int.
|
||||||
|
|
||||||
|
## Scope filter
|
||||||
|
|
||||||
|
The `Scope` column distinguishes program-scoped tags (`Local:1`, `Local:2`, …)
|
||||||
|
from globals. RSLogix exports usually mix both. The CLI's `--scope` flag (and
|
||||||
|
`ImportOptions.ScopeFilter` at the API level) keeps only the rows whose
|
||||||
|
`Scope` value matches case-insensitively; rows with no `Scope` column count as
|
||||||
|
`Global`.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Import only the Global symbols
|
||||||
|
otopcua-ablegacy-cli import-rslogix `
|
||||||
|
--file plc-export.csv `
|
||||||
|
--device ab://192.168.1.20/1,0 `
|
||||||
|
--scope Global
|
||||||
|
|
||||||
|
# Import only the file-2 program-scope tags
|
||||||
|
otopcua-ablegacy-cli import-rslogix `
|
||||||
|
--file plc-export.csv `
|
||||||
|
--device ab://192.168.1.20/1,0 `
|
||||||
|
--scope Local:2
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI subcommand — `import-rslogix`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-ablegacy-cli import-rslogix --help
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `-f` / `--file` | **required** | Path to the CSV export |
|
||||||
|
| `-d` / `--device` | **required** | Canonical AB Legacy gateway URI every imported tag binds to |
|
||||||
|
| `--emit` | `appsettings-fragment` | `appsettings-fragment` (JSON) or `summary` (one-line counter) |
|
||||||
|
| `-o` / `--output` | stdout | Optional path; when set the JSON fragment is written there + summary line goes to stdout |
|
||||||
|
| `--scope` | none | Optional Scope filter (case-insensitive) |
|
||||||
|
| `--max-rows` | unlimited | Defensive cap on rows imported |
|
||||||
|
| `--strict` | off | Fail-fast on the first malformed row (default permissive: skip + log) |
|
||||||
|
|
||||||
|
### `appsettings-fragment` output shape
|
||||||
|
|
||||||
|
The default `--emit appsettings-fragment` mode writes a JSON object whose
|
||||||
|
`Tags` array is shaped like the `AbLegacyDriverConfigDto.Tags` array — paste
|
||||||
|
straight into the driver-instance config under
|
||||||
|
`Drivers/<instance>/Config/Tags`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Tags": [
|
||||||
|
{
|
||||||
|
"Name": "MotorSpeed",
|
||||||
|
"DeviceHostAddress": "ab://192.168.1.20/1,0",
|
||||||
|
"Address": "N7:0",
|
||||||
|
"DataType": "Int",
|
||||||
|
"Writable": true
|
||||||
|
},
|
||||||
|
…
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Summary line
|
||||||
|
|
||||||
|
`--emit summary` writes a single line:
|
||||||
|
|
||||||
|
```
|
||||||
|
Imported 142 tag(s), skipped 3, errors 0.
|
||||||
|
```
|
||||||
|
|
||||||
|
`Skipped` covers Scope-filter rejections + missing-required-field rows; `errors`
|
||||||
|
covers rows whose `Address` failed to parse as a PCCC address.
|
||||||
|
|
||||||
|
## API surface — `IRsLogixImporter` + `AddRsLogixImport`
|
||||||
|
|
||||||
|
For server-side / bootstrap use-cases the importer is also reachable via:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Import;
|
||||||
|
|
||||||
|
var options = new AbLegacyDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbLegacyDeviceOptions("ab://192.168.1.20/1,0")],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Append imported tags onto an existing options object.
|
||||||
|
var updated = options.AddRsLogixImport(
|
||||||
|
path: @"C:\plc\plc-export.csv",
|
||||||
|
deviceHostAddress: "ab://192.168.1.20/1,0",
|
||||||
|
out var result);
|
||||||
|
|
||||||
|
// result.ParsedCount / SkippedCount / ErrorCount surface the import telemetry.
|
||||||
|
Console.WriteLine($"Imported {result.ParsedCount} tags");
|
||||||
|
```
|
||||||
|
|
||||||
|
For a hand-managed importer instance (e.g. supplying a custom `ILogger`) call
|
||||||
|
`new RsLogixSymbolImport(logger).Parse(stream, deviceHostAddress, opts)`
|
||||||
|
directly.
|
||||||
|
|
||||||
|
## Operational notes
|
||||||
|
|
||||||
|
- The importer is **additive** — `AddRsLogixImport` concatenates onto the
|
||||||
|
existing `Tags` list rather than replacing it. Hand-rolled tags (system-status
|
||||||
|
variables, computed fields the operator added by hand) survive a re-import.
|
||||||
|
- Re-imports are not idempotent today — calling `AddRsLogixImport` twice will
|
||||||
|
produce duplicate tag rows. Operators are expected to either start from a
|
||||||
|
clean options object or de-duplicate themselves; a future schema rev may add
|
||||||
|
a `replace=true` switch.
|
||||||
|
- Description metadata is dropped on the floor — see the column reference
|
||||||
|
above. When [#248](https://github.com/dohertj2/lmxopcua/issues/248) lands a
|
||||||
|
`Description` field on `AbLegacyTagDefinition` the importer will start
|
||||||
|
populating it without further changes to the CSV contract.
|
||||||
@@ -36,6 +36,12 @@ supplies a `FakeAbLegacyTag`.
|
|||||||
|
|
||||||
- `AbLegacyAddressTests` — PCCC address parsing for SLC / MicroLogix / PLC-5
|
- `AbLegacyAddressTests` — PCCC address parsing for SLC / MicroLogix / PLC-5
|
||||||
/ LogixPccc-mode (`N7:0`, `F8:12`, `B3:0/5`, etc.)
|
/ LogixPccc-mode (`N7:0`, `F8:12`, `B3:0/5`, etc.)
|
||||||
|
- `AbLegacyArrayTests` — PR 7 array contiguous-block addressing: parser
|
||||||
|
positives + rejects for `,N` / `[N]` suffixes, options-override
|
||||||
|
(`ArrayLength`), driver `IsArray` discovery, and array decoding for N / F /
|
||||||
|
L / B files (Rockwell convention: one BOOL per word for `B3:0,10`). Latency
|
||||||
|
benchmark against the Docker fixture is a perf-flagged integration case in
|
||||||
|
`AbLegacyArrayReadTests` — runs only when ab_server is reachable.
|
||||||
- `AbLegacyCapabilityTests` — data type mapping, read-only enforcement
|
- `AbLegacyCapabilityTests` — data type mapping, read-only enforcement
|
||||||
- `AbLegacyReadWriteTests` — read + write happy + error paths against the fake
|
- `AbLegacyReadWriteTests` — read + write happy + error paths against the fake
|
||||||
- `AbLegacyBitRmwTests` — bit-within-DINT read-modify-write serialization via
|
- `AbLegacyBitRmwTests` — bit-within-DINT read-modify-write serialization via
|
||||||
@@ -43,6 +49,63 @@ supplies a `FakeAbLegacyTag`.
|
|||||||
- `AbLegacyHostAndStatusTests` — probe + host-status transitions driven by
|
- `AbLegacyHostAndStatusTests` — probe + host-status transitions driven by
|
||||||
fake-returned statuses
|
fake-returned statuses
|
||||||
- `AbLegacyDriverTests` — `IDriver` lifecycle
|
- `AbLegacyDriverTests` — `IDriver` lifecycle
|
||||||
|
- `AbLegacyDiagnosticsTests` — PR ablegacy-10 / #253 per-device diagnostic
|
||||||
|
counters: 5 reads (3 ok / 2 fail) → `RequestCount=5`, `ResponseCount=3`,
|
||||||
|
`ErrorCount=2`; `LastErrorCode` reflects the most recent libplctag status;
|
||||||
|
`RetryCount` increments per retry attempt beyond the first; counters reset
|
||||||
|
on `ReinitializeAsync`; discovery emits the canonical diagnostic variables
|
||||||
|
per device under `_Diagnostics/` (now 9 with PR ablegacy-12); collision
|
||||||
|
rejection at `InitializeAsync` for user tags shadowing reserved names or
|
||||||
|
`_Diagnostics/` addresses; the `_Diagnostics/<host>/<name>` short-circuit
|
||||||
|
returns the live snapshot through `ReadAsync` without bumping
|
||||||
|
`RequestCount`; two devices keep counters independent.
|
||||||
|
- `AbLegacyAutoDemoteTests` — **PR ablegacy-12 / #255** auto-demote on comm
|
||||||
|
failure: 3 consecutive failures arm the demote window and surface
|
||||||
|
`HostState.Demoted`; subsequent reads short-circuit with
|
||||||
|
`BadCommunicationError` *without invoking libplctag* (verified via
|
||||||
|
`factory.Tags["N7:0"].ReadCount` not advancing); successful read resets
|
||||||
|
the consecutive-failure counter; failure-success-failure pattern doesn't
|
||||||
|
cross the threshold; `DemoteCount` + `LastDemotedUtc` surface via
|
||||||
|
`_Diagnostics/`; `Enabled=false` opts out (failures still count, demotion
|
||||||
|
never fires); `ReinitializeAsync` clears the active window but preserves
|
||||||
|
cumulative `DemoteCount`; cool-down expiry allows the next read through;
|
||||||
|
two devices in one driver — one faulty, one healthy — proves the faulty
|
||||||
|
side's demotion doesn't starve the healthy side; `BadNodeIdUnknown`
|
||||||
|
(terminal) does not count toward the comm-failure tally; DTO JSON
|
||||||
|
round-trip preserves `FailureThreshold` / `DemoteForMs` / `Enabled` at
|
||||||
|
the per-device level; `HostState.Demoted` enum value is wired through
|
||||||
|
`Core.Abstractions`. Companion integration test in
|
||||||
|
`tests/.../IntegrationTests/AbLegacyAutoDemoteTests.cs` runs the
|
||||||
|
two-device-one-unreachable scenario against a live ab_server fixture
|
||||||
|
using `127.0.0.1:1` as the unreachable peer.
|
||||||
|
- `RsLogixSymbolImportTests` — ablegacy-11 / #254 RSLogix CSV symbol-import parser:
|
||||||
|
canonical 8-row CSV (one row per N/F/B/L/ST/T/C/R) → 8 typed
|
||||||
|
`AbLegacyTagDefinition`s with the right `DataType`; header + comment-line
|
||||||
|
(`;` / `#`) skipping; malformed-row → log warning + skip (`IgnoreInvalid=true`
|
||||||
|
default) vs. `InvalidDataException` (`IgnoreInvalid=false`); empty stream →
|
||||||
|
empty result; UTF-8 BOM survival; embedded comma in quoted Description;
|
||||||
|
doubled-quote escape; `--scope` filter (Global vs. Local:N); `MaxRowsToImport`
|
||||||
|
cap; missing required header column → `InvalidDataException` regardless of
|
||||||
|
`IgnoreInvalid`; `TryResolveDataType` rejects garbage + bit-suffix overrides
|
||||||
|
the file letter (`N7:0/3` → Bit).
|
||||||
|
- `RsLogixSymbolImportGoldenTests` — golden-snapshot integration: loads
|
||||||
|
`Fixtures/rslogix-canonical.csv` (8-row canonical export covering every v1
|
||||||
|
file letter), serialises the resulting tag list, and compares to
|
||||||
|
`Fixtures/rslogix-canonical-expected.json`. On mismatch the actual JSON is
|
||||||
|
dumped to `%TEMP%/rslogix-canonical-actual.json` and the path printed in the
|
||||||
|
failure message so the dev can `cp` the golden after reviewing the diff.
|
||||||
|
- `AbLegacyDriverFactoryAddRsLogixImportTests` — covers the
|
||||||
|
`AbLegacyDriverFactoryExtensions.AddRsLogixImport` extension method:
|
||||||
|
appends imported tags onto an existing options object without dropping the
|
||||||
|
hand-rolled tags or the device list; mutates by-copy (immutability
|
||||||
|
guarantee); `AddRsLogixImportWithResult` tuple overload returns both the
|
||||||
|
modified options and the import counters.
|
||||||
|
- `AbLegacyDeadbandTests` — PR 8 per-tag deadband / change filter:
|
||||||
|
absolute-only suppression sequence `[10.0, 10.5, 11.5, 11.6] -> [10.0, 11.5]`,
|
||||||
|
percent-only suppression with a zero-prev short-circuit, both-set logical-OR
|
||||||
|
semantics (Kepware), Boolean edge-only publish, string change-only publish,
|
||||||
|
status-change always-publish, first-seen always-publish, ReinitializeAsync
|
||||||
|
cache wipe, JSON DTO round-trip.
|
||||||
|
|
||||||
Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
|
Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
|
||||||
`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
|
`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
|
||||||
@@ -69,6 +132,17 @@ driver-side correctness depends on libplctag being correct.
|
|||||||
`IPerCallHostResolver` contract is verified; real PCCC wire routing across
|
`IPerCallHostResolver` contract is verified; real PCCC wire routing across
|
||||||
multiple gateways is not.
|
multiple gateways is not.
|
||||||
|
|
||||||
|
### 3a. DH+ via 1756-DHRIO bridging (PR ablegacy-13 / #256)
|
||||||
|
|
||||||
|
Unit-only — coverage lives in `AbLegacyDhPlusBridgingTests`. The CIP-path
|
||||||
|
parser positive / negative cases (octal-station validation, slot bounds, port
|
||||||
|
shape) and the PLC-5-only family guard at `InitializeAsync` are exercised
|
||||||
|
against fakes. There is no Docker fixture for DH+ because libplctag's
|
||||||
|
`ab_server` doesn't simulate the DHRIO + DH+ + PLC-5 stack — wire-level
|
||||||
|
validation requires real hardware. See
|
||||||
|
[`AbLegacy-DH-Bridging.md`](AbLegacy-DH-Bridging.md) for the manual smoke
|
||||||
|
procedure.
|
||||||
|
|
||||||
### 4. Alarms / history
|
### 4. Alarms / history
|
||||||
|
|
||||||
PCCC has no alarm object + no history object. Driver doesn't implement
|
PCCC has no alarm object + no history object. Driver doesn't implement
|
||||||
@@ -93,11 +167,13 @@ cover the common ones but uncommon ones (`R` counters, `S` status files,
|
|||||||
|
|
||||||
## Follow-up candidates
|
## Follow-up candidates
|
||||||
|
|
||||||
1. **Fix ab_server PCCC coverage upstream** — the scaffold lands the
|
1. **Expand ab_server PCCC coverage** — the smoke suite passes today
|
||||||
Docker infrastructure; the wire-level round-trip gap is in ab_server
|
for N (Int16), F (Float32), and L (Int32) files across SLC500 /
|
||||||
itself. Filing a patch to `libplctag/libplctag` to expand PCCC
|
MicroLogix / PLC-5 modes with the `/1,0` cip-path workaround in
|
||||||
server-side opcode coverage would make the scaffolded smoke tests
|
place. Known residual gap: bit-file writes (`B3:0/5`) surface
|
||||||
pass without a golden-box tier.
|
`0x803D0000`. Contributing a patch to `libplctag/libplctag` to close
|
||||||
|
this + documenting ab_server's empty-path rejection in its README
|
||||||
|
would remove the last Docker-vs-hardware divergences.
|
||||||
2. **Rockwell RSEmulate 500 golden-box tier** — Rockwell's real emulator
|
2. **Rockwell RSEmulate 500 golden-box tier** — Rockwell's real emulator
|
||||||
for SLC/MicroLogix/PLC-5. Would close UDT-equivalent (integer-file
|
for SLC/MicroLogix/PLC-5. Would close UDT-equivalent (integer-file
|
||||||
indirection), timer/counter decomposition, and real ladder execution
|
indirection), timer/counter decomposition, and real ladder execution
|
||||||
@@ -109,17 +185,50 @@ cover the common ones but uncommon ones (`R` counters, `S` status files,
|
|||||||
network; parts are end-of-life but still available. PLC-5 +
|
network; parts are end-of-life but still available. PLC-5 +
|
||||||
LogixPccc-mode behaviour + DF1 serial need specific controllers.
|
LogixPccc-mode behaviour + DF1 serial need specific controllers.
|
||||||
|
|
||||||
|
## Per-device options (`AbLegacyDeviceOptions`)
|
||||||
|
|
||||||
|
Each entry in `AbLegacyDriverOptions.Devices` carries:
|
||||||
|
|
||||||
|
| Field | Type | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `HostAddress` | string | required | `ab://host[:port]/cip-path` |
|
||||||
|
| `PlcFamily` | enum | `Slc500` | Slc500 / MicroLogix / Plc5 / LogixPccc |
|
||||||
|
| `DeviceName` | string | null | Friendly label used in browse + diagnostics |
|
||||||
|
| `Timeout` | TimeSpan? | null → driver-wide default | **PR 9 / #252** — wins over the driver-wide `Timeout`. Mix-and-match: SLC 5/01 ≈ 5 s, SLC 5/05 ≈ 2 s, MicroLogix 1100 ≈ 3 s |
|
||||||
|
| `Retries` | int? | null → driver-wide default → 0 | **PR 9 / #252** — retries on transient `BadCommunicationError`; terminal errors surface on the first attempt |
|
||||||
|
|
||||||
|
JSON shape (mirrored on `AbLegacyDeviceDto`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"HostAddress": "ab://192.168.1.10/1,0",
|
||||||
|
"PlcFamily": "Slc500",
|
||||||
|
"DeviceName": "slc-5-01-line-A",
|
||||||
|
"TimeoutMs": 5000,
|
||||||
|
"Retries": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-device overrides also flow into the probe loop — slow chassis won't be
|
||||||
|
falsely marked Stopped just because the driver-wide probe timeout is tight.
|
||||||
|
|
||||||
## Key fixture / config files
|
## Key fixture / config files
|
||||||
|
|
||||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyServerFixture.cs`
|
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyServerFixture.cs`
|
||||||
— TCP probe + skip attributes + env-var parsing
|
— TCP probe + skip attributes + env-var parsing
|
||||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs`
|
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs`
|
||||||
— three wire-level smoke tests (currently blocked by ab_server PCCC gap)
|
— wire-level smoke tests; pass against the ab_server Docker fixture
|
||||||
|
with `AB_LEGACY_COMPOSE_PROFILE` set to the running container
|
||||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml`
|
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml`
|
||||||
— compose profiles reusing AB CIP Dockerfile
|
— compose profiles reusing AB CIP Dockerfile
|
||||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md`
|
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md`
|
||||||
— known-limitations write-up + resolution paths
|
— known-limitations write-up + resolution paths
|
||||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs` —
|
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs` —
|
||||||
in-process fake + factory
|
in-process fake + factory
|
||||||
|
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/Fixtures/rslogix-canonical.csv`
|
||||||
|
— ablegacy-11 / #254 8-row canonical RSLogix CSV symbol export, one row per
|
||||||
|
v1 file letter (N/F/B/L/ST/T/C/R)
|
||||||
|
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/Fixtures/rslogix-canonical-expected.json`
|
||||||
|
— golden snapshot the import tests compare against
|
||||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs` — scope remarks
|
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs` — scope remarks
|
||||||
at the top of the file
|
at the top of the file
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ quirk. UDT / alarm / quirk behavior is verified only by unit tests with
|
|||||||
- `--plc controllogix` and `--plc compactlogix` mode dispatch.
|
- `--plc controllogix` and `--plc compactlogix` mode dispatch.
|
||||||
- The skip-on-missing-binary behavior (`AbServerFactAttribute`) so a fresh
|
- The skip-on-missing-binary behavior (`AbServerFactAttribute`) so a fresh
|
||||||
clone without the simulator stays green.
|
clone without the simulator stays green.
|
||||||
|
- **Symbolic vs Logical addressing wall-clock** (PR abcip-3.2,
|
||||||
|
`AbCipAddressingModeBenchTests`) — both modes complete + emit timing.
|
||||||
|
**Emulate-tier only**: `ab_server` does not currently honour the CIP Symbol
|
||||||
|
Object class 0x6B `cip_addr` attribute that Logical mode sets, so on the
|
||||||
|
fixture the two modes measure the same wire path. The bench scaffold
|
||||||
|
asserts both complete + records timing for human inspection; the actual
|
||||||
|
Symbolic-vs-Logical perf comparison requires a real ControlLogix /
|
||||||
|
CompactLogix on the network. See
|
||||||
|
[`docs/drivers/AbCip-Performance.md`](AbCip-Performance.md) §"Addressing
|
||||||
|
mode" for the full caveat.
|
||||||
|
|
||||||
## What it does NOT cover
|
## What it does NOT cover
|
||||||
|
|
||||||
@@ -60,6 +70,19 @@ Unit coverage: `AbCipFetchUdtShapeTests`, `CipTemplateObjectDecoderTests`,
|
|||||||
`AbCipDriverWholeUdtReadTests` — all with golden Template-Object byte buffers
|
`AbCipDriverWholeUdtReadTests` — all with golden Template-Object byte buffers
|
||||||
+ offset-keyed `FakeAbCipTag` values.
|
+ offset-keyed `FakeAbCipTag` values.
|
||||||
|
|
||||||
|
PR abcip-3.3 layers a per-device **`ReadStrategy`** selector on top
|
||||||
|
(`WholeUdt` / `MultiPacket` / `Auto`, see
|
||||||
|
[`AbCip-Performance.md`](AbCip-Performance.md) §"Read strategy"). Strategy
|
||||||
|
switching is planner-side: the dispatcher picks between
|
||||||
|
`AbCipUdtReadPlanner` (whole-UDT) and `AbCipMultiPacketReadPlanner`
|
||||||
|
(per-member, bundled per parent) per batch. The selector + per-batch Auto
|
||||||
|
heuristic + family-compat fall-back + per-device dispatch counters are
|
||||||
|
**unit-tested only** in `AbCipReadStrategyTests` — `ab_server` cannot host
|
||||||
|
a 50-member UDT to exercise the sparse case the strategy is designed for,
|
||||||
|
and the libplctag .NET wrapper (1.5.x) does not expose explicit
|
||||||
|
Multi-Service-Packet bundling, so wire-level coverage stays Emulate-tier
|
||||||
|
in `AbCipEmulateMultiPacketReadTests` (gated on `AB_SERVER_PROFILE=emulate`).
|
||||||
|
|
||||||
### 2. ALMD / ALMA alarm projection (#177)
|
### 2. ALMD / ALMA alarm projection (#177)
|
||||||
|
|
||||||
Depends on the ALMD UDT shape, which `ab_server` cannot emulate. The
|
Depends on the ALMD UDT shape, which `ab_server` cannot emulate. The
|
||||||
@@ -96,6 +119,15 @@ value per PR 10, but `ab_server` accepts whatever the client asks for — the
|
|||||||
cap's correctness is trusted from its unit test, never stressed against a
|
cap's correctness is trusted from its unit test, never stressed against a
|
||||||
simulator that rejects oversized requests.
|
simulator that rejects oversized requests.
|
||||||
|
|
||||||
|
PR abcip-3.1 layers the **per-device `ConnectionSize` override** on top
|
||||||
|
(`AbCipDeviceOptions.ConnectionSize`, range `[500..4002]`, see
|
||||||
|
[`AbCip-Performance.md`](AbCip-Performance.md)). Same gap — `ab_server`
|
||||||
|
happily honours an oversized override against the CompactLogix profile, so
|
||||||
|
the legacy-firmware warning + Forward Open rejection that real 5069-L1/L2/L3
|
||||||
|
parts emit are unit-tested only. Live coverage stays Emulate / rig-only
|
||||||
|
(connect against a real CompactLogix L2 with `ConnectionSize=1500` to
|
||||||
|
confirm the Forward Open fails with CIP error 0x01/0x113).
|
||||||
|
|
||||||
### 6. BOOL-within-DINT read-modify-write (#181)
|
### 6. BOOL-within-DINT read-modify-write (#181)
|
||||||
|
|
||||||
The `AbCipDriver.WriteBitInDIntAsync` RMW path + its per-parent `SemaphoreSlim`
|
The `AbCipDriver.WriteBitInDIntAsync` RMW path + its per-parent `SemaphoreSlim`
|
||||||
@@ -107,14 +139,48 @@ the RMW path is not exercised end-to-end.
|
|||||||
|
|
||||||
No smoke test for:
|
No smoke test for:
|
||||||
|
|
||||||
- `IWritable.WriteAsync`
|
- `IWritable.WriteAsync` — atomic write coverage; PR abcip-4.2 added a
|
||||||
|
multi-write *suppression* smoke (jittery 5-write sequence with
|
||||||
|
`WriteDeadband: 1.0` against `ab_server`, asserting the driver's
|
||||||
|
diagnostics counter matches the expected suppression count) but pure
|
||||||
|
atomic-write coverage end-to-end is still unit-only.
|
||||||
- `ITagDiscovery.DiscoverAsync` (`@tags` walker)
|
- `ITagDiscovery.DiscoverAsync` (`@tags` walker)
|
||||||
- `ISubscribable.SubscribeAsync` (poll-group engine)
|
- `ISubscribable.SubscribeAsync` (poll-group engine)
|
||||||
- `IHostConnectivityProbe` state transitions under wire failure
|
- ~~`IHostConnectivityProbe` state transitions under wire failure~~ —
|
||||||
|
covered as of PR abcip-4.3. `AbCipSystemTagDiscoveryTests` connects to
|
||||||
|
`ab_server`, drives the discovery + read path against the synthetic
|
||||||
|
`_System/_ConnectionStatus` variable, and asserts the live snapshot
|
||||||
|
reflects the probe-driven `HostState`. Wire-failure transitions still
|
||||||
|
rely on unit-level `ThrowOnRead` injection rather than a real wire pull,
|
||||||
|
but the end-to-end probe → snapshot → OPC UA address-space link is
|
||||||
|
exercised against `ab_server`.
|
||||||
- `IPerCallHostResolver` multi-device routing
|
- `IPerCallHostResolver` multi-device routing
|
||||||
|
|
||||||
The driver implements all of these + they have unit coverage, but the only
|
The driver implements all of these + they have unit coverage, but the only
|
||||||
end-to-end path `ab_server` validates today is atomic `ReadAsync`.
|
end-to-end paths `ab_server` validates today are atomic `ReadAsync` and
|
||||||
|
write-deadband / write-on-change suppression.
|
||||||
|
|
||||||
|
### 8. ControlLogix HSBY paired-IP role probing (PR abcip-5.1)
|
||||||
|
|
||||||
|
`ab_server` has no second-chassis concept and no `WallClockTime.SyncStatus`
|
||||||
|
tag. The HSBY paired-IP role-prober (PR abcip-5.1) is unit-tested only —
|
||||||
|
`AbCipHsbyTests` drives two fake runtimes (primary + partner), pins each
|
||||||
|
chassis's role-tag value, and asserts the active-resolution rules + DTO
|
||||||
|
round-trip + diagnostics surface.
|
||||||
|
|
||||||
|
The `paired` Docker compose profile spins up two `ab_server` instances +
|
||||||
|
a stub `hsby-mux` sidecar so the topology is documented, but PR 5.2 follow-
|
||||||
|
up needs a patched `ab_server` image (or a Python shim) that actually
|
||||||
|
serves the role tag before the integration test
|
||||||
|
(`AbCipHsbyRoleProberTests`) can flip its `Assert.Skip` into a real wire
|
||||||
|
assertion. Until then the test is gated on `Category=Hsby` + skipped by
|
||||||
|
default.
|
||||||
|
|
||||||
|
Lab-rig coverage is the authoritative path — a real 1756-RM redundant
|
||||||
|
chassis pair is the only place the live `WallClockTime.SyncStatus` matrix
|
||||||
|
+ split-brain handling can be exercised end-to-end. See
|
||||||
|
[`AbCip-HSBY.md`](AbCip-HSBY.md) for the full configuration + role-tag
|
||||||
|
detection matrix.
|
||||||
|
|
||||||
## Logix Emulate golden-box tier
|
## Logix Emulate golden-box tier
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,42 @@ Tier-C pipeline end-to-end without any CNC.
|
|||||||
| "Does `Fwlib32.dll` crash on concurrent reads?" | no | yes (stress) |
|
| "Does `Fwlib32.dll` crash on concurrent reads?" | no | yes (stress) |
|
||||||
| "Do macro variables round-trip across power cycles?" | no | yes (required) |
|
| "Do macro variables round-trip across power cycles?" | no | yes (required) |
|
||||||
|
|
||||||
|
## Alarm history (`cnc_rdalmhistry`) — issue #267, plan PR F3-a
|
||||||
|
|
||||||
|
`FocasAlarmProjection` ships two modes:
|
||||||
|
|
||||||
|
- **`ActiveOnly`** (default) — surfaces only currently-active alarms.
|
||||||
|
No history poll. Same back-compat shape every prior FOCAS deployment used.
|
||||||
|
- **`ActivePlusHistory`** — additionally polls `cnc_rdalmhistry` on connect
|
||||||
|
+ on the configured cadence (`HistoryPollInterval`, default 5 min). Each
|
||||||
|
unseen entry fires an `OnAlarmEvent` with `SourceTimestampUtc` set from
|
||||||
|
the CNC's reported timestamp, not Now.
|
||||||
|
|
||||||
|
Unit-test coverage in `FocasAlarmProjectionTests`:
|
||||||
|
|
||||||
|
- mode `ActiveOnly` — no `ReadAlarmHistoryAsync` call ever issued
|
||||||
|
- mode `ActivePlusHistory` — first poll fires on subscribe (== "on connect")
|
||||||
|
- dedup — same `(OccurrenceTime, AlarmNumber, AlarmType)` triple across two
|
||||||
|
polls only emits once
|
||||||
|
- distinct entries with different timestamps each emit separately
|
||||||
|
- same alarm number / different type still emits both (type is part of the
|
||||||
|
dedup key)
|
||||||
|
- `OccurrenceTime` is the wire timestamp (round-trips a year-old stamp
|
||||||
|
without bleeding into Now)
|
||||||
|
- `HistoryDepth` clamp — user-supplied 500 collapses to 250 on the wire;
|
||||||
|
zero / negative falls back to the 100 default
|
||||||
|
- `FocasAlarmHistoryDecoder` — round-trips through `Encode` / `Decode` and
|
||||||
|
pins the simulator command id at `0x0F1A`
|
||||||
|
|
||||||
|
Future integration coverage (not yet shipped — no FOCAS integration test
|
||||||
|
project exists):
|
||||||
|
|
||||||
|
- a focas-mock with a per-profile ring buffer and `mock_patch_alarmhistory`
|
||||||
|
admin endpoint will let `cnc_rdalmhistry` round-trip end-to-end through
|
||||||
|
the wire protocol
|
||||||
|
- `FocasSimFixture.SeedAlarmHistoryAsync` will let series tests prime canned
|
||||||
|
history without per-test JSON
|
||||||
|
|
||||||
## Follow-up candidates
|
## Follow-up candidates
|
||||||
|
|
||||||
1. **Nothing public** — Fanuc's FOCAS Developer Kit ships an emulator DLL
|
1. **Nothing public** — Fanuc's FOCAS Developer Kit ships an emulator DLL
|
||||||
|
|||||||
281
docs/drivers/FOCAS.md
Normal file
281
docs/drivers/FOCAS.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# FOCAS driver
|
||||||
|
|
||||||
|
Fanuc CNC driver for the FS 0i / 16i / 18i / 21i / 30i / 31i / 32i / 35i /
|
||||||
|
Power Mate i families. Talks to the controller via the licensed
|
||||||
|
`Fwlib32.dll` (Tier C, process-isolated per
|
||||||
|
[`docs/v2/driver-stability.md`](../v2/driver-stability.md)).
|
||||||
|
|
||||||
|
For range-validation and per-series capability surface see
|
||||||
|
[`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md).
|
||||||
|
|
||||||
|
## Fixed-tree `Production/` projection — issue #258 (F1-b) + issue #272 (F5-a)
|
||||||
|
|
||||||
|
Per-device read-only nodes refreshed from the same `cnc_rdparam` /
|
||||||
|
cycle-timer poll the probe loop already runs. No additional wire calls
|
||||||
|
are issued for any of these — they are all cache-or-derive reads.
|
||||||
|
|
||||||
|
| Node | DataType | Source | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `Production/PartsProduced` | `Int32` | `cnc_rdparam(6711)` | Active parts-count counter. Wraps to 0 on operator reset. |
|
||||||
|
| `Production/PartsRequired` | `Int32` | `cnc_rdparam(6712)` | Operator-set target. |
|
||||||
|
| `Production/PartsTotal` | `Int32` | `cnc_rdparam(6713)` | Lifetime parts counter. |
|
||||||
|
| `Production/CycleTimeSeconds` | `Int32` | `cnc_rdtimer` (channel 0) | Live cycle-time accumulator. Resets to 0 on next cycle start (CNC-side behaviour). |
|
||||||
|
| **`Production/LastCycleSeconds`** | **`Float64`** | **derived** | **Plan PR F5-a — seconds for the most recently completed cycle, computed as `CycleTimeSeconds(now) - CycleTimeSeconds(at previous parts-count increment)`. `null` until the second observed parts-count increment establishes a delta. Pure derivation, no new wire calls. See edge-case rules below.** |
|
||||||
|
| **`Production/LastCycleStartUtc`** | **`DateTime`** *(UTC)* | **derived** | **Plan PR F5-a — UTC wall-clock of the most-recent cycle's start, computed as `nowUtc - LastCycleSeconds`. `null` alongside `LastCycleSeconds` until the second observed increment.** |
|
||||||
|
|
||||||
|
### F5-a derivation edge-case rules
|
||||||
|
|
||||||
|
- **First observation** establishes the baseline; `LastCycleSeconds` /
|
||||||
|
`LastCycleStartUtc` stay `null` until the second observed parts-count
|
||||||
|
increment produces the first delta.
|
||||||
|
- **Parts-count counter reset** (current value goes backwards, e.g.
|
||||||
|
shift-change zero) **preserves the last published values** so an
|
||||||
|
operator reading the tag mid-shift-change sees the last known cycle
|
||||||
|
duration rather than `null` / Bad. The next positive transition
|
||||||
|
produces a fresh delta from the new baseline.
|
||||||
|
- **Cycle-timer rollover** (delta would be negative — e.g. CNC zeroes
|
||||||
|
the cycle timer at part completion) **leaves the previously-published
|
||||||
|
values unchanged for one tick** and re-baselines so the next
|
||||||
|
increment produces a clean delta. The driver does NOT publish a
|
||||||
|
negative `LastCycleSeconds`.
|
||||||
|
- **Parts-count jumps `> 1`** (backfill — e.g. counter increments by
|
||||||
|
3 at once) publish the **timer delta over the window** as
|
||||||
|
`LastCycleSeconds`. The plan's "delta over the window between
|
||||||
|
successive parts-count increments" definition does not divide by the
|
||||||
|
count delta; the value reflects the actual elapsed timer between the
|
||||||
|
two observations.
|
||||||
|
- **Reconnect / reinit** clears the derivation state — the prior CNC
|
||||||
|
session's cycle-timer + parts-count snapshots may be invalidated by
|
||||||
|
the FWLIB session boundary, so the next post-reconnect probe tick
|
||||||
|
re-establishes the baseline before the next delta publishes.
|
||||||
|
|
||||||
|
## Alarm history (`cnc_rdalmhistry`) — issue #267, plan PR F3-a
|
||||||
|
|
||||||
|
`FocasAlarmProjection` exposes two modes via `FocasDriverOptions.AlarmProjection`:
|
||||||
|
|
||||||
|
| Mode | Behaviour |
|
||||||
|
| --- | --- |
|
||||||
|
| `ActiveOnly` *(default)* | Subscribe / unsubscribe / acknowledge wire up so capability negotiation works, but no history poll runs. Back-compat with every pre-F3-a deployment. |
|
||||||
|
| `ActivePlusHistory` | On subscribe (== "on connect") and on every `HistoryPollInterval` tick, the projection issues `cnc_rdalmhistry` for the most recent `HistoryDepth` entries. Each previously-unseen entry fires an `OnAlarmEvent` with `SourceTimestampUtc` set from the CNC's reported timestamp — OPC UA dashboards see the real occurrence time, not the moment the projection polled. |
|
||||||
|
|
||||||
|
### Config knobs
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"AlarmProjection": {
|
||||||
|
"Mode": "ActivePlusHistory", // "ActiveOnly" (default) | "ActivePlusHistory"
|
||||||
|
"HistoryPollInterval": "00:05:00", // default 5 min
|
||||||
|
"HistoryDepth": 100 // default 100, capped at 250
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dedup key
|
||||||
|
|
||||||
|
`(OccurrenceTime, AlarmNumber, AlarmType)`. The same triple across two
|
||||||
|
polls only emits once. The dedup set is in-memory and **resets on
|
||||||
|
reconnect** — first poll after reconnect re-emits everything in the ring
|
||||||
|
buffer. OPC UA clients that need exactly-once semantics dedupe client-side
|
||||||
|
on the same triple (the timestamp + type + number tuple is stable across
|
||||||
|
the boundary).
|
||||||
|
|
||||||
|
### `HistoryDepth` cap
|
||||||
|
|
||||||
|
Capped at `FocasAlarmProjectionOptions.MaxHistoryDepth = 250` so an
|
||||||
|
operator who types `10000` by accident can't blast the wire session with a
|
||||||
|
giant request. Typical FANUC ring buffers cap at ~100 entries; the default
|
||||||
|
`HistoryDepth = 100` matches the most common ring-buffer size.
|
||||||
|
|
||||||
|
### Wire surface
|
||||||
|
|
||||||
|
- Wire-protocol command id: `0x0F1A` (see
|
||||||
|
[`docs/v2/implementation/focas-wire-protocol.md`](../v2/implementation/focas-wire-protocol.md)).
|
||||||
|
- ODBALMHIS struct decoder: `Wire/FocasAlarmHistoryDecoder.cs`.
|
||||||
|
- Tier-C Fwlib32 backend short-circuits the packed-buffer decoder by
|
||||||
|
surfacing the FWLIB struct fields directly into
|
||||||
|
`FocasAlarmHistoryEntry`.
|
||||||
|
|
||||||
|
## Writes (opt-in, off by default) — issue #268 (F4-a) + #269 (F4-b) + #270 (F4-c)
|
||||||
|
|
||||||
|
Writes ship behind multiple independent opt-ins. All default off so a freshly
|
||||||
|
deployed FOCAS driver is read-only until the deployment makes a deliberate
|
||||||
|
choice. Decision record: [`docs/v2/decisions.md`](../v2/decisions.md) →
|
||||||
|
"FOCAS write-path opt-in".
|
||||||
|
|
||||||
|
| Knob | Default | Effect when off |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `FocasDriverOptions.Writes.Enabled` *(driver-level master switch)* | `false` | Every entry in a `WriteAsync` batch short-circuits to `BadNotWritable` with status text `writes disabled at driver level`. Wire client never gets touched. |
|
||||||
|
| **`FocasDriverOptions.Writes.AllowParameter`** *(F4-b granular kill switch)* | **`false`** | **`PARAM:` writes return `BadNotWritable` with no wire client constructed. Defense in depth — even if `Enabled = true` an operator must explicitly opt into parameter writes per kind because a misdirected `cnc_wrparam` can put the CNC in a bad state.** |
|
||||||
|
| **`FocasDriverOptions.Writes.AllowMacro`** *(F4-b granular kill switch)* | **`false`** | **`MACRO:` writes return `BadNotWritable` with no wire client constructed. Macro writes are the normal HMI-driven recipe / setpoint surface; gating them separately from `AllowParameter` lets a deployment open MACRO without exposing the heavier PARAM write surface.** |
|
||||||
|
| **`FocasDriverOptions.Writes.AllowPmc`** *(F4-c granular kill switch)* | **`false`** | **PMC writes (R/G/F/D/X/Y/K/A/E/T/C letters, both Bit and Byte) return `BadNotWritable` with no wire client constructed. PMC is ladder working memory — a mistargeted bit can move motion, latch a feedhold, or flip a safety interlock, so PMC writes are gated separately from PARAM/MACRO so an operator team can open PARAM (commissioning) without exposing the much higher-blast-radius PMC surface.** |
|
||||||
|
| `FocasTagDefinition.Writable` *(per-tag opt-in)* | `false` | The per-tag check returns `BadNotWritable` for that tag even when the driver-level flags are on. |
|
||||||
|
|
||||||
|
> **PMC SAFETY CALLOUT** — PMC is the FANUC ladder's working memory. A
|
||||||
|
> mistargeted bit can move motion (a Y-coil writing to a servo enable),
|
||||||
|
> latch a feedhold (an internal R-relay the ladder ANDs with cycle-start),
|
||||||
|
> or flip a safety interlock (an X-input shadow). **Treat PMC writes the
|
||||||
|
> same way you'd treat editing a live ladder:** verify e-stop is live and
|
||||||
|
> the machine is in jog mode before issuing the first write of a session.
|
||||||
|
> The driver gates these writes behind THREE independent opt-ins
|
||||||
|
> (`Writes.Enabled` + `Writes.AllowPmc` + per-tag `Writable`) precisely
|
||||||
|
> because the blast radius is higher than parameter writes.
|
||||||
|
|
||||||
|
### PMC bit-write read-modify-write semantics — F4-c
|
||||||
|
|
||||||
|
The FOCAS wire call `pmc_wrpmcrng` is **byte-addressed** — there is no
|
||||||
|
sub-byte write primitive. When the driver receives a write request on a
|
||||||
|
`Bit` tag (e.g. `R100.3`), it:
|
||||||
|
|
||||||
|
1. Reads the parent byte via `pmc_rdpmcrng` (1 byte at `R100`).
|
||||||
|
2. Masks the target bit (set: `current | (1 << bit)`; clear: `current & ~(1 << bit)`).
|
||||||
|
3. Writes the modified byte back via `pmc_wrpmcrng` (1 byte at `R100`).
|
||||||
|
|
||||||
|
A **per-byte semaphore** serialises concurrent bit writes against the same
|
||||||
|
byte so two updates that race never lose one another's bit. RMW means **a
|
||||||
|
PMC bit write reads first, then writes back the whole byte** — if the ladder
|
||||||
|
is also writing to that byte at the same instant, there is a small window
|
||||||
|
where the driver's value can clobber the ladder's. Operators who care about
|
||||||
|
this race must coordinate the write through a ladder-side handshake (e.g.
|
||||||
|
the operator sets a request bit, the ladder reads + clears it).
|
||||||
|
|
||||||
|
### Config shape — F4-c
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"Writes": {
|
||||||
|
"Enabled": true,
|
||||||
|
"AllowParameter": true, // F4-b — opt into cnc_wrparam
|
||||||
|
"AllowMacro": true, // F4-b — opt into cnc_wrmacro
|
||||||
|
"AllowPmc": true // F4-c — opt into pmc_wrpmcrng (incl. RMW bit writes)
|
||||||
|
},
|
||||||
|
"Tags": [
|
||||||
|
{ "Name": "RPM", "Address": "PARAM:1815", "DataType": "Int32",
|
||||||
|
"Writable": true, "WriteIdempotent": false },
|
||||||
|
{ "Name": "Recipe", "Address": "MACRO:500", "DataType": "Int32",
|
||||||
|
"Writable": true, "WriteIdempotent": false },
|
||||||
|
{ "Name": "StartFlag", "Address": "R100.3", "DataType": "Bit",
|
||||||
|
"Writable": true, "WriteIdempotent": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server-layer ACL (LDAP groups)
|
||||||
|
|
||||||
|
Per the [`docs/v2/acl-design.md`](../v2/acl-design.md) tier model, the FOCAS
|
||||||
|
driver only declares per-tag `SecurityClassification`; `DriverNodeManager`
|
||||||
|
applies the gate. The classification post-F4-b is:
|
||||||
|
|
||||||
|
| Tag kind | Classification | LDAP group required (default mapping) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PARAM:N` writable | `Configure` | **`WriteConfigure`** |
|
||||||
|
| `MACRO:N` writable | `Operate` | `WriteOperate` |
|
||||||
|
| Other writable (PMC R/G/F/...) | `Operate` | `WriteOperate` |
|
||||||
|
| Non-writable | `ViewOnly` | (no write permission) |
|
||||||
|
|
||||||
|
Parameter writes need the heavier `WriteConfigure` group because they're
|
||||||
|
mostly emergency commissioning territory; macro writes use `WriteOperate`
|
||||||
|
because they're the normal HMI recipe surface. The driver-level
|
||||||
|
`AllowParameter` / `AllowMacro` kill switches sit independently of ACL — an
|
||||||
|
operator-team kill switch the deployment can flip without redeploying ACL
|
||||||
|
group memberships. See [`docs/security.md`](../security.md) for the full
|
||||||
|
group/permission map.
|
||||||
|
|
||||||
|
`WriteIdempotent` is plumbed through Polly retry by the server-layer
|
||||||
|
`CapabilityInvoker.ExecuteWriteAsync`. When `false` (default), failed writes
|
||||||
|
are NOT auto-retried per plan decisions #44/#45 — a timeout that fires after
|
||||||
|
the CNC already accepted the write would otherwise risk a duplicate
|
||||||
|
non-idempotent action (alarm acks, M-code pulses, recipe steps). Flip
|
||||||
|
`WriteIdempotent` on per tag for genuinely-idempotent writes (a parameter
|
||||||
|
value that the operator simply wants forced to a target).
|
||||||
|
|
||||||
|
### FOCAS password — issue #271 (F4-d)
|
||||||
|
|
||||||
|
Some controllers — notably 16i and certain 30i firmwares with the
|
||||||
|
parameter-protect switch on — gate `cnc_wrparam` and a handful of reads
|
||||||
|
behind a connection-level password. Without unlocking the session, every
|
||||||
|
gated wire call returns `EW_PASSWD`, which the F4-b mapping surfaces as
|
||||||
|
`BadUserAccessDenied`.
|
||||||
|
|
||||||
|
`FocasDeviceOptions.Password` plumbs the password through the device config:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"Devices": [
|
||||||
|
{
|
||||||
|
"HostAddress": "focas://10.0.0.5:8193",
|
||||||
|
"Password": "1234" // F4-d — optional CNC password
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When set, the driver:
|
||||||
|
|
||||||
|
1. **On connect**, calls `IFocasClient.UnlockAsync(password, ct)` after
|
||||||
|
the FWLIB handle opens but before any read/write fires. The FWLIB-backed
|
||||||
|
client emits `cnc_wrunlockparam` with the password ASCII-encoded into
|
||||||
|
the 4-byte FOCAS password slot (right-padded with `0x00`, truncated at
|
||||||
|
4 bytes — that's the shape the public Fanuc samples document).
|
||||||
|
2. **On `BadUserAccessDenied` from any gated read or write**, re-issues
|
||||||
|
`UnlockAsync` and retries the call **exactly once**. A second
|
||||||
|
`EW_PASSWD` propagates unchanged so a wrong password doesn't loop
|
||||||
|
forever on the wire.
|
||||||
|
3. **Reset on reconnect** — FWLIB unlock state lives on the handle, so
|
||||||
|
any reconnect path (planned or unplanned) re-runs unlock automatically
|
||||||
|
via `EnsureConnectedAsync`.
|
||||||
|
|
||||||
|
**No-log invariant.** The password is a secret. The driver MUST NOT log
|
||||||
|
it. Specifically:
|
||||||
|
|
||||||
|
- `FocasDeviceOptions` overrides the record's auto-generated `ToString`
|
||||||
|
to print `Password = ***` when the field is non-null. Any Serilog
|
||||||
|
destructure that flows the device options through `{Device}` gets the
|
||||||
|
redaction for free.
|
||||||
|
- `FwlibFocasClient.UnlockAsync` does not include the password in any
|
||||||
|
exception message — only the FWLIB return code (`EW_PASSWD`,
|
||||||
|
`EW_HANDLE`, etc.) makes it into the surface.
|
||||||
|
- `FocasDriver` logs only `"FOCAS unlock applied for {host}"` when the
|
||||||
|
unlock succeeds — no password.
|
||||||
|
- The Driver.FOCAS.Cli `--cnc-password` flag is also redacted at the
|
||||||
|
same `FocasDeviceOptions` choke point.
|
||||||
|
- See [`docs/v2/focas-deployment.md`](../v2/focas-deployment.md)
|
||||||
|
§ "FOCAS password handling" for the storage/rotation runbook + the
|
||||||
|
cross-link to [`docs/Security.md`](../Security.md).
|
||||||
|
|
||||||
|
When the controller does **not** need a password, leave `Password`
|
||||||
|
unset (`null`) and the driver short-circuits the unlock call entirely —
|
||||||
|
no wire-level cost.
|
||||||
|
|
||||||
|
### Status-code semantics post-F4-b
|
||||||
|
|
||||||
|
- `BadNotWritable` — one of: driver-level `Writes.Enabled = false`; per-tag
|
||||||
|
`Writable = false`; **`Writes.AllowParameter = false` for a `PARAM:` tag
|
||||||
|
(F4-b)**; **`Writes.AllowMacro = false` for a `MACRO:` tag (F4-b)**;
|
||||||
|
**`Writes.AllowPmc = false` for a PMC tag (F4-c)**. Same status code,
|
||||||
|
five distinct paths — operators distinguish by checking the knobs.
|
||||||
|
- `BadUserAccessDenied` — **F4-b** — the CNC reported `EW_PASSWD`
|
||||||
|
(parameter-write switch off / unlock required). **F4-d** wires the
|
||||||
|
`cnc_wrunlockparam` retry path on top: when `Password` is configured
|
||||||
|
the driver re-issues unlock + retries the gated call once before
|
||||||
|
surfacing this status. A persistent `BadUserAccessDenied` after F4-d
|
||||||
|
means either (a) the password doesn't match the controller, or (b)
|
||||||
|
the parameter-write switch on the pendant is still off and the
|
||||||
|
controller wants both the switch + the password.
|
||||||
|
- `BadNotSupported` — both opt-ins flipped on, but the wire client doesn't
|
||||||
|
implement the kind being written (e.g. older transport variant). F4-a
|
||||||
|
wired the generic dispatch; F4-b adds typed `WriteParameterAsync` /
|
||||||
|
`WriteMacroAsync` entry points whose default impls return
|
||||||
|
`BadNotSupported` so transports compiled against a stale `IFocasClient`
|
||||||
|
surface still build.
|
||||||
|
- `BadNodeIdUnknown` — full-reference doesn't match any configured
|
||||||
|
`FocasTagDefinition.Name`.
|
||||||
|
- `BadCommunicationError` — wire failure (DLL not loaded, IPC peer dead,
|
||||||
|
etc.).
|
||||||
|
|
||||||
|
### CLI bypass
|
||||||
|
|
||||||
|
`otopcua-focas-cli write` ([`docs/Driver.FOCAS.Cli.md`](../Driver.FOCAS.Cli.md))
|
||||||
|
sets `Writes.Enabled=true` locally for the lifetime of one invocation
|
||||||
|
because the CLI is a per-operator tool — not a long-lived process bound to
|
||||||
|
the central config DB. The server-side flag is untouched; configure-the-
|
||||||
|
server code paths remain safer-by-default.
|
||||||
@@ -47,6 +47,13 @@ the tests mock.
|
|||||||
- `OpcUaClientSmokeTests.Client_subscribe_receives_StepUp_data_changes_from_live_server` —
|
- `OpcUaClientSmokeTests.Client_subscribe_receives_StepUp_data_changes_from_live_server` —
|
||||||
real `MonitoredItem` subscription against `ns=3;s=FastUInt1` (ticks every
|
real `MonitoredItem` subscription against `ns=3;s=FastUInt1` (ticks every
|
||||||
100 ms); asserts `OnDataChange` fires within 3 s of subscribe
|
100 ms); asserts `OnDataChange` fires within 3 s of subscribe
|
||||||
|
- `OpcUaClientReverseConnectSmokeTests.Driver_accepts_reverse_connect_from_opc_plc_rc_simulator` —
|
||||||
|
reverse-connect (server-initiated) coverage. Driver binds
|
||||||
|
`opc.tcp://0.0.0.0:4844`, the `opc-plc-rc` docker service dials in via
|
||||||
|
`--rc opc.tcp://host.docker.internal:4844`, and a Read round-trips over
|
||||||
|
the inbound socket. Gated on `OPCUA_RC_SIM=1` because the simulator
|
||||||
|
requires `host.docker.internal` resolution which not every CI runner
|
||||||
|
exposes.
|
||||||
|
|
||||||
Wire-level surfaces verified: `IDriver` + `IReadable` + `ISubscribable` +
|
Wire-level surfaces verified: `IDriver` + `IReadable` + `ISubscribable` +
|
||||||
`IHostConnectivityProbe` (via the Secure Channel exchange).
|
`IHostConnectivityProbe` (via the Secure Channel exchange).
|
||||||
@@ -159,6 +166,35 @@ Beyond that:
|
|||||||
3. **Dedicated historian integration lab** — only path for
|
3. **Dedicated historian integration lab** — only path for
|
||||||
historian-specific coverage.
|
historian-specific coverage.
|
||||||
|
|
||||||
|
## HistoryRead aggregate coverage
|
||||||
|
|
||||||
|
PR-13 (issue #285) extended `HistoryAggregateType` from 5 to ~30 values
|
||||||
|
matching the OPC UA Part 13 §5 catalog. The mapping itself
|
||||||
|
(`OpcUaClientDriver.MapAggregateToNodeId`) is unit-tested via
|
||||||
|
`OpcUaClientAggregateMappingTests`:
|
||||||
|
|
||||||
|
- The full enum is swept with `Enum.GetValues<HistoryAggregateType>()` —
|
||||||
|
every value must resolve to a non-null namespace-0 numeric `NodeId`.
|
||||||
|
- The 25 new aggregates each assert against a reflection-resolved
|
||||||
|
`Opc.Ua.ObjectIds.AggregateFunction_*` field by name, so a future SDK
|
||||||
|
upgrade that renames a constant trips the test loudly.
|
||||||
|
- The original 5 ordinals stay pinned to their pre-PR-13 NodeIds so existing
|
||||||
|
config files / persisted enums keep working.
|
||||||
|
|
||||||
|
This is **the well-known-NodeId test path** — the standard Part 13 NodeIds
|
||||||
|
are stable across SDK versions; round-tripping each one against a live
|
||||||
|
upstream is the integration suite's job and doesn't add coverage to the
|
||||||
|
mapping table itself.
|
||||||
|
|
||||||
|
`OpcUaClientAggregateSweepTests` is the integration counterpart. It loops
|
||||||
|
every enum value against a real opc-plc upstream and asserts the wire path
|
||||||
|
doesn't crash even when the simulator returns
|
||||||
|
`BadAggregateNotSupported` for an aggregate it doesn't honour. opc-plc's
|
||||||
|
default profile doesn't enable HistoryRead on the well-known nodes, so the
|
||||||
|
test currently `Assert.Skip`s — re-enables when the fixture image is
|
||||||
|
upgraded to a history-sim profile (`--useslowtypes --ut=10` or similar) and
|
||||||
|
a known-good historized NodeId is wired into `OpcPlcProfile`.
|
||||||
|
|
||||||
## Key fixture / config files
|
## Key fixture / config files
|
||||||
|
|
||||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` — unit tests with
|
- `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` — unit tests with
|
||||||
@@ -168,3 +204,7 @@ Beyond that:
|
|||||||
- `tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs` —
|
- `tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs` —
|
||||||
the server-side integration harness a future loopback client test could
|
the server-side integration harness a future loopback client test could
|
||||||
piggyback on
|
piggyback on
|
||||||
|
- `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientAggregateMappingTests.cs`
|
||||||
|
— Part 13 aggregate enum-to-NodeId mapping coverage (PR-13)
|
||||||
|
- `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientAggregateSweepTests.cs`
|
||||||
|
— wire-side aggregate sweep against opc-plc (build-only scaffold; PR-13)
|
||||||
|
|||||||
350
docs/drivers/OpcUaClient.md
Normal file
350
docs/drivers/OpcUaClient.md
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
# OPC UA Client driver
|
||||||
|
|
||||||
|
Tier-A in-process driver that opens a `Session` against a remote OPC UA server
|
||||||
|
and re-exposes its address space through the local OtOpcUa server. The
|
||||||
|
"gateway / aggregation" direction — opposite to the usual "server exposes PLC
|
||||||
|
data" flow.
|
||||||
|
|
||||||
|
For the test fixture (opc-plc) see [`OpcUaClient-Test-Fixture.md`](OpcUaClient-Test-Fixture.md).
|
||||||
|
For the configuration surface see `OpcUaClientDriverOptions` in
|
||||||
|
[`src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs`](../../src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs).
|
||||||
|
|
||||||
|
## Auto re-import on `ModelChangeEvent`
|
||||||
|
|
||||||
|
The driver subscribes to `BaseModelChangeEventType` (and its subtype
|
||||||
|
`GeneralModelChangeEventType`) on the upstream `Server` node (`i=2253`) at
|
||||||
|
the end of `InitializeAsync`. When the upstream server advertises a
|
||||||
|
topology change, the driver coalesces events over a debounce window and
|
||||||
|
runs a single re-import (equivalent to calling `ReinitializeAsync` —
|
||||||
|
internally `ShutdownAsync` + `InitializeAsync`).
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
| Option | Default | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `WatchModelChanges` | `true` | Disable to skip the watch entirely (no extra subscription, no re-import on topology change). |
|
||||||
|
| `ModelChangeDebounce` | `5s` | Coalescing window. The first event starts the timer; further events extend it; when it elapses with no new events, the driver fires one re-import. |
|
||||||
|
|
||||||
|
### Behaviour
|
||||||
|
|
||||||
|
- One model-change subscription per driver instance, separate from the
|
||||||
|
data + alarm subscriptions. Created best-effort: a server that doesn't
|
||||||
|
advertise the event types or rejects the `EventFilter` falls through to
|
||||||
|
no-watch — `InitializeAsync` still succeeds.
|
||||||
|
- The `EventFilter` selects only the `EventType` field (a `WhereClause`
|
||||||
|
constrains by `OfType BaseModelChangeEventType`). Payload fields like
|
||||||
|
`Changes[]` are intentionally ignored: the driver always re-imports the
|
||||||
|
full upstream root, so per-event delta tracking would just add wire
|
||||||
|
overhead.
|
||||||
|
- Debounce is implemented via a single-shot `Timer`; every event calls
|
||||||
|
`Timer.Change(window, Infinite)` so a burst of N events triggers exactly
|
||||||
|
one re-import after the window elapses with no further events.
|
||||||
|
- The re-import path acquires the same `_gate` semaphore that `ReadAsync`
|
||||||
|
/ `WriteAsync` / `BrowseAsync` / `SubscribeAsync` use. Downstream callers
|
||||||
|
see a brief browse-gap (≈ the upstream `DiscoverAsync` duration) while
|
||||||
|
the gate is held — but no torn reads or split-batch writes.
|
||||||
|
- Failure during the re-import is best-effort: the next `ModelChangeEvent`
|
||||||
|
triggers another attempt, and the keep-alive watchdog covers permanent
|
||||||
|
upstream loss. Operators see failures through `DriverHealth.LastError`
|
||||||
|
+ the diagnostics counters.
|
||||||
|
|
||||||
|
### When to disable
|
||||||
|
|
||||||
|
Flip `WatchModelChanges` to `false` when:
|
||||||
|
|
||||||
|
- The upstream topology is known-static (e.g. firmware-pinned PLC) and
|
||||||
|
the driver should never run a re-import unprompted.
|
||||||
|
- The brief browse-gap during re-import is unacceptable and a manual
|
||||||
|
`ReinitializeAsync` call from the operator is preferred.
|
||||||
|
- The upstream server fires spurious `ModelChangeEvent`s that don't
|
||||||
|
reflect real topology changes, causing wasted re-imports. Tighten or
|
||||||
|
disable rather than chasing the noise downstream.
|
||||||
|
|
||||||
|
## Reverse Connect (server-initiated)
|
||||||
|
|
||||||
|
OPC UA's reverse-connect mode flips the transport direction: instead of the
|
||||||
|
client dialling the server, the **server** dials the client's listener. The
|
||||||
|
upstream sends a `ReverseHello` and the client continues the OPC UA
|
||||||
|
handshake on the inbound socket. Required for OT-DMZ deployments where the
|
||||||
|
plant firewall only permits outbound traffic from the upstream — the
|
||||||
|
gateway opens a listener, the upstream reaches out.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
| Option | Default | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ReverseConnect.Enabled` | `false` | Opt-in. When `true`, replaces the failover dial-sweep with a `WaitForConnection` call. |
|
||||||
|
| `ReverseConnect.ListenerUrl` | `null` | Local listener URL the SDK binds. Typically `opc.tcp://0.0.0.0:4844` (any interface) or a specific NIC for multi-homed gateways. **Required when `Enabled` is `true`.** |
|
||||||
|
| `ReverseConnect.ExpectedServerUri` | `null` | Upstream's `ApplicationUri` to filter inbound dials. `null` accepts the first connection (only safe with one upstream targeting the listener). |
|
||||||
|
|
||||||
|
### Shared listener (singleton)
|
||||||
|
|
||||||
|
A single underlying `Opc.Ua.Client.ReverseConnectManager` per process keyed
|
||||||
|
on `ListenerUrl`. Two driver instances that share a listener URL multiplex
|
||||||
|
onto one TCP socket; the SDK demuxes inbound dials by the upstream's
|
||||||
|
reported `ServerUri`. The wrapper (`ReverseConnectListener`) is
|
||||||
|
reference-counted — first `Acquire` binds the port, last `Release` tears it
|
||||||
|
down. Letting drivers come and go independently without races on
|
||||||
|
port-bind / port-unbind.
|
||||||
|
|
||||||
|
When two drivers share a listener:
|
||||||
|
|
||||||
|
- They MUST set `ExpectedServerUri` to disambiguate; otherwise the first
|
||||||
|
upstream to dial in wins regardless of which driver is waiting.
|
||||||
|
- They CAN come and go independently; the listener stays alive while at
|
||||||
|
least one driver references it.
|
||||||
|
|
||||||
|
### Behaviour
|
||||||
|
|
||||||
|
- The dial path is bypassed entirely when `Enabled` is `true`. Failover
|
||||||
|
across multiple `EndpointUrls` doesn't apply — there's no client-side
|
||||||
|
dial to fail over.
|
||||||
|
- `ExpectedServerUri` is the SDK's filter parameter to `WaitForConnectionAsync`.
|
||||||
|
Inbound `ReverseHello`s from a different upstream are ignored and the
|
||||||
|
caller keeps waiting.
|
||||||
|
- The same `EndpointDescription` derivation runs as the dial path — the
|
||||||
|
first `EndpointUrl` in the candidate list seeds `SecurityPolicy` /
|
||||||
|
`SecurityMode` / `EndpointUrl` for the session-create call. The actual
|
||||||
|
endpoint lives on the upstream and the SDK reconciles after the
|
||||||
|
`ReverseHello`.
|
||||||
|
- Cancellation: `Timeout` bounds the wait. A stuck listener with no inbound
|
||||||
|
dial throws after `Timeout` rather than hanging init forever.
|
||||||
|
- Shutdown releases the listener reference. The last release stops the
|
||||||
|
listener so the port can be re-bound by a future driver lifecycle.
|
||||||
|
|
||||||
|
### Wiring it up on the upstream
|
||||||
|
|
||||||
|
The upstream OPC UA server has to be configured to dial out. The `opc-plc`
|
||||||
|
simulator does this with `--rc=opc.tcp://<gateway-host>:4844`; for a real
|
||||||
|
upstream see your server's reverse-connect docs (most major implementations
|
||||||
|
expose a "ReverseConnect.Endpoint" config knob).
|
||||||
|
|
||||||
|
### When NOT to use
|
||||||
|
|
||||||
|
- Standard plant networks where the gateway can dial the upstream — the
|
||||||
|
conventional dial path is simpler and supports failover natively.
|
||||||
|
- Public-internet OPC UA: reverse-connect is a network-policy workaround,
|
||||||
|
not a security primitive. Always pair with `Sign` or `SignAndEncrypt`
|
||||||
|
+ a vetted user-token policy.
|
||||||
|
|
||||||
|
## HistoryRead Events
|
||||||
|
|
||||||
|
The driver passes through OPC UA `HistoryReadEvents` to the upstream server.
|
||||||
|
HistoryRead Raw / Processed / AtTime ship in the same code path
|
||||||
|
(`ExecuteHistoryReadAsync`); event history takes a slightly different shape
|
||||||
|
because the client sends an `EventFilter` (SelectClauses + WhereClause) rather
|
||||||
|
than a plain numeric / time-based detail block.
|
||||||
|
|
||||||
|
### Wire path
|
||||||
|
|
||||||
|
`IHistoryProvider.ReadEventsAsync(fullReference, EventHistoryRequest, ct)`
|
||||||
|
translates to:
|
||||||
|
|
||||||
|
```
|
||||||
|
new ReadEventDetails {
|
||||||
|
StartTime,
|
||||||
|
EndTime,
|
||||||
|
NumValuesPerNode,
|
||||||
|
Filter = EventFilter { SelectClauses, WhereClause }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
…and is sent through `Session.HistoryReadAsync` to the upstream server. The
|
||||||
|
returned `HistoryEvent.Events` collection (one `HistoryEventFieldList` per
|
||||||
|
historical event) is unwrapped into `HistoricalEventBatch.Events`, where each
|
||||||
|
`HistoricalEventRow.Fields` dictionary is keyed by the
|
||||||
|
`SimpleAttributeSpec.FieldName` the caller supplied. The server-side history
|
||||||
|
dispatcher uses those keys to align fields with the wire-side SelectClause
|
||||||
|
order — drivers don't have to honour the entire OPC UA `EventFilter` shape
|
||||||
|
verbatim.
|
||||||
|
|
||||||
|
### SelectClauses
|
||||||
|
|
||||||
|
When `EventHistoryRequest.SelectClauses` is `null` the driver falls back to a
|
||||||
|
default set that matches `BuildHistoryEvent` on the server side:
|
||||||
|
|
||||||
|
| Field | Browse path | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `EventId` | `EventId` | BaseEventType — stable unique id. |
|
||||||
|
| `SourceName` | `SourceName` | Source-object name. |
|
||||||
|
| `Time` | `Time` | Process-side event timestamp. Used for `OccurrenceTime`. |
|
||||||
|
| `Message` | `Message` | LocalizedText payload. |
|
||||||
|
| `Severity` | `Severity` | OPC UA 1-1000 scale. |
|
||||||
|
| `ReceiveTime` | `ReceiveTime` | Server-side ingest timestamp. |
|
||||||
|
|
||||||
|
Custom SelectClauses are supported — pass any
|
||||||
|
`IReadOnlyList<SimpleAttributeSpec>`. Each entry's `TypeDefinitionId`
|
||||||
|
defaults to `BaseEventType` when `null`; pass an explicit NodeId text (e.g.
|
||||||
|
`"i=2782"` for `ConditionType`) to reach typed-condition fields.
|
||||||
|
|
||||||
|
### WhereClause
|
||||||
|
|
||||||
|
`ContentFilterSpec.EncodedOperands` carries the binary-encoded
|
||||||
|
`ContentFilter` from the wire. The driver decodes it into the SDK
|
||||||
|
`ContentFilter` and attaches it to the outgoing `EventFilter` verbatim — the
|
||||||
|
OPC UA Client driver is a passthrough for filter semantics, it does not
|
||||||
|
evaluate them. A malformed filter is dropped silently; the SelectClause
|
||||||
|
projection still goes out.
|
||||||
|
|
||||||
|
### Continuation points
|
||||||
|
|
||||||
|
Returned in `HistoricalEventBatch.ContinuationPoint`. The server-side
|
||||||
|
HistoryRead facade is responsible for round-tripping these so a paged event
|
||||||
|
read against a chatty upstream completes incrementally. The driver itself
|
||||||
|
doesn't track them — every `ReadEventsAsync` call issues a fresh
|
||||||
|
`HistoryReadAsync`.
|
||||||
|
|
||||||
|
## HistoryRead Aggregates (Part 13 catalog)
|
||||||
|
|
||||||
|
`IHistoryProvider.ReadProcessedAsync` takes a `HistoryAggregateType` and the
|
||||||
|
driver maps it to the standard `Opc.Ua.ObjectIds.AggregateFunction_*` NodeId
|
||||||
|
in `MapAggregateToNodeId`. PR-13 (issue #285) extended the enum from the
|
||||||
|
original 5 values (Average / Minimum / Maximum / Total / Count) to the full
|
||||||
|
OPC UA Part 13 §5 catalog — ~30 aggregates.
|
||||||
|
|
||||||
|
The mapping is best-effort: not every upstream OPC UA server implements every
|
||||||
|
aggregate. Aggregates the upstream rejects come back with
|
||||||
|
`StatusCode=BadAggregateNotSupported` on the per-row HistoryRead result; the
|
||||||
|
driver passes that through verbatim (cascading-quality rule, Part 11 §8) — it
|
||||||
|
does not throw. Servers advertise the aggregates they support via the
|
||||||
|
`AggregateConfiguration` object on the `Server` node; clients can probe it at
|
||||||
|
runtime.
|
||||||
|
|
||||||
|
### Catalog
|
||||||
|
|
||||||
|
| Enum value | SDK NodeId field | Part 13 § | Server-side support | Typical use |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `Average` | `AggregateFunction_Average` | §5.4 | almost always | smoothing |
|
||||||
|
| `Minimum` | `AggregateFunction_Minimum` | §5.5 | almost always | low watermark |
|
||||||
|
| `Maximum` | `AggregateFunction_Maximum` | §5.6 | almost always | high watermark |
|
||||||
|
| `Total` | `AggregateFunction_Total` | §5.10 | usually | totalisation |
|
||||||
|
| `Count` | `AggregateFunction_Count` | §5.18 | almost always | sample count |
|
||||||
|
| `TimeAverage` | `AggregateFunction_TimeAverage` | §5.4.2 | usually | time-weighted mean |
|
||||||
|
| `TimeAverage2` | `AggregateFunction_TimeAverage2` | §5.4.3 | sometimes | bounded time-weighted mean |
|
||||||
|
| `Interpolative` | `AggregateFunction_Interpolative` | §5.3 | usually | trend snapshot |
|
||||||
|
| `MinimumActualTime` | `AggregateFunction_MinimumActualTime` | §5.5.4 | sometimes | when low occurred |
|
||||||
|
| `MaximumActualTime` | `AggregateFunction_MaximumActualTime` | §5.6.4 | sometimes | when high occurred |
|
||||||
|
| `Range` | `AggregateFunction_Range` | §5.7 | usually | spread |
|
||||||
|
| `Range2` | `AggregateFunction_Range2` | §5.7 | sometimes | bounded spread |
|
||||||
|
| `AnnotationCount` | `AggregateFunction_AnnotationCount` | §5.21 | rarely | operator notes |
|
||||||
|
| `DurationGood` | `AggregateFunction_DurationGood` | §5.16 | sometimes | quality coverage |
|
||||||
|
| `DurationBad` | `AggregateFunction_DurationBad` | §5.16 | sometimes | gap accounting |
|
||||||
|
| `PercentGood` | `AggregateFunction_PercentGood` | §5.17 | sometimes | quality % |
|
||||||
|
| `PercentBad` | `AggregateFunction_PercentBad` | §5.17 | sometimes | gap % |
|
||||||
|
| `WorstQuality` | `AggregateFunction_WorstQuality` | §5.20 | sometimes | worst seen |
|
||||||
|
| `WorstQuality2` | `AggregateFunction_WorstQuality2` | §5.20 | rarely | bounded worst |
|
||||||
|
| `StandardDeviationSample` | `AggregateFunction_StandardDeviationSample` | §5.13 | sometimes | n-1 stddev |
|
||||||
|
| `StandardDeviationPopulation` | `AggregateFunction_StandardDeviationPopulation` | §5.13 | sometimes | n stddev |
|
||||||
|
| `VarianceSample` | `AggregateFunction_VarianceSample` | §5.13 | sometimes | n-1 variance |
|
||||||
|
| `VariancePopulation` | `AggregateFunction_VariancePopulation` | §5.13 | sometimes | n variance |
|
||||||
|
| `NumberOfTransitions` | `AggregateFunction_NumberOfTransitions` | §5.12 | sometimes | event count |
|
||||||
|
| `DurationInStateZero` | `AggregateFunction_DurationInStateZero` | §5.19 | sometimes | OFF time |
|
||||||
|
| `DurationInStateNonZero` | `AggregateFunction_DurationInStateNonZero` | §5.19 | sometimes | ON time |
|
||||||
|
| `Start` | `AggregateFunction_Start` | §5.8 | usually | first sample |
|
||||||
|
| `End` | `AggregateFunction_End` | §5.9 | usually | last sample |
|
||||||
|
| `Delta` | `AggregateFunction_Delta` | §5.11 | usually | end-start |
|
||||||
|
| `StartBound` | `AggregateFunction_StartBound` | §5.8 | sometimes | extrapolated start |
|
||||||
|
| `EndBound` | `AggregateFunction_EndBound` | §5.9 | sometimes | extrapolated end |
|
||||||
|
|
||||||
|
"Server-side support" is heuristic — see your upstream's `AggregateConfiguration`
|
||||||
|
node for the authoritative list. AVEVA Historian, KEPServerEX, Prosys, and
|
||||||
|
opc-plc each implement different subsets.
|
||||||
|
|
||||||
|
### Driver-side validation
|
||||||
|
|
||||||
|
The mapping itself is unit-tested over the full enum
|
||||||
|
(`OpcUaClientAggregateMappingTests`) — every value resolves to a non-null
|
||||||
|
namespace-0 NodeId, and the original 5 ordinals stay pinned. Wire-side
|
||||||
|
behaviour against a live server is exercised by
|
||||||
|
`OpcUaClientAggregateSweepTests` (build-only scaffold pending an opc-plc
|
||||||
|
history-sim profile).
|
||||||
|
|
||||||
|
## Upstream redundancy (`ServerArray`)
|
||||||
|
|
||||||
|
When the upstream OPC UA server is itself a redundant pair (warm or hot per
|
||||||
|
OPC UA Part 4 §6.6.2), the driver supports **mid-session failover** driven by
|
||||||
|
the upstream's own `Server.ServerRedundancy.RedundancySupport` +
|
||||||
|
`ServerUriArray` + `Server.ServiceLevel` nodes. Distinct from the static
|
||||||
|
boot-time failover sweep on `EndpointUrls`: that path picks a single survivor
|
||||||
|
at session-create time; this path swaps the active session live when the
|
||||||
|
upstream signals degradation, transferring subscriptions onto the secondary so
|
||||||
|
monitored-item handles stay valid.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
| Option | Default | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Redundancy.Enabled` | `false` | Opt-in. When `false`, the driver doesn't read `RedundancySupport` / `ServerUriArray` and doesn't subscribe to `ServiceLevel`. |
|
||||||
|
| `Redundancy.ServiceLevelThreshold` | `200` | Byte value below which the driver triggers failover. OPC UA spec convention: 200+ = healthy primary, 100..199 = degraded, 0..99 = unrecoverable. |
|
||||||
|
| `Redundancy.RecheckInterval` | `5s` | Lower bound between two consecutive failovers — suppresses oscillation when ServiceLevel flaps around the threshold. |
|
||||||
|
|
||||||
|
### Behaviour
|
||||||
|
|
||||||
|
- At session activation the driver reads
|
||||||
|
`Server.ServerRedundancy.RedundancySupport`. When `None`, the driver records
|
||||||
|
an empty peer list and the failover path becomes a no-op (`ServiceLevel`
|
||||||
|
drops are still observable via diagnostics but trigger nothing).
|
||||||
|
- When the upstream advertises `Cold` / `Warm` / `WarmActive` / `Hot`, the
|
||||||
|
driver pulls `Server.ServerRedundancy.ServerUriArray` for the peer list,
|
||||||
|
falling back to the top-level `Server.ServerArray` for legacy upstreams that
|
||||||
|
don't expose the redundancy node.
|
||||||
|
- A dedicated subscription on `Server.ServiceLevel` (publish interval 1s,
|
||||||
|
separate from the alarm + data subscriptions) drives every failover decision
|
||||||
|
via the SDK's notification path — no polling loop.
|
||||||
|
- On a drop below `ServiceLevelThreshold` the driver picks the next URI in the
|
||||||
|
peer list that isn't the active one, opens a parallel session against it,
|
||||||
|
and calls `Session.TransferSubscriptionsAsync(other, sendInitialValues:true)`
|
||||||
|
to migrate every live subscription (data + alarm + model-change +
|
||||||
|
service-level itself). On success the driver swaps `Session`, closes the
|
||||||
|
old one, and bumps `RedundancyFailoverCount`.
|
||||||
|
- On any failure (`BadSecureChannelClosed`, `BadCertificateUntrusted`,
|
||||||
|
`TransferSubscriptions` returning `false`, secondary unreachable) the driver
|
||||||
|
leaves the existing session untouched, increments
|
||||||
|
`RedundancyFailoverFailures`, and waits for the next ServiceLevel
|
||||||
|
notification. The keep-alive watchdog continues to cover full
|
||||||
|
upstream-loss scenarios.
|
||||||
|
|
||||||
|
### Shared client-cert prerequisite
|
||||||
|
|
||||||
|
`TransferSubscriptionsAsync` requires the secondary's secure channel to accept
|
||||||
|
the same client certificate the primary did. Operators running heterogeneous
|
||||||
|
secondaries (different cert trust stores) will see `BadCertificateUntrusted`
|
||||||
|
on every failover attempt and the failures counter climbing. The fix is to
|
||||||
|
push the gateway driver's application-instance certificate into both
|
||||||
|
upstreams' `TrustedPeerCertificates` store before enabling redundancy. A
|
||||||
|
follow-up adds a fallback path that re-creates subscriptions instead of
|
||||||
|
transferring when the secondary rejects the channel.
|
||||||
|
|
||||||
|
### Diagnostics
|
||||||
|
|
||||||
|
The `driver-diagnostics` RPC surfaces three new counters via
|
||||||
|
`DriverHealth.Diagnostics`:
|
||||||
|
|
||||||
|
| Key | Type | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `RedundancyFailoverCount` | `double` (long-counted) | Successful mid-session swaps since driver start. |
|
||||||
|
| `RedundancyFailoverFailures` | `double` (long-counted) | Swap attempts that bailed (TransferSubscriptions false, secondary unreachable, etc.). |
|
||||||
|
| `ActiveServerUri` | string (in `OpcUaClientDiagnostics.ActiveServerUri`) | URI of the upstream the driver is currently bound to. Updates on every successful failover. |
|
||||||
|
|
||||||
|
### Forced-failover runbook
|
||||||
|
|
||||||
|
To validate the wiring against a real redundant upstream pair:
|
||||||
|
|
||||||
|
1. Confirm the upstream advertises `RedundancySupport != None` and a
|
||||||
|
non-empty `ServerUriArray`. Use the Client CLI:
|
||||||
|
`dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- redundancy -u <primary>`.
|
||||||
|
2. Set `Redundancy.Enabled = true` on the gateway's `OpcUaClient` driver
|
||||||
|
instance and restart.
|
||||||
|
3. Tail driver diagnostics:
|
||||||
|
`driver-diagnostics --instance <id>` — note `RedundancyFailoverCount = 0`
|
||||||
|
pre-test.
|
||||||
|
4. Drive a `ServiceLevel` drop on the primary. On AVEVA / KEPServer this is
|
||||||
|
typically a "force standby" Admin action; on a custom server it's a write
|
||||||
|
to the simulated ServiceLevel node.
|
||||||
|
5. Observe `RedundancyFailoverCount = 1` within `RecheckInterval` of the
|
||||||
|
drop, the gateway's `HostName` swap to the secondary URI, and downstream
|
||||||
|
reads/subscriptions continuing without interruption.
|
||||||
|
|
||||||
|
For non-redundant upstreams (single-server deployments) the recommended
|
||||||
|
configuration is to leave `Redundancy.Enabled = false` and rely on
|
||||||
|
`EndpointUrls` for boot-time failover only.
|
||||||
@@ -23,7 +23,7 @@ Driver type metadata is registered at startup in `DriverTypeRegistry` (`src/ZB.M
|
|||||||
| [Galaxy](Galaxy.md) | `Driver.Galaxy.{Shared, Host, Proxy}` | C | MXAccess COM + `aahClientManaged` + SqlClient | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHistoryProvider, IRediscoverable, IHostConnectivityProbe | Out-of-process — Host is its own Windows service (.NET 4.8 x86 for the COM bitness constraint); Proxy talks to Host over a named pipe |
|
| [Galaxy](Galaxy.md) | `Driver.Galaxy.{Shared, Host, Proxy}` | C | MXAccess COM + `aahClientManaged` + SqlClient | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IAlarmSource, IHistoryProvider, IRediscoverable, IHostConnectivityProbe | Out-of-process — Host is its own Windows service (.NET 4.8 x86 for the COM bitness constraint); Proxy talks to Host over a named pipe |
|
||||||
| Modbus TCP | `Driver.Modbus` | A | NModbus-derived in-house client | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe | Polled subscriptions via the shared `PollGroupEngine`. DL205 PLCs are covered by `AddressFormat=DL205` (octal V/X/Y/C/T/CT translation) — no separate driver |
|
| Modbus TCP | `Driver.Modbus` | A | NModbus-derived in-house client | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe | Polled subscriptions via the shared `PollGroupEngine`. DL205 PLCs are covered by `AddressFormat=DL205` (octal V/X/Y/C/T/CT translation) — no separate driver |
|
||||||
| Siemens S7 | `Driver.S7` | A | [S7netplus](https://github.com/S7NetPlus/s7netplus) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe | Single S7netplus `Plc` instance per PLC serialized with `SemaphoreSlim` — the S7 CPU's comm mailbox is scanned at most once per cycle, so parallel reads don't help |
|
| Siemens S7 | `Driver.S7` | A | [S7netplus](https://github.com/S7NetPlus/s7netplus) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe | Single S7netplus `Plc` instance per PLC serialized with `SemaphoreSlim` — the S7 CPU's comm mailbox is scanned at most once per cycle, so parallel reads don't help |
|
||||||
| AB CIP | `Driver.AbCip` | A | libplctag CIP | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | ControlLogix / CompactLogix. Tag discovery uses the `@tags` walker to enumerate controller-scoped + program-scoped symbols; UDT member resolution via the UDT template reader |
|
| AB CIP | `Driver.AbCip` | A | libplctag CIP | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource | ControlLogix / CompactLogix. Tag discovery uses the `@tags` walker to enumerate controller-scoped + program-scoped symbols; UDT member resolution via the UDT template reader |
|
||||||
| AB Legacy | `Driver.AbLegacy` | A | libplctag PCCC | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | SLC 500 / MicroLogix. File-based addressing (`N7:0`, `F8:0`) — no symbol table, tag list is user-authored in the config DB |
|
| AB Legacy | `Driver.AbLegacy` | A | libplctag PCCC | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | SLC 500 / MicroLogix. File-based addressing (`N7:0`, `F8:0`) — no symbol table, tag list is user-authored in the config DB |
|
||||||
| TwinCAT | `Driver.TwinCAT` | B | Beckhoff `TwinCAT.Ads` (`TcAdsClient`) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | The only native-notification driver outside Galaxy — ADS delivers `ValueChangedCallback` events the driver forwards straight to `ISubscribable.OnDataChange` without polling. Symbol tree uploaded via `SymbolLoaderFactory` |
|
| TwinCAT | `Driver.TwinCAT` | B | Beckhoff `TwinCAT.Ads` (`TcAdsClient`) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | The only native-notification driver outside Galaxy — ADS delivers `ValueChangedCallback` events the driver forwards straight to `ISubscribable.OnDataChange` without polling. Symbol tree uploaded via `SymbolLoaderFactory` |
|
||||||
| FOCAS | `Driver.FOCAS` | C | FANUC FOCAS2 (`Fwlib32.dll` P/Invoke) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | Tier C — FOCAS DLL has crash modes that warrant process isolation. CNC-shaped data model (axes, spindle, PMC, macros, alarms) not a flat tag map |
|
| FOCAS | `Driver.FOCAS` | C | FANUC FOCAS2 (`Fwlib32.dll` P/Invoke) | IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver | Tier C — FOCAS DLL has crash modes that warrant process isolation. CNC-shaped data model (axes, spindle, PMC, macros, alarms) not a flat tag map |
|
||||||
@@ -44,7 +44,7 @@ Each driver has a dedicated fixture doc that lays out what the integration / uni
|
|||||||
- [AB CIP](AbServer-Test-Fixture.md) — Dockerized `ab_server` (multi-stage build from libplctag source); atomic-read smoke across 4 families; UDT / ALMD / family quirks unit-only
|
- [AB CIP](AbServer-Test-Fixture.md) — Dockerized `ab_server` (multi-stage build from libplctag source); atomic-read smoke across 4 families; UDT / ALMD / family quirks unit-only
|
||||||
- [Modbus](Modbus-Test-Fixture.md) — Dockerized `pymodbus` + per-family JSON profiles (4 compose profiles); best-covered driver, gaps are error-path-shaped
|
- [Modbus](Modbus-Test-Fixture.md) — Dockerized `pymodbus` + per-family JSON profiles (4 compose profiles); best-covered driver, gaps are error-path-shaped
|
||||||
- [Siemens S7](S7-Test-Fixture.md) — Dockerized `python-snap7` server; DB/MB read + write round-trip verified end-to-end on `:1102`
|
- [Siemens S7](S7-Test-Fixture.md) — Dockerized `python-snap7` server; DB/MB read + write round-trip verified end-to-end on `:1102`
|
||||||
- [AB Legacy](AbLegacy-Test-Fixture.md) — Docker scaffold via `ab_server` PCCC mode (task #224); wire-level round-trip currently blocked by ab_server's PCCC coverage gap, docs call out RSEmulate 500 + lab-rig resolution paths
|
- [AB Legacy](AbLegacy-Test-Fixture.md) — Dockerized `ab_server` PCCC mode across SLC500 / MicroLogix / PLC-5 profiles (task #224); N/F/L-file round-trip verified end-to-end. `/1,0` cip-path required for the Docker fixture; real hardware uses empty. Residual gap: bit-file writes (`B3:0/5`) still surface BadState — real HW / RSEmulate 500 for those
|
||||||
- [TwinCAT](TwinCAT-Test-Fixture.md) — XAR-VM integration scaffolding (task #221); three smoke tests skip when VM unreachable. Unit via `FakeTwinCATClient` with native-notification harness
|
- [TwinCAT](TwinCAT-Test-Fixture.md) — XAR-VM integration scaffolding (task #221); three smoke tests skip when VM unreachable. Unit via `FakeTwinCATClient` with native-notification harness
|
||||||
- [FOCAS](FOCAS-Test-Fixture.md) — no integration fixture, unit-only via `FakeFocasClient`; Tier C out-of-process isolation scoped but not shipped
|
- [FOCAS](FOCAS-Test-Fixture.md) — no integration fixture, unit-only via `FakeFocasClient`; Tier C out-of-process isolation scoped but not shipped
|
||||||
- [OPC UA Client](OpcUaClient-Test-Fixture.md) — no integration fixture, unit-only via mocked `Session`; loopback against this repo's own server is the obvious next step
|
- [OPC UA Client](OpcUaClient-Test-Fixture.md) — no integration fixture, unit-only via mocked `Session`; loopback against this repo's own server is the obvious next step
|
||||||
|
|||||||
338
docs/drivers/S7-TIA-Import.md
Normal file
338
docs/drivers/S7-TIA-Import.md
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
# S7 — TIA Portal CSV & STEP 7 Classic AWL symbol import
|
||||||
|
|
||||||
|
PR-S7-D1 / [#299](https://github.com/dohertj2/lmxopcua/issues/299) — bulk-import
|
||||||
|
TIA Portal "Show all tags" CSV exports and STEP 7 Classic AWL declaration files
|
||||||
|
into the S7 driver. Saves operators from hand-typing every `%MW0` /
|
||||||
|
`%DB1.DBW0` row of a several-hundred-tag PLC into `appsettings.json`.
|
||||||
|
|
||||||
|
## Supported formats — v1
|
||||||
|
|
||||||
|
| Format | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| TIA Portal `.CSV` ("Show all tags" export) | **supported** | Header columns `Name,Path,Data type,Logical address,Comment,Hmi accessible,…`; en-US (`,`) and DE-locale (`;` separator + `,` decimal) auto-detected |
|
||||||
|
| STEP 7 Classic `.AWL` (`VAR_GLOBAL` + `DATA_BLOCK`) | **supported, best-effort** | Position-based offset assignment (no exact byte offsets in hand-exported AWL — see below) |
|
||||||
|
| STEP 7 / TIA Portal native binary (`.s7p`, `.zap`) | **out of scope** | Proprietary; no community parser. Use TIA's "Show all tags" CSV export |
|
||||||
|
| TIA Portal Openness API | **out of scope** | Requires a licensed TIA install + OpenAPI license; future PR |
|
||||||
|
|
||||||
|
## TIA Portal CSV column reference
|
||||||
|
|
||||||
|
| Column | Required | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `Name` | yes | OPC UA tag name. TIA symbols are stable across deployments; the importer uses them verbatim |
|
||||||
|
| `Logical address` (or `Address`) | yes | TIA-style address with leading `%` (e.g. `%MW0`, `%DB1.DBW10`, `%DB1.DBX2.3`). Stripped on import |
|
||||||
|
| `Data type` | recommended | TIA primitive type (`Int`, `Real`, `Bool`, `String`, …) — drives the imported `S7DataType` |
|
||||||
|
| `Comment` | no | Parsed but currently unused — `S7TagDefinition` has no `Description` field at the v2 schema layer (see [#248](https://github.com/dohertj2/lmxopcua/issues/248)). Held in the column contract for future schema bumps |
|
||||||
|
| `Hmi accessible` | no | Filter — rows with `False` / `FALSCH` / `nein` are skipped (internal symbols TIA shows in the editor but doesn't expose to client interfaces). Missing column defaults to `True` |
|
||||||
|
| `Hmi visible` / `Hmi writeable` | no | Currently unused — held for future Admin-UI-side metadata |
|
||||||
|
| `Length` | no | For `String` rows: max length. Default 254. Drives `StringLength` on the imported tag |
|
||||||
|
| `Path` | no | TIA tag-table path (`Default tag table`, custom names). Currently unused; held in the contract |
|
||||||
|
|
||||||
|
### TIA `Data type` → `S7DataType` mapping
|
||||||
|
|
||||||
|
| TIA type | Maps to | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `Bool` | `Bool` | Bit access; address must include a `.bit` suffix |
|
||||||
|
| `Byte`, `SInt`, `USInt` | `Byte` | 1-byte unsigned/signed |
|
||||||
|
| `Int` | `Int16` | Signed 16-bit |
|
||||||
|
| `Word`, `UInt` | `UInt16` | Unsigned 16-bit |
|
||||||
|
| `DInt` | `Int32` | Signed 32-bit |
|
||||||
|
| `DWord`, `UDInt` | `UInt32` | Unsigned 32-bit |
|
||||||
|
| `LInt` | `Int64` | 64-bit signed (S7-1500 only) |
|
||||||
|
| `LWord`, `ULInt` | `UInt64` | 64-bit unsigned (S7-1500 only) |
|
||||||
|
| `Real` | `Float32` | IEEE-754 32-bit |
|
||||||
|
| `LReal` | `Float64` | IEEE-754 64-bit (S7-1500 only) |
|
||||||
|
| `String` | `String` | S7 STRING with 2-byte header; `Length` column drives `StringLength` |
|
||||||
|
| `WString` | `WString` | S7 WSTRING (UTF-16BE) |
|
||||||
|
| `Char` / `WChar` | `Char` / `WChar` | Single-character |
|
||||||
|
| `Date` | `Date` | UInt16 days since 1990-01-01 |
|
||||||
|
| `Time` | `Time` | Int32 ms |
|
||||||
|
| `TOD` / `Time_Of_Day` | `TimeOfDay` | UInt32 ms since midnight |
|
||||||
|
| `DT` / `Date_And_Time` | `DateAndTime` | 8-byte BCD |
|
||||||
|
| `DTL` | `Dtl` | 12-byte structured (S7-1200 / S7-1500) |
|
||||||
|
| `S5Time` | `S5Time` | 16-bit BCD duration |
|
||||||
|
| `Struct` / quoted UDT name | UDT placeholder | See below |
|
||||||
|
|
||||||
|
### UDT placeholders
|
||||||
|
|
||||||
|
UDT-typed symbols (TIA `Data type` = `"MyUdt"` quoted, or the literal `Struct`)
|
||||||
|
import as a **placeholder** — the resulting tag lands in the driver options so
|
||||||
|
it shows up in the Admin UI tag list, but its data type is forced to `Byte`
|
||||||
|
and the row is marked `Writable = false`.
|
||||||
|
|
||||||
|
`S7ImportResult.UdtPlaceholderCount` tracks how many of the imported tags
|
||||||
|
landed in this bucket.
|
||||||
|
|
||||||
|
#### Cooperation with `Udts` declarations (PR-S7-D2 / #300)
|
||||||
|
|
||||||
|
PR-S7-D2 ships UDT fan-out via `S7DriverOptions.Udts` + `S7TagDefinition.UdtName`.
|
||||||
|
The importer and the `Udts` declaration cooperate as follows:
|
||||||
|
|
||||||
|
1. The importer emits a placeholder row for each UDT-typed symbol — same as
|
||||||
|
today (data type forced to `Byte`, `Writable = false`).
|
||||||
|
2. The operator hand-edits the placeholder row in the resulting JSON / options
|
||||||
|
object and:
|
||||||
|
- Sets `UdtName` to the UDT type name from the TIA "Data type" column
|
||||||
|
- Removes the `Writable: false` marker (UDT leaves inherit the parent's
|
||||||
|
writability)
|
||||||
|
3. The operator declares the matching `S7UdtDefinition` in
|
||||||
|
`S7DriverOptions.Udts` (member offsets come from the TIA UDT definition
|
||||||
|
in the project file — TIA's "Show all tags" CSV does not export struct
|
||||||
|
field offsets, hence the manual layout step).
|
||||||
|
4. At driver init, the fan-out replaces the placeholder with one scalar leaf
|
||||||
|
per UDT member.
|
||||||
|
|
||||||
|
The importer does NOT auto-populate `Udts` — UDT layouts live in the project
|
||||||
|
file, not the symbol-table CSV. A future enhancement may parse the SCL UDT
|
||||||
|
declaration alongside the CSV; for now the cooperation is "importer flags it,
|
||||||
|
operator declares the layout, driver fans out at init".
|
||||||
|
|
||||||
|
See [`docs/v2/s7.md` "UDT / STRUCT support"](../v2/s7.md#udt--struct-support)
|
||||||
|
for the full fan-out semantics, the 4-level nesting cap, and the
|
||||||
|
Optimized-block-access prerequisite.
|
||||||
|
|
||||||
|
## Instance DBs / FB parameters
|
||||||
|
|
||||||
|
PR-S7-D3 / [#301](https://github.com/dohertj2/lmxopcua/issues/301) — multi-instance
|
||||||
|
Function-Block (FB) instances are addressed symbolically inside the PLC program
|
||||||
|
(`MyFB_Instance.MyParam`) but the runtime wire access still needs the absolute
|
||||||
|
`DBn.DBW_offset`. TIA Portal's "Show all tags" CSV export distinguishes these
|
||||||
|
rows from regular global DBs via the **`DB type`** column.
|
||||||
|
|
||||||
|
### `DB type` column convention
|
||||||
|
|
||||||
|
| `DB type` value | Meaning | Path |
|
||||||
|
|---|---|---|
|
||||||
|
| (empty) | Legacy export — no column at all (TIA pre-v15 / partial export). Treated as Global. | D1 (existing) |
|
||||||
|
| `Global DB` / `Global` / `Global Data Block` | Standalone DB declared in the project tree. | D1 (existing) |
|
||||||
|
| `Globaler Datenbaustein` | Same as above, DE locale. | D1 (existing) |
|
||||||
|
| `Instance DB` / `Instance` / `Instance Data Block` | Multi-instance FB instance. Member tags are the FB's `IN` / `OUT` / `IN_OUT` / `STAT` parameters. | **D3 (new)** |
|
||||||
|
| `Instance-DB` / `Instanz-DB` / `Instanz-Datenbaustein` | Same as above (locale + dashing variants). | **D3 (new)** |
|
||||||
|
|
||||||
|
The `DB type` column is matched case-insensitively; quoting and surrounding
|
||||||
|
whitespace are tolerated.
|
||||||
|
|
||||||
|
### `MyFB_Instance.MyParam` → `DBn.DBW_offset`
|
||||||
|
|
||||||
|
The TIA Portal export ships the **resolved absolute address** in the
|
||||||
|
`Logical address` column for every instance-DB member — TIA itself walks the FB
|
||||||
|
interface declaration at export time and writes out the byte-offset-anchored
|
||||||
|
address verbatim. The importer accepts these rows the same way as a Global-DB
|
||||||
|
row, with two differences:
|
||||||
|
|
||||||
|
1. The row counts under `S7ImportResult.InstanceDbCount` (a sub-counter of
|
||||||
|
`ParsedCount`) so the operator can see how much of the import depends on the
|
||||||
|
FB-interface layout.
|
||||||
|
2. The row is rejected from the UDT placeholder path even if the data type
|
||||||
|
column happens to match a UDT name pattern — instance-DB members always
|
||||||
|
import as fully-functional scalar tags.
|
||||||
|
|
||||||
|
Example fixture row:
|
||||||
|
|
||||||
|
```csv
|
||||||
|
Name,Path,Data type,Logical address,Comment,Hmi accessible,DB type
|
||||||
|
MotorFB_1.Speed,FB instances,Int,%DB7.DBW0,Speed setpoint,True,Instance DB
|
||||||
|
```
|
||||||
|
|
||||||
|
The imported `S7TagDefinition` ends up with:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
new S7TagDefinition(
|
||||||
|
Name: "MotorFB_1.Speed",
|
||||||
|
Address: "DB7.DBW0",
|
||||||
|
DataType: S7DataType.Int16,
|
||||||
|
Writable: true);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Empty-`Logical address` fallback
|
||||||
|
|
||||||
|
When TIA exports an instance-DB row with an empty `Logical address` column
|
||||||
|
(rare in practice — happens when the export was generated against a
|
||||||
|
not-yet-compiled project), `InstanceDbResolver` can compute the absolute
|
||||||
|
address from explicit parent-DB / parent-base-offset / member-offset inputs.
|
||||||
|
This fallback is exposed at the resolver-class level for advanced bootstrap
|
||||||
|
scenarios; the CSV path itself does not currently parse interface declarations
|
||||||
|
out of the file (TIA's CSV doesn't carry them).
|
||||||
|
|
||||||
|
For now the operator workflow is: re-export from TIA after compiling the
|
||||||
|
project so every instance-DB row carries a resolved `Logical address`.
|
||||||
|
|
||||||
|
### Re-import on FB-interface edit — caveat
|
||||||
|
|
||||||
|
When the FB interface changes — a member is added, removed, or reordered in
|
||||||
|
TIA — the instance-DB layout shifts on the PLC side. Member byte offsets that
|
||||||
|
worked yesterday point at the wrong word today; absolute-offset addressing has
|
||||||
|
no in-band schema check.
|
||||||
|
|
||||||
|
**The driver does not auto-detect this.** Operators must:
|
||||||
|
|
||||||
|
1. Recompile the FB in TIA Portal.
|
||||||
|
2. Download the updated program to the PLC.
|
||||||
|
3. **Re-export "Show all tags" CSV** from the updated project.
|
||||||
|
4. Re-import the CSV via `AddTiaCsvImport` or the `import-symbols` CLI.
|
||||||
|
5. Restart the driver instance (Admin UI → Drivers → Reload).
|
||||||
|
|
||||||
|
A stale import will silently read / write the wrong byte offsets — the values
|
||||||
|
will look like valid PLC data but reference whichever member used to live at
|
||||||
|
that offset before the interface edit. There is no runtime guard; this is the
|
||||||
|
same caveat that applies to all absolute-offset DB addressing on S7-1200 /
|
||||||
|
1500 (see [`docs/v2/s7.md` "UDT / STRUCT support"](../v2/s7.md#udt--struct-support)
|
||||||
|
for the parallel UDT-edit story).
|
||||||
|
|
||||||
|
A future enhancement may add a project-fingerprint compare at driver init —
|
||||||
|
hashing the interface offsets at import time and re-checking against a known
|
||||||
|
PLC system function. Tracked as a follow-up; not in PR-S7-D3.
|
||||||
|
|
||||||
|
## DE locale handling
|
||||||
|
|
||||||
|
TIA Portal honours the Windows display locale when writing CSV. A DE-locale
|
||||||
|
install emits:
|
||||||
|
|
||||||
|
- Field separator `;` (because `,` is the decimal separator)
|
||||||
|
- Decimal-comma in addresses: `%MW0,5` rather than `%MW0.5` for bit addresses
|
||||||
|
- Boolean column values `WAHR` / `FALSCH` rather than `True` / `False`
|
||||||
|
|
||||||
|
The importer **auto-detects** the locale from the first non-blank line:
|
||||||
|
|
||||||
|
- Field-separator detection: counts `;` vs `,` occurrences in the header
|
||||||
|
- Decimal-comma detection: scans the first data row's address column for a
|
||||||
|
digit-comma-digit pattern
|
||||||
|
- Boolean column values: recognises both languages (`true/false/wahr/falsch/yes/no/ja/nein`,
|
||||||
|
case-insensitive) plus bare `0`/`1`
|
||||||
|
|
||||||
|
The address column is rewritten to en-US shape (`%MW0,5` → `MW0.5`) before the
|
||||||
|
strict `S7AddressParser` runs, so the rest of the driver pipeline sees a
|
||||||
|
single canonical address shape.
|
||||||
|
|
||||||
|
## STEP 7 Classic AWL — `VAR_GLOBAL` + `DATA_BLOCK`
|
||||||
|
|
||||||
|
Best-effort parser for legacy STEP 7 Classic projects:
|
||||||
|
|
||||||
|
- `VAR_GLOBAL … END_VAR` — global memory area declarations. Each entry maps to
|
||||||
|
a sequential `M{B|W|D}{offset}` address based on declaration order.
|
||||||
|
- `DATA_BLOCK DBn … END_DATA_BLOCK` — DB declarations. Each field maps to a
|
||||||
|
`DB{n}.DB{B|W|D}{offset}` address based on declaration order; the DB number
|
||||||
|
is parsed from the `DATA_BLOCK` line's `DBn` keyword.
|
||||||
|
|
||||||
|
### Position-based addressing — heuristic
|
||||||
|
|
||||||
|
Real STEP 7 Classic projects carry exact byte offsets in the symbol table /
|
||||||
|
.gr8 deployment artefact, but a hand-exported AWL file omits them. The
|
||||||
|
importer assumes:
|
||||||
|
|
||||||
|
| Type | Bytes |
|
||||||
|
|---|---|
|
||||||
|
| `BOOL` | 1 (rounded up to byte alignment) |
|
||||||
|
| `BYTE` / `SINT` / `USINT` / `CHAR` | 1 |
|
||||||
|
| `INT` / `WORD` / `UINT` | 2 |
|
||||||
|
| `DINT` / `DWORD` / `UDINT` / `REAL` | 4 |
|
||||||
|
| `LREAL` / `LINT` / `ULINT` / `LWORD` | 8 |
|
||||||
|
| `STRING[N]` | N + 2 (2-byte header) |
|
||||||
|
| `STRING` (no length) | 256 |
|
||||||
|
| `STRUCT` / `Array[…] of …` / quoted UDT name | UDT placeholder (8-bit Byte at next aligned offset) |
|
||||||
|
|
||||||
|
S7 alignment rule: offsets round up to a 2-byte boundary for any 16-bit-or-larger
|
||||||
|
type. Sites needing exact offsets should drive their symbol import from the
|
||||||
|
TIA Portal CSV path instead — the CSV carries the offsets verbatim.
|
||||||
|
|
||||||
|
Comments (`(* ... *)` block, `// ...` line) are stripped before declaration
|
||||||
|
parsing. Initial-value clauses (`:= 0`) are recognised and discarded.
|
||||||
|
|
||||||
|
## CLI subcommand — `import-symbols`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
otopcua-s7-cli import-symbols --help
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `-f` / `--file` | **required** | Path to the TIA CSV or `.AWL` file |
|
||||||
|
| `--format` | `tia` | `tia` (CSV) or `awl` (STEP 7 Classic) |
|
||||||
|
| `-d` / `--device` | none | Optional documentation tag (reserved for symmetry with `import-rslogix`) |
|
||||||
|
| `--emit` | `appsettings-fragment` | `appsettings-fragment` (JSON) or `summary` (one-line counter) |
|
||||||
|
| `-o` / `--output` | stdout | Optional path; when set the JSON fragment is written there + summary line goes to stdout |
|
||||||
|
| `--max-rows` | unlimited | Defensive cap on rows imported |
|
||||||
|
| `--strict` | off | Fail-fast on the first malformed row (default permissive: skip + log) |
|
||||||
|
|
||||||
|
### `appsettings-fragment` output shape
|
||||||
|
|
||||||
|
The default `--emit appsettings-fragment` mode writes a JSON object whose
|
||||||
|
`Tags` array is shaped like the `S7DriverConfigDto.Tags` array — paste
|
||||||
|
straight into the driver-instance config under
|
||||||
|
`Drivers/<instance>/Config/Tags`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Tags": [
|
||||||
|
{
|
||||||
|
"Name": "MotorSpeed",
|
||||||
|
"Address": "MW0",
|
||||||
|
"DataType": "Int16",
|
||||||
|
"Writable": true,
|
||||||
|
"StringLength": 254
|
||||||
|
},
|
||||||
|
…
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Summary line
|
||||||
|
|
||||||
|
`--emit summary` writes a single line:
|
||||||
|
|
||||||
|
```
|
||||||
|
Imported 142 tag(s), skipped 3, errors 0, udt-placeholders 5, instance-db 9.
|
||||||
|
```
|
||||||
|
|
||||||
|
`Skipped` covers HMI-accessible-false rows + missing-required-field rows;
|
||||||
|
`errors` covers rows whose `Address` failed to parse as an S7 address;
|
||||||
|
`udt-placeholders` covers UDT-typed rows that imported as placeholders;
|
||||||
|
`instance-db` (PR-S7-D3) covers rows whose `DB type` column tagged them as
|
||||||
|
multi-instance FB-instance members.
|
||||||
|
|
||||||
|
## API surface — `IS7SymbolImporter` + `AddTiaCsvImport` / `AddAwlImport`
|
||||||
|
|
||||||
|
For server-side / bootstrap use-cases the importer is reachable via:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||||
|
|
||||||
|
var options = new S7DriverOptions { Host = "192.168.1.30", CpuType = CpuType.S71500 };
|
||||||
|
|
||||||
|
// Append imported tags onto an existing options object.
|
||||||
|
var updated = options.AddTiaCsvImport(
|
||||||
|
path: @"C:\plc\tia-export.csv",
|
||||||
|
out var result);
|
||||||
|
|
||||||
|
Console.WriteLine($"Imported {result.ParsedCount} tags ({result.UdtPlaceholderCount} placeholders)");
|
||||||
|
|
||||||
|
// AWL variant — same shape.
|
||||||
|
var withAwl = updated.AddAwlImport(
|
||||||
|
path: @"C:\plc\classic.awl",
|
||||||
|
out var awlResult);
|
||||||
|
```
|
||||||
|
|
||||||
|
For a hand-managed importer instance (e.g. supplying a custom `ILogger`) call
|
||||||
|
`new TiaCsvImporter(logger).Parse(stream, opts)` or
|
||||||
|
`new AwlImporter(logger).Parse(stream, opts)` directly.
|
||||||
|
|
||||||
|
## Operational notes
|
||||||
|
|
||||||
|
- The importers are **additive** — `AddTiaCsvImport` / `AddAwlImport` concatenate
|
||||||
|
onto the existing `Tags` list rather than replacing it. Hand-rolled tags
|
||||||
|
(system-status variables, computed fields the operator added by hand) survive
|
||||||
|
a re-import.
|
||||||
|
- Re-imports are not idempotent — calling `AddTiaCsvImport` twice will produce
|
||||||
|
duplicate tag rows. Operators are expected to start from a clean options
|
||||||
|
object or de-duplicate themselves; a future schema rev may add a
|
||||||
|
`replace=true` switch.
|
||||||
|
- UDT placeholders surface in the Admin UI as non-writable Byte tags. PR-S7-D2
|
||||||
|
added the runtime UDT fan-out (`S7DriverOptions.Udts` + `S7TagDefinition.UdtName`)
|
||||||
|
— operators upgrade a placeholder row by setting `UdtName` and declaring the
|
||||||
|
matching `S7UdtDefinition`; see "Cooperation with `Udts` declarations" above.
|
||||||
|
Placeholder-only rows still work as a Byte view of the first byte but
|
||||||
|
can't browse / read their members until the layout is declared.
|
||||||
|
- Description metadata is dropped on the floor today — see the column
|
||||||
|
reference above. When [#248](https://github.com/dohertj2/lmxopcua/issues/248)
|
||||||
|
lands a `Description` field on `S7TagDefinition` the importer will start
|
||||||
|
populating it without further changes to the CSV contract.
|
||||||
@@ -44,6 +44,10 @@ The driver ctor change that made this possible:
|
|||||||
bool-with-bit in one batch call; proves typed decode per S7DataType
|
bool-with-bit in one batch call; proves typed decode per S7DataType
|
||||||
- `S7_1500SmokeTests.Driver_write_then_read_round_trip_on_scratch_word` —
|
- `S7_1500SmokeTests.Driver_write_then_read_round_trip_on_scratch_word` —
|
||||||
`DB1.DBW100` write → read-back; proves write path + buffer visibility
|
`DB1.DBW100` write → read-back; proves write path + buffer visibility
|
||||||
|
- `S7_1500DiagnosticsTests.Driver_exposes_negotiated_pdu_size_post_init` —
|
||||||
|
asserts `DriverHealth.Diagnostics["S7.NegotiatedPduSize"]` is non-zero
|
||||||
|
after `InitializeAsync`; proves the negotiated PDU size surfaces in
|
||||||
|
driver health (Snap7 fixture pins this at 240 bytes — see fixture README)
|
||||||
|
|
||||||
### Unit
|
### Unit
|
||||||
|
|
||||||
@@ -86,8 +90,10 @@ not differentiated at test time.
|
|||||||
|
|
||||||
### 5. Data types beyond the scalars
|
### 5. Data types beyond the scalars
|
||||||
|
|
||||||
UDT fan-out, `STRING` with length-prefix quirks, `DTL` / `DATE_AND_TIME`,
|
`STRING` with length-prefix quirks, `DTL` / `DATE_AND_TIME`, arrays of
|
||||||
arrays of structs — not covered.
|
structs — not covered. UDT fan-out IS covered (PR-S7-D2 / #300) via the
|
||||||
|
`udt_layout` meta-seed in `Docker/profiles/s7_1500.json` and the
|
||||||
|
`Driver_fans_out_udt_into_member_tags` integration test.
|
||||||
|
|
||||||
## When to trust the S7 tests, when to reach for a rig
|
## When to trust the S7 tests, when to reach for a rig
|
||||||
|
|
||||||
@@ -97,7 +103,7 @@ arrays of structs — not covered.
|
|||||||
| "Does the driver lifecycle hang / crash?" | yes | yes |
|
| "Does the driver lifecycle hang / crash?" | yes | yes |
|
||||||
| "Does a real read against an S7-1500 return correct bytes?" | no | yes (required) |
|
| "Does a real read against an S7-1500 return correct bytes?" | no | yes (required) |
|
||||||
| "Does mailbox serialization actually prevent PG timeouts?" | no | yes (required) |
|
| "Does mailbox serialization actually prevent PG timeouts?" | no | yes (required) |
|
||||||
| "Does a UDT fan-out produce usable member variables?" | no | yes (required) |
|
| "Does a UDT fan-out produce usable member variables?" | yes (Snap7 + `udt_layout` meta-seed) | yes |
|
||||||
|
|
||||||
## Follow-up candidates
|
## Follow-up candidates
|
||||||
|
|
||||||
@@ -109,6 +115,18 @@ arrays of structs — not covered.
|
|||||||
lab rig but not CI.
|
lab rig but not CI.
|
||||||
3. **Real S7 lab rig** — cheapest physical PLC (CPU 1212C) on a dedicated
|
3. **Real S7 lab rig** — cheapest physical PLC (CPU 1212C) on a dedicated
|
||||||
network port, wired via self-hosted runner.
|
network port, wired via self-hosted runner.
|
||||||
|
4. **PR-S7-C5 — PUT/GET-disabled pre-flight rejection.** Snap7 does *not*
|
||||||
|
model the hardened-CPU PUT/GET response (it accepts every read once the
|
||||||
|
COTP handshake completes), so the **failure** path of the pre-flight
|
||||||
|
probe — `S7PutGetDisabledException` thrown from `InitializeAsync` when
|
||||||
|
the PLC rejects the probe read with `ErrorCode.WrongCPU_Type` /
|
||||||
|
`ErrorCode.ReadData` — needs a real S7-1500 with PUT/GET disabled in TIA
|
||||||
|
Portal. The integration suite covers the *happy* path
|
||||||
|
(`Driver_preflight_passes_when_probe_address_seeded`); the failure path
|
||||||
|
should be added as a `--with-real-plc` opt-in test that the self-hosted
|
||||||
|
runner with the lab rig executes. The classifier branch
|
||||||
|
(`S7PreflightClassifier.IsPutGetDisabled`) is unit-tested without a
|
||||||
|
network in `S7PreflightTests.Classifier_matches_only_PUT_GET_disabled_error_codes`.
|
||||||
|
|
||||||
Without any of these, S7 driver correctness against real hardware is trusted
|
Without any of these, S7 driver correctness against real hardware is trusted
|
||||||
from field deployments, not from the test suite.
|
from field deployments, not from the test suite.
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ All three gated on `TWINCAT_TARGET_HOST` + `TWINCAT_TARGET_NETID` env
|
|||||||
vars; skip cleanly via `[TwinCATFact]` when the VM isn't reachable or
|
vars; skip cleanly via `[TwinCATFact]` when the VM isn't reachable or
|
||||||
vars are unset.
|
vars are unset.
|
||||||
|
|
||||||
|
PR 4.1 / #315 adds `TwinCATUdtBrowseTests.Driver_browses_UDT_tree_and_flattens_to_atomic_leaves`
|
||||||
|
which exercises `TwinCATDriver.DiscoverAsync` end-to-end against the
|
||||||
|
`GVL_Plant` UDT fixture. Asserts the discovery surface emits one OPC UA
|
||||||
|
variable per atomic leaf and folds `aAlarmRecords[1..2000]` into a
|
||||||
|
single `IsArrayRoot` placeholder when the element count exceeds the
|
||||||
|
default 1024-element cap (UDT per-member coverage; see
|
||||||
|
`TwinCatProject/README.md §Complex hierarchy` for the supporting DUTs).
|
||||||
|
|
||||||
### Unit
|
### Unit
|
||||||
|
|
||||||
- `TwinCATAmsAddressTests` — `ads://<netId>:<port>` parsing + routing
|
- `TwinCATAmsAddressTests` — `ads://<netId>:<port>` parsing + routing
|
||||||
@@ -66,6 +74,14 @@ vars are unset.
|
|||||||
- `TwinCATSymbolPathTests` — symbol-path routing for nested struct members
|
- `TwinCATSymbolPathTests` — symbol-path routing for nested struct members
|
||||||
- `TwinCATSymbolBrowserTests` — `ITagDiscovery.DiscoverAsync` via
|
- `TwinCATSymbolBrowserTests` — `ITagDiscovery.DiscoverAsync` via
|
||||||
`ReadSymbolsAsync` (#188) + system-symbol filtering
|
`ReadSymbolsAsync` (#188) + system-symbol filtering
|
||||||
|
- `TwinCATTypeWalkerTests` — PR 4.1 / #315 nested-UDT decomposition:
|
||||||
|
atomic / single-level struct / nested struct / array-of-atomic
|
||||||
|
(in / over `MaxArrayExpansion`) / array-of-struct / alias chain /
|
||||||
|
pointer skip / self-referencing struct depth-cap / per-leaf
|
||||||
|
`MaxArrayExpansion` honored / ReadOnly propagation. Stub `IDataType`
|
||||||
|
/ `IStructType` / `IArrayType` / `IMember` / `IDimensionCollection`
|
||||||
|
trees built in-test so the walker is exercised without
|
||||||
|
`Beckhoff.TwinCAT.Ads`-internal ctors.
|
||||||
- `TwinCATNativeNotificationTests` — `AddDeviceNotification` (#189)
|
- `TwinCATNativeNotificationTests` — `AddDeviceNotification` (#189)
|
||||||
registration, callback-delivery-to-`OnDataChange` wiring, unregister on
|
registration, callback-delivery-to-`OnDataChange` wiring, unregister on
|
||||||
unsubscribe
|
unsubscribe
|
||||||
@@ -96,6 +112,16 @@ CPU load or network jitter real notifications can coalesce. The fake fires
|
|||||||
one callback per test invocation — real callback-coalescing behavior is
|
one callback per test invocation — real callback-coalescing behavior is
|
||||||
untested.
|
untested.
|
||||||
|
|
||||||
|
PR 3.1 (#313) makes the per-tag `MaxDelay` configurable via
|
||||||
|
`TwinCATTagDefinition.MaxDelayMs` — the runtime can buffer changes for up to
|
||||||
|
that many milliseconds before dispatch, deliberately coalescing bursty
|
||||||
|
high-frequency signals so the OPC UA queue downstream doesn't flood. Default
|
||||||
|
`null` / `0` preserves the pre-PR-3.1 "fire ASAP" behaviour.
|
||||||
|
`TwinCATMaxDelayTests.Driver_coalesces_notifications_at_max_delay` exercises
|
||||||
|
the wire-side coalescer end-to-end against `GVL_Fixture.nCounter`; the unit
|
||||||
|
suite (`TwinCATNativeNotificationTests`) covers the plumbing contract via
|
||||||
|
the `FakeTwinCATClient.FakeNotification.MaxDelayMs` capture.
|
||||||
|
|
||||||
### 4. TC2 vs TC3 variant handling
|
### 4. TC2 vs TC3 variant handling
|
||||||
|
|
||||||
TwinCAT 2 (ADS v1) and TwinCAT 3 (ADS v2) have subtly different
|
TwinCAT 2 (ADS v1) and TwinCAT 3 (ADS v2) have subtly different
|
||||||
@@ -125,6 +151,104 @@ back an `IAlarmSource`, but shipping that is a separate feature.
|
|||||||
| "Do notifications coalesce under load?" | no | yes (required) |
|
| "Do notifications coalesce under load?" | no | yes (required) |
|
||||||
| "Does a TC2 PLC work the same as TC3?" | no | yes (required) |
|
| "Does a TC2 PLC work the same as TC3?" | no | yes (required) |
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
PR 2.1 (Sum-read / Sum-write, IndexGroup `0xF080..0xF084`) replaced the per-tag
|
||||||
|
`ReadValueAsync` loop in `TwinCATDriver.ReadAsync` / `WriteAsync` with a
|
||||||
|
bucketed bulk dispatch — N tags addressed against the same device flow through a
|
||||||
|
single ADS sum-command round-trip via `SumInstancePathAnyTypeRead` (read) and
|
||||||
|
`SumWriteBySymbolPath` (write). Whole-array tags + bit-extracted BOOL tags
|
||||||
|
remain on the per-tag fallback path because the sum surface only marshals
|
||||||
|
scalars and bit-RMW writes need the per-parent serialisation lock.
|
||||||
|
|
||||||
|
**Baseline → Sum-command delta** (dev box, 1000 × DINT, XAR VM over LAN):
|
||||||
|
|
||||||
|
| Path | Round-trips | Wall-clock |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Per-tag loop (pre-PR 2.1) | 1000 | ~5–8 s |
|
||||||
|
| Sum-command bulk (PR 2.1) | 1 | ~250–600 ms |
|
||||||
|
| Ratio | — | ≥ 10× typical, ≥ 5× CI floor |
|
||||||
|
|
||||||
|
The perf-tier test
|
||||||
|
`TwinCATSumCommandPerfTests.Driver_sum_read_1000_tags_beats_loop_baseline_by_5x`
|
||||||
|
asserts the ratio with a conservative 5× lower bound that survives noisy CI /
|
||||||
|
VM scheduling. It is gated behind both `TWINCAT_TARGET_NETID` (XAR reachable)
|
||||||
|
and `TWINCAT_PERF=1` (operator opt-in) — perf runs aren't part of the default
|
||||||
|
integration pass because they hit the wire heavily.
|
||||||
|
|
||||||
|
The required fixture state (1000-DINT GVL + churn POU) is documented in
|
||||||
|
`TwinCatProject/README.md §Performance scenarios`; XAE-form sources land at
|
||||||
|
`TwinCatProject/PLC/GVLs/GVL_Perf.TcGVL` + `TwinCatProject/PLC/POUs/FB_PerfChurn.TcPOU`.
|
||||||
|
|
||||||
|
### Handle caching (PR 2.2)
|
||||||
|
|
||||||
|
Per-tag reads / writes route through an in-process ADS variable-handle cache.
|
||||||
|
The first read of a symbol resolves a handle via `CreateVariableHandleAsync`;
|
||||||
|
subsequent reads / writes of the same symbol issue against the cached handle.
|
||||||
|
On the wire this trades a multi-byte symbolic path (`GVL_Perf.aTags[742]` =
|
||||||
|
20+ bytes) for a 4-byte handle, and the device server skips name resolution
|
||||||
|
on every subsequent op. Cache lifetime is process-scoped; entries are evicted
|
||||||
|
on `AdsErrorCode.DeviceSymbolVersionInvalid` (with one retry against a fresh
|
||||||
|
handle), wiped on reconnect (handles are per-AMS-session), and deleted
|
||||||
|
best-effort on driver disposal.
|
||||||
|
|
||||||
|
`TwinCATHandleCachePerfTests.Driver_handle_cache_avoids_repeat_symbol_resolution`
|
||||||
|
asserts the contract on real XAR by reading 50 symbols twice and verifying
|
||||||
|
the second pass issues zero new `CreateVariableHandleAsync` calls. It runs
|
||||||
|
under the standard `[TwinCATFact]` gate (XAR reachable; no `TWINCAT_PERF`
|
||||||
|
opt-in needed because 50 symbols is cheap).
|
||||||
|
|
||||||
|
**Self-invalidation (PR 2.3)**: handle cache is now self-invalidating on
|
||||||
|
TwinCAT online changes. `AdsTwinCATClient` registers an
|
||||||
|
`AdsSymbolVersionChanged` event listener (Beckhoff's high-level wrapper
|
||||||
|
around the SymbolVersion ADS notification, IndexGroup `0xF008`) on connect;
|
||||||
|
when the PLC's symbol-version counter increments — full re-init after a
|
||||||
|
download / activate-config — the listener fires and wipes the handle cache
|
||||||
|
proactively. Three-layered defence in depth: (1) proactive listener
|
||||||
|
preempts the next read entirely on full re-inits, (2) the
|
||||||
|
`DeviceSymbolVersionInvalid` evict-and-retry path from PR 2.2 catches the
|
||||||
|
narrower "symbol survives but its descriptor moved" race, and (3)
|
||||||
|
operators can still call `ITwinCATClient.FlushOptionalCachesAsync` manually
|
||||||
|
for the truly-paranoid case. The bulk Sum-read / Sum-write path remains
|
||||||
|
on symbolic paths in PR 2.2 (the bulk path's per-call symbol resolution
|
||||||
|
is already amortised across N tags; the perf delta vs. handle-batched
|
||||||
|
bulk is marginal — tracked as a follow-up for the Phase-2 perf sweep).
|
||||||
|
|
||||||
|
## Diagnostics
|
||||||
|
|
||||||
|
PR 3.2 (#314) augments the probe loop. On every successful tick (post `ReadStateAsync`)
|
||||||
|
the driver also reads four well-known system symbols off the AMS target and stashes
|
||||||
|
them on `DeviceState.LastDiagnostics` as a `TwinCATDeviceDiagnostics` record. The same
|
||||||
|
snapshot is folded into `DriverHealth.Diagnostics` so the cross-driver
|
||||||
|
`driver-diagnostics` RPC (added for Modbus, task #154) renders TwinCAT cycle-time /
|
||||||
|
jitter / online-change counters next to its peers without a per-driver special-case.
|
||||||
|
|
||||||
|
| Symbol | Type | Diagnostic key | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `TwinCAT_SystemInfoVarList._AppInfo.AppName` | `STRING(80)` | (record only) | Running PLC project name, e.g. `"Plc1"` |
|
||||||
|
| `TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt` | `UDINT` | `TwinCAT.OnlineChangeCnt` | Increments on every accepted online change; informational |
|
||||||
|
| `TwinCAT_SystemInfoVarList._TaskInfo[1].CycleTime` | `UDINT` (100 ns ticks) | `TwinCAT.CycleTimeMs` | Configured task period after `÷10000` ms conversion |
|
||||||
|
| `TwinCAT_SystemInfoVarList._TaskInfo[1].LastExecTime` | `UDINT` (100 ns ticks) | `TwinCAT.LastExecTimeMs` | Wall-clock duration of the last task tick |
|
||||||
|
| (computed) | `double` | `TwinCAT.JitterMs` | `LastExecTimeMs - CycleTimeMs`; positive = overrun |
|
||||||
|
| (computed) | `long` | `TwinCAT.OnlineChangeIncrements` | Cumulative deltas observed since the driver started; only emitted once non-zero |
|
||||||
|
|
||||||
|
Each individual read is wrapped in best-effort try/catch. A runtime that doesn't
|
||||||
|
expose `_TaskInfo[1]` (older TwinCAT 2 builds, some soft-PLC implementations) still
|
||||||
|
produces a partial snapshot; the missing fields fall back to the previous tick's value
|
||||||
|
or the type default for the first probe tick. Wholesale failure of all four reads
|
||||||
|
leaves the previous snapshot in place and the next tick retries.
|
||||||
|
|
||||||
|
Single-device deployments produce flat keys (`TwinCAT.CycleTimeMs`); multi-device
|
||||||
|
deployments prefix with the AMS host address (`TwinCAT.<hostAddress>.CycleTimeMs`)
|
||||||
|
so the readout is unambiguous when one driver instance owns multiple AMS targets.
|
||||||
|
|
||||||
|
Wire-level coverage lives in
|
||||||
|
`TwinCATDiagnosticsIntegrationTests.Probe_loop_surfaces_cycle_time_and_online_change_count`
|
||||||
|
(asserts `CycleTimeMs > 0` + `OnlineChangeCnt >= 0` within one probe interval against a
|
||||||
|
reachable XAR runtime). Unit-level coverage of the dictionary shape, the per-symbol
|
||||||
|
try/catch, and the multi-device prefixing lives in `TwinCATDeviceDiagnosticsTests` —
|
||||||
|
the `FakeTwinCATClient.SetSystemSymbolValue` helper drives the surface deterministically.
|
||||||
|
|
||||||
## Follow-up candidates
|
## Follow-up candidates
|
||||||
|
|
||||||
1. **XAR VM live-population** — scaffolding is in place (this PR); the
|
1. **XAR VM live-population** — scaffolding is in place (this PR); the
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# OPC UA Server — Component Requirements
|
# OPC UA Server — Component Requirements
|
||||||
|
|
||||||
> **Revision** — Refreshed 2026-04-19 for the OtOpcUa v2 multi-driver platform (task #205). OPC-001…OPC-013 have been rewritten driver-agnostically — they now describe how the core OPC UA server composes multiple driver subtrees, enforces authorization, and invokes capabilities through the Polly-wrapped dispatch path. OPC-014 through OPC-022 are new and cover capability dispatch, per-host Polly isolation, idempotence-aware write retry, `AuthorizationGate`, `ServiceLevel` reporting, the alarm surface, history surface, server-certificate management, and the transport-security profile matrix. Galaxy-specific behavior has been moved out to `GalaxyRepositoryReqs.md` and `MxAccessClientReqs.md`.
|
> **Revision** — Refreshed 2026-04-19 for the OtOpcUa v2 multi-driver platform (task #205). OPC-001…OPC-013 have been rewritten driver-agnostically — they now describe how the core OPC UA server composes multiple driver subtrees, enforces authorization, and invokes capabilities through the Polly-wrapped dispatch path. OPC-014 through OPC-019 are new and cover `AuthorizationGate` + permission trie, dynamic `ServiceLevel` reporting, session management, surgical address-space rebuild on generation apply, server diagnostics nodes, and OpenTelemetry observability hooks. Capability dispatch (OPC-012), per-host Polly isolation (OPC-013), idempotence-aware write retry (OPC-006 + OPC-012), the alarm surface (OPC-008), the history surface (OPC-009), and the transport-security / server-certificate profile matrix (OPC-010) are folded into the renumbered body above. Galaxy-specific behavior has been moved out to `GalaxyRepositoryReqs.md` and `MxAccessClientReqs.md`.
|
||||||
|
>
|
||||||
|
> **Reserved** — OPC-020, OPC-021, and OPC-022 are intentionally unallocated and held for future use. An earlier draft of this revision header listed them; no matching requirement bodies were ever pinned down because the scope they were meant to hold is already covered by OPC-006/008/009/010/012/013. Do not recycle these IDs for unrelated requirements without a deliberate renumbering pass.
|
||||||
|
|
||||||
Parent: [HLR-001](HighLevelReqs.md#hlr-001-opc-ua-server), [HLR-003](HighLevelReqs.md#hlr-003-address-space-composition-per-namespace), [HLR-009](HighLevelReqs.md#hlr-009-transport-security-and-authentication), [HLR-010](HighLevelReqs.md#hlr-010-per-driver-instance-resilience), [HLR-013](HighLevelReqs.md#hlr-013-cluster-redundancy)
|
Parent: [HLR-001](HighLevelReqs.md#hlr-001-opc-ua-server), [HLR-003](HighLevelReqs.md#hlr-003-address-space-composition-per-namespace), [HLR-009](HighLevelReqs.md#hlr-009-transport-security-and-authentication), [HLR-010](HighLevelReqs.md#hlr-010-per-driver-instance-resilience), [HLR-013](HighLevelReqs.md#hlr-013-cluster-redundancy)
|
||||||
|
|
||||||
|
|||||||
73
docs/v2/decisions.md
Normal file
73
docs/v2/decisions.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Decisions
|
||||||
|
|
||||||
|
Architecture-level decisions taken during the v2 implementation, captured
|
||||||
|
once and referenced from feature docs / PR descriptions / ADR-style
|
||||||
|
follow-ups. Each entry lists the decision, the alternatives we considered,
|
||||||
|
and the rationale that tipped the call.
|
||||||
|
|
||||||
|
## FOCAS write-path opt-in
|
||||||
|
|
||||||
|
**Issue:** [#268](https://github.com/dohertj2/lmxopcua/issues/268). **Plan PR:** F4-a.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
The FOCAS driver ships writes behind two independent opt-ins, both default
|
||||||
|
off:
|
||||||
|
|
||||||
|
1. **Driver-level master switch** — `FocasDriverOptions.Writes.Enabled`,
|
||||||
|
default `false`. When off, every entry in a `WriteAsync` batch short-
|
||||||
|
circuits to `BadNotWritable` with status text `writes disabled at
|
||||||
|
driver level`. The wire client is never touched.
|
||||||
|
2. **Per-tag opt-in** — `FocasTagDefinition.Writable`, default `false`
|
||||||
|
(flipped from `true` in F4-a). A `Writable = false` tag returns
|
||||||
|
`BadNotWritable` even when the driver-level flag is on.
|
||||||
|
|
||||||
|
`BadNotSupported` is reserved for kinds the wire client hasn't yet
|
||||||
|
implemented; F4-b/c land actual macro / parameter / PMC writes that
|
||||||
|
currently dispatch to `BadNotSupported` (or to `Good` against the F4-a
|
||||||
|
fake) for unimplemented branches.
|
||||||
|
|
||||||
|
### Alternatives considered
|
||||||
|
|
||||||
|
- **Always-on writes (the pre-F4-a default).** Rejected: a single
|
||||||
|
misconfigured tag flipping `Writable = true` by accident would let an
|
||||||
|
operator overwrite a CNC parameter from any OPC UA client. The two-
|
||||||
|
opt-in posture means an accidental tag flip alone isn't enough.
|
||||||
|
- **Driver-level switch only.** Rejected: doesn't protect against an
|
||||||
|
operator with admin rights flipping the master switch to do bulk diag
|
||||||
|
reads but inheriting write capability for tags that were intended
|
||||||
|
read-only.
|
||||||
|
- **Per-tag opt-in only.** Rejected: doesn't give the deployment an "all
|
||||||
|
writes off" emergency lever — useful during a CNC commissioning where
|
||||||
|
writes are unsafe across the board for a period.
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
CNC writes are non-idempotent in the field's worst-case shape: feed
|
||||||
|
overrides, M-code pulses, alarm acks, recipe-step advances. Two opt-ins
|
||||||
|
is the cheapest defence-in-depth posture that still lets writes ship.
|
||||||
|
Both default off so a fresh deployment is read-only — the explicit choice
|
||||||
|
to enable writes lands at config time where it's reviewable, not at
|
||||||
|
runtime where it's invisible.
|
||||||
|
|
||||||
|
`WriteIdempotent` plumbs through `CapabilityInvoker.ExecuteWriteAsync`
|
||||||
|
into the Polly retry pipeline; default `false` means failed writes are
|
||||||
|
not auto-retried (plan decisions #44 / #45). Per-tag flip required for
|
||||||
|
genuinely-idempotent writes.
|
||||||
|
|
||||||
|
### CLI carve-out
|
||||||
|
|
||||||
|
`otopcua-focas-cli write` sets `Writes.Enabled = true` locally for the
|
||||||
|
lifetime of one process and synthesises a `Writable = true` tag. The CLI
|
||||||
|
is a per-operator direct-to-CNC tool — not a long-lived process bound to
|
||||||
|
the central config DB. Configuring the server still requires both opt-ins
|
||||||
|
to be set explicitly in the DriverInstance JSON. The bypass is documented
|
||||||
|
in `docs/Driver.FOCAS.Cli.md` so operators understand the asymmetry.
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
Pre-F4-a deployments that relied on the `Writable = true` default need to
|
||||||
|
add `"Writable": true` to every tag they intend to write + an enclosing
|
||||||
|
`"Writes": { "Enabled": true }` block in their DriverInstance JSON.
|
||||||
|
Bootstrap rows seeded before F4-a get `Writable = false` after upgrade —
|
||||||
|
this is intentional; review-then-flip is the safer migration path.
|
||||||
321
docs/v2/focas-deployment.md
Normal file
321
docs/v2/focas-deployment.md
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
# FOCAS deployment guide
|
||||||
|
|
||||||
|
Per-driver runbook for deploying the FANUC FOCAS driver. See
|
||||||
|
[`docs/drivers/FOCAS.md`](../drivers/FOCAS.md) for the per-feature
|
||||||
|
reference and [`focas-version-matrix.md`](./focas-version-matrix.md) for
|
||||||
|
the per-CNC-series capability surface.
|
||||||
|
|
||||||
|
## Operator config-knob cheat sheet
|
||||||
|
|
||||||
|
| Knob | Where | Default | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `Devices[].HostAddress` | `FocasDriverOptions.Devices` | — | `focas://{ip}[:{port}]` |
|
||||||
|
| `Devices[].Series` | `FocasDriverOptions.Devices` | `Unknown` | Drives per-series range validation in `FocasCapabilityMatrix`. |
|
||||||
|
| `Devices[].OverrideParameters` | `FocasDriverOptions.Devices` | `null` | MTB-specific parameter numbers for Feed/Rapid/Spindle/Jog overrides. `null` suppresses the `Override/` subtree. |
|
||||||
|
| `Probe.Enabled` | `FocasDriverOptions.Probe` | `true` | Background reachability probe. |
|
||||||
|
| `Probe.Interval` | `FocasDriverOptions.Probe` | `00:00:05` | Probe cadence. |
|
||||||
|
| `FixedTree.ApplyFigureScaling` | `FocasDriverOptions.FixedTree` | `true` | Divide position values by 10^decimal-places (issue #262). |
|
||||||
|
| **`AlarmProjection.Mode`** | **`FocasDriverOptions.AlarmProjection`** | **`ActiveOnly`** | **`ActiveOnly` keeps today's behaviour. `ActivePlusHistory` polls `cnc_rdalmhistry` on connect + on `HistoryPollInterval` ticks (issue #267, plan PR F3-a).** |
|
||||||
|
| **`AlarmProjection.HistoryPollInterval`** | **`FocasDriverOptions.AlarmProjection`** | **`00:05:00`** | **Cadence of the history poll. Operator dashboards run the default; high-frequency rigs can drop to 30 s.** |
|
||||||
|
| **`AlarmProjection.HistoryDepth`** | **`FocasDriverOptions.AlarmProjection`** | **`100`** | **Most-recent-N ring-buffer entries pulled per poll. Hard-capped at `250` so misconfigured values can't blast the wire session.** |
|
||||||
|
|
||||||
|
## Sample `appsettings.json` snippet for `ActivePlusHistory`
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"Drivers": {
|
||||||
|
"FOCAS": {
|
||||||
|
"Devices": [
|
||||||
|
{ "HostAddress": "focas://10.0.0.5:8193", "Series": "Series30i" }
|
||||||
|
],
|
||||||
|
"AlarmProjection": {
|
||||||
|
"Mode": "ActivePlusHistory",
|
||||||
|
"HistoryPollInterval": "00:05:00",
|
||||||
|
"HistoryDepth": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The history projection emits each unseen entry through
|
||||||
|
`IAlarmSource.OnAlarmEvent` with `SourceTimestampUtc` set from the CNC's
|
||||||
|
reported wall-clock — keep CNC clocks on UTC so the dedup key
|
||||||
|
`(OccurrenceTime, AlarmNumber, AlarmType)` stays stable across DST
|
||||||
|
transitions.
|
||||||
|
|
||||||
|
## Derived telemetry — issue #272 (plan PR F5-a)
|
||||||
|
|
||||||
|
The `Production/` subtree gains two **derived** nodes alongside the four
|
||||||
|
F1-b wire-sourced fields:
|
||||||
|
|
||||||
|
- `Production/LastCycleSeconds` (`Float64`)
|
||||||
|
- `Production/LastCycleStartUtc` (`DateTime` UTC)
|
||||||
|
|
||||||
|
**No new wire calls.** Both nodes are computed client-visible from the
|
||||||
|
same `cnc_rdparam(6711)` + `cnc_rdtimer` poll the F1-b projection
|
||||||
|
already runs on every probe tick. There is no per-device knob — the
|
||||||
|
nodes are present for every CNC the driver connects to and surface
|
||||||
|
`null` until the second observed parts-count increment produces the
|
||||||
|
first delta.
|
||||||
|
|
||||||
|
This means:
|
||||||
|
|
||||||
|
- **No additional CNC load.** Probe-tick wire traffic is unchanged.
|
||||||
|
- **No new opt-in.** The nodes ship enabled by default and are
|
||||||
|
read-only (`SecurityClassification.ViewOnly`); no LDAP group needs
|
||||||
|
the new permission.
|
||||||
|
- **Reconnect re-baselines.** Per the FWLIB session boundary the
|
||||||
|
derivation state resets on reconnect / reinit, so the first cycle
|
||||||
|
observed after a reconnect re-establishes the baseline before
|
||||||
|
publishing the first post-reconnect delta.
|
||||||
|
|
||||||
|
See [`docs/drivers/FOCAS.md`](../drivers/FOCAS.md) § "Fixed-tree
|
||||||
|
`Production/` projection" for the full edge-case behaviour matrix
|
||||||
|
(parts-count counter reset, cycle-timer rollover, parts-count jumps
|
||||||
|
> 1).
|
||||||
|
|
||||||
|
## Write safety — issue #269 (PARAM/MACRO, F4-b) + issue #270 (PMC, F4-c)
|
||||||
|
|
||||||
|
The FOCAS driver supports `cnc_wrparam`, `cnc_wrmacro`, and `pmc_wrpmcrng`
|
||||||
|
writes behind multiple independent opt-ins. A misdirected parameter write
|
||||||
|
can put the CNC in a bad state; a misdirected PMC write can move motion or
|
||||||
|
latch a feedhold. The runbook below MUST be followed before flipping any
|
||||||
|
of the granular kill switches on.
|
||||||
|
|
||||||
|
### Operator pre-checks (every deployment, every change)
|
||||||
|
|
||||||
|
1. **CNC must be in MDI mode.** Most parameter writes fail with `EW_PASSWD`
|
||||||
|
(surfaces as `BadUserAccessDenied`) unless the CNC is in MDI. The
|
||||||
|
server-side write returns immediately with the access-denied status; no
|
||||||
|
value reaches the wire.
|
||||||
|
2. **Parameter-write switch enabled on the CNC pendant.** Even in MDI mode
|
||||||
|
protected parameters require the operator to physically enable the
|
||||||
|
parameter-write switch. Without it `cnc_wrparam` returns `EW_PASSWD`.
|
||||||
|
Plan PR F4-d will land an OPC UA-side unlock workflow; today the only
|
||||||
|
path is the pendant.
|
||||||
|
3. **Verify each tag's address against the FANUC manual.** Ranges vary per
|
||||||
|
CNC series; the
|
||||||
|
[`focas-version-matrix`](./focas-version-matrix.md) capability matrix
|
||||||
|
rejects out-of-range numbers at startup, but address-vs-meaning is the
|
||||||
|
operator's job.
|
||||||
|
4. **Dry run with `Writable = true` but `Writes.AllowParameter = false`.**
|
||||||
|
Staged opt-in catches mis-mapped tags: every PARAM write returns
|
||||||
|
`BadNotWritable` until you flip the granular flag, so you can confirm
|
||||||
|
the tag list before any wire write fires.
|
||||||
|
|
||||||
|
### PMC pre-checks (in addition to the above) — F4-c
|
||||||
|
|
||||||
|
PMC writes have a higher blast radius than PARAM/MACRO writes because PMC
|
||||||
|
is the ladder's working memory — bits in R/G/F/D directly drive servo
|
||||||
|
enables, feedhold latches, and safety interlocks. Before flipping
|
||||||
|
`Writes.AllowPmc` on:
|
||||||
|
|
||||||
|
1. **E-stop verified live + reachable.** The first PMC write of a session
|
||||||
|
should be issued with the operator's hand on the e-stop. PMC writes
|
||||||
|
bypass the ladder's normal MDI-mode protections; a misdirected bit can
|
||||||
|
move motion the moment it lands on the wire.
|
||||||
|
2. **Machine in JOG mode (or equivalent low-energy mode).** Auto / MEM
|
||||||
|
modes interpret PMC state immediately; JOG / MDI surface symptoms
|
||||||
|
slowly enough that the e-stop is the recovery path. **Never issue the
|
||||||
|
first PMC write of a deployment in Auto.**
|
||||||
|
3. **Audit the PMC tag list against the ladder print-out.** `R100.3` on
|
||||||
|
one machine is "homing complete"; on another it's "feedhold released".
|
||||||
|
The driver has no way to distinguish — the ladder source is the only
|
||||||
|
ground truth.
|
||||||
|
4. **Bit writes are read-modify-write — see
|
||||||
|
[`docs/drivers/FOCAS.md`](../drivers/FOCAS.md) "PMC bit-write read-modify-write semantics".**
|
||||||
|
`pmc_wrpmcrng` is byte-addressed; the driver reads the parent byte
|
||||||
|
first, masks the target bit, and writes the byte back. Concurrent
|
||||||
|
ladder writes to the same byte create a small race window. Coordinate
|
||||||
|
through a ladder-side handshake when this matters.
|
||||||
|
5. **Dry run with `Writable = true` but `Writes.AllowPmc = false`.** Same
|
||||||
|
staged-opt-in pattern as PARAM/MACRO — confirm tag mapping before any
|
||||||
|
PMC byte hits the wire.
|
||||||
|
|
||||||
|
### LDAP group requirements
|
||||||
|
|
||||||
|
Per [`docs/security.md`](../security.md) the server-layer ACL maps
|
||||||
|
`SecurityClassification` to LDAP groups. Post-F4-b:
|
||||||
|
|
||||||
|
| Tag kind | LDAP group required |
|
||||||
|
| --- | --- |
|
||||||
|
| `PARAM:N` (writable) | **`WriteConfigure`** — heaviest write tier; matches commissioning roles |
|
||||||
|
| `MACRO:N` (writable) | `WriteOperate` — standard HMI recipe / setpoint group |
|
||||||
|
| PMC R/G/F (writable) | `WriteOperate` |
|
||||||
|
| Read-only | `ReadOnly` |
|
||||||
|
|
||||||
|
Per the `feedback_acl_at_server_layer` design note, the FOCAS driver
|
||||||
|
declares the classification but does NOT enforce it; `DriverNodeManager`
|
||||||
|
applies the gate before the driver's `WriteAsync` ever runs. A user
|
||||||
|
without `WriteConfigure` who attempts a `PARAM:` write gets
|
||||||
|
`BadUserAccessDenied` from the server with no driver-level audit entry —
|
||||||
|
the OPC UA layer's audit log catches it.
|
||||||
|
|
||||||
|
### Audit-log expectations
|
||||||
|
|
||||||
|
Every successful write produces:
|
||||||
|
|
||||||
|
- An OPC UA AuditWriteEvent (server layer — see
|
||||||
|
[`docs/security.md`](../security.md) "Audit logging").
|
||||||
|
- A FOCAS driver-level Serilog entry tagged `Driver=FOCAS DriverInstanceId=...
|
||||||
|
TagName=... Address=... ResultStatus=...`.
|
||||||
|
- A `Writes/LastWriteAt` and `Writes/LastWriteStatus` diagnostic counter
|
||||||
|
refresh on the device's `Diagnostics/` fixed-tree node (planned;
|
||||||
|
populated as F4-c lands).
|
||||||
|
|
||||||
|
Failures to write (`BadUserAccessDenied`, `BadCommunicationError`, etc.)
|
||||||
|
produce the same audit entries with the failure status code so a
|
||||||
|
post-incident reviewer sees the same shape regardless of whether the write
|
||||||
|
succeeded.
|
||||||
|
|
||||||
|
**Audit PMC writes specifically.** Because PMC writes have the highest blast
|
||||||
|
radius of the three write kinds, ops should set up a saved-search /
|
||||||
|
dashboard query for `Driver=FOCAS` + `Address` matching the PMC letter
|
||||||
|
prefixes (`R*`, `G*`, `F*`, `D*`, `Y*`, etc.) and review on the same
|
||||||
|
cadence as ladder change reviews. A spike in PMC write rate or a write
|
||||||
|
to an address outside the audited tag list is the leading indicator of a
|
||||||
|
misconfigured client or compromised credential.
|
||||||
|
|
||||||
|
### Granular config example
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"Drivers": {
|
||||||
|
"FOCAS": {
|
||||||
|
"Devices": [
|
||||||
|
{ "HostAddress": "focas://10.0.0.5:8193", "Series": "Series30i" }
|
||||||
|
],
|
||||||
|
"Writes": {
|
||||||
|
"Enabled": true,
|
||||||
|
"AllowMacro": true, // recipe / setpoint writes — operator role
|
||||||
|
"AllowParameter": false, // commissioning only — keep locked except during planned work
|
||||||
|
"AllowPmc": false // PMC writes — keep locked unless the deployment specifically needs them
|
||||||
|
},
|
||||||
|
"Tags": [
|
||||||
|
{ "Name": "Recipe.PartCount", "DeviceHostAddress": "focas://10.0.0.5:8193",
|
||||||
|
"Address": "MACRO:500", "DataType": "Int32",
|
||||||
|
"Writable": true, "WriteIdempotent": true },
|
||||||
|
{ "Name": "MaxFeedrate", "DeviceHostAddress": "focas://10.0.0.5:8193",
|
||||||
|
"Address": "PARAM:1815", "DataType": "Int32",
|
||||||
|
"Writable": false /* keep read-only until commissioning window */ },
|
||||||
|
{ "Name": "OperatorRequest", "DeviceHostAddress": "focas://10.0.0.5:8193",
|
||||||
|
"Address": "R100.3", "DataType": "Bit",
|
||||||
|
"Writable": false /* keep PMC read-only until ladder handshake reviewed */ }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Flipping `AllowParameter` / `AllowPmc` on for the commissioning window
|
||||||
|
(and back off afterward) is the recommended deployment cadence — the
|
||||||
|
granular kill switches are lightweight runtime toggles, not config-DB
|
||||||
|
redeploys. PMC in particular should default OFF in production and only
|
||||||
|
flip on for windows where the ladder team has signed off on the write
|
||||||
|
path.
|
||||||
|
|
||||||
|
## FOCAS password handling — issue #271 (F4-d)
|
||||||
|
|
||||||
|
Some controllers (16i + certain 30i firmwares with parameter-protect on)
|
||||||
|
gate `cnc_wrparam` and selected reads behind a connection-level password.
|
||||||
|
The driver supports this via the `Password` field on `FocasDeviceOptions`
|
||||||
|
which is emitted via `cnc_wrunlockparam` on connect and re-emitted on any
|
||||||
|
`EW_PASSWD` read/write retry path. See
|
||||||
|
[`docs/drivers/FOCAS.md`](../drivers/FOCAS.md) § "FOCAS password" for the
|
||||||
|
driver-side behaviour; this section covers the deployment side.
|
||||||
|
|
||||||
|
### Storage in `appsettings.json`
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"Drivers": {
|
||||||
|
"Focas01": {
|
||||||
|
"DriverConfigJson": {
|
||||||
|
"Backend": "fwlib",
|
||||||
|
"Series": "Sixteen_i",
|
||||||
|
"Devices": [
|
||||||
|
{
|
||||||
|
"HostAddress": "focas://10.0.0.5:8193",
|
||||||
|
"Password": "1234"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For dev environments, the password is materialised under
|
||||||
|
`.local/focas-passwords.txt` (or whichever .local subkey the deployment
|
||||||
|
team prefers); production deployments use the same secrets-store /
|
||||||
|
KeyVault pattern the LDAP `Authentication.Ldap.Password` field follows.
|
||||||
|
**The `.local/` directory is .gitignore'd** — this is the same posture
|
||||||
|
as `.local/galaxy-host-secret.txt` and other dev secrets in this repo.
|
||||||
|
|
||||||
|
### No-log invariant
|
||||||
|
|
||||||
|
The driver guarantees the password is **never logged**:
|
||||||
|
|
||||||
|
1. **`FocasDeviceOptions` ToString redaction.** The record overrides
|
||||||
|
`PrintMembers` so any Serilog destructure of the device options renders
|
||||||
|
`Password = ***` when the field is non-null. This catches the most
|
||||||
|
common leak path — a structured-log statement that included
|
||||||
|
`{@Device}` for diagnostic context.
|
||||||
|
2. **No password in exception messages.** `FwlibFocasClient.UnlockAsync`
|
||||||
|
omits the password from its `InvalidOperationException` text — only
|
||||||
|
the FWLIB error code (`EW_PASSWD`, `EW_HANDLE`, etc.) makes it through.
|
||||||
|
3. **Driver log line uses host only.** When unlock succeeds the driver
|
||||||
|
updates `DriverHealth.StatusText` to `"FOCAS unlock applied for
|
||||||
|
{host}"` — no password.
|
||||||
|
4. **CLI flag covered by the same choke point.** The
|
||||||
|
`Driver.FOCAS.Cli --cnc-password` flag flows through
|
||||||
|
`FocasDeviceOptions.Password`, so its redaction is identical to the
|
||||||
|
server's. The PowerShell e2e harness (`scripts/e2e/test-focas.ps1
|
||||||
|
-CncPassword`) follows the same path.
|
||||||
|
|
||||||
|
Any new logging surface that touches `FocasDeviceOptions` MUST continue
|
||||||
|
to use the record's `ToString` (or otherwise omit `Password`). A code
|
||||||
|
review checklist item: "no log statement contains `device.Options.Password`
|
||||||
|
or `device.Password` directly."
|
||||||
|
|
||||||
|
### Password-rotation runbook
|
||||||
|
|
||||||
|
When the CNC password rotates (operator team flipped a parameter-protect
|
||||||
|
gate, or your security policy requires periodic rotation):
|
||||||
|
|
||||||
|
1. **Update the password on the controller** (CNC pendant or vendor's
|
||||||
|
admin tool). The exact path varies by series — Fanuc service manual
|
||||||
|
page reference depends on the MTB.
|
||||||
|
2. **Update `appsettings.json`** in place with the new value.
|
||||||
|
- Production: bump the secrets-store entry that backs the
|
||||||
|
`Devices[*].Password` config-DB column. Same workflow as rotating
|
||||||
|
the LDAP service-account password.
|
||||||
|
- Dev: update `.local/focas-passwords.txt` (or wherever the dev
|
||||||
|
deployment sources the secret).
|
||||||
|
3. **Restart the OtOpcUa server** (or trigger a config-DB bump that
|
||||||
|
forces driver reinitialise). The driver picks up the new password
|
||||||
|
on the next `EnsureConnectedAsync` call. **No need to manually
|
||||||
|
reconnect each device** — `cnc_wrunlockparam` emits on the next
|
||||||
|
wire-call boundary.
|
||||||
|
4. **Verify**. The first wire call after restart logs
|
||||||
|
`"FOCAS unlock applied for focas://{host}:{port}"` at info. A wrong
|
||||||
|
password surfaces as `BadUserAccessDenied` on the next gated read or
|
||||||
|
write.
|
||||||
|
5. **Audit.** OPC UA wrote-event entries (per
|
||||||
|
[`audit-log-rules.md`](audit-log-rules.md)) cover the
|
||||||
|
parameter/macro write paths. Password rotation itself is NOT logged
|
||||||
|
beyond "unlock applied" — same posture as LDAP service-account
|
||||||
|
rotation, where the password change is logged out-of-band by the IAM
|
||||||
|
system.
|
||||||
|
|
||||||
|
### Cross-references
|
||||||
|
|
||||||
|
- [`docs/Security.md`](../Security.md) — server-wide secrets handling +
|
||||||
|
the same `.local/` pattern used for LDAP and the Galaxy.Host pipe
|
||||||
|
secret. The FOCAS password follows the same posture.
|
||||||
|
- [`docs/drivers/FOCAS.md`](../drivers/FOCAS.md) § "FOCAS password" —
|
||||||
|
driver-side behaviour, EW_PASSWD retry semantics, status-code
|
||||||
|
surface.
|
||||||
|
- [`docs/v2/implementation/focas-wire-protocol.md`](implementation/focas-wire-protocol.md)
|
||||||
|
§ "cnc_wrunlockparam" — wire-frame layout for the password buffer.
|
||||||
79
docs/v2/implementation/exit-gate-phase-7.md
Normal file
79
docs/v2/implementation/exit-gate-phase-7.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# 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.
|
||||||
|
>
|
||||||
|
> **Compliance script**: `scripts/compliance/phase-7-compliance.ps1`
|
||||||
|
> **Plan doc**: `docs/v2/implementation/phase-7-scripting-and-alarming.md`
|
||||||
|
|
||||||
|
## What shipped
|
||||||
|
|
||||||
|
| Stream | PR | Summary |
|
||||||
|
|--------|-----|---------|
|
||||||
|
| A | #177–#179 | `Core.Scripting` — Roslyn sandbox + `DependencyExtractor` + `ForbiddenTypeAnalyzer` + per-script Serilog sink + 63 tests |
|
||||||
|
| B | #180 | `Core.VirtualTags` — dep graph (iterative Tarjan) + engine + timer scheduler + `VirtualTagSource` + 36 tests |
|
||||||
|
| C | #181 | `Core.ScriptedAlarms` — Part 9 state machine + predicate engine + message template + `ScriptedAlarmSource` + 47 tests |
|
||||||
|
| D | #182 | `Core.AlarmHistorian` — SQLite store-and-forward + backoff ladder + dead-letter retention + Galaxy.Host IPC contracts + 14 tests |
|
||||||
|
| E | #183 | Config DB schema — `Script` / `VirtualTag` / `ScriptedAlarm` / `ScriptedAlarmState` entities + migration + 12 tests |
|
||||||
|
| F | #185 | Admin UI — `ScriptService` / `VirtualTagService` / `ScriptedAlarmService` / `ScriptTestHarnessService` / `HistorianDiagnosticsService` + Monaco editor + `/alarms/historian` page + 13 tests |
|
||||||
|
| G | #184 | Walker emits Virtual + ScriptedAlarm variables with `NodeSourceKind` discriminator + 5 tests |
|
||||||
|
| G follow-up | #186 | `DriverNodeManager` dispatch routes by `NodeSourceKind` + writes rejected for non-Driver sources + 7 tests |
|
||||||
|
|
||||||
|
**Phase 7 totals**: ~197 new tests across 7 projects. Plan decisions #1–#22 all realised in code.
|
||||||
|
|
||||||
|
## Compliance Checks (run at exit gate)
|
||||||
|
|
||||||
|
Covered by `scripts/compliance/phase-7-compliance.ps1`:
|
||||||
|
|
||||||
|
- [x] Roslyn sandbox anchored on `ScriptContext` assembly with `ForbiddenTypeAnalyzer` defense-in-depth (plan #6)
|
||||||
|
- [x] `DependencyExtractor` rejects non-literal tag paths with source spans (plan #7)
|
||||||
|
- [x] Per-script rolling Serilog sink + companion-forwarding Error+ to main log (plan #12)
|
||||||
|
- [x] VirtualTag dep graph uses iterative SCC — no stack overflow on 10 000-deep chains
|
||||||
|
- [x] `VirtualTagSource` implements `IReadable` + `ISubscribable` per ADR-002
|
||||||
|
- [x] Part 9 state machine covers every transition (Apply/Ack/Confirm/Shelve/Unshelve/Enable/Disable/Comment/ShelvingCheck)
|
||||||
|
- [x] `AlarmPredicateContext` rejects `SetVirtualTag` at runtime (predicates must be pure)
|
||||||
|
- [x] `MessageTemplate` substitutes `{TagPath}` tokens at event emission (plan #13); missing/bad → `{?}`
|
||||||
|
- [x] SQLite sink backoff ladder 1s → 2s → 5s → 15s → 60s cap (plan #16)
|
||||||
|
- [x] Default 1M-row capacity + 30-day dead-letter retention (plan #21)
|
||||||
|
- [x] Per-event outcomes Ack/RetryPlease/PermanentFail on the wire
|
||||||
|
- [x] Galaxy.Host IPC contracts (`HistorianAlarmEventRequest` / `Response` / `ConnectivityStatusNotification`)
|
||||||
|
- [x] Config DB check constraints: trigger-required, timer-min, severity-range, alarm-type-enum, JSON comments
|
||||||
|
- [x] `ScriptedAlarmState` keyed on `ScriptedAlarmId` (not generation-scoped) per plan #14
|
||||||
|
- [x] Admin services: SourceHash preserves compile-cache hit on rename; Update recomputes on source change
|
||||||
|
- [x] `ScriptTestHarnessService` enforces declared-inputs-only contract (plan #22)
|
||||||
|
- [x] Monaco editor via CDN + textarea fallback (plan #18)
|
||||||
|
- [x] `/alarms/historian` page with Retry-dead-lettered operator action
|
||||||
|
- [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
|
||||||
|
|
||||||
|
Kept out of the capstone so the gate can close cleanly while the less-critical wiring lands in targeted PRs:
|
||||||
|
|
||||||
|
- [ ] **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.
|
||||||
|
|
||||||
|
## Completion Checklist
|
||||||
|
|
||||||
|
- [x] Stream A shipped + merged
|
||||||
|
- [x] Stream B shipped + merged
|
||||||
|
- [x] Stream C shipped + merged
|
||||||
|
- [x] Stream D shipped + merged
|
||||||
|
- [x] Stream E shipped + merged
|
||||||
|
- [x] Stream F shipped + merged
|
||||||
|
- [x] Stream G shipped + merged
|
||||||
|
- [x] Stream G follow-up (dispatch) shipped + merged
|
||||||
|
- [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
|
||||||
|
|
||||||
|
## How to run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh ./scripts/compliance/phase-7-compliance.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit code 0 = all pass; non-zero = failures listed in the preceding `[FAIL]` lines.
|
||||||
394
docs/v2/implementation/focas-simulator-plan.md
Normal file
394
docs/v2/implementation/focas-simulator-plan.md
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
# FOCAS simulator (focas-mock) plan
|
||||||
|
|
||||||
|
Notes on the focas-mock simulator that the FOCAS driver's integration
|
||||||
|
tests will eventually talk to. Today there is no FOCAS integration-test
|
||||||
|
project; this doc is the contract the future fixture will be built
|
||||||
|
against. Keeping the contract tracked in repo means the wire-protocol
|
||||||
|
command ids (and their request/response payloads) don't drift between the
|
||||||
|
.NET wire client and a future Python implementation.
|
||||||
|
|
||||||
|
## Ground rules
|
||||||
|
|
||||||
|
- Append-only command ids. Mirror
|
||||||
|
[`focas-wire-protocol.md`](./focas-wire-protocol.md) verbatim.
|
||||||
|
- Per-profile state. The simulator hosts N CNC profiles concurrently
|
||||||
|
(`Series0i`, `Series30i`, `PowerMotion`, ...). Each profile has its own
|
||||||
|
alarm-history ring buffer + its own override map.
|
||||||
|
- Admin endpoints under `POST /admin/...` mutate state without going
|
||||||
|
through the wire protocol; integration tests use these to seed canned
|
||||||
|
inputs.
|
||||||
|
|
||||||
|
## Protocol surface (current scope)
|
||||||
|
|
||||||
|
| Cmd | API | State impact |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `0x0001` | `cnc_rdcncstat` | reads cached ODBST per profile |
|
||||||
|
| `0x0002` | `cnc_rdparam` | reads parameter map per profile |
|
||||||
|
| `0x0003` | `cnc_rdmacro` | reads macro variables per profile |
|
||||||
|
| `0x0004` | `cnc_rddiag` | reads diagnostic map per profile |
|
||||||
|
| `0x0010` | `pmc_rdpmcrng` | reads PMC byte ranges |
|
||||||
|
| `0x0020` | `cnc_modal` | reads cached modal MSTB per profile |
|
||||||
|
| ... | ... | ... |
|
||||||
|
| **`0x0102`** | **`cnc_wrparam`** | **mutates per-profile parameter map; returns `EW_PASSWD` (`11`) when the profile's `unlock_state` is off (sets up F4-d's unlock workflow) — issue #269, plan PR F4-b** |
|
||||||
|
| **`0x0103`** | **`cnc_wrmacro`** | **mutates per-profile macro map; integer-only writes for now (decimalPointCount=0) — issue #269, plan PR F4-b** |
|
||||||
|
| **`0x0104`** | **`pmc_wrpmcrng`** | **mutates per-profile PMC byte tables; byte-aligned writes preserve untouched bytes; bit-level writes never reach the simulator (driver wraps with RMW) — issue #270, plan PR F4-c** |
|
||||||
|
| **`0x0105`** | **`cnc_wrunlockparam`** | **flips the per-profile `unlock_state` to true when the supplied 4-byte password buffer matches the profile's `unlock_password`; otherwise returns `EW_PASSWD`. State persists for the connection lifetime (per-session). — issue #271, plan PR F4-d** |
|
||||||
|
| **`0x0F1A`** | **`cnc_rdalmhistry`** | **dumps the per-profile alarm-history ring buffer (issue #267, plan PR F3-a)** |
|
||||||
|
|
||||||
|
## `cnc_rdalmhistry` mock behaviour
|
||||||
|
|
||||||
|
The simulator keeps a per-profile ring buffer of alarm-history entries.
|
||||||
|
Default fixture seeds 5 profiles with 10 canned entries each (per the F3-a
|
||||||
|
plan).
|
||||||
|
|
||||||
|
### Request decode
|
||||||
|
|
||||||
|
```
|
||||||
|
[int16 LE depth]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response encode
|
||||||
|
|
||||||
|
Use `FocasAlarmHistoryDecoder.Encode` semantics in reverse: emit the
|
||||||
|
count followed by `ALMHIS_data` blocks padded to 4-byte boundaries. The
|
||||||
|
.NET-side decoder consumes the same format verbatim, so a Python encoder
|
||||||
|
written against the table in
|
||||||
|
[`focas-wire-protocol.md`](./focas-wire-protocol.md) interoperates without
|
||||||
|
extra glue.
|
||||||
|
|
||||||
|
### Admin endpoint — `POST /admin/mock_patch_alarmhistory`
|
||||||
|
|
||||||
|
Replaces the alarm-history ring buffer for a profile.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /admin/mock_patch_alarmhistory
|
||||||
|
{
|
||||||
|
"profile": "Series30i",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"occurrenceTime": "2025-04-01T09:30:00Z",
|
||||||
|
"axisNo": 1,
|
||||||
|
"alarmType": 2,
|
||||||
|
"alarmNumber": 100,
|
||||||
|
"message": "Spindle overload"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`entries` order is interpreted as ring-buffer order (most-recent first to
|
||||||
|
match FANUC's natural surface).
|
||||||
|
|
||||||
|
### `FocasSimFixture.SeedAlarmHistoryAsync`
|
||||||
|
|
||||||
|
The future test-support helper wraps the admin endpoint:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await fixture.SeedAlarmHistoryAsync(
|
||||||
|
profile: "Series30i",
|
||||||
|
entries: new []
|
||||||
|
{
|
||||||
|
new FocasAlarmHistoryEntry(
|
||||||
|
new DateTimeOffset(2025, 4, 1, 9, 30, 0, TimeSpan.Zero),
|
||||||
|
AxisNo: 1, AlarmType: 2, AlarmNumber: 100, Message: "Spindle overload"),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Integration test `Series/AlarmHistoryProjectionTests.cs` will assert:
|
||||||
|
|
||||||
|
- historic events fire once with the seeded timestamps
|
||||||
|
- second poll yields zero new events (dedup honoured end-to-end)
|
||||||
|
- active-alarm raise/clear still works alongside the history poll
|
||||||
|
|
||||||
|
These tests are blocked on the focas-mock + integration-test project
|
||||||
|
landing; the unit-test coverage in `FocasAlarmProjectionTests` already
|
||||||
|
exercises every same-process invariant.
|
||||||
|
|
||||||
|
## `cnc_wrparam` / `cnc_wrmacro` mock behaviour — issue #269, plan PR F4-b
|
||||||
|
|
||||||
|
When the focas-mock fixture lands, it MUST implement the contract below.
|
||||||
|
The .NET side already ships against this contract (`FwlibFocasClient.cs`
|
||||||
|
write helpers, `FakeFocasClient` round-trip support); writing the simulator
|
||||||
|
to the same shape lets the existing integration-test scaffolds at
|
||||||
|
`tests/.../IntegrationTests/Series/ParameterWriteTests.cs` and
|
||||||
|
`MacroWriteTests.cs` (when they materialise) light up without driver
|
||||||
|
changes.
|
||||||
|
|
||||||
|
### Per-profile state
|
||||||
|
|
||||||
|
Each profile owns:
|
||||||
|
|
||||||
|
- `parameters: Dict[int, int]` — map from parameter number to current value.
|
||||||
|
- `macros: Dict[int, int]` — map from macro number to current scaled-int
|
||||||
|
value (decimal-point count fixed at 0 for F4-b).
|
||||||
|
- `unlock_state: bool` — defaults `False`. When `False`, every
|
||||||
|
`cnc_wrparam` returns `EW_PASSWD` (numeric `11`) regardless of
|
||||||
|
parameter. Macro writes are NOT gated by `unlock_state`.
|
||||||
|
- `unlock_password: bytes` (4-byte buffer) — defaults to the profile's
|
||||||
|
fixture default (e.g. `b"1234"` for Series30i). Compared byte-for-byte
|
||||||
|
by the `cnc_wrunlockparam` handler; flips `unlock_state = True` on
|
||||||
|
match, leaves it untouched on mismatch (and returns `EW_PASSWD`).
|
||||||
|
Mutable via `POST /admin/mock_set_password` for tests that exercise
|
||||||
|
rotation. Issue #271, plan PR F4-d.
|
||||||
|
- `last_write: Optional[LastWrite]` — most-recent successful
|
||||||
|
`(kind, number, value, ts)` tuple, surfaced via the admin endpoint
|
||||||
|
below for audit-log assertions.
|
||||||
|
|
||||||
|
### `cnc_wrparam` request decode
|
||||||
|
|
||||||
|
```
|
||||||
|
[int16 LE datano][int16 LE axis][int8|int16|int32 LE value]
|
||||||
|
```
|
||||||
|
|
||||||
|
Width of the value field is determined by the request frame trailer
|
||||||
|
length per the table in
|
||||||
|
[`focas-wire-protocol.md`](./focas-wire-protocol.md). On
|
||||||
|
`unlock_state == False` short-circuit to `[int16 LE 11]` (`EW_PASSWD`).
|
||||||
|
Otherwise mutate `parameters[datano] = value`, set `last_write`, return
|
||||||
|
`[int16 LE 0]`.
|
||||||
|
|
||||||
|
### `cnc_wrmacro` request decode
|
||||||
|
|
||||||
|
```
|
||||||
|
[int16 LE number][int16 LE length=8][int32 LE mcr_val][int16 LE dec_val]
|
||||||
|
```
|
||||||
|
|
||||||
|
Always accept (no `unlock_state` gate). Mutate
|
||||||
|
`macros[number] = mcr_val` (we ignore `dec_val` for F4-b — integer-only).
|
||||||
|
Return `[int16 LE 0]`. Round-trip: a subsequent `cnc_rdmacro(number)`
|
||||||
|
returns `(mcr_val, 0)`.
|
||||||
|
|
||||||
|
### Admin endpoint — `POST /admin/mock_set_unlock_state`
|
||||||
|
|
||||||
|
Toggles `unlock_state` for the F4-d unlock workflow tests. Without this,
|
||||||
|
F4-b parameter-write integration tests can't reproduce the
|
||||||
|
`EW_PASSWD` → `BadUserAccessDenied` mapping.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /admin/mock_set_unlock_state
|
||||||
|
{ "profile": "Series30i", "unlocked": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `cnc_wrunlockparam` request decode — issue #271, plan PR F4-d
|
||||||
|
|
||||||
|
```
|
||||||
|
[byte[4] password]
|
||||||
|
```
|
||||||
|
|
||||||
|
Match `password == profile.unlock_password` byte-for-byte. On match:
|
||||||
|
flip `unlock_state = True`, return `[int16 LE 0]`. On mismatch: leave
|
||||||
|
`unlock_state` untouched, return `[int16 LE 11]` (`EW_PASSWD`).
|
||||||
|
|
||||||
|
The simulator deliberately keeps unlock state per-session (per OpenSession
|
||||||
|
handle) so a reconnect drops back to `unlock_state = False` — matching the
|
||||||
|
FWLIB lifetime semantics described in
|
||||||
|
[`focas-wire-protocol.md`](./focas-wire-protocol.md) § "cnc_wrunlockparam".
|
||||||
|
|
||||||
|
### Admin endpoint — `POST /admin/mock_set_password`
|
||||||
|
|
||||||
|
Rotates the per-profile `unlock_password` for tests that exercise the
|
||||||
|
F4-d password-rotation runbook (`docs/v2/focas-deployment.md`
|
||||||
|
§ "FOCAS password handling"). Idempotent — call again to revert.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /admin/mock_set_password
|
||||||
|
{ "profile": "Series30i", "password": "5678" }
|
||||||
|
```
|
||||||
|
|
||||||
|
The endpoint accepts the password as a UTF-8/ASCII string and applies
|
||||||
|
the same right-pad-to-4-bytes / truncate-to-4-bytes normalisation the
|
||||||
|
driver does, so simulator-side matching is byte-symmetric with the
|
||||||
|
production wire encoder.
|
||||||
|
|
||||||
|
### Admin endpoint — `GET /admin/mock_get_last_write`
|
||||||
|
|
||||||
|
Returns the simulator's view of the most-recent successful write, used by
|
||||||
|
F4-b audit-log integration assertions ("did the write actually reach the
|
||||||
|
fixture, and is the audit log capturing the right kind/number/value?").
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/mock_get_last_write?profile=Series30i
|
||||||
|
->
|
||||||
|
{
|
||||||
|
"kind": "param", // "param" | "macro"
|
||||||
|
"number": 1815,
|
||||||
|
"value": 100,
|
||||||
|
"writtenAt": "2026-04-25T13:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When no write has happened the endpoint returns `null` rather than 404 so
|
||||||
|
the test helper can assert "no writes since fixture reset" without
|
||||||
|
exception handling.
|
||||||
|
|
||||||
|
## `pmc_wrpmcrng` mock behaviour — issue #270, plan PR F4-c
|
||||||
|
|
||||||
|
The simulator keeps a per-profile PMC byte table keyed by `(addr_type,
|
||||||
|
byte_address)` — the same map the existing `pmc_rdpmcrng` handler reads
|
||||||
|
from. The write handler mutates the same map so a subsequent read sees
|
||||||
|
the written bytes.
|
||||||
|
|
||||||
|
### Per-profile state
|
||||||
|
|
||||||
|
Each profile carries:
|
||||||
|
|
||||||
|
```python
|
||||||
|
pmc: Dict[int, bytearray] # addr_type -> bytearray (one per PMC letter, default 256 bytes each)
|
||||||
|
```
|
||||||
|
|
||||||
|
`addr_type` is the PMC area code (R=5, G=4, F=3, D=8, X=1, Y=2, K=10,
|
||||||
|
A=11, E=12, T=6, C=7); the existing `pmc_rdpmcrng` fixture seeds the
|
||||||
|
defaults (zeros + a few canned bits per the dl205-style profile fixtures).
|
||||||
|
|
||||||
|
### `pmc_wrpmcrng` request decode
|
||||||
|
|
||||||
|
| Offset | Width | Field |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0 | int16 LE | `addr_type` |
|
||||||
|
| 2 | int16 LE | `data_type` (must be `0` = byte; the driver only emits byte writes) |
|
||||||
|
| 4 | uint16 LE | `datano_s` |
|
||||||
|
| 6 | uint16 LE | `datano_e` |
|
||||||
|
| 8 | bytes | `data[]` — `(datano_e - datano_s + 1)` bytes |
|
||||||
|
|
||||||
|
Handler steps:
|
||||||
|
|
||||||
|
1. Look up the per-profile bytearray for `addr_type` (allocate on first
|
||||||
|
write, default 256 zeros).
|
||||||
|
2. **Validate** `0 <= datano_s <= datano_e < len(bytearray)` — otherwise
|
||||||
|
return `EW_NUMBER` (`4`).
|
||||||
|
3. **Validate** `len(data) == datano_e - datano_s + 1` — otherwise
|
||||||
|
return `EW_LENGTH` (`14`).
|
||||||
|
4. **Validate** `data_type == 0` — otherwise return `EW_DATA` (`9`)
|
||||||
|
because the driver only ever emits byte writes (bit writes wrap with
|
||||||
|
driver-side RMW so they reach the simulator as 1-byte writes).
|
||||||
|
5. Copy `data[]` into `bytearray[datano_s:datano_e+1]`. Other bytes
|
||||||
|
in the array are untouched.
|
||||||
|
6. Update `last_write` admin-endpoint state (kind=`pmc`, address-type,
|
||||||
|
start byte, length, bytes).
|
||||||
|
7. Return `ew_status = 0`.
|
||||||
|
|
||||||
|
### Round-trip invariant
|
||||||
|
|
||||||
|
The simulator MUST satisfy:
|
||||||
|
|
||||||
|
```
|
||||||
|
write(R, [10..12], [0xAA, 0xBB, 0xCC]); read(R, [10..12]) == [0xAA, 0xBB, 0xCC]
|
||||||
|
```
|
||||||
|
|
||||||
|
and the **byte-isolation invariant**:
|
||||||
|
|
||||||
|
```
|
||||||
|
write(R, [11], [0xFF]); bytes[10] == prior bytes[10] && bytes[12] == prior bytes[12]
|
||||||
|
```
|
||||||
|
|
||||||
|
The integration tests `Series/PmcRangeWriteTests.cs` and
|
||||||
|
`Series/PmcBitRmwIntegrationTests.cs` assert both shapes.
|
||||||
|
|
||||||
|
### Admin endpoint — `GET /admin/mock_get_last_write` extension
|
||||||
|
|
||||||
|
The `last_write` payload gains a `kind: "pmc"` variant:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"kind": "pmc",
|
||||||
|
"addr_type": 5, // R
|
||||||
|
"datano_s": 100,
|
||||||
|
"datano_e": 100,
|
||||||
|
"bytes": "0x08", // hex-encoded
|
||||||
|
"writtenAt": "2026-04-25T13:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Bit-level writes never appear here as a separate kind — they reach the
|
||||||
|
simulator as 1-byte writes after the driver's RMW wrapper, so the audit
|
||||||
|
shape is identical to a byte write at the same address.
|
||||||
|
|
||||||
|
## Cycle-time per part / last cycle delta — F5-a (issue #272)
|
||||||
|
|
||||||
|
Plan PR F5-a derives `Production/LastCycleSeconds` +
|
||||||
|
`Production/LastCycleStartUtc` from the existing `cnc_rdparam(6711)` +
|
||||||
|
`cnc_rdtimer` snapshot stream — **pure derivation, no new wire calls**.
|
||||||
|
The simulator does NOT need new wire commands; the existing
|
||||||
|
`cnc_rdparam` + `cnc_rdtimer` handlers already cover the read surface.
|
||||||
|
|
||||||
|
What focas-mock DOES need is an admin endpoint + test-fixture helper
|
||||||
|
that lets integration tests atomically increment the parts-count
|
||||||
|
counter alongside the cycle-time timer so the driver sees a clean
|
||||||
|
"cycle completed" transition on the next probe tick.
|
||||||
|
|
||||||
|
### Per-profile state
|
||||||
|
|
||||||
|
Already covered by the existing F1-b state map:
|
||||||
|
|
||||||
|
- `parameters: Dict[int, int]` (entry `6711` is the parts-count counter).
|
||||||
|
- `timers: Dict[int, int]` (entry `0` is the live cycle-time counter,
|
||||||
|
in seconds).
|
||||||
|
|
||||||
|
### Admin endpoint — `POST /admin/mock_simulate_cycle_completion`
|
||||||
|
|
||||||
|
Atomically advances both values to model "the CNC just finished a
|
||||||
|
cycle". Atomicity matters: the F5-a derivation samples both fields on
|
||||||
|
every probe tick, so if the simulator updated parts-count and the
|
||||||
|
timer in two separate writes the test could observe an intermediate
|
||||||
|
state where parts-count incremented but the timer hasn't updated yet
|
||||||
|
(producing a misleading `LastCycleSeconds`).
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /admin/mock_simulate_cycle_completion
|
||||||
|
{
|
||||||
|
"profile": "Series30i",
|
||||||
|
"partsDelta": 1, // default 1; tests asserting backfill use 3+
|
||||||
|
"newCycleTimerSeconds": 18 // absolute value, NOT a delta
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Handler steps:
|
||||||
|
|
||||||
|
1. `parameters[6711] += partsDelta` (under the per-profile lock).
|
||||||
|
2. `timers[0] = newCycleTimerSeconds`.
|
||||||
|
3. Return `200 OK` with the new values for verification.
|
||||||
|
|
||||||
|
The endpoint MUST hold the profile's update lock for the full
|
||||||
|
read-modify-write so a concurrent `cnc_rdparam` + `cnc_rdtimer` poll
|
||||||
|
sees both fields in their pre-update OR post-update state — never
|
||||||
|
half-applied.
|
||||||
|
|
||||||
|
### `FocasSimFixture.SimulateCycleCompletionAsync`
|
||||||
|
|
||||||
|
The future test-support helper wraps the admin endpoint:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await fixture.SimulateCycleCompletionAsync(
|
||||||
|
profile: "Series30i",
|
||||||
|
partsDelta: 1,
|
||||||
|
newCycleTimerSeconds: 18);
|
||||||
|
```
|
||||||
|
|
||||||
|
Integration test `Series/CycleDeltaTests.cs` will assert:
|
||||||
|
|
||||||
|
- After a 5 -> 6 transition with `newCycleTimerSeconds=18`, the
|
||||||
|
driver's `Production/LastCycleSeconds` settles to `currentTimer -
|
||||||
|
prevTimer`.
|
||||||
|
- `Production/LastCycleStartUtc` is within driver-tolerance of
|
||||||
|
`nowUtc - LastCycleSeconds` (allow a small window for probe-tick
|
||||||
|
jitter).
|
||||||
|
- Counter reset (parts -> 0) preserves the last published values.
|
||||||
|
- Cycle-timer rollover does not publish a negative delta.
|
||||||
|
|
||||||
|
These tests are blocked on the focas-mock + integration-test project
|
||||||
|
landing; the unit-test coverage in `FocasCycleDeltaTests` already
|
||||||
|
exercises every same-process invariant of the derivation.
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
focas-mock simulator has not landed yet (tracked separately from F4-b /
|
||||||
|
F4-c). F4-b + F4-c land the .NET-side wire encoders + dispatch + status
|
||||||
|
mapping unconditionally; the integration-test scaffolds at
|
||||||
|
`tests/.../IntegrationTests/Series/ParameterWriteTests.cs`,
|
||||||
|
`MacroWriteTests.cs`, `PmcRangeWriteTests.cs`, and
|
||||||
|
`PmcBitRmwIntegrationTests.cs` are deferred until the simulator +
|
||||||
|
integration-test project land. Until then unit-test coverage in
|
||||||
|
`FocasWriteParameterTests` / `FocasWriteMacroTests` /
|
||||||
|
`FocasWritePmcTests` exercises every same-process invariant against the
|
||||||
|
in-memory `FakeFocasClient`.
|
||||||
267
docs/v2/implementation/focas-wire-protocol.md
Normal file
267
docs/v2/implementation/focas-wire-protocol.md
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
# FOCAS wire protocol — packed-buffer surface
|
||||||
|
|
||||||
|
Notes on the language-neutral packed-buffer encoding the FOCAS driver +
|
||||||
|
focas-mock simulator share. This format is **not** the FWLIB native struct
|
||||||
|
layout — Tier-C Fwlib32 backends marshal directly from the FANUC C struct.
|
||||||
|
The packed surface exists so the simulator (Python / FastAPI) and the .NET
|
||||||
|
wire client can speak a common format over IPC without piping a Win32 DLL
|
||||||
|
through both ends.
|
||||||
|
|
||||||
|
## Command id table
|
||||||
|
|
||||||
|
Each FOCAS-equivalent call gets a stable wire-protocol command id. Ids are
|
||||||
|
**append-only** — never renumber, never reuse.
|
||||||
|
|
||||||
|
| Id | FOCAS API | Surface |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `0x0001` | `cnc_rdcncstat` | ODBST 9-field status struct |
|
||||||
|
| `0x0002` | `cnc_rdparam` | parameter value (one number) |
|
||||||
|
| `0x0003` | `cnc_rdmacro` | macro variable value |
|
||||||
|
| `0x0004` | `cnc_rddiag` | diagnostic value |
|
||||||
|
| ... | ... | ... |
|
||||||
|
| **`0x0102`** | **`cnc_wrparam`** | **IODBPSD parameter-write packet (issue #269, plan PR F4-b)** |
|
||||||
|
| **`0x0103`** | **`cnc_wrmacro`** | **ODBM macro-write packet (issue #269, plan PR F4-b)** |
|
||||||
|
| **`0x0104`** | **`pmc_wrpmcrng`** | **IODBPMC PMC range-write packet (issue #270, plan PR F4-c)** |
|
||||||
|
| **`0x0105`** | **`cnc_wrunlockparam`** | **4-byte password buffer for the parameter-protect / read-protect unlock (issue #271, plan PR F4-d)** |
|
||||||
|
| `0x0F1A` | **`cnc_rdalmhistry`** | **ODBALMHIS alarm-history ring-buffer dump (issue #267, plan PR F3-a)** |
|
||||||
|
|
||||||
|
## ODBALMHIS — alarm history (`cnc_rdalmhistry`, command `0x0F1A`)
|
||||||
|
|
||||||
|
Issued by `FocasAlarmProjection` when
|
||||||
|
`FocasDriverOptions.AlarmProjection.Mode == ActivePlusHistory`. Returns up
|
||||||
|
to `depth` most-recent ring-buffer entries.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
| Offset | Width | Field | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 0 | `int16 LE` | `depth` | clamped client-side to `[1..250]` (`FocasAlarmProjectionOptions.MaxHistoryDepth`) |
|
||||||
|
|
||||||
|
### Response (packed buffer, little-endian)
|
||||||
|
|
||||||
|
| Offset | Width | Field |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0 | `int16 LE` | `num_alm` — number of entries that follow. `< 0` indicates CNC error. |
|
||||||
|
| 2 | repeated | `ALMHIS_data alm[num_alm]` (see below) |
|
||||||
|
|
||||||
|
Each entry block:
|
||||||
|
|
||||||
|
| Offset (rel.) | Width | Field |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0 | `int16 LE` | `year` |
|
||||||
|
| 2 | `int16 LE` | `month` |
|
||||||
|
| 4 | `int16 LE` | `day` |
|
||||||
|
| 6 | `int16 LE` | `hour` |
|
||||||
|
| 8 | `int16 LE` | `minute` |
|
||||||
|
| 10 | `int16 LE` | `second` |
|
||||||
|
| 12 | `int16 LE` | `axis_no` (1-based; 0 = whole-CNC) |
|
||||||
|
| 14 | `int16 LE` | `alm_type` (P/S/OT/SV/SR/MC/SP/PW/IO encoded numerically) |
|
||||||
|
| 16 | `int16 LE` | `alm_no` |
|
||||||
|
| 18 | `int16 LE` | `msg_len` (0..32 typical) |
|
||||||
|
| 20 | `msg_len` | ASCII message (no null terminator) |
|
||||||
|
| `20 + msg_len` | 0..3 | pad to 4-byte boundary so per-entry blocks stay self-delimiting |
|
||||||
|
|
||||||
|
The CNC stamps `year..second` in **its own local time**. The deployment
|
||||||
|
guide instructs operators to keep CNC clocks on UTC so the projection's
|
||||||
|
dedup key `(OccurrenceTime, AlarmNumber, AlarmType)` stays stable across
|
||||||
|
DST transitions. The .NET decoder
|
||||||
|
(`Wire/FocasAlarmHistoryDecoder.Decode`) constructs each
|
||||||
|
`DateTimeOffset` with `TimeSpan.Zero` (UTC) on that assumption.
|
||||||
|
|
||||||
|
### Error handling
|
||||||
|
|
||||||
|
- A negative `num_alm` short-circuits decode to an empty list — the
|
||||||
|
projection treats it as "no history this tick" and the next poll
|
||||||
|
retries.
|
||||||
|
- Malformed timestamps (e.g. month=0) are skipped per-entry instead of
|
||||||
|
faulting the whole decode; the dedup key for malformed entries would be
|
||||||
|
unstable anyway.
|
||||||
|
- `msg_len` overrunning the payload truncates the entry list at the
|
||||||
|
malformed entry rather than throwing.
|
||||||
|
|
||||||
|
## IODBPSD — parameter write (`cnc_wrparam`, command `0x0102`)
|
||||||
|
|
||||||
|
Issue #269, plan PR F4-b. The write-side payload is the **byte-symmetric
|
||||||
|
inverse of the `cnc_rdparam` read** — the same `IODBPSD` struct shape, and
|
||||||
|
the .NET wire client uses the read-side decoder reversed (`EncodeParamValue`
|
||||||
|
in `FwlibFocasClient.cs`) so the encoder/decoder are guaranteed to stay in
|
||||||
|
lock-step.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
| Offset | Width | Field |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0 | `int16 LE` | `datano` — parameter number (e.g. `1815`) |
|
||||||
|
| 2 | `int16 LE` | `type` — axis index (1-based; `0` = whole-CNC parameter) |
|
||||||
|
| 4 | `length` | `data` payload — width depends on parameter type |
|
||||||
|
|
||||||
|
`length` (request frame trailer, drives `data` width):
|
||||||
|
|
||||||
|
| FocasDataType | `length` | Payload encoding |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Byte` | `4 + 1` | one signed byte at offset 4 |
|
||||||
|
| `Int16` | `4 + 2` | int16 LE at offset 4 |
|
||||||
|
| `Int32` | `4 + 4` | int32 LE at offset 4 |
|
||||||
|
|
||||||
|
Bit-addressed parameters (`PARAM:1815/0` form) are not supported by F4-b
|
||||||
|
and surface as `BadNotSupported`; F4-c will land the read-modify-write
|
||||||
|
helper alongside the PMC bit RMW path.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
Single `int16 LE` return code per the standard FWLIB convention:
|
||||||
|
|
||||||
|
- `0` → `Good`
|
||||||
|
- `11` (`EW_PASSWD`) → **`BadUserAccessDenied`** (was `BadNotWritable`
|
||||||
|
pre-F4-b — see `FocasStatusMapper`). Means the parameter-write switch is
|
||||||
|
off or the CNC isn't in MDI mode; the F4-d unlock workflow will close
|
||||||
|
the loop on this from the OPC UA side.
|
||||||
|
- Other `EW_*` codes map per
|
||||||
|
[`FocasStatusMapper.MapFocasReturn`](../../src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs).
|
||||||
|
|
||||||
|
## ODBM — macro write (`cnc_wrmacro`, command `0x0103`)
|
||||||
|
|
||||||
|
Issue #269, plan PR F4-b. The write-side payload mirrors the
|
||||||
|
`cnc_rdmacro` read shape: the same `(mcr_val, dec_val)` (integer +
|
||||||
|
decimal-point count) split, but emitted from the .NET side rather than
|
||||||
|
decoded.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
| Offset | Width | Field |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0 | `int16 LE` | `number` — macro variable number (e.g. `500`) |
|
||||||
|
| 2 | `int16 LE` | `length` — fixed at `8` for ODBM |
|
||||||
|
| 4 | `int32 LE` | `mcr_val` — scaled integer value |
|
||||||
|
| 8 | `int16 LE` | `dec_val` — decimal-point count |
|
||||||
|
|
||||||
|
F4-b ships **integer-only writes** (`dec_val = 0`) to match the most
|
||||||
|
common HMI pattern; a future `WriteMacroScaled` overload will land if the
|
||||||
|
field calls for fractional macro setpoints. Read-side decoders apply
|
||||||
|
`mcr_val / 10^dec_val`, so a `dec_val = 0` write surfaces back as the
|
||||||
|
integer it was emitted as.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
Same single-int16 envelope as `cnc_wrparam`. `EW_PASSWD` is rare on macro
|
||||||
|
writes (the gate-switch protection is parameter-specific) but the mapper
|
||||||
|
treats both kinds identically.
|
||||||
|
|
||||||
|
### Symmetry note
|
||||||
|
|
||||||
|
The plan carries a "byte layout symmetry" requirement — the encoder for
|
||||||
|
each kind is the read-side decoder reversed. Adding a new parameter type
|
||||||
|
(e.g. `Int64` parameters, when they ship) means extending both sides in
|
||||||
|
the same PR; the unit test
|
||||||
|
`FocasWriteParameterTests.ParameterWrite_round_trip_stores_value_visible_to_subsequent_read`
|
||||||
|
exercises encode → store → decode with the fake wire client and is the
|
||||||
|
canary for symmetry regressions.
|
||||||
|
|
||||||
|
## IODBPMC — PMC range write (`pmc_wrpmcrng`, command `0x0104`)
|
||||||
|
|
||||||
|
Issue #270, plan PR F4-c. The write-side payload is the read-side
|
||||||
|
`pmc_rdpmcrng` IODBPMC packet with the data direction inverted: the
|
||||||
|
caller fills the `data[]` byte run and the simulator / Fwlib32 stores
|
||||||
|
it; the response is the small status envelope rather than the populated
|
||||||
|
data buffer the read side returns.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
| Offset | Width | Field |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0 | `int16 LE` | `type_a` — PMC address-type code (R=5, G=4, F=3, D=8, X=1, Y=2, K=10, A=11, E=12, T=6, C=7) |
|
||||||
|
| 2 | `int16 LE` | `type_d` — data type (`0` = byte; only byte writes are issued — bit writes wrap the byte path with a read-modify-write helper) |
|
||||||
|
| 4 | `uint16 LE` | `datano_s` — first byte address (inclusive) |
|
||||||
|
| 6 | `uint16 LE` | `datano_e` — last byte address (inclusive) — `(datano_e - datano_s + 1)` is the byte count |
|
||||||
|
| 8 | `bytes` | `data[]` — payload, exactly `(datano_e - datano_s + 1)` bytes |
|
||||||
|
|
||||||
|
The header is 8 bytes; the FWLIB `IODBPMC.data` field caps at 32 bytes
|
||||||
|
(40-byte total per call), so larger ranges are chunked into 32-byte
|
||||||
|
sub-calls by the wire client. The simulator MUST honour the same chunk
|
||||||
|
ceiling so chunked-vs-single round-trips produce the same final bytes.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
Same single-int16 envelope as `cnc_wrparam` / `cnc_wrmacro`:
|
||||||
|
|
||||||
|
| Offset | Width | Field |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0 | `int16 LE` | `ew_status` — `0` = success, non-zero = FANUC `EW_*` |
|
||||||
|
|
||||||
|
`EW_NOOPT` (option not installed), `EW_NUMBER` (out-of-range address),
|
||||||
|
`EW_LENGTH` (chunk size mismatch) are the typical failures the simulator
|
||||||
|
reproduces; the mapper translates them to OPC UA status codes the same
|
||||||
|
way the read-side does.
|
||||||
|
|
||||||
|
### Bit-level RMW (driver-side, no extra wire op)
|
||||||
|
|
||||||
|
`pmc_wrpmcrng` is **byte-addressed** — there is no sub-byte write op on
|
||||||
|
the wire. Bit writes go through `IFocasClient.WritePmcBitAsync` which:
|
||||||
|
|
||||||
|
1. Issues a 1-byte `pmc_rdpmcrng` to fetch the parent byte.
|
||||||
|
2. Masks the target bit (set: OR; clear: AND-NOT).
|
||||||
|
3. Issues a 1-byte `pmc_wrpmcrng` with the modified byte.
|
||||||
|
|
||||||
|
A per-byte semaphore in `FwlibFocasClient` serialises concurrent bit
|
||||||
|
writes against the same byte so two updates that race never lose one
|
||||||
|
another's bit. The simulator's handler implements the same byte-aligned
|
||||||
|
semantics — bit writes never reach it as a separate frame.
|
||||||
|
|
||||||
|
### Symmetry note
|
||||||
|
|
||||||
|
The encoder is the `pmc_rdpmcrng` decoder reversed: the read side parses
|
||||||
|
`(type_a, type_d, datano_s, datano_e)` from the request and emits the
|
||||||
|
data buffer in the response; the write side parses all five fields plus
|
||||||
|
the data buffer from the request and emits a status int16 in the
|
||||||
|
response. Tests `FocasWritePmcTests.PMC_*` exercise the round-trip on
|
||||||
|
the fake wire client.
|
||||||
|
|
||||||
|
## cnc_wrunlockparam — connection-level password unlock (command `0x0105`)
|
||||||
|
|
||||||
|
Issue #271, plan PR F4-d. Some controllers (notably 16i + certain 30i
|
||||||
|
firmwares with parameter-protect on) gate `cnc_wrparam` and selected
|
||||||
|
reads behind a connection-level password switch. The driver emits this
|
||||||
|
frame on connect when `FocasDeviceOptions.Password` is configured, and
|
||||||
|
re-emits it on any read/write that returns `EW_PASSWD` (then retries the
|
||||||
|
gated call once).
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
| Offset | Width | Field |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0 | `byte[4]` | `password[4]` — 4-byte password buffer. ASCII-encoded from `FocasDeviceOptions.Password`, right-padded with `0x00`, truncated at 4 bytes. |
|
||||||
|
|
||||||
|
The 4-byte fixed slot matches the FANUC published shape — the controller
|
||||||
|
compares byte-for-byte. Longer / shorter source strings are normalised at
|
||||||
|
the driver layer before they hit this frame so the wire surface stays
|
||||||
|
canonical.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
Same single-int16 envelope as the write frames:
|
||||||
|
|
||||||
|
| Offset | Width | Field |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0 | `int16 LE` | `ew_status` — `0` = success (gate now lifted for the lifetime of this FWLIB handle), `EW_PASSWD` = supplied password did not match the controller's slot, `EW_HANDLE` = handle invalid. |
|
||||||
|
|
||||||
|
### Lifetime
|
||||||
|
|
||||||
|
Unlock is bound to the FWLIB handle: it persists until the handle closes
|
||||||
|
(disconnect / reconnect). The driver reinvokes unlock on every
|
||||||
|
`EnsureConnectedAsync` reconnect path so a planned or unplanned wire
|
||||||
|
restart self-heals without operator intervention. A `BadUserAccessDenied`
|
||||||
|
on a read/write triggers a single-shot retry: re-emit unlock + redispatch
|
||||||
|
the gated call once. A second `EW_PASSWD` propagates unchanged so a
|
||||||
|
mismatched password doesn't loop forever on the wire.
|
||||||
|
|
||||||
|
### No-log invariant
|
||||||
|
|
||||||
|
The password is a secret. Wire-client implementations MUST NOT log the
|
||||||
|
password on either request or response. The current
|
||||||
|
`FwlibFocasClient.UnlockAsync` constructs an exception that includes
|
||||||
|
only the `EW_*` return code; the `FocasDeviceOptions` record overrides
|
||||||
|
its auto-generated `ToString` so any Serilog destructure renders
|
||||||
|
`Password = ***`. See
|
||||||
|
[`docs/v2/focas-deployment.md`](../focas-deployment.md)
|
||||||
|
§ "FOCAS password handling" for the deployment-side guarantees +
|
||||||
|
rotation runbook.
|
||||||
157
docs/v2/implementation/phase-7-e2e-smoke.md
Normal file
157
docs/v2/implementation/phase-7-e2e-smoke.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# Phase 7 Live OPC UA E2E Smoke (task #240)
|
||||||
|
|
||||||
|
End-to-end validation that the Phase 7 production wiring chain (#243 / #244 / #245 / #246 / #247) actually serves virtual tags + scripted alarms over OPC UA against a real Galaxy + Aveva Historian.
|
||||||
|
|
||||||
|
> **Scope.** Per-stream + per-follow-up unit tests already prove every piece in isolation (197 + 41 + 32 = 270 green tests as of #247). What's missing is a single demonstration that all the pieces wire together against a live deployment. This runbook is that demonstration.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
| Component | How to verify |
|
||||||
|
|-----------|---------------|
|
||||||
|
| AVEVA Galaxy + MXAccess installed | `Get-Service ArchestrA*` returns at least one running service |
|
||||||
|
| `OtOpcUaGalaxyHost` Windows service running | `sc query OtOpcUaGalaxyHost` → `STATE: 4 RUNNING` |
|
||||||
|
| Galaxy.Host shared secret matches `.local/galaxy-host-secret.txt` | Set during NSSM install — see `docs/ServiceHosting.md` |
|
||||||
|
| SQL Server reachable, `OtOpcUaConfig` DB exists with all migrations applied | `sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "..." -Q "SELECT COUNT(*) FROM dbo.__EFMigrationsHistory"` returns ≥ 11 |
|
||||||
|
| Server's `appsettings.json` `Node:ConfigDbConnectionString` matches your SQL Server | `cat src/ZB.MOM.WW.OtOpcUa.Server/appsettings.json` |
|
||||||
|
|
||||||
|
> **Galaxy.Host pipe ACL.** Per `docs/ServiceHosting.md`, the pipe ACL deliberately denies `BUILTIN\Administrators`. **Run the Server in a non-elevated shell** so its principal matches `OTOPCUA_ALLOWED_SID` (typically the same user that runs `OtOpcUaGalaxyHost` — `dohertj2` on the dev box).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Migrate the Config DB
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd src/ZB.MOM.WW.OtOpcUa.Configuration
|
||||||
|
dotnet ef database update --connection "Server=localhost,14330;Database=OtOpcUaConfig;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=True;Encrypt=False;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect every migration through `20260420232000_ExtendComputeGenerationDiffWithPhase7` to report `Applying migration...`. Re-running is a no-op.
|
||||||
|
|
||||||
|
### 2. Seed the smoke fixture
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" `
|
||||||
|
-I -i scripts/smoke/seed-phase-7-smoke.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output ends with `Phase 7 smoke seed complete.` plus a Cluster / Node / Generation summary. Idempotent — re-running wipes the prior smoke state and starts clean.
|
||||||
|
|
||||||
|
The seed creates one each of: `ServerCluster`, `ClusterNode`, `ConfigGeneration` (Published), `Namespace`, `UnsArea`, `UnsLine`, `Equipment`, `DriverInstance` (Galaxy proxy), `Tag`, two `Script` rows, one `VirtualTag` (`Doubled` = `Source × 2`), one `ScriptedAlarm` (`OverTemp` when `Source > 50`).
|
||||||
|
|
||||||
|
### 3. Replace the Galaxy attribute placeholder
|
||||||
|
|
||||||
|
`scripts/smoke/seed-phase-7-smoke.sql` inserts a `dbo.Tag.TagConfig` JSON with `FullName = "REPLACE_WITH_REAL_GALAXY_ATTRIBUTE"`. Edit the SQL + re-run, or `UPDATE dbo.Tag SET TagConfig = N'{"FullName":"YourReal.GalaxyAttr","DataType":"Float64"}' WHERE TagId='p7-smoke-tag-source'`. Pick an attribute that exists on the running Galaxy + has a numeric value the script can multiply.
|
||||||
|
|
||||||
|
### 4. Point Server.appsettings at the smoke node
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Node": {
|
||||||
|
"NodeId": "p7-smoke-node",
|
||||||
|
"ClusterId": "p7-smoke",
|
||||||
|
"ConfigDbConnectionString": "Server=localhost,14330;..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
### 5. Start the Server (non-elevated shell)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected log markers (in order):
|
||||||
|
|
||||||
|
```
|
||||||
|
Bootstrap complete: source=db generation=1
|
||||||
|
Equipment namespace snapshots loaded for 1/1 driver(s) at generation 1
|
||||||
|
Phase 7 historian sink: driver p7-smoke-galaxy provides IAlarmHistorianWriter — wiring SqliteStoreAndForwardSink
|
||||||
|
Phase 7: composed engines from generation 1 — 1 virtual tag(s), 1 scripted alarm(s), 2 script(s)
|
||||||
|
Phase 7 bridge subscribed N attribute(s) from driver GalaxyProxyDriver
|
||||||
|
OPC UA server started — endpoint=opc.tcp://0.0.0.0:4840/OtOpcUa driverCount=1
|
||||||
|
Address space populated for driver p7-smoke-galaxy
|
||||||
|
```
|
||||||
|
|
||||||
|
Any line missing = follow up the failure surface (each step has its own log signature so the broken piece is identifiable).
|
||||||
|
|
||||||
|
### 6. Validate via Client.CLI
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- browse -u opc.tcp://localhost:4840/OtOpcUa -r -d 5
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect to see under the namespace root: `lab-floor → galaxy-line → reactor-1` with three child variables: `Source` (driver-sourced), `Doubled` (virtual tag, value should track Source×2), and `OverTemp` (scripted alarm, boolean reflecting whether Source > 50).
|
||||||
|
|
||||||
|
#### Read the virtual tag
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840/OtOpcUa -n "ns=2;s=p7-smoke-vt-derived"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: a `Float64` value approximately equal to `2 × Source`. Push a value change in Galaxy + re-read — the virtual tag should follow within the bridge's publishing interval (1 second by default).
|
||||||
|
|
||||||
|
#### Read the scripted alarm
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840/OtOpcUa -n "ns=2;s=p7-smoke-al-overtemp"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `Boolean` — `false` when Source ≤ 50, `true` when Source > 50.
|
||||||
|
|
||||||
|
#### Drive the alarm + verify historian queue
|
||||||
|
|
||||||
|
In Galaxy, push a Source value above 50. Within ~1 second, `OverTemp.Read` flips to `true`. The alarm engine emits a transition to `Phase7EngineComposer.RouteToHistorianAsync` → `SqliteStoreAndForwardSink.EnqueueAsync` → drain worker (every 2s) → `GalaxyHistorianWriter.WriteBatchAsync` → Galaxy.Host pipe → Aveva Historian alarm schema.
|
||||||
|
|
||||||
|
Verify the queue absorbed the event:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
sqlite3 "$env:ProgramData\OtOpcUa\alarm-historian-queue.db" "SELECT COUNT(*) FROM Queue;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Should return 0 once the drain worker successfully forwards (or a small positive number while in-flight). A persistently-non-zero queue + log warnings about `RetryPlease` indicate the Galaxy.Host historian write path is failing — check the Host's log file.
|
||||||
|
|
||||||
|
#### Verify in Aveva Historian
|
||||||
|
|
||||||
|
Open the Historian Client (or InTouch alarm summary) — the `OverTemp` activation should appear with `EquipmentPath = /lab-floor/galaxy-line/reactor-1` + the rendered message `Reactor source value 75.3 exceeded 50` (or whatever value tripped it).
|
||||||
|
|
||||||
|
## Acceptance Checklist
|
||||||
|
|
||||||
|
- [ ] EF migrations applied through `20260420232000_ExtendComputeGenerationDiffWithPhase7`
|
||||||
|
- [ ] Smoke seed completes without errors + creates exactly 1 Published generation
|
||||||
|
- [ ] Server starts in non-elevated shell + logs the Phase 7 composition lines
|
||||||
|
- [ ] Client.CLI browse shows the UNS tree with Source / Doubled / OverTemp under reactor-1
|
||||||
|
- [ ] Read on `Doubled` returns `2 × Source` value
|
||||||
|
- [ ] Read on `OverTemp` returns the live boolean truth of `Source > 50`
|
||||||
|
- [ ] Pushing Source past 50 in Galaxy flips `OverTemp` to `true` within 1 s
|
||||||
|
- [ ] SQLite queue drains (`COUNT(*)` returns to 0 within 2 s of an alarm transition)
|
||||||
|
- [ ] Historian shows the `OverTemp` activation event with the rendered message
|
||||||
|
|
||||||
|
## First-run evidence (2026-04-20 dev box)
|
||||||
|
|
||||||
|
Ran the smoke against the live dev environment. Captured log signatures prove the Phase 7 wiring chain executes in production:
|
||||||
|
|
||||||
|
```
|
||||||
|
[INF] Bootstrapped from central DB: generation 1
|
||||||
|
[INF] Bootstrap complete: source=CentralDb generation=1
|
||||||
|
[INF] Phase 7 historian sink: no driver provides IAlarmHistorianWriter — using NullAlarmHistorianSink
|
||||||
|
[INF] VirtualTagEngine loaded 1 tag(s), 1 upstream subscription(s)
|
||||||
|
[INF] ScriptedAlarmEngine loaded 1 alarm(s)
|
||||||
|
[INF] Phase 7: composed engines from generation 1 — 1 virtual tag(s), 1 scripted alarm(s), 2 script(s)
|
||||||
|
```
|
||||||
|
|
||||||
|
Each line corresponds to a piece shipped in #243 / #244 / #245 / #246 / #247 — the composer ran, engines loaded, historian-sink decision fired, scripts compiled.
|
||||||
|
|
||||||
|
**Two gaps surfaced** (filed as new tasks below, NOT Phase 7 regressions):
|
||||||
|
|
||||||
|
1. **No driver-instance bootstrap pipeline.** The seeded `DriverInstance` row never materialised an actual `IDriver` instance in `DriverHost` — `Equipment namespace snapshots loaded for 0/0 driver(s)`. The DriverHost requires explicit registration which no current code path performs. Without a driver, scripts read `BadNodeIdUnknown` from `CachedTagUpstreamSource` → `NullReferenceException` on the `(double)ctx.GetTag(...).Value` cast. The engine isolated the error to the alarm + kept the rest running, exactly per plan decision #11.
|
||||||
|
2. **OPC UA endpoint port collision.** `Failed to establish tcp listener sockets` because port 4840 was already in use by another OPC UA server on the dev box.
|
||||||
|
|
||||||
|
Both are pre-Phase-7 deployment-wiring gaps. Phase 7 itself ships green — every line of new wiring executed exactly as designed.
|
||||||
|
|
||||||
|
## Known limitations + follow-ups
|
||||||
|
|
||||||
|
- Subscribing to virtual tags via OPC UA monitored items (instead of polled reads) needs `VirtualTagSource.SubscribeAsync` wiring through `DriverNodeManager.OnCreateMonitoredItem` — covered as part of release-readiness.
|
||||||
|
- Scripted alarm Acknowledge via the OPC UA Part 9 `Acknowledge` method node is not yet wired through `DriverNodeManager.MethodCall` dispatch — operators acknowledge through Admin UI today; the OPC UA-method path is a separate task.
|
||||||
|
- Phase 7 compliance script (`scripts/compliance/phase-7-compliance.ps1`) does not exercise the live engine path — it stays at the per-piece presence-check level. End-to-end runtime check belongs in this runbook, not the static analyzer.
|
||||||
618
docs/v2/s7.md
618
docs/v2/s7.md
@@ -450,6 +450,624 @@ Test names:
|
|||||||
- **ET 200SP CPU (1510SP / 1512SP)**: behaves as S7-1500 from `MB_SERVER`
|
- **ET 200SP CPU (1510SP / 1512SP)**: behaves as S7-1500 from `MB_SERVER`
|
||||||
perspective. No known deltas [3].
|
perspective. No known deltas [3].
|
||||||
|
|
||||||
|
## Performance (native S7comm driver)
|
||||||
|
|
||||||
|
This section covers the native S7comm driver (`ZB.MOM.WW.OtOpcUa.Driver.S7`),
|
||||||
|
not the Modbus-on-S7 quirks above. Both share a CPU but use different ports,
|
||||||
|
different libraries, and different optimization levers.
|
||||||
|
|
||||||
|
### Block-read coalescing
|
||||||
|
|
||||||
|
The S7 driver runs a coalescing planner before every read pass: same-area /
|
||||||
|
same-DB tags are sorted by byte offset and merged into single
|
||||||
|
`Plc.ReadBytesAsync` requests when the gap between them is small. Reading
|
||||||
|
`DB1.DBW0`, `DB1.DBW2`, `DB1.DBW4` issues **one** 6-byte byte-range read
|
||||||
|
covering offsets 0..6, sliced client-side instead of three multi-var items
|
||||||
|
(let alone three individual `Plc.ReadAsync` round-trips). On a 50-tag
|
||||||
|
contiguous workload this reduces wire traffic from 50 single reads (or 3
|
||||||
|
multi-var batches at the 19-item PDU ceiling) to **1 byte-range PDU**.
|
||||||
|
|
||||||
|
#### Default 16-byte gap-merge threshold
|
||||||
|
|
||||||
|
The planner merges two adjacent ranges when the gap between them is at most
|
||||||
|
16 bytes. The default reflects the cost arithmetic on a 240-byte default
|
||||||
|
PDU: an S7 request frame is ~30 bytes and a per-item response header is
|
||||||
|
~12 bytes, so over-fetching 16 bytes (which decode-time discards) is
|
||||||
|
cheaper than paying for one extra PDU round-trip.
|
||||||
|
|
||||||
|
The math also holds for 480/960-byte PDUs but the relative cost flips —
|
||||||
|
on a 960-byte PDU you can fit a much larger request and the over-fetch
|
||||||
|
ceiling is less of a concern. Sites running the extended PDU on S7-1500
|
||||||
|
can safely raise the threshold (see operator guidance below).
|
||||||
|
|
||||||
|
#### Opaque-size opt-out for STRING / array / structured-timestamp tags
|
||||||
|
|
||||||
|
Variable-width and header-prefixed tag types **never** participate in
|
||||||
|
coalescing:
|
||||||
|
|
||||||
|
- **STRING / WSTRING** carry a 2-byte (or 4-byte) length header, and the
|
||||||
|
per-tag width depends on the configured `StringLength`.
|
||||||
|
- **CHAR / WCHAR** are routed through the dedicated `S7StringCodec` decode
|
||||||
|
path, which expects an exact byte slice, not an offset into a larger
|
||||||
|
buffer.
|
||||||
|
- **DTL / DT / S5TIME / TIME / TOD / DATE-as-DateTime** route through
|
||||||
|
`S7DateTimeCodec` for the same reason.
|
||||||
|
- **Arrays** (`ElementCount > 1`) carry a per-tag width of `N × elementBytes`
|
||||||
|
and would silently mis-decode if the slice landed mid-block.
|
||||||
|
|
||||||
|
Each opaque-size tag emits its own standalone `Plc.ReadBytesAsync` call.
|
||||||
|
A STRING in the middle of a contiguous run of DBWs will split the
|
||||||
|
neighbour reads into "before STRING" and "after STRING" merged ranges
|
||||||
|
without straddling the STRING's bytes — verified by the
|
||||||
|
`S7BlockCoalescingPlannerTests` unit suite.
|
||||||
|
|
||||||
|
#### Operator tuning: `BlockCoalescingGapBytes`
|
||||||
|
|
||||||
|
Surface knob in the driver options:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"Host": "10.0.0.50",
|
||||||
|
"Port": 102,
|
||||||
|
"CpuType": "S71500",
|
||||||
|
"BlockCoalescingGapBytes": 16, // default
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Tuning guidance:
|
||||||
|
|
||||||
|
- **Raise the threshold (32-64 bytes)** when the PLC has chatty firmware
|
||||||
|
(S7-1200 with default 240-byte PDU and many DBs scattered every few
|
||||||
|
bytes). One fewer PDU round-trip beats over-fetching a kilobyte.
|
||||||
|
- **Lower the threshold (4-8 bytes)** when DBs are sparsely populated
|
||||||
|
with hot tags far apart — over-fetching dead bytes wastes the PDU
|
||||||
|
envelope and the saved round-trip never materialises.
|
||||||
|
- **Set to 0** to disable gap merging entirely (only literally adjacent
|
||||||
|
ranges with `gap == 0` coalesce). Useful as a debugging knob: if a
|
||||||
|
driver is misreading values you can flip the threshold to 0 to confirm
|
||||||
|
the slice math isn't the culprit.
|
||||||
|
- **Per-DB tuning isn't supported yet** — the knob is global per driver
|
||||||
|
instance. If a site needs different policies for two DBs they live in
|
||||||
|
different drivers (different `Host:Port` rows in the config DB).
|
||||||
|
|
||||||
|
#### Diagnostics counters
|
||||||
|
|
||||||
|
The driver surfaces three coalescing counters via `DriverHealth.Diagnostics`
|
||||||
|
under the standard `<DriverType>.<Counter>` naming convention:
|
||||||
|
|
||||||
|
- `S7.TotalBlockReads` — number of `Plc.ReadBytesAsync` calls issued by
|
||||||
|
the coalesced path. A fully-coalesced contiguous workload bumps this
|
||||||
|
by 1 per `ReadAsync`.
|
||||||
|
- `S7.TotalMultiVarBatches` — `Plc.ReadMultipleVarsAsync` batches issued
|
||||||
|
for residual singletons that didn't merge. With perfect coalescing this
|
||||||
|
stays at 0.
|
||||||
|
- `S7.TotalSingleReads` — per-tag fallbacks (strings, dates, arrays,
|
||||||
|
64-bit ints, anything that bypasses both the coalescer and the packer).
|
||||||
|
|
||||||
|
Observe via the `driver-diagnostics` RPC (`/api/v2/drivers/{id}/diagnostics`)
|
||||||
|
or the Admin UI's per-driver dashboard.
|
||||||
|
|
||||||
|
### Diagnostics surfacing
|
||||||
|
|
||||||
|
Beyond the coalescing counters above, the S7 driver also surfaces the
|
||||||
|
**negotiated PDU size** captured during the COTP/S7comm handshake under the
|
||||||
|
same `<DriverType>.<Counter>` naming convention:
|
||||||
|
|
||||||
|
- `S7.NegotiatedPduSize` — the PDU envelope size advertised by the CPU
|
||||||
|
during `Plc.OpenAsync`. Default S7-1500 CPUs negotiate **240 bytes**;
|
||||||
|
CPUs running the extended PDU advertise **480 or 960 bytes**. The value
|
||||||
|
is `0` before the first successful connect and is reset to `0` on
|
||||||
|
driver shutdown so an operator inspecting the Admin UI dashboard can
|
||||||
|
immediately tell whether the driver is currently online.
|
||||||
|
|
||||||
|
Together these counters answer the most common operator questions about
|
||||||
|
S7 driver health without reaching for a Wireshark capture:
|
||||||
|
|
||||||
|
- "Is the driver actually connected?" → `S7.NegotiatedPduSize > 0`
|
||||||
|
- "Is coalescing working?" → `S7.TotalBlockReads` climbing while
|
||||||
|
`S7.TotalMultiVarBatches` stays flat
|
||||||
|
- "Why is throughput poor?" → `S7.NegotiatedPduSize` is 240 instead of 960
|
||||||
|
(operator can switch the CPU to extended PDU if the project allows)
|
||||||
|
|
||||||
|
The values render alongside Modbus / OPC UA Client metrics in the Admin
|
||||||
|
UI driver-diagnostics panel — same RPC, same dashboard row layout.
|
||||||
|
|
||||||
|
### Per-tag scan groups
|
||||||
|
|
||||||
|
Before PR-S7-C3, `ISubscribable.SubscribeAsync` took **one** publishing
|
||||||
|
interval and applied it to every tag in the input list. A site that wanted
|
||||||
|
mixed cadences — say a 100 ms HMI pulse, a 1 s dashboard tile, and a 10 s
|
||||||
|
slow-poll for trend data — had to issue **three separate subscribe calls**,
|
||||||
|
each with its own list of tags. That works, but it pushes the partitioning
|
||||||
|
problem up to the caller (the OPC UA address space layer) and means an
|
||||||
|
operator can't express "this tag is slow-poll" purely in driver config.
|
||||||
|
|
||||||
|
PR-S7-C3 adds **per-tag scan groups** so a single `SubscribeAsync` call
|
||||||
|
naturally splits into N independent poll loops:
|
||||||
|
|
||||||
|
- `S7TagDefinition.ScanGroup` (string, optional) — the group identifier the
|
||||||
|
tag belongs to. Tags with no group (or with a group not declared in the
|
||||||
|
rate map below) keep the legacy behaviour and inherit the
|
||||||
|
subscription-default publishing interval.
|
||||||
|
- `S7DriverOptions.ScanGroupIntervals` (`IReadOnlyDictionary<string, TimeSpan>`,
|
||||||
|
optional) — the rate map. Group names are matched case-insensitively. Any
|
||||||
|
group with a non-positive interval (≤ 0 ms) is silently dropped at config
|
||||||
|
load and tags falling back to that group land in the default partition.
|
||||||
|
|
||||||
|
At subscribe time the driver buckets the input tag list by **resolved
|
||||||
|
publishing interval** (per-tag group → map lookup → fallback to the
|
||||||
|
subscription default), then spins up one background poll loop per distinct
|
||||||
|
interval. Each loop owns its own `CancellationTokenSource` and its own
|
||||||
|
`LastValues` cache; `UnsubscribeAsync` cancels and disposes every per-group
|
||||||
|
loop together so a multi-rate subscription can't leak background tasks.
|
||||||
|
|
||||||
|
#### JSON config example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Host": "10.0.0.50",
|
||||||
|
"ScanGroupIntervalsMs": {
|
||||||
|
"Fast": 100,
|
||||||
|
"Medium": 1000,
|
||||||
|
"Slow": 10000
|
||||||
|
},
|
||||||
|
"Tags": [
|
||||||
|
{ "Name": "PressureSetpoint", "Address": "DB1.DBW0", "DataType": "Int16", "ScanGroup": "Fast" },
|
||||||
|
{ "Name": "BatchTotal", "Address": "DB1.DBD10", "DataType": "Int32", "ScanGroup": "Medium" },
|
||||||
|
{ "Name": "TrendBucket", "Address": "DB1.DBD20", "DataType": "Float32", "ScanGroup": "Slow" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A single `SubscribeAsync(["PressureSetpoint","BatchTotal","TrendBucket"], 1s)`
|
||||||
|
call against this driver produces **three independent poll loops** —
|
||||||
|
the fast HMI tag ticks at 100 ms, the dashboard tile at 1 s, the trend
|
||||||
|
bucket at 10 s. The caller-supplied 1 s default is unused because every
|
||||||
|
tag carries an explicit group.
|
||||||
|
|
||||||
|
#### 100 ms floor applies per partition
|
||||||
|
|
||||||
|
The `100 ms` floor that protects the S7 mailbox from sub-scan polling
|
||||||
|
applies to **both** the subscription default **and** every per-group rate.
|
||||||
|
A typo'd entry like `{"TooFast": 25}` is silently floored to 100 ms at
|
||||||
|
partition-build time — the driver never schedules a sub-100 ms `Task.Delay`
|
||||||
|
even if the operator tries.
|
||||||
|
|
||||||
|
#### `_gate` contention caveat — "1 connection / 1 mailbox"
|
||||||
|
|
||||||
|
Partitioning into N poll loops does **not** parallelise wire-level reads.
|
||||||
|
S7netplus's documented pattern is one `Plc` instance per CPU, and the
|
||||||
|
driver enforces that with a per-instance `SemaphoreSlim` (`_gate`) that
|
||||||
|
every read takes before touching the socket. All N partitions share the
|
||||||
|
same gate, so the **mailbox is still strictly serial** — what the multi-rate
|
||||||
|
split actually buys you is **cadence decoupling**:
|
||||||
|
|
||||||
|
- Before PR-S7-C3: every tag ticked at the slowest configured interval (or
|
||||||
|
required three separate subscribe calls and three separate logical
|
||||||
|
subscription handles, complicating the address-space layer).
|
||||||
|
- After PR-S7-C3: a 100 ms HMI tag isn't blocked behind a 10 s slow-poll
|
||||||
|
batch's `Task.Delay`. While Slow is sleeping, the gate is free and Fast
|
||||||
|
acquires it, polls, releases. The CPU sees more frequent small requests
|
||||||
|
rather than infrequent large ones — which is what you want for a
|
||||||
|
responsive HMI surface.
|
||||||
|
|
||||||
|
The caveat to be aware of: if Fast's per-tick read takes longer than its
|
||||||
|
tick interval (e.g. 100 ms tick but 200 ms gate-held read because Medium
|
||||||
|
or Slow happens to be mid-read on the gate), Fast's effective cadence
|
||||||
|
slows to "as fast as the gate lets me." That's a property of S7netplus's
|
||||||
|
single-connection design, not of partitioning — three separate driver
|
||||||
|
instances against the same CPU would just waste the CPU's
|
||||||
|
8-64-connection-resource budget without speeding anything up.
|
||||||
|
|
||||||
|
#### Diagnostics
|
||||||
|
|
||||||
|
Partition counts aren't yet surfaced under
|
||||||
|
`DriverHealth.Diagnostics` (planned for a follow-up alongside per-partition
|
||||||
|
tick rate). Tests can call the internal helpers `S7Driver.GetPartitionCount`
|
||||||
|
and `S7Driver.GetPartitionSummary` to inspect the resolved partitioning of
|
||||||
|
a live subscription handle.
|
||||||
|
|
||||||
|
### Deadband / on-change
|
||||||
|
|
||||||
|
Before PR-S7-C4 the subscription poll loop emitted `OnDataChange` whenever
|
||||||
|
the freshly-read value differed from the last cached one — a strict
|
||||||
|
`!Equals(prev, current)` test. That's correct for booleans and discrete
|
||||||
|
state, but for analog tags (Float32 / Float64 / scaled integer set-points)
|
||||||
|
it floods the OPC UA subscription queue with insignificant noise: the last
|
||||||
|
counts of an ADC's least-significant-bit jitter, sub-percent setpoint drift,
|
||||||
|
sensor-grade flutter on a flow rate. PR-S7-C4 lets the operator configure
|
||||||
|
**per-tag deadband thresholds** so the driver suppresses uninteresting
|
||||||
|
publishes at source, before they cross the OPC UA boundary.
|
||||||
|
|
||||||
|
Two knobs, both optional, both per-tag:
|
||||||
|
|
||||||
|
- `DeadbandAbsolute` (`double?`) — minimum value change in raw units.
|
||||||
|
Suppress when `|new - prev| < DeadbandAbsolute`.
|
||||||
|
- `DeadbandPercent` (`double?`, 0..100) — minimum value change as a
|
||||||
|
percentage of the previous published value. Suppress when
|
||||||
|
`|new - prev| < |prev| * DeadbandPercent / 100`.
|
||||||
|
|
||||||
|
When both knobs are set the filters are **OR'd** — the value publishes if
|
||||||
|
**either** threshold says publish. This matches Kepware's documented
|
||||||
|
"either threshold triggers" semantics and mirrors the AbLegacy driver's
|
||||||
|
shipped behaviour for cross-driver consistency.
|
||||||
|
|
||||||
|
#### JSON config example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Host": "10.0.0.50",
|
||||||
|
"Tags": [
|
||||||
|
{ "Name": "BoilerPressure", "Address": "DB1.DBD0", "DataType": "Float32",
|
||||||
|
"DeadbandAbsolute": 0.5 },
|
||||||
|
|
||||||
|
{ "Name": "FlowRate", "Address": "DB1.DBD4", "DataType": "Float32",
|
||||||
|
"DeadbandPercent": 1.0 },
|
||||||
|
|
||||||
|
{ "Name": "Temperature", "Address": "DB1.DBD8", "DataType": "Float32",
|
||||||
|
"DeadbandAbsolute": 0.1, "DeadbandPercent": 0.5 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`BoilerPressure` only republishes after a 0.5-bar change; `FlowRate` only
|
||||||
|
when the rate moves by more than 1% of its last published value;
|
||||||
|
`Temperature` whenever **either** `0.1 °C absolute` **or** `0.5% of last`
|
||||||
|
is satisfied.
|
||||||
|
|
||||||
|
#### Edge cases
|
||||||
|
|
||||||
|
- **First sample.** `PollOnceAsync` gates `forceRaise` and the
|
||||||
|
no-prior-value case ahead of the deadband filter — the first sample for
|
||||||
|
a tag always publishes (otherwise an OPC UA subscription would never see
|
||||||
|
an initial-data push).
|
||||||
|
- **Status-code change.** Any transition in the OPC UA `StatusCode` channel
|
||||||
|
(`Bad → Good`, `Good → Bad`, etc.) bypasses deadband and publishes,
|
||||||
|
because quality is a semantically different signal from value.
|
||||||
|
- **Non-numeric types.** `String` / `WString` / `Char` / `WChar` /
|
||||||
|
`DateTime` / byte-array tags ignore deadband entirely and keep the
|
||||||
|
legacy `!Equals` semantics. Configuring `DeadbandAbsolute` on a
|
||||||
|
`String` tag is harmless — the filter just doesn't engage.
|
||||||
|
- **`NaN` samples.** If either `prev` or `current` is `NaN`, the filter
|
||||||
|
publishes. NaN never equals NaN; treating it as "changed" surfaces the
|
||||||
|
degenerate float to the client rather than hiding it.
|
||||||
|
- **`±Infinity` samples.** Same rationale as NaN — degenerate values are
|
||||||
|
always published, never deadbanded.
|
||||||
|
- **Sign flip.** A tag swinging `+10 → -10` produces `|delta|=20`; the
|
||||||
|
deadband math operates on the **absolute** delta so a sign flip with
|
||||||
|
`DeadbandAbsolute=1` always publishes. This is the right answer for
|
||||||
|
bidirectional set-points (positive / negative torque, valve-direction
|
||||||
|
flags encoded as signed scalars).
|
||||||
|
- **Near-zero baseline (`|prev| < 1e-6`).** A percent threshold against a
|
||||||
|
zero or near-zero baseline diverges (any tiny change is "infinity
|
||||||
|
percent"), so the driver falls back to absolute when `|prev| < 1e-6`:
|
||||||
|
- If `DeadbandAbsolute` is also configured, that threshold takes over.
|
||||||
|
- If only `DeadbandPercent` is set (no absolute fallback), the sample
|
||||||
|
publishes — there's no usable threshold and silently dropping changes
|
||||||
|
against a near-zero baseline would mask a genuine signal.
|
||||||
|
|
||||||
|
The `1e-6` cutoff is a deliberately conservative floor: floats below
|
||||||
|
`~1e-7` are already in denormal-precision territory; anything above
|
||||||
|
`~1e-6` carries enough magnitude that `|prev| * pct / 100` produces a
|
||||||
|
meaningful threshold.
|
||||||
|
|
||||||
|
#### Implementation notes
|
||||||
|
|
||||||
|
- The filter is the pure-function helper `S7Driver.ShouldPublish(tag,
|
||||||
|
prev, current)`. It's exposed at `internal` scope so unit tests can
|
||||||
|
drive every decision branch (NaN, ±Inf, sign flip, near-zero baseline,
|
||||||
|
both-set OR semantics) without spinning up a partition or poll loop.
|
||||||
|
- `LastValues` continues to cache the **last published** snapshot, not
|
||||||
|
the last polled one. After a deadband suppression the next sample
|
||||||
|
compares against the cached (previously published) value, so a slow
|
||||||
|
drift that never crosses the threshold in any single tick still gets
|
||||||
|
caught the moment cumulative drift exceeds the threshold.
|
||||||
|
- Deadband is a **publish-time** filter, not a wire-level one — every
|
||||||
|
configured tag is still read every tick, the filter only decides
|
||||||
|
whether to invoke `OnDataChange`. The mailbox / PDU / coalescing path
|
||||||
|
is untouched.
|
||||||
|
|
||||||
|
## Pre-flight PUT/GET enablement
|
||||||
|
|
||||||
|
S7-1200 / S7-1500 CPUs ship with **PUT/GET communication disabled by
|
||||||
|
default**. The COTP / S7comm handshake itself succeeds against these
|
||||||
|
locked-down CPUs (you can `OpenAsync` / negotiate PDU size cleanly), so
|
||||||
|
the failure surfaces only on the *first* `Plc.ReadAsync` — at which
|
||||||
|
point the driver is already past `InitializeAsync`, has flipped to
|
||||||
|
`DriverState.Healthy`, and dependent code (subscriptions, Admin UI) is
|
||||||
|
binding against a connection it can't actually use. Operators see
|
||||||
|
`BadDeviceFailure` per tag instead of a single, actionable
|
||||||
|
configuration error.
|
||||||
|
|
||||||
|
PR-S7-C5 adds a **post-`OpenAsync` pre-flight probe**: a tiny 2-byte
|
||||||
|
read against `Probe.ProbeAddress` (default `MW0`). If the PLC rejects
|
||||||
|
that read with the wire-level "function not allowed in current
|
||||||
|
operating state" response (S7 error family `D6 05` / `85 00`),
|
||||||
|
S7netplus surfaces the rejection as `PlcException` with one of
|
||||||
|
`ErrorCode.WrongCPU_Type` (CPU drops the connection mid-response) or
|
||||||
|
`ErrorCode.ReadData` (CPU sends an S7-level error byte). The driver
|
||||||
|
classifies that pair as "PUT/GET disabled" and throws a typed
|
||||||
|
`S7PutGetDisabledException` from `InitializeAsync` so the operator sees
|
||||||
|
the TIA-Portal fix path immediately:
|
||||||
|
|
||||||
|
> PUT/GET communication is disabled on the PLC. Enable it in TIA Portal:
|
||||||
|
> *Device → Properties → Protection & Security → Connection mechanisms →
|
||||||
|
> "Permit access with PUT/GET communication from remote partner"*.
|
||||||
|
> Re-deploy the hardware config and restart the S7 driver.
|
||||||
|
|
||||||
|
`S7PreflightClassifier.IsPutGetDisabled(PlcException)` is the pure
|
||||||
|
function that decides whether a given `PlcException` qualifies; it
|
||||||
|
matches **only** `WrongCPU_Type` and `ReadData`. Other error codes
|
||||||
|
(`ConnectionError`, `IPAddressNotAvailable`, `WrongVarFormat`, …)
|
||||||
|
indicate transport / framing faults rather than PUT/GET gating, so the
|
||||||
|
driver re-throws the original `PlcException` unchanged and the existing
|
||||||
|
`DriverState.Faulted` path takes over with the original message.
|
||||||
|
|
||||||
|
### Knobs
|
||||||
|
|
||||||
|
Two opt-out knobs on `S7ProbeOptions`:
|
||||||
|
|
||||||
|
- `ProbeAddress` (`string?`, default `"MW0"`) — address probed by both
|
||||||
|
the background liveness loop and the pre-flight read. Set to `null`
|
||||||
|
(or empty string in JSON) to skip the pre-flight entirely. Useful
|
||||||
|
for sites where no fingerprint address has been wired and an arbitrary
|
||||||
|
read at `MW0` would itself be misleading.
|
||||||
|
- `SkipPreflight` (`bool`, default `false`) — opt out of the pre-flight
|
||||||
|
read while keeping the background probe. Init succeeds against a
|
||||||
|
PUT/GET-disabled CPU; per-tag reads still surface `BadDeviceFailure`
|
||||||
|
at runtime. Useful for staged deployments where the operator hasn't
|
||||||
|
enabled PUT/GET yet but wants the driver visible in the Admin UI.
|
||||||
|
|
||||||
|
### Why `MW0`?
|
||||||
|
|
||||||
|
The convention from `Driver.S7.Cli.md`'s `probe` command. `MW0` exists
|
||||||
|
on every S7 CPU regardless of project — Merker memory is universal —
|
||||||
|
so it's a safe default that doesn't require a per-site DB to be wired.
|
||||||
|
Sites with a dedicated fingerprint DB can override to e.g.
|
||||||
|
`DB1.DBW0`.
|
||||||
|
|
||||||
|
### JSON config example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Host": "10.0.0.50",
|
||||||
|
"Probe": {
|
||||||
|
"Enabled": true,
|
||||||
|
"IntervalMs": 5000,
|
||||||
|
"TimeoutMs": 2000,
|
||||||
|
"ProbeAddress": "DB1.DBW0",
|
||||||
|
"SkipPreflight": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To skip the pre-flight (defer the check to first read):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Host": "10.0.0.50",
|
||||||
|
"Probe": { "SkipPreflight": true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To skip the probe entirely (no pre-flight, no liveness loop):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Host": "10.0.0.50",
|
||||||
|
"Probe": { "Enabled": false, "ProbeAddress": "" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## TSAP / Connection Type
|
||||||
|
|
||||||
|
S7comm runs on top of ISO-on-TCP (RFC 1006), and the COTP connection-request
|
||||||
|
PDU carries a 16-bit **TSAP pair** (local + remote) that the CPU validates
|
||||||
|
before any S7comm payload flows. S7netplus's default `Plc(CpuType, host, port,
|
||||||
|
rack, slot)` constructor picks a **PG-class** TSAP pair via
|
||||||
|
`TsapPair.GetDefaultTsapPair`. That choice works against most lab S7-1200 /
|
||||||
|
S7-1500 CPUs and against TIA Portal itself, but **hardened deployments**
|
||||||
|
(security-config'd S7-1500, ET 200SP, locked-down PROFINET projects) reject
|
||||||
|
PG class outright at COTP-handshake time, returning the same connection-refused
|
||||||
|
shape as a wrong slot byte.
|
||||||
|
|
||||||
|
PR-S7-C2 surfaces a `TsapMode` enum on `S7DriverOptions` so an operator can
|
||||||
|
force a specific class without re-flashing the PLC project. It applies equally
|
||||||
|
to the Admin-UI-driven config DB row and to the `otopcua-s7-cli` test client.
|
||||||
|
|
||||||
|
### Raw-TSAP byte table
|
||||||
|
|
||||||
|
The high byte is the connection class. The local low byte is conventionally
|
||||||
|
`0x00` (caller / unprivileged), and the remote low byte is
|
||||||
|
`(rack << 5) | slot` per the S7 spec — the same convention S7netplus's
|
||||||
|
`TsapPair.GetDefaultTsapPair(CpuType, rack, slot)` uses for the remote endpoint.
|
||||||
|
|
||||||
|
| Class | High byte | Local TSAP (rack=0/slot=0) | Remote TSAP (rack=0/slot=0) | Remote TSAP (rack=0/slot=2) | Typical use |
|
||||||
|
|----------|-----------|----------------------------|------------------------------|------------------------------|----------------------------------------------|
|
||||||
|
| PG | `0x01` | `0x0100` | `0x0100` | `0x0102` | TIA Portal, dev laptops, lab S7-1200/1500 |
|
||||||
|
| OP | `0x02` | `0x0200` | `0x0200` | `0x0202` | Operator panels, hardened-CPU S7-1500 |
|
||||||
|
| S7-Basic | `0x03` | `0x0300` | `0x0300` | `0x0302` | WinCC BasicPanel SDK, S7-Basic clients |
|
||||||
|
| Other | caller | caller-supplied | caller-supplied | caller-supplied | escape hatch — unusual fixed-TSAP firmware |
|
||||||
|
|
||||||
|
### `TsapMode` enum
|
||||||
|
|
||||||
|
| Mode | Behaviour |
|
||||||
|
|-----------|----------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `Auto` | Existing behaviour — S7netplus picks the TSAP pair from `CpuType`. Explicit `LocalTsap` / `RemoteTsap` are ignored under `Auto`. |
|
||||||
|
| `Pg` | Force PG class (high byte `0x01`). Local / remote computed from rack + slot. |
|
||||||
|
| `Op` | Force OP class (high byte `0x02`). |
|
||||||
|
| `S7Basic` | Force S7-Basic class (high byte `0x03`). |
|
||||||
|
| `Other` | Caller-supplied `LocalTsap` + `RemoteTsap`. Both must be set or driver init throws `InvalidOperationException`. |
|
||||||
|
|
||||||
|
Explicit `LocalTsap` / `RemoteTsap` overrides win over the class-derived
|
||||||
|
defaults under any non-`Auto` mode — a site that needs a fixed source-TSAP for
|
||||||
|
firewall reasons can pin `LocalTsap` while keeping `TsapMode = Pg` for the
|
||||||
|
remote computation.
|
||||||
|
|
||||||
|
### Worked example: hardened S7-1500 requiring OP class
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"Host": "10.50.12.30",
|
||||||
|
"CpuType": "S71500",
|
||||||
|
"Rack": 0,
|
||||||
|
"Slot": 0,
|
||||||
|
"TsapMode": "Op",
|
||||||
|
"Tags": [ /* … */ ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This produces local = `0x0200`, remote = `0x0200` (rack=0, slot=0). The same
|
||||||
|
PLC under `TsapMode = "Auto"` (PG class) returns COTP rejection — same packet
|
||||||
|
capture shape as a wrong-slot misconfig, which is the failure-mode footnote
|
||||||
|
under §5 of `driver-specs.md`.
|
||||||
|
|
||||||
|
### Why not just expose `LocalTsap` / `RemoteTsap` directly?
|
||||||
|
|
||||||
|
Most operators don't know the byte format off-hand and reach for `Pg` /
|
||||||
|
`Op` / `S7Basic` based on Siemens-doc terminology. Keeping the enum lets the
|
||||||
|
Admin UI render a dropdown with sensible labels, while the `ushort?` fields
|
||||||
|
stay available as the manual escape hatch when a site has truly unusual
|
||||||
|
firmware (e.g. third-party S7-protocol gateways with fixed proprietary
|
||||||
|
TSAPs). Both paths are exercised in the unit-test mapping table.
|
||||||
|
|
||||||
|
### Live-firmware verification
|
||||||
|
|
||||||
|
The PG/OP/S7-Basic byte table above is the documented Siemens convention; the
|
||||||
|
actual handshake is verified against the dev-box S7-1500 lab rig (a hardened
|
||||||
|
project that rejects PG and accepts OP). That test is documented in
|
||||||
|
`tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests` but only runs against
|
||||||
|
real firmware — the pymodbus-style "TSAP simulator" doesn't exist for S7.
|
||||||
|
|
||||||
|
## Symbol import
|
||||||
|
|
||||||
|
PR-S7-D1 / [#299](https://github.com/dohertj2/lmxopcua/issues/299) — bulk-import
|
||||||
|
TIA Portal "Show all tags" CSV exports and STEP 7 Classic AWL declaration files
|
||||||
|
into the S7 driver's tag list. Operators no longer hand-edit the
|
||||||
|
`Drivers/<instance>/Config/Tags` JSON for hundred-tag projects.
|
||||||
|
|
||||||
|
Two formats supported v1:
|
||||||
|
|
||||||
|
- **TIA Portal CSV** — `Name,Path,Data type,Logical address,Comment,Hmi accessible,…`.
|
||||||
|
en-US (`,`) and DE-locale (`;` separator + `,` decimal) auto-detected.
|
||||||
|
HMI-hidden symbols filter out automatically; UDT-typed rows import as
|
||||||
|
placeholders until PR-S7-D2 ships proper UDT layout.
|
||||||
|
- **STEP 7 Classic AWL** — `VAR_GLOBAL` + `DATA_BLOCK` declarations parsed
|
||||||
|
best-effort with position-based offset assignment.
|
||||||
|
|
||||||
|
Two surface options:
|
||||||
|
|
||||||
|
- **CLI**: `otopcua-s7-cli import-symbols --file foo.csv --format tia` emits
|
||||||
|
an `appsettings.json` JSON fragment for hand-merge.
|
||||||
|
- **API**: `S7DriverOptions.AddTiaCsvImport(path, out result)` /
|
||||||
|
`AddAwlImport(path, out result)` for server-side bootstrap paths.
|
||||||
|
|
||||||
|
Full reference: [`docs/drivers/S7-TIA-Import.md`](../drivers/S7-TIA-Import.md).
|
||||||
|
CLI flag table: [`docs/Driver.S7.Cli.md` "import-symbols"](../Driver.S7.Cli.md#import-symbols).
|
||||||
|
|
||||||
|
## UDT / STRUCT support
|
||||||
|
|
||||||
|
PR-S7-D2 / #300 — UDT-typed DBs are exposed via per-member fan-out at driver
|
||||||
|
init time. The driver reads / writes / subscribes only ever target scalar
|
||||||
|
leaves; the parent UDT pointer never reaches the wire. This keeps the rest of
|
||||||
|
the driver pipeline (address parser, block-coalescing planner, scan-group
|
||||||
|
partitioner, deadband filter) UDT-unaware.
|
||||||
|
|
||||||
|
### `S7UdtDefinition`
|
||||||
|
|
||||||
|
A UDT is declared once in `S7DriverOptions.Udts` and referenced by tags whose
|
||||||
|
`UdtName` is set:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
new S7UdtDefinition(
|
||||||
|
Name: "Pump",
|
||||||
|
Members: [
|
||||||
|
new S7UdtMember("Pressure", Offset: 0, S7DataType.Float32),
|
||||||
|
new S7UdtMember("Status", Offset: 4, S7DataType.Int16),
|
||||||
|
new S7UdtMember("Enabled", Offset: 6, S7DataType.Bool),
|
||||||
|
],
|
||||||
|
SizeBytes: 7);
|
||||||
|
```
|
||||||
|
|
||||||
|
Tags adopt the UDT layout via `UdtName`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
new S7TagDefinition("Pump1", "DB1.DBX0.0", S7DataType.Byte, UdtName: "Pump");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fan-out semantics
|
||||||
|
|
||||||
|
At `InitializeAsync` time the driver:
|
||||||
|
|
||||||
|
1. Walks `_options.Tags`. For each tag with `UdtName`, looks up the UDT in
|
||||||
|
`_options.Udts` (case-insensitive).
|
||||||
|
2. For each UDT member, computes `parent.Address.ByteOffset + member.Offset`
|
||||||
|
and emits one scalar `S7TagDefinition` per leaf with name
|
||||||
|
`Parent.Member` (dot-separated).
|
||||||
|
3. Array members emit `Member[0]`, `Member[1]`, ... at stride `elementBytes`.
|
||||||
|
4. Nested UDT members recurse — array-of-UDT walks at stride `inner.SizeBytes`.
|
||||||
|
5. The fanned-out leaves replace the parent UDT tag in the driver's tag map.
|
||||||
|
|
||||||
|
Reads / writes / subscribes that target the parent name surface
|
||||||
|
`BadNodeIdUnknown` — clients must address the leaves directly.
|
||||||
|
|
||||||
|
### 4-level nesting cap
|
||||||
|
|
||||||
|
UDT-of-UDT is supported up to 4 levels deep. Anything deeper throws
|
||||||
|
`InvalidOperationException("UDT nesting depth exceeds 4 levels…")` at Init.
|
||||||
|
This catches accidentally-recursive declarations early; real industrial UDTs
|
||||||
|
rarely go beyond 2 layers.
|
||||||
|
|
||||||
|
### Optimized block access — must be off
|
||||||
|
|
||||||
|
The static-offset model assumes member byte offsets in the declaration match
|
||||||
|
the runtime layout exactly. TIA Portal's "Optimized block access" flag lets
|
||||||
|
the runtime reorder members for memory alignment, breaking that assumption.
|
||||||
|
Same prerequisite as general absolute-offset DB addressing on S7-1200 / 1500:
|
||||||
|
**Optimized block access must be disabled** on any DB that the driver
|
||||||
|
addresses by absolute offset, including UDT-typed DBs.
|
||||||
|
|
||||||
|
If a customer can't disable Optimized access (e.g., shared-DB constraints),
|
||||||
|
the workaround is to expose the UDT through the symbolic-tag path once that
|
||||||
|
ships — not in PR-S7-D2.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
The fan-out rejects, with clear errors:
|
||||||
|
|
||||||
|
- UDT name not found in `Udts` collection
|
||||||
|
- Member offsets not in ascending order
|
||||||
|
- Member offsets that overlap (a primitive's `[offset, offset+width)` range
|
||||||
|
intersects the next member's offset)
|
||||||
|
- Total members extending past `SizeBytes`
|
||||||
|
- Tag with `UdtName` AND `ElementCount > 1` (array-of-UDT belongs in the UDT
|
||||||
|
layout, not at the parent-tag level)
|
||||||
|
|
||||||
|
### Re-import on UDT / FB-interface edit — caveat
|
||||||
|
|
||||||
|
The static-offset model assumes the declared layout matches the runtime
|
||||||
|
layout exactly. When the underlying UDT or FB interface changes in TIA Portal
|
||||||
|
— a member added, removed, or reordered — the byte offsets shift on the PLC
|
||||||
|
side and the cached `S7UdtDefinition` / instance-DB addresses point at the
|
||||||
|
wrong member.
|
||||||
|
|
||||||
|
**The driver does not auto-detect interface drift.** After any UDT edit or
|
||||||
|
multi-instance-FB interface edit on the PLC side, the operator must:
|
||||||
|
|
||||||
|
1. Recompile + download the updated program in TIA Portal.
|
||||||
|
2. Re-export "Show all tags" CSV from the updated project.
|
||||||
|
3. Re-import via `AddTiaCsvImport` (or `import-symbols` CLI) and update the
|
||||||
|
matching `S7UdtDefinition` declarations to mirror the new offsets.
|
||||||
|
4. Restart the driver instance (Admin UI → Drivers → Reload).
|
||||||
|
|
||||||
|
A stale UDT layout will silently read / write the wrong byte offsets — the
|
||||||
|
values will look like valid PLC data but reference whichever member used to
|
||||||
|
live at that offset before the edit. The same caveat applies to multi-instance
|
||||||
|
FB-instance DBs imported via PR-S7-D3 / [#301](https://github.com/dohertj2/lmxopcua/issues/301);
|
||||||
|
see [`docs/drivers/S7-TIA-Import.md` "Re-import on FB-interface edit"](../drivers/S7-TIA-Import.md#re-import-on-fb-interface-edit--caveat)
|
||||||
|
for the FB-instance-specific workflow.
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
1. Siemens Industry Online Support, *Modbus/TCP Communication between SIMATIC S7-1500 / S7-1200 and Modbus/TCP Controllers with Instructions `MB_CLIENT` and `MB_SERVER`*, Entry ID 102020340, V6 (Feb 2021). https://cache.industry.siemens.com/dl/files/340/102020340/att_118119/v6/net_modbus_tcp_s7-1500_s7-1200_en.pdf
|
1. Siemens Industry Online Support, *Modbus/TCP Communication between SIMATIC S7-1500 / S7-1200 and Modbus/TCP Controllers with Instructions `MB_CLIENT` and `MB_SERVER`*, Entry ID 102020340, V6 (Feb 2021). https://cache.industry.siemens.com/dl/files/340/102020340/att_118119/v6/net_modbus_tcp_s7-1500_s7-1200_en.pdf
|
||||||
|
|||||||
151
scripts/compliance/phase-7-compliance.ps1
Normal file
151
scripts/compliance/phase-7-compliance.ps1
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Phase 7 exit-gate compliance check. Each check either passes or records a failure;
|
||||||
|
non-zero exit = fail.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Validates Phase 7 (scripting runtime + virtual tags + scripted alarms + historian
|
||||||
|
alarm sink + Admin UI + address-space integration) per
|
||||||
|
`docs/v2/implementation/phase-7-scripting-and-alarming.md`.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Usage: pwsh ./scripts/compliance/phase-7-compliance.ps1
|
||||||
|
Exit: 0 = all checks passed; non-zero = one or more FAILs
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param()
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$script:failures = 0
|
||||||
|
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||||
|
|
||||||
|
function Assert-Pass { param([string]$C) Write-Host " [PASS] $C" -ForegroundColor Green }
|
||||||
|
function Assert-Fail { param([string]$C, [string]$R) Write-Host " [FAIL] $C - $R" -ForegroundColor Red; $script:failures++ }
|
||||||
|
function Assert-Deferred { param([string]$C, [string]$P) Write-Host " [DEFERRED] $C (follow-up: $P)" -ForegroundColor Yellow }
|
||||||
|
|
||||||
|
function Assert-FileExists {
|
||||||
|
param([string]$C, [string]$P)
|
||||||
|
if (Test-Path (Join-Path $repoRoot $P)) { Assert-Pass "$C ($P)" }
|
||||||
|
else { Assert-Fail $C "missing file: $P" }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-TextFound {
|
||||||
|
param([string]$C, [string]$Pat, [string[]]$Paths)
|
||||||
|
foreach ($p in $Paths) {
|
||||||
|
$full = Join-Path $repoRoot $p
|
||||||
|
if (-not (Test-Path $full)) { continue }
|
||||||
|
if (Select-String -Path $full -Pattern $Pat -Quiet) {
|
||||||
|
Assert-Pass "$C (matched in $p)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Assert-Fail $C "pattern '$Pat' not found in any of: $($Paths -join ', ')"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=== Phase 7 compliance - scripting + virtual tags + scripted alarms + historian ===" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
Write-Host "Stream A - Core.Scripting (Roslyn + sandbox + AST inference + logger)"
|
||||||
|
Assert-FileExists "Core.Scripting project" "src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"
|
||||||
|
Assert-TextFound "ScriptSandbox allow-list anchored on ScriptContext assembly" "contextType\.Assembly" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs")
|
||||||
|
Assert-TextFound "ForbiddenTypeAnalyzer defense-in-depth (plan decision #6)" "class ForbiddenTypeAnalyzer" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ForbiddenTypeAnalyzer.cs")
|
||||||
|
Assert-TextFound "DependencyExtractor rejects non-literal paths (plan decision #7)" "class DependencyExtractor" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs")
|
||||||
|
Assert-TextFound "Per-script log companion sink forwards Error+ to main log (plan decision #12)" "class ScriptLogCompanionSink" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLogCompanionSink.cs")
|
||||||
|
Assert-TextFound "ScriptContext static Deadband helper" "static bool Deadband" @("src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptContext.cs")
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Stream B - Core.VirtualTags (dependency graph + change/timer + source)"
|
||||||
|
Assert-FileExists "Core.VirtualTags project" "src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"
|
||||||
|
Assert-TextFound "DependencyGraph iterative Tarjan SCC (no stack overflow on 10k chains)" "class DependencyGraph" @("src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/DependencyGraph.cs")
|
||||||
|
Assert-TextFound "VirtualTagEngine SemaphoreSlim async-safe cascade" "SemaphoreSlim" @("src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs")
|
||||||
|
Assert-TextFound "VirtualTagSource IReadable + ISubscribable per ADR-002" "class VirtualTagSource" @("src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs")
|
||||||
|
Assert-TextFound "TimerTriggerScheduler groups by interval" "class TimerTriggerScheduler" @("src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/TimerTriggerScheduler.cs")
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Stream C - Core.ScriptedAlarms (Part 9 state machine + predicate engine + IAlarmSource)"
|
||||||
|
Assert-FileExists "Core.ScriptedAlarms project" "src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"
|
||||||
|
Assert-TextFound "Part9StateMachine pure functions" "class Part9StateMachine" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs")
|
||||||
|
Assert-TextFound "Alarm condition state with GxP audit Comments list" "Comments" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmConditionState.cs")
|
||||||
|
Assert-TextFound "MessageTemplate {TagPath} substitution (plan decision #13)" "class MessageTemplate" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs")
|
||||||
|
Assert-TextFound "AlarmPredicateContext rejects SetVirtualTag (predicates must be pure)" "class AlarmPredicateContext" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmPredicateContext.cs")
|
||||||
|
Assert-TextFound "ScriptedAlarmSource implements IAlarmSource" "class ScriptedAlarmSource" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs")
|
||||||
|
Assert-TextFound "IAlarmStateStore abstraction + in-memory default" "class InMemoryAlarmStateStore" @("src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/IAlarmStateStore.cs")
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Stream D - Core.AlarmHistorian (SQLite store-and-forward + Galaxy.Host IPC contracts)"
|
||||||
|
Assert-FileExists "Core.AlarmHistorian project" "src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"
|
||||||
|
Assert-TextFound "SqliteStoreAndForwardSink backoff ladder (1s..60s cap)" "BackoffLadder" @("src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs")
|
||||||
|
Assert-TextFound "Default 1M row capacity + 30-day dead-letter retention (plan decision #21)" "DefaultDeadLetterRetention" @("src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/SqliteStoreAndForwardSink.cs")
|
||||||
|
Assert-TextFound "Per-event outcomes (Ack/RetryPlease/PermanentFail)" "HistorianWriteOutcome" @("src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/IAlarmHistorianSink.cs")
|
||||||
|
Assert-TextFound "Galaxy.Host IPC contract HistorianAlarmEventRequest" "class HistorianAlarmEventRequest" @("src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/HistorianAlarms.cs")
|
||||||
|
Assert-TextFound "Historian connectivity status notification" "HistorianConnectivityStatusNotification" @("src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/HistorianAlarms.cs")
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Stream E - Config DB schema"
|
||||||
|
Assert-FileExists "Script entity" "src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Script.cs"
|
||||||
|
Assert-FileExists "VirtualTag entity" "src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/VirtualTag.cs"
|
||||||
|
Assert-FileExists "ScriptedAlarm entity" "src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ScriptedAlarm.cs"
|
||||||
|
Assert-FileExists "ScriptedAlarmState entity (logical-id keyed per plan decision #14)" "src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ScriptedAlarmState.cs"
|
||||||
|
Assert-TextFound "VirtualTag trigger check constraint (change OR timer)" "CK_VirtualTag_Trigger_AtLeastOne" @("src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs")
|
||||||
|
Assert-TextFound "ScriptedAlarm severity range check" "CK_ScriptedAlarm_Severity_Range" @("src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs")
|
||||||
|
Assert-TextFound "ScriptedAlarm type enum check" "CK_ScriptedAlarm_AlarmType" @("src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs")
|
||||||
|
Assert-TextFound "ScriptedAlarmState.CommentsJson is ISJSON (GxP audit)" "CK_ScriptedAlarmState_CommentsJson_IsJson" @("src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs")
|
||||||
|
Assert-FileExists "Phase 7 migration present" "src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260420231641_AddPhase7ScriptingTables.cs"
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Stream F - Admin UI (services + Monaco editor + test harness + historian diagnostics)"
|
||||||
|
Assert-FileExists "ScriptService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptService.cs"
|
||||||
|
Assert-FileExists "VirtualTagService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/VirtualTagService.cs"
|
||||||
|
Assert-FileExists "ScriptedAlarmService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptedAlarmService.cs"
|
||||||
|
Assert-FileExists "ScriptTestHarnessService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptTestHarnessService.cs"
|
||||||
|
Assert-FileExists "HistorianDiagnosticsService" "src/ZB.MOM.WW.OtOpcUa.Admin/Services/HistorianDiagnosticsService.cs"
|
||||||
|
Assert-FileExists "ScriptEditor Razor component" "src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptEditor.razor"
|
||||||
|
Assert-FileExists "ScriptsTab Razor component" "src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ScriptsTab.razor"
|
||||||
|
Assert-FileExists "AlarmsHistorian diagnostics page" "src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/AlarmsHistorian.razor"
|
||||||
|
Assert-FileExists "Monaco loader (CDN progressive enhancement)" "src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/js/monaco-loader.js"
|
||||||
|
Assert-TextFound "Scripts tab wired into DraftEditor" "ScriptsTab " @("src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/DraftEditor.razor")
|
||||||
|
Assert-TextFound "Harness enforces declared-inputs-only contract (plan decision #22)" "UnknownInputs" @("src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptTestHarnessService.cs")
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Stream G - Address-space integration"
|
||||||
|
Assert-TextFound "NodeSourceKind discriminator in DriverAttributeInfo" "enum NodeSourceKind" @("src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs")
|
||||||
|
Assert-TextFound "Walker emits VirtualTag variables with Source=Virtual" "AddVirtualTagVariable" @("src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs")
|
||||||
|
Assert-TextFound "Walker emits ScriptedAlarm variables with Source=ScriptedAlarm + IsAlarm" "AddScriptedAlarmVariable" @("src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs")
|
||||||
|
Assert-TextFound "EquipmentNamespaceContent carries VirtualTags + ScriptedAlarms" "VirtualTags" @("src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs")
|
||||||
|
Assert-TextFound "DriverNodeManager selects IReadable by source kind" "SelectReadable" @("src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs")
|
||||||
|
Assert-TextFound "Virtual/ScriptedAlarm writes rejected (plan decision #6)" "IsWriteAllowedBySource" @("src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs")
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Deferred surfaces"
|
||||||
|
Assert-Deferred "SealedBootstrap composition root wiring (VirtualTagEngine + ScriptedAlarmEngine + SqliteStoreAndForwardSink)" "task #239"
|
||||||
|
Assert-Deferred "Live OPC UA end-to-end test (virtual-tag Read + scripted-alarm Ack via method node)" "task #240"
|
||||||
|
Assert-Deferred "sp_ComputeGenerationDiff extension for Script/VirtualTag/ScriptedAlarm sections" "task #241"
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Cross-cutting"
|
||||||
|
Write-Host " Running full solution test suite..." -ForegroundColor DarkGray
|
||||||
|
$prevPref = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
$testOutput = & dotnet test (Join-Path $repoRoot 'ZB.MOM.WW.OtOpcUa.slnx') --nologo 2>&1
|
||||||
|
$ErrorActionPreference = $prevPref
|
||||||
|
$passLine = $testOutput | Select-String 'Passed:\s+(\d+)' -AllMatches
|
||||||
|
$failLine = $testOutput | Select-String 'Failed:\s+(\d+)' -AllMatches
|
||||||
|
$passCount = 0; foreach ($m in $passLine.Matches) { $passCount += [int]$m.Groups[1].Value }
|
||||||
|
$failCount = 0; foreach ($m in $failLine.Matches) { $failCount += [int]$m.Groups[1].Value }
|
||||||
|
|
||||||
|
# Phase 6.4 exit-gate baseline was 1137; Phase 7 adds ~197 across 7 streams.
|
||||||
|
$baseline = 1300
|
||||||
|
if ($passCount -ge $baseline) { Assert-Pass "No test-count regression ($passCount >= $baseline pre-Phase-7-exit baseline)" }
|
||||||
|
else { Assert-Fail "Test-count regression" "passed $passCount < baseline $baseline" }
|
||||||
|
|
||||||
|
if ($failCount -le 1) { Assert-Pass "No new failing tests (pre-existing CLI flake tolerated)" }
|
||||||
|
else { Assert-Fail "New failing tests" "$failCount failures > 1 tolerated" }
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
if ($script:failures -eq 0) {
|
||||||
|
Write-Host "Phase 7 compliance: PASS" -ForegroundColor Green
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
Write-Host "Phase 7 compliance: $script:failures FAIL(s)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
179
scripts/e2e/README.md
Normal file
179
scripts/e2e/README.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# E2E CLI test scripts
|
||||||
|
|
||||||
|
End-to-end black-box tests that drive each protocol through its driver CLI
|
||||||
|
and verify the resulting OPC UA address-space state through
|
||||||
|
`otopcua-cli`. They answer one question per driver:
|
||||||
|
|
||||||
|
> **If I poke the real PLC through the driver, does the running OtOpcUa
|
||||||
|
> server see the change?**
|
||||||
|
|
||||||
|
This is the acceptance gate v1 was missing — the driver-level integration
|
||||||
|
tests (`tests/.../IntegrationTests/`) confirm the driver sees the PLC, and
|
||||||
|
the OPC UA `Client.CLI.Tests` confirm the client sees the server — but
|
||||||
|
nothing glued them end-to-end. These scripts close that loop.
|
||||||
|
|
||||||
|
## Five-stage test per driver
|
||||||
|
|
||||||
|
Every per-driver script runs the same five tests. The goal is to prove
|
||||||
|
**both directions** across the bridge plus subscription delivery —
|
||||||
|
forward-only coverage would miss writable-flag drops, `IWritable`
|
||||||
|
dispatch bugs, and broken data-change notification paths where a fresh
|
||||||
|
read still returns the right value.
|
||||||
|
|
||||||
|
1. **`probe`** — driver CLI opens a session + reads a sentinel. Confirms
|
||||||
|
the simulator / PLC is reachable and speaking the protocol.
|
||||||
|
2. **Driver loopback** — write a random value via the driver CLI, read
|
||||||
|
it back via the same CLI. Confirms the driver round-trips without
|
||||||
|
involving the OPC UA server. A failure here is a driver bug, not a
|
||||||
|
server-bridge bug.
|
||||||
|
3. **Forward bridge (driver → server → client)** — write a different
|
||||||
|
random value via the driver CLI, wait `--ServerPollDelaySec` (default
|
||||||
|
3s), read the OPC UA NodeId the server publishes that tag at via
|
||||||
|
`otopcua-cli read`. Confirms reads propagate from PLC to OPC UA
|
||||||
|
client.
|
||||||
|
4. **Reverse bridge (client → server → driver)** — write a fresh random
|
||||||
|
value via `otopcua-cli write` against the same NodeId, wait
|
||||||
|
`--DriverPollDelaySec` (default 3s), read the PLC-side via the
|
||||||
|
driver CLI. Confirms writes propagate the other way — catches
|
||||||
|
writable-flag drops, ACL misconfiguration, and `IWritable` dispatch
|
||||||
|
bugs the forward test can't see.
|
||||||
|
5. **Subscribe-sees-change** — start `otopcua-cli subscribe --duration N`
|
||||||
|
in the background, give it `--SettleSec` (default 2s) to attach,
|
||||||
|
write a random value via the driver CLI, wait for the subscription
|
||||||
|
window to close, and assert the captured output mentions the new
|
||||||
|
value. Confirms the server's monitored-item + data-change path
|
||||||
|
actually fires — not just that a fresh read returns the new value.
|
||||||
|
|
||||||
|
The OtOpcUa server must already be running with a config that
|
||||||
|
(a) binds a driver instance to the same PLC the script points at, and
|
||||||
|
(b) publishes the address the script writes under a NodeId the script
|
||||||
|
knows. Those NodeIds live in `e2e-config.json` (see below). The
|
||||||
|
published tag must be **writable** — stages 4 + 5 will fail against a
|
||||||
|
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:
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Prereqs
|
||||||
|
|
||||||
|
1. **OtOpcUa server** running on `opc.tcp://localhost:4840` (or pass
|
||||||
|
`-OpcUaUrl` to override). The server's Config DB must define a
|
||||||
|
driver instance per protocol you want to test, bound to the matching
|
||||||
|
simulator endpoint.
|
||||||
|
2. **Per-driver simulators** running. See `docs/v2/test-data-sources.md`
|
||||||
|
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.
|
||||||
|
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
|
||||||
|
src/ZB.MOM.WW.OtOpcUa.Driver.<Name>.Cli` directly, or if
|
||||||
|
`$env:OTOPCUA_CLI_BIN` points at a publish folder, runs the pre-built
|
||||||
|
`otopcua-*.exe` from there (faster for repeat loops).
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
### One protocol at a time
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./scripts/e2e/test-modbus.ps1 `
|
||||||
|
-ModbusHost 127.0.0.1:5502 `
|
||||||
|
-BridgeNodeId "ns=2;s=Modbus/HR100"
|
||||||
|
```
|
||||||
|
|
||||||
|
Every per-protocol script takes the driver endpoint, the address to
|
||||||
|
write, and the OPC UA NodeId the server exposes it at.
|
||||||
|
|
||||||
|
### Full matrix
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./scripts/e2e/test-all.ps1 `
|
||||||
|
-ConfigFile ./scripts/e2e/e2e-config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The runner reads the sidecar JSON, invokes each driver's script with the
|
||||||
|
parameters from that section, and prints a `FINAL MATRIX` showing
|
||||||
|
PASS / FAIL / SKIP per driver. Any driver absent from the sidecar is
|
||||||
|
SKIP-ed rather than failing hard — useful on dev boxes that only have
|
||||||
|
one simulator up.
|
||||||
|
|
||||||
|
### Sidecar format
|
||||||
|
|
||||||
|
Copy `e2e-config.sample.json` → `e2e-config.json` and fill in the
|
||||||
|
NodeIds from **your** server's Config DB. The file is `.gitignore`-d
|
||||||
|
(each dev's NodeIds are specific to their local seed). Omit a driver
|
||||||
|
section to skip it.
|
||||||
|
|
||||||
|
## Expected pass/fail matrix (default config)
|
||||||
|
|
||||||
|
| Driver | Gate | Default state on a clean dev box |
|
||||||
|
|---|---|---|
|
||||||
|
| Modbus | — | **PASS** (pymodbus fixture) |
|
||||||
|
| AB CIP | — | **PASS** (ab_server fixture) |
|
||||||
|
| AB Legacy | — | **PASS** (ab_server SLC500/MicroLogix/PLC-5 profiles; `/1,0` cip-path required for the Docker fixture) |
|
||||||
|
| 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) |
|
||||||
|
| 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
|
||||||
|
real hardware or a properly-configured simulator.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Each step prints one of:
|
||||||
|
|
||||||
|
- `[PASS] ...` — step succeeded
|
||||||
|
- `[FAIL] ...` — step failed, stdout of the failing CLI is echoed below
|
||||||
|
for diagnosis
|
||||||
|
- `[SKIP] ...` — step short-circuited (env-var gate)
|
||||||
|
- `[INFO] ...` — progress note (e.g., "waiting 3s for server-side poll")
|
||||||
|
|
||||||
|
The runner ends with a coloured summary per driver:
|
||||||
|
|
||||||
|
```
|
||||||
|
==================== FINAL MATRIX ====================
|
||||||
|
modbus PASS
|
||||||
|
abcip PASS
|
||||||
|
ablegacy SKIP (no config entry)
|
||||||
|
s7 PASS
|
||||||
|
focas SKIP (no config entry)
|
||||||
|
twincat SKIP (no config entry)
|
||||||
|
phase7 PASS
|
||||||
|
All present suites passed.
|
||||||
|
```
|
||||||
|
|
||||||
|
Non-zero exit if any present suite failed. SKIPs do not fail the run.
|
||||||
|
|
||||||
|
## Why this is separate from `dotnet test`
|
||||||
|
|
||||||
|
`dotnet test` covers driver-layer + server-layer correctness in
|
||||||
|
isolation — mocks + in-process test hosts. These e2e scripts cover the
|
||||||
|
integration seam that unit tests *can't* cover by design: a live OPC UA
|
||||||
|
server process, a live simulator, and the wire between them. Run them
|
||||||
|
before a v2 release-readiness sign-off, after a driver-layer change
|
||||||
|
that could plausibly affect the NodeManager contract, and before any
|
||||||
|
"it works on my box" handoff to QA.
|
||||||
430
scripts/e2e/_common.ps1
Normal file
430
scripts/e2e/_common.ps1
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
# Shared PowerShell helpers for the OtOpcUa end-to-end CLI test scripts.
|
||||||
|
#
|
||||||
|
# Every per-protocol script dot-sources this file and calls the Test-* functions
|
||||||
|
# below. Keeps the per-script code down to ~50 lines of parameterisation +
|
||||||
|
# bridging-tag identifiers.
|
||||||
|
#
|
||||||
|
# Conventions:
|
||||||
|
# - All test helpers return a hashtable: @{ Passed=<bool>; Reason=<string> }
|
||||||
|
# - Helpers never throw unless the test setup is itself broken (a crashed
|
||||||
|
# CLI is a test failure, not an exception).
|
||||||
|
# - Output is plain text with [PASS] / [FAIL] / [SKIP] / [INFO] prefixes so
|
||||||
|
# grep/log-scraping works.
|
||||||
|
|
||||||
|
Set-StrictMode -Version 3.0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Colouring + prefixes.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Write-Header {
|
||||||
|
param([string]$Title)
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=== $Title ===" -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Pass {
|
||||||
|
param([string]$Message)
|
||||||
|
Write-Host "[PASS] $Message" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Fail {
|
||||||
|
param([string]$Message)
|
||||||
|
Write-Host "[FAIL] $Message" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Skip {
|
||||||
|
param([string]$Message)
|
||||||
|
Write-Host "[SKIP] $Message" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Info {
|
||||||
|
param([string]$Message)
|
||||||
|
Write-Host "[INFO] $Message" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI invocation helpers.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Resolve a CLI path from either a published binary OR a `dotnet run` fallback.
|
||||||
|
# Preferred order:
|
||||||
|
# 1. $env:OTOPCUA_CLI_BIN points at a publish/ folder → use <exe> there
|
||||||
|
# 2. Fall back to `dotnet run --project src/<ProjectFolder> --`
|
||||||
|
#
|
||||||
|
# $ProjectFolder = relative path from repo root
|
||||||
|
# $ExeName = expected AssemblyName (no .exe)
|
||||||
|
function Get-CliInvocation {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] [string]$ProjectFolder,
|
||||||
|
[Parameter(Mandatory)] [string]$ExeName
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($env:OTOPCUA_CLI_BIN) {
|
||||||
|
$binPath = Join-Path $env:OTOPCUA_CLI_BIN "$ExeName.exe"
|
||||||
|
if (Test-Path $binPath) {
|
||||||
|
return @{ File = $binPath; PrefixArgs = @() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dotnet-run fallback. --no-build would be faster but not every CI step
|
||||||
|
# has rebuilt; default to a full run so the script is forgiving.
|
||||||
|
return @{
|
||||||
|
File = "dotnet"
|
||||||
|
PrefixArgs = @("run", "--project", $ProjectFolder, "--")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run a CLI and capture stdout+stderr+exitcode. Never throws.
|
||||||
|
function Invoke-Cli {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] $Cli, # output of Get-CliInvocation
|
||||||
|
[Parameter(Mandatory)] [string[]]$Args, # CLI arguments (after `-- `)
|
||||||
|
[int]$TimeoutSec = 30
|
||||||
|
)
|
||||||
|
|
||||||
|
$allArgs = @($Cli.PrefixArgs) + $Args
|
||||||
|
$output = $null
|
||||||
|
$exitCode = -1
|
||||||
|
|
||||||
|
try {
|
||||||
|
$output = & $Cli.File @allArgs 2>&1 | Out-String
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return @{
|
||||||
|
Output = $_.Exception.Message
|
||||||
|
ExitCode = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return @{
|
||||||
|
Output = $output
|
||||||
|
ExitCode = $exitCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test helpers — reusable building blocks every per-protocol script calls.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Test 1 — the driver CLI's probe command exits 0. Confirms the PLC / simulator
|
||||||
|
# is reachable and speaks the protocol. Prerequisite for everything else.
|
||||||
|
function Test-Probe {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] $Cli,
|
||||||
|
[Parameter(Mandatory)] [string[]]$ProbeArgs
|
||||||
|
)
|
||||||
|
Write-Header "Probe"
|
||||||
|
$r = Invoke-Cli -Cli $Cli -Args $ProbeArgs
|
||||||
|
if ($r.ExitCode -eq 0) {
|
||||||
|
Write-Pass "driver CLI probe succeeded"
|
||||||
|
return @{ Passed = $true }
|
||||||
|
}
|
||||||
|
Write-Fail "driver CLI probe exit=$($r.ExitCode)"
|
||||||
|
Write-Host $r.Output
|
||||||
|
return @{ Passed = $false; Reason = "probe exit $($r.ExitCode)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 2 — driver-loopback. Write a value via the driver CLI, read it back via
|
||||||
|
# the same CLI, assert round-trip equality. Confirms the driver itself is
|
||||||
|
# functional without pulling the OtOpcUa server into the loop.
|
||||||
|
function Test-DriverLoopback {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] $Cli,
|
||||||
|
[Parameter(Mandatory)] [string[]]$WriteArgs,
|
||||||
|
[Parameter(Mandatory)] [string[]]$ReadArgs,
|
||||||
|
[Parameter(Mandatory)] [string]$ExpectedValue
|
||||||
|
)
|
||||||
|
Write-Header "Driver loopback"
|
||||||
|
|
||||||
|
$w = Invoke-Cli -Cli $Cli -Args $WriteArgs
|
||||||
|
if ($w.ExitCode -ne 0) {
|
||||||
|
Write-Fail "write failed (exit=$($w.ExitCode))"
|
||||||
|
Write-Host $w.Output
|
||||||
|
return @{ Passed = $false; Reason = "write failed" }
|
||||||
|
}
|
||||||
|
Write-Info "write ok"
|
||||||
|
|
||||||
|
$r = Invoke-Cli -Cli $Cli -Args $ReadArgs
|
||||||
|
if ($r.ExitCode -ne 0) {
|
||||||
|
Write-Fail "read failed (exit=$($r.ExitCode))"
|
||||||
|
Write-Host $r.Output
|
||||||
|
return @{ Passed = $false; Reason = "read failed" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($r.Output -match "Value:\s+$([Regex]::Escape($ExpectedValue))\b") {
|
||||||
|
Write-Pass "round-trip equals $ExpectedValue"
|
||||||
|
return @{ Passed = $true }
|
||||||
|
}
|
||||||
|
Write-Fail "round-trip value mismatch — expected $ExpectedValue"
|
||||||
|
Write-Host $r.Output
|
||||||
|
return @{ Passed = $false; Reason = "value mismatch" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 3 — server bridge. Write via the driver CLI, read the corresponding
|
||||||
|
# OPC UA NodeId via the OPC UA client CLI. Confirms the full path:
|
||||||
|
# driver CLI → PLC → OtOpcUa server (polling/subscription) → OPC UA client.
|
||||||
|
function Test-ServerBridge {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] $DriverCli,
|
||||||
|
[Parameter(Mandatory)] [string[]]$DriverWriteArgs,
|
||||||
|
[Parameter(Mandatory)] $OpcUaCli,
|
||||||
|
[Parameter(Mandatory)] [string]$OpcUaUrl,
|
||||||
|
[Parameter(Mandatory)] [string]$OpcUaNodeId,
|
||||||
|
[Parameter(Mandatory)] [string]$ExpectedValue,
|
||||||
|
[int]$ServerPollDelaySec = 3
|
||||||
|
)
|
||||||
|
Write-Header "Server bridge"
|
||||||
|
|
||||||
|
$w = Invoke-Cli -Cli $DriverCli -Args $DriverWriteArgs
|
||||||
|
if ($w.ExitCode -ne 0) {
|
||||||
|
Write-Fail "driver-side write failed (exit=$($w.ExitCode))"
|
||||||
|
Write-Host $w.Output
|
||||||
|
return @{ Passed = $false; Reason = "driver write failed" }
|
||||||
|
}
|
||||||
|
Write-Info "driver write ok, waiting ${ServerPollDelaySec}s for server-side poll"
|
||||||
|
Start-Sleep -Seconds $ServerPollDelaySec
|
||||||
|
|
||||||
|
$r = Invoke-Cli -Cli $OpcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $OpcUaNodeId)
|
||||||
|
if ($r.ExitCode -ne 0) {
|
||||||
|
Write-Fail "OPC UA client read failed (exit=$($r.ExitCode))"
|
||||||
|
Write-Host $r.Output
|
||||||
|
return @{ Passed = $false; Reason = "opc-ua read failed" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($r.Output -match "Value:\s+$([Regex]::Escape($ExpectedValue))\b") {
|
||||||
|
Write-Pass "server-side read equals $ExpectedValue"
|
||||||
|
return @{ Passed = $true }
|
||||||
|
}
|
||||||
|
Write-Fail "server-side value mismatch — expected $ExpectedValue"
|
||||||
|
Write-Host $r.Output
|
||||||
|
return @{ Passed = $false; Reason = "bridge value mismatch" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 4 — reverse bridge. Write via the OPC UA client CLI, then read the PLC
|
||||||
|
# side via the driver CLI. Confirms the write path: OPC UA client → server →
|
||||||
|
# driver → PLC. This is the direction Test-ServerBridge does NOT cover — a
|
||||||
|
# clean Test-ServerBridge only proves reads flow server-ward.
|
||||||
|
function Test-OpcUaWriteBridge {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] $OpcUaCli,
|
||||||
|
[Parameter(Mandatory)] [string]$OpcUaUrl,
|
||||||
|
[Parameter(Mandatory)] [string]$OpcUaNodeId,
|
||||||
|
[Parameter(Mandatory)] $DriverCli,
|
||||||
|
[Parameter(Mandatory)] [string[]]$DriverReadArgs,
|
||||||
|
[Parameter(Mandatory)] [string]$ExpectedValue,
|
||||||
|
[int]$DriverPollDelaySec = 3
|
||||||
|
)
|
||||||
|
Write-Header "OPC UA write bridge"
|
||||||
|
|
||||||
|
$w = Invoke-Cli -Cli $OpcUaCli -Args @(
|
||||||
|
"write", "-u", $OpcUaUrl, "-n", $OpcUaNodeId, "-v", $ExpectedValue)
|
||||||
|
if ($w.ExitCode -ne 0 -or $w.Output -notmatch "Write successful") {
|
||||||
|
Write-Fail "OPC UA client write failed (exit=$($w.ExitCode))"
|
||||||
|
Write-Host $w.Output
|
||||||
|
return @{ Passed = $false; Reason = "opc-ua write failed" }
|
||||||
|
}
|
||||||
|
Write-Info "opc-ua write ok, waiting ${DriverPollDelaySec}s for driver-side apply"
|
||||||
|
Start-Sleep -Seconds $DriverPollDelaySec
|
||||||
|
|
||||||
|
$r = Invoke-Cli -Cli $DriverCli -Args $DriverReadArgs
|
||||||
|
if ($r.ExitCode -ne 0) {
|
||||||
|
Write-Fail "driver-side read failed (exit=$($r.ExitCode))"
|
||||||
|
Write-Host $r.Output
|
||||||
|
return @{ Passed = $false; Reason = "driver read failed" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($r.Output -match "Value:\s+$([Regex]::Escape($ExpectedValue))\b") {
|
||||||
|
Write-Pass "PLC-side value equals $ExpectedValue"
|
||||||
|
return @{ Passed = $true }
|
||||||
|
}
|
||||||
|
Write-Fail "PLC-side value mismatch — expected $ExpectedValue"
|
||||||
|
Write-Host $r.Output
|
||||||
|
return @{ Passed = $false; Reason = "reverse-bridge value mismatch" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test 5 — subscribe-sees-change. Start `otopcua-cli subscribe --duration N`
|
||||||
|
# in the background, give it ~2s to attach, then write a known value via the
|
||||||
|
# driver CLI. After the subscription window closes, assert its captured
|
||||||
|
# output mentions the new value. Confirms the OPC UA server is actually
|
||||||
|
# pushing data-change notifications for driver-originated changes — not just
|
||||||
|
# that a fresh read returns the new value.
|
||||||
|
function Test-SubscribeSeesChange {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] $OpcUaCli,
|
||||||
|
[Parameter(Mandatory)] [string]$OpcUaUrl,
|
||||||
|
[Parameter(Mandatory)] [string]$OpcUaNodeId,
|
||||||
|
[Parameter(Mandatory)] $DriverCli,
|
||||||
|
[Parameter(Mandatory)] [string[]]$DriverWriteArgs,
|
||||||
|
[Parameter(Mandatory)] [string]$ExpectedValue,
|
||||||
|
[int]$DurationSec = 8,
|
||||||
|
[int]$SettleSec = 2
|
||||||
|
)
|
||||||
|
Write-Header "Subscribe sees change"
|
||||||
|
|
||||||
|
# `Start-Job` would spin up a fresh PowerShell runtime and cost 2s+. Use
|
||||||
|
# Start-Process + a temp file instead — it's the same shape Invoke-Cli
|
||||||
|
# uses but non-blocking.
|
||||||
|
$stdout = New-TemporaryFile
|
||||||
|
$stderr = New-TemporaryFile
|
||||||
|
$allArgs = @($OpcUaCli.PrefixArgs) + @(
|
||||||
|
"subscribe", "-u", $OpcUaUrl, "-n", $OpcUaNodeId,
|
||||||
|
"-i", "200", "--duration", "$DurationSec")
|
||||||
|
$proc = Start-Process -FilePath $OpcUaCli.File `
|
||||||
|
-ArgumentList $allArgs `
|
||||||
|
-NoNewWindow -PassThru `
|
||||||
|
-RedirectStandardOutput $stdout.FullName `
|
||||||
|
-RedirectStandardError $stderr.FullName
|
||||||
|
Write-Info "subscription started (pid $($proc.Id)), waiting ${SettleSec}s to settle"
|
||||||
|
Start-Sleep -Seconds $SettleSec
|
||||||
|
|
||||||
|
$w = Invoke-Cli -Cli $DriverCli -Args $DriverWriteArgs
|
||||||
|
if ($w.ExitCode -ne 0) {
|
||||||
|
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue
|
||||||
|
Write-Fail "driver write during subscribe failed (exit=$($w.ExitCode))"
|
||||||
|
Write-Host $w.Output
|
||||||
|
return @{ Passed = $false; Reason = "driver write failed" }
|
||||||
|
}
|
||||||
|
Write-Info "driver write ok, waiting for subscription window to close"
|
||||||
|
|
||||||
|
# Wait for the subscribe process to exit its --duration timer. Grace
|
||||||
|
# margin on top of the duration in case the first data-change races the
|
||||||
|
# final flush.
|
||||||
|
$proc.WaitForExit(($DurationSec + 5) * 1000) | Out-Null
|
||||||
|
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
|
||||||
|
|
||||||
|
# The subscribe command prints `[timestamp] displayName = value (status)`
|
||||||
|
# per data-change event. We only care that one of those events carried
|
||||||
|
# the new value.
|
||||||
|
if ($out -match "=\s*$([Regex]::Escape($ExpectedValue))\b") {
|
||||||
|
Write-Pass "subscribe saw $ExpectedValue"
|
||||||
|
return @{ Passed = $true }
|
||||||
|
}
|
||||||
|
Write-Fail "subscribe did not observe $ExpectedValue in ${DurationSec}s"
|
||||||
|
Write-Host $out
|
||||||
|
return @{ Passed = $false; Reason = "change not observed on subscription" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test — alarm fires on threshold. Start `otopcua-cli alarms --refresh` on the
|
||||||
|
# alarm Condition NodeId in the background; drive the underlying data change via
|
||||||
|
# `otopcua-cli write` on the input NodeId; wait for the subscription window to
|
||||||
|
# close; assert the captured stdout contains a matching ALARM line (`SourceName`
|
||||||
|
# of the Condition + an Active state). Covers Part 9 alarm propagation through
|
||||||
|
# the server → driver → Condition node path.
|
||||||
|
function Test-AlarmFiresOnThreshold {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] $OpcUaCli,
|
||||||
|
[Parameter(Mandatory)] [string]$OpcUaUrl,
|
||||||
|
[Parameter(Mandatory)] [string]$AlarmNodeId,
|
||||||
|
[Parameter(Mandatory)] [string]$InputNodeId,
|
||||||
|
[Parameter(Mandatory)] [string]$TriggerValue,
|
||||||
|
[int]$DurationSec = 10,
|
||||||
|
[int]$SettleSec = 2
|
||||||
|
)
|
||||||
|
Write-Header "Alarm fires on threshold"
|
||||||
|
|
||||||
|
$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 ${SettleSec}s to settle"
|
||||||
|
Start-Sleep -Seconds $SettleSec
|
||||||
|
|
||||||
|
$w = Invoke-Cli -Cli $OpcUaCli -Args @(
|
||||||
|
"write", "-u", $OpcUaUrl, "-n", $InputNodeId, "-v", $TriggerValue)
|
||||||
|
if ($w.ExitCode -ne 0) {
|
||||||
|
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item $stdout.FullName, $stderr.FullName -ErrorAction SilentlyContinue
|
||||||
|
Write-Fail "input write failed (exit=$($w.ExitCode))"
|
||||||
|
Write-Host $w.Output
|
||||||
|
return @{ Passed = $false; Reason = "input write failed" }
|
||||||
|
}
|
||||||
|
Write-Info "input write ok, waiting up to ${DurationSec}s for the alarm to surface"
|
||||||
|
|
||||||
|
# otopcua-cli alarms runs until Ctrl+C; terminate it ourselves after the
|
||||||
|
# duration window (no built-in --duration flag on the alarms command).
|
||||||
|
Start-Sleep -Seconds $DurationSec
|
||||||
|
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
|
||||||
|
|
||||||
|
# AlarmsCommand emits `[ts] ALARM <SourceName>` per event + lines for
|
||||||
|
# State: Active,Unacknowledged | Severity | Message. Match on `ALARM` +
|
||||||
|
# `Active` — both need to appear for the alarm to count as fired.
|
||||||
|
if ($out -match "ALARM\b" -and $out -match "Active\b") {
|
||||||
|
Write-Pass "alarm condition fired with Active state"
|
||||||
|
return @{ Passed = $true }
|
||||||
|
}
|
||||||
|
Write-Fail "no Active alarm event observed in ${DurationSec}s"
|
||||||
|
Write-Host $out
|
||||||
|
return @{ Passed = $false; Reason = "no alarm event" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test — history-read returns samples. Calls `otopcua-cli historyread` on the
|
||||||
|
# target NodeId for a time window (default 1h back) and asserts the CLI reports
|
||||||
|
# at least one value returned. Works against any historized tag — driver-sourced,
|
||||||
|
# virtual, or scripted-alarm historizing to the Aveva / SQLite sink.
|
||||||
|
function Test-HistoryHasSamples {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] $OpcUaCli,
|
||||||
|
[Parameter(Mandatory)] [string]$OpcUaUrl,
|
||||||
|
[Parameter(Mandatory)] [string]$NodeId,
|
||||||
|
[int]$LookbackSec = 3600,
|
||||||
|
[int]$MinSamples = 1
|
||||||
|
)
|
||||||
|
Write-Header "History read"
|
||||||
|
|
||||||
|
$end = (Get-Date).ToUniversalTime().ToString("o")
|
||||||
|
$start = (Get-Date).ToUniversalTime().AddSeconds(-$LookbackSec).ToString("o")
|
||||||
|
|
||||||
|
$r = Invoke-Cli -Cli $OpcUaCli -Args @(
|
||||||
|
"historyread", "-u", $OpcUaUrl, "-n", $NodeId,
|
||||||
|
"--start", $start, "--end", $end, "--max", "1000")
|
||||||
|
if ($r.ExitCode -ne 0) {
|
||||||
|
Write-Fail "historyread exit=$($r.ExitCode)"
|
||||||
|
Write-Host $r.Output
|
||||||
|
return @{ Passed = $false; Reason = "historyread failed" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# HistoryReadCommand ends with `N values returned.` — parse and check >= MinSamples.
|
||||||
|
if ($r.Output -match '(\d+)\s+values?\s+returned') {
|
||||||
|
$count = [int]$Matches[1]
|
||||||
|
if ($count -ge $MinSamples) {
|
||||||
|
Write-Pass "$count samples returned (>= $MinSamples)"
|
||||||
|
return @{ Passed = $true }
|
||||||
|
}
|
||||||
|
Write-Fail "only $count samples returned, expected >= $MinSamples — tag may not be historized, or lookback window misses samples"
|
||||||
|
Write-Host $r.Output
|
||||||
|
return @{ Passed = $false; Reason = "insufficient samples" }
|
||||||
|
}
|
||||||
|
Write-Fail "could not parse 'N values returned.' marker from historyread output"
|
||||||
|
Write-Host $r.Output
|
||||||
|
return @{ Passed = $false; Reason = "parse failure" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Summary helper — caller passes an array of test results.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Write-Summary {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] [string]$Title,
|
||||||
|
[Parameter(Mandatory)] [array]$Results
|
||||||
|
)
|
||||||
|
$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" })
|
||||||
|
}
|
||||||
76
scripts/e2e/e2e-config.sample.json
Normal file
76
scripts/e2e/e2e-config.sample.json
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"$comment": "Copy this file to e2e-config.json and replace the NodeIds with the ones your Config DB publishes. Fields named `opcUaUrl` override the -OpcUaUrl parameter on test-all.ps1 per-driver. Omit a top-level key to skip that driver.",
|
||||||
|
|
||||||
|
"modbus": {
|
||||||
|
"$comment": "Port 5020 matches tests/.../Modbus.IntegrationTests/Docker/docker-compose.yml — `docker compose --profile standard up -d`.",
|
||||||
|
"endpoint": "127.0.0.1:5020",
|
||||||
|
"bridgeNodeId": "ns=2;s=Modbus/HR200",
|
||||||
|
"opcUaUrl": "opc.tcp://localhost:4840"
|
||||||
|
},
|
||||||
|
|
||||||
|
"abcip": {
|
||||||
|
"$comment": "ab_server listens on port 44818 (default CIP/EIP). `docker compose --profile controllogix up -d`.",
|
||||||
|
"gateway": "ab://127.0.0.1:44818/1,0",
|
||||||
|
"family": "ControlLogix",
|
||||||
|
"tagPath": "TestDINT",
|
||||||
|
"bridgeNodeId": "ns=2;s=AbCip/TestDINT"
|
||||||
|
},
|
||||||
|
|
||||||
|
"ablegacy": {
|
||||||
|
"$comment": "Works against ab_server --profile slc500 (Docker fixture) or real SLC/MicroLogix/PLC-5 hardware. `/1,0` cip-path is required for the Docker fixture; real hardware accepts an empty path — e.g. `ab://10.0.1.50:44818/`.",
|
||||||
|
"gateway": "ab://127.0.0.1/1,0",
|
||||||
|
"plcType": "Slc500",
|
||||||
|
"address": "N7:5",
|
||||||
|
"bridgeNodeId": "ns=2;s=AbLegacy/N7_5"
|
||||||
|
},
|
||||||
|
|
||||||
|
"s7": {
|
||||||
|
"$comment": "Port 1102 matches tests/.../S7.IntegrationTests/Docker/docker-compose.yml (python-snap7 needs non-priv port). `docker compose --profile s7_1500 up -d`. Real S7 PLCs listen on 102.",
|
||||||
|
"endpoint": "127.0.0.1:1102",
|
||||||
|
"cpu": "S71500",
|
||||||
|
"slot": 0,
|
||||||
|
"address": "DB1.DBW0",
|
||||||
|
"bridgeNodeId": "ns=2;s=S7/DB1_DBW0"
|
||||||
|
},
|
||||||
|
|
||||||
|
"focas": {
|
||||||
|
"$comment": "Gated behind FOCAS_TRUST_WIRE=1 — no public simulator. Point at a real CNC + ensure Fwlib32.dll is on PATH.",
|
||||||
|
"host": "192.168.1.20",
|
||||||
|
"port": 8193,
|
||||||
|
"address": "R100",
|
||||||
|
"bridgeNodeId": "ns=2;s=Focas/R100"
|
||||||
|
},
|
||||||
|
|
||||||
|
"twincat": {
|
||||||
|
"$comment": "Gated behind TWINCAT_TRUST_WIRE=1 — needs XAR or standalone TwinCAT Router NuGet reachable at -AmsNetId.",
|
||||||
|
"amsNetId": "127.0.0.1.1.1",
|
||||||
|
"amsPort": 851,
|
||||||
|
"symbolPath": "MAIN.iCounter",
|
||||||
|
"bridgeNodeId": "ns=2;s=TwinCAT/MAIN_iCounter"
|
||||||
|
},
|
||||||
|
|
||||||
|
"galaxy": {
|
||||||
|
"$comment": "Galaxy (MXAccess) driver. Has no per-driver CLI — all stages go through otopcua-cli against the published NodeIds. Seven stages: probe / source read / virtual-tag bridge / subscribe-sees-change / reverse write / alarm fires / history read. Requires OtOpcUaGalaxyHost running + seed-phase-7-smoke.sql applied with a real Galaxy attribute substituted into dbo.Tag.TagConfig.",
|
||||||
|
"sourceNodeId": "ns=2;s=p7-smoke-tag-source",
|
||||||
|
"virtualNodeId": "ns=2;s=p7-smoke-vt-derived",
|
||||||
|
"alarmNodeId": "ns=2;s=p7-smoke-al-overtemp",
|
||||||
|
"alarmTriggerValue": "75",
|
||||||
|
"changeWaitSec": 10,
|
||||||
|
"alarmWaitSec": 10,
|
||||||
|
"historyLookbackSec": 3600
|
||||||
|
},
|
||||||
|
|
||||||
|
"opcuaclient": {
|
||||||
|
"$comment": "Optional upstream-redundancy probe (PR-14). When both primaryUrl and secondaryUrl are set, test-opcuaclient.ps1 runs an extra bridged read while both upstreams are reachable. Leave keys absent to skip the redundancy stage. The OtOpcUa server's DriverConfig for the OpcUaClient instance must already have Redundancy.Enabled=true + the same EndpointUrls list; this script doesn't reconfigure the driver.",
|
||||||
|
"primaryUrl": "opc.tcp://localhost:50000",
|
||||||
|
"secondaryUrl": "opc.tcp://localhost:50002"
|
||||||
|
},
|
||||||
|
|
||||||
|
"phase7": {
|
||||||
|
"$comment": "Virtual tags + scripted alarms. The VirtualNodeId must resolve to a server-side virtual tag whose script reads the modbus InputNodeId and writes VT = input * 2. The AlarmNodeId is the ConditionId of a scripted alarm that fires when VT > 100.",
|
||||||
|
"modbusEndpoint": "127.0.0.1:5502",
|
||||||
|
"inputNodeId": "ns=2;s=Modbus/HR100",
|
||||||
|
"virtualNodeId": "ns=2;s=Virtual/VT_DoubledHR100",
|
||||||
|
"alarmNodeId": "ns=2;s=Alarm/HR100_High"
|
||||||
|
}
|
||||||
|
}
|
||||||
210
scripts/e2e/test-abcip-hsby.ps1
Normal file
210
scripts/e2e/test-abcip-hsby.ps1
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
#Requires -Version 7.0
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
End-to-end CLI test for AB CIP HSBY failover routing (PR abcip-5.2). Subscribes to
|
||||||
|
a tag through the OtOpcUa OPC UA server, flips the active chassis mid-stream via
|
||||||
|
the paired-fixture's hsby-mux sidecar HTTP endpoint, and asserts the subscribe
|
||||||
|
stream survives the failover (no permanent loss of notifications + the post-flip
|
||||||
|
data carries the partner-side update).
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Paired-fixture variant of test-abcip.ps1. Where test-abcip.ps1 runs against a
|
||||||
|
single ab_server instance, this script assumes a paired fixture with two
|
||||||
|
ab_server instances (primary + partner) and an hsby-mux sidecar exposing
|
||||||
|
/flip {"active": "primary" | "partner"} over HTTP.
|
||||||
|
|
||||||
|
Five assertions:
|
||||||
|
- HsbyInitialActive — primary is Active at start (hsby-mux primes it)
|
||||||
|
- HsbyResolveActive — driver-diagnostics surfaces AbCip.HsbyActive == 1
|
||||||
|
- HsbyFailoverFlip — POST {"active": "partner"} → AbCip.HsbyActive == 2
|
||||||
|
- HsbySubscribeSurvives — subscribe stream stays open across the flip + sees
|
||||||
|
an updated value from the partner side
|
||||||
|
- HsbyFailoverCount — AbCip.HsbyFailoverCount increments by ≥ 1
|
||||||
|
|
||||||
|
.PARAMETER PrimaryGateway
|
||||||
|
ab://host[:port]/cip-path of the primary chassis. Default ab://127.0.0.1/1,0.
|
||||||
|
|
||||||
|
.PARAMETER PartnerGateway
|
||||||
|
ab://host[:port]/cip-path of the partner chassis. Default ab://127.0.0.2/1,0.
|
||||||
|
|
||||||
|
.PARAMETER HsbyMuxUrl
|
||||||
|
Base URL of the paired-fixture's hsby-mux sidecar. Default http://localhost:7080.
|
||||||
|
Endpoints used:
|
||||||
|
GET /role → returns {"primary":"Active","partner":"Standby"}
|
||||||
|
POST /flip {"active":"primary"|"partner"} → flips role tag values on each chassis
|
||||||
|
|
||||||
|
.PARAMETER OpcUaUrl
|
||||||
|
OtOpcUa server endpoint. Default opc.tcp://localhost:4840.
|
||||||
|
|
||||||
|
.PARAMETER BridgeNodeId
|
||||||
|
NodeId at which the server publishes the tag exercised by the subscribe assertion.
|
||||||
|
Required.
|
||||||
|
|
||||||
|
.PARAMETER TagPath
|
||||||
|
Logix symbolic path the bridge tag points at. Default 'TestDINT'.
|
||||||
|
|
||||||
|
.PARAMETER DriverInstanceId
|
||||||
|
DriverInstance ID for the AB CIP driver under test. Used to scope the
|
||||||
|
driver-diagnostics RPC. Default 'abcip-hsby'.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
./test-abcip-hsby.ps1 -BridgeNodeId 'ns=2;s=AbCip/Bridge/TestDINT'
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$PrimaryGateway = "ab://127.0.0.1/1,0",
|
||||||
|
[string]$PartnerGateway = "ab://127.0.0.2/1,0",
|
||||||
|
[string]$HsbyMuxUrl = "http://localhost:7080",
|
||||||
|
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||||
|
[Parameter(Mandatory)] [string]$BridgeNodeId,
|
||||||
|
[string]$TagPath = "TestDINT",
|
||||||
|
[string]$DriverInstanceId = "abcip-hsby"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
. "$PSScriptRoot/_common.ps1"
|
||||||
|
|
||||||
|
$abcipCli = Get-CliInvocation `
|
||||||
|
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli" `
|
||||||
|
-ExeName "otopcua-abcip-cli"
|
||||||
|
$opcUaCli = Get-CliInvocation `
|
||||||
|
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||||
|
-ExeName "otopcua-cli"
|
||||||
|
|
||||||
|
$results = @()
|
||||||
|
|
||||||
|
function Invoke-HsbyFlip {
|
||||||
|
param([string]$Active)
|
||||||
|
$body = @{ active = $Active } | ConvertTo-Json -Compress
|
||||||
|
try {
|
||||||
|
Invoke-RestMethod -Uri "$HsbyMuxUrl/flip" -Method Post -Body $body -ContentType 'application/json'
|
||||||
|
} catch {
|
||||||
|
throw "hsby-mux at $HsbyMuxUrl/flip rejected the request: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-HsbyDiagnosticValue {
|
||||||
|
param([string]$Counter)
|
||||||
|
# Pull driver-diagnostics through the OPC UA Admin RPC surface. The CLI returns
|
||||||
|
# a raw JSON blob; we grep out the named counter so the assertion is robust to
|
||||||
|
# other counters the driver surfaces.
|
||||||
|
$diagArgs = @($opcUaCli.PrefixArgs) + @(
|
||||||
|
"driver-diagnostics", "-u", $OpcUaUrl, "-d", $DriverInstanceId)
|
||||||
|
$diagOut = & $opcUaCli.File @diagArgs 2>&1
|
||||||
|
$joined = ($diagOut -join "`n")
|
||||||
|
if ($joined -match "${Counter}.*?:\s*([\d\.]+)") {
|
||||||
|
return [double]$matches[1]
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- HsbyInitialActive — hsby-mux primes primary as Active ----
|
||||||
|
Write-Header "HsbyInitialActive (POST $HsbyMuxUrl/flip {active=primary})"
|
||||||
|
try {
|
||||||
|
Invoke-HsbyFlip -Active "primary" | Out-Null
|
||||||
|
Start-Sleep -Seconds 3 # role-probe loop default tick is 2s
|
||||||
|
$active = Get-HsbyDiagnosticValue -Counter "AbCip.HsbyActive"
|
||||||
|
$passed = ($active -eq 1.0)
|
||||||
|
$results += [PSCustomObject]@{
|
||||||
|
Name = "HsbyInitialActive"
|
||||||
|
Passed = $passed
|
||||||
|
Detail = if ($passed) { "AbCip.HsbyActive=1 after priming primary" } else { "AbCip.HsbyActive=$active (expected 1)" }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$results += [PSCustomObject]@{
|
||||||
|
Name = "HsbyInitialActive"; Passed = $false; Detail = $_.Exception.Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- HsbyResolveActive — driver routing reads through the primary ----
|
||||||
|
Write-Header "HsbyResolveActive (read $TagPath via primary)"
|
||||||
|
$readArgs = @("read") + @("-g", $PrimaryGateway, "-f", "ControlLogix") + @("-t", $TagPath, "--type", "DInt")
|
||||||
|
$readOut = & $abcipCli.Exe @($abcipCli.Args + $readArgs) 2>&1
|
||||||
|
$readOk = ($readOut -join "`n") -notmatch "(error|fail)"
|
||||||
|
$results += [PSCustomObject]@{
|
||||||
|
Name = "HsbyResolveActive"
|
||||||
|
Passed = $readOk
|
||||||
|
Detail = if ($readOk) { "primary read completed without error" } else { "read failed: $($readOut -join ' ')" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- HsbySubscribeSurvives + HsbyFailoverFlip + HsbyFailoverCount ----
|
||||||
|
Write-Header "HsbyFailoverFlip + HsbySubscribeSurvives (subscribe across flip)"
|
||||||
|
$failoverBaseline = Get-HsbyDiagnosticValue -Counter "AbCip.HsbyFailoverCount"
|
||||||
|
if ($null -eq $failoverBaseline) { $failoverBaseline = 0 }
|
||||||
|
|
||||||
|
$duration = 12
|
||||||
|
$subOut = New-TemporaryFile
|
||||||
|
$subErr = New-TemporaryFile
|
||||||
|
$subArgs = @($opcUaCli.PrefixArgs) + @(
|
||||||
|
"subscribe", "-u", $OpcUaUrl, "-n", $BridgeNodeId, "-i", "200", "--duration", "$duration")
|
||||||
|
$subProc = Start-Process -FilePath $opcUaCli.File -ArgumentList $subArgs `
|
||||||
|
-NoNewWindow -PassThru `
|
||||||
|
-RedirectStandardOutput $subOut.FullName `
|
||||||
|
-RedirectStandardError $subErr.FullName
|
||||||
|
|
||||||
|
# Let the subscribe settle + accumulate primary-side notifications.
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
|
||||||
|
# Mid-stream flip — primary→Standby, partner→Active.
|
||||||
|
try {
|
||||||
|
Invoke-HsbyFlip -Active "partner" | Out-Null
|
||||||
|
} catch {
|
||||||
|
Stop-Process -Id $subProc.Id -Force -ErrorAction SilentlyContinue
|
||||||
|
$results += [PSCustomObject]@{
|
||||||
|
Name = "HsbyFailoverFlip"; Passed = $false; Detail = "hsby-mux flip rejected: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wait for the role-probe loop to catch up (default tick 2s + ProbeIntervalMs slack).
|
||||||
|
Start-Sleep -Seconds 4
|
||||||
|
|
||||||
|
# Drive a write through the partner so the subscribe sees a fresh value.
|
||||||
|
$flipValue = Get-Random -Minimum 70000 -Maximum 79999
|
||||||
|
$writeArgs = @("write") + @("-g", $PartnerGateway, "-f", "ControlLogix") + @("-t", $TagPath, "--type", "DInt", "-v", $flipValue)
|
||||||
|
& $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null
|
||||||
|
|
||||||
|
$activeAfter = Get-HsbyDiagnosticValue -Counter "AbCip.HsbyActive"
|
||||||
|
$flipPassed = ($activeAfter -eq 2.0)
|
||||||
|
$results += [PSCustomObject]@{
|
||||||
|
Name = "HsbyFailoverFlip"
|
||||||
|
Passed = $flipPassed
|
||||||
|
Detail = if ($flipPassed) { "AbCip.HsbyActive=2 after flip" } else { "AbCip.HsbyActive=$activeAfter (expected 2)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stop the subscribe + harvest the stream.
|
||||||
|
$subProc.WaitForExit(($duration + 5) * 1000) | Out-Null
|
||||||
|
if (-not $subProc.HasExited) { Stop-Process -Id $subProc.Id -Force }
|
||||||
|
|
||||||
|
$subText = (Get-Content $subOut.FullName -Raw) + (Get-Content $subErr.FullName -Raw)
|
||||||
|
Remove-Item $subOut.FullName, $subErr.FullName -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
# Stream survival = at least one notification *after* the flip carries the new
|
||||||
|
# partner-side value. The post-flip write of $flipValue is the canary.
|
||||||
|
$saw = $subText -match "$flipValue"
|
||||||
|
$results += [PSCustomObject]@{
|
||||||
|
Name = "HsbySubscribeSurvives"
|
||||||
|
Passed = $saw
|
||||||
|
Detail = if ($saw) {
|
||||||
|
"subscribe stream surfaced post-flip value $flipValue from partner chassis"
|
||||||
|
} else {
|
||||||
|
"subscribe stream did not see the post-flip canary $flipValue — output: $subText"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- HsbyFailoverCount — counter incremented by ≥ 1 ----
|
||||||
|
Write-Header "HsbyFailoverCount"
|
||||||
|
$failoverAfter = Get-HsbyDiagnosticValue -Counter "AbCip.HsbyFailoverCount"
|
||||||
|
if ($null -eq $failoverAfter) { $failoverAfter = 0 }
|
||||||
|
$counterOk = ($failoverAfter - $failoverBaseline) -ge 1
|
||||||
|
$results += [PSCustomObject]@{
|
||||||
|
Name = "HsbyFailoverCount"
|
||||||
|
Passed = $counterOk
|
||||||
|
Detail = if ($counterOk) {
|
||||||
|
"AbCip.HsbyFailoverCount went from $failoverBaseline → $failoverAfter"
|
||||||
|
} else {
|
||||||
|
"AbCip.HsbyFailoverCount unchanged ($failoverBaseline → $failoverAfter); expected at least 1 increment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Summary -Title "AB CIP HSBY failover e2e" -Results $results
|
||||||
|
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||||
297
scripts/e2e/test-abcip.ps1
Normal file
297
scripts/e2e/test-abcip.ps1
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
#Requires -Version 7.0
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
End-to-end CLI test for the AB CIP driver (ControlLogix / CompactLogix /
|
||||||
|
Micro800 / GuardLogix) bridged through the OtOpcUa server.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Mirrors test-modbus.ps1 but against libplctag's ab_server (or a real Logix
|
||||||
|
controller). Five assertions: probe / driver-loopback / forward-bridge /
|
||||||
|
reverse-bridge / subscribe-sees-change.
|
||||||
|
|
||||||
|
Prereqs:
|
||||||
|
- ab_server container up (tests/.../AbCip.IntegrationTests/Docker/docker-compose.yml,
|
||||||
|
--profile controllogix) OR a real PLC on the network.
|
||||||
|
- OtOpcUa server running with an AB CIP DriverInstance pointing at the
|
||||||
|
same gateway + a Tag published at the -BridgeNodeId you pass.
|
||||||
|
|
||||||
|
.PARAMETER Gateway
|
||||||
|
ab://host[:port]/cip-path. Default ab://127.0.0.1/1,0 (ab_server ControlLogix).
|
||||||
|
|
||||||
|
.PARAMETER Family
|
||||||
|
ControlLogix / CompactLogix / Micro800 / GuardLogix (default ControlLogix).
|
||||||
|
|
||||||
|
.PARAMETER TagPath
|
||||||
|
Logix symbolic path to exercise. Default 'TestDINT' — matches the ab_server
|
||||||
|
--tag=TestDINT:DINT[1] seed.
|
||||||
|
|
||||||
|
.PARAMETER OpcUaUrl
|
||||||
|
OtOpcUa server endpoint.
|
||||||
|
|
||||||
|
.PARAMETER BridgeNodeId
|
||||||
|
NodeId at which the server publishes the TagPath.
|
||||||
|
|
||||||
|
.PARAMETER FastBridgeNodeId
|
||||||
|
Optional NodeId for a Tag declared with ScanRateMs <= 100. When supplied
|
||||||
|
alongside SlowBridgeNodeId the script runs the per-tag scan-rate assertion
|
||||||
|
(PR abcip-4.1).
|
||||||
|
|
||||||
|
.PARAMETER SlowBridgeNodeId
|
||||||
|
Optional NodeId for a Tag declared with ScanRateMs >= 1000. Pair with
|
||||||
|
FastBridgeNodeId to enable the scan-rate assertion.
|
||||||
|
|
||||||
|
.PARAMETER SystemConnectionStatusNodeId
|
||||||
|
Optional NodeId for the synthetic _System/_ConnectionStatus variable
|
||||||
|
emitted by AB CIP discovery (PR abcip-4.3). When supplied, the script
|
||||||
|
runs the SystemTagBrowse assertion — reads the value through the OPC UA
|
||||||
|
server + asserts it surfaces one of the canonical HostState strings.
|
||||||
|
NodeId form: ns=<n>;s=AbCip/<gateway>/_System/_ConnectionStatus.
|
||||||
|
|
||||||
|
.PARAMETER RefreshTagDbNodeId
|
||||||
|
Optional NodeId for the writeable _System/_RefreshTagDb trigger added in
|
||||||
|
PR abcip-4.4. When supplied, the script runs the RefreshTagDbWrite
|
||||||
|
assertion — writes True through the OPC UA server + reads back, asserting
|
||||||
|
the trigger latches to False (Kepware-style "always idle" semantics) and
|
||||||
|
the write itself surfaces Good. NodeId form:
|
||||||
|
ns=<n>;s=AbCip/<gateway>/_System/_RefreshTagDb.
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$Gateway = "ab://127.0.0.1/1,0",
|
||||||
|
[string]$Family = "ControlLogix",
|
||||||
|
[string]$TagPath = "TestDINT",
|
||||||
|
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||||
|
[Parameter(Mandatory)] [string]$BridgeNodeId,
|
||||||
|
[string]$FastBridgeNodeId,
|
||||||
|
[string]$SlowBridgeNodeId,
|
||||||
|
# PR abcip-4.3 — NodeId for the synthetic _System/_ConnectionStatus variable that
|
||||||
|
# discovery emits under each device. Optional — when wired, runs the
|
||||||
|
# SystemTagBrowse assertion that browses + reads the system folder through the OPC UA
|
||||||
|
# server. NodeId form: ns=<n>;s=AbCip/<gateway>/_System/_ConnectionStatus.
|
||||||
|
[string]$SystemConnectionStatusNodeId,
|
||||||
|
# PR abcip-4.4 — NodeId for the writeable _System/_RefreshTagDb refresh-trigger.
|
||||||
|
# Mirrors the SystemConnectionStatusNodeId knob: optional, only runs the
|
||||||
|
# RefreshTagDbWrite assertion when supplied. NodeId form:
|
||||||
|
# ns=<n>;s=AbCip/<gateway>/_System/_RefreshTagDb.
|
||||||
|
[string]$RefreshTagDbNodeId
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
. "$PSScriptRoot/_common.ps1"
|
||||||
|
|
||||||
|
$abcipCli = Get-CliInvocation `
|
||||||
|
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli" `
|
||||||
|
-ExeName "otopcua-abcip-cli"
|
||||||
|
$opcUaCli = Get-CliInvocation `
|
||||||
|
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||||
|
-ExeName "otopcua-cli"
|
||||||
|
|
||||||
|
$commonAbCip = @("-g", $Gateway, "-f", $Family)
|
||||||
|
$results = @()
|
||||||
|
|
||||||
|
# The AbCip driver's TagPath parser rejects CIP attribute syntax like
|
||||||
|
# `@raw_cpu_type` ("malformed TagPath"), so probe uses the real TagPath for
|
||||||
|
# every family. Works against ab_server + real controllers alike.
|
||||||
|
$results += Test-Probe `
|
||||||
|
-Cli $abcipCli `
|
||||||
|
-ProbeArgs (@("probe") + $commonAbCip + @("-t", $TagPath, "--type", "DInt"))
|
||||||
|
|
||||||
|
$writeValue = Get-Random -Minimum 1 -Maximum 9999
|
||||||
|
$results += Test-DriverLoopback `
|
||||||
|
-Cli $abcipCli `
|
||||||
|
-WriteArgs (@("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $writeValue)) `
|
||||||
|
-ReadArgs (@("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt")) `
|
||||||
|
-ExpectedValue "$writeValue"
|
||||||
|
|
||||||
|
$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
|
||||||
|
$results += Test-ServerBridge `
|
||||||
|
-DriverCli $abcipCli `
|
||||||
|
-DriverWriteArgs (@("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $bridgeValue)) `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-OpcUaNodeId $BridgeNodeId `
|
||||||
|
-ExpectedValue "$bridgeValue"
|
||||||
|
|
||||||
|
$reverseValue = Get-Random -Minimum 20000 -Maximum 29999
|
||||||
|
$results += Test-OpcUaWriteBridge `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-OpcUaNodeId $BridgeNodeId `
|
||||||
|
-DriverCli $abcipCli `
|
||||||
|
-DriverReadArgs (@("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt")) `
|
||||||
|
-ExpectedValue "$reverseValue"
|
||||||
|
|
||||||
|
$subValue = Get-Random -Minimum 30000 -Maximum 39999
|
||||||
|
$results += Test-SubscribeSeesChange `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-OpcUaNodeId $BridgeNodeId `
|
||||||
|
-DriverCli $abcipCli `
|
||||||
|
-DriverWriteArgs (@("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $subValue)) `
|
||||||
|
-ExpectedValue "$subValue"
|
||||||
|
|
||||||
|
# PR abcip-3.2 — Symbolic-vs-Logical sanity assertion. Reads the same tag with both
|
||||||
|
# addressing modes through the CLI's --addressing-mode flag. Logical-mode against ab_server
|
||||||
|
# falls back to Symbolic on the wire (libplctag wrapper limitation; see AbCip-Performance.md
|
||||||
|
# §Addressing mode), so the assertion is "both modes complete + return the same value" — not
|
||||||
|
# a perf comparison. Skipped on Micro800 (driver downgrades Logical → Symbolic with warning,
|
||||||
|
# making both reads identical-by-design + uninteresting to compare here).
|
||||||
|
if ($Family -ne "Micro800") {
|
||||||
|
$symValue = Get-Random -Minimum 40000 -Maximum 49999
|
||||||
|
Write-Host "AB CIP e2e: priming gateway with $symValue then reading via Symbolic + Logical"
|
||||||
|
$writeArgs = @("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $symValue)
|
||||||
|
& $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null
|
||||||
|
|
||||||
|
$symRead = & $abcipCli.Exe @($abcipCli.Args + @("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "--addressing-mode", "Symbolic"))
|
||||||
|
$logRead = & $abcipCli.Exe @($abcipCli.Args + @("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "--addressing-mode", "Logical"))
|
||||||
|
|
||||||
|
$symMatched = ($symRead -join "`n") -match "$symValue"
|
||||||
|
$logMatched = ($logRead -join "`n") -match "$symValue"
|
||||||
|
$passed = $symMatched -and $logMatched
|
||||||
|
$results += [PSCustomObject]@{
|
||||||
|
Name = "AddressingModeSanity"
|
||||||
|
Passed = $passed
|
||||||
|
Detail = if ($passed) { "Symbolic + Logical both returned $symValue" } else { "Sym=$symMatched Log=$logMatched" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# PR abcip-4.1 — per-tag scan-rate divergence assertion. Runs only when both fast + slow
|
||||||
|
# NodeIds are wired; otherwise this knob is skipped on the existing single-NodeId fixture.
|
||||||
|
# The assertion is "fast bucket sees > 5x as many notifications as slow bucket" — the
|
||||||
|
# unit + integration tests cover the bucketing math, this just proves the multi-rate split
|
||||||
|
# survives end-to-end through the OPC UA server's Subscription / MonitoredItem path.
|
||||||
|
if ($FastBridgeNodeId -and $SlowBridgeNodeId) {
|
||||||
|
Write-Header "Per-tag scan rate (FastBridge=$FastBridgeNodeId, SlowBridge=$SlowBridgeNodeId)"
|
||||||
|
$duration = 8
|
||||||
|
$fastOut = New-TemporaryFile
|
||||||
|
$slowOut = New-TemporaryFile
|
||||||
|
$fastErr = New-TemporaryFile
|
||||||
|
$slowErr = New-TemporaryFile
|
||||||
|
$fastArgs = @($opcUaCli.PrefixArgs) + @("subscribe", "-u", $OpcUaUrl, "-n", $FastBridgeNodeId, "-i", "100", "--duration", "$duration")
|
||||||
|
$slowArgs = @($opcUaCli.PrefixArgs) + @("subscribe", "-u", $OpcUaUrl, "-n", $SlowBridgeNodeId, "-i", "1000", "--duration", "$duration")
|
||||||
|
$fastProc = Start-Process -FilePath $opcUaCli.File -ArgumentList $fastArgs `
|
||||||
|
-NoNewWindow -PassThru `
|
||||||
|
-RedirectStandardOutput $fastOut.FullName `
|
||||||
|
-RedirectStandardError $fastErr.FullName
|
||||||
|
$slowProc = Start-Process -FilePath $opcUaCli.File -ArgumentList $slowArgs `
|
||||||
|
-NoNewWindow -PassThru `
|
||||||
|
-RedirectStandardOutput $slowOut.FullName `
|
||||||
|
-RedirectStandardError $slowErr.FullName
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
|
||||||
|
# Drive a single PLC change so even stable tags get *one* notification during the window
|
||||||
|
# (initial-data push + 1 change). The cadence assertion below relies on the fast tag
|
||||||
|
# accumulating sampling-interval-driven events even between explicit changes.
|
||||||
|
$tickValue = Get-Random -Minimum 50000 -Maximum 59999
|
||||||
|
$writeArgs = @("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $tickValue)
|
||||||
|
& $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null
|
||||||
|
|
||||||
|
$fastProc.WaitForExit(($duration + 5) * 1000) | Out-Null
|
||||||
|
$slowProc.WaitForExit(($duration + 5) * 1000) | Out-Null
|
||||||
|
if (-not $fastProc.HasExited) { Stop-Process -Id $fastProc.Id -Force }
|
||||||
|
if (-not $slowProc.HasExited) { Stop-Process -Id $slowProc.Id -Force }
|
||||||
|
|
||||||
|
$fastText = (Get-Content $fastOut.FullName -Raw) + (Get-Content $fastErr.FullName -Raw)
|
||||||
|
$slowText = (Get-Content $slowOut.FullName -Raw) + (Get-Content $slowErr.FullName -Raw)
|
||||||
|
Remove-Item $fastOut.FullName, $slowOut.FullName, $fastErr.FullName, $slowErr.FullName -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
# Each data-change line matches `=\s*<value>\s*(<status>)` per Test-SubscribeSeesChange.
|
||||||
|
$fastMatches = ([regex]::Matches($fastText, "=\s*\S+\s*\(")).Count
|
||||||
|
$slowMatches = ([regex]::Matches($slowText, "=\s*\S+\s*\(")).Count
|
||||||
|
$passed = ($fastMatches -ge 5) -and ($fastMatches -gt ($slowMatches * 5))
|
||||||
|
$detail = if ($passed) {
|
||||||
|
"fast=$fastMatches notifications vs slow=$slowMatches (>5x ratio achieved)"
|
||||||
|
} else {
|
||||||
|
"fast=$fastMatches slow=$slowMatches — expected fast > slow*5"
|
||||||
|
}
|
||||||
|
$results += [PSCustomObject]@{ Name = "PerTagScanRate"; Passed = $passed; Detail = $detail }
|
||||||
|
}
|
||||||
|
|
||||||
|
# PR abcip-4.2 — write-coalesce assertion. Writes the same value twice through the OPC UA
|
||||||
|
# server and verifies the PLC-side state reflects only one wire write. The driver-side
|
||||||
|
# diagnostics counter (AbCip.WritesSuppressed) is the authoritative signal, but ab_server
|
||||||
|
# itself doesn't expose a "writes received" counter so this script-level check is intentionally
|
||||||
|
# observational — it primes the tag with a baseline, writes the same value twice, and reads
|
||||||
|
# back to confirm the value matches without surfacing additional state changes. The unit + integration
|
||||||
|
# tests do the strict "exactly N suppressions" math; this is the e2e shape proof.
|
||||||
|
$coalesceValue = Get-Random -Minimum 60000 -Maximum 69999
|
||||||
|
Write-Header "WriteCoalesce (baseline=$coalesceValue, two redundant writes)"
|
||||||
|
$writeArgs = @("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $coalesceValue)
|
||||||
|
& $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null
|
||||||
|
& $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null
|
||||||
|
& $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null
|
||||||
|
$readArgs = @("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt")
|
||||||
|
$readOut = & $abcipCli.Exe @($abcipCli.Args + $readArgs)
|
||||||
|
$coalesceMatch = ($readOut -join "`n") -match "$coalesceValue"
|
||||||
|
$results += [PSCustomObject]@{
|
||||||
|
Name = "WriteCoalesce"
|
||||||
|
Passed = $coalesceMatch
|
||||||
|
Detail = if ($coalesceMatch) {
|
||||||
|
"three identical writes of $coalesceValue produced the expected readback (driver-side WritesSuppressed counter exposed via driver-diagnostics RPC)"
|
||||||
|
} else {
|
||||||
|
"three identical writes did not converge on $coalesceValue — got '$readOut'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# PR abcip-4.3 — _System/_ConnectionStatus browse-and-read assertion. Reads the live
|
||||||
|
# diagnostic snapshot via the OPC UA Client CLI; the value comes straight from the
|
||||||
|
# AbCipSystemTagSource (no libplctag round-trip). When the probe loop is healthy + the
|
||||||
|
# gateway is reachable, the value should be "Running"; on a stopped fixture it would be
|
||||||
|
# "Stopped". The assertion accepts any of the four canonical states, plus the "Unknown"
|
||||||
|
# transient that surfaces before the first probe iteration completes.
|
||||||
|
if ($SystemConnectionStatusNodeId) {
|
||||||
|
Write-Header "SystemTagBrowse (_System/_ConnectionStatus from $SystemConnectionStatusNodeId)"
|
||||||
|
$sysReadArgs = @($opcUaCli.PrefixArgs) + @("read", "-u", $OpcUaUrl, "-n", $SystemConnectionStatusNodeId)
|
||||||
|
$sysOut = & $opcUaCli.File @sysReadArgs 2>&1
|
||||||
|
$sysJoined = ($sysOut -join "`n")
|
||||||
|
$sysMatched = $sysJoined -match "Running|Stopped|Unknown|Faulted"
|
||||||
|
$results += [PSCustomObject]@{
|
||||||
|
Name = "SystemTagBrowse"
|
||||||
|
Passed = $sysMatched
|
||||||
|
Detail = if ($sysMatched) {
|
||||||
|
"_ConnectionStatus surfaced one of Running / Stopped / Unknown / Faulted via OPC UA"
|
||||||
|
} else {
|
||||||
|
"_ConnectionStatus did not surface a recognised HostState — got '$sysJoined'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# PR abcip-4.4 — _RefreshTagDb write-then-verify assertion. Writes True through the
|
||||||
|
# OPC UA server (the live driver intercepts the write + dispatches to RebrowseAsync
|
||||||
|
# against the cached IAddressSpaceBuilder) + reads back, asserting Kepware-style
|
||||||
|
# latch semantics: the trigger always reads False the moment the dispatch returns.
|
||||||
|
# Pairs with the existing rebrowse step driven by the AbCip CLI (issue #233) — both
|
||||||
|
# surfaces hit the same RebrowseAsync entry point, just from different sides of the
|
||||||
|
# OPC UA wire.
|
||||||
|
if ($RefreshTagDbNodeId) {
|
||||||
|
Write-Header "RefreshTagDbWrite (_System/_RefreshTagDb from $RefreshTagDbNodeId)"
|
||||||
|
$writeArgs = @($opcUaCli.PrefixArgs) + @(
|
||||||
|
"write", "-u", $OpcUaUrl, "-n", $RefreshTagDbNodeId, "-v", "true", "--type", "Boolean")
|
||||||
|
$writeOut = & $opcUaCli.File @writeArgs 2>&1
|
||||||
|
$writeJoined = ($writeOut -join "`n")
|
||||||
|
# The OPC UA Client CLI surfaces "Good" on success; a non-Good result still
|
||||||
|
# round-trips the literal status code so we can match generously.
|
||||||
|
$writeOk = $writeJoined -match "Good"
|
||||||
|
|
||||||
|
$readArgs = @($opcUaCli.PrefixArgs) + @("read", "-u", $OpcUaUrl, "-n", $RefreshTagDbNodeId)
|
||||||
|
$readOut = & $opcUaCli.File @readArgs 2>&1
|
||||||
|
$readJoined = ($readOut -join "`n")
|
||||||
|
# Kepware-style trigger reads always return false — assert the trigger isn't
|
||||||
|
# latched to true after the write. Match case-insensitively because the OPC UA
|
||||||
|
# Client CLI may render the value as "False" or "false".
|
||||||
|
$readFalse = $readJoined -imatch "false"
|
||||||
|
|
||||||
|
$passed = $writeOk -and $readFalse
|
||||||
|
$results += [PSCustomObject]@{
|
||||||
|
Name = "RefreshTagDbWrite"
|
||||||
|
Passed = $passed
|
||||||
|
Detail = if ($passed) {
|
||||||
|
"_RefreshTagDb write returned Good and read-back surfaced false — Kepware-style latch held"
|
||||||
|
} else {
|
||||||
|
"RefreshTagDb write/verify failed — write='$writeJoined' read='$readJoined'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Summary -Title "AB CIP e2e" -Results $results
|
||||||
|
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||||
342
scripts/e2e/test-ablegacy.ps1
Normal file
342
scripts/e2e/test-ablegacy.ps1
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
#Requires -Version 7.0
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
End-to-end CLI test for the AB Legacy (PCCC) driver.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Runs against libplctag's ab_server PCCC Docker fixture (one of the
|
||||||
|
slc500 / micrologix / plc5 compose profiles) or real SLC / MicroLogix /
|
||||||
|
PLC-5 hardware. Five assertions: probe / driver-loopback / forward-
|
||||||
|
bridge / reverse-bridge / subscribe-sees-change.
|
||||||
|
|
||||||
|
ab_server enforces a non-empty CIP routing path (`/1,0`) before the
|
||||||
|
PCCC dispatcher runs; real hardware accepts an empty path. The default
|
||||||
|
$Gateway uses `/1,0` for the Docker fixture — pass `-Gateway
|
||||||
|
"ab://host:44818/"` when pointing at a real SLC 5/05 / MicroLogix /
|
||||||
|
PLC-5.
|
||||||
|
|
||||||
|
.PARAMETER Gateway
|
||||||
|
ab://host[:port]/cip-path. Default ab://127.0.0.1/1,0 (Docker fixture).
|
||||||
|
|
||||||
|
.PARAMETER PlcType
|
||||||
|
Slc500 / MicroLogix / Plc5 / LogixPccc (default Slc500).
|
||||||
|
|
||||||
|
.PARAMETER Address
|
||||||
|
PCCC address to exercise. Default N7:5.
|
||||||
|
|
||||||
|
.PARAMETER OpcUaUrl
|
||||||
|
OtOpcUa server endpoint.
|
||||||
|
|
||||||
|
.PARAMETER BridgeNodeId
|
||||||
|
NodeId at which the server publishes the Address.
|
||||||
|
|
||||||
|
.PARAMETER DiagnosticsRequestCountNodeId
|
||||||
|
Optional NodeId for the synthetic _Diagnostics/<host>/RequestCount variable
|
||||||
|
emitted by AB Legacy discovery (PR ablegacy-10 / #253). When supplied, the
|
||||||
|
script runs the DiagnosticsRequestCount assertion: reads the user-tag
|
||||||
|
BridgeNodeId N times through the OPC UA server, then reads the diagnostic
|
||||||
|
counter and asserts the value is at least N (a probe loop or a parallel
|
||||||
|
client may have bumped it by more, so the comparison is `>=`). NodeId form:
|
||||||
|
ns=<n>;s=AbLegacy/<gateway>/_Diagnostics/RequestCount. Mirrors the
|
||||||
|
-SystemConnectionStatusNodeId knob on test-abcip.ps1.
|
||||||
|
|
||||||
|
.PARAMETER DiagnosticsDemoteCountNodeId
|
||||||
|
Optional NodeId for the synthetic _Diagnostics/<host>/DemoteCount variable
|
||||||
|
emitted by AB Legacy discovery (PR ablegacy-12 / #255). When supplied, the
|
||||||
|
script runs the auto-demote assertion: kills the simulator container so
|
||||||
|
reads start failing, hammers the user-tag BridgeNodeId at least
|
||||||
|
FailureThreshold times to trip the demotion, then reads the diagnostic
|
||||||
|
counter and asserts the value increased by >= 1. NodeId form:
|
||||||
|
ns=<n>;s=AbLegacy/<gateway>/_Diagnostics/DemoteCount. The simulator
|
||||||
|
must support `docker stop otopcua-ab-server-slc500` for the kill stage.
|
||||||
|
|
||||||
|
.PARAMETER FailureThresholdForDemote
|
||||||
|
Failure threshold the server is configured with (default 3). The
|
||||||
|
demote assertion writes/reads N+1 times against the killed simulator
|
||||||
|
to guarantee the threshold trips even if some reads beat the kill.
|
||||||
|
|
||||||
|
.PARAMETER DhPlusStation
|
||||||
|
PR ablegacy-13 / #256 — DH+ node address (octal 0..77 == decimal 0..63)
|
||||||
|
of a PLC-5 reachable through a 1756-DHRIO module. **Documentation
|
||||||
|
parameter only — there is no automated assertion**: libplctag's ab_server
|
||||||
|
does not simulate the DHRIO + DH+ + PLC-5 stack, so wire-level coverage
|
||||||
|
requires real hardware. When supplied alongside a `-Gateway` of the form
|
||||||
|
`ab://<host>/1,<slot>,2,<station-octal>` and `-PlcType Plc5`, the value
|
||||||
|
here is recorded in the run log so reproducibility is auditable. See
|
||||||
|
docs/drivers/AbLegacy-DH-Bridging.md for the manual smoke procedure.
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$Gateway = "ab://127.0.0.1/1,0",
|
||||||
|
[string]$PlcType = "Slc500",
|
||||||
|
[string]$Address = "N7:5",
|
||||||
|
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||||
|
[Parameter(Mandatory)] [string]$BridgeNodeId,
|
||||||
|
[string]$DiagnosticsRequestCountNodeId,
|
||||||
|
[string]$DiagnosticsDemoteCountNodeId,
|
||||||
|
[int]$FailureThresholdForDemote = 3,
|
||||||
|
# PR ablegacy-13 / #256 — DH+ station via 1756-DHRIO bridging. Doc-only:
|
||||||
|
# no automated assertion (no Docker fixture covers DH+). See script header
|
||||||
|
# comment + docs/drivers/AbLegacy-DH-Bridging.md.
|
||||||
|
[string]$DhPlusStation
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
. "$PSScriptRoot/_common.ps1"
|
||||||
|
|
||||||
|
# ab_server PCCC works; the earlier "upstream-broken" gate is gone. The only
|
||||||
|
# caveat: libplctag's ab_server rejects empty CIP paths, so $Gateway must
|
||||||
|
# carry a non-empty path segment (default /1,0). Real SLC/PLC-5 hardware
|
||||||
|
# accepts an empty path — use `ab://host:44818/` when pointing at real PLCs.
|
||||||
|
|
||||||
|
$abLegacyCli = Get-CliInvocation `
|
||||||
|
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli" `
|
||||||
|
-ExeName "otopcua-ablegacy-cli"
|
||||||
|
$opcUaCli = Get-CliInvocation `
|
||||||
|
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||||
|
-ExeName "otopcua-cli"
|
||||||
|
|
||||||
|
$commonAbLegacy = @("-g", $Gateway, "-P", $PlcType)
|
||||||
|
$results = @()
|
||||||
|
|
||||||
|
$results += Test-Probe `
|
||||||
|
-Cli $abLegacyCli `
|
||||||
|
-ProbeArgs (@("probe") + $commonAbLegacy + @("-a", "N7:0"))
|
||||||
|
|
||||||
|
$writeValue = Get-Random -Minimum 1 -Maximum 9999
|
||||||
|
$results += Test-DriverLoopback `
|
||||||
|
-Cli $abLegacyCli `
|
||||||
|
-WriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $writeValue)) `
|
||||||
|
-ReadArgs (@("read") + $commonAbLegacy + @("-a", $Address, "-t", "Int")) `
|
||||||
|
-ExpectedValue "$writeValue"
|
||||||
|
|
||||||
|
$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
|
||||||
|
$results += Test-ServerBridge `
|
||||||
|
-DriverCli $abLegacyCli `
|
||||||
|
-DriverWriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $bridgeValue)) `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-OpcUaNodeId $BridgeNodeId `
|
||||||
|
-ExpectedValue "$bridgeValue"
|
||||||
|
|
||||||
|
$reverseValue = Get-Random -Minimum 20000 -Maximum 29999
|
||||||
|
$results += Test-OpcUaWriteBridge `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-OpcUaNodeId $BridgeNodeId `
|
||||||
|
-DriverCli $abLegacyCli `
|
||||||
|
-DriverReadArgs (@("read") + $commonAbLegacy + @("-a", $Address, "-t", "Int")) `
|
||||||
|
-ExpectedValue "$reverseValue"
|
||||||
|
|
||||||
|
$subValue = Get-Random -Minimum 30000 -Maximum 32766
|
||||||
|
$results += Test-SubscribeSeesChange `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-OpcUaNodeId $BridgeNodeId `
|
||||||
|
-DriverCli $abLegacyCli `
|
||||||
|
-DriverWriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $subValue)) `
|
||||||
|
-ExpectedValue "$subValue"
|
||||||
|
|
||||||
|
# PR 7 — contiguous array read smoke. The default `--tag=N7[120]` in the Docker
|
||||||
|
# fixture's docker-compose.yml has plenty of room for `,10`; against real hardware
|
||||||
|
# the seeded N7 file just needs at least 10 words. Asserts the CLI exits 0 (the
|
||||||
|
# driver issued one PCCC frame for the whole block) — the per-element values are
|
||||||
|
# whatever the device currently holds.
|
||||||
|
Write-Header "Array contiguous read"
|
||||||
|
$arrayResult = Invoke-Cli -Cli $abLegacyCli `
|
||||||
|
-Args (@("read") + $commonAbLegacy + @("-a", "N7:0,10", "-t", "Int"))
|
||||||
|
if ($arrayResult.ExitCode -eq 0) {
|
||||||
|
Write-Pass "array read N7:0,10 succeeded"
|
||||||
|
$results += @{ Passed = $true }
|
||||||
|
} else {
|
||||||
|
Write-Fail "array read N7:0,10 exit=$($arrayResult.ExitCode)"
|
||||||
|
Write-Host $arrayResult.Output
|
||||||
|
$results += @{ Passed = $false; Reason = "array read exit $($arrayResult.ExitCode)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# PR 8 — deadband subscribe assertion. Subscribe with --deadband-absolute 5,
|
||||||
|
# write three small deltas (each within the 5-unit deadband), assert exactly
|
||||||
|
# one notification fires (the first-seen sample). The fourth write breaks
|
||||||
|
# above the threshold and the subscription should fire again.
|
||||||
|
Write-Header "Deadband subscribe (--deadband-absolute 5)"
|
||||||
|
$baseValue = Get-Random -Minimum 100 -Maximum 200
|
||||||
|
& $abLegacyCli.File @($abLegacyCli.PrefixArgs) `
|
||||||
|
@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $baseValue) | Out-Null
|
||||||
|
$subscribeProc = Start-Process -FilePath $abLegacyCli.File `
|
||||||
|
-ArgumentList ($abLegacyCli.PrefixArgs + @("subscribe") + $commonAbLegacy `
|
||||||
|
+ @("-a", $Address, "-t", "Int", "-i", "200", "--deadband-absolute", "5")) `
|
||||||
|
-PassThru -RedirectStandardOutput "$env:TEMP/ablegacy-deadband.out" `
|
||||||
|
-RedirectStandardError "$env:TEMP/ablegacy-deadband.err"
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
# Three small deltas within deadband.
|
||||||
|
& $abLegacyCli.File @($abLegacyCli.PrefixArgs) `
|
||||||
|
@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", ($baseValue + 1)) | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
& $abLegacyCli.File @($abLegacyCli.PrefixArgs) `
|
||||||
|
@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", ($baseValue + 2)) | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
& $abLegacyCli.File @($abLegacyCli.PrefixArgs) `
|
||||||
|
@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", ($baseValue + 3)) | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
Stop-Process -Id $subscribeProc.Id -Force -ErrorAction SilentlyContinue
|
||||||
|
$subscribeOutput = Get-Content "$env:TEMP/ablegacy-deadband.out" -ErrorAction SilentlyContinue
|
||||||
|
# Count `=` lines (the SubscribeCommand format prints one per OnDataChange). Expect exactly 1
|
||||||
|
# (the first-seen sample at $baseValue) — none of the +1/+2/+3 deltas crosses the 5 absolute.
|
||||||
|
$notifyLines = @($subscribeOutput | Where-Object { $_ -match " = " })
|
||||||
|
if ($notifyLines.Count -eq 1) {
|
||||||
|
Write-Pass "deadband subscribe emitted 1 notification (initial only); 3 sub-threshold writes suppressed"
|
||||||
|
$results += @{ Passed = $true }
|
||||||
|
} else {
|
||||||
|
Write-Fail "deadband subscribe expected 1 notification; got $($notifyLines.Count)"
|
||||||
|
Write-Host ($subscribeOutput -join "`n")
|
||||||
|
$results += @{ Passed = $false; Reason = "deadband notify count $($notifyLines.Count)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# PR ablegacy-10 / #253 — diagnostic-counter round-trip assertion. After N reads
|
||||||
|
# against the user-tag BridgeNodeId the auto-emitted _Diagnostics/<host>/RequestCount
|
||||||
|
# counter must be >= N. The exact equality isn't asserted because a probe loop /
|
||||||
|
# parallel client may have bumped the counter — the spec is "every read counts".
|
||||||
|
if ($DiagnosticsRequestCountNodeId) {
|
||||||
|
Write-Header "DiagnosticsRequestCount (_Diagnostics/RequestCount from $DiagnosticsRequestCountNodeId)"
|
||||||
|
$diagN = 5
|
||||||
|
# Read the first counter snapshot to baseline; the assertion compares delta against
|
||||||
|
# the N OPC UA reads we issue between snapshots so a noisy probe loop doesn't
|
||||||
|
# invalidate the test.
|
||||||
|
$baselineOut = & $opcUaCli.File @($opcUaCli.PrefixArgs) `
|
||||||
|
@("read", "-u", $OpcUaUrl, "-n", $DiagnosticsRequestCountNodeId) 2>&1
|
||||||
|
$baseline = 0
|
||||||
|
if (($baselineOut -join "`n") -match '(\d+)') { $baseline = [int64]$Matches[1] }
|
||||||
|
|
||||||
|
for ($i = 0; $i -lt $diagN; $i++) {
|
||||||
|
& $opcUaCli.File @($opcUaCli.PrefixArgs) `
|
||||||
|
@("read", "-u", $OpcUaUrl, "-n", $BridgeNodeId) | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$afterOut = & $opcUaCli.File @($opcUaCli.PrefixArgs) `
|
||||||
|
@("read", "-u", $OpcUaUrl, "-n", $DiagnosticsRequestCountNodeId) 2>&1
|
||||||
|
$after = 0
|
||||||
|
if (($afterOut -join "`n") -match '(\d+)') { $after = [int64]$Matches[1] }
|
||||||
|
|
||||||
|
$delta = $after - $baseline
|
||||||
|
if ($delta -ge $diagN) {
|
||||||
|
Write-Pass "DiagnosticsRequestCount delta $delta >= $diagN OPC UA reads"
|
||||||
|
$results += @{ Passed = $true }
|
||||||
|
} else {
|
||||||
|
Write-Fail "DiagnosticsRequestCount delta $delta < $diagN OPC UA reads (baseline=$baseline after=$after)"
|
||||||
|
$results += @{ Passed = $false; Reason = "diag delta $delta < $diagN" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ablegacy-11 / #254 — RSLogix CSV import smoke. Builds an in-memory canonical CSV
|
||||||
|
# (one row per N/F/B/L/ST/T/C/R file letter), invokes `import-rslogix --emit
|
||||||
|
# appsettings-fragment` against it, parses the resulting JSON, and asserts the Tags
|
||||||
|
# array carries exactly 8 entries. Doesn't talk to the PLC — purely offline parser
|
||||||
|
# coverage.
|
||||||
|
Write-Header "RSLogix CSV import"
|
||||||
|
$importCsvPath = Join-Path $env:TEMP "ablegacy-rslogix-canonical-$([guid]::NewGuid()).csv"
|
||||||
|
$importJsonPath = Join-Path $env:TEMP "ablegacy-rslogix-fragment-$([guid]::NewGuid()).json"
|
||||||
|
@"
|
||||||
|
Symbol,Address,Description,DataType,Scope
|
||||||
|
MotorSpeed,N7:0,Motor speed setpoint,INT,Global
|
||||||
|
TankLevel,F8:0,Tank level (gallons),REAL,Global
|
||||||
|
RunFlag,B3:0/0,Run command flag,BOOL,Global
|
||||||
|
TotalCount,L9:0,Total piece count,LINT,Global
|
||||||
|
RecipeName,ST10:0,"Recipe name, free-form text",STRING,Global
|
||||||
|
DwellTimer,T4:0.ACC,Dwell timer accumulator,TIMER,Global
|
||||||
|
PieceCounter,C5:0.ACC,Piece counter accumulator,COUNTER,Global
|
||||||
|
StateMachine,R6:0.LEN,State-machine control length,CONTROL,Global
|
||||||
|
"@ | Set-Content -Path $importCsvPath -Encoding UTF8
|
||||||
|
|
||||||
|
try {
|
||||||
|
$importResult = Invoke-Cli -Cli $abLegacyCli `
|
||||||
|
-Args @("import-rslogix", "--file", $importCsvPath, "--device", $Gateway,
|
||||||
|
"--emit", "appsettings-fragment", "--output", $importJsonPath)
|
||||||
|
if ($importResult.ExitCode -ne 0) {
|
||||||
|
Write-Fail "import-rslogix exit=$($importResult.ExitCode): $($importResult.Output)"
|
||||||
|
$results += @{ Passed = $false; Reason = "import-rslogix exit $($importResult.ExitCode)" }
|
||||||
|
}
|
||||||
|
elseif (-not (Test-Path $importJsonPath)) {
|
||||||
|
Write-Fail "import-rslogix produced no output file at $importJsonPath"
|
||||||
|
$results += @{ Passed = $false; Reason = "no output file" }
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$fragment = Get-Content $importJsonPath -Raw | ConvertFrom-Json
|
||||||
|
$tagCount = @($fragment.Tags).Count
|
||||||
|
if ($tagCount -eq 8) {
|
||||||
|
Write-Pass "import-rslogix emitted $tagCount tag(s) — matches CSV row count"
|
||||||
|
$results += @{ Passed = $true }
|
||||||
|
} else {
|
||||||
|
Write-Fail "import-rslogix emitted $tagCount tag(s); expected 8"
|
||||||
|
$results += @{ Passed = $false; Reason = "tag count $tagCount" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Remove-Item -Path $importCsvPath -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -Path $importJsonPath -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
# PR ablegacy-12 / #255 — auto-demote round-trip. Kill the simulator container,
|
||||||
|
# hammer the bridge NodeId past the failure threshold, then assert the
|
||||||
|
# DemoteCount diagnostic incremented. Restart the simulator at the end so the
|
||||||
|
# next run gets a clean baseline. Gated on -DiagnosticsDemoteCountNodeId so
|
||||||
|
# environments without docker-side control of the simulator can opt out.
|
||||||
|
if ($DiagnosticsDemoteCountNodeId) {
|
||||||
|
Write-Header "AutoDemote (kill simulator + observe DemoteCount from $DiagnosticsDemoteCountNodeId)"
|
||||||
|
$baselineDemoteOut = & $opcUaCli.File @($opcUaCli.PrefixArgs) `
|
||||||
|
@("read", "-u", $OpcUaUrl, "-n", $DiagnosticsDemoteCountNodeId) 2>&1
|
||||||
|
$baselineDemote = 0
|
||||||
|
if (($baselineDemoteOut -join "`n") -match '(\d+)') { $baselineDemote = [int64]$Matches[1] }
|
||||||
|
|
||||||
|
# Best-effort container kill — prefer the slc500 profile name; fall back to
|
||||||
|
# micrologix / plc5 in case the operator pointed the e2e at a different family.
|
||||||
|
$simContainers = @("otopcua-ab-server-slc500", "otopcua-ab-server-micrologix", "otopcua-ab-server-plc5")
|
||||||
|
$killed = $false
|
||||||
|
foreach ($c in $simContainers) {
|
||||||
|
$stop = docker stop $c 2>$null
|
||||||
|
if ($LASTEXITCODE -eq 0 -and $stop) {
|
||||||
|
Write-Host "Stopped $c"
|
||||||
|
$killed = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $killed) {
|
||||||
|
Write-Fail "AutoDemote: no ab_server container found via 'docker stop' — skipping demote assertion"
|
||||||
|
$results += @{ Passed = $false; Reason = "no simulator container to kill" }
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Hammer past the threshold. Each read against a now-unreachable simulator
|
||||||
|
# surfaces BadCommunicationError; FailureThreshold consecutive ones trip
|
||||||
|
# the demotion. We add 2 extra to absorb timing slack (one read may be
|
||||||
|
# in-flight when the kill lands).
|
||||||
|
$hammerCount = $FailureThresholdForDemote + 2
|
||||||
|
for ($i = 0; $i -lt $hammerCount; $i++) {
|
||||||
|
& $opcUaCli.File @($opcUaCli.PrefixArgs) `
|
||||||
|
@("read", "-u", $OpcUaUrl, "-n", $BridgeNodeId) 2>&1 | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
|
||||||
|
$afterDemoteOut = & $opcUaCli.File @($opcUaCli.PrefixArgs) `
|
||||||
|
@("read", "-u", $OpcUaUrl, "-n", $DiagnosticsDemoteCountNodeId) 2>&1
|
||||||
|
$afterDemote = 0
|
||||||
|
if (($afterDemoteOut -join "`n") -match '(\d+)') { $afterDemote = [int64]$Matches[1] }
|
||||||
|
|
||||||
|
$deltaDemote = $afterDemote - $baselineDemote
|
||||||
|
if ($deltaDemote -ge 1) {
|
||||||
|
Write-Pass "AutoDemote DemoteCount delta $deltaDemote >= 1 after $hammerCount failed reads"
|
||||||
|
$results += @{ Passed = $true }
|
||||||
|
} else {
|
||||||
|
Write-Fail "AutoDemote DemoteCount delta $deltaDemote < 1 (baseline=$baselineDemote after=$afterDemote)"
|
||||||
|
$results += @{ Passed = $false; Reason = "demote delta $deltaDemote" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Restart the simulator so subsequent test runs have a clean baseline.
|
||||||
|
# Best-effort — if docker-compose isn't on the path the operator can
|
||||||
|
# bring it back manually via the Docker/docker-compose.yml profile.
|
||||||
|
try { docker start (docker ps -aq -f "name=otopcua-ab-server-") | Out-Null } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Summary -Title "AB Legacy e2e" -Results $results
|
||||||
|
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||||
228
scripts/e2e/test-all.ps1
Normal file
228
scripts/e2e/test-all.ps1
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
#Requires -Version 7.0
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Runs every scripts/e2e/test-*.ps1 and tallies PASS / FAIL / SKIP.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
The per-protocol scripts require protocol-specific NodeIds that depend on
|
||||||
|
your server's config DB seed. This runner expects a JSON sidecar at
|
||||||
|
scripts/e2e/e2e-config.json (not checked in — see README) with one entry
|
||||||
|
per driver giving the NodeIds + endpoints to pass through. Any driver
|
||||||
|
missing from the sidecar is skipped with a clear message rather than
|
||||||
|
failing hard.
|
||||||
|
|
||||||
|
.PARAMETER ConfigFile
|
||||||
|
Path to the sidecar JSON. Default: scripts/e2e/e2e-config.json.
|
||||||
|
|
||||||
|
.PARAMETER OpcUaUrl
|
||||||
|
Default OPC UA endpoint passed to each per-driver script. Default
|
||||||
|
opc.tcp://localhost:4840. Individual entries in the config file can override.
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$ConfigFile = "$PSScriptRoot/e2e-config.json",
|
||||||
|
[string]$OpcUaUrl = "opc.tcp://localhost:4840"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
. "$PSScriptRoot/_common.ps1"
|
||||||
|
|
||||||
|
if (-not (Test-Path $ConfigFile)) {
|
||||||
|
Write-Fail "no config at $ConfigFile — copy e2e-config.sample.json + fill in your NodeIds first (see README)"
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
# -AsHashtable + Get-Or below keeps access tolerant of missing keys even under
|
||||||
|
# Set-StrictMode -Version 3.0 (inherited from _common.ps1). Without this a
|
||||||
|
# missing "$config.ablegacy" throws "property cannot be found on this object".
|
||||||
|
$config = Get-Content $ConfigFile -Raw | ConvertFrom-Json -AsHashtable
|
||||||
|
$summary = [ordered]@{}
|
||||||
|
|
||||||
|
# Return $Table[$Key] if present, else $Default. Nested tables are themselves
|
||||||
|
# hashtables so this composes: (Get-Or $config modbus)['opcUaUrl'].
|
||||||
|
function Get-Or {
|
||||||
|
param($Table, [string]$Key, $Default = $null)
|
||||||
|
if ($Table -and $Table.ContainsKey($Key)) { return $Table[$Key] }
|
||||||
|
return $Default
|
||||||
|
}
|
||||||
|
|
||||||
|
function Run-Suite {
|
||||||
|
param(
|
||||||
|
[string]$Name,
|
||||||
|
[scriptblock]$Action
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
& $Action
|
||||||
|
$summary[$Name] = if ($LASTEXITCODE -eq 0) { "PASS" } else { "FAIL" }
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Fail "$Name runner crashed: $_"
|
||||||
|
$summary[$Name] = "FAIL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Modbus
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
$modbus = Get-Or $config "modbus"
|
||||||
|
if ($modbus) {
|
||||||
|
Write-Header "== MODBUS =="
|
||||||
|
Run-Suite "modbus" {
|
||||||
|
& "$PSScriptRoot/test-modbus.ps1" `
|
||||||
|
-ModbusHost $modbus["endpoint"] `
|
||||||
|
-OpcUaUrl (Get-Or $modbus "opcUaUrl" $OpcUaUrl) `
|
||||||
|
-BridgeNodeId $modbus["bridgeNodeId"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else { $summary["modbus"] = "SKIP (no config entry)" }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AB CIP
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
$abcip = Get-Or $config "abcip"
|
||||||
|
if ($abcip) {
|
||||||
|
Write-Header "== AB CIP =="
|
||||||
|
Run-Suite "abcip" {
|
||||||
|
& "$PSScriptRoot/test-abcip.ps1" `
|
||||||
|
-Gateway $abcip["gateway"] `
|
||||||
|
-Family (Get-Or $abcip "family" "ControlLogix") `
|
||||||
|
-TagPath (Get-Or $abcip "tagPath" "TestDINT") `
|
||||||
|
-OpcUaUrl (Get-Or $abcip "opcUaUrl" $OpcUaUrl) `
|
||||||
|
-BridgeNodeId $abcip["bridgeNodeId"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else { $summary["abcip"] = "SKIP (no config entry)" }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AB Legacy
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
$ablegacy = Get-Or $config "ablegacy"
|
||||||
|
if ($ablegacy) {
|
||||||
|
Write-Header "== AB LEGACY =="
|
||||||
|
Run-Suite "ablegacy" {
|
||||||
|
& "$PSScriptRoot/test-ablegacy.ps1" `
|
||||||
|
-Gateway $ablegacy["gateway"] `
|
||||||
|
-PlcType (Get-Or $ablegacy "plcType" "Slc500") `
|
||||||
|
-Address (Get-Or $ablegacy "address" "N7:5") `
|
||||||
|
-OpcUaUrl (Get-Or $ablegacy "opcUaUrl" $OpcUaUrl) `
|
||||||
|
-BridgeNodeId $ablegacy["bridgeNodeId"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else { $summary["ablegacy"] = "SKIP (no config entry)" }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# S7
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
$s7 = Get-Or $config "s7"
|
||||||
|
if ($s7) {
|
||||||
|
Write-Header "== S7 =="
|
||||||
|
Run-Suite "s7" {
|
||||||
|
& "$PSScriptRoot/test-s7.ps1" `
|
||||||
|
-S7Host $s7["endpoint"] `
|
||||||
|
-Cpu (Get-Or $s7 "cpu" "S71500") `
|
||||||
|
-Slot (Get-Or $s7 "slot" 0) `
|
||||||
|
-Address (Get-Or $s7 "address" "DB1.DBW0") `
|
||||||
|
-OpcUaUrl (Get-Or $s7 "opcUaUrl" $OpcUaUrl) `
|
||||||
|
-BridgeNodeId $s7["bridgeNodeId"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else { $summary["s7"] = "SKIP (no config entry)" }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FOCAS
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
$focas = Get-Or $config "focas"
|
||||||
|
if ($focas) {
|
||||||
|
Write-Header "== FOCAS =="
|
||||||
|
Run-Suite "focas" {
|
||||||
|
& "$PSScriptRoot/test-focas.ps1" `
|
||||||
|
-CncHost $focas["host"] `
|
||||||
|
-CncPort (Get-Or $focas "port" 8193) `
|
||||||
|
-Address (Get-Or $focas "address" "R100") `
|
||||||
|
-OpcUaUrl (Get-Or $focas "opcUaUrl" $OpcUaUrl) `
|
||||||
|
-BridgeNodeId $focas["bridgeNodeId"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else { $summary["focas"] = "SKIP (no config entry)" }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TwinCAT
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
$twincat = Get-Or $config "twincat"
|
||||||
|
if ($twincat) {
|
||||||
|
Write-Header "== TWINCAT =="
|
||||||
|
Run-Suite "twincat" {
|
||||||
|
& "$PSScriptRoot/test-twincat.ps1" `
|
||||||
|
-AmsNetId $twincat["amsNetId"] `
|
||||||
|
-AmsPort (Get-Or $twincat "amsPort" 851) `
|
||||||
|
-SymbolPath (Get-Or $twincat "symbolPath" "MAIN.iCounter") `
|
||||||
|
-OpcUaUrl (Get-Or $twincat "opcUaUrl" $OpcUaUrl) `
|
||||||
|
-BridgeNodeId $twincat["bridgeNodeId"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else { $summary["twincat"] = "SKIP (no config entry)" }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 7 virtual tags + scripted alarms
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
$galaxy = Get-Or $config "galaxy"
|
||||||
|
if ($galaxy) {
|
||||||
|
Write-Header "== GALAXY =="
|
||||||
|
Run-Suite "galaxy" {
|
||||||
|
& "$PSScriptRoot/test-galaxy.ps1" `
|
||||||
|
-OpcUaUrl (Get-Or $galaxy "opcUaUrl" $OpcUaUrl) `
|
||||||
|
-SourceNodeId $galaxy["sourceNodeId"] `
|
||||||
|
-VirtualNodeId (Get-Or $galaxy "virtualNodeId" "") `
|
||||||
|
-AlarmNodeId (Get-Or $galaxy "alarmNodeId" "") `
|
||||||
|
-AlarmTriggerValue (Get-Or $galaxy "alarmTriggerValue" "75") `
|
||||||
|
-ChangeWaitSec (Get-Or $galaxy "changeWaitSec" 10) `
|
||||||
|
-AlarmWaitSec (Get-Or $galaxy "alarmWaitSec" 10) `
|
||||||
|
-HistoryLookbackSec (Get-Or $galaxy "historyLookbackSec" 3600)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else { $summary["galaxy"] = "SKIP (no config entry)" }
|
||||||
|
|
||||||
|
$phase7 = Get-Or $config "phase7"
|
||||||
|
if ($phase7) {
|
||||||
|
Write-Header "== PHASE 7 virtual tags + scripted alarms =="
|
||||||
|
Run-Suite "phase7" {
|
||||||
|
$defaultModbus = if ($modbus) { $modbus["endpoint"] } else { $null }
|
||||||
|
& "$PSScriptRoot/test-phase7-virtualtags.ps1" `
|
||||||
|
-ModbusHost (Get-Or $phase7 "modbusEndpoint" $defaultModbus) `
|
||||||
|
-OpcUaUrl (Get-Or $phase7 "opcUaUrl" $OpcUaUrl) `
|
||||||
|
-InputNodeId $phase7["inputNodeId"] `
|
||||||
|
-VirtualNodeId $phase7["virtualNodeId"] `
|
||||||
|
-AlarmNodeId (Get-Or $phase7 "alarmNodeId" $null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else { $summary["phase7"] = "SKIP (no config entry)" }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Final matrix
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "==================== FINAL MATRIX ====================" -ForegroundColor Cyan
|
||||||
|
$summary.GetEnumerator() | ForEach-Object {
|
||||||
|
$color = switch -Wildcard ($_.Value) {
|
||||||
|
"PASS" { "Green" }
|
||||||
|
"FAIL" { "Red" }
|
||||||
|
"SKIP*" { "Yellow" }
|
||||||
|
default { "Gray" }
|
||||||
|
}
|
||||||
|
Write-Host (" {0,-10} {1}" -f $_.Key, $_.Value) -ForegroundColor $color
|
||||||
|
}
|
||||||
|
|
||||||
|
$failed = ($summary.Values | Where-Object { $_ -eq "FAIL" }).Count
|
||||||
|
if ($failed -gt 0) {
|
||||||
|
Write-Host "$failed suite(s) failed." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "All present suites passed." -ForegroundColor Green
|
||||||
203
scripts/e2e/test-focas.ps1
Normal file
203
scripts/e2e/test-focas.ps1
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
#Requires -Version 7.0
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
End-to-end CLI test for the FOCAS (Fanuc CNC) driver.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
**Hardware-gated.** There is no public FOCAS simulator; the driver's
|
||||||
|
FwlibFocasClient P/Invokes Fanuc's licensed Fwlib32.dll. Against a dev
|
||||||
|
box without the DLL on PATH the test will skip with a clear message.
|
||||||
|
Against a real CNC with the DLL present it runs probe / driver-loopback /
|
||||||
|
server-bridge the same way the other scripts do.
|
||||||
|
|
||||||
|
Set FOCAS_TRUST_WIRE=1 when -CncHost points at a real CNC to un-gate.
|
||||||
|
|
||||||
|
.PARAMETER CncHost
|
||||||
|
IP or hostname of the CNC. Default 127.0.0.1 — override for real runs.
|
||||||
|
|
||||||
|
.PARAMETER CncPort
|
||||||
|
FOCAS TCP port. Default 8193.
|
||||||
|
|
||||||
|
.PARAMETER Address
|
||||||
|
FOCAS address to exercise. Default R100 (PMC R-file register).
|
||||||
|
|
||||||
|
.PARAMETER OpcUaUrl
|
||||||
|
OtOpcUa server endpoint.
|
||||||
|
|
||||||
|
.PARAMETER BridgeNodeId
|
||||||
|
NodeId at which the server publishes the Address.
|
||||||
|
|
||||||
|
.PARAMETER Write
|
||||||
|
Issue #268 (F4-a) + #269 (F4-b) — opts the script into write stages.
|
||||||
|
Without -Write the script runs read-only probe / loopback / bridge
|
||||||
|
coverage. With -Write the script additionally exercises the F4-b
|
||||||
|
cnc_wrparam + cnc_wrmacro round-trip stages against the configured
|
||||||
|
-ParamAddress / -MacroAddress (default safe values). The wire writes
|
||||||
|
fire only when FOCAS_TRUST_WIRE=1 (already gated above) AND the
|
||||||
|
operator explicitly requests the write path.
|
||||||
|
|
||||||
|
.PARAMETER ParamAddress
|
||||||
|
Parameter address for the F4-b write stage (default PARAM:1815).
|
||||||
|
Only used when -Write is supplied. Pick a parameter that's safe to
|
||||||
|
scribble on for your CNC setup — the default is benign for a stock
|
||||||
|
Fanuc 30i but every site differs.
|
||||||
|
|
||||||
|
.PARAMETER MacroAddress
|
||||||
|
Macro variable for the F4-b write stage (default MACRO:500). Macro
|
||||||
|
writes are the lowest-risk write surface (no parameter-write switch
|
||||||
|
needed, no MDI mode required) so this stage runs whenever -Write is
|
||||||
|
supplied.
|
||||||
|
|
||||||
|
.PARAMETER PmcBitAddress
|
||||||
|
PMC bit address for the F4-c bit-write round-trip stage (default
|
||||||
|
R100.3). Only fires when -Write is supplied AND the operator
|
||||||
|
double-opts in via FOCAS_PMC_WRITE=1, mirroring the FOCAS_PARAM_WRITE
|
||||||
|
gate. PMC writes have a higher blast radius than PARAM/MACRO (a
|
||||||
|
mistargeted bit can move motion or latch a feedhold) so the gate is
|
||||||
|
off by default — see docs/v2/focas-deployment.md "Write safety / PMC
|
||||||
|
pre-checks".
|
||||||
|
|
||||||
|
.PARAMETER CncPassword
|
||||||
|
Issue #271 (F4-d) — optional CNC connection-level password emitted via
|
||||||
|
cnc_wrunlockparam on connect. Required only when the controller gates
|
||||||
|
parameter writes behind a password switch (16i + some 30i firmwares
|
||||||
|
with parameter-protect on). Threaded through to every CLI invocation
|
||||||
|
in the -Write stage as --cnc-password. PASSWORD INVARIANT: never
|
||||||
|
logged — the CLI's Serilog config does not destructure this flag.
|
||||||
|
See docs/v2/focas-deployment.md § "FOCAS password handling" for the
|
||||||
|
no-log invariant + rotation runbook.
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$CncHost = "127.0.0.1",
|
||||||
|
[int]$CncPort = 8193,
|
||||||
|
[string]$Address = "R100",
|
||||||
|
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||||
|
[Parameter(Mandatory)] [string]$BridgeNodeId,
|
||||||
|
[switch]$Write,
|
||||||
|
[string]$ParamAddress = "PARAM:1815",
|
||||||
|
[string]$MacroAddress = "MACRO:500",
|
||||||
|
[string]$PmcBitAddress = "R100.3",
|
||||||
|
[string]$CncPassword = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
. "$PSScriptRoot/_common.ps1"
|
||||||
|
|
||||||
|
if (-not ($env:FOCAS_TRUST_WIRE -eq "1" -or $env:FOCAS_TRUST_WIRE -eq "true")) {
|
||||||
|
Write-Skip "FOCAS_TRUST_WIRE not set — no public simulator exists (task #222 tracks the lab rig). Set =1 when -CncHost points at a real CNC with Fwlib32.dll on PATH."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$focasCli = Get-CliInvocation `
|
||||||
|
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli" `
|
||||||
|
-ExeName "otopcua-focas-cli"
|
||||||
|
$opcUaCli = Get-CliInvocation `
|
||||||
|
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||||
|
-ExeName "otopcua-cli"
|
||||||
|
|
||||||
|
$commonFocas = @("-h", $CncHost, "-p", $CncPort)
|
||||||
|
# F4-d (issue #271) — thread the CNC connection password through to every CLI
|
||||||
|
# invocation. The CLI's --cnc-password flag emits cnc_wrunlockparam on connect
|
||||||
|
# and the driver's per-call retry path re-issues unlock + retries once on
|
||||||
|
# EW_PASSWD. PASSWORD INVARIANT: the password is NOT logged here. Write-Host
|
||||||
|
# and Test-* helpers never destructure $commonFocas, but we still avoid
|
||||||
|
# Write-Host'ing the array directly; the CLI's Serilog config also redacts.
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($CncPassword)) {
|
||||||
|
$commonFocas += @("--cnc-password", $CncPassword)
|
||||||
|
}
|
||||||
|
$results = @()
|
||||||
|
|
||||||
|
$results += Test-Probe `
|
||||||
|
-Cli $focasCli `
|
||||||
|
-ProbeArgs (@("probe") + $commonFocas + @("-a", $Address, "--type", "Int16"))
|
||||||
|
|
||||||
|
$writeValue = Get-Random -Minimum 1 -Maximum 9999
|
||||||
|
$results += Test-DriverLoopback `
|
||||||
|
-Cli $focasCli `
|
||||||
|
-WriteArgs (@("write") + $commonFocas + @("-a", $Address, "-t", "Int16", "-v", $writeValue)) `
|
||||||
|
-ReadArgs (@("read") + $commonFocas + @("-a", $Address, "-t", "Int16")) `
|
||||||
|
-ExpectedValue "$writeValue"
|
||||||
|
|
||||||
|
$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
|
||||||
|
$results += Test-ServerBridge `
|
||||||
|
-DriverCli $focasCli `
|
||||||
|
-DriverWriteArgs (@("write") + $commonFocas + @("-a", $Address, "-t", "Int16", "-v", $bridgeValue)) `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-OpcUaNodeId $BridgeNodeId `
|
||||||
|
-ExpectedValue "$bridgeValue"
|
||||||
|
|
||||||
|
$reverseValue = Get-Random -Minimum 20000 -Maximum 29999
|
||||||
|
$results += Test-OpcUaWriteBridge `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-OpcUaNodeId $BridgeNodeId `
|
||||||
|
-DriverCli $focasCli `
|
||||||
|
-DriverReadArgs (@("read") + $commonFocas + @("-a", $Address, "-t", "Int16")) `
|
||||||
|
-ExpectedValue "$reverseValue"
|
||||||
|
|
||||||
|
$subValue = Get-Random -Minimum 30000 -Maximum 32766
|
||||||
|
$results += Test-SubscribeSeesChange `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-OpcUaNodeId $BridgeNodeId `
|
||||||
|
-DriverCli $focasCli `
|
||||||
|
-DriverWriteArgs (@("write") + $commonFocas + @("-a", $Address, "-t", "Int16", "-v", $subValue)) `
|
||||||
|
-ExpectedValue "$subValue"
|
||||||
|
|
||||||
|
if ($Write) {
|
||||||
|
# F4-b — macro + parameter round-trip writes. Both stages use the same
|
||||||
|
# write-then-read shape the existing PMC stages use; the per-tag value
|
||||||
|
# comes back through Test-DriverLoopback's read step.
|
||||||
|
#
|
||||||
|
# Macro writes run unconditionally when -Write is supplied — no MDI / no
|
||||||
|
# parameter-write switch dependency, lowest-risk write surface on a CNC.
|
||||||
|
$macroValue = Get-Random -Minimum 100 -Maximum 9999
|
||||||
|
$results += Test-DriverLoopback `
|
||||||
|
-Cli $focasCli `
|
||||||
|
-WriteArgs (@("write") + $commonFocas + @("-a", $MacroAddress, "-t", "Int32", "-v", $macroValue)) `
|
||||||
|
-ReadArgs (@("read") + $commonFocas + @("-a", $MacroAddress, "-t", "Int32")) `
|
||||||
|
-ExpectedValue "$macroValue"
|
||||||
|
|
||||||
|
# Parameter writes only fire when the operator double-opts in via
|
||||||
|
# FOCAS_PARAM_WRITE=1. The CNC must be in MDI mode + parameter-write
|
||||||
|
# switch enabled or every write returns EW_PASSWD (BadUserAccessDenied);
|
||||||
|
# without an opt-in the script won't even attempt the write. F4-d will
|
||||||
|
# land an OPC UA-side unlock workflow that lets this stage run without
|
||||||
|
# the pendant.
|
||||||
|
if ($env:FOCAS_PARAM_WRITE -eq "1" -or $env:FOCAS_PARAM_WRITE -eq "true") {
|
||||||
|
$paramValue = Get-Random -Minimum 100 -Maximum 9999
|
||||||
|
$results += Test-DriverLoopback `
|
||||||
|
-Cli $focasCli `
|
||||||
|
-WriteArgs (@("write") + $commonFocas + @("-a", $ParamAddress, "-t", "Int32", "-v", $paramValue)) `
|
||||||
|
-ReadArgs (@("read") + $commonFocas + @("-a", $ParamAddress, "-t", "Int32")) `
|
||||||
|
-ExpectedValue "$paramValue"
|
||||||
|
} else {
|
||||||
|
Write-Host "[skip] FOCAS_PARAM_WRITE not set — parameter-write stage requires the CNC to be in MDI mode + parameter-write switch enabled (see docs/v2/focas-deployment.md 'Write safety')."
|
||||||
|
}
|
||||||
|
|
||||||
|
# F4-c — PMC bit round-trip. PMC writes have a higher blast radius
|
||||||
|
# than PARAM/MACRO (a mistargeted bit can move motion or latch a
|
||||||
|
# feedhold) so the stage is gated on a separate FOCAS_PMC_WRITE=1
|
||||||
|
# opt-in. The bit write exercises the driver's read-modify-write
|
||||||
|
# path: write 'on' -> read returns 'on'; write 'off' -> read returns
|
||||||
|
# 'off'. Both halves run so a regression in either branch is caught.
|
||||||
|
if ($env:FOCAS_PMC_WRITE -eq "1" -or $env:FOCAS_PMC_WRITE -eq "true") {
|
||||||
|
$results += Test-DriverLoopback `
|
||||||
|
-Cli $focasCli `
|
||||||
|
-WriteArgs (@("write") + $commonFocas + @("-a", $PmcBitAddress, "-t", "Bit", "-v", "on")) `
|
||||||
|
-ReadArgs (@("read") + $commonFocas + @("-a", $PmcBitAddress, "-t", "Bit")) `
|
||||||
|
-ExpectedValue "True"
|
||||||
|
$results += Test-DriverLoopback `
|
||||||
|
-Cli $focasCli `
|
||||||
|
-WriteArgs (@("write") + $commonFocas + @("-a", $PmcBitAddress, "-t", "Bit", "-v", "off")) `
|
||||||
|
-ReadArgs (@("read") + $commonFocas + @("-a", $PmcBitAddress, "-t", "Bit")) `
|
||||||
|
-ExpectedValue "False"
|
||||||
|
} else {
|
||||||
|
Write-Host "[skip] FOCAS_PMC_WRITE not set — PMC bit-write round-trip is off by default because a mistargeted PMC bit can move motion or latch a feedhold (see docs/v2/focas-deployment.md 'PMC pre-checks')."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Summary -Title "FOCAS e2e" -Results $results
|
||||||
|
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||||
278
scripts/e2e/test-galaxy.ps1
Normal file
278
scripts/e2e/test-galaxy.ps1
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
#Requires -Version 7.0
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
End-to-end CLI test for the Galaxy (MXAccess) driver — read, write, subscribe,
|
||||||
|
alarms, and history through a running OtOpcUa server.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Unlike the other e2e scripts there is no `otopcua-galaxy-cli` — the Galaxy
|
||||||
|
driver proxy lives in-process with the server + talks to `OtOpcUaGalaxyHost`
|
||||||
|
over a named pipe (MXAccess is 32-bit COM, can't ship in the .NET 10 process).
|
||||||
|
Every stage therefore goes through `otopcua-cli` against the published OPC UA
|
||||||
|
address space.
|
||||||
|
|
||||||
|
Seven stages:
|
||||||
|
|
||||||
|
1. Probe — otopcua-cli connect + read the source NodeId; confirms
|
||||||
|
the whole Galaxy.Host → Proxy → server → client chain is
|
||||||
|
up
|
||||||
|
2. Source read — otopcua-cli read returns a Good value for the source
|
||||||
|
attribute; proves IReadable.ReadAsync is dispatching
|
||||||
|
through the IPC bridge
|
||||||
|
3. Virtual-tag bridge — `otopcua-cli read` on the VirtualTag NodeId; confirms
|
||||||
|
the Phase 7 CachedTagUpstreamSource is bridging the
|
||||||
|
driver-sourced input into the scripting engine
|
||||||
|
4. Subscribe-sees-change — subscribe to the source NodeId in the background;
|
||||||
|
Galaxy pushes a data-change event within N seconds
|
||||||
|
(Galaxy's underlying attribute must be actively
|
||||||
|
changing — production Galaxies typically have
|
||||||
|
scan-driven updates; for idle galaxies, widen
|
||||||
|
-ChangeWaitSec or drive the write stage below first)
|
||||||
|
5. Reverse bridge — `otopcua-cli write` to a writable Galaxy attribute;
|
||||||
|
read it back. Gracefully becomes INFO-only if the
|
||||||
|
attribute's Galaxy-side AccessLevel forbids writes
|
||||||
|
(BadUserAccessDenied / BadNotWritable)
|
||||||
|
6. Alarm fires — subscribe to the scripted-alarm Condition NodeId,
|
||||||
|
drive the source tag above its threshold, confirm an
|
||||||
|
Active alarm event surfaces. Exercises the Part 9
|
||||||
|
alarm-condition propagation path
|
||||||
|
7. History read — historyread on the source tag over the last hour;
|
||||||
|
confirms Aveva Historian → IHistoryProvider dispatch
|
||||||
|
returns samples
|
||||||
|
|
||||||
|
The Phase 7 seed (`scripts/smoke/seed-phase-7-smoke.sql`) already plants the
|
||||||
|
right shape — one Galaxy DriverInstance, one source Tag, one VirtualTag
|
||||||
|
(source × 2), one ScriptedAlarm (source > 50). Substitute the real Galaxy
|
||||||
|
attribute FullName into `dbo.Tag.TagConfig` before running.
|
||||||
|
|
||||||
|
.PARAMETER OpcUaUrl
|
||||||
|
OtOpcUa server endpoint. Default opc.tcp://localhost:4840.
|
||||||
|
|
||||||
|
.PARAMETER SourceNodeId
|
||||||
|
NodeId of the driver-sourced Galaxy tag (numeric, writable preferred).
|
||||||
|
Default matches the Phase 7 seed — `ns=2;s=p7-smoke-tag-source`.
|
||||||
|
|
||||||
|
.PARAMETER VirtualNodeId
|
||||||
|
NodeId of the VirtualTag computed as Source × 2 (Phase 7 scripting).
|
||||||
|
Default matches the Phase 7 seed — `ns=2;s=p7-smoke-vt-derived`.
|
||||||
|
|
||||||
|
.PARAMETER AlarmNodeId
|
||||||
|
NodeId of the scripted-alarm Condition (fires when Source > 50).
|
||||||
|
Default matches the Phase 7 seed — `ns=2;s=p7-smoke-al-overtemp`.
|
||||||
|
|
||||||
|
.PARAMETER AlarmTriggerValue
|
||||||
|
Value written to -SourceNodeId to push it over the alarm threshold.
|
||||||
|
Default 75 (well above the seeded 50-threshold).
|
||||||
|
|
||||||
|
.PARAMETER ChangeWaitSec
|
||||||
|
Seconds the subscribe-sees-change stage waits for a natural data change.
|
||||||
|
Default 10. Idle galaxies may need this extended or the stage will fail
|
||||||
|
with "subscribe did not observe...".
|
||||||
|
|
||||||
|
.PARAMETER AlarmWaitSec
|
||||||
|
Seconds the alarm-fires stage waits after triggering the write. Default 10.
|
||||||
|
|
||||||
|
.PARAMETER HistoryLookbackSec
|
||||||
|
Seconds back from now to query history. Default 3600 (1 h).
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# Against the default Phase-7 smoke seed + live Galaxy + OtOpcUa server
|
||||||
|
./scripts/e2e/test-galaxy.ps1
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# Custom NodeIds from a non-smoke cluster
|
||||||
|
./scripts/e2e/test-galaxy.ps1 `
|
||||||
|
-SourceNodeId "ns=2;s=Reactor1.Temperature" `
|
||||||
|
-VirtualNodeId "ns=2;s=Reactor1.TempDoubled" `
|
||||||
|
-AlarmNodeId "ns=2;s=Reactor1.OverTemp" `
|
||||||
|
-AlarmTriggerValue 120
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||||
|
[string]$SourceNodeId = "ns=2;s=p7-smoke-tag-source",
|
||||||
|
[string]$VirtualNodeId = "ns=2;s=p7-smoke-vt-derived",
|
||||||
|
[string]$AlarmNodeId = "ns=2;s=p7-smoke-al-overtemp",
|
||||||
|
[string]$AlarmTriggerValue = "75",
|
||||||
|
[int]$ChangeWaitSec = 10,
|
||||||
|
[int]$AlarmWaitSec = 10,
|
||||||
|
[int]$HistoryLookbackSec = 3600
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
. "$PSScriptRoot/_common.ps1"
|
||||||
|
|
||||||
|
$opcUaCli = Get-CliInvocation `
|
||||||
|
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||||
|
-ExeName "otopcua-cli"
|
||||||
|
|
||||||
|
$results = @()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 1 — Probe. The probe is an otopcua-cli read against the source NodeId;
|
||||||
|
# success implies Galaxy.Host is up + the pipe ACL lets the server connect +
|
||||||
|
# the Proxy is tracking the tag + the server published it.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Write-Header "Probe"
|
||||||
|
$probe = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $SourceNodeId)
|
||||||
|
if ($probe.ExitCode -eq 0 -and $probe.Output -match "Status:\s+0x00000000") {
|
||||||
|
Write-Pass "source NodeId readable (Galaxy pipe → proxy → server → client chain up)"
|
||||||
|
$results += @{ Passed = $true }
|
||||||
|
} else {
|
||||||
|
Write-Fail "probe read failed (exit=$($probe.ExitCode))"
|
||||||
|
Write-Host $probe.Output
|
||||||
|
$results += @{ Passed = $false; Reason = "probe failed" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 2 — Source read. Captures the current value for the later virtual-tag
|
||||||
|
# comparison + confirms read dispatch works end-to-end. Failure here without a
|
||||||
|
# stage-1 failure would be unusual — probe already reads.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Write-Header "Source read"
|
||||||
|
$sourceRead = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $SourceNodeId)
|
||||||
|
$sourceValue = $null
|
||||||
|
if ($sourceRead.ExitCode -eq 0 -and $sourceRead.Output -match "Value:\s+([^\r\n]+)") {
|
||||||
|
$sourceValue = $Matches[1].Trim()
|
||||||
|
Write-Pass "source value = $sourceValue"
|
||||||
|
$results += @{ Passed = $true }
|
||||||
|
} else {
|
||||||
|
Write-Fail "source read failed"
|
||||||
|
Write-Host $sourceRead.Output
|
||||||
|
$results += @{ Passed = $false; Reason = "source read failed" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 3 — Virtual-tag bridge. Reads the Phase 7 VirtualTag (source × 2). Not
|
||||||
|
# strictly driver-specific, but exercises the CachedTagUpstreamSource bridge
|
||||||
|
# (the seam most likely to silently stop working after a Galaxy-side change).
|
||||||
|
# Skip if the VirtualNodeId param is empty (non-Phase-7 clusters).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if ([string]::IsNullOrEmpty($VirtualNodeId)) {
|
||||||
|
Write-Header "Virtual-tag bridge"
|
||||||
|
Write-Skip "VirtualNodeId not supplied — skipping Phase 7 bridge check"
|
||||||
|
} else {
|
||||||
|
Write-Header "Virtual-tag bridge"
|
||||||
|
$vtRead = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $VirtualNodeId)
|
||||||
|
if ($vtRead.ExitCode -eq 0 -and $vtRead.Output -match "Value:\s+([^\r\n]+)") {
|
||||||
|
$vtValue = $Matches[1].Trim()
|
||||||
|
Write-Pass "virtual-tag value = $vtValue (source was $sourceValue)"
|
||||||
|
$results += @{ Passed = $true }
|
||||||
|
} else {
|
||||||
|
Write-Fail "virtual-tag read failed"
|
||||||
|
Write-Host $vtRead.Output
|
||||||
|
$results += @{ Passed = $false; Reason = "virtual-tag read failed" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 4 — Subscribe-sees-change. otopcua-cli subscribe in the background;
|
||||||
|
# wait N seconds for Galaxy to push any data-change event on the source node.
|
||||||
|
# This is optimistic — if the Galaxy attribute is idle, widen -ChangeWaitSec.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Write-Header "Subscribe sees change"
|
||||||
|
$stdout = New-TemporaryFile
|
||||||
|
$stderr = New-TemporaryFile
|
||||||
|
$subArgs = @($opcUaCli.PrefixArgs) + @(
|
||||||
|
"subscribe", "-u", $OpcUaUrl, "-n", $SourceNodeId,
|
||||||
|
"-i", "500", "--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
|
||||||
|
|
||||||
|
# Any `=` followed by `(Good)` line after the initial subscribe-confirmation
|
||||||
|
# indicates at least one data-change tick arrived.
|
||||||
|
$changeLines = ($subOut -split "`n") | Where-Object { $_ -match "=\s+.*\(Good\)" }
|
||||||
|
if ($changeLines.Count -gt 0) {
|
||||||
|
Write-Pass "$($changeLines.Count) data-change events observed"
|
||||||
|
$results += @{ Passed = $true }
|
||||||
|
} else {
|
||||||
|
Write-Fail "no data-change events in ${ChangeWaitSec}s — Galaxy attribute may be idle; rerun with -ChangeWaitSec larger, or trigger a change first"
|
||||||
|
Write-Host $subOut
|
||||||
|
$results += @{ Passed = $false; Reason = "no data-change" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 5 — Reverse bridge (OPC UA write → Galaxy). Galaxy attributes with
|
||||||
|
# AccessLevel > FreeAccess often reject anonymous writes; record as INFO when
|
||||||
|
# that's the case rather than failing the whole script.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Write-Header "Reverse bridge (OPC UA write)"
|
||||||
|
$writeValue = [int]$AlarmTriggerValue # reuse the alarm trigger value — two stages for one write
|
||||||
|
$w = Invoke-Cli -Cli $opcUaCli -Args @(
|
||||||
|
"write", "-u", $OpcUaUrl, "-n", $SourceNodeId, "-v", "$writeValue")
|
||||||
|
if ($w.ExitCode -ne 0) {
|
||||||
|
# Connection/protocol failure — still a test failure.
|
||||||
|
Write-Fail "write CLI exit=$($w.ExitCode)"
|
||||||
|
Write-Host $w.Output
|
||||||
|
$results += @{ Passed = $false; Reason = "write failed" }
|
||||||
|
} elseif ($w.Output -match "Write failed:\s*0x801F0000") {
|
||||||
|
Write-Info "BadUserAccessDenied — attribute's Galaxy-side ACL blocks writes for this session. Not a bug; grant WriteOperate or run against a writable attribute."
|
||||||
|
$results += @{ Passed = $true; Reason = "acl-expected" }
|
||||||
|
} elseif ($w.Output -match "Write failed:\s*0x80390000|BadNotWritable") {
|
||||||
|
Write-Info "BadNotWritable — attribute is read-only at the Galaxy layer (status attributes, @-prefixed meta, etc)."
|
||||||
|
$results += @{ Passed = $true; Reason = "readonly-expected" }
|
||||||
|
} elseif ($w.Output -match "Write successful") {
|
||||||
|
# Read back — Galaxy poll interval + MXAccess advise may need a second or two to settle.
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
$r = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $SourceNodeId)
|
||||||
|
if ($r.Output -match "Value:\s+$([Regex]::Escape("$writeValue"))\b") {
|
||||||
|
Write-Pass "write propagated — source reads back $writeValue"
|
||||||
|
$results += @{ Passed = $true }
|
||||||
|
} else {
|
||||||
|
Write-Fail "write reported success but read-back did not reflect $writeValue"
|
||||||
|
Write-Host $r.Output
|
||||||
|
$results += @{ Passed = $false; Reason = "write-readback mismatch" }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Fail "unexpected write response"
|
||||||
|
Write-Host $w.Output
|
||||||
|
$results += @{ Passed = $false; Reason = "unexpected write response" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 6 — Alarm fires. Uses the helper from _common.ps1. If stage 5 already
|
||||||
|
# wrote the trigger value the alarm may already be active; that's fine — the
|
||||||
|
# Part 9 ConditionRefresh in the alarms CLI replays the current state so the
|
||||||
|
# subscribe window still captures the Active event.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if ([string]::IsNullOrEmpty($AlarmNodeId)) {
|
||||||
|
Write-Header "Alarm fires on threshold"
|
||||||
|
Write-Skip "AlarmNodeId not supplied — skipping alarm check"
|
||||||
|
} else {
|
||||||
|
$results += Test-AlarmFiresOnThreshold `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-AlarmNodeId $AlarmNodeId `
|
||||||
|
-InputNodeId $SourceNodeId `
|
||||||
|
-TriggerValue $AlarmTriggerValue `
|
||||||
|
-DurationSec $AlarmWaitSec
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 7 — History read. historyread against the source tag over the last N
|
||||||
|
# seconds. Failure modes the skip pattern catches: tag not historized in the
|
||||||
|
# Galaxy attribute's historization profile, or the lookback window misses the
|
||||||
|
# sample cadence.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
$results += Test-HistoryHasSamples `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-NodeId $SourceNodeId `
|
||||||
|
-LookbackSec $HistoryLookbackSec
|
||||||
|
|
||||||
|
Write-Summary -Title "Galaxy e2e" -Results $results
|
||||||
|
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||||
99
scripts/e2e/test-modbus.ps1
Normal file
99
scripts/e2e/test-modbus.ps1
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
#Requires -Version 7.0
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
End-to-end CLI test for the Modbus-TCP driver bridged through the OtOpcUa server.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Five assertions:
|
||||||
|
1. `otopcua-modbus-cli probe` hits the simulator
|
||||||
|
2. Driver-loopback write + read-back via modbus-cli
|
||||||
|
3. Forward bridge: modbus-cli writes HR[200], OPC UA client reads the bridged NodeId
|
||||||
|
4. Reverse bridge: OPC UA client writes the NodeId, modbus-cli reads HR[200]
|
||||||
|
5. Subscribe-sees-change: OPC UA subscription observes a modbus-cli write
|
||||||
|
|
||||||
|
Requires a running Modbus simulator on localhost:5020 (the pymodbus fixture
|
||||||
|
default — see tests/.../Modbus.IntegrationTests/Docker/docker-compose.yml)
|
||||||
|
and a running OtOpcUa server whose config DB has a Modbus DriverInstance
|
||||||
|
bound to that simulator + a Tag at HR[200] UInt16 published under the
|
||||||
|
NodeId passed via -BridgeNodeId.
|
||||||
|
|
||||||
|
NOTE: HR[200] (not HR[100]) — pymodbus standard.json makes HR[100] an
|
||||||
|
auto-incrementing register that mutates every poll, so loopback writes
|
||||||
|
can't be verified there.
|
||||||
|
|
||||||
|
.PARAMETER ModbusHost
|
||||||
|
Host:port of the Modbus simulator. Default 127.0.0.1:5020.
|
||||||
|
|
||||||
|
.PARAMETER OpcUaUrl
|
||||||
|
Endpoint URL of the OtOpcUa server. Default opc.tcp://localhost:4840.
|
||||||
|
|
||||||
|
.PARAMETER BridgeNodeId
|
||||||
|
OPC UA NodeId the OtOpcUa server publishes the HR[100] tag at. Set per your
|
||||||
|
server config — e.g. 'ns=2;s=/warsaw/modbus-sim/HR_100'. Required.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\test-modbus.ps1 -BridgeNodeId "ns=2;s=/warsaw/modbus-sim/HR_100"
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$ModbusHost = "127.0.0.1:5020",
|
||||||
|
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||||
|
[Parameter(Mandatory)] [string]$BridgeNodeId
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
. "$PSScriptRoot/_common.ps1"
|
||||||
|
|
||||||
|
$hostPart, $portPart = $ModbusHost.Split(":")
|
||||||
|
$port = [int]$portPart
|
||||||
|
|
||||||
|
$modbusCli = Get-CliInvocation `
|
||||||
|
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli" `
|
||||||
|
-ExeName "otopcua-modbus-cli"
|
||||||
|
$opcUaCli = Get-CliInvocation `
|
||||||
|
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||||
|
-ExeName "otopcua-cli"
|
||||||
|
|
||||||
|
$commonModbus = @("-h", $hostPart, "-p", $port)
|
||||||
|
$results = @()
|
||||||
|
|
||||||
|
$results += Test-Probe `
|
||||||
|
-Cli $modbusCli `
|
||||||
|
-ProbeArgs (@("probe") + $commonModbus)
|
||||||
|
|
||||||
|
$writeValue = Get-Random -Minimum 1 -Maximum 9999
|
||||||
|
$results += Test-DriverLoopback `
|
||||||
|
-Cli $modbusCli `
|
||||||
|
-WriteArgs (@("write") + $commonModbus + @("-r", "HoldingRegisters", "-a", "200", "-t", "UInt16", "-v", $writeValue)) `
|
||||||
|
-ReadArgs (@("read") + $commonModbus + @("-r", "HoldingRegisters", "-a", "200", "-t", "UInt16")) `
|
||||||
|
-ExpectedValue "$writeValue"
|
||||||
|
|
||||||
|
$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
|
||||||
|
$results += Test-ServerBridge `
|
||||||
|
-DriverCli $modbusCli `
|
||||||
|
-DriverWriteArgs (@("write") + $commonModbus + @("-r", "HoldingRegisters", "-a", "200", "-t", "UInt16", "-v", $bridgeValue)) `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-OpcUaNodeId $BridgeNodeId `
|
||||||
|
-ExpectedValue "$bridgeValue"
|
||||||
|
|
||||||
|
$reverseValue = Get-Random -Minimum 20000 -Maximum 29999
|
||||||
|
$results += Test-OpcUaWriteBridge `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-OpcUaNodeId $BridgeNodeId `
|
||||||
|
-DriverCli $modbusCli `
|
||||||
|
-DriverReadArgs (@("read") + $commonModbus + @("-r", "HoldingRegisters", "-a", "200", "-t", "UInt16")) `
|
||||||
|
-ExpectedValue "$reverseValue"
|
||||||
|
|
||||||
|
$subValue = Get-Random -Minimum 30000 -Maximum 39999
|
||||||
|
$results += Test-SubscribeSeesChange `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-OpcUaNodeId $BridgeNodeId `
|
||||||
|
-DriverCli $modbusCli `
|
||||||
|
-DriverWriteArgs (@("write") + $commonModbus + @("-r", "HoldingRegisters", "-a", "200", "-t", "UInt16", "-v", $subValue)) `
|
||||||
|
-ExpectedValue "$subValue"
|
||||||
|
|
||||||
|
Write-Summary -Title "Modbus e2e" -Results $results
|
||||||
|
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||||
240
scripts/e2e/test-opcuaclient.ps1
Normal file
240
scripts/e2e/test-opcuaclient.ps1
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
#Requires -Version 7.0
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
End-to-end CLI test for the OPC UA Client (gateway) driver bridged through
|
||||||
|
the OtOpcUa server. Stages: probe, read, subscribe, topology-change.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
The OPC UA Client driver reads from an upstream OPC UA server (default:
|
||||||
|
Microsoft's opc-plc simulator on opc.tcp://localhost:50000) and re-exposes
|
||||||
|
its address space through the local OtOpcUa server. This script drives
|
||||||
|
the bridged path end-to-end via `otopcua-cli`.
|
||||||
|
|
||||||
|
Four stages:
|
||||||
|
|
||||||
|
1. Probe — otopcua-cli connect succeeds against the OtOpcUa
|
||||||
|
server; confirms the gateway is up.
|
||||||
|
2. Bridged read — otopcua-cli read on the bridged NodeId returns a
|
||||||
|
Good value with a non-null payload; proves the
|
||||||
|
IReadable.ReadAsync path round-trips through the
|
||||||
|
driver to the upstream simulator.
|
||||||
|
3. Subscribe — otopcua-cli subscribe observes a data change within
|
||||||
|
N seconds (opc-plc's StepUp ticks once per second by
|
||||||
|
default, so this should always see a change).
|
||||||
|
4. Topology change — assert the auto-reimport-on-ModelChangeEvent path
|
||||||
|
is wired up. We can't easily fire a real upstream
|
||||||
|
model change without elevated opc-plc access, so
|
||||||
|
this stage prints the option settings + asserts the
|
||||||
|
driver's diagnostic surface reflects WatchModelChanges
|
||||||
|
is enabled (or skips with INFO when the upstream
|
||||||
|
doesn't expose ModelChangeEventType).
|
||||||
|
|
||||||
|
Requires:
|
||||||
|
- a running OtOpcUa server whose config DB has an OpcUaClient
|
||||||
|
DriverInstance bound to opc-plc (or another upstream server)
|
||||||
|
- the upstream OPC UA simulator reachable at $UpstreamUrl
|
||||||
|
- a Tag bridged from upstream NodeId $UpstreamNodeId to local
|
||||||
|
$BridgedNodeId
|
||||||
|
|
||||||
|
.PARAMETER OpcUaUrl
|
||||||
|
Endpoint URL of the OtOpcUa server. Default opc.tcp://localhost:4840.
|
||||||
|
|
||||||
|
.PARAMETER UpstreamUrl
|
||||||
|
Endpoint URL of the upstream OPC UA server (for documentation; the bridge
|
||||||
|
itself is wired in the OtOpcUa server config). Default opc.tcp://localhost:50000.
|
||||||
|
|
||||||
|
.PARAMETER BridgedNodeId
|
||||||
|
Local NodeId the OtOpcUa server exposes for the upstream tag. Required —
|
||||||
|
set per your server config (e.g. 'ns=2;s=/warsaw/opc-plc/StepUp').
|
||||||
|
|
||||||
|
.PARAMETER UpstreamNodeId
|
||||||
|
The upstream NodeId being bridged (informational only; default
|
||||||
|
'ns=3;s=StepUp' which is opc-plc's monotonically-increasing UInt32).
|
||||||
|
|
||||||
|
.PARAMETER ChangeWaitSec
|
||||||
|
How long the subscribe stage waits for a data-change. Default 10s.
|
||||||
|
|
||||||
|
.PARAMETER ReverseConnect
|
||||||
|
When set, the script asserts the gateway is configured for reverse-connect
|
||||||
|
(server-initiated) mode. The OtOpcUa server's DriverConfig for the OpcUaClient
|
||||||
|
instance must already have ReverseConnect.Enabled=true + ListenerUrl set; this
|
||||||
|
script doesn't reconfigure the driver, only verifies the bridged path still
|
||||||
|
reads end-to-end with the listener up. The reverse-connect topology is opaque
|
||||||
|
to the downstream OPC UA client (us), so the read assertion is identical to
|
||||||
|
the dial-mode path — the value of running the script in this mode is to catch
|
||||||
|
regressions where reverse-connect breaks the post-init capability surface.
|
||||||
|
|
||||||
|
.PARAMETER ReverseListenerUrl
|
||||||
|
Documentation-only. The listener URL the gateway is expected to be bound to
|
||||||
|
when -ReverseConnect is set; printed in the run banner so operators can
|
||||||
|
cross-check their server config. Default opc.tcp://0.0.0.0:4844.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\test-opcuaclient.ps1 -BridgedNodeId "ns=2;s=/warsaw/opc-plc/StepUp"
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# OT-DMZ deployment: the upstream dials the gateway. The script flow is the
|
||||||
|
# same — we still drive the bridged read through the OtOpcUa server — but the
|
||||||
|
# banner reflects the reverse-connect topology.
|
||||||
|
.\test-opcuaclient.ps1 -BridgedNodeId "ns=2;s=/warsaw/opc-plc/StepUp" -ReverseConnect
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||||
|
[string]$UpstreamUrl = "opc.tcp://localhost:50000",
|
||||||
|
[Parameter(Mandatory)] [string]$BridgedNodeId,
|
||||||
|
[string]$UpstreamNodeId = "ns=3;s=StepUp",
|
||||||
|
[int]$ChangeWaitSec = 10,
|
||||||
|
[switch]$ReverseConnect,
|
||||||
|
[string]$ReverseListenerUrl = "opc.tcp://0.0.0.0:4844",
|
||||||
|
# PR-12: HistoryReadEvents passthrough check. Requires the upstream to be running
|
||||||
|
# in alarm-history mode (opc-plc --alm) AND the OtOpcUa server to expose a notifier
|
||||||
|
# node bridged to the upstream's events source. The CLI doesn't have a dedicated
|
||||||
|
# event-history command yet; this stage runs a regular historyread against the
|
||||||
|
# bridged notifier and confirms the gateway round-trips the request without
|
||||||
|
# surfacing BadHistoryOperationUnsupported, which would indicate the filter-aware
|
||||||
|
# ReadEventsAsync path lost wiring.
|
||||||
|
[switch]$HistoryEvents,
|
||||||
|
[string]$EventsNotifierNodeId = "i=2253",
|
||||||
|
# PR-14: upstream-redundancy probe. Passes the primary + secondary URLs
|
||||||
|
# straight through to the gateway driver via DriverConfig (operator must have
|
||||||
|
# already wired Redundancy.Enabled=true on the OpcUaClient instance — this
|
||||||
|
# script doesn't reconfigure the driver, only verifies the bridged read still
|
||||||
|
# works while both upstreams are reachable, and that the driver's redundancy
|
||||||
|
# diagnostics are non-null). Stage is no-op when neither URL is provided.
|
||||||
|
[string]$PrimaryUrl,
|
||||||
|
[string]$SecondaryUrl
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
. "$PSScriptRoot/_common.ps1"
|
||||||
|
|
||||||
|
$opcUaCli = Get-CliInvocation `
|
||||||
|
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||||
|
-ExeName "otopcua-cli"
|
||||||
|
|
||||||
|
if ($ReverseConnect) {
|
||||||
|
Write-Host "[INFO] -ReverseConnect set: gateway is expected to be bound to listener $ReverseListenerUrl"
|
||||||
|
Write-Host "[INFO] Upstream OPC UA server should be configured with --rc=$ReverseListenerUrl (or equivalent on a real server)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = @()
|
||||||
|
|
||||||
|
# Stage 1: probe
|
||||||
|
$results += Test-Probe `
|
||||||
|
-Name "OpcUaClient probe" `
|
||||||
|
-Cmd $opcUaCli `
|
||||||
|
-Args @("connect", "-u", $OpcUaUrl)
|
||||||
|
|
||||||
|
# Stage 2: bridged read
|
||||||
|
$results += Test-Probe `
|
||||||
|
-Name "OpcUaClient bridged read" `
|
||||||
|
-Cmd $opcUaCli `
|
||||||
|
-Args @("read", "-u", $OpcUaUrl, "-n", $BridgedNodeId)
|
||||||
|
|
||||||
|
# Stage 3: subscribe-sees-change
|
||||||
|
Write-Host "[INFO] Subscribing to $BridgedNodeId for ${ChangeWaitSec}s..."
|
||||||
|
$subResults = & $opcUaCli.Cmd @($opcUaCli.Args + @(
|
||||||
|
"subscribe", "-u", $OpcUaUrl, "-n", $BridgedNodeId,
|
||||||
|
"-i", "500", "--duration", "$ChangeWaitSec"))
|
||||||
|
if ($LASTEXITCODE -eq 0 -and $subResults -match "DataChange|StepUp|value=") {
|
||||||
|
$results += [pscustomobject]@{ Stage = "Subscribe-sees-change"; Status = "PASS" }
|
||||||
|
} else {
|
||||||
|
$results += [pscustomobject]@{ Stage = "Subscribe-sees-change"; Status = "FAIL" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stage 4: topology change (auto-reimport on ModelChangeEvent)
|
||||||
|
#
|
||||||
|
# The OPC UA Client driver subscribes to BaseModelChangeEventType on the
|
||||||
|
# upstream Server node (i=2253) at the end of InitializeAsync, then debounces
|
||||||
|
# events over OpcUaClientDriverOptions.ModelChangeDebounce (default 5s) and
|
||||||
|
# triggers ReinitializeAsync.
|
||||||
|
#
|
||||||
|
# Driving a real upstream ModelChangeEvent from outside the simulator is
|
||||||
|
# upstream-specific:
|
||||||
|
# - opc-plc: invoke OpcPlc.AddSlowNode via OPC UA Call (requires a session
|
||||||
|
# directly to opc-plc, not via the gateway, since the gateway exposes
|
||||||
|
# mirrored read/write paths only for variables — methods are mirrored
|
||||||
|
# under PR-9 but call permissions on the simulator's namespace may
|
||||||
|
# not allow downstream invocation).
|
||||||
|
# - production server: deploy a topology-change to the upstream server +
|
||||||
|
# observe the local re-import.
|
||||||
|
#
|
||||||
|
# This stage is therefore documentation-only by default. Set
|
||||||
|
# $env:OPCUACLIENT_TOPOLOGY_TRIGGER_CMD to a command that drives a real
|
||||||
|
# topology change on the upstream and we'll execute it + wait for the
|
||||||
|
# debounced re-import.
|
||||||
|
$triggerCmd = $env:OPCUACLIENT_TOPOLOGY_TRIGGER_CMD
|
||||||
|
if ($triggerCmd) {
|
||||||
|
Write-Host "[INFO] Driving topology change via: $triggerCmd"
|
||||||
|
& cmd.exe /c $triggerCmd
|
||||||
|
Start-Sleep -Seconds 8 # debounce window + re-import duration
|
||||||
|
# After re-import the bridged node should still be readable (or, if
|
||||||
|
# the upstream removed the node, the read should return BadNodeIdUnknown).
|
||||||
|
# Either way the gateway must remain healthy.
|
||||||
|
$results += Test-Probe `
|
||||||
|
-Name "Topology-change re-read" `
|
||||||
|
-Cmd $opcUaCli `
|
||||||
|
-Args @("read", "-u", $OpcUaUrl, "-n", $BridgedNodeId)
|
||||||
|
} else {
|
||||||
|
Write-Host "[INFO] Topology-change stage skipped (set OPCUACLIENT_TOPOLOGY_TRIGGER_CMD to drive a real upstream model change)."
|
||||||
|
$results += [pscustomobject]@{ Stage = "Topology-change"; Status = "SKIP" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stage 5 (gated): HistoryReadEvents passthrough
|
||||||
|
#
|
||||||
|
# PR-12 lands the filter-aware IHistoryProvider.ReadEventsAsync overload on the
|
||||||
|
# OPC UA Client driver. End-to-end coverage requires:
|
||||||
|
# (a) the upstream in alarm-history mode (opc-plc --alm or a real server);
|
||||||
|
# (b) the OtOpcUa server forwarding HistoryReadEvents to the gateway driver.
|
||||||
|
# Gated behind -HistoryEvents because the default opc-plc fixture image isn't
|
||||||
|
# launched with --alm. When set, the stage issues a historyread against the
|
||||||
|
# bridged notifier ($EventsNotifierNodeId) and confirms the gateway returns
|
||||||
|
# the request without BadHistoryOperationUnsupported.
|
||||||
|
# Stage 6 (gated): upstream-redundancy probe (PR-14)
|
||||||
|
#
|
||||||
|
# When -PrimaryUrl + -SecondaryUrl are both supplied, the script runs an extra
|
||||||
|
# read against the bridged NodeId and reports whether the gateway is still
|
||||||
|
# answering. The actual ServiceLevel-driven failover is observable only on the
|
||||||
|
# server side (driver-diagnostics RPC reports RedundancyFailoverCount); this
|
||||||
|
# stage is a smoke check that the bridged path keeps round-tripping while
|
||||||
|
# both upstreams are reachable. Drive a real failover by writing to the
|
||||||
|
# primary's ServiceLevel node from outside this script.
|
||||||
|
if ($PrimaryUrl -and $SecondaryUrl) {
|
||||||
|
Write-Host "[INFO] Upstream redundancy probe: primary=$PrimaryUrl secondary=$SecondaryUrl"
|
||||||
|
$results += Test-Probe `
|
||||||
|
-Name "OpcUaClient redundancy bridged-read" `
|
||||||
|
-Cmd $opcUaCli `
|
||||||
|
-Args @("read", "-u", $OpcUaUrl, "-n", $BridgedNodeId)
|
||||||
|
} else {
|
||||||
|
if (-not $PrimaryUrl -and -not $SecondaryUrl) {
|
||||||
|
Write-Host "[INFO] Upstream redundancy stage skipped (set -PrimaryUrl and -SecondaryUrl to enable)."
|
||||||
|
$results += [pscustomobject]@{ Stage = "Upstream-redundancy"; Status = "SKIP" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($HistoryEvents) {
|
||||||
|
Write-Host "[INFO] HistoryEvents stage: issuing historyread against $EventsNotifierNodeId"
|
||||||
|
$start = (Get-Date).ToUniversalTime().AddMinutes(-30).ToString("o")
|
||||||
|
$end = (Get-Date).ToUniversalTime().AddMinutes(1).ToString("o")
|
||||||
|
$eventOut = & $opcUaCli.Cmd @($opcUaCli.Args + @(
|
||||||
|
"historyread", "-u", $OpcUaUrl, "-n", $EventsNotifierNodeId,
|
||||||
|
"--start", $start, "--end", $end))
|
||||||
|
if ($LASTEXITCODE -eq 0 -and $eventOut -notmatch "BadHistoryOperationUnsupported") {
|
||||||
|
$results += [pscustomobject]@{ Stage = "HistoryReadEvents"; Status = "PASS" }
|
||||||
|
} elseif ($eventOut -match "BadHistoryOperationUnsupported") {
|
||||||
|
Write-Host "[INFO] Upstream returned BadHistoryOperationUnsupported — re-run with --alm + a notifier that has event history."
|
||||||
|
$results += [pscustomobject]@{ Stage = "HistoryReadEvents"; Status = "SKIP" }
|
||||||
|
} else {
|
||||||
|
$results += [pscustomobject]@{ Stage = "HistoryReadEvents"; Status = "FAIL" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "=== test-opcuaclient.ps1 results ==="
|
||||||
|
$results | Format-Table -AutoSize
|
||||||
|
$failed = $results | Where-Object { $_.Status -eq "FAIL" }
|
||||||
|
if ($failed) {
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
exit 0
|
||||||
156
scripts/e2e/test-phase7-virtualtags.ps1
Normal file
156
scripts/e2e/test-phase7-virtualtags.ps1
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
#Requires -Version 7.0
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
End-to-end test for Phase 7 virtual tags + scripted alarms, driven via the
|
||||||
|
Modbus CLI.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Assumes the OtOpcUa server's config DB has this Phase 7 scaffolding:
|
||||||
|
|
||||||
|
1. A Modbus DriverInstance bound to -ModbusHost, with a Tag at HR[100]
|
||||||
|
as UInt16 published under -InputNodeId.
|
||||||
|
2. A VirtualTag `VT_DoubledHR100` = `double(input)` where input is
|
||||||
|
HR[100], published under -VirtualNodeId.
|
||||||
|
3. A ScriptedAlarm `Alarm_HighHR100` that fires when VT_DoubledHR100 > 100,
|
||||||
|
published so the client can subscribe to AlarmConditionType events.
|
||||||
|
|
||||||
|
Three assertions:
|
||||||
|
1. Virtual-tag bridge — modbus-cli writes HR[100]=21, OPC UA client reads
|
||||||
|
VirtualNodeId + expects 42.
|
||||||
|
2. Alarm fire — modbus-cli writes HR[100]=60 (VT=120, above threshold),
|
||||||
|
OPC UA client alarms subscribe sees the condition go Active.
|
||||||
|
3. Alarm clear — modbus-cli writes HR[100]=10 (VT=20, below threshold),
|
||||||
|
OPC UA client sees the condition go back to Inactive.
|
||||||
|
|
||||||
|
See scripts/smoke/seed-phase-7-smoke.sql for the seed shape. This script
|
||||||
|
doesn't seed; it verifies the running state.
|
||||||
|
|
||||||
|
.PARAMETER ModbusHost
|
||||||
|
Modbus simulator endpoint. Default 127.0.0.1:5502.
|
||||||
|
|
||||||
|
.PARAMETER OpcUaUrl
|
||||||
|
OtOpcUa server endpoint.
|
||||||
|
|
||||||
|
.PARAMETER InputNodeId
|
||||||
|
NodeId at which the server publishes HR[100] (the input tag).
|
||||||
|
|
||||||
|
.PARAMETER VirtualNodeId
|
||||||
|
NodeId at which the server publishes VT_DoubledHR100.
|
||||||
|
|
||||||
|
.PARAMETER AlarmNodeId
|
||||||
|
NodeId of the AlarmConditionType (or its source) the server publishes for
|
||||||
|
Alarm_HighHR100. Alarms subscribe filters by SourceNode = this NodeId.
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$ModbusHost = "127.0.0.1:5502",
|
||||||
|
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||||
|
[Parameter(Mandatory)] [string]$InputNodeId,
|
||||||
|
[Parameter(Mandatory)] [string]$VirtualNodeId,
|
||||||
|
[string]$AlarmNodeId
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
. "$PSScriptRoot/_common.ps1"
|
||||||
|
|
||||||
|
$hostPart, $portPart = $ModbusHost.Split(":")
|
||||||
|
$port = [int]$portPart
|
||||||
|
|
||||||
|
$modbusCli = Get-CliInvocation `
|
||||||
|
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli" `
|
||||||
|
-ExeName "otopcua-modbus-cli"
|
||||||
|
$opcUaCli = Get-CliInvocation `
|
||||||
|
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||||
|
-ExeName "otopcua-cli"
|
||||||
|
|
||||||
|
$commonModbus = @("-h", $hostPart, "-p", $port)
|
||||||
|
$results = @()
|
||||||
|
|
||||||
|
# --- Assertion 1: virtual-tag bridge ------------------------------------------
|
||||||
|
Write-Header "Virtual tag — VT_DoubledHR100 = HR[100] * 2"
|
||||||
|
$inputValue = 21
|
||||||
|
$expectedVirtual = $inputValue * 2
|
||||||
|
|
||||||
|
$w = Invoke-Cli -Cli $modbusCli -Args (@("write") + $commonModbus + `
|
||||||
|
@("-r", "HoldingRegisters", "-a", "100", "-t", "UInt16", "-v", $inputValue))
|
||||||
|
if ($w.ExitCode -ne 0) {
|
||||||
|
Write-Fail "modbus write failed (exit=$($w.ExitCode))"
|
||||||
|
$results += @{ Passed = $false; Reason = "seed write failed" }
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Info "wrote HR[100]=$inputValue, waiting 3s for virtual-tag engine to re-evaluate"
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
$r = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $VirtualNodeId)
|
||||||
|
if ($r.ExitCode -eq 0 -and $r.Output -match "Value:\s+$expectedVirtual\b") {
|
||||||
|
Write-Pass "virtual tag = $expectedVirtual (input * 2)"
|
||||||
|
$results += @{ Passed = $true }
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Fail "expected VT = $expectedVirtual; got:"
|
||||||
|
Write-Host $r.Output
|
||||||
|
$results += @{ Passed = $false; Reason = "virtual tag mismatch" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Assertion 2: scripted alarm fires ---------------------------------------
|
||||||
|
if ([string]::IsNullOrWhiteSpace($AlarmNodeId)) {
|
||||||
|
Write-Skip "AlarmNodeId not provided — skipping alarm fire/clear assertions"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Header "Scripted alarm — fires when VT > 100"
|
||||||
|
$fireValue = 60 # VT = 120, above threshold
|
||||||
|
$w = Invoke-Cli -Cli $modbusCli -Args (@("write") + $commonModbus + `
|
||||||
|
@("-r", "HoldingRegisters", "-a", "100", "-t", "UInt16", "-v", $fireValue))
|
||||||
|
if ($w.ExitCode -ne 0) {
|
||||||
|
Write-Fail "modbus write failed"
|
||||||
|
$results += @{ Passed = $false }
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Info "wrote HR[100]=$fireValue (VT=$($fireValue*2)); subscribing alarms for 5s"
|
||||||
|
# otopcua-cli's `alarms` command subscribes + prints events until an
|
||||||
|
# interrupt or timeout. We capture ~5s worth then parse for ActiveState.
|
||||||
|
$job = Start-Job -ScriptBlock {
|
||||||
|
param($file, $prefix, $url, $source)
|
||||||
|
$cmdArgs = $prefix + @("alarms", "-u", $url, "-n", $source, "--duration-seconds", "5")
|
||||||
|
& $file @cmdArgs 2>&1
|
||||||
|
} -ArgumentList $opcUaCli.File, $opcUaCli.PrefixArgs, $OpcUaUrl, $AlarmNodeId
|
||||||
|
|
||||||
|
$alarmOutput = Receive-Job -Job $job -Wait -AutoRemoveJob
|
||||||
|
$alarmText = ($alarmOutput | Out-String)
|
||||||
|
if ($alarmText -match "Active" -or $alarmText -match "HighAlarm" -or $alarmText -match "Severity") {
|
||||||
|
Write-Pass "alarm subscription received an event"
|
||||||
|
$results += @{ Passed = $true }
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Fail "expected alarm event in subscription output"
|
||||||
|
Write-Host $alarmText
|
||||||
|
$results += @{ Passed = $false; Reason = "alarm did not fire" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Assertion 3: alarm clears ---
|
||||||
|
Write-Header "Scripted alarm — clears when VT falls below threshold"
|
||||||
|
$clearValue = 10 # VT = 20, below threshold
|
||||||
|
$w = Invoke-Cli -Cli $modbusCli -Args (@("write") + $commonModbus + `
|
||||||
|
@("-r", "HoldingRegisters", "-a", "100", "-t", "UInt16", "-v", $clearValue))
|
||||||
|
if ($w.ExitCode -eq 0) {
|
||||||
|
Write-Info "wrote HR[100]=$clearValue (VT=$($clearValue*2)); alarm should clear"
|
||||||
|
# We don't re-subscribe here — the clear is asserted via the virtual
|
||||||
|
# tag's current value (the Phase 7 engine's commitment is that state
|
||||||
|
# propagates on the next tick; the OPC UA alarm transition follows).
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
$r = Invoke-Cli -Cli $opcUaCli -Args @("read", "-u", $OpcUaUrl, "-n", $VirtualNodeId)
|
||||||
|
if ($r.Output -match "Value:\s+$($clearValue*2)\b") {
|
||||||
|
Write-Pass "virtual tag returned to below-threshold ($($clearValue*2))"
|
||||||
|
$results += @{ Passed = $true }
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Fail "virtual tag did not reflect cleared state"
|
||||||
|
Write-Host $r.Output
|
||||||
|
$results += @{ Passed = $false; Reason = "clear state mismatch" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Summary -Title "Phase 7 virtual tags + scripted alarms" -Results $results
|
||||||
|
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||||
123
scripts/e2e/test-s7.ps1
Normal file
123
scripts/e2e/test-s7.ps1
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
#Requires -Version 7.0
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
End-to-end CLI test for the Siemens S7 driver bridged through the OtOpcUa server.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Five assertions (probe / driver-loopback / forward-bridge / reverse-bridge /
|
||||||
|
subscribe-sees-change) against a Siemens S7-300/400/1200/1500 or compatible
|
||||||
|
soft-PLC. python-snap7 simulator (task #216) or real hardware both work.
|
||||||
|
|
||||||
|
Prereqs:
|
||||||
|
- S7 simulator / PLC on $S7Host:$S7Port
|
||||||
|
- On real S7-1200/1500: PUT/GET communication enabled in TIA Portal.
|
||||||
|
- OtOpcUa server running with an S7 DriverInstance bound to the same
|
||||||
|
endpoint + a Tag at DB1.DBW0 Int16 published under -BridgeNodeId.
|
||||||
|
|
||||||
|
.PARAMETER S7Host
|
||||||
|
Host:port of the S7 simulator / PLC. Default 127.0.0.1:102.
|
||||||
|
|
||||||
|
.PARAMETER Cpu
|
||||||
|
S7200 / S7200Smart / S7300 / S7400 / S71200 / S71500 (default S71500).
|
||||||
|
|
||||||
|
.PARAMETER Slot
|
||||||
|
CPU slot. Default 0 (S7-1200/1500). S7-300 uses 2.
|
||||||
|
|
||||||
|
.PARAMETER Address
|
||||||
|
S7 address to exercise. Default DB1.DBW0.
|
||||||
|
|
||||||
|
.PARAMETER OpcUaUrl
|
||||||
|
OtOpcUa server endpoint.
|
||||||
|
|
||||||
|
.PARAMETER BridgeNodeId
|
||||||
|
NodeId at which the server publishes the Address.
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$S7Host = "127.0.0.1:102",
|
||||||
|
[string]$Cpu = "S71500",
|
||||||
|
[int]$Slot = 0,
|
||||||
|
[string]$Address = "DB1.DBW0",
|
||||||
|
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||||
|
[Parameter(Mandatory)] [string]$BridgeNodeId
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
. "$PSScriptRoot/_common.ps1"
|
||||||
|
|
||||||
|
$hostPart, $portPart = $S7Host.Split(":")
|
||||||
|
$port = [int]$portPart
|
||||||
|
|
||||||
|
$s7Cli = Get-CliInvocation `
|
||||||
|
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli" `
|
||||||
|
-ExeName "otopcua-s7-cli"
|
||||||
|
$opcUaCli = Get-CliInvocation `
|
||||||
|
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||||
|
-ExeName "otopcua-cli"
|
||||||
|
|
||||||
|
$commonS7 = @("-h", $hostPart, "-p", $port, "-c", $Cpu, "--slot", $Slot)
|
||||||
|
$results = @()
|
||||||
|
|
||||||
|
$results += Test-Probe `
|
||||||
|
-Cli $s7Cli `
|
||||||
|
-ProbeArgs (@("probe") + $commonS7)
|
||||||
|
|
||||||
|
$writeValue = Get-Random -Minimum 1 -Maximum 9999
|
||||||
|
$results += Test-DriverLoopback `
|
||||||
|
-Cli $s7Cli `
|
||||||
|
-WriteArgs (@("write") + $commonS7 + @("-a", $Address, "-t", "Int16", "-v", $writeValue)) `
|
||||||
|
-ReadArgs (@("read") + $commonS7 + @("-a", $Address, "-t", "Int16")) `
|
||||||
|
-ExpectedValue "$writeValue"
|
||||||
|
|
||||||
|
$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
|
||||||
|
$results += Test-ServerBridge `
|
||||||
|
-DriverCli $s7Cli `
|
||||||
|
-DriverWriteArgs (@("write") + $commonS7 + @("-a", $Address, "-t", "Int16", "-v", $bridgeValue)) `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-OpcUaNodeId $BridgeNodeId `
|
||||||
|
-ExpectedValue "$bridgeValue"
|
||||||
|
|
||||||
|
$reverseValue = Get-Random -Minimum 20000 -Maximum 29999
|
||||||
|
$results += Test-OpcUaWriteBridge `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-OpcUaNodeId $BridgeNodeId `
|
||||||
|
-DriverCli $s7Cli `
|
||||||
|
-DriverReadArgs (@("read") + $commonS7 + @("-a", $Address, "-t", "Int16")) `
|
||||||
|
-ExpectedValue "$reverseValue"
|
||||||
|
|
||||||
|
$subValue = Get-Random -Minimum 30000 -Maximum 32766
|
||||||
|
$results += Test-SubscribeSeesChange `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-OpcUaNodeId $BridgeNodeId `
|
||||||
|
-DriverCli $s7Cli `
|
||||||
|
-DriverWriteArgs (@("write") + $commonS7 + @("-a", $Address, "-t", "Int16", "-v", $subValue)) `
|
||||||
|
-ExpectedValue "$subValue"
|
||||||
|
|
||||||
|
# PR-S7-D2 / #300 — UDT-member round-trip. Exercises the byte offsets the
|
||||||
|
# driver's UDT fan-out uses when expanding a UDT-typed parent tag into per-
|
||||||
|
# member scalar leaves: Real at DB1.DBD400 and Int16 at DB1.DBW404 match the
|
||||||
|
# `MyUdt` layout seeded by Docker/profiles/s7_1500.json's udt_layout meta-seed
|
||||||
|
# and declared by S7_1500UdtFanOutTests. The CLI itself is UDT-unaware so the
|
||||||
|
# e2e step writes / reads at the explicit member byte offsets — proves the
|
||||||
|
# wire-level path the fan-out emits is sound end-to-end.
|
||||||
|
$udtPressureAddress = $Address.Substring(0, $Address.IndexOf('.')) + ".DBD400"
|
||||||
|
$udtPressureValue = "27.5"
|
||||||
|
$results += Test-DriverLoopback `
|
||||||
|
-Cli $s7Cli `
|
||||||
|
-WriteArgs (@("write") + $commonS7 + @("-a", $udtPressureAddress, "-t", "Float32", "-v", $udtPressureValue)) `
|
||||||
|
-ReadArgs (@("read") + $commonS7 + @("-a", $udtPressureAddress, "-t", "Float32")) `
|
||||||
|
-ExpectedValue $udtPressureValue
|
||||||
|
|
||||||
|
$udtStatusAddress = $Address.Substring(0, $Address.IndexOf('.')) + ".DBW404"
|
||||||
|
$udtStatusValue = Get-Random -Minimum 100 -Maximum 999
|
||||||
|
$results += Test-DriverLoopback `
|
||||||
|
-Cli $s7Cli `
|
||||||
|
-WriteArgs (@("write") + $commonS7 + @("-a", $udtStatusAddress, "-t", "Int16", "-v", $udtStatusValue)) `
|
||||||
|
-ReadArgs (@("read") + $commonS7 + @("-a", $udtStatusAddress, "-t", "Int16")) `
|
||||||
|
-ExpectedValue "$udtStatusValue"
|
||||||
|
|
||||||
|
Write-Summary -Title "S7 e2e" -Results $results
|
||||||
|
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||||
99
scripts/e2e/test-twincat.ps1
Normal file
99
scripts/e2e/test-twincat.ps1
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
#Requires -Version 7.0
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
End-to-end CLI test for the TwinCAT (Beckhoff ADS) driver.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Requires a reachable AMS router (local TwinCAT XAR, Beckhoff.TwinCAT.Ads.
|
||||||
|
TcpRouter NuGet, or an authorised remote AMS route) + a live TwinCAT
|
||||||
|
runtime on -AmsNetId. Without one the driver surfaces a transport error
|
||||||
|
on InitializeAsync + the script's probe fails.
|
||||||
|
|
||||||
|
Set TWINCAT_TRUST_WIRE=1 to promise the endpoint is live. Without it the
|
||||||
|
script skips (task #221 tracks the 7-day-trial CI fixture — until that
|
||||||
|
lands, TwinCAT testing is a manual operator task).
|
||||||
|
|
||||||
|
.PARAMETER AmsNetId
|
||||||
|
AMS Net ID of the target (e.g. 127.0.0.1.1.1 for local XAR,
|
||||||
|
192.168.1.40.1.1 for a remote PLC).
|
||||||
|
|
||||||
|
.PARAMETER AmsPort
|
||||||
|
AMS port. Default 851 (TC3 PLC runtime). TC2 uses 801.
|
||||||
|
|
||||||
|
.PARAMETER SymbolPath
|
||||||
|
TwinCAT symbol to exercise. Default 'MAIN.iCounter' — substitute with
|
||||||
|
whatever your project actually declares.
|
||||||
|
|
||||||
|
.PARAMETER OpcUaUrl
|
||||||
|
OtOpcUa server endpoint.
|
||||||
|
|
||||||
|
.PARAMETER BridgeNodeId
|
||||||
|
NodeId at which the server publishes the Symbol.
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$AmsNetId = "127.0.0.1.1.1",
|
||||||
|
[int]$AmsPort = 851,
|
||||||
|
[string]$SymbolPath = "MAIN.iCounter",
|
||||||
|
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||||
|
[Parameter(Mandatory)] [string]$BridgeNodeId
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
. "$PSScriptRoot/_common.ps1"
|
||||||
|
|
||||||
|
if (-not ($env:TWINCAT_TRUST_WIRE -eq "1" -or $env:TWINCAT_TRUST_WIRE -eq "true")) {
|
||||||
|
Write-Skip "TWINCAT_TRUST_WIRE not set — requires reachable AMS router + live TC runtime (task #221 tracks the CI fixture). Set =1 once the router is up."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$twinCatCli = Get-CliInvocation `
|
||||||
|
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli" `
|
||||||
|
-ExeName "otopcua-twincat-cli"
|
||||||
|
$opcUaCli = Get-CliInvocation `
|
||||||
|
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||||
|
-ExeName "otopcua-cli"
|
||||||
|
|
||||||
|
$commonTc = @("-n", $AmsNetId, "-p", $AmsPort)
|
||||||
|
$results = @()
|
||||||
|
|
||||||
|
$results += Test-Probe `
|
||||||
|
-Cli $twinCatCli `
|
||||||
|
-ProbeArgs (@("probe") + $commonTc + @("-s", $SymbolPath, "--type", "DInt"))
|
||||||
|
|
||||||
|
$writeValue = Get-Random -Minimum 1 -Maximum 9999
|
||||||
|
$results += Test-DriverLoopback `
|
||||||
|
-Cli $twinCatCli `
|
||||||
|
-WriteArgs (@("write") + $commonTc + @("-s", $SymbolPath, "-t", "DInt", "-v", $writeValue)) `
|
||||||
|
-ReadArgs (@("read") + $commonTc + @("-s", $SymbolPath, "-t", "DInt")) `
|
||||||
|
-ExpectedValue "$writeValue"
|
||||||
|
|
||||||
|
$bridgeValue = Get-Random -Minimum 10000 -Maximum 19999
|
||||||
|
$results += Test-ServerBridge `
|
||||||
|
-DriverCli $twinCatCli `
|
||||||
|
-DriverWriteArgs (@("write") + $commonTc + @("-s", $SymbolPath, "-t", "DInt", "-v", $bridgeValue)) `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-OpcUaNodeId $BridgeNodeId `
|
||||||
|
-ExpectedValue "$bridgeValue"
|
||||||
|
|
||||||
|
$reverseValue = Get-Random -Minimum 20000 -Maximum 29999
|
||||||
|
$results += Test-OpcUaWriteBridge `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-OpcUaNodeId $BridgeNodeId `
|
||||||
|
-DriverCli $twinCatCli `
|
||||||
|
-DriverReadArgs (@("read") + $commonTc + @("-s", $SymbolPath, "-t", "DInt")) `
|
||||||
|
-ExpectedValue "$reverseValue"
|
||||||
|
|
||||||
|
$subValue = Get-Random -Minimum 30000 -Maximum 39999
|
||||||
|
$results += Test-SubscribeSeesChange `
|
||||||
|
-OpcUaCli $opcUaCli `
|
||||||
|
-OpcUaUrl $OpcUaUrl `
|
||||||
|
-OpcUaNodeId $BridgeNodeId `
|
||||||
|
-DriverCli $twinCatCli `
|
||||||
|
-DriverWriteArgs (@("write") + $commonTc + @("-s", $SymbolPath, "-t", "DInt", "-v", $subValue)) `
|
||||||
|
-ExpectedValue "$subValue"
|
||||||
|
|
||||||
|
Write-Summary -Title "TwinCAT e2e" -Results $results
|
||||||
|
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||||
141
scripts/smoke/seed-abcip-smoke.sql
Normal file
141
scripts/smoke/seed-abcip-smoke.sql
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
-- AB CIP e2e smoke seed — closes #211 (umbrella #209).
|
||||||
|
--
|
||||||
|
-- One-cluster seed pointing at the ab_server ControlLogix fixture
|
||||||
|
-- (`docker compose -f tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml --profile controllogix up -d`).
|
||||||
|
-- Publishes a single `TestDINT:DInt` tag under NodeId `ns=<N>;s=TestDINT`
|
||||||
|
-- (ab_server seeds this tag by default).
|
||||||
|
--
|
||||||
|
-- Usage:
|
||||||
|
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
|
||||||
|
-- -i scripts/smoke/seed-abcip-smoke.sql
|
||||||
|
--
|
||||||
|
-- After seeding, point appsettings at this cluster:
|
||||||
|
-- Node:NodeId = "abcip-smoke-node"
|
||||||
|
-- Node:ClusterId = "abcip-smoke"
|
||||||
|
-- Then start server + run `./scripts/e2e/test-abcip.ps1 -BridgeNodeId "ns=2;s=TestDINT"`.
|
||||||
|
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET ANSI_PADDING ON;
|
||||||
|
SET ANSI_WARNINGS ON;
|
||||||
|
SET ARITHABORT ON;
|
||||||
|
SET CONCAT_NULL_YIELDS_NULL ON;
|
||||||
|
|
||||||
|
DECLARE @ClusterId nvarchar(64) = 'abcip-smoke';
|
||||||
|
DECLARE @NodeId nvarchar(64) = 'abcip-smoke-node';
|
||||||
|
DECLARE @DrvId nvarchar(64) = 'abcip-smoke-drv';
|
||||||
|
DECLARE @NsId nvarchar(64) = 'abcip-smoke-ns';
|
||||||
|
DECLARE @AreaId nvarchar(64) = 'abcip-smoke-area';
|
||||||
|
DECLARE @LineId nvarchar(64) = 'abcip-smoke-line';
|
||||||
|
DECLARE @EqId nvarchar(64) = 'abcip-smoke-eq';
|
||||||
|
DECLARE @EqUuid uniqueidentifier = '41BC12E0-41BC-412E-841B-C12E041BC12E';
|
||||||
|
DECLARE @TagId nvarchar(64) = 'abcip-smoke-tag-testdint';
|
||||||
|
|
||||||
|
BEGIN TRAN;
|
||||||
|
|
||||||
|
DELETE FROM dbo.Tag WHERE TagId IN (@TagId);
|
||||||
|
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
|
||||||
|
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
|
||||||
|
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
|
||||||
|
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
|
||||||
|
DELETE FROM dbo.Namespace WHERE NamespaceId = @NsId;
|
||||||
|
DELETE FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId;
|
||||||
|
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
|
||||||
|
DELETE FROM dbo.ClusterNodeGenerationState WHERE NodeId = @NodeId;
|
||||||
|
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
|
||||||
|
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
|
||||||
|
|
||||||
|
DELETE FROM dbo.ClusterNodeCredential WHERE Kind = 'SqlLogin' AND Value = 'sa';
|
||||||
|
|
||||||
|
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
|
||||||
|
VALUES (@ClusterId, 'AB CIP Smoke', 'zb', 'lab', 1, 'None', 1, 'abcip-smoke');
|
||||||
|
|
||||||
|
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
|
||||||
|
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||||
|
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 15050,
|
||||||
|
'urn:OtOpcUa:abcip-smoke-node', 200, 1, 'abcip-smoke');
|
||||||
|
|
||||||
|
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
|
||||||
|
VALUES (@NodeId, 'SqlLogin', 'sa', 1, 'abcip-smoke');
|
||||||
|
|
||||||
|
DECLARE @Gen bigint;
|
||||||
|
INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy)
|
||||||
|
VALUES (@ClusterId, 'Draft', 'abcip-smoke');
|
||||||
|
SET @Gen = SCOPE_IDENTITY();
|
||||||
|
|
||||||
|
INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
|
||||||
|
VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:abcip-smoke:eq', 1);
|
||||||
|
|
||||||
|
INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name)
|
||||||
|
VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor');
|
||||||
|
|
||||||
|
INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name)
|
||||||
|
VALUES (@Gen, @LineId, @AreaId, 'abcip-line');
|
||||||
|
|
||||||
|
INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId,
|
||||||
|
Name, MachineCode, Enabled)
|
||||||
|
VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'ab-sim', 'abcip-001', 1);
|
||||||
|
|
||||||
|
-- AB CIP DriverInstance — single ControlLogix device at the ab_server fixture
|
||||||
|
-- gateway. DriverConfig shape mirrors AbCipDriverConfigDto.
|
||||||
|
--
|
||||||
|
-- The second device entry (CompactLogix L2 example, commented out) demonstrates
|
||||||
|
-- the PR abcip-3.1 ConnectionSize override knob. Uncomment + point at a real
|
||||||
|
-- 5069-L2 to verify the narrow-buffer Forward Open path; ab_server itself
|
||||||
|
-- doesn't enforce the narrow cap (see docs/drivers/AbServer-Test-Fixture.md §5).
|
||||||
|
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
|
||||||
|
Name, DriverType, DriverConfig, Enabled)
|
||||||
|
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ab-server-smoke', 'AbCip', N'{
|
||||||
|
"TimeoutMs": 2000,
|
||||||
|
"Devices": [
|
||||||
|
{
|
||||||
|
"HostAddress": "ab://127.0.0.1:44818/1,0",
|
||||||
|
"PlcFamily": "ControlLogix",
|
||||||
|
"DeviceName": "ab-server"
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
, {
|
||||||
|
"HostAddress": "ab://10.0.0.7/1,0",
|
||||||
|
"PlcFamily": "CompactLogix",
|
||||||
|
"DeviceName": "compactlogix-l2-narrow",
|
||||||
|
"ConnectionSize": 504
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
],
|
||||||
|
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000 },
|
||||||
|
"Tags": [
|
||||||
|
{
|
||||||
|
"Name": "TestDINT",
|
||||||
|
"DeviceHostAddress": "ab://127.0.0.1:44818/1,0",
|
||||||
|
"TagPath": "TestDINT",
|
||||||
|
"DataType": "DInt",
|
||||||
|
"Writable": true,
|
||||||
|
"WriteIdempotent": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}', 1);
|
||||||
|
|
||||||
|
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
|
||||||
|
AccessLevel, TagConfig, WriteIdempotent)
|
||||||
|
VALUES (@Gen, @TagId, @DrvId, @EqId, 'TestDINT', 'Int32', 'ReadWrite',
|
||||||
|
N'{"FullName":"TestDINT","DataType":"DInt"}', 1);
|
||||||
|
|
||||||
|
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
|
||||||
|
@Notes = N'AB CIP smoke — task #211';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'AB CIP smoke seed complete.';
|
||||||
|
PRINT ' Cluster: ' + @ClusterId;
|
||||||
|
PRINT ' Node: ' + @NodeId;
|
||||||
|
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'Next steps:';
|
||||||
|
PRINT ' 1. Set src/.../Server/appsettings.json Node:NodeId = "abcip-smoke-node"';
|
||||||
|
PRINT ' Node:ClusterId = "abcip-smoke"';
|
||||||
|
PRINT ' 2. docker compose -f tests/.../AbCip.IntegrationTests/Docker/docker-compose.yml --profile controllogix up -d';
|
||||||
|
PRINT ' 3. dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server';
|
||||||
|
PRINT ' 4. ./scripts/e2e/test-abcip.ps1 -BridgeNodeId "ns=2;s=TestDINT"';
|
||||||
174
scripts/smoke/seed-ablegacy-smoke.sql
Normal file
174
scripts/smoke/seed-ablegacy-smoke.sql
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
-- AB Legacy e2e smoke seed — closes #213 (umbrella #209).
|
||||||
|
--
|
||||||
|
-- Works against the ab_server PCCC Docker fixture (one of the slc500 /
|
||||||
|
-- micrologix / plc5 compose profiles) or real SLC 500 / MicroLogix / PLC-5
|
||||||
|
-- hardware. Default HostAddress below points at the Docker fixture with a
|
||||||
|
-- `/1,0` cip-path; libplctag's ab_server rejects empty paths before routing
|
||||||
|
-- to the PCCC dispatcher. Real hardware uses an empty path — change the
|
||||||
|
-- HostAddress to `ab://<plc-ip>:44818/` (note the trailing slash with nothing
|
||||||
|
-- after) before running the seed for that setup.
|
||||||
|
--
|
||||||
|
-- Usage:
|
||||||
|
-- docker compose -f tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml --profile slc500 up -d
|
||||||
|
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
|
||||||
|
-- -i scripts/smoke/seed-ablegacy-smoke.sql
|
||||||
|
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET ANSI_PADDING ON;
|
||||||
|
SET ANSI_WARNINGS ON;
|
||||||
|
SET ARITHABORT ON;
|
||||||
|
SET CONCAT_NULL_YIELDS_NULL ON;
|
||||||
|
|
||||||
|
DECLARE @ClusterId nvarchar(64) = 'ablegacy-smoke';
|
||||||
|
DECLARE @NodeId nvarchar(64) = 'ablegacy-smoke-node';
|
||||||
|
DECLARE @DrvId nvarchar(64) = 'ablegacy-smoke-drv';
|
||||||
|
DECLARE @NsId nvarchar(64) = 'ablegacy-smoke-ns';
|
||||||
|
DECLARE @AreaId nvarchar(64) = 'ablegacy-smoke-area';
|
||||||
|
DECLARE @LineId nvarchar(64) = 'ablegacy-smoke-line';
|
||||||
|
DECLARE @EqId nvarchar(64) = 'ablegacy-smoke-eq';
|
||||||
|
DECLARE @EqUuid uniqueidentifier = '5A1D2030-5A1D-4203-A5A1-D20305A1D203';
|
||||||
|
DECLARE @TagId nvarchar(64) = 'ablegacy-smoke-tag-n7_5';
|
||||||
|
DECLARE @ArrTagId nvarchar(64) = 'ablegacy-smoke-tag-n7_block';
|
||||||
|
|
||||||
|
BEGIN TRAN;
|
||||||
|
|
||||||
|
DELETE FROM dbo.Tag WHERE TagId IN (@TagId, @ArrTagId);
|
||||||
|
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
|
||||||
|
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
|
||||||
|
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
|
||||||
|
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
|
||||||
|
DELETE FROM dbo.Namespace WHERE NamespaceId = @NsId;
|
||||||
|
DELETE FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId;
|
||||||
|
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
|
||||||
|
DELETE FROM dbo.ClusterNodeGenerationState WHERE NodeId = @NodeId;
|
||||||
|
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
|
||||||
|
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
|
||||||
|
|
||||||
|
DELETE FROM dbo.ClusterNodeCredential WHERE Kind = 'SqlLogin' AND Value = 'sa';
|
||||||
|
|
||||||
|
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
|
||||||
|
VALUES (@ClusterId, 'AB Legacy Smoke', 'zb', 'lab', 1, 'None', 1, 'ablegacy-smoke');
|
||||||
|
|
||||||
|
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
|
||||||
|
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||||
|
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 15050,
|
||||||
|
'urn:OtOpcUa:ablegacy-smoke-node', 200, 1, 'ablegacy-smoke');
|
||||||
|
|
||||||
|
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
|
||||||
|
VALUES (@NodeId, 'SqlLogin', 'sa', 1, 'ablegacy-smoke');
|
||||||
|
|
||||||
|
DECLARE @Gen bigint;
|
||||||
|
INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy)
|
||||||
|
VALUES (@ClusterId, 'Draft', 'ablegacy-smoke');
|
||||||
|
SET @Gen = SCOPE_IDENTITY();
|
||||||
|
|
||||||
|
INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
|
||||||
|
VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:ablegacy-smoke:eq', 1);
|
||||||
|
|
||||||
|
INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name)
|
||||||
|
VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor');
|
||||||
|
|
||||||
|
INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name)
|
||||||
|
VALUES (@Gen, @LineId, @AreaId, 'ablegacy-line');
|
||||||
|
|
||||||
|
INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId,
|
||||||
|
Name, MachineCode, Enabled)
|
||||||
|
VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'slc-sim', 'ablegacy-001', 1);
|
||||||
|
|
||||||
|
-- AB Legacy DriverInstance — SLC 500 target. Replace the placeholder gateway
|
||||||
|
-- `192.168.1.10` with the real PLC / RSEmulate host before running.
|
||||||
|
--
|
||||||
|
-- PR 9 / #252 demo: the device row carries `"TimeoutMs": 500` + `"Retries": 1`,
|
||||||
|
-- both overriding the driver-wide `TimeoutMs: 2000` / `Retries: 0` defaults.
|
||||||
|
-- For real chassis tune per family (SLC 5/01 ≈ 5000, SLC 5/05 ≈ 2000,
|
||||||
|
-- MicroLogix 1100 ≈ 3000); see docs/Driver.AbLegacy.Cli.md for the cheat sheet.
|
||||||
|
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
|
||||||
|
Name, DriverType, DriverConfig, Enabled)
|
||||||
|
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ablegacy-smoke', 'AbLegacy', N'{
|
||||||
|
"TimeoutMs": 2000,
|
||||||
|
"Retries": 0,
|
||||||
|
"Devices": [
|
||||||
|
{
|
||||||
|
"HostAddress": "ab://127.0.0.1:44818/1,0",
|
||||||
|
"PlcFamily": "Slc500",
|
||||||
|
"DeviceName": "slc-500",
|
||||||
|
"TimeoutMs": 500,
|
||||||
|
"Retries": 1,
|
||||||
|
"Demote": {
|
||||||
|
"FailureThreshold": 3,
|
||||||
|
"DemoteForMs": 30000,
|
||||||
|
"Enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": "S:0" },
|
||||||
|
"Tags": [
|
||||||
|
{
|
||||||
|
"Name": "N7_5",
|
||||||
|
"DeviceHostAddress": "ab://127.0.0.1:44818/1,0",
|
||||||
|
"Address": "N7:5",
|
||||||
|
"DataType": "Int",
|
||||||
|
"Writable": true,
|
||||||
|
"WriteIdempotent": true,
|
||||||
|
"AbsoluteDeadband": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "N7_Block",
|
||||||
|
"DeviceHostAddress": "ab://127.0.0.1:44818/1,0",
|
||||||
|
"Address": "N7:0,10",
|
||||||
|
"DataType": "Int",
|
||||||
|
"Writable": false,
|
||||||
|
"ArrayLength": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}', 1);
|
||||||
|
|
||||||
|
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
|
||||||
|
AccessLevel, TagConfig, WriteIdempotent)
|
||||||
|
VALUES (@Gen, @TagId, @DrvId, @EqId, 'N7_5', 'Int16', 'ReadWrite',
|
||||||
|
N'{"FullName":"N7_5","Address":"N7:5","DataType":"Int"}', 1);
|
||||||
|
|
||||||
|
-- PR 7 — array contiguous-block tag. The TagConfig JSON carries the address suffix
|
||||||
|
-- + ArrayLength override; the driver picks both up at discovery time and emits the
|
||||||
|
-- DriverAttributeInfo with IsArray=true + ArrayDim=10 so the generic node manager
|
||||||
|
-- materialises a 1-D Int16 array variable. The dbo.Tag schema doesn't carry
|
||||||
|
-- IsArray/ArrayDim columns — the array shape is fully driver-side metadata.
|
||||||
|
-- Read-only because the smoke harness only exercises array reads.
|
||||||
|
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
|
||||||
|
AccessLevel, TagConfig, WriteIdempotent)
|
||||||
|
VALUES (@Gen, @ArrTagId, @DrvId, @EqId, 'N7_Block', 'Int16', 'Read',
|
||||||
|
N'{"FullName":"N7_Block","Address":"N7:0,10","DataType":"Int","ArrayLength":10}', 0);
|
||||||
|
|
||||||
|
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
|
||||||
|
@Notes = N'AB Legacy smoke — task #213';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'AB Legacy smoke seed complete.';
|
||||||
|
PRINT ' Cluster: ' + @ClusterId;
|
||||||
|
PRINT ' Node: ' + @NodeId;
|
||||||
|
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'NOTE: default points at the ab_server slc500 Docker fixture with a /1,0';
|
||||||
|
PRINT ' cip-path (required by ab_server). For real SLC/MicroLogix/PLC-5';
|
||||||
|
PRINT ' hardware, edit the DriverConfig HostAddress to end with /<empty>';
|
||||||
|
PRINT ' e.g. "ab://<plc-ip>:44818/" and re-run this seed.';
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'PR ablegacy-10 / #253 — diagnostic counters auto-emit per device under';
|
||||||
|
PRINT ' AbLegacy/<host>/_Diagnostics/<name>. No dbo.Tag rows needed — the';
|
||||||
|
PRINT ' driver registers them at DiscoverAsync time. Nine counters per device:';
|
||||||
|
PRINT ' RequestCount, ResponseCount, ErrorCount, RetryCount, LastErrorCode,';
|
||||||
|
PRINT ' LastErrorMessage, CommFailures, DemoteCount, LastDemotedUtc. See';
|
||||||
|
PRINT ' docs/drivers/AbLegacy-Diagnostics.md for the full surface + reset';
|
||||||
|
PRINT ' semantics.';
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'PR ablegacy-12 / #255 — auto-demote on comm failure: 3 consecutive';
|
||||||
|
PRINT ' failed reads / probes mark the device Demoted for DemoteFor=PT30S';
|
||||||
|
PRINT ' (30 s); reads against a demoted device short-circuit with';
|
||||||
|
PRINT ' BadCommunicationError so one slow PLC can''t starve the driver.';
|
||||||
|
PRINT ' Tune via the Demote block on each Devices[] row. DemoteCount +';
|
||||||
|
PRINT ' LastDemotedUtc on the _Diagnostics folder surface flapping links.';
|
||||||
156
scripts/smoke/seed-modbus-smoke.sql
Normal file
156
scripts/smoke/seed-modbus-smoke.sql
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
-- Modbus e2e smoke seed — closes #210 (umbrella #209).
|
||||||
|
--
|
||||||
|
-- Idempotent — DROP-and-recreate of one cluster's worth of Modbus test config:
|
||||||
|
-- * 1 ServerCluster ('modbus-smoke') + ClusterNode ('modbus-smoke-node')
|
||||||
|
-- * 1 ConfigGeneration (Draft → Published at the end)
|
||||||
|
-- * 1 Namespace + UnsArea + UnsLine + Equipment
|
||||||
|
-- * 1 Modbus DriverInstance pointing at the pymodbus standard fixture
|
||||||
|
-- (127.0.0.1:5020 per tests/.../Modbus.IntegrationTests/Docker)
|
||||||
|
-- * 1 Tag at HR[200]:UInt16 (HR[100] is auto-increment in standard.json,
|
||||||
|
-- unusable as a write target — the e2e script uses HR[200] for that reason)
|
||||||
|
--
|
||||||
|
-- Usage:
|
||||||
|
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
|
||||||
|
-- -i scripts/smoke/seed-modbus-smoke.sql
|
||||||
|
--
|
||||||
|
-- After seeding, update src/.../Server/appsettings.json:
|
||||||
|
-- Node:NodeId = "modbus-smoke-node"
|
||||||
|
-- Node:ClusterId = "modbus-smoke"
|
||||||
|
--
|
||||||
|
-- Then start the simulator + server + run the e2e script:
|
||||||
|
-- docker compose -f tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml --profile standard up -d
|
||||||
|
-- dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server
|
||||||
|
-- ./scripts/e2e/test-modbus.ps1 -BridgeNodeId "ns=2;s=HR200"
|
||||||
|
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET ANSI_PADDING ON;
|
||||||
|
SET ANSI_WARNINGS ON;
|
||||||
|
SET ARITHABORT ON;
|
||||||
|
SET CONCAT_NULL_YIELDS_NULL ON;
|
||||||
|
|
||||||
|
DECLARE @ClusterId nvarchar(64) = 'modbus-smoke';
|
||||||
|
DECLARE @NodeId nvarchar(64) = 'modbus-smoke-node';
|
||||||
|
DECLARE @DrvId nvarchar(64) = 'modbus-smoke-drv';
|
||||||
|
DECLARE @NsId nvarchar(64) = 'modbus-smoke-ns';
|
||||||
|
DECLARE @AreaId nvarchar(64) = 'modbus-smoke-area';
|
||||||
|
DECLARE @LineId nvarchar(64) = 'modbus-smoke-line';
|
||||||
|
DECLARE @EqId nvarchar(64) = 'modbus-smoke-eq';
|
||||||
|
DECLARE @EqUuid uniqueidentifier = '72BD5A10-72BD-45A1-B72B-D5A1072BD5A1';
|
||||||
|
DECLARE @TagHr200 nvarchar(64) = 'modbus-smoke-tag-hr200';
|
||||||
|
|
||||||
|
BEGIN TRAN;
|
||||||
|
|
||||||
|
-- Clean prior smoke state (child rows first).
|
||||||
|
DELETE FROM dbo.Tag WHERE TagId IN (@TagHr200);
|
||||||
|
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
|
||||||
|
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
|
||||||
|
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
|
||||||
|
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
|
||||||
|
DELETE FROM dbo.Namespace WHERE NamespaceId = @NsId;
|
||||||
|
DELETE FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId;
|
||||||
|
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
|
||||||
|
DELETE FROM dbo.ClusterNodeGenerationState WHERE NodeId = @NodeId;
|
||||||
|
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
|
||||||
|
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
|
||||||
|
|
||||||
|
-- `UX_ClusterNodeCredential_Value` is a unique index on (Kind, Value) WHERE
|
||||||
|
-- Enabled=1, so a `sa` login can only bind to one node at a time. Drop any
|
||||||
|
-- prior smoke cluster's binding before we claim the login for this one.
|
||||||
|
DELETE FROM dbo.ClusterNodeCredential WHERE Kind = 'SqlLogin' AND Value = 'sa';
|
||||||
|
|
||||||
|
-- 1. Cluster + Node.
|
||||||
|
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
|
||||||
|
VALUES (@ClusterId, 'Modbus Smoke', 'zb', 'lab', 1, 'None', 1, 'modbus-smoke');
|
||||||
|
|
||||||
|
-- DashboardPort 15050 rather than 5000 — HttpListener on :5000 requires
|
||||||
|
-- URL-ACL reservation or admin rights on Windows (HttpListenerException 32).
|
||||||
|
-- 15000+ ports are unreserved by default. Safe to change back when deploying
|
||||||
|
-- with a netsh urlacl grant or reverse-proxy fronting :5000.
|
||||||
|
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
|
||||||
|
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||||
|
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 15050,
|
||||||
|
'urn:OtOpcUa:modbus-smoke-node', 200, 1, 'modbus-smoke');
|
||||||
|
|
||||||
|
-- Bind the SQL login this smoke test connects as to the node identity. The
|
||||||
|
-- sp_GetCurrentGenerationForCluster + sp_UpdateClusterNodeGenerationState
|
||||||
|
-- sprocs raise RAISERROR('Unauthorized: caller %s is not bound to NodeId %s')
|
||||||
|
-- when this row is missing. `Kind='SqlLogin'` / `Value='sa'` matches the
|
||||||
|
-- container's SA user; rotate Value for real deployments using a non-SA login.
|
||||||
|
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
|
||||||
|
VALUES (@NodeId, 'SqlLogin', 'sa', 1, 'modbus-smoke');
|
||||||
|
|
||||||
|
-- 2. Draft generation.
|
||||||
|
DECLARE @Gen bigint;
|
||||||
|
INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy)
|
||||||
|
VALUES (@ClusterId, 'Draft', 'modbus-smoke');
|
||||||
|
SET @Gen = SCOPE_IDENTITY();
|
||||||
|
|
||||||
|
-- 3. Namespace.
|
||||||
|
INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
|
||||||
|
VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:modbus-smoke:eq', 1);
|
||||||
|
|
||||||
|
-- 4. UNS hierarchy.
|
||||||
|
INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name)
|
||||||
|
VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor');
|
||||||
|
|
||||||
|
INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name)
|
||||||
|
VALUES (@Gen, @LineId, @AreaId, 'modbus-line');
|
||||||
|
|
||||||
|
INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId,
|
||||||
|
Name, MachineCode, Enabled)
|
||||||
|
VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'modbus-sim', 'modbus-001', 1);
|
||||||
|
|
||||||
|
-- 5. Modbus DriverInstance. DriverConfig mirrors ModbusDriverConfigDto
|
||||||
|
-- (mapped to ModbusDriverOptions by ModbusDriverFactoryExtensions).
|
||||||
|
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
|
||||||
|
Name, DriverType, DriverConfig, Enabled)
|
||||||
|
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'pymodbus-smoke', 'Modbus', N'{
|
||||||
|
"Host": "127.0.0.1",
|
||||||
|
"Port": 5020,
|
||||||
|
"UnitId": 1,
|
||||||
|
"TimeoutMs": 2000,
|
||||||
|
"AutoReconnect": true,
|
||||||
|
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": 0 },
|
||||||
|
"Tags": [
|
||||||
|
{
|
||||||
|
"Name": "HR200",
|
||||||
|
"Region": "HoldingRegisters",
|
||||||
|
"Address": 200,
|
||||||
|
"DataType": "UInt16",
|
||||||
|
"Writable": true,
|
||||||
|
"WriteIdempotent": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}', 1);
|
||||||
|
|
||||||
|
-- 6. Tag row bound to the Equipment. Driver reports the same tag via
|
||||||
|
-- DiscoverAsync + the walker maps the UnsArea/Line/Equipment/Tag path to the
|
||||||
|
-- driver's folder/variable (NodeId ends up ns=<driver-ns>;s=HR200 per
|
||||||
|
-- ModbusDriver.DiscoverAsync using FullName = tag.Name).
|
||||||
|
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
|
||||||
|
AccessLevel, TagConfig, WriteIdempotent)
|
||||||
|
VALUES (@Gen, @TagHr200, @DrvId, @EqId, 'HR200', 'UInt16', 'ReadWrite',
|
||||||
|
N'{"FullName":"HR200","DataType":"UInt16"}', 1);
|
||||||
|
|
||||||
|
-- 7. Publish the generation — flips Status Draft → Published, merges
|
||||||
|
-- ExternalIdReservation, claims cluster write lock.
|
||||||
|
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
|
||||||
|
@Notes = N'Modbus smoke — task #210';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'Modbus smoke seed complete.';
|
||||||
|
PRINT ' Cluster: ' + @ClusterId;
|
||||||
|
PRINT ' Node: ' + @NodeId;
|
||||||
|
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'Next steps:';
|
||||||
|
PRINT ' 1. Set src/.../Server/appsettings.json Node:NodeId = "modbus-smoke-node"';
|
||||||
|
PRINT ' Node:ClusterId = "modbus-smoke"';
|
||||||
|
PRINT ' 2. docker compose -f tests/.../Modbus.IntegrationTests/Docker/docker-compose.yml --profile standard up -d';
|
||||||
|
PRINT ' 3. dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server';
|
||||||
|
PRINT ' 4. ./scripts/e2e/test-modbus.ps1 -BridgeNodeId "ns=2;s=HR200"';
|
||||||
166
scripts/smoke/seed-phase-7-smoke.sql
Normal file
166
scripts/smoke/seed-phase-7-smoke.sql
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
-- Phase 7 live OPC UA E2E smoke seed (task #240).
|
||||||
|
--
|
||||||
|
-- Idempotent — DROP-and-recreate of one cluster's worth of test config:
|
||||||
|
-- * 1 ServerCluster ('p7-smoke')
|
||||||
|
-- * 1 ClusterNode ('p7-smoke-node')
|
||||||
|
-- * 1 ConfigGeneration (created Draft, then flipped to Published at the end)
|
||||||
|
-- * 1 Namespace (Equipment kind)
|
||||||
|
-- * 1 UnsArea / UnsLine / Equipment / Tag — Tag bound to a real Galaxy attribute
|
||||||
|
-- * 1 DriverInstance (Galaxy)
|
||||||
|
-- * 1 Script + 1 VirtualTag using it
|
||||||
|
-- * 1 Script + 1 ScriptedAlarm using it
|
||||||
|
--
|
||||||
|
-- Drop & re-create deletes ALL rows scoped to the cluster (in dependency order)
|
||||||
|
-- so re-running this script after a code change starts from a clean state.
|
||||||
|
-- Table-level CHECK constraints are validated on insert; if a constraint is
|
||||||
|
-- violated this script aborts with the offending row's column.
|
||||||
|
--
|
||||||
|
-- Usage:
|
||||||
|
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
|
||||||
|
-- -i scripts/smoke/seed-phase-7-smoke.sql
|
||||||
|
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET ANSI_PADDING ON;
|
||||||
|
SET ANSI_WARNINGS ON;
|
||||||
|
SET ARITHABORT ON;
|
||||||
|
SET CONCAT_NULL_YIELDS_NULL ON;
|
||||||
|
|
||||||
|
DECLARE @ClusterId nvarchar(64) = 'p7-smoke';
|
||||||
|
DECLARE @NodeId nvarchar(64) = 'p7-smoke-node';
|
||||||
|
DECLARE @DrvId nvarchar(64) = 'p7-smoke-galaxy';
|
||||||
|
DECLARE @NsId nvarchar(64) = 'p7-smoke-ns';
|
||||||
|
DECLARE @AreaId nvarchar(64) = 'p7-smoke-area';
|
||||||
|
DECLARE @LineId nvarchar(64) = 'p7-smoke-line';
|
||||||
|
DECLARE @EqId nvarchar(64) = 'p7-smoke-eq';
|
||||||
|
DECLARE @EqUuid uniqueidentifier = '5B2CF10D-5B2C-4F10-B5B2-CF10D5B2CF10';
|
||||||
|
DECLARE @TagId nvarchar(64) = 'p7-smoke-tag-source';
|
||||||
|
DECLARE @VtScript nvarchar(64) = 'p7-smoke-script-vt';
|
||||||
|
DECLARE @AlScript nvarchar(64) = 'p7-smoke-script-al';
|
||||||
|
DECLARE @VtId nvarchar(64) = 'p7-smoke-vt-derived';
|
||||||
|
DECLARE @AlId nvarchar(64) = 'p7-smoke-al-overtemp';
|
||||||
|
|
||||||
|
BEGIN TRAN;
|
||||||
|
|
||||||
|
-- Wipe any prior smoke state. Order matters: child rows first.
|
||||||
|
DELETE s FROM dbo.ScriptedAlarmState s
|
||||||
|
WHERE s.ScriptedAlarmId = @AlId;
|
||||||
|
DELETE FROM dbo.ScriptedAlarm WHERE ScriptedAlarmId = @AlId;
|
||||||
|
DELETE FROM dbo.VirtualTag WHERE VirtualTagId = @VtId;
|
||||||
|
DELETE FROM dbo.Script WHERE ScriptId IN (@VtScript, @AlScript);
|
||||||
|
DELETE FROM dbo.Tag WHERE TagId = @TagId;
|
||||||
|
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
|
||||||
|
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
|
||||||
|
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
|
||||||
|
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
|
||||||
|
DELETE FROM dbo.Namespace WHERE NamespaceId = @NsId;
|
||||||
|
DELETE FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId;
|
||||||
|
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
|
||||||
|
DELETE FROM dbo.ClusterNodeGenerationState WHERE NodeId = @NodeId;
|
||||||
|
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
|
||||||
|
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
|
||||||
|
|
||||||
|
-- 1. Cluster + Node
|
||||||
|
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
|
||||||
|
VALUES (@ClusterId, 'P7 Smoke', 'zb', 'lab', 1, 'None', 1, 'p7-smoke');
|
||||||
|
|
||||||
|
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
|
||||||
|
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||||
|
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 5000,
|
||||||
|
'urn:OtOpcUa:p7-smoke-node', 200, 1, 'p7-smoke');
|
||||||
|
|
||||||
|
-- 2. Generation (created Draft, flipped to Published at the end so insert order
|
||||||
|
-- constraints (one Draft per cluster, etc.) don't fight us).
|
||||||
|
DECLARE @Gen bigint;
|
||||||
|
INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy)
|
||||||
|
VALUES (@ClusterId, 'Draft', 'p7-smoke');
|
||||||
|
SET @Gen = SCOPE_IDENTITY();
|
||||||
|
|
||||||
|
-- 3. Namespace
|
||||||
|
INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
|
||||||
|
VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:p7-smoke:eq', 1);
|
||||||
|
|
||||||
|
-- 4. UNS hierarchy
|
||||||
|
INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name)
|
||||||
|
VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor');
|
||||||
|
|
||||||
|
INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name)
|
||||||
|
VALUES (@Gen, @LineId, @AreaId, 'galaxy-line');
|
||||||
|
|
||||||
|
INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId,
|
||||||
|
Name, MachineCode, Enabled)
|
||||||
|
VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'reactor-1', 'p7-rx-001', 1);
|
||||||
|
|
||||||
|
-- 5. Driver — Galaxy proxy. DriverConfig JSON tells the proxy how to reach the
|
||||||
|
-- already-running OtOpcUaGalaxyHost. Secret + pipe name match
|
||||||
|
-- .local/galaxy-host-secret.txt + the OtOpcUaGalaxyHost service env.
|
||||||
|
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
|
||||||
|
Name, DriverType, DriverConfig, Enabled)
|
||||||
|
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'galaxy-smoke', 'Galaxy', N'{
|
||||||
|
"DriverInstanceId": "p7-smoke-galaxy",
|
||||||
|
"PipeName": "OtOpcUaGalaxy",
|
||||||
|
"SharedSecret": "4hgDJ4jLcKXmOmD1Ara8xtE8N3R47Q2y1Xf/Eama/Fk=",
|
||||||
|
"ConnectTimeoutMs": 10000
|
||||||
|
}', 1);
|
||||||
|
|
||||||
|
-- 6. One driver-sourced Tag bound to the Equipment. TagConfig is the Galaxy
|
||||||
|
-- fullRef ("DelmiaReceiver_001.DownloadPath" style); replace with a real
|
||||||
|
-- attribute on this Galaxy. The script paths below use
|
||||||
|
-- /lab-floor/galaxy-line/reactor-1/Source which the EquipmentNodeWalker
|
||||||
|
-- emits + the DriverSubscriptionBridge maps to this driver fullRef.
|
||||||
|
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
|
||||||
|
AccessLevel, TagConfig, WriteIdempotent)
|
||||||
|
VALUES (@Gen, @TagId, @DrvId, @EqId, 'Source', 'Float64', 'Read',
|
||||||
|
N'{"FullName":"REPLACE_WITH_REAL_GALAXY_ATTRIBUTE","DataType":"Float64"}', 0);
|
||||||
|
|
||||||
|
-- 7. Scripts (SourceHash is SHA-256 of SourceCode, computed externally — using
|
||||||
|
-- a placeholder here; the engine recomputes on first use anyway).
|
||||||
|
INSERT dbo.Script(GenerationId, ScriptId, Name, SourceCode, SourceHash, Language)
|
||||||
|
VALUES
|
||||||
|
(@Gen, @VtScript, 'doubled-source',
|
||||||
|
N'return ((double)ctx.GetTag("/lab-floor/galaxy-line/reactor-1/Source").Value) * 2.0;',
|
||||||
|
'0000000000000000000000000000000000000000000000000000000000000000', 'CSharp'),
|
||||||
|
(@Gen, @AlScript, 'overtemp-predicate',
|
||||||
|
N'return ((double)ctx.GetTag("/lab-floor/galaxy-line/reactor-1/Source").Value) > 50.0;',
|
||||||
|
'0000000000000000000000000000000000000000000000000000000000000000', 'CSharp');
|
||||||
|
|
||||||
|
-- 8. VirtualTag — derived value computed by Roslyn each time Source changes.
|
||||||
|
INSERT dbo.VirtualTag(GenerationId, VirtualTagId, EquipmentId, Name, DataType,
|
||||||
|
ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled)
|
||||||
|
VALUES (@Gen, @VtId, @EqId, 'Doubled', 'Float64', @VtScript, 1, NULL, 0, 1);
|
||||||
|
|
||||||
|
-- 9. ScriptedAlarm — Active when Source > 50.
|
||||||
|
INSERT dbo.ScriptedAlarm(GenerationId, ScriptedAlarmId, EquipmentId, Name, AlarmType,
|
||||||
|
Severity, MessageTemplate, PredicateScriptId,
|
||||||
|
HistorizeToAveva, Retain, Enabled)
|
||||||
|
VALUES (@Gen, @AlId, @EqId, 'OverTemp', 'LimitAlarm', 800,
|
||||||
|
N'Reactor source value {/lab-floor/galaxy-line/reactor-1/Source} exceeded 50',
|
||||||
|
@AlScript, 1, 1, 1);
|
||||||
|
|
||||||
|
-- 10. Publish — flip the generation Status. sp_PublishGeneration takes
|
||||||
|
-- concurrency locks + does ExternalIdReservation merging; we drive it via
|
||||||
|
-- EXEC rather than UPDATE so the rest of the publish workflow runs.
|
||||||
|
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
|
||||||
|
@Notes = N'Phase 7 live smoke — task #240';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'Phase 7 smoke seed complete.';
|
||||||
|
PRINT ' Cluster: ' + @ClusterId;
|
||||||
|
PRINT ' Node: ' + @NodeId + ' (set Node:NodeId in appsettings.json)';
|
||||||
|
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'Next steps:';
|
||||||
|
PRINT ' 1. Edit src/ZB.MOM.WW.OtOpcUa.Server/appsettings.json:';
|
||||||
|
PRINT ' Node:NodeId = "p7-smoke-node"';
|
||||||
|
PRINT ' Node:ClusterId = "p7-smoke"';
|
||||||
|
PRINT ' 2. Edit the placeholder Galaxy attribute in dbo.Tag.TagConfig above';
|
||||||
|
PRINT ' so it points at a real attribute on this Galaxy — replace';
|
||||||
|
PRINT ' REPLACE_WITH_REAL_GALAXY_ATTRIBUTE with e.g. "Plant1.Reactor1.Temp".';
|
||||||
|
PRINT ' 3. Start the Server in a non-elevated shell so the Galaxy.Host pipe ACL';
|
||||||
|
PRINT ' accepts the connection:';
|
||||||
|
PRINT ' dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server';
|
||||||
|
PRINT ' 4. Validate via Client.CLI per docs/v2/implementation/phase-7-e2e-smoke.md';
|
||||||
127
scripts/smoke/seed-s7-smoke.sql
Normal file
127
scripts/smoke/seed-s7-smoke.sql
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
-- S7 e2e smoke seed — closes #212 (umbrella #209).
|
||||||
|
--
|
||||||
|
-- One-cluster seed pointing at the python-snap7 fixture
|
||||||
|
-- (`docker compose -f tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/docker-compose.yml --profile s7_1500 up -d`).
|
||||||
|
-- python-snap7 listens on port 1102 (non-priv); real S7 CPUs listen on 102.
|
||||||
|
-- Publishes one Int16 tag at DB1.DBW0 under `ns=<N>;s=DB1_DBW0` (driver
|
||||||
|
-- sanitises the dot for browse names — see S7Driver.DiscoverAsync).
|
||||||
|
--
|
||||||
|
-- Usage:
|
||||||
|
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
|
||||||
|
-- -i scripts/smoke/seed-s7-smoke.sql
|
||||||
|
--
|
||||||
|
-- After seeding:
|
||||||
|
-- Node:NodeId = "s7-smoke-node"
|
||||||
|
-- Node:ClusterId = "s7-smoke"
|
||||||
|
-- Then start server + run `./scripts/e2e/test-s7.ps1 -BridgeNodeId "ns=2;s=DB1_DBW0" -S7Host "127.0.0.1:1102"`.
|
||||||
|
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET ANSI_PADDING ON;
|
||||||
|
SET ANSI_WARNINGS ON;
|
||||||
|
SET ARITHABORT ON;
|
||||||
|
SET CONCAT_NULL_YIELDS_NULL ON;
|
||||||
|
|
||||||
|
DECLARE @ClusterId nvarchar(64) = 's7-smoke';
|
||||||
|
DECLARE @NodeId nvarchar(64) = 's7-smoke-node';
|
||||||
|
DECLARE @DrvId nvarchar(64) = 's7-smoke-drv';
|
||||||
|
DECLARE @NsId nvarchar(64) = 's7-smoke-ns';
|
||||||
|
DECLARE @AreaId nvarchar(64) = 's7-smoke-area';
|
||||||
|
DECLARE @LineId nvarchar(64) = 's7-smoke-line';
|
||||||
|
DECLARE @EqId nvarchar(64) = 's7-smoke-eq';
|
||||||
|
DECLARE @EqUuid uniqueidentifier = '17BD5A10-17BD-417B-917B-D5A1017BD5A1';
|
||||||
|
DECLARE @TagId nvarchar(64) = 's7-smoke-tag-db1dbw0';
|
||||||
|
|
||||||
|
BEGIN TRAN;
|
||||||
|
|
||||||
|
DELETE FROM dbo.Tag WHERE TagId IN (@TagId);
|
||||||
|
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
|
||||||
|
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
|
||||||
|
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
|
||||||
|
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
|
||||||
|
DELETE FROM dbo.Namespace WHERE NamespaceId = @NsId;
|
||||||
|
DELETE FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId;
|
||||||
|
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
|
||||||
|
DELETE FROM dbo.ClusterNodeGenerationState WHERE NodeId = @NodeId;
|
||||||
|
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
|
||||||
|
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
|
||||||
|
|
||||||
|
DELETE FROM dbo.ClusterNodeCredential WHERE Kind = 'SqlLogin' AND Value = 'sa';
|
||||||
|
|
||||||
|
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
|
||||||
|
VALUES (@ClusterId, 'S7 Smoke', 'zb', 'lab', 1, 'None', 1, 's7-smoke');
|
||||||
|
|
||||||
|
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
|
||||||
|
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
|
||||||
|
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 15050,
|
||||||
|
'urn:OtOpcUa:s7-smoke-node', 200, 1, 's7-smoke');
|
||||||
|
-- Dashboard moved off :5000 (Windows URL-ACL).
|
||||||
|
|
||||||
|
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
|
||||||
|
VALUES (@NodeId, 'SqlLogin', 'sa', 1, 's7-smoke');
|
||||||
|
|
||||||
|
DECLARE @Gen bigint;
|
||||||
|
INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy)
|
||||||
|
VALUES (@ClusterId, 'Draft', 's7-smoke');
|
||||||
|
SET @Gen = SCOPE_IDENTITY();
|
||||||
|
|
||||||
|
INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
|
||||||
|
VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:s7-smoke:eq', 1);
|
||||||
|
|
||||||
|
INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name)
|
||||||
|
VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor');
|
||||||
|
|
||||||
|
INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name)
|
||||||
|
VALUES (@Gen, @LineId, @AreaId, 's7-line');
|
||||||
|
|
||||||
|
INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId,
|
||||||
|
Name, MachineCode, Enabled)
|
||||||
|
VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 's7-sim', 's7-001', 1);
|
||||||
|
|
||||||
|
-- S7 DriverInstance — python-snap7 S7-1500 profile, slot 0, port 1102.
|
||||||
|
-- DriverConfig shape mirrors S7DriverConfigDto.
|
||||||
|
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
|
||||||
|
Name, DriverType, DriverConfig, Enabled)
|
||||||
|
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'snap7-smoke', 'S7', N'{
|
||||||
|
"Host": "127.0.0.1",
|
||||||
|
"Port": 1102,
|
||||||
|
"CpuType": "S71500",
|
||||||
|
"Rack": 0,
|
||||||
|
"Slot": 0,
|
||||||
|
"TimeoutMs": 5000,
|
||||||
|
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": "MW0" },
|
||||||
|
"Tags": [
|
||||||
|
{
|
||||||
|
"Name": "DB1_DBW0",
|
||||||
|
"Address": "DB1.DBW0",
|
||||||
|
"DataType": "Int16",
|
||||||
|
"Writable": true,
|
||||||
|
"WriteIdempotent": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}', 1);
|
||||||
|
|
||||||
|
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
|
||||||
|
AccessLevel, TagConfig, WriteIdempotent)
|
||||||
|
VALUES (@Gen, @TagId, @DrvId, @EqId, 'DB1_DBW0', 'Int16', 'ReadWrite',
|
||||||
|
N'{"FullName":"DB1_DBW0","Address":"DB1.DBW0","DataType":"Int16"}', 1);
|
||||||
|
|
||||||
|
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
|
||||||
|
@Notes = N'S7 smoke — task #212';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'S7 smoke seed complete.';
|
||||||
|
PRINT ' Cluster: ' + @ClusterId;
|
||||||
|
PRINT ' Node: ' + @NodeId;
|
||||||
|
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
|
||||||
|
PRINT '';
|
||||||
|
PRINT 'Next steps:';
|
||||||
|
PRINT ' 1. Set src/.../Server/appsettings.json Node:NodeId = "s7-smoke-node"';
|
||||||
|
PRINT ' Node:ClusterId = "s7-smoke"';
|
||||||
|
PRINT ' 2. docker compose -f tests/.../S7.IntegrationTests/Docker/docker-compose.yml --profile s7_1500 up -d';
|
||||||
|
PRINT ' 3. dotnet run --project src/ZB.MOM.WW.OtOpcUa.Server';
|
||||||
|
PRINT ' 4. ./scripts/e2e/test-s7.ps1 -BridgeNodeId "ns=2;s=DB1_DBW0" -S7Host "127.0.0.1:1102"';
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
@page "/alarms/historian"
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian
|
||||||
|
@inject HistorianDiagnosticsService Diag
|
||||||
|
|
||||||
|
<h1>Alarm historian</h1>
|
||||||
|
<p class="text-muted">Local store-and-forward queue that ships alarm events to Aveva Historian via Galaxy.Host.</p>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<small class="text-muted">Drain state</small>
|
||||||
|
<h4><span class="badge @BadgeFor(_status.DrainState)">@_status.DrainState</span></h4>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<small class="text-muted">Queue depth</small>
|
||||||
|
<h4>@_status.QueueDepth.ToString("N0")</h4>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<small class="text-muted">Dead-letter depth</small>
|
||||||
|
<h4 class="@(_status.DeadLetterDepth > 0 ? "text-warning" : "")">@_status.DeadLetterDepth.ToString("N0")</h4>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<small class="text-muted">Last success</small>
|
||||||
|
<h4>@(_status.LastSuccessUtc?.ToString("u") ?? "—")</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_status.LastError))
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning mt-3 mb-0">
|
||||||
|
<strong>Last error:</strong> @_status.LastError
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="RefreshAsync">Refresh</button>
|
||||||
|
<button class="btn btn-warning" disabled="@(_status.DeadLetterDepth == 0)" @onclick="RetryDeadLetteredAsync">
|
||||||
|
Retry dead-lettered (@_status.DeadLetterDepth)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_retryResult is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-success mt-3">Requeued @_retryResult row(s) for retry.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private HistorianSinkStatus _status = new(0, 0, null, null, null, HistorianDrainState.Disabled);
|
||||||
|
private int? _retryResult;
|
||||||
|
|
||||||
|
protected override void OnInitialized() => _status = Diag.GetStatus();
|
||||||
|
|
||||||
|
private Task RefreshAsync()
|
||||||
|
{
|
||||||
|
_status = Diag.GetStatus();
|
||||||
|
_retryResult = null;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task RetryDeadLetteredAsync()
|
||||||
|
{
|
||||||
|
_retryResult = Diag.TryRetryDeadLettered();
|
||||||
|
_status = Diag.GetStatus();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BadgeFor(HistorianDrainState s) => s switch
|
||||||
|
{
|
||||||
|
HistorianDrainState.Idle => "bg-success",
|
||||||
|
HistorianDrainState.Draining => "bg-info",
|
||||||
|
HistorianDrainState.BackingOff => "bg-warning text-dark",
|
||||||
|
HistorianDrainState.Disabled => "bg-secondary",
|
||||||
|
_ => "bg-secondary",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
<li class="nav-item"><button class="nav-link @Active("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
|
<li class="nav-item"><button class="nav-link @Active("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
|
||||||
<li class="nav-item"><button class="nav-link @Active("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
|
<li class="nav-item"><button class="nav-link @Active("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
|
||||||
<li class="nav-item"><button class="nav-link @Active("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
|
<li class="nav-item"><button class="nav-link @Active("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link @Active("scripts")" @onclick='() => _tab = "scripts"'>Scripts</button></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
else if (_tab == "namespaces") { <NamespacesTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
else if (_tab == "namespaces") { <NamespacesTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||||
else if (_tab == "drivers") { <DriversTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
else if (_tab == "drivers") { <DriversTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||||
else if (_tab == "acls") { <AclsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
else if (_tab == "acls") { <AclsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||||
|
else if (_tab == "scripts") { <ScriptsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="card sticky-top">
|
<div class="card sticky-top">
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
|
@*
|
||||||
|
Monaco-backed C# code editor (Phase 7 Stream F). Progressive enhancement:
|
||||||
|
textarea renders immediately, Monaco mounts via JS interop after first render.
|
||||||
|
Monaco script tags are loaded once from the parent layout (wwwroot/js/monaco-loader.js
|
||||||
|
pulls the CDN bundle).
|
||||||
|
|
||||||
|
Stream F keeps the interop surface small — bind `Source` two-way, and the parent
|
||||||
|
tab re-renders on change for the dependency preview. The test-harness button
|
||||||
|
lives in the parent so one editor can drive multiple script types.
|
||||||
|
*@
|
||||||
|
|
||||||
|
<div class="script-editor">
|
||||||
|
<textarea class="form-control font-monospace" rows="14" spellcheck="false"
|
||||||
|
@bind="Source" @bind:event="oninput" id="@_editorId">@Source</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string Source { get; set; } = string.Empty;
|
||||||
|
[Parameter] public EventCallback<string> SourceChanged { get; set; }
|
||||||
|
|
||||||
|
private readonly string _editorId = $"script-editor-{Guid.NewGuid():N}";
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("otOpcUaScriptEditor.attach", _editorId);
|
||||||
|
}
|
||||||
|
catch (JSException)
|
||||||
|
{
|
||||||
|
// Monaco bundle not yet loaded on this page — textarea fallback is
|
||||||
|
// still functional.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Core.Scripting
|
||||||
|
@inject ScriptService ScriptSvc
|
||||||
|
@inject ScriptTestHarnessService Harness
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0">Scripts</h4>
|
||||||
|
<small class="text-muted">C# (Roslyn). Used by virtual tags + scripted alarms.</small>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" @onclick="StartNew">+ New script</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/monaco-loader.js"></script>
|
||||||
|
|
||||||
|
@if (_loading) { <p class="text-muted">Loading…</p> }
|
||||||
|
else if (_scripts.Count == 0 && _editing is null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info">No scripts yet in this draft.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="list-group">
|
||||||
|
@foreach (var s in _scripts)
|
||||||
|
{
|
||||||
|
<button class="list-group-item list-group-item-action @(_editing?.ScriptId == s.ScriptId ? "active" : "")"
|
||||||
|
@onclick="() => Open(s)">
|
||||||
|
<strong>@s.Name</strong>
|
||||||
|
<div class="small text-muted font-monospace">@s.ScriptId</div>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
@if (_editing is not null)
|
||||||
|
{
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<strong>@(_isNew ? "New script" : _editing.Name)</strong>
|
||||||
|
<div>
|
||||||
|
@if (!_isNew)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-danger me-2" @onclick="DeleteAsync">Delete</button>
|
||||||
|
}
|
||||||
|
<button class="btn btn-sm btn-primary" disabled="@_busy" @onclick="SaveAsync">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input class="form-control" @bind="_editing.Name"/>
|
||||||
|
</div>
|
||||||
|
<label class="form-label">Source</label>
|
||||||
|
<ScriptEditor @bind-Source="_editing.SourceCode"/>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="PreviewDependencies">Analyze dependencies</button>
|
||||||
|
<button class="btn btn-sm btn-outline-info ms-2" @onclick="RunHarnessAsync" disabled="@_harnessBusy">Run test harness</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_dependencies is not null)
|
||||||
|
{
|
||||||
|
<div class="mt-3">
|
||||||
|
<strong>Inferred reads</strong>
|
||||||
|
@if (_dependencies.Reads.Count == 0) { <span class="text-muted ms-2">none</span> }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ul class="mb-1">
|
||||||
|
@foreach (var r in _dependencies.Reads) { <li><code>@r</code></li> }
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
<strong>Inferred writes</strong>
|
||||||
|
@if (_dependencies.Writes.Count == 0) { <span class="text-muted ms-2">none</span> }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ul class="mb-1">
|
||||||
|
@foreach (var w in _dependencies.Writes) { <li><code>@w</code></li> }
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
@if (_dependencies.Rejections.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger mt-2">
|
||||||
|
<strong>Non-literal paths rejected:</strong>
|
||||||
|
<ul class="mb-0">
|
||||||
|
@foreach (var r in _dependencies.Rejections) { <li>@r.Message</li> }
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_testResult is not null)
|
||||||
|
{
|
||||||
|
<div class="mt-3 border-top pt-3">
|
||||||
|
<strong>Harness result:</strong> <span class="badge bg-secondary">@_testResult.Outcome</span>
|
||||||
|
@if (_testResult.Outcome == ScriptTestOutcome.Success)
|
||||||
|
{
|
||||||
|
<div>Output: <code>@(_testResult.Output?.ToString() ?? "null")</code></div>
|
||||||
|
@if (_testResult.Writes.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="mt-1"><strong>Writes:</strong>
|
||||||
|
<ul class="mb-0">
|
||||||
|
@foreach (var kv in _testResult.Writes) { <li><code>@kv.Key</code> = <code>@(kv.Value?.ToString() ?? "null")</code></li> }
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (_testResult.Errors.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning mt-2 mb-0">
|
||||||
|
@foreach (var e in _testResult.Errors) { <div>@e</div> }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (_testResult.LogEvents.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="mt-2"><strong>Script log output:</strong>
|
||||||
|
<ul class="small mb-0">
|
||||||
|
@foreach (var e in _testResult.LogEvents) { <li>[@e.Level] @e.RenderMessage()</li> }
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public long GenerationId { get; set; }
|
||||||
|
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
private bool _loading = true;
|
||||||
|
private bool _busy;
|
||||||
|
private bool _harnessBusy;
|
||||||
|
private bool _isNew;
|
||||||
|
private List<Script> _scripts = [];
|
||||||
|
private Script? _editing;
|
||||||
|
private DependencyExtractionResult? _dependencies;
|
||||||
|
private ScriptTestResult? _testResult;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Open(Script s)
|
||||||
|
{
|
||||||
|
_editing = new Script
|
||||||
|
{
|
||||||
|
ScriptRowId = s.ScriptRowId, GenerationId = s.GenerationId,
|
||||||
|
ScriptId = s.ScriptId, Name = s.Name, SourceCode = s.SourceCode,
|
||||||
|
SourceHash = s.SourceHash, Language = s.Language,
|
||||||
|
};
|
||||||
|
_isNew = false;
|
||||||
|
_dependencies = null;
|
||||||
|
_testResult = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartNew()
|
||||||
|
{
|
||||||
|
_editing = new Script
|
||||||
|
{
|
||||||
|
GenerationId = GenerationId, ScriptId = "",
|
||||||
|
Name = "new-script", SourceCode = "return 0;", SourceHash = "",
|
||||||
|
};
|
||||||
|
_isNew = true;
|
||||||
|
_dependencies = null;
|
||||||
|
_testResult = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
if (_editing is null) return;
|
||||||
|
_busy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_isNew)
|
||||||
|
await ScriptSvc.AddAsync(GenerationId, _editing.Name, _editing.SourceCode, CancellationToken.None);
|
||||||
|
else
|
||||||
|
await ScriptSvc.UpdateAsync(GenerationId, _editing.ScriptId, _editing.Name, _editing.SourceCode, CancellationToken.None);
|
||||||
|
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||||
|
_isNew = false;
|
||||||
|
}
|
||||||
|
finally { _busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAsync()
|
||||||
|
{
|
||||||
|
if (_editing is null || _isNew) return;
|
||||||
|
await ScriptSvc.DeleteAsync(GenerationId, _editing.ScriptId, CancellationToken.None);
|
||||||
|
_editing = null;
|
||||||
|
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PreviewDependencies()
|
||||||
|
{
|
||||||
|
if (_editing is null) return;
|
||||||
|
_dependencies = DependencyExtractor.Extract(_editing.SourceCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunHarnessAsync()
|
||||||
|
{
|
||||||
|
if (_editing is null) return;
|
||||||
|
_harnessBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_dependencies ??= DependencyExtractor.Extract(_editing.SourceCode);
|
||||||
|
var inputs = new Dictionary<string, DataValueSnapshot>();
|
||||||
|
foreach (var read in _dependencies.Reads)
|
||||||
|
inputs[read] = new DataValueSnapshot(0.0, 0u, DateTime.UtcNow, DateTime.UtcNow);
|
||||||
|
_testResult = await Harness.RunVirtualTagAsync(_editing.SourceCode, inputs, CancellationToken.None);
|
||||||
|
}
|
||||||
|
finally { _harnessBusy = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,18 @@ builder.Services.AddScoped<EquipmentImportBatchService>();
|
|||||||
builder.Services.AddScoped<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
|
builder.Services.AddScoped<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
|
||||||
ZB.MOM.WW.OtOpcUa.Configuration.Services.LdapGroupRoleMappingService>();
|
ZB.MOM.WW.OtOpcUa.Configuration.Services.LdapGroupRoleMappingService>();
|
||||||
|
|
||||||
|
// Phase 7 Stream F — scripting + virtual tag + scripted alarm draft services, test
|
||||||
|
// harness, and historian diagnostics. The historian sink is the Null variant here —
|
||||||
|
// the real SqliteStoreAndForwardSink lives in the server process. Admin reads status
|
||||||
|
// from whichever sink is provided at composition time.
|
||||||
|
builder.Services.AddScoped<ScriptService>();
|
||||||
|
builder.Services.AddScoped<VirtualTagService>();
|
||||||
|
builder.Services.AddScoped<ScriptedAlarmService>();
|
||||||
|
builder.Services.AddScoped<ScriptTestHarnessService>();
|
||||||
|
builder.Services.AddScoped<HistorianDiagnosticsService>();
|
||||||
|
builder.Services.AddSingleton<ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.IAlarmHistorianSink>(
|
||||||
|
ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.NullAlarmHistorianSink.Instance);
|
||||||
|
|
||||||
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
|
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
|
||||||
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
|
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
|
||||||
// filesystem operations.
|
// filesystem operations.
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Surfaces the local-node historian queue health on the Admin UI's
|
||||||
|
/// <c>/alarms/historian</c> diagnostics page (Phase 7 plan decisions #16/#21).
|
||||||
|
/// Exposes queue depth / drain state / last-error, and lets the operator retry
|
||||||
|
/// dead-lettered rows without restarting the node.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The sink injected here is the server-process <see cref="IAlarmHistorianSink"/>.
|
||||||
|
/// When <see cref="NullAlarmHistorianSink"/> is bound (historian disabled for this
|
||||||
|
/// deployment), <see cref="TryRetryDeadLettered"/> silently returns 0 and
|
||||||
|
/// <see cref="GetStatus"/> reports <see cref="HistorianDrainState.Disabled"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class HistorianDiagnosticsService(IAlarmHistorianSink sink)
|
||||||
|
{
|
||||||
|
public HistorianSinkStatus GetStatus() => sink.GetStatus();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Operator action from the UI's "Retry dead-lettered" button. Returns the number
|
||||||
|
/// of rows revived so the UI can flash a confirmation. When the live sink doesn't
|
||||||
|
/// implement retry (test doubles, Null sink), returns 0.
|
||||||
|
/// </summary>
|
||||||
|
public int TryRetryDeadLettered()
|
||||||
|
{
|
||||||
|
if (sink is SqliteStoreAndForwardSink concrete)
|
||||||
|
return concrete.RetryDeadLettered();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptService.cs
Normal file
66
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptService.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Draft-generation CRUD for <see cref="Script"/> rows — the C# source code referenced
|
||||||
|
/// by Phase 7 virtual tags and scripted alarms. <see cref="Script.SourceHash"/> is
|
||||||
|
/// recomputed on every save so Core.Scripting's compile cache sees a fresh key when
|
||||||
|
/// source changes and reuses the compile when it doesn't.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ScriptService(OtOpcUaConfigDbContext db)
|
||||||
|
{
|
||||||
|
public Task<List<Script>> ListAsync(long generationId, CancellationToken ct) =>
|
||||||
|
db.Scripts.AsNoTracking()
|
||||||
|
.Where(s => s.GenerationId == generationId)
|
||||||
|
.OrderBy(s => s.Name)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
public Task<Script?> GetAsync(long generationId, string scriptId, CancellationToken ct) =>
|
||||||
|
db.Scripts.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(s => s.GenerationId == generationId && s.ScriptId == scriptId, ct);
|
||||||
|
|
||||||
|
public async Task<Script> AddAsync(long generationId, string name, string sourceCode, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var s = new Script
|
||||||
|
{
|
||||||
|
GenerationId = generationId,
|
||||||
|
ScriptId = $"scr-{Guid.NewGuid():N}"[..20],
|
||||||
|
Name = name,
|
||||||
|
SourceCode = sourceCode,
|
||||||
|
SourceHash = ComputeHash(sourceCode),
|
||||||
|
};
|
||||||
|
db.Scripts.Add(s);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Script> UpdateAsync(long generationId, string scriptId, string name, string sourceCode, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var s = await db.Scripts.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptId == scriptId, ct)
|
||||||
|
?? throw new InvalidOperationException($"Script '{scriptId}' not found in generation {generationId}");
|
||||||
|
s.Name = name;
|
||||||
|
s.SourceCode = sourceCode;
|
||||||
|
s.SourceHash = ComputeHash(sourceCode);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(long generationId, string scriptId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var s = await db.Scripts.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptId == scriptId, ct);
|
||||||
|
if (s is null) return;
|
||||||
|
db.Scripts.Remove(s);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string ComputeHash(string source)
|
||||||
|
{
|
||||||
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(source ?? string.Empty));
|
||||||
|
return Convert.ToHexString(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptTestHarnessService.cs
Normal file
121
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptTestHarnessService.cs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
using Serilog; // resolves Serilog.ILogger explicitly in signatures
|
||||||
|
using Serilog.Core;
|
||||||
|
using Serilog.Events;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dry-run harness for the Phase 7 scripting UI. Takes a script + a synthetic input
|
||||||
|
/// map + evaluates once, returns the output (or rejection / exception) plus any
|
||||||
|
/// logger emissions the script produced. Per Phase 7 plan decision #22: only inputs
|
||||||
|
/// the <see cref="DependencyExtractor"/> identified can be supplied, so a dependency
|
||||||
|
/// the harness can't prove statically surfaces as a harness error, not a runtime
|
||||||
|
/// surprise later.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ScriptTestHarnessService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluate <paramref name="source"/> as a virtual-tag script (return value is the
|
||||||
|
/// tag's new value). <paramref name="inputs"/> supplies synthetic
|
||||||
|
/// <see cref="DataValueSnapshot"/>s for every path the extractor found.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ScriptTestResult> RunVirtualTagAsync(
|
||||||
|
string source, IDictionary<string, DataValueSnapshot> inputs, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var deps = DependencyExtractor.Extract(source);
|
||||||
|
if (!deps.IsValid)
|
||||||
|
return ScriptTestResult.DependencyRejections(deps.Rejections);
|
||||||
|
|
||||||
|
var missing = deps.Reads.Where(r => !inputs.ContainsKey(r)).ToArray();
|
||||||
|
if (missing.Length > 0)
|
||||||
|
return ScriptTestResult.MissingInputs(missing);
|
||||||
|
|
||||||
|
var extra = inputs.Keys.Where(k => !deps.Reads.Contains(k)).ToArray();
|
||||||
|
if (extra.Length > 0)
|
||||||
|
return ScriptTestResult.UnknownInputs(extra);
|
||||||
|
|
||||||
|
ScriptEvaluator<HarnessVirtualTagContext, object?> evaluator;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
evaluator = ScriptEvaluator<HarnessVirtualTagContext, object?>.Compile(source);
|
||||||
|
}
|
||||||
|
catch (Exception compileEx)
|
||||||
|
{
|
||||||
|
return ScriptTestResult.Threw(compileEx.Message, []);
|
||||||
|
}
|
||||||
|
var capturing = new CapturingSink();
|
||||||
|
var logger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(capturing).CreateLogger();
|
||||||
|
var ctx = new HarnessVirtualTagContext(inputs, logger);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await evaluator.RunAsync(ctx, ct);
|
||||||
|
return ScriptTestResult.Ok(result, ctx.Writes, capturing.Events);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return ScriptTestResult.Threw(ex.Message, capturing.Events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public so Roslyn's script compilation can reference the context type through the
|
||||||
|
// ScriptGlobals<T> surface. The harness instantiates this directly; operators never see it.
|
||||||
|
public sealed class HarnessVirtualTagContext(
|
||||||
|
IDictionary<string, DataValueSnapshot> inputs, Serilog.ILogger logger) : ScriptContext
|
||||||
|
{
|
||||||
|
public Dictionary<string, object?> Writes { get; } = [];
|
||||||
|
public override DataValueSnapshot GetTag(string path) =>
|
||||||
|
inputs.TryGetValue(path, out var v)
|
||||||
|
? v
|
||||||
|
: new DataValueSnapshot(null, Ua.StatusCodes.BadNotFound, null, DateTime.UtcNow);
|
||||||
|
public override void SetVirtualTag(string path, object? value) => Writes[path] = value;
|
||||||
|
public override DateTime Now => DateTime.UtcNow;
|
||||||
|
public override Serilog.ILogger Logger => logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CapturingSink : ILogEventSink
|
||||||
|
{
|
||||||
|
public List<LogEvent> Events { get; } = [];
|
||||||
|
public void Emit(LogEvent e) => Events.Add(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Harness outcome: outputs, write-set, logger events, or a rejection/throw reason.</summary>
|
||||||
|
public sealed record ScriptTestResult(
|
||||||
|
ScriptTestOutcome Outcome,
|
||||||
|
object? Output,
|
||||||
|
IReadOnlyDictionary<string, object?> Writes,
|
||||||
|
IReadOnlyList<LogEvent> LogEvents,
|
||||||
|
IReadOnlyList<string> Errors)
|
||||||
|
{
|
||||||
|
public static ScriptTestResult Ok(object? output, IReadOnlyDictionary<string, object?> writes, IReadOnlyList<LogEvent> logs) =>
|
||||||
|
new(ScriptTestOutcome.Success, output, writes, logs, []);
|
||||||
|
public static ScriptTestResult Threw(string reason, IReadOnlyList<LogEvent> logs) =>
|
||||||
|
new(ScriptTestOutcome.Threw, null, new Dictionary<string, object?>(), logs, [reason]);
|
||||||
|
public static ScriptTestResult DependencyRejections(IReadOnlyList<DependencyRejection> rejs) =>
|
||||||
|
new(ScriptTestOutcome.DependencyRejected, null, new Dictionary<string, object?>(), [],
|
||||||
|
rejs.Select(r => r.Message).ToArray());
|
||||||
|
public static ScriptTestResult MissingInputs(string[] paths) =>
|
||||||
|
new(ScriptTestOutcome.MissingInputs, null, new Dictionary<string, object?>(), [],
|
||||||
|
paths.Select(p => $"Missing synthetic input: {p}").ToArray());
|
||||||
|
public static ScriptTestResult UnknownInputs(string[] paths) =>
|
||||||
|
new(ScriptTestOutcome.UnknownInputs, null, new Dictionary<string, object?>(), [],
|
||||||
|
paths.Select(p => $"Input '{p}' is not referenced by the script — remove it").ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ScriptTestOutcome
|
||||||
|
{
|
||||||
|
Success,
|
||||||
|
Threw,
|
||||||
|
DependencyRejected,
|
||||||
|
MissingInputs,
|
||||||
|
UnknownInputs,
|
||||||
|
}
|
||||||
|
|
||||||
|
file static class Ua
|
||||||
|
{
|
||||||
|
// Mirrors OPC UA StatusCodes.BadNotFound without pulling the OPC stack into Admin.
|
||||||
|
public static class StatusCodes { public const uint BadNotFound = 0x803E0000; }
|
||||||
|
}
|
||||||
55
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptedAlarmService.cs
Normal file
55
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptedAlarmService.cs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
/// <summary>Draft-generation CRUD for <see cref="ScriptedAlarm"/> rows.</summary>
|
||||||
|
public sealed class ScriptedAlarmService(OtOpcUaConfigDbContext db)
|
||||||
|
{
|
||||||
|
public Task<List<ScriptedAlarm>> ListAsync(long generationId, CancellationToken ct) =>
|
||||||
|
db.ScriptedAlarms.AsNoTracking()
|
||||||
|
.Where(a => a.GenerationId == generationId)
|
||||||
|
.OrderBy(a => a.EquipmentId).ThenBy(a => a.Name)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<ScriptedAlarm> AddAsync(
|
||||||
|
long generationId, string equipmentId, string name, string alarmType,
|
||||||
|
int severity, string messageTemplate, string predicateScriptId,
|
||||||
|
bool historizeToAveva, bool retain, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var a = new ScriptedAlarm
|
||||||
|
{
|
||||||
|
GenerationId = generationId,
|
||||||
|
ScriptedAlarmId = $"sal-{Guid.NewGuid():N}"[..20],
|
||||||
|
EquipmentId = equipmentId,
|
||||||
|
Name = name,
|
||||||
|
AlarmType = alarmType,
|
||||||
|
Severity = severity,
|
||||||
|
MessageTemplate = messageTemplate,
|
||||||
|
PredicateScriptId = predicateScriptId,
|
||||||
|
HistorizeToAveva = historizeToAveva,
|
||||||
|
Retain = retain,
|
||||||
|
};
|
||||||
|
db.ScriptedAlarms.Add(a);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(long generationId, string scriptedAlarmId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var a = await db.ScriptedAlarms.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptedAlarmId == scriptedAlarmId, ct);
|
||||||
|
if (a is null) return;
|
||||||
|
db.ScriptedAlarms.Remove(a);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the persistent state row (ack/confirm/shelve) for this alarm identity —
|
||||||
|
/// alarm state is NOT generation-scoped per Phase 7 plan decision #14, so the
|
||||||
|
/// lookup is by <see cref="ScriptedAlarm.ScriptedAlarmId"/> only.
|
||||||
|
/// </summary>
|
||||||
|
public Task<ScriptedAlarmState?> GetStateAsync(string scriptedAlarmId, CancellationToken ct) =>
|
||||||
|
db.ScriptedAlarmStates.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(s => s.ScriptedAlarmId == scriptedAlarmId, ct);
|
||||||
|
}
|
||||||
53
src/ZB.MOM.WW.OtOpcUa.Admin/Services/VirtualTagService.cs
Normal file
53
src/ZB.MOM.WW.OtOpcUa.Admin/Services/VirtualTagService.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||||
|
|
||||||
|
/// <summary>Draft-generation CRUD for <see cref="VirtualTag"/> rows.</summary>
|
||||||
|
public sealed class VirtualTagService(OtOpcUaConfigDbContext db)
|
||||||
|
{
|
||||||
|
public Task<List<VirtualTag>> ListAsync(long generationId, CancellationToken ct) =>
|
||||||
|
db.VirtualTags.AsNoTracking()
|
||||||
|
.Where(v => v.GenerationId == generationId)
|
||||||
|
.OrderBy(v => v.EquipmentId).ThenBy(v => v.Name)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
public async Task<VirtualTag> AddAsync(
|
||||||
|
long generationId, string equipmentId, string name, string dataType, string scriptId,
|
||||||
|
bool changeTriggered, int? timerIntervalMs, bool historize, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var v = new VirtualTag
|
||||||
|
{
|
||||||
|
GenerationId = generationId,
|
||||||
|
VirtualTagId = $"vt-{Guid.NewGuid():N}"[..20],
|
||||||
|
EquipmentId = equipmentId,
|
||||||
|
Name = name,
|
||||||
|
DataType = dataType,
|
||||||
|
ScriptId = scriptId,
|
||||||
|
ChangeTriggered = changeTriggered,
|
||||||
|
TimerIntervalMs = timerIntervalMs,
|
||||||
|
Historize = historize,
|
||||||
|
};
|
||||||
|
db.VirtualTags.Add(v);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(long generationId, string virtualTagId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var v = await db.VirtualTags.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.VirtualTagId == virtualTagId, ct);
|
||||||
|
if (v is null) return;
|
||||||
|
db.VirtualTags.Remove(v);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<VirtualTag> UpdateEnabledAsync(long generationId, string virtualTagId, bool enabled, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var v = await db.VirtualTags.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.VirtualTagId == virtualTagId, ct)
|
||||||
|
?? throw new InvalidOperationException($"VirtualTag '{virtualTagId}' not found in generation {generationId}");
|
||||||
|
v.Enabled = enabled;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
59
src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/js/monaco-loader.js
Normal file
59
src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/js/monaco-loader.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// Phase 7 Stream F — Monaco editor loader for ScriptEditor.razor.
|
||||||
|
// Progressive enhancement: the textarea is authoritative until Monaco attaches;
|
||||||
|
// after attach, Monaco syncs back into the textarea on every change so Blazor's
|
||||||
|
// @bind still sees the latest value.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
if (window.otOpcUaScriptEditor) return;
|
||||||
|
|
||||||
|
const MONACO_CDN = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs';
|
||||||
|
let loaderPromise = null;
|
||||||
|
|
||||||
|
function ensureLoader() {
|
||||||
|
if (loaderPromise) return loaderPromise;
|
||||||
|
loaderPromise = new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = `${MONACO_CDN}/loader.js`;
|
||||||
|
script.onload = () => {
|
||||||
|
window.require.config({ paths: { vs: MONACO_CDN } });
|
||||||
|
window.require(['vs/editor/editor.main'], () => resolve(window.monaco));
|
||||||
|
};
|
||||||
|
script.onerror = () => reject(new Error('Monaco CDN unreachable'));
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
return loaderPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.otOpcUaScriptEditor = {
|
||||||
|
attach: async function (textareaId) {
|
||||||
|
const ta = document.getElementById(textareaId);
|
||||||
|
if (!ta) return;
|
||||||
|
const monaco = await ensureLoader();
|
||||||
|
|
||||||
|
// Mount Monaco over the textarea. The textarea stays in the DOM as the
|
||||||
|
// source of truth for Blazor's @bind — Monaco mirrors into it on every
|
||||||
|
// keystroke so server-side state stays in sync.
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.style.height = '340px';
|
||||||
|
host.style.border = '1px solid #ced4da';
|
||||||
|
host.style.borderRadius = '0.25rem';
|
||||||
|
ta.style.display = 'none';
|
||||||
|
ta.parentNode.insertBefore(host, ta);
|
||||||
|
|
||||||
|
const editor = monaco.editor.create(host, {
|
||||||
|
value: ta.value,
|
||||||
|
language: 'csharp',
|
||||||
|
theme: 'vs',
|
||||||
|
automaticLayout: true,
|
||||||
|
fontSize: 13,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.onDidChangeModelContent(() => {
|
||||||
|
ta.value = editor.getValue();
|
||||||
|
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,232 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Phase 7 follow-up (task #241) — extends <c>dbo.sp_ComputeGenerationDiff</c> to emit
|
||||||
|
/// Script / VirtualTag / ScriptedAlarm rows alongside the existing Namespace /
|
||||||
|
/// DriverInstance / Equipment / Tag / NodeAcl output. Admin DiffViewer now shows
|
||||||
|
/// Phase 7 changes between generations.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Logical ids: ScriptId, VirtualTagId, ScriptedAlarmId — stable across generations
|
||||||
|
/// so a Script whose source changes surfaces as Modified (CHECKSUM picks up the
|
||||||
|
/// SourceHash delta) while a renamed script surfaces as Modified on Name alone.
|
||||||
|
/// ScriptedAlarmState is deliberately excluded — it's not generation-scoped, so
|
||||||
|
/// diffing it between generations is meaningless.
|
||||||
|
/// </remarks>
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ExtendComputeGenerationDiffWithPhase7 : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(Procs.ComputeGenerationDiffV3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(Procs.ComputeGenerationDiffV2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class Procs
|
||||||
|
{
|
||||||
|
/// <summary>V3 — adds Script / VirtualTag / ScriptedAlarm sections.</summary>
|
||||||
|
public const string ComputeGenerationDiffV3 = @"
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
|
||||||
|
@FromGenerationId bigint,
|
||||||
|
@ToGenerationId bigint
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), ChangeKind nvarchar(16));
|
||||||
|
|
||||||
|
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'Namespace', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'DriverInstance', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'Equipment', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'Tag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
WITH f AS (
|
||||||
|
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
|
||||||
|
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
|
||||||
|
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (
|
||||||
|
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
|
||||||
|
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
|
||||||
|
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'NodeAcl', COALESCE(f.LogicalId, t.LogicalId),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
-- Phase 7 — Script section. CHECKSUM picks up source changes via SourceHash + rename
|
||||||
|
-- via Name; Language future-proofs for non-C# engines. Same Name + same Source =
|
||||||
|
-- Unchanged (identical hash).
|
||||||
|
WITH f AS (SELECT ScriptId AS LogicalId, CHECKSUM(Name, SourceHash, Language) AS Sig FROM dbo.Script WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT ScriptId AS LogicalId, CHECKSUM(Name, SourceHash, Language) AS Sig FROM dbo.Script WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'Script', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
-- Phase 7 — VirtualTag section.
|
||||||
|
WITH f AS (SELECT VirtualTagId AS LogicalId, CHECKSUM(EquipmentId, Name, DataType, ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) AS Sig FROM dbo.VirtualTag WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT VirtualTagId AS LogicalId, CHECKSUM(EquipmentId, Name, DataType, ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) AS Sig FROM dbo.VirtualTag WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'VirtualTag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
-- Phase 7 — ScriptedAlarm section. ScriptedAlarmState (operator ack trail) is
|
||||||
|
-- logical-id keyed outside the generation scope + intentionally excluded here —
|
||||||
|
-- diffing ack state between generations is semantically meaningless.
|
||||||
|
WITH f AS (SELECT ScriptedAlarmId AS LogicalId, CHECKSUM(EquipmentId, Name, AlarmType, Severity, MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) AS Sig FROM dbo.ScriptedAlarm WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT ScriptedAlarmId AS LogicalId, CHECKSUM(EquipmentId, Name, AlarmType, Severity, MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) AS Sig FROM dbo.ScriptedAlarm WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'ScriptedAlarm', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
SELECT TableName, LogicalId, ChangeKind FROM #diff;
|
||||||
|
DROP TABLE #diff;
|
||||||
|
END
|
||||||
|
";
|
||||||
|
|
||||||
|
/// <summary>V2 — restores the pre-Phase-7 proc on Down().</summary>
|
||||||
|
public const string ComputeGenerationDiffV2 = @"
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
|
||||||
|
@FromGenerationId bigint,
|
||||||
|
@ToGenerationId bigint
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), ChangeKind nvarchar(16));
|
||||||
|
|
||||||
|
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'Namespace', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'DriverInstance', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'Equipment', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'Tag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
WITH f AS (
|
||||||
|
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
|
||||||
|
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
|
||||||
|
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
|
||||||
|
t AS (
|
||||||
|
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
|
||||||
|
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
|
||||||
|
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
|
||||||
|
INSERT #diff
|
||||||
|
SELECT 'NodeAcl', COALESCE(f.LogicalId, t.LogicalId),
|
||||||
|
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||||
|
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||||
|
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||||
|
ELSE 'Unchanged' END
|
||||||
|
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||||
|
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||||
|
|
||||||
|
SELECT TableName, LogicalId, ChangeKind FROM #diff;
|
||||||
|
DROP TABLE #diff;
|
||||||
|
END
|
||||||
|
";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,25 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
/// (holding registers with level-set values, set-point writes to analog tags) — the
|
/// (holding registers with level-set values, set-point writes to analog tags) — the
|
||||||
/// capability invoker respects this flag when deciding whether to apply Polly retry.
|
/// capability invoker respects this flag when deciding whether to apply Polly retry.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="Source">
|
||||||
|
/// Per ADR-002 — discriminates which runtime subsystem owns this node's dispatch.
|
||||||
|
/// Defaults to <see cref="NodeSourceKind.Driver"/> so existing callers are unchanged.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="VirtualTagId">
|
||||||
|
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.Virtual"/> — stable
|
||||||
|
/// logical id the VirtualTagEngine addresses by. Null otherwise.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ScriptedAlarmId">
|
||||||
|
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.ScriptedAlarm"/> —
|
||||||
|
/// stable logical id the ScriptedAlarmEngine addresses by. Null otherwise.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Description">
|
||||||
|
/// Human-readable description for this attribute. When non-null + non-empty the generic
|
||||||
|
/// node-manager surfaces the value as the OPC UA <c>Description</c> attribute on the
|
||||||
|
/// Variable node so SCADA / engineering clients see the field comment from the source
|
||||||
|
/// project (Studio 5000 tag descriptions, Galaxy attribute help text, etc.). Defaults to
|
||||||
|
/// null so drivers that don't carry descriptions are unaffected.
|
||||||
|
/// </param>
|
||||||
public sealed record DriverAttributeInfo(
|
public sealed record DriverAttributeInfo(
|
||||||
string FullName,
|
string FullName,
|
||||||
DriverDataType DriverDataType,
|
DriverDataType DriverDataType,
|
||||||
@@ -41,4 +60,22 @@ public sealed record DriverAttributeInfo(
|
|||||||
SecurityClassification SecurityClass,
|
SecurityClassification SecurityClass,
|
||||||
bool IsHistorized,
|
bool IsHistorized,
|
||||||
bool IsAlarm = false,
|
bool IsAlarm = false,
|
||||||
bool WriteIdempotent = false);
|
bool WriteIdempotent = false,
|
||||||
|
NodeSourceKind Source = NodeSourceKind.Driver,
|
||||||
|
string? VirtualTagId = null,
|
||||||
|
string? ScriptedAlarmId = null,
|
||||||
|
string? Description = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per ADR-002 — discriminates which runtime subsystem owns this node's Read/Write/
|
||||||
|
/// Subscribe dispatch. <c>Driver</c> = a real IDriver capability surface;
|
||||||
|
/// <c>Virtual</c> = a Phase 7 <see cref="DriverAttributeInfo"/>.VirtualTagId'd tag
|
||||||
|
/// computed by the VirtualTagEngine; <c>ScriptedAlarm</c> = a scripted Part 9 alarm
|
||||||
|
/// materialized by the ScriptedAlarmEngine.
|
||||||
|
/// </summary>
|
||||||
|
public enum NodeSourceKind
|
||||||
|
{
|
||||||
|
Driver = 0,
|
||||||
|
Virtual = 1,
|
||||||
|
ScriptedAlarm = 2,
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public enum DriverCapability
|
|||||||
/// <summary><see cref="ITagDiscovery.DiscoverAsync"/>. Retries by default.</summary>
|
/// <summary><see cref="ITagDiscovery.DiscoverAsync"/>. Retries by default.</summary>
|
||||||
Discover,
|
Discover,
|
||||||
|
|
||||||
/// <summary><see cref="ISubscribable.SubscribeAsync"/> and unsubscribe. Retries by default.</summary>
|
/// <summary><see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/> and unsubscribe. Retries by default.</summary>
|
||||||
Subscribe,
|
Subscribe,
|
||||||
|
|
||||||
/// <summary><see cref="IHostConnectivityProbe"/> probe loop. Retries by default.</summary>
|
/// <summary><see cref="IHostConnectivityProbe"/> probe loop. Retries by default.</summary>
|
||||||
|
|||||||
@@ -25,4 +25,11 @@ public enum DriverDataType
|
|||||||
|
|
||||||
/// <summary>Galaxy-style attribute reference encoded as an OPC UA String.</summary>
|
/// <summary>Galaxy-style attribute reference encoded as an OPC UA String.</summary>
|
||||||
Reference,
|
Reference,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OPC UA <c>Duration</c> — a Double-encoded period in milliseconds. Subtype of Double
|
||||||
|
/// in the address space; surfaced as <see cref="System.TimeSpan"/> in the driver layer.
|
||||||
|
/// Used by IEC 61131-3 <c>TIME</c> / <c>TOD</c> attributes (TwinCAT et al.).
|
||||||
|
/// </summary>
|
||||||
|
Duration,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,26 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
/// <param name="State">Current driver-instance state.</param>
|
/// <param name="State">Current driver-instance state.</param>
|
||||||
/// <param name="LastSuccessfulRead">Timestamp of the most recent successful equipment read; null if never.</param>
|
/// <param name="LastSuccessfulRead">Timestamp of the most recent successful equipment read; null if never.</param>
|
||||||
/// <param name="LastError">Most recent error message; null when state is Healthy.</param>
|
/// <param name="LastError">Most recent error message; null when state is Healthy.</param>
|
||||||
|
/// <param name="Diagnostics">
|
||||||
|
/// Optional driver-attributable counters/metrics surfaced for the <c>driver-diagnostics</c>
|
||||||
|
/// RPC (introduced for Modbus task #154). Drivers populate the dictionary with stable,
|
||||||
|
/// well-known keys (e.g. <c>PublishRequestCount</c>, <c>NotificationsPerSecond</c>);
|
||||||
|
/// Core treats it as opaque metadata. Defaulted to an empty read-only dictionary so
|
||||||
|
/// existing drivers and call-sites that don't construct this field stay back-compat.
|
||||||
|
/// </param>
|
||||||
public sealed record DriverHealth(
|
public sealed record DriverHealth(
|
||||||
DriverState State,
|
DriverState State,
|
||||||
DateTime? LastSuccessfulRead,
|
DateTime? LastSuccessfulRead,
|
||||||
string? LastError);
|
string? LastError,
|
||||||
|
IReadOnlyDictionary<string, double>? Diagnostics = null)
|
||||||
|
{
|
||||||
|
/// <summary>Driver-attributable counters, empty when the driver doesn't surface any.</summary>
|
||||||
|
public IReadOnlyDictionary<string, double> DiagnosticsOrEmpty
|
||||||
|
=> Diagnostics ?? EmptyDiagnostics;
|
||||||
|
|
||||||
|
private static readonly IReadOnlyDictionary<string, double> EmptyDiagnostics
|
||||||
|
= new Dictionary<string, double>(0);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Driver-instance lifecycle state.</summary>
|
/// <summary>Driver-instance lifecycle state.</summary>
|
||||||
public enum DriverState
|
public enum DriverState
|
||||||
|
|||||||
@@ -35,8 +35,159 @@ public interface IAddressSpaceBuilder
|
|||||||
/// <c>_base</c> equipment-class template).
|
/// <c>_base</c> equipment-class template).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
void AddProperty(string browseName, DriverDataType dataType, object? value);
|
void AddProperty(string browseName, DriverDataType dataType, object? value);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register a type-definition node (ObjectType / VariableType / DataType / ReferenceType)
|
||||||
|
/// mirrored from an upstream OPC UA server. Optional surface — drivers that don't mirror
|
||||||
|
/// types simply never call it; address-space builders that don't materialise upstream
|
||||||
|
/// types can leave the default no-op in place. Default implementation drops the call so
|
||||||
|
/// adding this method doesn't break existing <see cref="IAddressSpaceBuilder"/>
|
||||||
|
/// implementations.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="info">Metadata describing the type-definition node to mirror.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The OPC UA Client driver is the primary caller — it walks <c>i=86</c>
|
||||||
|
/// (TypesFolder) during <c>DiscoverAsync</c> when
|
||||||
|
/// <c>OpcUaClientDriverOptions.MirrorTypeDefinitions</c> is set so downstream clients
|
||||||
|
/// see the upstream type system instead of rendering structured-type values as opaque
|
||||||
|
/// strings.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The default no-op is intentional — most builders (Galaxy, Modbus, FOCAS, S7,
|
||||||
|
/// TwinCAT, AB-CIP) don't have a meaningful type folder to project into and would
|
||||||
|
/// otherwise need empty-stub overrides.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
void RegisterTypeNode(MirroredTypeNodeInfo info) { /* default: no-op */ }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register a method node mirrored from an upstream OPC UA server. The method is
|
||||||
|
/// registered as a child of the current builder scope (i.e. the folder representing
|
||||||
|
/// the upstream Object that owns the method). Optional surface — drivers that don't
|
||||||
|
/// mirror methods simply never call it; address-space builders that don't materialise
|
||||||
|
/// method nodes can leave the default no-op in place. Default implementation drops
|
||||||
|
/// the call so adding this method doesn't break existing
|
||||||
|
/// <see cref="IAddressSpaceBuilder"/> implementations.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="info">Metadata describing the method node, including input/output argument schemas.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The OPC UA Client driver is the primary caller — it picks up
|
||||||
|
/// <c>NodeClass.Method</c> nodes during the <c>HierarchicalReferences</c> browse
|
||||||
|
/// pass, then walks each method's <c>HasProperty</c> references to harvest the
|
||||||
|
/// <c>InputArguments</c> / <c>OutputArguments</c> property values.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The OPC UA server-side <c>DriverNodeManager</c> overrides this to materialize
|
||||||
|
/// a real <c>MethodNode</c> in the local address space and wire its
|
||||||
|
/// <c>OnCallMethod</c> handler to the driver's
|
||||||
|
/// <see cref="IMethodInvoker.CallMethodAsync"/>. Other builders (Galaxy, Modbus,
|
||||||
|
/// FOCAS, S7, TwinCAT, AB-CIP, AB-Legacy) ignore the projection because their
|
||||||
|
/// backends don't expose method nodes.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
void RegisterMethodNode(MirroredMethodNodeInfo info) { /* default: no-op */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Metadata describing a single method node mirrored from an upstream OPC UA server.
|
||||||
|
/// Built by the OPC UA Client driver during the discovery browse pass and consumed by
|
||||||
|
/// <see cref="IAddressSpaceBuilder.RegisterMethodNode"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="BrowseName">OPC UA BrowseName segment from the upstream BrowseName.</param>
|
||||||
|
/// <param name="DisplayName">Human-readable display name; falls back to <paramref name="BrowseName"/>.</param>
|
||||||
|
/// <param name="ObjectNodeId">
|
||||||
|
/// Stringified NodeId of the parent Object that owns this method — the <c>ObjectId</c>
|
||||||
|
/// argument the dispatcher passes back to <see cref="IMethodInvoker.CallMethodAsync"/>.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="MethodNodeId">
|
||||||
|
/// Stringified NodeId of the method node itself — the <c>MethodId</c> argument.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="InputArguments">
|
||||||
|
/// Declaration of the method's input arguments, in order. <c>null</c> or empty when the
|
||||||
|
/// method takes no inputs (or the upstream property couldn't be read).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="OutputArguments">
|
||||||
|
/// Declaration of the method's output arguments, in order. <c>null</c> or empty when the
|
||||||
|
/// method returns no outputs (or the upstream property couldn't be read).
|
||||||
|
/// </param>
|
||||||
|
public sealed record MirroredMethodNodeInfo(
|
||||||
|
string BrowseName,
|
||||||
|
string DisplayName,
|
||||||
|
string ObjectNodeId,
|
||||||
|
string MethodNodeId,
|
||||||
|
IReadOnlyList<MethodArgumentInfo>? InputArguments,
|
||||||
|
IReadOnlyList<MethodArgumentInfo>? OutputArguments);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row of an OPC UA Argument array — name + data type + array hint. Mirrors the
|
||||||
|
/// <c>Opc.Ua.Argument</c> structure but without the SDK-only types so this DTO can live
|
||||||
|
/// in <c>Core.Abstractions</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Name">Argument name from the upstream Argument structure.</param>
|
||||||
|
/// <param name="DriverDataType">
|
||||||
|
/// Mapped local <see cref="DriverDataType"/>. Unknown / structured upstream types fall
|
||||||
|
/// through to <see cref="DriverDataType.String"/> — same convention as variable mirroring.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ValueRank">
|
||||||
|
/// OPC UA ValueRank: <c>-1</c> = scalar, <c>0</c> = OneOrMoreDimensions, <c>1+</c> = array
|
||||||
|
/// dimensions. Driven directly from the upstream Argument's ValueRank.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Description">
|
||||||
|
/// Human-readable description from the upstream Argument structure; <c>null</c> when the
|
||||||
|
/// upstream doesn't carry one.
|
||||||
|
/// </param>
|
||||||
|
public sealed record MethodArgumentInfo(
|
||||||
|
string Name,
|
||||||
|
DriverDataType DriverDataType,
|
||||||
|
int ValueRank,
|
||||||
|
string? Description);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Categorises a mirrored type-definition node so the receiving builder can route it into
|
||||||
|
/// the right OPC UA standard subtree (<c>ObjectTypesFolder</c>, <c>VariableTypesFolder</c>,
|
||||||
|
/// <c>DataTypesFolder</c>, <c>ReferenceTypesFolder</c>) when projecting upstream types into
|
||||||
|
/// the local address space.
|
||||||
|
/// </summary>
|
||||||
|
public enum MirroredTypeKind
|
||||||
|
{
|
||||||
|
ObjectType,
|
||||||
|
VariableType,
|
||||||
|
DataType,
|
||||||
|
ReferenceType,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Metadata describing a single type-definition node mirrored from an upstream OPC UA
|
||||||
|
/// server. Built by the OPC UA Client driver during type-mirror pass and consumed by
|
||||||
|
/// <see cref="IAddressSpaceBuilder.RegisterTypeNode"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Kind">Type category — drives which standard sub-folder the node lives under.</param>
|
||||||
|
/// <param name="UpstreamNodeId">
|
||||||
|
/// Stringified upstream NodeId (e.g. <c>"ns=2;i=1234"</c>) — preserves the original identity
|
||||||
|
/// so a builder that wants to project the type with a stable cross-namespace reference can do
|
||||||
|
/// so. The driver applies any configured namespace remap before stamping this field.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="BrowseName">OPC UA BrowseName segment from the upstream BrowseName.</param>
|
||||||
|
/// <param name="DisplayName">Human-readable display name; falls back to <paramref name="BrowseName"/>.</param>
|
||||||
|
/// <param name="SuperTypeNodeId">
|
||||||
|
/// Stringified upstream NodeId of the super-type (parent type), or <c>null</c> when the node
|
||||||
|
/// sits directly under the root (e.g. <c>BaseObjectType</c>, <c>BaseVariableType</c>). Lets
|
||||||
|
/// the builder reconstruct the inheritance chain.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="IsAbstract">
|
||||||
|
/// <c>true</c> when the upstream node has the <c>IsAbstract</c> flag set (Object / Variable /
|
||||||
|
/// ReferenceType). DataTypes also expose this — the driver passes it through verbatim.
|
||||||
|
/// </param>
|
||||||
|
public sealed record MirroredTypeNodeInfo(
|
||||||
|
MirroredTypeKind Kind,
|
||||||
|
string UpstreamNodeId,
|
||||||
|
string BrowseName,
|
||||||
|
string DisplayName,
|
||||||
|
string? SuperTypeNodeId,
|
||||||
|
bool IsAbstract);
|
||||||
|
|
||||||
/// <summary>Opaque handle for a registered variable. Used by Core for subscription routing.</summary>
|
/// <summary>Opaque handle for a registered variable. Used by Core for subscription routing.</summary>
|
||||||
public interface IVariableHandle
|
public interface IVariableHandle
|
||||||
{
|
{
|
||||||
|
|||||||
27
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverControl.cs
Normal file
27
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverControl.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional control-plane capability — drivers whose backend exposes a way to refresh
|
||||||
|
/// the symbol table on-demand (without tearing the driver down) implement this so the
|
||||||
|
/// Admin UI / CLI can trigger a re-walk in response to an operator action.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Distinct from <see cref="IRediscoverable"/>: that interface is the driver telling Core
|
||||||
|
/// a refresh is needed; this one is Core asking the driver to refresh now. For drivers that
|
||||||
|
/// implement both, the typical wiring is "operator clicks Rebrowse → Core calls
|
||||||
|
/// <see cref="RebrowseAsync"/> → driver re-walks → driver fires
|
||||||
|
/// <c>OnRediscoveryNeeded</c> so the address space is rebuilt".
|
||||||
|
///
|
||||||
|
/// For AB CIP this is the "force re-walk of @tags" hook — useful after a controller
|
||||||
|
/// program download added new tags but the static config still drives the address space.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IDriverControl
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Re-run the driver's discovery pass against live backend state and stream the
|
||||||
|
/// resulting nodes through the supplied builder. Implementations must be safe to call
|
||||||
|
/// concurrently with reads / writes; they typically serialize internally so a second
|
||||||
|
/// concurrent rebrowse waits for the first to complete rather than racing it.
|
||||||
|
/// </summary>
|
||||||
|
Task RebrowseAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -76,8 +76,107 @@ public interface IHistoryProvider
|
|||||||
=> throw new NotSupportedException(
|
=> throw new NotSupportedException(
|
||||||
$"{GetType().Name} does not implement ReadEventsAsync. " +
|
$"{GetType().Name} does not implement ReadEventsAsync. " +
|
||||||
"Drivers whose backends have an event historian override this method.");
|
"Drivers whose backends have an event historian override this method.");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filter-aware historical event read — OPC UA HistoryReadEvents service with full
|
||||||
|
/// <c>EventFilter</c> support (SelectClauses + WhereClause). Distinct from the simpler
|
||||||
|
/// <see cref="ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/>
|
||||||
|
/// overload which is sufficient for "give me the standard BaseEventType fields"
|
||||||
|
/// queries; this overload is for clients that send a custom <c>EventFilter</c> on the
|
||||||
|
/// wire (per-select-clause Variant population, where-filter evaluation).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fullReference">
|
||||||
|
/// Driver-specific node identifier. May be a notifier object (e.g. the driver-root
|
||||||
|
/// folder) — drivers that support cluster-wide queries treat it as
|
||||||
|
/// "all sources in the namespace".
|
||||||
|
/// </param>
|
||||||
|
/// <param name="request">Filter spec — time range + select clauses + optional where clause.</param>
|
||||||
|
/// <param name="cancellationToken">Request cancellation.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Default implementation throws — drivers opt in by overriding. Existing drivers
|
||||||
|
/// that only handle the parameterless overload stay green; new drivers that need
|
||||||
|
/// filter-aware event history (OPC UA Client passthrough, future event-historian
|
||||||
|
/// backends) override this method.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The OPC UA Client driver implements this by translating <see cref="EventHistoryRequest"/>
|
||||||
|
/// into <c>ReadEventDetails</c> and calling <c>Session.HistoryReadAsync</c> against
|
||||||
|
/// the upstream server.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
Task<HistoricalEventBatch> ReadEventsAsync(
|
||||||
|
string fullReference,
|
||||||
|
EventHistoryRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
=> throw new NotSupportedException(
|
||||||
|
$"{GetType().Name} does not implement filter-aware ReadEventsAsync(EventHistoryRequest). " +
|
||||||
|
"Drivers whose backends carry historical events with EventFilter support override this method.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filter spec for the filter-aware <see cref="IHistoryProvider.ReadEventsAsync(string, EventHistoryRequest, CancellationToken)"/>
|
||||||
|
/// overload. Mirrors the OPC UA <c>ReadEventDetails</c> wire shape (StartTime, EndTime,
|
||||||
|
/// NumValuesPerNode, EventFilter) but transport-neutral so non-UA drivers can implement it
|
||||||
|
/// without taking a dependency on the UA SDK type.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="StartTime">Inclusive lower bound on event time.</param>
|
||||||
|
/// <param name="EndTime">Exclusive upper bound on event time.</param>
|
||||||
|
/// <param name="NumValuesPerNode">Maximum events per node (0 = no driver-side cap, server may still apply one).</param>
|
||||||
|
/// <param name="SelectClauses">
|
||||||
|
/// Per-field projection. Each entry names a BaseEventType-rooted field (or a
|
||||||
|
/// typed-path field via <see cref="SimpleAttributeSpec.TypeDefinitionId"/>) the caller
|
||||||
|
/// wants returned. <c>null</c> means "use the driver's default field set" — typically
|
||||||
|
/// EventId, SourceName, Time, Message, Severity, ReceiveTime.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="WhereClause">
|
||||||
|
/// Optional content-filter restriction (e.g. <c>EventType OfType AlarmConditionType</c>).
|
||||||
|
/// Drivers may ignore the where clause if their backend doesn't support it; that's a
|
||||||
|
/// best-effort projection rather than a hard error.
|
||||||
|
/// </param>
|
||||||
|
public sealed record EventHistoryRequest(
|
||||||
|
DateTime StartTime,
|
||||||
|
DateTime EndTime,
|
||||||
|
uint NumValuesPerNode,
|
||||||
|
IReadOnlyList<SimpleAttributeSpec>? SelectClauses,
|
||||||
|
ContentFilterSpec? WhereClause);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transport-neutral mirror of OPC UA's <c>SimpleAttributeOperand</c> — picks one field
|
||||||
|
/// from a node by typed browse path. <see cref="TypeDefinitionId"/> is the OPC UA NodeId
|
||||||
|
/// of the type that the path is rooted at (e.g. <c>BaseEventType</c>); <see cref="BrowsePath"/>
|
||||||
|
/// is a sequence of QualifiedName-style segments (<c>"ns:Name"</c> or just <c>"Name"</c>
|
||||||
|
/// when ns=0). An empty <see cref="BrowsePath"/> means "the node itself".
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="TypeDefinitionId">
|
||||||
|
/// Type the path is rooted at. <c>null</c> defaults to the OPC UA <c>BaseEventType</c>
|
||||||
|
/// when the driver has a UA mapping. Format is driver-specific NodeId text (e.g.
|
||||||
|
/// <c>"i=2041"</c> for BaseEventType).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="BrowsePath">Browse-path segments. Empty list = the typed node itself.</param>
|
||||||
|
/// <param name="FieldName">
|
||||||
|
/// Stable key the driver uses when populating <see cref="HistoricalEventRow.Fields"/>. The
|
||||||
|
/// server-side dispatcher uses this to align the returned values with the wire-side
|
||||||
|
/// SelectClause order, even when a driver doesn't honour the BrowsePath verbatim.
|
||||||
|
/// </param>
|
||||||
|
public sealed record SimpleAttributeSpec(
|
||||||
|
string? TypeDefinitionId,
|
||||||
|
IReadOnlyList<string> BrowsePath,
|
||||||
|
string FieldName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transport-neutral mirror of OPC UA's <c>ContentFilter</c>. The current shape carries the
|
||||||
|
/// raw filter operands as opaque OPC UA <c>ExtensionObject</c> bytes — drivers that need to
|
||||||
|
/// evaluate the filter (Galaxy historian) parse it themselves; the OPC UA Client driver
|
||||||
|
/// forwards it untouched. A future PR may replace this with a structured AST when more
|
||||||
|
/// than one driver needs to evaluate where-clauses locally.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="EncodedOperands">
|
||||||
|
/// Optional binary-encoded <c>ContentFilter</c> from the wire. <c>null</c> when no
|
||||||
|
/// where-clause was supplied.
|
||||||
|
/// </param>
|
||||||
|
public sealed record ContentFilterSpec(byte[]? EncodedOperands);
|
||||||
|
|
||||||
/// <summary>Result of a HistoryRead call.</summary>
|
/// <summary>Result of a HistoryRead call.</summary>
|
||||||
/// <param name="Samples">Returned samples in chronological order.</param>
|
/// <param name="Samples">Returned samples in chronological order.</param>
|
||||||
/// <param name="ContinuationPoint">Opaque token for the next call when more samples are available; null when complete.</param>
|
/// <param name="ContinuationPoint">Opaque token for the next call when more samples are available; null when complete.</param>
|
||||||
@@ -85,20 +184,116 @@ public sealed record HistoryReadResult(
|
|||||||
IReadOnlyList<DataValueSnapshot> Samples,
|
IReadOnlyList<DataValueSnapshot> Samples,
|
||||||
byte[]? ContinuationPoint);
|
byte[]? ContinuationPoint);
|
||||||
|
|
||||||
/// <summary>Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates.</summary>
|
/// <summary>
|
||||||
|
/// Aggregate function for processed history reads. Mirrors the OPC UA Part 13 §5
|
||||||
|
/// standard aggregate catalog. Each value maps 1:1 onto an
|
||||||
|
/// <c>Opc.Ua.ObjectIds.AggregateFunction_*</c> NodeId — the OPC UA Client driver does the
|
||||||
|
/// translation in <c>OpcUaClientDriver.MapAggregateToNodeId</c>; other drivers either
|
||||||
|
/// evaluate the aggregate locally (Galaxy historian) or surface
|
||||||
|
/// <c>BadAggregateNotSupported</c> for the values their backend can't honour.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Stable ordinals.</b> The first 5 values (<see cref="Average"/>..<see cref="Count"/>)
|
||||||
|
/// carry ordinals 0-4 from the original PR — additions are appended to keep prior
|
||||||
|
/// persisted enums (config files, Admin UI dropdowns) compatible.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Server-side support.</b> Not every upstream OPC UA server implements every
|
||||||
|
/// Part 13 aggregate. Implementations advertise their support through
|
||||||
|
/// <c>AggregateConfiguration</c> on the Server object; clients can probe it at runtime.
|
||||||
|
/// Aggregates that the upstream rejects come back with
|
||||||
|
/// <c>StatusCode=BadAggregateNotSupported</c> on the per-row HistoryRead result —
|
||||||
|
/// the driver passes that through verbatim (cascading-quality rule, Part 11 §8).
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
public enum HistoryAggregateType
|
public enum HistoryAggregateType
|
||||||
{
|
{
|
||||||
|
// ---- Original 5 (ordinals 0-4 — keep stable) ----
|
||||||
|
/// <summary>Average of all values in the interval. Part 13 §5.4.</summary>
|
||||||
Average,
|
Average,
|
||||||
|
/// <summary>Minimum value in the interval. Part 13 §5.5.</summary>
|
||||||
Minimum,
|
Minimum,
|
||||||
|
/// <summary>Maximum value in the interval. Part 13 §5.6.</summary>
|
||||||
Maximum,
|
Maximum,
|
||||||
|
/// <summary>Sum of values in the interval (numeric only). Part 13 §5.10.</summary>
|
||||||
Total,
|
Total,
|
||||||
|
/// <summary>Count of Good-quality samples in the interval. Part 13 §5.18.</summary>
|
||||||
Count,
|
Count,
|
||||||
|
|
||||||
|
// ---- Time-weighted averages (Part 13 §5.4) ----
|
||||||
|
/// <summary>Time-weighted average — values held until next sample. Part 13 §5.4.2.</summary>
|
||||||
|
TimeAverage,
|
||||||
|
/// <summary>Time-weighted average using simple-bounds extrapolation. Part 13 §5.4.3.</summary>
|
||||||
|
TimeAverage2,
|
||||||
|
|
||||||
|
// ---- Interpolation (Part 13 §5.3) ----
|
||||||
|
/// <summary>Interpolated value at each interval boundary. Part 13 §5.3.</summary>
|
||||||
|
Interpolative,
|
||||||
|
|
||||||
|
// ---- Min/Max with timestamps and range (Part 13 §5.5–§5.7) ----
|
||||||
|
/// <summary>Timestamp of the minimum-value sample. Part 13 §5.5.4.</summary>
|
||||||
|
MinimumActualTime,
|
||||||
|
/// <summary>Timestamp of the maximum-value sample. Part 13 §5.6.4.</summary>
|
||||||
|
MaximumActualTime,
|
||||||
|
/// <summary>Maximum minus minimum across the interval. Part 13 §5.7.</summary>
|
||||||
|
Range,
|
||||||
|
/// <summary>Range computed using simple-bounds extrapolation. Part 13 §5.7.</summary>
|
||||||
|
Range2,
|
||||||
|
|
||||||
|
// ---- Annotation / duration / quality coverage (Part 13 §5.16–§5.21) ----
|
||||||
|
/// <summary>Number of annotations attached to samples in the interval. Part 13 §5.21.</summary>
|
||||||
|
AnnotationCount,
|
||||||
|
/// <summary>Total time (ms) covered by Good-quality data. Part 13 §5.16.</summary>
|
||||||
|
DurationGood,
|
||||||
|
/// <summary>Total time (ms) covered by Bad-quality data. Part 13 §5.16.</summary>
|
||||||
|
DurationBad,
|
||||||
|
/// <summary>Percent of the interval covered by Good-quality data (0-100). Part 13 §5.17.</summary>
|
||||||
|
PercentGood,
|
||||||
|
/// <summary>Percent of the interval covered by Bad-quality data (0-100). Part 13 §5.17.</summary>
|
||||||
|
PercentBad,
|
||||||
|
/// <summary>Worst (most-severe) quality code seen in the interval. Part 13 §5.20.</summary>
|
||||||
|
WorstQuality,
|
||||||
|
/// <summary>Worst-quality code using simple-bounds extrapolation. Part 13 §5.20.</summary>
|
||||||
|
WorstQuality2,
|
||||||
|
|
||||||
|
// ---- Statistical (Part 13 §5.13) ----
|
||||||
|
/// <summary>Sample-population standard deviation (n-1 divisor). Part 13 §5.13.</summary>
|
||||||
|
StandardDeviationSample,
|
||||||
|
/// <summary>Whole-population standard deviation (n divisor). Part 13 §5.13.</summary>
|
||||||
|
StandardDeviationPopulation,
|
||||||
|
/// <summary>Sample-population variance (n-1 divisor). Part 13 §5.13.</summary>
|
||||||
|
VarianceSample,
|
||||||
|
/// <summary>Whole-population variance (n divisor). Part 13 §5.13.</summary>
|
||||||
|
VariancePopulation,
|
||||||
|
|
||||||
|
// ---- State-based (Part 13 §5.12, §5.19) ----
|
||||||
|
/// <summary>Number of value transitions observed in the interval. Part 13 §5.12.</summary>
|
||||||
|
NumberOfTransitions,
|
||||||
|
/// <summary>Total time (ms) the value was 0 (state Zero). Part 13 §5.19.</summary>
|
||||||
|
DurationInStateZero,
|
||||||
|
/// <summary>Total time (ms) the value was non-zero (state NonZero). Part 13 §5.19.</summary>
|
||||||
|
DurationInStateNonZero,
|
||||||
|
|
||||||
|
// ---- Interval bounds and deltas (Part 13 §5.8–§5.9, §5.11) ----
|
||||||
|
/// <summary>First Good-quality sample at or after the interval start. Part 13 §5.8.</summary>
|
||||||
|
Start,
|
||||||
|
/// <summary>Last Good-quality sample at or before the interval end. Part 13 §5.9.</summary>
|
||||||
|
End,
|
||||||
|
/// <summary>End sample minus Start sample. Part 13 §5.11.</summary>
|
||||||
|
Delta,
|
||||||
|
/// <summary>Boundary value (extrapolated) at the interval start. Part 13 §5.8.</summary>
|
||||||
|
StartBound,
|
||||||
|
/// <summary>Boundary value (extrapolated) at the interval end. Part 13 §5.9.</summary>
|
||||||
|
EndBound,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One row returned by <see cref="IHistoryProvider.ReadEventsAsync"/> — a historical
|
/// One row returned by the fixed-field
|
||||||
/// alarm/event record, not the OPC UA live-event stream. Fields match the minimum set the
|
/// <see cref="IHistoryProvider.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/>
|
||||||
/// Server needs to populate a <c>HistoryEventFieldList</c> for HistoryReadEvents responses.
|
/// overload — a historical alarm/event record, not the OPC UA live-event stream. Fields
|
||||||
|
/// match the minimum set the Server needs to populate a <c>HistoryEventFieldList</c>
|
||||||
|
/// for HistoryReadEvents responses.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="EventId">Stable unique id for the event — driver-specific format.</param>
|
/// <param name="EventId">Stable unique id for the event — driver-specific format.</param>
|
||||||
/// <param name="SourceName">Source object that emitted the event. May differ from the <c>sourceName</c> filter the caller passed (fuzzy matches).</param>
|
/// <param name="SourceName">Source object that emitted the event. May differ from the <c>sourceName</c> filter the caller passed (fuzzy matches).</param>
|
||||||
@@ -114,9 +309,46 @@ public sealed record HistoricalEvent(
|
|||||||
string? Message,
|
string? Message,
|
||||||
ushort Severity);
|
ushort Severity);
|
||||||
|
|
||||||
/// <summary>Result of a <see cref="IHistoryProvider.ReadEventsAsync"/> call.</summary>
|
/// <summary>Result of a <see cref="IHistoryProvider.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/> call.</summary>
|
||||||
/// <param name="Events">Events in chronological order by <c>EventTimeUtc</c>.</param>
|
/// <param name="Events">Events in chronological order by <c>EventTimeUtc</c>.</param>
|
||||||
/// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
|
/// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
|
||||||
public sealed record HistoricalEventsResult(
|
public sealed record HistoricalEventsResult(
|
||||||
IReadOnlyList<HistoricalEvent> Events,
|
IReadOnlyList<HistoricalEvent> Events,
|
||||||
byte[]? ContinuationPoint);
|
byte[]? ContinuationPoint);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row returned by the filter-aware
|
||||||
|
/// <see cref="IHistoryProvider.ReadEventsAsync(string, EventHistoryRequest, CancellationToken)"/>
|
||||||
|
/// overload. Carries an open-ended <see cref="Fields"/> bag keyed by
|
||||||
|
/// <see cref="SimpleAttributeSpec.FieldName"/> (or a stable default name when no
|
||||||
|
/// SelectClauses were supplied) so the server-side dispatcher can re-align fields with
|
||||||
|
/// the client's requested order — without forcing every driver to honour the entire
|
||||||
|
/// OPC UA EventFilter shape verbatim.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Fields">
|
||||||
|
/// SelectClause results. Keys match the <c>FieldName</c> on the corresponding
|
||||||
|
/// <see cref="SimpleAttributeSpec"/>; values are the raw .NET payload (string,
|
||||||
|
/// <c>DateTime</c>, severity int, etc.). <c>null</c> values are legitimate (the
|
||||||
|
/// upstream had a missing field).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="OccurrenceTime">
|
||||||
|
/// Wall-clock event time — convenience for ordering / windowing without picking a key
|
||||||
|
/// out of <see cref="Fields"/>. Drivers populate this from the underlying event row.
|
||||||
|
/// </param>
|
||||||
|
public sealed record HistoricalEventRow(
|
||||||
|
IReadOnlyDictionary<string, object?> Fields,
|
||||||
|
DateTimeOffset OccurrenceTime);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of the filter-aware
|
||||||
|
/// <see cref="IHistoryProvider.ReadEventsAsync(string, EventHistoryRequest, CancellationToken)"/>
|
||||||
|
/// overload. Mirrors <see cref="HistoricalEventsResult"/> but carries
|
||||||
|
/// <see cref="HistoricalEventRow"/> instead of the fixed-shape
|
||||||
|
/// <see cref="HistoricalEvent"/> — the server-side dispatcher unpacks the keyed fields
|
||||||
|
/// into a <c>HistoryEventFieldList</c> aligned with the client's SelectClauses.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Events">Events in chronological order by <see cref="HistoricalEventRow.OccurrenceTime"/>.</param>
|
||||||
|
/// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
|
||||||
|
public sealed record HistoricalEventBatch(
|
||||||
|
IReadOnlyList<HistoricalEventRow> Events,
|
||||||
|
byte[]? ContinuationPoint);
|
||||||
|
|||||||
@@ -38,4 +38,16 @@ public sealed record HostStatusChangedEventArgs(
|
|||||||
HostState NewState);
|
HostState NewState);
|
||||||
|
|
||||||
/// <summary>Host lifecycle state. Generalization of Galaxy's Platform/Engine ScanState.</summary>
|
/// <summary>Host lifecycle state. Generalization of Galaxy's Platform/Engine ScanState.</summary>
|
||||||
public enum HostState { Unknown, Running, Stopped, Faulted }
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Demoted"/> (PR ablegacy-12 / #255) is a soft-stopped state used by drivers
|
||||||
|
/// that auto-throttle a host after N consecutive comm failures. Reads are short-circuited
|
||||||
|
/// with <c>BadCommunicationError</c> for a configurable cool-down window so one slow PLC
|
||||||
|
/// doesn't starve faster peers sharing the same driver. Demoted is *not* the same as
|
||||||
|
/// <see cref="Stopped"/> (which means "probe says it's down") nor <see cref="Faulted"/>
|
||||||
|
/// (which means "the driver itself is broken"); it's a deliberate driver-side back-off.
|
||||||
|
/// Consumers that don't recognize <c>Demoted</c> can safely treat it as <c>Stopped</c>
|
||||||
|
/// (see <c>HostStatusPublisher.MapState</c>).
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public enum HostState { Unknown, Running, Stopped, Faulted, Demoted }
|
||||||
|
|||||||
82
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IMethodInvoker.cs
Normal file
82
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IMethodInvoker.cs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver capability for invoking OPC UA Methods on the upstream backend (the OPC UA
|
||||||
|
/// <c>Call</c> service). Optional — only drivers whose backends carry method nodes
|
||||||
|
/// implement it. Currently the OPC UA Client driver is the only implementer; tag-based
|
||||||
|
/// drivers (Modbus, S7, FOCAS, Galaxy, AB-CIP, AB-Legacy, TwinCAT) don't expose method
|
||||||
|
/// nodes so they don't need this surface.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Per <c>docs/v2/plan.md</c> decision #4 (composable capability interfaces) — the
|
||||||
|
/// server-side <c>DriverNodeManager</c> discovers method-bearing drivers via an
|
||||||
|
/// <c>is IMethodInvoker</c> check and routes <c>OnCallMethod</c> handlers to
|
||||||
|
/// <see cref="CallMethodAsync"/>. Drivers that don't implement the interface simply
|
||||||
|
/// never have method nodes registered for them.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The address-space mirror is driven by <see cref="IAddressSpaceBuilder.RegisterMethodNode"/>
|
||||||
|
/// — drivers register the method node + its <c>InputArguments</c> /
|
||||||
|
/// <c>OutputArguments</c> properties during discovery, then invocations land back on
|
||||||
|
/// <see cref="CallMethodAsync"/> via the server-side dispatcher.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public interface IMethodInvoker
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Invoke an upstream OPC UA Method. The driver translates input arguments into the
|
||||||
|
/// wire-level <c>CallMethodRequest</c>, dispatches via the active session, and packs
|
||||||
|
/// the response back into a <see cref="MethodCallResult"/>. Per-argument validation
|
||||||
|
/// errors flow through <see cref="MethodCallResult.InputArgumentResults"/>; method-level
|
||||||
|
/// errors (<c>BadMethodInvalid</c>, <c>BadUserAccessDenied</c>, etc.) flow through
|
||||||
|
/// <see cref="MethodCallResult.StatusCode"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="objectNodeId">
|
||||||
|
/// Stringified NodeId of the OPC UA Object that owns the method (the <c>ObjectId</c>
|
||||||
|
/// field of <c>CallMethodRequest</c>). Same serialization as <c>IReadable</c>'s
|
||||||
|
/// <c>fullReference</c> — <c>ns=2;s=…</c> / <c>i=…</c> / <c>nsu=…;…</c>.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="methodNodeId">
|
||||||
|
/// Stringified NodeId of the Method node itself (the <c>MethodId</c> field).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="inputs">
|
||||||
|
/// Input arguments in declaration order. The driver wraps each value as a
|
||||||
|
/// <c>Variant</c>; callers pass CLR primitives (plus arrays) — the wire-level
|
||||||
|
/// encoding is the driver's concern.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="cancellationToken">Per-call cancellation.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// Result of the call — see <see cref="MethodCallResult"/>. Never throws for a
|
||||||
|
/// <c>Bad</c> upstream status; the bad code is surfaced via the result so the caller
|
||||||
|
/// can map it onto an OPC UA service-result for downstream clients.
|
||||||
|
/// </returns>
|
||||||
|
Task<MethodCallResult> CallMethodAsync(
|
||||||
|
string objectNodeId,
|
||||||
|
string methodNodeId,
|
||||||
|
object[] inputs,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of a single OPC UA <c>Call</c> service invocation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="StatusCode">
|
||||||
|
/// Method-level status. <c>0</c> = Good. Bad codes pass through verbatim from the
|
||||||
|
/// upstream so downstream clients see the canonical OPC UA error (e.g.
|
||||||
|
/// <c>BadMethodInvalid</c>, <c>BadUserAccessDenied</c>, <c>BadArgumentsMissing</c>).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Outputs">
|
||||||
|
/// Output argument values in declaration order. <c>null</c> when the upstream returned
|
||||||
|
/// no output arguments (or returned a Bad status before producing any).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="InputArgumentResults">
|
||||||
|
/// Per-input-argument status codes. <c>null</c> when the upstream didn't surface
|
||||||
|
/// per-argument validation results (typical for Good calls). Each entry is the OPC UA
|
||||||
|
/// status code for the matching input argument — drivers can use this to surface
|
||||||
|
/// <c>BadTypeMismatch</c>, <c>BadOutOfRange</c>, etc. on a specific argument.
|
||||||
|
/// </param>
|
||||||
|
public sealed record MethodCallResult(
|
||||||
|
uint StatusCode,
|
||||||
|
object[]? Outputs,
|
||||||
|
uint[]? InputArgumentResults);
|
||||||
@@ -20,7 +20,29 @@ public interface ISubscribable
|
|||||||
TimeSpan publishingInterval,
|
TimeSpan publishingInterval,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Cancel a subscription returned by <see cref="SubscribeAsync"/>.</summary>
|
/// <summary>
|
||||||
|
/// Subscribe to data changes with per-tag advanced tuning (sampling interval, queue
|
||||||
|
/// size, monitoring mode, deadband filter). Drivers that don't have a native concept
|
||||||
|
/// of these knobs (e.g. polled drivers like Modbus) MAY ignore the per-tag knobs and
|
||||||
|
/// delegate to the simple
|
||||||
|
/// <see cref="SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/>
|
||||||
|
/// overload — the default implementation does exactly that, so existing implementers
|
||||||
|
/// compile unchanged.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tags">Per-tag subscription specs. <see cref="MonitoredTagSpec.TagName"/> is the driver-side full reference.</param>
|
||||||
|
/// <param name="publishingInterval">Subscription publishing interval, applied to the whole batch.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation.</param>
|
||||||
|
/// <returns>Opaque subscription handle for <see cref="UnsubscribeAsync"/>.</returns>
|
||||||
|
Task<ISubscriptionHandle> SubscribeAsync(
|
||||||
|
IReadOnlyList<MonitoredTagSpec> tags,
|
||||||
|
TimeSpan publishingInterval,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
=> SubscribeAsync(
|
||||||
|
tags.Select(t => t.TagName).ToList(),
|
||||||
|
publishingInterval,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Cancel a subscription returned by either <c>SubscribeAsync</c> overload.</summary>
|
||||||
Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken);
|
Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -30,7 +52,7 @@ public interface ISubscribable
|
|||||||
event EventHandler<DataChangeEventArgs>? OnDataChange;
|
event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Opaque subscription identity returned by <see cref="ISubscribable.SubscribeAsync"/>.</summary>
|
/// <summary>Opaque subscription identity returned by <see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/>.</summary>
|
||||||
public interface ISubscriptionHandle
|
public interface ISubscriptionHandle
|
||||||
{
|
{
|
||||||
/// <summary>Driver-internal subscription identifier (for diagnostics + post-mortem).</summary>
|
/// <summary>Driver-internal subscription identifier (for diagnostics + post-mortem).</summary>
|
||||||
@@ -38,10 +60,99 @@ public interface ISubscriptionHandle
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Event payload for <see cref="ISubscribable.OnDataChange"/>.</summary>
|
/// <summary>Event payload for <see cref="ISubscribable.OnDataChange"/>.</summary>
|
||||||
/// <param name="SubscriptionHandle">The handle returned by the original <see cref="ISubscribable.SubscribeAsync"/> call.</param>
|
/// <param name="SubscriptionHandle">The handle returned by the original <see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/> call.</param>
|
||||||
/// <param name="FullReference">Driver-side full reference of the changed attribute.</param>
|
/// <param name="FullReference">Driver-side full reference of the changed attribute.</param>
|
||||||
/// <param name="Snapshot">New value + quality + timestamps.</param>
|
/// <param name="Snapshot">New value + quality + timestamps.</param>
|
||||||
public sealed record DataChangeEventArgs(
|
public sealed record DataChangeEventArgs(
|
||||||
ISubscriptionHandle SubscriptionHandle,
|
ISubscriptionHandle SubscriptionHandle,
|
||||||
string FullReference,
|
string FullReference,
|
||||||
DataValueSnapshot Snapshot);
|
DataValueSnapshot Snapshot);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-tag subscription tuning. Maps onto OPC UA <c>MonitoredItem</c> properties for the
|
||||||
|
/// OpcUaClient driver; non-OPC-UA drivers either map a subset (e.g. ADS picks up
|
||||||
|
/// <see cref="SamplingIntervalMs"/>) or ignore the knobs entirely and fall back to the
|
||||||
|
/// simple <see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="TagName">Driver-side full reference (e.g. <c>ns=2;s=Foo</c> for OPC UA).</param>
|
||||||
|
/// <param name="SamplingIntervalMs">
|
||||||
|
/// Server-side sampling rate in milliseconds. <c>null</c> = use the publishing interval.
|
||||||
|
/// Sub-publish-interval values let a server sample faster than it publishes (queue +
|
||||||
|
/// coalesce), useful for events that change between publish ticks.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="QueueSize">Server-side notification queue depth. <c>null</c> = driver default (1).</param>
|
||||||
|
/// <param name="DiscardOldest">
|
||||||
|
/// When the server-side queue overflows: <c>true</c> drops oldest, <c>false</c> drops newest.
|
||||||
|
/// <c>null</c> = driver default (true — preserve recency).
|
||||||
|
/// </param>
|
||||||
|
/// <param name="MonitoringMode">
|
||||||
|
/// Per-item monitoring mode. <c>Reporting</c> = sample + publish, <c>Sampling</c> = sample
|
||||||
|
/// but suppress publishing (useful with triggering), <c>Disabled</c> = neither.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="DataChangeFilter">
|
||||||
|
/// Optional data-change filter (deadband + trigger semantics). <c>null</c> = no filter
|
||||||
|
/// (every change publishes regardless of magnitude).
|
||||||
|
/// </param>
|
||||||
|
public sealed record MonitoredTagSpec(
|
||||||
|
string TagName,
|
||||||
|
double? SamplingIntervalMs = null,
|
||||||
|
uint? QueueSize = null,
|
||||||
|
bool? DiscardOldest = null,
|
||||||
|
SubscriptionMonitoringMode? MonitoringMode = null,
|
||||||
|
DataChangeFilterSpec? DataChangeFilter = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OPC UA <c>DataChangeFilter</c> spec. Mirrors the OPC UA Part 4 §7.17.2 structure but
|
||||||
|
/// lives in Core.Abstractions so non-OpcUaClient drivers (e.g. Modbus, S7) can accept it
|
||||||
|
/// as metadata even if they ignore the deadband mechanics.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Trigger">When to fire: status only / status+value / status+value+timestamp.</param>
|
||||||
|
/// <param name="DeadbandType">Deadband mode: none / absolute (engineering units) / percent of EURange.</param>
|
||||||
|
/// <param name="DeadbandValue">
|
||||||
|
/// Magnitude of the deadband. For <see cref="OtOpcUa.Core.Abstractions.DeadbandType.Absolute"/>
|
||||||
|
/// this is in the variable's engineering units; for <see cref="OtOpcUa.Core.Abstractions.DeadbandType.Percent"/>
|
||||||
|
/// it's a 0..100 percentage of EURange (server returns BadFilterNotAllowed if EURange isn't set).
|
||||||
|
/// </param>
|
||||||
|
public sealed record DataChangeFilterSpec(
|
||||||
|
DataChangeTrigger Trigger,
|
||||||
|
DeadbandType DeadbandType,
|
||||||
|
double DeadbandValue);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OPC UA <c>DataChangeTrigger</c> values. Wraps the SDK enum so Core.Abstractions doesn't
|
||||||
|
/// leak an OPC-UA-stack reference into every driver project.
|
||||||
|
/// </summary>
|
||||||
|
public enum DataChangeTrigger
|
||||||
|
{
|
||||||
|
/// <summary>Fire only when StatusCode changes.</summary>
|
||||||
|
Status = 0,
|
||||||
|
/// <summary>Fire when StatusCode or Value changes (the OPC UA default).</summary>
|
||||||
|
StatusValue = 1,
|
||||||
|
/// <summary>Fire when StatusCode, Value, or SourceTimestamp changes.</summary>
|
||||||
|
StatusValueTimestamp = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>OPC UA deadband-filter modes.</summary>
|
||||||
|
public enum DeadbandType
|
||||||
|
{
|
||||||
|
/// <summary>No deadband — every value change publishes.</summary>
|
||||||
|
None = 0,
|
||||||
|
/// <summary>Deadband expressed in the variable's engineering units.</summary>
|
||||||
|
Absolute = 1,
|
||||||
|
/// <summary>Deadband expressed as 0..100 percent of the variable's EURange.</summary>
|
||||||
|
Percent = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-item subscription monitoring mode. Wraps the OPC UA SDK's <c>MonitoringMode</c>
|
||||||
|
/// so Core.Abstractions stays SDK-free.
|
||||||
|
/// </summary>
|
||||||
|
public enum SubscriptionMonitoringMode
|
||||||
|
{
|
||||||
|
/// <summary>Item is created but neither sampling nor publishing.</summary>
|
||||||
|
Disabled = 0,
|
||||||
|
/// <summary>Item samples and queues but does not publish (useful with triggering).</summary>
|
||||||
|
Sampling = 1,
|
||||||
|
/// <summary>Item samples and publishes — the OPC UA default.</summary>
|
||||||
|
Reporting = 2,
|
||||||
|
}
|
||||||
|
|||||||
64
src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs
Normal file
64
src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Process-singleton registry of <see cref="IDriver"/> factories keyed by
|
||||||
|
/// <c>DriverInstance.DriverType</c> string. Each driver project ships a DI
|
||||||
|
/// extension (e.g. <c>services.AddGalaxyProxyDriverFactory()</c>) that registers
|
||||||
|
/// its factory at startup; the bootstrapper looks up the factory by
|
||||||
|
/// <c>DriverInstance.DriverType</c> + invokes it with the row's
|
||||||
|
/// <c>DriverInstanceId</c> + <c>DriverConfig</c> JSON.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Closes the gap surfaced by task #240 live smoke — DriverInstance rows in
|
||||||
|
/// the central config DB had no path to materialise as registered <see cref="IDriver"/>
|
||||||
|
/// instances. The factory registry is the seam.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class DriverFactoryRegistry
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, Func<string, string, IDriver>> _factories
|
||||||
|
= new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register a factory for <paramref name="driverType"/>. Throws if a factory is
|
||||||
|
/// already registered for that type — drivers are singletons by type-name in
|
||||||
|
/// this process.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="driverType">Matches <c>DriverInstance.DriverType</c>.</param>
|
||||||
|
/// <param name="factory">
|
||||||
|
/// Receives <c>(driverInstanceId, driverConfigJson)</c>; returns a new
|
||||||
|
/// <see cref="IDriver"/>. Must NOT call <see cref="IDriver.InitializeAsync"/>
|
||||||
|
/// itself — the bootstrapper calls it via <see cref="DriverHost.RegisterAsync"/>
|
||||||
|
/// so the host's per-driver retry semantics apply uniformly.
|
||||||
|
/// </param>
|
||||||
|
public void Register(string driverType, Func<string, string, IDriver> factory)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
||||||
|
ArgumentNullException.ThrowIfNull(factory);
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_factories.ContainsKey(driverType))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"DriverType '{driverType}' factory already registered for this process");
|
||||||
|
_factories[driverType] = factory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Try to look up the factory for <paramref name="driverType"/>. Returns null
|
||||||
|
/// if no driver assembly registered one — bootstrapper logs + skips so a
|
||||||
|
/// missing-assembly deployment doesn't take down the whole server.
|
||||||
|
/// </summary>
|
||||||
|
public Func<string, string, IDriver>? TryGet(string driverType)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
||||||
|
lock (_lock) return _factories.GetValueOrDefault(driverType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyCollection<string> RegisteredTypes
|
||||||
|
{
|
||||||
|
get { lock (_lock) return [.. _factories.Keys]; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,6 +87,16 @@ public static class EquipmentNodeWalker
|
|||||||
.GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase)
|
.GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var virtualTagsByEquipment = (content.VirtualTags ?? [])
|
||||||
|
.Where(v => v.Enabled)
|
||||||
|
.GroupBy(v => v.EquipmentId, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(g => g.Key, g => g.OrderBy(v => v.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var scriptedAlarmsByEquipment = (content.ScriptedAlarms ?? [])
|
||||||
|
.Where(a => a.Enabled)
|
||||||
|
.GroupBy(a => a.EquipmentId, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(g => g.Key, g => g.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
foreach (var area in content.Areas.OrderBy(a => a.Name, StringComparer.Ordinal))
|
foreach (var area in content.Areas.OrderBy(a => a.Name, StringComparer.Ordinal))
|
||||||
{
|
{
|
||||||
var areaBuilder = namespaceBuilder.Folder(area.Name, area.Name);
|
var areaBuilder = namespaceBuilder.Folder(area.Name, area.Name);
|
||||||
@@ -103,9 +113,17 @@ public static class EquipmentNodeWalker
|
|||||||
AddIdentifierProperties(equipmentBuilder, equipment);
|
AddIdentifierProperties(equipmentBuilder, equipment);
|
||||||
IdentificationFolderBuilder.Build(equipmentBuilder, equipment);
|
IdentificationFolderBuilder.Build(equipmentBuilder, equipment);
|
||||||
|
|
||||||
if (!tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags)) continue;
|
if (tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags))
|
||||||
foreach (var tag in equipmentTags)
|
foreach (var tag in equipmentTags)
|
||||||
AddTagVariable(equipmentBuilder, tag);
|
AddTagVariable(equipmentBuilder, tag);
|
||||||
|
|
||||||
|
if (virtualTagsByEquipment.TryGetValue(equipment.EquipmentId, out var vTags))
|
||||||
|
foreach (var vtag in vTags)
|
||||||
|
AddVirtualTagVariable(equipmentBuilder, vtag);
|
||||||
|
|
||||||
|
if (scriptedAlarmsByEquipment.TryGetValue(equipment.EquipmentId, out var alarms))
|
||||||
|
foreach (var alarm in alarms)
|
||||||
|
AddScriptedAlarmVariable(equipmentBuilder, alarm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,6 +175,55 @@ public static class EquipmentNodeWalker
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static DriverDataType ParseDriverDataType(string raw) =>
|
private static DriverDataType ParseDriverDataType(string raw) =>
|
||||||
Enum.TryParse<DriverDataType>(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String;
|
Enum.TryParse<DriverDataType>(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emit a <see cref="VirtualTag"/> row as a <see cref="NodeSourceKind.Virtual"/>
|
||||||
|
/// variable node. <c>FullName</c> doubles as the UNS path Phase 7's VirtualTagEngine
|
||||||
|
/// addresses its engine-side entries by. The <c>VirtualTagId</c> discriminator lets
|
||||||
|
/// the DriverNodeManager dispatch Reads/Subscribes to the engine rather than any
|
||||||
|
/// driver.
|
||||||
|
/// </summary>
|
||||||
|
private static void AddVirtualTagVariable(IAddressSpaceBuilder equipmentBuilder, VirtualTag vtag)
|
||||||
|
{
|
||||||
|
var attr = new DriverAttributeInfo(
|
||||||
|
FullName: vtag.VirtualTagId,
|
||||||
|
DriverDataType: ParseDriverDataType(vtag.DataType),
|
||||||
|
IsArray: false,
|
||||||
|
ArrayDim: null,
|
||||||
|
SecurityClass: SecurityClassification.FreeAccess,
|
||||||
|
IsHistorized: vtag.Historize,
|
||||||
|
IsAlarm: false,
|
||||||
|
WriteIdempotent: false,
|
||||||
|
Source: NodeSourceKind.Virtual,
|
||||||
|
VirtualTagId: vtag.VirtualTagId,
|
||||||
|
ScriptedAlarmId: null);
|
||||||
|
equipmentBuilder.Variable(vtag.Name, vtag.Name, attr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emit a <see cref="ScriptedAlarm"/> row as a <see cref="NodeSourceKind.ScriptedAlarm"/>
|
||||||
|
/// variable node. The OPC UA Part 9 alarm-condition materialization happens at the
|
||||||
|
/// node-manager level (which wires the concrete <c>AlarmConditionState</c> subclass
|
||||||
|
/// per <see cref="ScriptedAlarm.AlarmType"/>); this walker provides the browse-level
|
||||||
|
/// anchor + the <see cref="DriverAttributeInfo.IsAlarm"/> flag that triggers that
|
||||||
|
/// materialization path.
|
||||||
|
/// </summary>
|
||||||
|
private static void AddScriptedAlarmVariable(IAddressSpaceBuilder equipmentBuilder, ScriptedAlarm alarm)
|
||||||
|
{
|
||||||
|
var attr = new DriverAttributeInfo(
|
||||||
|
FullName: alarm.ScriptedAlarmId,
|
||||||
|
DriverDataType: DriverDataType.Boolean,
|
||||||
|
IsArray: false,
|
||||||
|
ArrayDim: null,
|
||||||
|
SecurityClass: SecurityClassification.FreeAccess,
|
||||||
|
IsHistorized: false,
|
||||||
|
IsAlarm: true,
|
||||||
|
WriteIdempotent: false,
|
||||||
|
Source: NodeSourceKind.ScriptedAlarm,
|
||||||
|
VirtualTagId: null,
|
||||||
|
ScriptedAlarmId: alarm.ScriptedAlarmId);
|
||||||
|
equipmentBuilder.Variable(alarm.Name, alarm.Name, attr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -170,4 +237,6 @@ public sealed record EquipmentNamespaceContent(
|
|||||||
IReadOnlyList<UnsArea> Areas,
|
IReadOnlyList<UnsArea> Areas,
|
||||||
IReadOnlyList<UnsLine> Lines,
|
IReadOnlyList<UnsLine> Lines,
|
||||||
IReadOnlyList<Equipment> Equipment,
|
IReadOnlyList<Equipment> Equipment,
|
||||||
IReadOnlyList<Tag> Tags);
|
IReadOnlyList<Tag> Tags,
|
||||||
|
IReadOnlyList<VirtualTag>? VirtualTags = null,
|
||||||
|
IReadOnlyList<ScriptedAlarm>? ScriptedAlarms = null);
|
||||||
|
|||||||
96
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs
Normal file
96
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base for every AB CIP CLI command. Carries the libplctag endpoint options
|
||||||
|
/// (<c>--gateway</c> + <c>--family</c>) and exposes <see cref="BuildOptions"/> so each
|
||||||
|
/// command can synthesise an <see cref="AbCipDriverOptions"/> from CLI flags + its own
|
||||||
|
/// tag list.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class AbCipCommandBase : DriverCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("gateway", 'g', Description =
|
||||||
|
"Canonical AB CIP gateway: ab://host[:port]/cip-path. Port defaults to 44818 " +
|
||||||
|
"(EtherNet/IP). cip-path is family-specific: ControlLogix / CompactLogix need " +
|
||||||
|
"'1,0' to reach slot 0 of the CPU chassis; Micro800 takes an empty path; " +
|
||||||
|
"GuardLogix typically '1,0' same as ControlLogix.",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string Gateway { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("family", 'f', Description =
|
||||||
|
"ControlLogix / CompactLogix / Micro800 / GuardLogix (default ControlLogix).")]
|
||||||
|
public AbCipPlcFamily Family { get; init; } = AbCipPlcFamily.ControlLogix;
|
||||||
|
|
||||||
|
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||||
|
public int TimeoutMs { get; init; } = 5000;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-3.2 — pin the device's CIP addressing mode for this CLI invocation.
|
||||||
|
/// Auto / Symbolic / Logical. Defaults to <see cref="AddressingMode.Auto"/> (resolves
|
||||||
|
/// to Symbolic until a future PR plumbs auto-detection). Logical against an
|
||||||
|
/// unsupported family (Micro800) silently falls back to Symbolic with a logged
|
||||||
|
/// warning, so passing <c>--addressing-mode Logical</c> across a mixed-family
|
||||||
|
/// fleet is safe.
|
||||||
|
/// </summary>
|
||||||
|
[CommandOption("addressing-mode", Description =
|
||||||
|
"CIP addressing mode: Auto / Symbolic / Logical (default Auto, resolves to " +
|
||||||
|
"Symbolic). Logical uses CIP Symbol Object instance IDs after a one-time @tags " +
|
||||||
|
"walk; unsupported on Micro800 (silent fallback to Symbolic with warning).")]
|
||||||
|
public AddressingMode AddressingMode { get; init; } = AddressingMode.Auto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-5.1 — partner gateway URI for HSBY (Hot-Standby) paired chassis. When
|
||||||
|
/// supplied, every CLI command auto-enables HSBY role probing on the device options
|
||||||
|
/// so subcommands like <c>hsby-status</c> + diagnostics surface the active chassis
|
||||||
|
/// without extra flags. Unset for non-redundant deployments.
|
||||||
|
/// </summary>
|
||||||
|
[CommandOption("partner", Description =
|
||||||
|
"Partner gateway URI for ControlLogix HSBY pair (e.g. ab://10.0.0.6/1,0). When " +
|
||||||
|
"set, the driver runs a second role-probe loop and the hsby-status command can " +
|
||||||
|
"surface which chassis is currently Active. Optional.")]
|
||||||
|
public string? Partner { get; init; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override TimeSpan Timeout
|
||||||
|
{
|
||||||
|
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||||
|
init { /* driven by TimeoutMs */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build an <see cref="AbCipDriverOptions"/> with the device + tag list a subclass
|
||||||
|
/// supplies. Probe + alarm projection are disabled — CLI runs are one-shot; the
|
||||||
|
/// probe loop would race the operator's own reads.
|
||||||
|
/// </summary>
|
||||||
|
protected AbCipDriverOptions BuildOptions(IReadOnlyList<AbCipTagDefinition> tags) => new()
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(
|
||||||
|
HostAddress: Gateway,
|
||||||
|
PlcFamily: Family,
|
||||||
|
DeviceName: $"cli-{Family}",
|
||||||
|
AddressingMode: AddressingMode,
|
||||||
|
// PR abcip-5.1 — surface --partner through the device options so commands that
|
||||||
|
// use BuildOptions can take advantage of HSBY role probing without subclassing.
|
||||||
|
// Hsby auto-enables only when a partner was actually supplied; pre-5.1 invocations
|
||||||
|
// (no --partner) see exactly the legacy options shape.
|
||||||
|
PartnerHostAddress: Partner,
|
||||||
|
Hsby: string.IsNullOrWhiteSpace(Partner) ? null : new AbCipHsbyOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
ProbeInterval = TimeSpan.FromSeconds(2),
|
||||||
|
})],
|
||||||
|
Tags = tags,
|
||||||
|
Timeout = Timeout,
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
EnableControllerBrowse = false,
|
||||||
|
EnableAlarmProjection = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Short instance id used in Serilog output so operators running the CLI against
|
||||||
|
/// multiple gateways in parallel can distinguish the logs.
|
||||||
|
/// </summary>
|
||||||
|
protected string DriverInstanceId => $"abcip-cli-{Gateway}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-5.1 — print the current HSBY role on each chassis of a paired ControlLogix
|
||||||
|
/// ControlLogix Hot-Standby setup. Requires <c>--partner</c> on the base command +
|
||||||
|
/// reads <c>WallClockTime.SyncStatus</c> on both gateways once before printing.
|
||||||
|
/// </summary>
|
||||||
|
[Command("hsby-status", Description =
|
||||||
|
"Read the WallClockTime.SyncStatus role tag on a ControlLogix HSBY pair and print " +
|
||||||
|
"which chassis is currently Active. Requires --partner.")]
|
||||||
|
public sealed class HsbyStatusCommand : AbCipCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("role-tag", Description =
|
||||||
|
"Role-tag address. Default WallClockTime.SyncStatus matches v20+ ControlLogix HSBY; " +
|
||||||
|
"use S:34 for legacy SLC500 / PLC-5 status-byte fronts.")]
|
||||||
|
public string RoleTagAddress { get; init; } = "WallClockTime.SyncStatus";
|
||||||
|
|
||||||
|
[CommandOption("samples", Description =
|
||||||
|
"Number of role-probe ticks to wait for before printing (default 3). Larger values " +
|
||||||
|
"give the role-prober loop more chances to sample both chassis through transient " +
|
||||||
|
"transport hiccups.")]
|
||||||
|
public int Samples { get; init; } = 3;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(Partner))
|
||||||
|
{
|
||||||
|
await console.Error.WriteLineAsync(
|
||||||
|
"hsby-status requires --partner <ab://gateway/cip-path>. Without a partner the " +
|
||||||
|
"command has no second chassis to compare roles against.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override the base BuildOptions so we can pin the role-tag address + a tight probe
|
||||||
|
// interval — the default 2 s would mean Samples * 2 s before the print fires, too slow
|
||||||
|
// for an interactive CLI. Tag list stays empty; only the role probe runs.
|
||||||
|
var options = new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new AbCipDeviceOptions(
|
||||||
|
HostAddress: Gateway,
|
||||||
|
PlcFamily: Family,
|
||||||
|
DeviceName: $"cli-{Family}",
|
||||||
|
AddressingMode: AddressingMode,
|
||||||
|
PartnerHostAddress: Partner,
|
||||||
|
Hsby: new AbCipHsbyOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
RoleTagAddress = RoleTagAddress,
|
||||||
|
ProbeInterval = TimeSpan.FromMilliseconds(500),
|
||||||
|
})],
|
||||||
|
Tags = [],
|
||||||
|
Timeout = Timeout,
|
||||||
|
Probe = new AbCipProbeOptions { Enabled = false },
|
||||||
|
EnableControllerBrowse = false,
|
||||||
|
EnableAlarmProjection = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await using var driver = new AbCipDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
|
||||||
|
// Wait Samples * ProbeInterval so the role probe has had time to sample each
|
||||||
|
// chassis at least <Samples> times. The role probe loop spins inside the driver;
|
||||||
|
// we just sleep + read GetDeviceState's ActiveAddress.
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(500 * Math.Max(1, Samples)), ct);
|
||||||
|
|
||||||
|
// Pull HSBY state out via DriverHealth.Diagnostics. Single-pair config emits
|
||||||
|
// the flat AbCip.HsbyActive / AbCip.HsbyPrimaryRole / AbCip.HsbyPartnerRole keys.
|
||||||
|
var diag = driver.GetHealth().Diagnostics
|
||||||
|
?? new Dictionary<string, double>();
|
||||||
|
var primaryRole = diag.TryGetValue("AbCip.HsbyPrimaryRole", out var pr)
|
||||||
|
? (HsbyRole)(int)pr : HsbyRole.Unknown;
|
||||||
|
var partnerRole = diag.TryGetValue("AbCip.HsbyPartnerRole", out var qr)
|
||||||
|
? (HsbyRole)(int)qr : HsbyRole.Unknown;
|
||||||
|
var activeCode = diag.TryGetValue("AbCip.HsbyActive", out var ac) ? (int)ac : 0;
|
||||||
|
var activeAddress = activeCode switch
|
||||||
|
{
|
||||||
|
1 => Gateway,
|
||||||
|
2 => Partner,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync($"Primary: {Gateway}");
|
||||||
|
await console.Output.WriteLineAsync($"Partner: {Partner}");
|
||||||
|
await console.Output.WriteLineAsync($"Role tag: {RoleTagAddress}");
|
||||||
|
await console.Output.WriteLineAsync();
|
||||||
|
await console.Output.WriteLineAsync($"Primary role: {primaryRole}");
|
||||||
|
await console.Output.WriteLineAsync($"Partner role: {partnerRole}");
|
||||||
|
await console.Output.WriteLineAsync($"Active chassis: {activeAddress ?? "<none>"}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Probes an AB CIP gateway: initialises the driver (connects via libplctag), reads a
|
||||||
|
/// single tag, and prints health + the read result. Fastest way to answer "is the PLC
|
||||||
|
/// up + reachable + speaking CIP via this path?".
|
||||||
|
/// </summary>
|
||||||
|
[Command("probe", Description = "Verify the AB CIP gateway is reachable and a sample tag reads.")]
|
||||||
|
public sealed class ProbeCommand : AbCipCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("tag", 't', Description =
|
||||||
|
"Tag path to probe. ControlLogix default is '@raw_cpu_type' (the canonical libplctag " +
|
||||||
|
"system tag); Micro800 takes a user-supplied global (e.g. '_SYSVA_CLOCK_HOUR').",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string TagPath { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", Description =
|
||||||
|
"Logix atomic type of the probe tag (default DInt).")]
|
||||||
|
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var probeTag = new AbCipTagDefinition(
|
||||||
|
Name: "__probe",
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
TagPath: TagPath,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([probeTag]);
|
||||||
|
|
||||||
|
await using var driver = new AbCipDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||||
|
var health = driver.GetHealth();
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
|
||||||
|
await console.Output.WriteLineAsync($"Family: {Family}");
|
||||||
|
await console.Output.WriteLineAsync($"Health: {health.State}");
|
||||||
|
if (health.LastError is { } err)
|
||||||
|
await console.Output.WriteLineAsync($"Last error: {err}");
|
||||||
|
await console.Output.WriteLineAsync();
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.Format(TagPath, snapshot[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read one Logix tag by symbolic path. Operator specifies <c>--tag</c> + <c>--type</c>;
|
||||||
|
/// the CLI synthesises a one-tag driver config, reads once, prints the snapshot, shuts
|
||||||
|
/// down. UDT / Structure reads are out of scope here — those need the member layout
|
||||||
|
/// declared, which belongs in a real driver config.
|
||||||
|
/// </summary>
|
||||||
|
[Command("read", Description = "Read a single Logix tag by symbolic path.")]
|
||||||
|
public sealed class ReadCommand : AbCipCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("tag", 't', Description =
|
||||||
|
"Logix symbolic path. Controller scope: 'Motor01_Speed'. Program scope: " +
|
||||||
|
"'Program:Main.Motor01_Speed'. Array element: 'Recipe[3]'. UDT member: " +
|
||||||
|
"'Motor01.Speed'.", IsRequired = true)]
|
||||||
|
public string TagPath { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", Description =
|
||||||
|
"Bool / SInt / Int / DInt / LInt / USInt / UInt / UDInt / ULInt / Real / LReal / " +
|
||||||
|
"String / Dt / Structure (default DInt).")]
|
||||||
|
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = SynthesiseTagName(TagPath, DataType);
|
||||||
|
var tag = new AbCipTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
TagPath: TagPath,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new AbCipDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.Format(TagPath, snapshot[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tag-name key the driver uses internally. The path + type pair is already unique
|
||||||
|
/// so we use them verbatim — keeps tag-level diagnostics readable without mangling.
|
||||||
|
/// </summary>
|
||||||
|
internal static string SynthesiseTagName(string tagPath, AbCipDataType type)
|
||||||
|
=> $"{tagPath}:{type}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Force a controller-side @tags re-walk on a live AbCip driver instance. Issue #233 —
|
||||||
|
/// online tag-DB refresh trigger. The CLI variant builds a transient driver against the
|
||||||
|
/// supplied gateway, runs <see cref="AbCipDriver.RebrowseAsync"/>, and prints the freshly
|
||||||
|
/// discovered tag names. In-server (Tier-A) operators wire this same call to an Admin UI
|
||||||
|
/// button so a controller program-download is reflected in the address space without a
|
||||||
|
/// driver restart.
|
||||||
|
/// </summary>
|
||||||
|
[Command("rebrowse", Description =
|
||||||
|
"Re-walk the AB CIP controller symbol table (force @tags refresh) and print discovered tags.")]
|
||||||
|
public sealed class RebrowseCommand : AbCipCommandBase
|
||||||
|
{
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
// EnableControllerBrowse must be true for the @tags walk to happen; the CLI baseline
|
||||||
|
// (BuildOptions in AbCipCommandBase) leaves it off for one-shot probes, so we flip it
|
||||||
|
// here without touching the base helper.
|
||||||
|
var baseOpts = BuildOptions(tags: []);
|
||||||
|
var options = new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = baseOpts.Devices,
|
||||||
|
Tags = baseOpts.Tags,
|
||||||
|
Timeout = baseOpts.Timeout,
|
||||||
|
Probe = baseOpts.Probe,
|
||||||
|
EnableControllerBrowse = true,
|
||||||
|
EnableAlarmProjection = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await using var driver = new AbCipDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
|
||||||
|
var builder = new ConsoleAddressSpaceBuilder();
|
||||||
|
await driver.RebrowseAsync(builder, ct);
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
|
||||||
|
await console.Output.WriteLineAsync($"Family: {Family}");
|
||||||
|
await console.Output.WriteLineAsync($"Variables: {builder.VariableCount}");
|
||||||
|
await console.Output.WriteLineAsync();
|
||||||
|
foreach (var line in builder.Lines)
|
||||||
|
await console.Output.WriteLineAsync(line);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal in-memory <see cref="IAddressSpaceBuilder"/> that flattens the tree to one
|
||||||
|
/// line per variable for CLI display. Folder nesting is captured in the prefix so the
|
||||||
|
/// operator can see the same shape the in-server builder would receive.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ConsoleAddressSpaceBuilder : IAddressSpaceBuilder
|
||||||
|
{
|
||||||
|
private readonly string _prefix;
|
||||||
|
private readonly Counter _counter;
|
||||||
|
public List<string> Lines { get; }
|
||||||
|
public int VariableCount => _counter.Count;
|
||||||
|
|
||||||
|
public ConsoleAddressSpaceBuilder() : this("", new List<string>(), new Counter()) { }
|
||||||
|
private ConsoleAddressSpaceBuilder(string prefix, List<string> sharedLines, Counter counter)
|
||||||
|
{
|
||||||
|
_prefix = prefix;
|
||||||
|
Lines = sharedLines;
|
||||||
|
_counter = counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||||
|
{
|
||||||
|
var newPrefix = string.IsNullOrEmpty(_prefix) ? browseName : $"{_prefix}/{browseName}";
|
||||||
|
return new ConsoleAddressSpaceBuilder(newPrefix, Lines, _counter);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||||
|
{
|
||||||
|
_counter.Count++;
|
||||||
|
Lines.Add($" {_prefix}/{browseName} ({info.DriverDataType}, {info.SecurityClass})");
|
||||||
|
return new Handle(info.FullName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||||
|
|
||||||
|
private sealed class Counter { public int Count; }
|
||||||
|
|
||||||
|
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,81 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Watch a Logix tag via polled subscription until Ctrl+C. Uses the driver's
|
||||||
|
/// <c>ISubscribable</c> surface (PollGroupEngine under the hood). Prints each change
|
||||||
|
/// event with an HH:mm:ss.fff timestamp.
|
||||||
|
/// </summary>
|
||||||
|
[Command("subscribe", Description = "Watch a Logix tag via polled subscription until Ctrl+C.")]
|
||||||
|
public sealed class SubscribeCommand : AbCipCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("tag", 't', Description =
|
||||||
|
"Logix symbolic path — same format as `read`.", IsRequired = true)]
|
||||||
|
public string TagPath { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", Description =
|
||||||
|
"Bool / SInt / Int / DInt / LInt / USInt / UInt / UDInt / ULInt / Real / LReal / " +
|
||||||
|
"String / Dt (default DInt).")]
|
||||||
|
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
|
||||||
|
|
||||||
|
[CommandOption("interval-ms", 'i', Description =
|
||||||
|
"Publishing interval in milliseconds (default 1000). PollGroupEngine floors " +
|
||||||
|
"sub-250ms values.")]
|
||||||
|
public int IntervalMs { get; init; } = 1000;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = ReadCommand.SynthesiseTagName(TagPath, DataType);
|
||||||
|
var tag = new AbCipTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
TagPath: TagPath,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new AbCipDriver(options, DriverInstanceId);
|
||||||
|
ISubscriptionHandle? handle = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
|
||||||
|
driver.OnDataChange += (_, e) =>
|
||||||
|
{
|
||||||
|
var line = $"[{DateTime.UtcNow:HH:mm:ss.fff}] " +
|
||||||
|
$"{e.FullReference} = {SnapshotFormatter.FormatValue(e.Snapshot.Value)} " +
|
||||||
|
$"({SnapshotFormatter.FormatStatus(e.Snapshot.StatusCode)})";
|
||||||
|
console.Output.WriteLine(line);
|
||||||
|
};
|
||||||
|
|
||||||
|
handle = await driver.SubscribeAsync([tagName], TimeSpan.FromMilliseconds(IntervalMs), ct);
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync(
|
||||||
|
$"Subscribed to {TagPath} @ {IntervalMs}ms. Ctrl+C to stop.");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Expected on Ctrl+C.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (handle is not null)
|
||||||
|
{
|
||||||
|
try { await driver.UnsubscribeAsync(handle, CancellationToken.None); }
|
||||||
|
catch { /* teardown best-effort */ }
|
||||||
|
}
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using CliFx;
|
||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Exceptions;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dump the merged tag table from an <see cref="AbCipDriverOptions"/> JSON config to a
|
||||||
|
/// Kepware-format CSV. The command reads the pre-declared <c>Tags</c> list, pulls in any
|
||||||
|
/// <c>L5kImports</c> / <c>L5xImports</c> / <c>CsvImports</c> entries, applies the same
|
||||||
|
/// declared-wins precedence used by the live driver, and writes the union as one CSV.
|
||||||
|
/// Mirrors the round-trip path operators want for Excel-driven editing: export → edit →
|
||||||
|
/// re-import via the driver's <c>CsvImports</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The command does not contact any PLC — it is a pure transform over the options JSON.
|
||||||
|
/// <c>--driver-options-json</c> may point at a full options file or at a fragment that
|
||||||
|
/// deserialises to <see cref="AbCipDriverOptions"/>.
|
||||||
|
/// </remarks>
|
||||||
|
[Command("tag-export", Description = "Export the merged tag table from a driver-options JSON to Kepware CSV.")]
|
||||||
|
public sealed class TagExportCommand : ICommand
|
||||||
|
{
|
||||||
|
[CommandOption("driver-options-json", Description =
|
||||||
|
"Path to a JSON file deserialising to AbCipDriverOptions (Tags + L5kImports + " +
|
||||||
|
"L5xImports + CsvImports). Imports with FilePath are loaded relative to the JSON.",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string DriverOptionsJsonPath { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("out", 'o', Description = "Output CSV path (UTF-8, no BOM).", IsRequired = true)]
|
||||||
|
public string OutputPath { get; init; } = default!;
|
||||||
|
|
||||||
|
public ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
if (!File.Exists(DriverOptionsJsonPath))
|
||||||
|
throw new CommandException($"driver-options-json '{DriverOptionsJsonPath}' does not exist.");
|
||||||
|
|
||||||
|
var json = File.ReadAllText(DriverOptionsJsonPath);
|
||||||
|
var opts = JsonSerializer.Deserialize<AbCipDriverOptions>(json, JsonOpts)
|
||||||
|
?? throw new CommandException("driver-options-json deserialised to null.");
|
||||||
|
|
||||||
|
var basePath = Path.GetDirectoryName(Path.GetFullPath(DriverOptionsJsonPath)) ?? string.Empty;
|
||||||
|
|
||||||
|
var declaredNames = new HashSet<string>(
|
||||||
|
opts.Tags.Select(t => t.Name), StringComparer.OrdinalIgnoreCase);
|
||||||
|
var allTags = new List<AbCipTagDefinition>(opts.Tags);
|
||||||
|
|
||||||
|
foreach (var import in opts.L5kImports)
|
||||||
|
MergeL5(import.DeviceHostAddress, ResolvePath(import.FilePath, basePath),
|
||||||
|
import.InlineText, import.NamePrefix, L5kParser.Parse, declaredNames, allTags);
|
||||||
|
foreach (var import in opts.L5xImports)
|
||||||
|
MergeL5(import.DeviceHostAddress, ResolvePath(import.FilePath, basePath),
|
||||||
|
import.InlineText, import.NamePrefix, L5xParser.Parse, declaredNames, allTags);
|
||||||
|
foreach (var import in opts.CsvImports)
|
||||||
|
MergeCsv(import, basePath, declaredNames, allTags);
|
||||||
|
|
||||||
|
CsvTagExporter.WriteFile(allTags, OutputPath);
|
||||||
|
console.Output.WriteLine($"Wrote {allTags.Count} tag(s) to {OutputPath}");
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolvePath(string? path, string basePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(path)) return path;
|
||||||
|
return Path.IsPathRooted(path) ? path : Path.Combine(basePath, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MergeL5(
|
||||||
|
string deviceHost, string? filePath, string? inlineText, string namePrefix,
|
||||||
|
Func<IL5kSource, L5kDocument> parse,
|
||||||
|
HashSet<string> declaredNames, List<AbCipTagDefinition> allTags)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(deviceHost)) return;
|
||||||
|
IL5kSource? src = null;
|
||||||
|
if (!string.IsNullOrEmpty(filePath)) src = new FileL5kSource(filePath);
|
||||||
|
else if (!string.IsNullOrEmpty(inlineText)) src = new StringL5kSource(inlineText);
|
||||||
|
if (src is null) return;
|
||||||
|
|
||||||
|
var doc = parse(src);
|
||||||
|
var ingest = new L5kIngest { DefaultDeviceHostAddress = deviceHost, NamePrefix = namePrefix };
|
||||||
|
foreach (var tag in ingest.Ingest(doc).Tags)
|
||||||
|
{
|
||||||
|
if (declaredNames.Contains(tag.Name)) continue;
|
||||||
|
allTags.Add(tag);
|
||||||
|
declaredNames.Add(tag.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MergeCsv(
|
||||||
|
AbCipCsvImportOptions import, string basePath,
|
||||||
|
HashSet<string> declaredNames, List<AbCipTagDefinition> allTags)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(import.DeviceHostAddress)) return;
|
||||||
|
string? text = null;
|
||||||
|
var resolved = ResolvePath(import.FilePath, basePath);
|
||||||
|
if (!string.IsNullOrEmpty(resolved)) text = File.ReadAllText(resolved);
|
||||||
|
else if (!string.IsNullOrEmpty(import.InlineText)) text = import.InlineText;
|
||||||
|
if (text is null) return;
|
||||||
|
|
||||||
|
var importer = new CsvTagImporter
|
||||||
|
{
|
||||||
|
DefaultDeviceHostAddress = import.DeviceHostAddress,
|
||||||
|
NamePrefix = import.NamePrefix,
|
||||||
|
};
|
||||||
|
foreach (var tag in importer.Import(text).Tags)
|
||||||
|
{
|
||||||
|
if (declaredNames.Contains(tag.Name)) continue;
|
||||||
|
allTags.Add(tag);
|
||||||
|
declaredNames.Add(tag.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
AllowTrailingCommas = true,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write one value to a Logix tag by symbolic path. Mirrors <see cref="ReadCommand"/>'s
|
||||||
|
/// flag shape + adds <c>--value</c>. Value parsing respects <c>--type</c> so you can
|
||||||
|
/// write <c>--value 3.14 --type Real</c> without hex-encoding. GuardLogix safety tags
|
||||||
|
/// are refused at the driver level (they're forced to ViewOnly by PR 12).
|
||||||
|
/// </summary>
|
||||||
|
[Command("write", Description = "Write a single Logix tag by symbolic path.")]
|
||||||
|
public sealed class WriteCommand : AbCipCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("tag", 't', Description =
|
||||||
|
"Logix symbolic path — same format as `read`.", IsRequired = true)]
|
||||||
|
public string TagPath { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", Description =
|
||||||
|
"Bool / SInt / Int / DInt / LInt / USInt / UInt / UDInt / ULInt / Real / LReal / " +
|
||||||
|
"String / Dt (default DInt).")]
|
||||||
|
public AbCipDataType DataType { get; init; } = AbCipDataType.DInt;
|
||||||
|
|
||||||
|
[CommandOption("value", 'v', Description =
|
||||||
|
"Value to write. Parsed per --type (booleans accept true/false/1/0).",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string Value { get; init; } = default!;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
if (DataType == AbCipDataType.Structure)
|
||||||
|
throw new CliFx.Exceptions.CommandException(
|
||||||
|
"Structure (UDT) writes need an explicit member layout — drop to the driver's " +
|
||||||
|
"config JSON for those. The CLI covers atomic types only.");
|
||||||
|
|
||||||
|
var tagName = ReadCommand.SynthesiseTagName(TagPath, DataType);
|
||||||
|
var tag = new AbCipTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
TagPath: TagPath,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: true);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
var parsed = ParseValue(Value, DataType);
|
||||||
|
|
||||||
|
await using var driver = new AbCipDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(TagPath, results[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse the operator's <c>--value</c> string into the CLR type the driver expects
|
||||||
|
/// for the declared <see cref="AbCipDataType"/>. Invariant culture everywhere.
|
||||||
|
/// </summary>
|
||||||
|
internal static object ParseValue(string raw, AbCipDataType type) => type switch
|
||||||
|
{
|
||||||
|
AbCipDataType.Bool => ParseBool(raw),
|
||||||
|
AbCipDataType.SInt => sbyte.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbCipDataType.Int => short.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbCipDataType.DInt or AbCipDataType.Dt => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbCipDataType.LInt => long.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbCipDataType.USInt => byte.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbCipDataType.UInt => ushort.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbCipDataType.UDInt => uint.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbCipDataType.ULInt => ulong.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbCipDataType.Real => float.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbCipDataType.LReal => double.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbCipDataType.String => raw,
|
||||||
|
_ => throw new CliFx.Exceptions.CommandException($"Unsupported DataType '{type}' for write."),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool ParseBool(string raw) => raw.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"1" or "true" or "on" or "yes" => true,
|
||||||
|
"0" or "false" or "off" or "no" => false,
|
||||||
|
_ => throw new CliFx.Exceptions.CommandException(
|
||||||
|
$"Boolean value '{raw}' is not recognised. Use true/false, 1/0, on/off, or yes/no."),
|
||||||
|
};
|
||||||
|
}
|
||||||
11
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Program.cs
Normal file
11
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Program.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using CliFx;
|
||||||
|
|
||||||
|
return await new CliApplicationBuilder()
|
||||||
|
.AddCommandsFromThisAssembly()
|
||||||
|
.SetExecutableName("otopcua-abcip-cli")
|
||||||
|
.SetDescription(
|
||||||
|
"OtOpcUa AB CIP test-client — ad-hoc probe + Logix symbolic reads/writes + polled " +
|
||||||
|
"subscriptions against ControlLogix / CompactLogix / Micro800 / GuardLogix families " +
|
||||||
|
"via libplctag. Second of four driver CLIs; mirrors otopcua-modbus-cli's shape.")
|
||||||
|
.Build()
|
||||||
|
.RunAsync(args);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli</RootNamespace>
|
||||||
|
<AssemblyName>otopcua-abcip-cli</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="CliFx" Version="2.3.6"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common\ZB.MOM.WW.OtOpcUa.Driver.Cli.Common.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user