Compare commits
136 Commits
phase-7-fu
...
auto/drive
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
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/">
|
||||||
@@ -44,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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
83
docs/Driver.AbCip.Cli.md
Normal file
83
docs/Driver.AbCip.Cli.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# `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 |
|
||||||
|
| `--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
|
||||||
|
```
|
||||||
|
|
||||||
|
### `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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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`.
|
||||||
105
docs/Driver.AbLegacy.Cli.md
Normal file
105
docs/Driver.AbLegacy.Cli.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# `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 |
|
||||||
|
| `--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)
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### `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
|
||||||
|
```
|
||||||
|
|
||||||
|
### `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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
158
docs/Driver.FOCAS.Cli.md
Normal file
158
docs/Driver.FOCAS.Cli.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# `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 |
|
||||||
|
| `--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
|
||||||
|
otopcua-focas-cli write -h 192.168.1.50 -a MACRO:500 -t Float64 -v 3.14
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Writes are non-idempotent by default** — a timeout after the CNC already
|
||||||
|
applied the write will NOT auto-retry (plan decisions #44 + #45).
|
||||||
|
|
||||||
|
### `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.
|
||||||
93
docs/Driver.S7.Cli.md
Normal file
93
docs/Driver.S7.Cli.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# `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 |
|
||||||
|
| `--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`.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
### `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.
|
||||||
101
docs/Driver.TwinCAT.Cli.md
Normal file
101
docs/Driver.TwinCAT.Cli.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# `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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### `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
|
||||||
|
```
|
||||||
|
|
||||||
|
### `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
|
||||||
|
```
|
||||||
|
|
||||||
|
The subscribe banner announces which mechanism is in play — "ADS notification"
|
||||||
|
or "polling" — so it's obvious in screen-recorded bug reports.
|
||||||
95
docs/DriverClis.md
Normal file
95
docs/DriverClis.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# 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.)
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|||||||
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
|
||||||
@@ -93,11 +93,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
|
||||||
@@ -114,7 +116,8 @@ cover the common ones but uncommon ones (`R` counters, `S` status 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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
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.
|
||||||
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" })
|
||||||
|
}
|
||||||
70
scripts/e2e/e2e-config.sample.json
Normal file
70
scripts/e2e/e2e-config.sample.json
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"$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
|
||||||
|
},
|
||||||
|
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
98
scripts/e2e/test-abcip.ps1
Normal file
98
scripts/e2e/test-abcip.ps1
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
#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.
|
||||||
|
#>
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
$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"
|
||||||
|
|
||||||
|
Write-Summary -Title "AB CIP e2e" -Results $results
|
||||||
|
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||||
99
scripts/e2e/test-ablegacy.ps1
Normal file
99
scripts/e2e/test-ablegacy.ps1
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
#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.
|
||||||
|
#>
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
$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"
|
||||||
|
|
||||||
|
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
|
||||||
96
scripts/e2e/test-focas.ps1
Normal file
96
scripts/e2e/test-focas.ps1
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
#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.
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$CncHost = "127.0.0.1",
|
||||||
|
[int]$CncPort = 8193,
|
||||||
|
[string]$Address = "R100",
|
||||||
|
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||||
|
[Parameter(Mandatory)] [string]$BridgeNodeId
|
||||||
|
)
|
||||||
|
|
||||||
|
$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)
|
||||||
|
$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"
|
||||||
|
|
||||||
|
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 }
|
||||||
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 }
|
||||||
100
scripts/e2e/test-s7.ps1
Normal file
100
scripts/e2e/test-s7.ps1
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
#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"
|
||||||
|
|
||||||
|
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 }
|
||||||
128
scripts/smoke/seed-abcip-smoke.sql
Normal file
128
scripts/smoke/seed-abcip-smoke.sql
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
-- 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.
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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"';
|
||||||
125
scripts/smoke/seed-ablegacy-smoke.sql
Normal file
125
scripts/smoke/seed-ablegacy-smoke.sql
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
-- 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';
|
||||||
|
|
||||||
|
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 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.
|
||||||
|
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
|
||||||
|
Name, DriverType, DriverConfig, Enabled)
|
||||||
|
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ablegacy-smoke', 'AbLegacy', N'{
|
||||||
|
"TimeoutMs": 2000,
|
||||||
|
"Devices": [
|
||||||
|
{
|
||||||
|
"HostAddress": "ab://127.0.0.1:44818/1,0",
|
||||||
|
"PlcFamily": "Slc500",
|
||||||
|
"DeviceName": "slc-500"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}', 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);
|
||||||
|
|
||||||
|
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.';
|
||||||
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"';
|
||||||
@@ -45,6 +45,13 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|||||||
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.ScriptedAlarm"/> —
|
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.ScriptedAlarm"/> —
|
||||||
/// stable logical id the ScriptedAlarmEngine addresses by. Null otherwise.
|
/// stable logical id the ScriptedAlarmEngine addresses by. Null otherwise.
|
||||||
/// </param>
|
/// </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,
|
||||||
@@ -56,7 +63,8 @@ public sealed record DriverAttributeInfo(
|
|||||||
bool WriteIdempotent = false,
|
bool WriteIdempotent = false,
|
||||||
NodeSourceKind Source = NodeSourceKind.Driver,
|
NodeSourceKind Source = NodeSourceKind.Driver,
|
||||||
string? VirtualTagId = null,
|
string? VirtualTagId = null,
|
||||||
string? ScriptedAlarmId = null);
|
string? ScriptedAlarmId = null,
|
||||||
|
string? Description = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per ADR-002 — discriminates which runtime subsystem owns this node's Read/Write/
|
/// Per ADR-002 — discriminates which runtime subsystem owns this node's Read/Write/
|
||||||
|
|||||||
@@ -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,77 @@ 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>
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
@@ -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]; }
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs
Normal file
59
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/// <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}")],
|
||||||
|
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,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>
|
||||||
94
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipArrayReadPlanner.cs
Normal file
94
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipArrayReadPlanner.cs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-1.3 — issues one libplctag tag-create with <c>ElementCount=N</c> per Rockwell
|
||||||
|
/// array-slice tag (<c>Tag[0..N]</c> in <see cref="AbCipTagPath"/>), then decodes the
|
||||||
|
/// contiguous buffer at element stride into <c>N</c> typed values. Mirrors the whole-UDT
|
||||||
|
/// planner pattern (<see cref="AbCipUdtReadPlanner"/>): pure shape — the planner never
|
||||||
|
/// touches the runtime + never reads the PLC, the driver wires the runtime in.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>Stride is the natural Logix size of the element type (DInt = 4, Real = 4, LInt = 8).
|
||||||
|
/// Bool / String / Structure slices aren't supported here — Logix packs BOOLs into a host
|
||||||
|
/// byte (no fixed stride), STRING members carry a Length+DATA pair that's not a flat array,
|
||||||
|
/// and structure arrays need the CIP Template Object reader (PR-tracked separately).</para>
|
||||||
|
///
|
||||||
|
/// <para>Output is a single <c>object[]</c> snapshot value containing the N decoded
|
||||||
|
/// elements at indices 0..Count-1. Pairing with one slice tag = one snapshot keeps the
|
||||||
|
/// <c>ReadAsync</c> 1:1 contract (one fullReference -> one snapshot) intact.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class AbCipArrayReadPlanner
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Build the libplctag create-params + decode descriptor for a slice tag. Returns
|
||||||
|
/// <c>null</c> when the slice element type isn't supported under this declaration-only
|
||||||
|
/// decoder (Bool / String / Structure / unrecognised) — the driver falls back to the
|
||||||
|
/// scalar read path so the operator gets a clean per-element result instead.
|
||||||
|
/// </summary>
|
||||||
|
public static AbCipArrayReadPlan? TryBuild(
|
||||||
|
AbCipTagDefinition definition,
|
||||||
|
AbCipTagPath parsedPath,
|
||||||
|
AbCipTagCreateParams baseParams)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(definition);
|
||||||
|
ArgumentNullException.ThrowIfNull(parsedPath);
|
||||||
|
ArgumentNullException.ThrowIfNull(baseParams);
|
||||||
|
if (parsedPath.Slice is null) return null;
|
||||||
|
|
||||||
|
if (!TryGetStride(definition.DataType, out var stride)) return null;
|
||||||
|
|
||||||
|
var slice = parsedPath.Slice;
|
||||||
|
var createParams = baseParams with
|
||||||
|
{
|
||||||
|
TagName = parsedPath.ToLibplctagSliceArrayName(),
|
||||||
|
ElementCount = slice.Count,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new AbCipArrayReadPlan(definition.DataType, slice, stride, createParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode <paramref name="plan"/>.Count elements from <paramref name="runtime"/> at
|
||||||
|
/// element stride. Caller has already invoked <see cref="IAbCipTagRuntime.ReadAsync"/>
|
||||||
|
/// and confirmed <see cref="IAbCipTagRuntime.GetStatus"/> == 0.
|
||||||
|
/// </summary>
|
||||||
|
public static object?[] Decode(AbCipArrayReadPlan plan, IAbCipTagRuntime runtime)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(plan);
|
||||||
|
ArgumentNullException.ThrowIfNull(runtime);
|
||||||
|
|
||||||
|
var values = new object?[plan.Slice.Count];
|
||||||
|
for (var i = 0; i < plan.Slice.Count; i++)
|
||||||
|
values[i] = runtime.DecodeValueAt(plan.ElementType, i * plan.Stride, bitIndex: null);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetStride(AbCipDataType type, out int stride)
|
||||||
|
{
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case AbCipDataType.SInt: case AbCipDataType.USInt:
|
||||||
|
stride = 1; return true;
|
||||||
|
case AbCipDataType.Int: case AbCipDataType.UInt:
|
||||||
|
stride = 2; return true;
|
||||||
|
case AbCipDataType.DInt: case AbCipDataType.UDInt:
|
||||||
|
case AbCipDataType.Real: case AbCipDataType.Dt:
|
||||||
|
stride = 4; return true;
|
||||||
|
case AbCipDataType.LInt: case AbCipDataType.ULInt:
|
||||||
|
case AbCipDataType.LReal:
|
||||||
|
stride = 8; return true;
|
||||||
|
default:
|
||||||
|
stride = 0; return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plan output: the libplctag create-params for the single array-read tag plus the
|
||||||
|
/// element-type / stride / slice metadata the decoder needs.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AbCipArrayReadPlan(
|
||||||
|
AbCipDataType ElementType,
|
||||||
|
AbCipTagPathSlice Slice,
|
||||||
|
int Stride,
|
||||||
|
AbCipTagCreateParams CreateParams);
|
||||||
@@ -50,11 +50,12 @@ public static class AbCipDataTypeExtensions
|
|||||||
AbCipDataType.Bool => DriverDataType.Boolean,
|
AbCipDataType.Bool => DriverDataType.Boolean,
|
||||||
AbCipDataType.SInt or AbCipDataType.Int or AbCipDataType.DInt => DriverDataType.Int32,
|
AbCipDataType.SInt or AbCipDataType.Int or AbCipDataType.DInt => DriverDataType.Int32,
|
||||||
AbCipDataType.USInt or AbCipDataType.UInt or AbCipDataType.UDInt => DriverDataType.Int32,
|
AbCipDataType.USInt or AbCipDataType.UInt or AbCipDataType.UDInt => DriverDataType.Int32,
|
||||||
AbCipDataType.LInt or AbCipDataType.ULInt => DriverDataType.Int32, // TODO: Int64 — matches Modbus gap
|
AbCipDataType.LInt => DriverDataType.Int64,
|
||||||
|
AbCipDataType.ULInt => DriverDataType.UInt64,
|
||||||
AbCipDataType.Real => DriverDataType.Float32,
|
AbCipDataType.Real => DriverDataType.Float32,
|
||||||
AbCipDataType.LReal => DriverDataType.Float64,
|
AbCipDataType.LReal => DriverDataType.Float64,
|
||||||
AbCipDataType.String => DriverDataType.String,
|
AbCipDataType.String => DriverDataType.String,
|
||||||
AbCipDataType.Dt => DriverDataType.Int32, // epoch-seconds DINT
|
AbCipDataType.Dt => DriverDataType.Int64, // Logix v32+ DT == LINT epoch-millis
|
||||||
AbCipDataType.Structure => DriverDataType.String, // placeholder until UDT PR 6 introduces a structured kind
|
AbCipDataType.Structure => DriverDataType.String, // placeholder until UDT PR 6 introduces a structured kind
|
||||||
_ => DriverDataType.Int32,
|
_ => DriverDataType.Int32,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
@@ -21,7 +22,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
|||||||
/// <see cref="PlcTagHandle"/> and reconnects each device.</para>
|
/// <see cref="PlcTagHandle"/> and reconnects each device.</para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||||
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
|
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDriverControl, IDisposable, IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly AbCipDriverOptions _options;
|
private readonly AbCipDriverOptions _options;
|
||||||
private readonly string _driverInstanceId;
|
private readonly string _driverInstanceId;
|
||||||
@@ -33,6 +34,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly AbCipAlarmProjection _alarmProjection;
|
private readonly AbCipAlarmProjection _alarmProjection;
|
||||||
|
private readonly SemaphoreSlim _discoverySemaphore = new(1, 1);
|
||||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
|
|
||||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||||
@@ -121,7 +123,42 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily);
|
var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily);
|
||||||
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
|
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
|
||||||
}
|
}
|
||||||
foreach (var tag in _options.Tags)
|
// Pre-declared tags first; L5K imports fill in only the names not already covered
|
||||||
|
// (operators can override an imported entry by re-declaring it under Tags).
|
||||||
|
var declaredNames = new HashSet<string>(
|
||||||
|
_options.Tags.Select(t => t.Name),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
var allTags = new List<AbCipTagDefinition>(_options.Tags);
|
||||||
|
foreach (var import in _options.L5kImports)
|
||||||
|
{
|
||||||
|
MergeImport(
|
||||||
|
deviceHost: import.DeviceHostAddress,
|
||||||
|
filePath: import.FilePath,
|
||||||
|
inlineText: import.InlineText,
|
||||||
|
namePrefix: import.NamePrefix,
|
||||||
|
parse: L5kParser.Parse,
|
||||||
|
formatLabel: "L5K",
|
||||||
|
declaredNames: declaredNames,
|
||||||
|
allTags: allTags);
|
||||||
|
}
|
||||||
|
foreach (var import in _options.L5xImports)
|
||||||
|
{
|
||||||
|
MergeImport(
|
||||||
|
deviceHost: import.DeviceHostAddress,
|
||||||
|
filePath: import.FilePath,
|
||||||
|
inlineText: import.InlineText,
|
||||||
|
namePrefix: import.NamePrefix,
|
||||||
|
parse: L5xParser.Parse,
|
||||||
|
formatLabel: "L5X",
|
||||||
|
declaredNames: declaredNames,
|
||||||
|
allTags: allTags);
|
||||||
|
}
|
||||||
|
foreach (var import in _options.CsvImports)
|
||||||
|
{
|
||||||
|
MergeCsvImport(import, declaredNames, allTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var tag in allTags)
|
||||||
{
|
{
|
||||||
_tagsByName[tag.Name] = tag;
|
_tagsByName[tag.Name] = tag;
|
||||||
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
|
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
|
||||||
@@ -134,7 +171,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
TagPath: $"{tag.TagPath}.{member.Name}",
|
TagPath: $"{tag.TagPath}.{member.Name}",
|
||||||
DataType: member.DataType,
|
DataType: member.DataType,
|
||||||
Writable: member.Writable,
|
Writable: member.Writable,
|
||||||
WriteIdempotent: member.WriteIdempotent);
|
WriteIdempotent: member.WriteIdempotent,
|
||||||
|
StringLength: member.StringLength);
|
||||||
_tagsByName[memberTag.Name] = memberTag;
|
_tagsByName[memberTag.Name] = memberTag;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,6 +198,84 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared L5K / L5X import path — keeps source-format selection (parser delegate) the
|
||||||
|
/// only behavioural axis between the two formats. Adds the parser's tags to
|
||||||
|
/// <paramref name="allTags"/> while skipping any name already covered by an earlier
|
||||||
|
/// declaration or import (declared > L5K > L5X precedence falls out from call order).
|
||||||
|
/// </summary>
|
||||||
|
private static void MergeImport(
|
||||||
|
string deviceHost,
|
||||||
|
string? filePath,
|
||||||
|
string? inlineText,
|
||||||
|
string namePrefix,
|
||||||
|
Func<IL5kSource, L5kDocument> parse,
|
||||||
|
string formatLabel,
|
||||||
|
HashSet<string> declaredNames,
|
||||||
|
List<AbCipTagDefinition> allTags)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(deviceHost))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"AbCip {formatLabel} import is missing DeviceHostAddress — every imported tag needs a target device.");
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
var result = ingest.Ingest(doc);
|
||||||
|
foreach (var importedTag in result.Tags)
|
||||||
|
{
|
||||||
|
if (declaredNames.Contains(importedTag.Name)) continue;
|
||||||
|
allTags.Add(importedTag);
|
||||||
|
declaredNames.Add(importedTag.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CSV-import variant of <see cref="MergeImport"/>. The CSV path produces
|
||||||
|
/// <see cref="AbCipTagDefinition"/> records directly (no intermediate document) so we
|
||||||
|
/// can't share the L5K/L5X parser-delegate signature. Merge semantics are identical:
|
||||||
|
/// a name already covered by a declaration or an earlier import is left untouched so
|
||||||
|
/// the precedence chain (declared > L5K > L5X > CSV) holds.
|
||||||
|
/// </summary>
|
||||||
|
private static void MergeCsvImport(
|
||||||
|
AbCipCsvImportOptions import,
|
||||||
|
HashSet<string> declaredNames,
|
||||||
|
List<AbCipTagDefinition> allTags)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(import.DeviceHostAddress))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"AbCip CSV import is missing DeviceHostAddress — every imported tag needs a target device.");
|
||||||
|
|
||||||
|
string? csvText = null;
|
||||||
|
if (!string.IsNullOrEmpty(import.FilePath))
|
||||||
|
csvText = System.IO.File.ReadAllText(import.FilePath);
|
||||||
|
else if (!string.IsNullOrEmpty(import.InlineText))
|
||||||
|
csvText = import.InlineText;
|
||||||
|
if (csvText is null) return;
|
||||||
|
|
||||||
|
var importer = new CsvTagImporter
|
||||||
|
{
|
||||||
|
DefaultDeviceHostAddress = import.DeviceHostAddress,
|
||||||
|
NamePrefix = import.NamePrefix,
|
||||||
|
};
|
||||||
|
var result = importer.Import(csvText);
|
||||||
|
foreach (var tag in result.Tags)
|
||||||
|
{
|
||||||
|
if (declaredNames.Contains(tag.Name)) continue;
|
||||||
|
allTags.Add(tag);
|
||||||
|
declaredNames.Add(tag.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||||
@@ -357,6 +473,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PR abcip-1.3 — array-slice path. A tag whose TagPath ends in [N..M] dispatches to
|
||||||
|
// AbCipArrayReadPlanner: one libplctag tag-create with ElementCount=N issues one
|
||||||
|
// Rockwell array read; the contiguous buffer is decoded at element stride into a
|
||||||
|
// single snapshot whose Value is an object[] of the N elements.
|
||||||
|
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
|
||||||
|
if (parsedPath?.Slice is not null)
|
||||||
|
{
|
||||||
|
await ReadSliceAsync(fb, def, parsedPath, device, results, now, ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
|
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
|
||||||
@@ -372,8 +499,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tagPath = AbCipTagPath.TryParse(def.TagPath);
|
var bitIndex = parsedPath?.BitIndex;
|
||||||
var bitIndex = tagPath?.BitIndex;
|
|
||||||
var value = runtime.DecodeValue(def.DataType, bitIndex);
|
var value = runtime.DecodeValue(def.DataType, bitIndex);
|
||||||
results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
||||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||||
@@ -390,6 +516,89 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-1.3 — slice read path. Builds an <see cref="AbCipArrayReadPlan"/> from the
|
||||||
|
/// parsed slice path, materialises a per-tag runtime keyed by the tag's full name (so
|
||||||
|
/// repeat reads reuse the same libplctag handle), issues one PLC array read, and
|
||||||
|
/// decodes the contiguous buffer into <c>object?[]</c> at element stride. Unsupported
|
||||||
|
/// element types fall back to <see cref="AbCipStatusMapper.BadNotSupported"/>.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ReadSliceAsync(
|
||||||
|
AbCipUdtReadFallback fb, AbCipTagDefinition def, AbCipTagPath parsedPath,
|
||||||
|
DeviceState device, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var baseParams = new AbCipTagCreateParams(
|
||||||
|
Gateway: device.ParsedAddress.Gateway,
|
||||||
|
Port: device.ParsedAddress.Port,
|
||||||
|
CipPath: device.ParsedAddress.CipPath,
|
||||||
|
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||||
|
TagName: parsedPath.ToLibplctagName(),
|
||||||
|
Timeout: _options.Timeout);
|
||||||
|
|
||||||
|
var plan = AbCipArrayReadPlanner.TryBuild(def, parsedPath, baseParams);
|
||||||
|
if (plan is null)
|
||||||
|
{
|
||||||
|
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||||||
|
AbCipStatusMapper.BadNotSupported, null, now);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var runtime = await EnsureSliceRuntimeAsync(device, def.Name, plan.CreateParams, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
await runtime.ReadAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var status = runtime.GetStatus();
|
||||||
|
if (status != 0)
|
||||||
|
{
|
||||||
|
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||||||
|
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||||
|
$"libplctag status {status} reading slice {def.Name}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var values = AbCipArrayReadPlanner.Decode(plan, runtime);
|
||||||
|
results[fb.OriginalIndex] = new DataValueSnapshot(values, AbCipStatusMapper.Good, now, now);
|
||||||
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||||||
|
AbCipStatusMapper.BadCommunicationError, null, now);
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Idempotently materialise a slice-read runtime. Slice runtimes share the device's
|
||||||
|
/// <see cref="DeviceState.Runtimes"/> dict keyed by the tag's full name so repeated
|
||||||
|
/// reads reuse the same libplctag handle without re-creating the native tag every poll.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<IAbCipTagRuntime> EnsureSliceRuntimeAsync(
|
||||||
|
DeviceState device, string tagName, AbCipTagCreateParams createParams, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (device.Runtimes.TryGetValue(tagName, out var existing)) return existing;
|
||||||
|
|
||||||
|
var runtime = _tagFactory.Create(createParams);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
runtime.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
device.Runtimes[tagName] = runtime;
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Task #194 — perform one whole-UDT read on the parent tag, then decode each
|
/// Task #194 — perform one whole-UDT read on the parent tag, then decode each
|
||||||
/// grouped member from the runtime's buffer at its computed byte offset. A per-group
|
/// grouped member from the runtime's buffer at its computed byte offset. A per-group
|
||||||
@@ -451,100 +660,184 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
// ---- IWritable ----
|
// ---- IWritable ----
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Write each request in order. Writes are NOT auto-retried by the driver — per plan
|
/// Write each request in the batch. Writes are NOT auto-retried by the driver — per
|
||||||
/// decisions #44, #45, #143 the caller opts in via <see cref="AbCipTagDefinition.WriteIdempotent"/>
|
/// plan decisions #44, #45, #143 the caller opts in via
|
||||||
/// and the resilience pipeline (layered above the driver) decides whether to replay.
|
/// <see cref="AbCipTagDefinition.WriteIdempotent"/> and the resilience pipeline (layered
|
||||||
/// Non-writable configurations surface as <c>BadNotWritable</c>; type-conversion failures
|
/// above the driver) decides whether to replay. Non-writable configurations surface as
|
||||||
/// as <c>BadTypeMismatch</c>; transport errors as <c>BadCommunicationError</c>.
|
/// <c>BadNotWritable</c>; type-conversion failures as <c>BadTypeMismatch</c>; transport
|
||||||
|
/// errors as <c>BadCommunicationError</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// PR abcip-1.4 — multi-tag write packing. Writes are grouped by device via
|
||||||
|
/// <see cref="AbCipMultiWritePlanner"/>. Devices whose family
|
||||||
|
/// <see cref="AbCipPlcFamilyProfile.SupportsRequestPacking"/> is <c>true</c> dispatch
|
||||||
|
/// their packable writes concurrently so libplctag's native scheduler can coalesce them
|
||||||
|
/// onto one CIP Multi-Service Packet (0x0A) per round-trip; Micro800 (no packing) still
|
||||||
|
/// issues writes one-at-a-time. BOOL-within-DINT writes always go through the RMW path
|
||||||
|
/// under a per-parent semaphore, regardless of the family flag, because two concurrent
|
||||||
|
/// RMWs on the same DINT could lose one another's update. Per-tag StatusCodes are
|
||||||
|
/// preserved in the caller's input order on partial failures.
|
||||||
|
/// </remarks>
|
||||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(writes);
|
ArgumentNullException.ThrowIfNull(writes);
|
||||||
var results = new WriteResult[writes.Count];
|
var results = new WriteResult[writes.Count];
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
|
|
||||||
for (var i = 0; i < writes.Count; i++)
|
var plans = AbCipMultiWritePlanner.Build(
|
||||||
|
writes, _tagsByName, _devices,
|
||||||
|
reportPreflight: (idx, code) => results[idx] = new WriteResult(code));
|
||||||
|
|
||||||
|
foreach (var plan in plans)
|
||||||
{
|
{
|
||||||
var w = writes[i];
|
if (!_devices.TryGetValue(plan.DeviceHostAddress, out var device))
|
||||||
if (!_tagsByName.TryGetValue(w.FullReference, out var def))
|
|
||||||
{
|
{
|
||||||
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
foreach (var e in plan.Packable) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||||
continue;
|
foreach (var e in plan.BitRmw) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||||
}
|
|
||||||
if (!def.Writable || def.SafetyTag)
|
|
||||||
{
|
|
||||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotWritable);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
|
||||||
{
|
|
||||||
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
// Bit-RMW writes always serialise per-parent — never packed.
|
||||||
{
|
foreach (var entry in plan.BitRmw)
|
||||||
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
|
results[entry.OriginalIndex] = new WriteResult(
|
||||||
|
await ExecuteBitRmwWriteAsync(device, entry, cancellationToken).ConfigureAwait(false));
|
||||||
|
|
||||||
// BOOL-within-DINT writes — per task #181, RMW against a parallel parent-DINT
|
if (plan.Packable.Count == 0) continue;
|
||||||
// runtime. Dispatching here keeps the normal EncodeValue path clean; the
|
|
||||||
// per-parent lock prevents two concurrent bit writes to the same DINT from
|
if (plan.Profile.SupportsRequestPacking && plan.Packable.Count > 1)
|
||||||
// losing one another's update.
|
{
|
||||||
if (def.DataType == AbCipDataType.Bool && parsedPath?.BitIndex is int bit)
|
// Concurrent dispatch — libplctag's native scheduler packs same-connection writes
|
||||||
|
// into one Multi-Service Packet when the family supports it.
|
||||||
|
var tasks = new Task<(int idx, uint code)>[plan.Packable.Count];
|
||||||
|
for (var i = 0; i < plan.Packable.Count; i++)
|
||||||
{
|
{
|
||||||
results[i] = new WriteResult(
|
var entry = plan.Packable[i];
|
||||||
await WriteBitInDIntAsync(device, parsedPath, bit, w.Value, cancellationToken)
|
tasks[i] = ExecutePackableWriteAsync(device, entry, cancellationToken);
|
||||||
.ConfigureAwait(false));
|
|
||||||
if (results[i].StatusCode == AbCipStatusMapper.Good)
|
|
||||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
var outcomes = await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
foreach (var (idx, code) in outcomes)
|
||||||
runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
|
results[idx] = new WriteResult(code);
|
||||||
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
var status = runtime.GetStatus();
|
|
||||||
results[i] = new WriteResult(status == 0
|
|
||||||
? AbCipStatusMapper.Good
|
|
||||||
: AbCipStatusMapper.MapLibplctagStatus(status));
|
|
||||||
if (status == 0) _health = new DriverHealth(DriverState.Healthy, now, null);
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
else
|
||||||
{
|
{
|
||||||
throw;
|
// Single-write groups + Micro800 (SupportsRequestPacking=false) — sequential.
|
||||||
}
|
foreach (var entry in plan.Packable)
|
||||||
catch (NotSupportedException nse)
|
{
|
||||||
{
|
var code = await ExecutePackableWriteAsync(device, entry, cancellationToken)
|
||||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
|
.ConfigureAwait(false);
|
||||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
results[entry.OriginalIndex] = new WriteResult(code.code);
|
||||||
}
|
}
|
||||||
catch (FormatException fe)
|
|
||||||
{
|
|
||||||
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
|
||||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
|
|
||||||
}
|
|
||||||
catch (InvalidCastException ice)
|
|
||||||
{
|
|
||||||
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
|
||||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
|
|
||||||
}
|
|
||||||
catch (OverflowException oe)
|
|
||||||
{
|
|
||||||
results[i] = new WriteResult(AbCipStatusMapper.BadOutOfRange);
|
|
||||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError);
|
|
||||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Execute one packable write — encode the value into the per-tag runtime, flush, and
|
||||||
|
/// map the resulting libplctag status. Exception-to-StatusCode mapping mirrors the
|
||||||
|
/// pre-1.4 per-tag loop so callers see no behaviour change for individual writes.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<(int idx, uint code)> ExecutePackableWriteAsync(
|
||||||
|
DeviceState device, AbCipMultiWritePlanner.ClassifiedWrite entry, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var def = entry.Definition;
|
||||||
|
var w = entry.Request;
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
|
||||||
|
runtime.EncodeValue(def.DataType, entry.ParsedPath?.BitIndex, w.Value);
|
||||||
|
await runtime.WriteAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var status = runtime.GetStatus();
|
||||||
|
if (status == 0)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||||
|
return (entry.OriginalIndex, AbCipStatusMapper.Good);
|
||||||
|
}
|
||||||
|
return (entry.OriginalIndex, AbCipStatusMapper.MapLibplctagStatus(status));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (NotSupportedException nse)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||||
|
return (entry.OriginalIndex, AbCipStatusMapper.BadNotSupported);
|
||||||
|
}
|
||||||
|
catch (FormatException fe)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
|
||||||
|
return (entry.OriginalIndex, AbCipStatusMapper.BadTypeMismatch);
|
||||||
|
}
|
||||||
|
catch (InvalidCastException ice)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
|
||||||
|
return (entry.OriginalIndex, AbCipStatusMapper.BadTypeMismatch);
|
||||||
|
}
|
||||||
|
catch (OverflowException oe)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
|
||||||
|
return (entry.OriginalIndex, AbCipStatusMapper.BadOutOfRange);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||||
|
return (entry.OriginalIndex, AbCipStatusMapper.BadCommunicationError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Execute one BOOL-within-DINT write through <see cref="WriteBitInDIntAsync"/>, with
|
||||||
|
/// the same exception-mapping fan-out as the pre-1.4 per-tag loop. Bit RMWs cannot be
|
||||||
|
/// packed because two concurrent writes against the same parent DINT would race their
|
||||||
|
/// read-modify-write windows.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<uint> ExecuteBitRmwWriteAsync(
|
||||||
|
DeviceState device, AbCipMultiWritePlanner.ClassifiedWrite entry, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bit = entry.ParsedPath!.BitIndex!.Value;
|
||||||
|
var code = await WriteBitInDIntAsync(device, entry.ParsedPath, bit, entry.Request.Value, ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (code == AbCipStatusMapper.Good)
|
||||||
|
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (NotSupportedException nse)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||||
|
return AbCipStatusMapper.BadNotSupported;
|
||||||
|
}
|
||||||
|
catch (FormatException fe)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
|
||||||
|
return AbCipStatusMapper.BadTypeMismatch;
|
||||||
|
}
|
||||||
|
catch (InvalidCastException ice)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
|
||||||
|
return AbCipStatusMapper.BadTypeMismatch;
|
||||||
|
}
|
||||||
|
catch (OverflowException oe)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
|
||||||
|
return AbCipStatusMapper.BadOutOfRange;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||||
|
return AbCipStatusMapper.BadCommunicationError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read-modify-write one bit within a DINT parent. Creates / reuses a parallel
|
/// Read-modify-write one bit within a DINT parent. Creates / reuses a parallel
|
||||||
/// parent-DINT runtime (distinct from the bit-selector handle) + serialises concurrent
|
/// parent-DINT runtime (distinct from the bit-selector handle) + serialises concurrent
|
||||||
@@ -633,7 +926,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
CipPath: device.ParsedAddress.CipPath,
|
CipPath: device.ParsedAddress.CipPath,
|
||||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||||
TagName: parsed.ToLibplctagName(),
|
TagName: parsed.ToLibplctagName(),
|
||||||
Timeout: _options.Timeout));
|
Timeout: _options.Timeout,
|
||||||
|
StringMaxCapacity: def.DataType == AbCipDataType.String ? def.StringLength : null));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||||
@@ -674,6 +968,43 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(builder);
|
ArgumentNullException.ThrowIfNull(builder);
|
||||||
|
await _discoverySemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DiscoverCoreAsync(builder, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_discoverySemaphore.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-2.5 — operator-triggered rebrowse. Drops the cached UDT template shapes so
|
||||||
|
/// the next read re-fetches them from the controller, then runs the same enumerator
|
||||||
|
/// walk + builder fan-out that <see cref="DiscoverAsync"/> drives. Serialised against
|
||||||
|
/// other rebrowse / discovery passes via <see cref="_discoverySemaphore"/> so two
|
||||||
|
/// concurrent triggers don't double-issue the @tags read.
|
||||||
|
/// </summary>
|
||||||
|
public async Task RebrowseAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(builder);
|
||||||
|
await _discoverySemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Stale template shapes can outlive a controller program-download, so a rebrowse
|
||||||
|
// is the natural moment to drop them; subsequent UDT reads re-populate on demand.
|
||||||
|
_templateCache.Clear();
|
||||||
|
await DiscoverCoreAsync(builder, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_discoverySemaphore.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DiscoverCoreAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
var root = builder.Folder("AbCip", "AbCip");
|
var root = builder.Folder("AbCip", "AbCip");
|
||||||
|
|
||||||
foreach (var device in _options.Devices)
|
foreach (var device in _options.Devices)
|
||||||
@@ -694,10 +1025,31 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
|
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
|
||||||
{
|
{
|
||||||
var udtFolder = deviceFolder.Folder(tag.Name, tag.Name);
|
var udtFolder = deviceFolder.Folder(tag.Name, tag.Name);
|
||||||
|
// PR abcip-2.6 — AOI-aware fan-out. When any member carries a non-Local
|
||||||
|
// AoiQualifier the tag is treated as an AOI instance: Input / Output / InOut
|
||||||
|
// members get grouped under sub-folders (Inputs/, Outputs/, InOut/) so the
|
||||||
|
// browse tree visually matches Studio 5000's AOI parameter tabs. Plain UDT
|
||||||
|
// tags (every member Local) retain the pre-2.6 flat layout under the parent
|
||||||
|
// folder so existing browse paths stay stable.
|
||||||
|
var hasDirectional = tag.Members.Any(m => m.AoiQualifier != AoiQualifier.Local);
|
||||||
|
IAddressSpaceBuilder? inputsFolder = null;
|
||||||
|
IAddressSpaceBuilder? outputsFolder = null;
|
||||||
|
IAddressSpaceBuilder? inOutFolder = null;
|
||||||
foreach (var member in tag.Members)
|
foreach (var member in tag.Members)
|
||||||
{
|
{
|
||||||
|
var parentFolder = udtFolder;
|
||||||
|
if (hasDirectional)
|
||||||
|
{
|
||||||
|
parentFolder = member.AoiQualifier switch
|
||||||
|
{
|
||||||
|
AoiQualifier.Input => inputsFolder ??= udtFolder.Folder("Inputs", "Inputs"),
|
||||||
|
AoiQualifier.Output => outputsFolder ??= udtFolder.Folder("Outputs", "Outputs"),
|
||||||
|
AoiQualifier.InOut => inOutFolder ??= udtFolder.Folder("InOut", "InOut"),
|
||||||
|
_ => udtFolder, // Local stays at the AOI root
|
||||||
|
};
|
||||||
|
}
|
||||||
var memberFullName = $"{tag.Name}.{member.Name}";
|
var memberFullName = $"{tag.Name}.{member.Name}";
|
||||||
udtFolder.Variable(member.Name, member.Name, new DriverAttributeInfo(
|
parentFolder.Variable(member.Name, member.Name, new DriverAttributeInfo(
|
||||||
FullName: memberFullName,
|
FullName: memberFullName,
|
||||||
DriverDataType: member.DataType.ToDriverDataType(),
|
DriverDataType: member.DataType.ToDriverDataType(),
|
||||||
IsArray: false,
|
IsArray: false,
|
||||||
@@ -707,7 +1059,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
: SecurityClassification.ViewOnly,
|
: SecurityClassification.ViewOnly,
|
||||||
IsHistorized: false,
|
IsHistorized: false,
|
||||||
IsAlarm: false,
|
IsAlarm: false,
|
||||||
WriteIdempotent: member.WriteIdempotent));
|
WriteIdempotent: member.WriteIdempotent,
|
||||||
|
Description: member.Description));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -767,7 +1120,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
: SecurityClassification.ViewOnly,
|
: SecurityClassification.ViewOnly,
|
||||||
IsHistorized: false,
|
IsHistorized: false,
|
||||||
IsAlarm: false,
|
IsAlarm: false,
|
||||||
WriteIdempotent: tag.WriteIdempotent);
|
WriteIdempotent: tag.WriteIdempotent,
|
||||||
|
Description: tag.Description);
|
||||||
|
|
||||||
/// <summary>Count of registered devices — exposed for diagnostics + tests.</summary>
|
/// <summary>Count of registered devices — exposed for diagnostics + tests.</summary>
|
||||||
internal int DeviceCount => _devices.Count;
|
internal int DeviceCount => _devices.Count;
|
||||||
@@ -781,6 +1135,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
|||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
_discoverySemaphore.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Static factory registration helper for <see cref="AbCipDriver"/>. Server's Program.cs
|
||||||
|
/// calls <see cref="Register"/> once at startup; the bootstrapper (task #248) then
|
||||||
|
/// materialises AB CIP DriverInstance rows from the central config DB into live driver
|
||||||
|
/// instances. Mirrors <c>GalaxyProxyDriverFactoryExtensions</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static class AbCipDriverFactoryExtensions
|
||||||
|
{
|
||||||
|
public const string DriverTypeName = "AbCip";
|
||||||
|
|
||||||
|
public static void Register(DriverFactoryRegistry registry)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(registry);
|
||||||
|
registry.Register(DriverTypeName, CreateInstance);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static AbCipDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
|
|
||||||
|
var dto = JsonSerializer.Deserialize<AbCipDriverConfigDto>(driverConfigJson, JsonOptions)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"AB CIP driver config for '{driverInstanceId}' deserialised to null");
|
||||||
|
|
||||||
|
var options = new AbCipDriverOptions
|
||||||
|
{
|
||||||
|
Devices = dto.Devices is { Count: > 0 }
|
||||||
|
? [.. dto.Devices.Select(d => new AbCipDeviceOptions(
|
||||||
|
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
|
||||||
|
$"AB CIP config for '{driverInstanceId}' has a device missing HostAddress"),
|
||||||
|
PlcFamily: ParseEnum<AbCipPlcFamily>(d.PlcFamily, "device", driverInstanceId, "PlcFamily",
|
||||||
|
fallback: AbCipPlcFamily.ControlLogix),
|
||||||
|
DeviceName: d.DeviceName))]
|
||||||
|
: [],
|
||||||
|
Tags = dto.Tags is { Count: > 0 }
|
||||||
|
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
|
||||||
|
: [],
|
||||||
|
Probe = new AbCipProbeOptions
|
||||||
|
{
|
||||||
|
Enabled = dto.Probe?.Enabled ?? true,
|
||||||
|
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
|
||||||
|
ProbeTagPath = dto.Probe?.ProbeTagPath,
|
||||||
|
},
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
|
||||||
|
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
|
||||||
|
EnableAlarmProjection = dto.EnableAlarmProjection ?? false,
|
||||||
|
AlarmPollInterval = TimeSpan.FromMilliseconds(dto.AlarmPollIntervalMs ?? 1_000),
|
||||||
|
};
|
||||||
|
|
||||||
|
return new AbCipDriver(options, driverInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AbCipTagDefinition BuildTag(AbCipTagDto t, string driverInstanceId) =>
|
||||||
|
new(
|
||||||
|
Name: t.Name ?? throw new InvalidOperationException(
|
||||||
|
$"AB CIP config for '{driverInstanceId}' has a tag missing Name"),
|
||||||
|
DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
|
||||||
|
$"AB CIP tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
|
||||||
|
TagPath: t.TagPath ?? throw new InvalidOperationException(
|
||||||
|
$"AB CIP tag '{t.Name}' in '{driverInstanceId}' missing TagPath"),
|
||||||
|
DataType: ParseEnum<AbCipDataType>(t.DataType, t.Name, driverInstanceId, "DataType"),
|
||||||
|
Writable: t.Writable ?? true,
|
||||||
|
WriteIdempotent: t.WriteIdempotent ?? false,
|
||||||
|
Members: t.Members is { Count: > 0 }
|
||||||
|
? [.. t.Members.Select(m => new AbCipStructureMember(
|
||||||
|
Name: m.Name ?? throw new InvalidOperationException(
|
||||||
|
$"AB CIP tag '{t.Name}' in '{driverInstanceId}' has a member missing Name"),
|
||||||
|
DataType: ParseEnum<AbCipDataType>(m.DataType, t.Name, driverInstanceId,
|
||||||
|
$"Members[{m.Name}].DataType"),
|
||||||
|
Writable: m.Writable ?? true,
|
||||||
|
WriteIdempotent: m.WriteIdempotent ?? false))]
|
||||||
|
: null,
|
||||||
|
SafetyTag: t.SafetyTag ?? false);
|
||||||
|
|
||||||
|
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field,
|
||||||
|
T? fallback = null) where T : struct, Enum
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
if (fallback.HasValue) return fallback.Value;
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"AB CIP tag '{tagName ?? "<unnamed>"}' in '{driverInstanceId}' missing {field}");
|
||||||
|
}
|
||||||
|
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
|
||||||
|
? v
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
$"AB CIP tag '{tagName}' has unknown {field} '{raw}'. " +
|
||||||
|
$"Expected one of {string.Join(", ", Enum.GetNames<T>())}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
AllowTrailingCommas = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
internal sealed class AbCipDriverConfigDto
|
||||||
|
{
|
||||||
|
public int? TimeoutMs { get; init; }
|
||||||
|
public bool? EnableControllerBrowse { get; init; }
|
||||||
|
public bool? EnableAlarmProjection { get; init; }
|
||||||
|
public int? AlarmPollIntervalMs { get; init; }
|
||||||
|
public List<AbCipDeviceDto>? Devices { get; init; }
|
||||||
|
public List<AbCipTagDto>? Tags { get; init; }
|
||||||
|
public AbCipProbeDto? Probe { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class AbCipDeviceDto
|
||||||
|
{
|
||||||
|
public string? HostAddress { get; init; }
|
||||||
|
public string? PlcFamily { get; init; }
|
||||||
|
public string? DeviceName { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class AbCipTagDto
|
||||||
|
{
|
||||||
|
public string? Name { get; init; }
|
||||||
|
public string? DeviceHostAddress { get; init; }
|
||||||
|
public string? TagPath { get; init; }
|
||||||
|
public string? DataType { get; init; }
|
||||||
|
public bool? Writable { get; init; }
|
||||||
|
public bool? WriteIdempotent { get; init; }
|
||||||
|
public List<AbCipMemberDto>? Members { get; init; }
|
||||||
|
public bool? SafetyTag { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class AbCipMemberDto
|
||||||
|
{
|
||||||
|
public string? Name { get; init; }
|
||||||
|
public string? DataType { get; init; }
|
||||||
|
public bool? Writable { get; init; }
|
||||||
|
public bool? WriteIdempotent { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class AbCipProbeDto
|
||||||
|
{
|
||||||
|
public bool? Enabled { get; init; }
|
||||||
|
public int? IntervalMs { get; init; }
|
||||||
|
public int? TimeoutMs { get; init; }
|
||||||
|
public string? ProbeTagPath { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,37 @@ public sealed class AbCipDriverOptions
|
|||||||
/// <summary>Pre-declared tag map across all devices — AB discovery lands in PR 5.</summary>
|
/// <summary>Pre-declared tag map across all devices — AB discovery lands in PR 5.</summary>
|
||||||
public IReadOnlyList<AbCipTagDefinition> Tags { get; init; } = [];
|
public IReadOnlyList<AbCipTagDefinition> Tags { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L5K (Studio 5000 controller export) imports merged into <see cref="Tags"/> at
|
||||||
|
/// <c>InitializeAsync</c>. Each entry points at one L5K file + the device whose tags it
|
||||||
|
/// describes; the parser extracts <c>TAG</c> + <c>DATATYPE</c> blocks and produces
|
||||||
|
/// <see cref="AbCipTagDefinition"/> records (alias tags + ExternalAccess=None tags
|
||||||
|
/// skipped — see <see cref="Import.L5kIngest"/>). Pre-declared <see cref="Tags"/> entries
|
||||||
|
/// win on <c>Name</c> conflicts so operators can override import results without
|
||||||
|
/// editing the L5K source.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<AbCipL5kImportOptions> L5kImports { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// L5X (Studio 5000 XML controller export) imports merged into <see cref="Tags"/> at
|
||||||
|
/// <c>InitializeAsync</c>. Same shape and merge semantics as <see cref="L5kImports"/> —
|
||||||
|
/// the entries differ only in source format. Pre-declared <see cref="Tags"/> entries win
|
||||||
|
/// on <c>Name</c> conflicts; entries already produced by <see cref="L5kImports"/> also win
|
||||||
|
/// so an L5X re-export of the same controller doesn't double-emit. See
|
||||||
|
/// <see cref="Import.L5xParser"/> for the format-specific mechanics.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<AbCipL5xImportOptions> L5xImports { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Kepware-format CSV imports merged into <see cref="Tags"/> at <c>InitializeAsync</c>.
|
||||||
|
/// Same merge semantics as <see cref="L5kImports"/> / <see cref="L5xImports"/> —
|
||||||
|
/// pre-declared <see cref="Tags"/> entries win on <c>Name</c> conflicts, and tags
|
||||||
|
/// produced by earlier import collections (L5K → L5X → CSV in call order) also win
|
||||||
|
/// so an Excel-edited copy of the same controller does not double-emit. See
|
||||||
|
/// <see cref="Import.CsvTagImporter"/> for the column layout + parse rules.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<AbCipCsvImportOptions> CsvImports { get; init; } = [];
|
||||||
|
|
||||||
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
|
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
|
||||||
public AbCipProbeOptions Probe { get; init; } = new();
|
public AbCipProbeOptions Probe { get; init; } = new();
|
||||||
|
|
||||||
@@ -92,6 +123,17 @@ public sealed record AbCipDeviceOptions(
|
|||||||
/// GuardLogix controller; non-safety writes violate the safety-partition isolation and are
|
/// GuardLogix controller; non-safety writes violate the safety-partition isolation and are
|
||||||
/// rejected by the PLC anyway. Surfaces the intent explicitly instead of relying on the
|
/// rejected by the PLC anyway. Surfaces the intent explicitly instead of relying on the
|
||||||
/// write attempt failing at runtime.</param>
|
/// write attempt failing at runtime.</param>
|
||||||
|
/// <param name="StringLength">Capacity of the DATA character array on a Logix STRING / STRINGnn
|
||||||
|
/// UDT — 82 for the stock <c>STRING</c>, 20/40/80/etc for user-defined <c>STRING_20</c>,
|
||||||
|
/// <c>STRING_40</c>, <c>STRING_80</c> variants. Threads through libplctag's
|
||||||
|
/// <c>str_max_capacity</c> attribute so the wrapper allocates the correct backing buffer
|
||||||
|
/// and <c>GetString</c> / <c>SetString</c> truncate at the right boundary. <c>null</c>
|
||||||
|
/// keeps libplctag's default 82-byte STRING behaviour for back-compat. Ignored for
|
||||||
|
/// non-<see cref="AbCipDataType.String"/> types.</param>
|
||||||
|
/// <param name="Description">Tag description carried from the L5K/L5X export (or set explicitly
|
||||||
|
/// in pre-declared config). Surfaces as the OPC UA <c>Description</c> attribute on the
|
||||||
|
/// produced Variable node so SCADA / engineering clients see the comment from the source
|
||||||
|
/// project. <c>null</c> leaves Description unset, matching pre-2.3 behaviour.</param>
|
||||||
public sealed record AbCipTagDefinition(
|
public sealed record AbCipTagDefinition(
|
||||||
string Name,
|
string Name,
|
||||||
string DeviceHostAddress,
|
string DeviceHostAddress,
|
||||||
@@ -100,7 +142,9 @@ public sealed record AbCipTagDefinition(
|
|||||||
bool Writable = true,
|
bool Writable = true,
|
||||||
bool WriteIdempotent = false,
|
bool WriteIdempotent = false,
|
||||||
IReadOnlyList<AbCipStructureMember>? Members = null,
|
IReadOnlyList<AbCipStructureMember>? Members = null,
|
||||||
bool SafetyTag = false);
|
bool SafetyTag = false,
|
||||||
|
int? StringLength = null,
|
||||||
|
string? Description = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>,
|
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>,
|
||||||
@@ -108,11 +152,92 @@ public sealed record AbCipTagDefinition(
|
|||||||
/// <see cref="AbCipTagDefinition"/>. Declaration-driven — the real CIP Template Object reader
|
/// <see cref="AbCipTagDefinition"/>. Declaration-driven — the real CIP Template Object reader
|
||||||
/// (class 0x6C) that would auto-discover member layouts lands as a follow-up PR.
|
/// (class 0x6C) that would auto-discover member layouts lands as a follow-up PR.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><see cref="Description"/> carries the per-member comment from L5K/L5X UDT definitions so
|
||||||
|
/// the OPC UA Variable nodes produced for individual members surface their descriptions too,
|
||||||
|
/// not just the top-level tag.</para>
|
||||||
|
/// <para>PR abcip-2.6 — <see cref="AoiQualifier"/> tags AOI parameters as Input / Output /
|
||||||
|
/// InOut / Local. Plain UDT members default to <see cref="AoiQualifier.Local"/>. Discovery
|
||||||
|
/// groups Input / Output / InOut members under sub-folders so an AOI-typed tag fans out as
|
||||||
|
/// <c>Tag/Inputs/...</c>, <c>Tag/Outputs/...</c>, <c>Tag/InOut/...</c> while Local stays at the
|
||||||
|
/// UDT root — matching how AOIs visually present in Studio 5000.</para>
|
||||||
|
/// </remarks>
|
||||||
public sealed record AbCipStructureMember(
|
public sealed record AbCipStructureMember(
|
||||||
string Name,
|
string Name,
|
||||||
AbCipDataType DataType,
|
AbCipDataType DataType,
|
||||||
bool Writable = true,
|
bool Writable = true,
|
||||||
bool WriteIdempotent = false);
|
bool WriteIdempotent = false,
|
||||||
|
int? StringLength = null,
|
||||||
|
string? Description = null,
|
||||||
|
AoiQualifier AoiQualifier = AoiQualifier.Local);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-2.6 — directional qualifier for AOI parameters. Surfaces the Studio 5000
|
||||||
|
/// <c>Usage</c> attribute (<c>Input</c> / <c>Output</c> / <c>InOut</c>) so discovery can group
|
||||||
|
/// AOI members into sub-folders and downstream consumers can reason about parameter direction.
|
||||||
|
/// Plain UDT members (non-AOI types) default to <see cref="Local"/>, which keeps them at the
|
||||||
|
/// UDT root + indicates they are internal storage rather than a directional parameter.
|
||||||
|
/// </summary>
|
||||||
|
public enum AoiQualifier
|
||||||
|
{
|
||||||
|
/// <summary>UDT member or AOI local tag — non-directional, browsed at the parent's root.</summary>
|
||||||
|
Local,
|
||||||
|
|
||||||
|
/// <summary>AOI input parameter — written by the caller, read by the AOI body.</summary>
|
||||||
|
Input,
|
||||||
|
|
||||||
|
/// <summary>AOI output parameter — written by the AOI body, read by the caller.</summary>
|
||||||
|
Output,
|
||||||
|
|
||||||
|
/// <summary>AOI bidirectional parameter — passed by reference, both sides may read/write.</summary>
|
||||||
|
InOut,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One L5K-import entry. Either <see cref="FilePath"/> or <see cref="InlineText"/> must be
|
||||||
|
/// set (FilePath wins when both supplied — useful for tests that pre-load fixtures into
|
||||||
|
/// options without touching disk).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="DeviceHostAddress">Target device <c>HostAddress</c> tags from this file are bound to.</param>
|
||||||
|
/// <param name="FilePath">On-disk path to a <c>*.L5K</c> export. Loaded eagerly at InitializeAsync.</param>
|
||||||
|
/// <param name="InlineText">Pre-loaded L5K body — used by tests + Admin UI uploads.</param>
|
||||||
|
/// <param name="NamePrefix">Optional prefix prepended to imported tag names to avoid collisions
|
||||||
|
/// when ingesting multiple files into one driver instance.</param>
|
||||||
|
public sealed record AbCipL5kImportOptions(
|
||||||
|
string DeviceHostAddress,
|
||||||
|
string? FilePath = null,
|
||||||
|
string? InlineText = null,
|
||||||
|
string NamePrefix = "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One L5X-import entry. Mirrors <see cref="AbCipL5kImportOptions"/> field-for-field — the
|
||||||
|
/// two are kept as distinct types so configuration JSON makes the source format explicit
|
||||||
|
/// (an L5X file under an <c>L5kImports</c> entry would parse-fail confusingly otherwise).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="DeviceHostAddress">Target device <c>HostAddress</c> tags from this file are bound to.</param>
|
||||||
|
/// <param name="FilePath">On-disk path to a <c>*.L5X</c> XML export. Loaded eagerly at InitializeAsync.</param>
|
||||||
|
/// <param name="InlineText">Pre-loaded L5X body — used by tests + Admin UI uploads.</param>
|
||||||
|
/// <param name="NamePrefix">Optional prefix prepended to imported tag names to avoid collisions
|
||||||
|
/// when ingesting multiple files into one driver instance.</param>
|
||||||
|
public sealed record AbCipL5xImportOptions(
|
||||||
|
string DeviceHostAddress,
|
||||||
|
string? FilePath = null,
|
||||||
|
string? InlineText = null,
|
||||||
|
string NamePrefix = "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One Kepware-format CSV import entry. Field shape mirrors <see cref="AbCipL5kImportOptions"/>
|
||||||
|
/// so configuration JSON stays consistent across the three import sources.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="DeviceHostAddress">Target device <c>HostAddress</c> tags from this file are bound to.</param>
|
||||||
|
/// <param name="FilePath">On-disk path to a Kepware-format <c>*.csv</c>. Loaded eagerly at InitializeAsync.</param>
|
||||||
|
/// <param name="InlineText">Pre-loaded CSV body — used by tests + Admin UI uploads.</param>
|
||||||
|
/// <param name="NamePrefix">Optional prefix prepended to imported tag names to avoid collisions.</param>
|
||||||
|
public sealed record AbCipCsvImportOptions(
|
||||||
|
string DeviceHostAddress,
|
||||||
|
string? FilePath = null,
|
||||||
|
string? InlineText = null,
|
||||||
|
string NamePrefix = "");
|
||||||
|
|
||||||
/// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary>
|
/// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary>
|
||||||
public enum AbCipPlcFamily
|
public enum AbCipPlcFamily
|
||||||
|
|||||||
112
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipMultiWritePlanner.cs
Normal file
112
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipMultiWritePlanner.cs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-1.4 — multi-tag write planner. Groups a batch of <see cref="WriteRequest"/>s by
|
||||||
|
/// device so the driver can submit one round of writes per device instead of looping
|
||||||
|
/// strictly serially across the whole batch. Honours the per-family
|
||||||
|
/// <see cref="AbCipPlcFamilyProfile.SupportsRequestPacking"/> flag: families that support
|
||||||
|
/// CIP request packing (ControlLogix / CompactLogix / GuardLogix) issue their writes in
|
||||||
|
/// parallel so libplctag's internal scheduler can coalesce them onto one Multi-Service
|
||||||
|
/// Packet (0x0A); Micro800 (no request packing) falls back to per-tag sequential writes.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>The libplctag .NET wrapper exposes one CIP service per <c>Tag</c> instance and does
|
||||||
|
/// not surface Multi-Service Packet construction at the API surface — but the underlying
|
||||||
|
/// native library packs concurrent operations against the same connection automatically
|
||||||
|
/// when the family's protocol supports it. Issuing the writes concurrently per device
|
||||||
|
/// therefore gives us the round-trip reduction described in #228 without having to drop to
|
||||||
|
/// raw CIP, while still letting us short-circuit packing on Micro800 where it would be
|
||||||
|
/// unsafe.</para>
|
||||||
|
///
|
||||||
|
/// <para>Bit-RMW writes (BOOL-with-bitIndex against a DINT parent) are excluded from
|
||||||
|
/// packing here because they need a serialised read-modify-write under the per-parent
|
||||||
|
/// <c>SemaphoreSlim</c> in <see cref="AbCipDriver.WriteBitInDIntAsync"/>. Packing two RMWs
|
||||||
|
/// on the same DINT would risk losing one another's update.</para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class AbCipMultiWritePlanner
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One classified entry in the input batch. <see cref="OriginalIndex"/> preserves the
|
||||||
|
/// caller's ordering so per-tag <c>StatusCode</c> fan-out lands at the right slot in
|
||||||
|
/// the result array. <see cref="IsBitRmw"/> routes the entry through the RMW path even
|
||||||
|
/// when the device supports packing.
|
||||||
|
/// </summary>
|
||||||
|
internal readonly record struct ClassifiedWrite(
|
||||||
|
int OriginalIndex,
|
||||||
|
WriteRequest Request,
|
||||||
|
AbCipTagDefinition Definition,
|
||||||
|
AbCipTagPath? ParsedPath,
|
||||||
|
bool IsBitRmw);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One device's plan slice. <see cref="Packable"/> entries can be issued concurrently;
|
||||||
|
/// <see cref="BitRmw"/> entries must go through the RMW path one-at-a-time per parent
|
||||||
|
/// DINT.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class DevicePlan
|
||||||
|
{
|
||||||
|
public required string DeviceHostAddress { get; init; }
|
||||||
|
public required AbCipPlcFamilyProfile Profile { get; init; }
|
||||||
|
public List<ClassifiedWrite> Packable { get; } = new();
|
||||||
|
public List<ClassifiedWrite> BitRmw { get; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the per-device plan list. Entries are visited in input order so the resulting
|
||||||
|
/// plan's traversal preserves caller ordering within each device. Entries that fail
|
||||||
|
/// resolution (unknown reference, non-writable tag, unknown device) are reported via
|
||||||
|
/// <paramref name="reportPreflight"/> with the appropriate StatusCode and excluded from
|
||||||
|
/// the plan.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<DevicePlan> Build(
|
||||||
|
IReadOnlyList<WriteRequest> writes,
|
||||||
|
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName,
|
||||||
|
IReadOnlyDictionary<string, AbCipDriver.DeviceState> devices,
|
||||||
|
Action<int, uint> reportPreflight)
|
||||||
|
{
|
||||||
|
var plans = new Dictionary<string, DevicePlan>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var order = new List<DevicePlan>();
|
||||||
|
|
||||||
|
for (var i = 0; i < writes.Count; i++)
|
||||||
|
{
|
||||||
|
var w = writes[i];
|
||||||
|
if (!tagsByName.TryGetValue(w.FullReference, out var def))
|
||||||
|
{
|
||||||
|
reportPreflight(i, AbCipStatusMapper.BadNodeIdUnknown);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!def.Writable || def.SafetyTag)
|
||||||
|
{
|
||||||
|
reportPreflight(i, AbCipStatusMapper.BadNotWritable);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||||
|
{
|
||||||
|
reportPreflight(i, AbCipStatusMapper.BadNodeIdUnknown);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plans.TryGetValue(def.DeviceHostAddress, out var plan))
|
||||||
|
{
|
||||||
|
plan = new DevicePlan
|
||||||
|
{
|
||||||
|
DeviceHostAddress = def.DeviceHostAddress,
|
||||||
|
Profile = device.Profile,
|
||||||
|
};
|
||||||
|
plans[def.DeviceHostAddress] = plan;
|
||||||
|
order.Add(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed = AbCipTagPath.TryParse(def.TagPath);
|
||||||
|
var isBitRmw = def.DataType == AbCipDataType.Bool && parsed?.BitIndex is int;
|
||||||
|
var entry = new ClassifiedWrite(i, w, def, parsed, isBitRmw);
|
||||||
|
if (isBitRmw) plan.BitRmw.Add(entry);
|
||||||
|
else plan.Packable.Add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
|||||||
public sealed record AbCipTagPath(
|
public sealed record AbCipTagPath(
|
||||||
string? ProgramScope,
|
string? ProgramScope,
|
||||||
IReadOnlyList<AbCipTagPathSegment> Segments,
|
IReadOnlyList<AbCipTagPathSegment> Segments,
|
||||||
int? BitIndex)
|
int? BitIndex,
|
||||||
|
AbCipTagPathSlice? Slice = null)
|
||||||
{
|
{
|
||||||
/// <summary>Rebuild the canonical Logix tag string.</summary>
|
/// <summary>Rebuild the canonical Logix tag string.</summary>
|
||||||
public string ToLibplctagName()
|
public string ToLibplctagName()
|
||||||
@@ -37,10 +38,39 @@ public sealed record AbCipTagPath(
|
|||||||
if (seg.Subscripts.Count > 0)
|
if (seg.Subscripts.Count > 0)
|
||||||
buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']');
|
buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']');
|
||||||
}
|
}
|
||||||
|
if (Slice is not null) buf.Append('[').Append(Slice.Start).Append("..").Append(Slice.End).Append(']');
|
||||||
if (BitIndex is not null) buf.Append('.').Append(BitIndex.Value);
|
if (BitIndex is not null) buf.Append('.').Append(BitIndex.Value);
|
||||||
return buf.ToString();
|
return buf.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Logix-symbol form for issuing a single libplctag tag-create that reads the slice as a
|
||||||
|
/// contiguous buffer — i.e. the bare array name (with the start subscript) without the
|
||||||
|
/// <c>..End</c> suffix. The driver pairs this with <see cref="AbCipTagCreateParams.ElementCount"/>
|
||||||
|
/// = <see cref="AbCipTagPathSlice.Count"/> to issue a single Rockwell array read.
|
||||||
|
/// </summary>
|
||||||
|
public string ToLibplctagSliceArrayName()
|
||||||
|
{
|
||||||
|
if (Slice is null) return ToLibplctagName();
|
||||||
|
var buf = new System.Text.StringBuilder();
|
||||||
|
if (ProgramScope is not null)
|
||||||
|
buf.Append("Program:").Append(ProgramScope).Append('.');
|
||||||
|
|
||||||
|
for (var i = 0; i < Segments.Count; i++)
|
||||||
|
{
|
||||||
|
if (i > 0) buf.Append('.');
|
||||||
|
var seg = Segments[i];
|
||||||
|
buf.Append(seg.Name);
|
||||||
|
if (seg.Subscripts.Count > 0)
|
||||||
|
buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']');
|
||||||
|
}
|
||||||
|
// Anchor the read at the slice start; libplctag treats Name=Tag[0] + ElementCount=N as
|
||||||
|
// "read N consecutive elements starting at index 0", which is the exact Rockwell
|
||||||
|
// array-read semantic this PR is wiring up.
|
||||||
|
buf.Append('[').Append(Slice.Start).Append(']');
|
||||||
|
return buf.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parse a Logix-symbolic tag reference. Returns <c>null</c> on a shape the parser
|
/// Parse a Logix-symbolic tag reference. Returns <c>null</c> on a shape the parser
|
||||||
/// doesn't support — the driver surfaces that as a config-validation error rather than
|
/// doesn't support — the driver surfaces that as a config-validation error rather than
|
||||||
@@ -91,8 +121,10 @@ public sealed record AbCipTagPath(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var segments = new List<AbCipTagPathSegment>(parts.Count);
|
var segments = new List<AbCipTagPathSegment>(parts.Count);
|
||||||
foreach (var part in parts)
|
AbCipTagPathSlice? slice = null;
|
||||||
|
for (var partIdx = 0; partIdx < parts.Count; partIdx++)
|
||||||
{
|
{
|
||||||
|
var part = parts[partIdx];
|
||||||
var bracketIdx = part.IndexOf('[');
|
var bracketIdx = part.IndexOf('[');
|
||||||
if (bracketIdx < 0)
|
if (bracketIdx < 0)
|
||||||
{
|
{
|
||||||
@@ -104,6 +136,25 @@ public sealed record AbCipTagPath(
|
|||||||
var name = part[..bracketIdx];
|
var name = part[..bracketIdx];
|
||||||
if (!IsValidIdent(name)) return null;
|
if (!IsValidIdent(name)) return null;
|
||||||
var inner = part[(bracketIdx + 1)..^1];
|
var inner = part[(bracketIdx + 1)..^1];
|
||||||
|
|
||||||
|
// Slice syntax `[N..M]` — only allowed on the LAST segment, must not coexist with
|
||||||
|
// multi-dim subscripts, must not be combined with bit-index, and requires M >= N.
|
||||||
|
// Any other shape is rejected so callers see a config-validation error rather than
|
||||||
|
// the driver attempting a best-effort scalar read.
|
||||||
|
if (inner.Contains(".."))
|
||||||
|
{
|
||||||
|
if (partIdx != parts.Count - 1) return null; // slice + sub-element
|
||||||
|
if (bitIndex is not null) return null; // slice + bit index
|
||||||
|
if (inner.Contains(',')) return null; // slice cannot be multi-dim
|
||||||
|
var parts2 = inner.Split("..", 2, StringSplitOptions.None);
|
||||||
|
if (parts2.Length != 2) return null;
|
||||||
|
if (!int.TryParse(parts2[0], out var sliceStart) || sliceStart < 0) return null;
|
||||||
|
if (!int.TryParse(parts2[1], out var sliceEnd) || sliceEnd < sliceStart) return null;
|
||||||
|
slice = new AbCipTagPathSlice(sliceStart, sliceEnd);
|
||||||
|
segments.Add(new AbCipTagPathSegment(name, []));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var subs = new List<int>();
|
var subs = new List<int>();
|
||||||
foreach (var tok in inner.Split(','))
|
foreach (var tok in inner.Split(','))
|
||||||
{
|
{
|
||||||
@@ -115,7 +166,7 @@ public sealed record AbCipTagPath(
|
|||||||
}
|
}
|
||||||
if (segments.Count == 0) return null;
|
if (segments.Count == 0) return null;
|
||||||
|
|
||||||
return new AbCipTagPath(programScope, segments, bitIndex);
|
return new AbCipTagPath(programScope, segments, bitIndex, slice);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsValidIdent(string s)
|
private static bool IsValidIdent(string s)
|
||||||
@@ -130,3 +181,15 @@ public sealed record AbCipTagPath(
|
|||||||
|
|
||||||
/// <summary>One path segment: a member name plus any numeric subscripts.</summary>
|
/// <summary>One path segment: a member name plus any numeric subscripts.</summary>
|
||||||
public sealed record AbCipTagPathSegment(string Name, IReadOnlyList<int> Subscripts);
|
public sealed record AbCipTagPathSegment(string Name, IReadOnlyList<int> Subscripts);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inclusive-on-both-ends array slice carried on the trailing segment of an
|
||||||
|
/// <see cref="AbCipTagPath"/>. <c>Tag[0..15]</c> parses to <c>Start=0, End=15</c>; the
|
||||||
|
/// planner pairs this with libplctag's <c>ElementCount</c> attribute to issue a single
|
||||||
|
/// Rockwell array read covering <c>End - Start + 1</c> elements.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AbCipTagPathSlice(int Start, int End)
|
||||||
|
{
|
||||||
|
/// <summary>Total element count covered by the slice (inclusive both ends).</summary>
|
||||||
|
public int Count => End - Start + 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,10 +65,20 @@ public interface IAbCipTagFactory
|
|||||||
/// <param name="LibplctagPlcAttribute">libplctag <c>plc=...</c> attribute, per family profile.</param>
|
/// <param name="LibplctagPlcAttribute">libplctag <c>plc=...</c> attribute, per family profile.</param>
|
||||||
/// <param name="TagName">Logix symbolic tag name as emitted by <see cref="AbCipTagPath.ToLibplctagName"/>.</param>
|
/// <param name="TagName">Logix symbolic tag name as emitted by <see cref="AbCipTagPath.ToLibplctagName"/>.</param>
|
||||||
/// <param name="Timeout">libplctag operation timeout (applies to Initialize / Read / Write).</param>
|
/// <param name="Timeout">libplctag operation timeout (applies to Initialize / Read / Write).</param>
|
||||||
|
/// <param name="StringMaxCapacity">Optional Logix STRINGnn DATA-array capacity (e.g. 20 / 40 / 80
|
||||||
|
/// for <c>STRING_20</c> / <c>STRING_40</c> / <c>STRING_80</c> UDTs). Threads through libplctag's
|
||||||
|
/// <c>str_max_capacity</c> attribute. <c>null</c> keeps libplctag's default 82-byte STRING
|
||||||
|
/// behaviour for back-compat.</param>
|
||||||
|
/// <param name="ElementCount">Optional libplctag <c>ElementCount</c> override — set to <c>N</c>
|
||||||
|
/// to issue a Rockwell array read covering <c>N</c> consecutive elements starting at the
|
||||||
|
/// subscripted index in <see cref="TagName"/>. Drives PR abcip-1.3 array-slice support;
|
||||||
|
/// <c>null</c> leaves libplctag's default scalar-element behaviour for back-compat.</param>
|
||||||
public sealed record AbCipTagCreateParams(
|
public sealed record AbCipTagCreateParams(
|
||||||
string Gateway,
|
string Gateway,
|
||||||
int Port,
|
int Port,
|
||||||
string CipPath,
|
string CipPath,
|
||||||
string LibplctagPlcAttribute,
|
string LibplctagPlcAttribute,
|
||||||
string TagName,
|
string TagName,
|
||||||
TimeSpan Timeout);
|
TimeSpan Timeout,
|
||||||
|
int? StringMaxCapacity = null,
|
||||||
|
int? ElementCount = null);
|
||||||
|
|||||||
99
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagExporter.cs
Normal file
99
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagExporter.cs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Render an enumerable of <see cref="AbCipTagDefinition"/> as a Kepware-format CSV
|
||||||
|
/// document. Emits the header expected by <see cref="CsvTagImporter"/> so the importer
|
||||||
|
/// and exporter form a complete round-trip path: load → export → reparse → identical
|
||||||
|
/// entries (modulo unknown-type tags, which export as <c>STRING</c> and reimport as
|
||||||
|
/// <see cref="AbCipDataType.Structure"/> per the importer's fall-through rule).
|
||||||
|
/// </summary>
|
||||||
|
public static class CsvTagExporter
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlyList<string> KepwareColumns =
|
||||||
|
[
|
||||||
|
"Tag Name",
|
||||||
|
"Address",
|
||||||
|
"Data Type",
|
||||||
|
"Respect Data Type",
|
||||||
|
"Client Access",
|
||||||
|
"Scan Rate",
|
||||||
|
"Description",
|
||||||
|
"Scaling",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>Write the tag list to <paramref name="writer"/> in Kepware CSV format.</summary>
|
||||||
|
public static void Write(IEnumerable<AbCipTagDefinition> tags, TextWriter writer)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(tags);
|
||||||
|
ArgumentNullException.ThrowIfNull(writer);
|
||||||
|
|
||||||
|
writer.WriteLine(string.Join(",", KepwareColumns.Select(EscapeField)));
|
||||||
|
foreach (var tag in tags)
|
||||||
|
{
|
||||||
|
var fields = new[]
|
||||||
|
{
|
||||||
|
tag.Name ?? string.Empty,
|
||||||
|
tag.TagPath ?? string.Empty,
|
||||||
|
FormatDataType(tag.DataType),
|
||||||
|
"1", // Respect Data Type — Kepware EX default.
|
||||||
|
tag.Writable ? "Read/Write" : "Read Only",
|
||||||
|
"100", // Scan Rate (ms) — placeholder default.
|
||||||
|
tag.Description ?? string.Empty,
|
||||||
|
"None", // Scaling — driver doesn't apply scaling.
|
||||||
|
};
|
||||||
|
writer.WriteLine(string.Join(",", fields.Select(EscapeField)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Render the tag list to a string.</summary>
|
||||||
|
public static string ToCsv(IEnumerable<AbCipTagDefinition> tags)
|
||||||
|
{
|
||||||
|
using var sw = new StringWriter(CultureInfo.InvariantCulture);
|
||||||
|
Write(tags, sw);
|
||||||
|
return sw.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Write the tag list to <paramref name="path"/> as UTF-8 (no BOM).</summary>
|
||||||
|
public static void WriteFile(IEnumerable<AbCipTagDefinition> tags, string path)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(path);
|
||||||
|
using var sw = new StreamWriter(path, append: false, new UTF8Encoding(false));
|
||||||
|
Write(tags, sw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatDataType(AbCipDataType t) => t switch
|
||||||
|
{
|
||||||
|
AbCipDataType.Bool => "BOOL",
|
||||||
|
AbCipDataType.SInt => "SINT",
|
||||||
|
AbCipDataType.Int => "INT",
|
||||||
|
AbCipDataType.DInt => "DINT",
|
||||||
|
AbCipDataType.LInt => "LINT",
|
||||||
|
AbCipDataType.USInt => "USINT",
|
||||||
|
AbCipDataType.UInt => "UINT",
|
||||||
|
AbCipDataType.UDInt => "UDINT",
|
||||||
|
AbCipDataType.ULInt => "ULINT",
|
||||||
|
AbCipDataType.Real => "REAL",
|
||||||
|
AbCipDataType.LReal => "LREAL",
|
||||||
|
AbCipDataType.String => "STRING",
|
||||||
|
AbCipDataType.Dt => "DT",
|
||||||
|
AbCipDataType.Structure => "STRING", // Surface UDT-typed tags as STRING — Kepware has no UDT cell.
|
||||||
|
_ => "STRING",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Quote a field if it contains comma, quote, CR, or LF; escape embedded quotes by doubling.</summary>
|
||||||
|
private static string EscapeField(string value)
|
||||||
|
{
|
||||||
|
value ??= string.Empty;
|
||||||
|
var needsQuotes =
|
||||||
|
value.IndexOf(',') >= 0 ||
|
||||||
|
value.IndexOf('"') >= 0 ||
|
||||||
|
value.IndexOf('\r') >= 0 ||
|
||||||
|
value.IndexOf('\n') >= 0;
|
||||||
|
if (!needsQuotes) return value;
|
||||||
|
return "\"" + value.Replace("\"", "\"\"") + "\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
226
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagImporter.cs
Normal file
226
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagImporter.cs
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a Kepware-format AB CIP tag CSV into <see cref="AbCipTagDefinition"/> entries.
|
||||||
|
/// The expected column layout matches the Kepware EX tag-export shape so operators can
|
||||||
|
/// round-trip tags through Excel without re-keying:
|
||||||
|
/// <c>Tag Name, Address, Data Type, Respect Data Type, Client Access, Scan Rate,
|
||||||
|
/// Description, Scaling</c>. The first non-blank, non-comment row is treated as the
|
||||||
|
/// header — column order is honoured by name lookup, so reorderings out of Excel still
|
||||||
|
/// work. Blank rows + rows whose first cell starts with a Kepware section marker
|
||||||
|
/// (<c>;</c> / <c>#</c>) are skipped.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Mapping: <c>Tag Name</c> → <see cref="AbCipTagDefinition.Name"/>;
|
||||||
|
/// <c>Address</c> → <see cref="AbCipTagDefinition.TagPath"/>;
|
||||||
|
/// <c>Data Type</c> → <see cref="AbCipTagDefinition.DataType"/> (Logix atomic name —
|
||||||
|
/// BOOL/SINT/INT/DINT/REAL/STRING/...; unknown values fall through as
|
||||||
|
/// <see cref="AbCipDataType.Structure"/> the same way <see cref="L5kIngest"/> handles
|
||||||
|
/// unknown types);
|
||||||
|
/// <c>Description</c> → <see cref="AbCipTagDefinition.Description"/>;
|
||||||
|
/// <c>Client Access</c> → <see cref="AbCipTagDefinition.Writable"/>: any value
|
||||||
|
/// containing <c>W</c> (case-insensitive) is treated as Read/Write; everything else
|
||||||
|
/// is Read-Only.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// CSV semantics are RFC-4180-ish: double-quoted fields support embedded commas, line
|
||||||
|
/// breaks, and escaped quotes (<c>""</c>). The parser is single-pass + deliberately
|
||||||
|
/// narrow — Kepware's exporter does not produce anything more exotic.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class CsvTagImporter
|
||||||
|
{
|
||||||
|
/// <summary>Default device host address applied to every imported tag.</summary>
|
||||||
|
public string DefaultDeviceHostAddress { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Optional prefix prepended to each imported tag's name. Default empty.</summary>
|
||||||
|
public string NamePrefix { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public CsvTagImportResult Import(string csvText)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(csvText);
|
||||||
|
if (string.IsNullOrWhiteSpace(DefaultDeviceHostAddress))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"{nameof(CsvTagImporter)}.{nameof(DefaultDeviceHostAddress)} must be set before {nameof(Import)} is called — every imported tag needs a target device.");
|
||||||
|
|
||||||
|
var rows = CsvReader.ReadAll(csvText);
|
||||||
|
var tags = new List<AbCipTagDefinition>();
|
||||||
|
var skippedBlank = 0;
|
||||||
|
Dictionary<string, int>? header = null;
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
if (row.Count == 0 || row.All(string.IsNullOrWhiteSpace))
|
||||||
|
{
|
||||||
|
skippedBlank++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var first = row[0].TrimStart();
|
||||||
|
if (first.StartsWith(';') || first.StartsWith('#'))
|
||||||
|
{
|
||||||
|
skippedBlank++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (header is null)
|
||||||
|
{
|
||||||
|
header = BuildHeader(row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = GetCell(row, header, "Tag Name");
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
skippedBlank++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var address = GetCell(row, header, "Address");
|
||||||
|
var dataTypeText = GetCell(row, header, "Data Type");
|
||||||
|
var description = GetCell(row, header, "Description");
|
||||||
|
var clientAccess = GetCell(row, header, "Client Access");
|
||||||
|
|
||||||
|
var dataType = ParseDataType(dataTypeText);
|
||||||
|
var writable = !string.IsNullOrEmpty(clientAccess)
|
||||||
|
&& clientAccess.IndexOf('W', StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
|
||||||
|
tags.Add(new AbCipTagDefinition(
|
||||||
|
Name: string.IsNullOrEmpty(NamePrefix) ? name : $"{NamePrefix}{name}",
|
||||||
|
DeviceHostAddress: DefaultDeviceHostAddress,
|
||||||
|
TagPath: string.IsNullOrEmpty(address) ? name : address,
|
||||||
|
DataType: dataType,
|
||||||
|
Writable: writable,
|
||||||
|
Description: string.IsNullOrEmpty(description) ? null : description));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CsvTagImportResult(tags, skippedBlank);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CsvTagImportResult ImportFile(string path) =>
|
||||||
|
Import(File.ReadAllText(path, Encoding.UTF8));
|
||||||
|
|
||||||
|
private static Dictionary<string, int> BuildHeader(IReadOnlyList<string> row)
|
||||||
|
{
|
||||||
|
var dict = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
for (var i = 0; i < row.Count; i++)
|
||||||
|
{
|
||||||
|
var key = row[i]?.Trim() ?? string.Empty;
|
||||||
|
if (key.Length > 0 && !dict.ContainsKey(key))
|
||||||
|
dict[key] = i;
|
||||||
|
}
|
||||||
|
return dict;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetCell(IReadOnlyList<string> row, Dictionary<string, int> header, string column)
|
||||||
|
{
|
||||||
|
if (!header.TryGetValue(column, out var idx)) return string.Empty;
|
||||||
|
if (idx < 0 || idx >= row.Count) return string.Empty;
|
||||||
|
return row[idx]?.Trim() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AbCipDataType ParseDataType(string s) =>
|
||||||
|
s?.Trim().ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"BOOL" or "BIT" => AbCipDataType.Bool,
|
||||||
|
"SINT" or "BYTE" => AbCipDataType.SInt,
|
||||||
|
"INT" or "WORD" or "SHORT" => AbCipDataType.Int,
|
||||||
|
"DINT" or "DWORD" or "LONG" => AbCipDataType.DInt,
|
||||||
|
"LINT" => AbCipDataType.LInt,
|
||||||
|
"USINT" => AbCipDataType.USInt,
|
||||||
|
"UINT" => AbCipDataType.UInt,
|
||||||
|
"UDINT" => AbCipDataType.UDInt,
|
||||||
|
"ULINT" => AbCipDataType.ULInt,
|
||||||
|
"REAL" or "FLOAT" => AbCipDataType.Real,
|
||||||
|
"LREAL" or "DOUBLE" => AbCipDataType.LReal,
|
||||||
|
"STRING" => AbCipDataType.String,
|
||||||
|
"DT" or "DATETIME" or "DATE" => AbCipDataType.Dt,
|
||||||
|
_ => AbCipDataType.Structure,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Result of <see cref="CsvTagImporter.Import"/>.</summary>
|
||||||
|
public sealed record CsvTagImportResult(
|
||||||
|
IReadOnlyList<AbCipTagDefinition> Tags,
|
||||||
|
int SkippedBlankCount);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tiny RFC-4180-ish CSV reader. Supports double-quoted fields, escaped <c>""</c>
|
||||||
|
/// quotes, and embedded line breaks inside quotes. Internal because the importer +
|
||||||
|
/// exporter are the only two callers and we don't want to add a CSV dep.
|
||||||
|
/// </summary>
|
||||||
|
internal static class CsvReader
|
||||||
|
{
|
||||||
|
public static List<List<string>> ReadAll(string text)
|
||||||
|
{
|
||||||
|
var rows = new List<List<string>>();
|
||||||
|
var row = new List<string>();
|
||||||
|
var field = new StringBuilder();
|
||||||
|
var inQuotes = false;
|
||||||
|
|
||||||
|
for (var i = 0; i < text.Length; i++)
|
||||||
|
{
|
||||||
|
var c = text[i];
|
||||||
|
if (inQuotes)
|
||||||
|
{
|
||||||
|
if (c == '"')
|
||||||
|
{
|
||||||
|
if (i + 1 < text.Length && text[i + 1] == '"')
|
||||||
|
{
|
||||||
|
field.Append('"');
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
inQuotes = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
field.Append(c);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (c)
|
||||||
|
{
|
||||||
|
case '"':
|
||||||
|
inQuotes = true;
|
||||||
|
break;
|
||||||
|
case ',':
|
||||||
|
row.Add(field.ToString());
|
||||||
|
field.Clear();
|
||||||
|
break;
|
||||||
|
case '\r':
|
||||||
|
// Swallow CR — handle CRLF and lone CR alike.
|
||||||
|
row.Add(field.ToString());
|
||||||
|
field.Clear();
|
||||||
|
rows.Add(row);
|
||||||
|
row = new List<string>();
|
||||||
|
if (i + 1 < text.Length && text[i + 1] == '\n') i++;
|
||||||
|
break;
|
||||||
|
case '\n':
|
||||||
|
row.Add(field.ToString());
|
||||||
|
field.Clear();
|
||||||
|
rows.Add(row);
|
||||||
|
row = new List<string>();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
field.Append(c);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.Length > 0 || row.Count > 0)
|
||||||
|
{
|
||||||
|
row.Add(field.ToString());
|
||||||
|
rows.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/IL5kSource.cs
Normal file
29
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/IL5kSource.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abstraction over an L5K text source so the parser can consume strings, files, or streams
|
||||||
|
/// without coupling to <see cref="System.IO"/>. Implementations return the full text in a
|
||||||
|
/// single call — L5K files are typically <10 MB even for large controllers, and the parser
|
||||||
|
/// needs random access to handle nested DATATYPE/TAG blocks regardless.
|
||||||
|
/// </summary>
|
||||||
|
public interface IL5kSource
|
||||||
|
{
|
||||||
|
/// <summary>Reads the full L5K body as a string.</summary>
|
||||||
|
string ReadAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>String-backed source — used by tests + when the L5K body is loaded elsewhere.</summary>
|
||||||
|
public sealed class StringL5kSource : IL5kSource
|
||||||
|
{
|
||||||
|
private readonly string _text;
|
||||||
|
public StringL5kSource(string text) => _text = text ?? throw new ArgumentNullException(nameof(text));
|
||||||
|
public string ReadAll() => _text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>File-backed source — used by Admin / driver init to load <c>*.L5K</c> exports.</summary>
|
||||||
|
public sealed class FileL5kSource : IL5kSource
|
||||||
|
{
|
||||||
|
private readonly string _path;
|
||||||
|
public FileL5kSource(string path) => _path = path ?? throw new ArgumentNullException(nameof(path));
|
||||||
|
public string ReadAll() => System.IO.File.ReadAllText(_path);
|
||||||
|
}
|
||||||
161
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kIngest.cs
Normal file
161
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kIngest.cs
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a parsed <see cref="L5kDocument"/> into <see cref="AbCipTagDefinition"/> entries
|
||||||
|
/// ready to be merged into <see cref="AbCipDriverOptions.Tags"/>. UDT definitions become
|
||||||
|
/// <see cref="AbCipStructureMember"/> lists keyed by data-type name; tags whose
|
||||||
|
/// <see cref="L5kTag.DataType"/> matches a known UDT get those members attached so the
|
||||||
|
/// discovery code can fan out the structure.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <strong>Alias tags are skipped</strong> — when <see cref="L5kTag.AliasFor"/> is
|
||||||
|
/// non-null the entry is dropped at ingest. Surfacing both the alias + its target
|
||||||
|
/// creates duplicate Variables in the OPC UA address space (Kepware's L5K importer
|
||||||
|
/// takes the same approach for this reason; the alias target is the single source of
|
||||||
|
/// truth for storage).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <strong>Tags with <c>ExternalAccess := None</c> are skipped</strong> — the controller
|
||||||
|
/// actively rejects external reads/writes, so emitting them as Variables would just
|
||||||
|
/// produce permanent BadCommunicationError. <c>Read Only</c> maps to <c>Writable=false</c>;
|
||||||
|
/// <c>Read/Write</c> (or absent) maps to <c>Writable=true</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Unknown data-type names (not atomic + not a parsed UDT) fall through as
|
||||||
|
/// <see cref="AbCipDataType.Structure"/> with no member layout — discovery can still
|
||||||
|
/// expose them as black-box variables and the operator can pin them via dotted paths.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class L5kIngest
|
||||||
|
{
|
||||||
|
/// <summary>Default device host address applied to every imported tag.</summary>
|
||||||
|
public string DefaultDeviceHostAddress { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional prefix prepended to imported tag names — useful when ingesting multiple
|
||||||
|
/// L5K exports into one driver instance to avoid name collisions. Default empty.
|
||||||
|
/// </summary>
|
||||||
|
public string NamePrefix { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public L5kIngestResult Ingest(L5kDocument document)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(document);
|
||||||
|
if (string.IsNullOrWhiteSpace(DefaultDeviceHostAddress))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"{nameof(L5kIngest)}.{nameof(DefaultDeviceHostAddress)} must be set before {nameof(Ingest)} is called — every imported tag needs a target device.");
|
||||||
|
|
||||||
|
// Index UDT definitions by name so we can fan out structure tags inline.
|
||||||
|
var udtIndex = new Dictionary<string, IReadOnlyList<AbCipStructureMember>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var dt in document.DataTypes)
|
||||||
|
{
|
||||||
|
var members = new List<AbCipStructureMember>(dt.Members.Count);
|
||||||
|
foreach (var m in dt.Members)
|
||||||
|
{
|
||||||
|
var atomic = TryMapAtomic(m.DataType);
|
||||||
|
var memberType = atomic ?? AbCipDataType.Structure;
|
||||||
|
var writable = !IsReadOnly(m.ExternalAccess) && !IsAccessNone(m.ExternalAccess);
|
||||||
|
members.Add(new AbCipStructureMember(
|
||||||
|
Name: m.Name,
|
||||||
|
DataType: memberType,
|
||||||
|
Writable: writable,
|
||||||
|
Description: m.Description,
|
||||||
|
AoiQualifier: MapAoiUsage(m.Usage)));
|
||||||
|
}
|
||||||
|
udtIndex[dt.Name] = members;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tags = new List<AbCipTagDefinition>();
|
||||||
|
var skippedAliases = 0;
|
||||||
|
var skippedNoAccess = 0;
|
||||||
|
foreach (var t in document.Tags)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(t.AliasFor)) { skippedAliases++; continue; }
|
||||||
|
if (IsAccessNone(t.ExternalAccess)) { skippedNoAccess++; continue; }
|
||||||
|
|
||||||
|
var atomic = TryMapAtomic(t.DataType);
|
||||||
|
AbCipDataType dataType;
|
||||||
|
IReadOnlyList<AbCipStructureMember>? members = null;
|
||||||
|
if (atomic is { } a)
|
||||||
|
{
|
||||||
|
dataType = a;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
dataType = AbCipDataType.Structure;
|
||||||
|
if (udtIndex.TryGetValue(t.DataType, out var udtMembers))
|
||||||
|
members = udtMembers;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tagPath = t.ProgramScope is { Length: > 0 }
|
||||||
|
? $"Program:{t.ProgramScope}.{t.Name}"
|
||||||
|
: t.Name;
|
||||||
|
var name = string.IsNullOrEmpty(NamePrefix) ? t.Name : $"{NamePrefix}{t.Name}";
|
||||||
|
// Make the OPC UA tag name unique when both controller-scope + program-scope tags
|
||||||
|
// share the same simple Name.
|
||||||
|
if (t.ProgramScope is { Length: > 0 })
|
||||||
|
name = string.IsNullOrEmpty(NamePrefix)
|
||||||
|
? $"{t.ProgramScope}.{t.Name}"
|
||||||
|
: $"{NamePrefix}{t.ProgramScope}.{t.Name}";
|
||||||
|
|
||||||
|
var writable = !IsReadOnly(t.ExternalAccess);
|
||||||
|
|
||||||
|
tags.Add(new AbCipTagDefinition(
|
||||||
|
Name: name,
|
||||||
|
DeviceHostAddress: DefaultDeviceHostAddress,
|
||||||
|
TagPath: tagPath,
|
||||||
|
DataType: dataType,
|
||||||
|
Writable: writable,
|
||||||
|
Members: members,
|
||||||
|
Description: t.Description));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new L5kIngestResult(tags, skippedAliases, skippedNoAccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsReadOnly(string? externalAccess) =>
|
||||||
|
externalAccess is not null
|
||||||
|
&& externalAccess.Trim().Replace(" ", string.Empty).Equals("ReadOnly", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static bool IsAccessNone(string? externalAccess) =>
|
||||||
|
externalAccess is not null && externalAccess.Trim().Equals("None", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-2.6 — map the AOI <c>Usage</c> attribute string to <see cref="AoiQualifier"/>.
|
||||||
|
/// Plain UDT members (Usage = null) + unrecognised values map to <see cref="AoiQualifier.Local"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static AoiQualifier MapAoiUsage(string? usage) =>
|
||||||
|
usage?.Trim().ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"INPUT" => AoiQualifier.Input,
|
||||||
|
"OUTPUT" => AoiQualifier.Output,
|
||||||
|
"INOUT" => AoiQualifier.InOut,
|
||||||
|
_ => AoiQualifier.Local,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Map a Logix atomic type name. Returns <c>null</c> for UDT/structure references.</summary>
|
||||||
|
private static AbCipDataType? TryMapAtomic(string logixType) =>
|
||||||
|
logixType?.Trim().ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"BOOL" or "BIT" => AbCipDataType.Bool,
|
||||||
|
"SINT" => AbCipDataType.SInt,
|
||||||
|
"INT" => AbCipDataType.Int,
|
||||||
|
"DINT" => AbCipDataType.DInt,
|
||||||
|
"LINT" => AbCipDataType.LInt,
|
||||||
|
"USINT" => AbCipDataType.USInt,
|
||||||
|
"UINT" => AbCipDataType.UInt,
|
||||||
|
"UDINT" => AbCipDataType.UDInt,
|
||||||
|
"ULINT" => AbCipDataType.ULInt,
|
||||||
|
"REAL" => AbCipDataType.Real,
|
||||||
|
"LREAL" => AbCipDataType.LReal,
|
||||||
|
"STRING" => AbCipDataType.String,
|
||||||
|
"DT" or "DATETIME" => AbCipDataType.Dt,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Result of <see cref="L5kIngest.Ingest"/> — produced tags + per-skip-reason counts.</summary>
|
||||||
|
public sealed record L5kIngestResult(
|
||||||
|
IReadOnlyList<AbCipTagDefinition> Tags,
|
||||||
|
int SkippedAliasCount,
|
||||||
|
int SkippedNoAccessCount);
|
||||||
469
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kParser.cs
Normal file
469
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kParser.cs
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure-text parser for Studio 5000 L5K controller exports. L5K is a labelled-section export
|
||||||
|
/// with TAG/END_TAG, DATATYPE/END_DATATYPE, PROGRAM/END_PROGRAM blocks. This parser handles
|
||||||
|
/// the common shapes:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Controller-scope <c>TAG ... END_TAG</c> with <c>Name</c>, <c>DataType</c>,
|
||||||
|
/// optional <c>ExternalAccess</c>, optional <c>Description</c>.</item>
|
||||||
|
/// <item>Program-scope tags inside <c>PROGRAM ... END_PROGRAM</c>.</item>
|
||||||
|
/// <item>UDT definitions via <c>DATATYPE ... END_DATATYPE</c> with <c>MEMBER</c> lines.</item>
|
||||||
|
/// <item>Alias tags (<c>AliasFor</c>) — recognised + flagged so callers can skip them.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Unknown sections (CONFIG, MODULE, AOI, MOTION_GROUP, etc.) are skipped silently.
|
||||||
|
/// Per Kepware precedent, alias tags are typically skipped on ingest because the alias target
|
||||||
|
/// is what owns the storage — surfacing both creates duplicate writes/reads.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is a permissive line-oriented parser, not a full L5K grammar. Comments
|
||||||
|
/// (<c>(* ... *)</c>) are stripped before tokenization. The parser is deliberately tolerant of
|
||||||
|
/// extra whitespace, unknown attributes, and trailing semicolons — real-world L5K files are
|
||||||
|
/// produced by RSLogix exports that vary across versions.
|
||||||
|
/// </remarks>
|
||||||
|
public static class L5kParser
|
||||||
|
{
|
||||||
|
public static L5kDocument Parse(IL5kSource source)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
|
var raw = source.ReadAll();
|
||||||
|
var stripped = StripBlockComments(raw);
|
||||||
|
var lines = stripped.Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.None);
|
||||||
|
|
||||||
|
var tags = new List<L5kTag>();
|
||||||
|
var datatypes = new List<L5kDataType>();
|
||||||
|
string? currentProgram = null;
|
||||||
|
var i = 0;
|
||||||
|
while (i < lines.Length)
|
||||||
|
{
|
||||||
|
var line = lines[i].Trim();
|
||||||
|
if (line.Length == 0) { i++; continue; }
|
||||||
|
|
||||||
|
// PROGRAM block — opens a program scope; the body contains nested TAG blocks.
|
||||||
|
if (StartsWithKeyword(line, "PROGRAM"))
|
||||||
|
{
|
||||||
|
currentProgram = ExtractFirstQuotedOrToken(line.Substring("PROGRAM".Length).Trim());
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (StartsWithKeyword(line, "END_PROGRAM"))
|
||||||
|
{
|
||||||
|
currentProgram = null;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TAG block — collects 1..N tag entries until END_TAG.
|
||||||
|
if (StartsWithKeyword(line, "TAG"))
|
||||||
|
{
|
||||||
|
var consumed = ParseTagBlock(lines, i, currentProgram, tags);
|
||||||
|
i += consumed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DATATYPE block.
|
||||||
|
if (StartsWithKeyword(line, "DATATYPE"))
|
||||||
|
{
|
||||||
|
var consumed = ParseDataTypeBlock(lines, i, datatypes);
|
||||||
|
i += consumed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PR abcip-2.6 — ADD_ON_INSTRUCTION_DEFINITION block. AOI parameters carry a Usage
|
||||||
|
// attribute (Input / Output / InOut); each PARAMETER becomes a member of the AOI's
|
||||||
|
// L5kDataType entry so AOI-typed tags pick up a layout the same way UDT-typed tags do.
|
||||||
|
if (StartsWithKeyword(line, "ADD_ON_INSTRUCTION_DEFINITION"))
|
||||||
|
{
|
||||||
|
var consumed = ParseAoiDefinitionBlock(lines, i, datatypes);
|
||||||
|
i += consumed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new L5kDocument(tags, datatypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- TAG block ---------------------------------------------------------
|
||||||
|
|
||||||
|
// Each TAG block contains 1..N entries of the form:
|
||||||
|
// TagName : DataType (Description := "...", ExternalAccess := Read/Write) := initialValue;
|
||||||
|
// until END_TAG. Entries can span multiple lines, terminated by ';'.
|
||||||
|
private static int ParseTagBlock(string[] lines, int start, string? program, List<L5kTag> into)
|
||||||
|
{
|
||||||
|
var i = start + 1;
|
||||||
|
while (i < lines.Length)
|
||||||
|
{
|
||||||
|
var line = lines[i].Trim();
|
||||||
|
if (StartsWithKeyword(line, "END_TAG")) return i - start + 1;
|
||||||
|
if (line.Length == 0) { i++; continue; }
|
||||||
|
|
||||||
|
var sb = new System.Text.StringBuilder(line);
|
||||||
|
while (!sb.ToString().TrimEnd().EndsWith(';') && i + 1 < lines.Length)
|
||||||
|
{
|
||||||
|
var peek = lines[i + 1].Trim();
|
||||||
|
if (StartsWithKeyword(peek, "END_TAG")) break;
|
||||||
|
i++;
|
||||||
|
sb.Append(' ').Append(peek);
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
|
||||||
|
var entry = sb.ToString().TrimEnd(';').Trim();
|
||||||
|
var tag = ParseTagEntry(entry, program);
|
||||||
|
if (tag is not null) into.Add(tag);
|
||||||
|
}
|
||||||
|
return i - start;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static L5kTag? ParseTagEntry(string entry, string? program)
|
||||||
|
{
|
||||||
|
// entry shape: Name : DataType [ (attribute := value, ...) ] [ := initialValue ]
|
||||||
|
// Find the first ':' that separates Name from DataType. Avoid ':=' (the assign op).
|
||||||
|
var colonIdx = FindBareColon(entry);
|
||||||
|
if (colonIdx < 0) return null;
|
||||||
|
|
||||||
|
var name = entry.Substring(0, colonIdx).Trim();
|
||||||
|
if (name.Length == 0) return null;
|
||||||
|
|
||||||
|
var rest = entry.Substring(colonIdx + 1).Trim();
|
||||||
|
// The attribute parens themselves contain ':=' assignments, so locate the top-level
|
||||||
|
// assignment (depth-0 ':=') that introduces the initial value before stripping.
|
||||||
|
var assignIdx = FindTopLevelAssign(rest);
|
||||||
|
var head = assignIdx >= 0 ? rest.Substring(0, assignIdx).Trim() : rest;
|
||||||
|
|
||||||
|
// Pull attribute tuple out of head: "DataType (attr := val, attr := val)".
|
||||||
|
string dataType;
|
||||||
|
var attributes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var openParen = head.IndexOf('(');
|
||||||
|
if (openParen >= 0)
|
||||||
|
{
|
||||||
|
dataType = head.Substring(0, openParen).Trim();
|
||||||
|
var closeParen = head.LastIndexOf(')');
|
||||||
|
if (closeParen > openParen)
|
||||||
|
{
|
||||||
|
var attrBody = head.Substring(openParen + 1, closeParen - openParen - 1);
|
||||||
|
ParseAttributeList(attrBody, attributes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
dataType = head.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataType.Length == 0) return null;
|
||||||
|
|
||||||
|
var description = attributes.TryGetValue("Description", out var d) ? Unquote(d) : null;
|
||||||
|
var externalAccess = attributes.TryGetValue("ExternalAccess", out var ea) ? ea.Trim() : null;
|
||||||
|
var aliasFor = attributes.TryGetValue("AliasFor", out var af) ? Unquote(af) : null;
|
||||||
|
|
||||||
|
return new L5kTag(
|
||||||
|
Name: name,
|
||||||
|
DataType: dataType,
|
||||||
|
ProgramScope: program,
|
||||||
|
ExternalAccess: externalAccess,
|
||||||
|
Description: description,
|
||||||
|
AliasFor: aliasFor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first ':=' at depth 0 (not inside parens / brackets / quotes). Returns -1 if none.
|
||||||
|
private static int FindTopLevelAssign(string entry)
|
||||||
|
{
|
||||||
|
var depth = 0;
|
||||||
|
var inQuote = false;
|
||||||
|
for (var k = 0; k < entry.Length - 1; k++)
|
||||||
|
{
|
||||||
|
var c = entry[k];
|
||||||
|
if (c == '"' || c == '\'') inQuote = !inQuote;
|
||||||
|
if (inQuote) continue;
|
||||||
|
if (c == '(' || c == '[' || c == '{') depth++;
|
||||||
|
else if (c == ')' || c == ']' || c == '}') depth--;
|
||||||
|
else if (c == ':' && entry[k + 1] == '=' && depth == 0) return k;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first colon that is NOT part of ':=' and not inside a quoted string.
|
||||||
|
private static int FindBareColon(string entry)
|
||||||
|
{
|
||||||
|
var inQuote = false;
|
||||||
|
for (var k = 0; k < entry.Length; k++)
|
||||||
|
{
|
||||||
|
var c = entry[k];
|
||||||
|
if (c == '"' || c == '\'') inQuote = !inQuote;
|
||||||
|
if (inQuote) continue;
|
||||||
|
if (c != ':') continue;
|
||||||
|
if (k + 1 < entry.Length && entry[k + 1] == '=') continue;
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ParseAttributeList(string body, Dictionary<string, string> into)
|
||||||
|
{
|
||||||
|
foreach (var part in SplitTopLevelCommas(body))
|
||||||
|
{
|
||||||
|
var assign = part.IndexOf(":=", StringComparison.Ordinal);
|
||||||
|
if (assign < 0) continue;
|
||||||
|
var key = part.Substring(0, assign).Trim();
|
||||||
|
var val = part.Substring(assign + 2).Trim();
|
||||||
|
if (key.Length > 0) into[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> SplitTopLevelCommas(string body)
|
||||||
|
{
|
||||||
|
var depth = 0;
|
||||||
|
var inQuote = false;
|
||||||
|
var start = 0;
|
||||||
|
for (var k = 0; k < body.Length; k++)
|
||||||
|
{
|
||||||
|
var c = body[k];
|
||||||
|
if (c == '"' || c == '\'') inQuote = !inQuote;
|
||||||
|
if (inQuote) continue;
|
||||||
|
if (c == '(' || c == '[' || c == '{') depth++;
|
||||||
|
else if (c == ')' || c == ']' || c == '}') depth--;
|
||||||
|
else if (c == ',' && depth == 0)
|
||||||
|
{
|
||||||
|
yield return body.Substring(start, k - start);
|
||||||
|
start = k + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (start < body.Length) yield return body.Substring(start);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DATATYPE block ----------------------------------------------------
|
||||||
|
|
||||||
|
private static int ParseDataTypeBlock(string[] lines, int start, List<L5kDataType> into)
|
||||||
|
{
|
||||||
|
var first = lines[start].Trim();
|
||||||
|
var head = first.Substring("DATATYPE".Length).Trim();
|
||||||
|
var name = ExtractFirstQuotedOrToken(head);
|
||||||
|
var members = new List<L5kMember>();
|
||||||
|
var i = start + 1;
|
||||||
|
while (i < lines.Length)
|
||||||
|
{
|
||||||
|
var line = lines[i].Trim();
|
||||||
|
if (StartsWithKeyword(line, "END_DATATYPE"))
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
|
||||||
|
return i - start + 1;
|
||||||
|
}
|
||||||
|
if (line.Length == 0) { i++; continue; }
|
||||||
|
|
||||||
|
if (StartsWithKeyword(line, "MEMBER"))
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder(line);
|
||||||
|
while (!sb.ToString().TrimEnd().EndsWith(';') && i + 1 < lines.Length)
|
||||||
|
{
|
||||||
|
var peek = lines[i + 1].Trim();
|
||||||
|
if (StartsWithKeyword(peek, "END_DATATYPE")) break;
|
||||||
|
i++;
|
||||||
|
sb.Append(' ').Append(peek);
|
||||||
|
}
|
||||||
|
var entry = sb.ToString().TrimEnd(';').Trim();
|
||||||
|
entry = entry.Substring("MEMBER".Length).Trim();
|
||||||
|
var member = ParseMemberEntry(entry);
|
||||||
|
if (member is not null) members.Add(member);
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
|
||||||
|
return i - start;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static L5kMember? ParseMemberEntry(string entry)
|
||||||
|
{
|
||||||
|
// entry shape: MemberName : DataType [ [arrayDim] ] [ (attr := val, ...) ] [ := default ]
|
||||||
|
var colonIdx = FindBareColon(entry);
|
||||||
|
if (colonIdx < 0) return null;
|
||||||
|
var name = entry.Substring(0, colonIdx).Trim();
|
||||||
|
if (name.Length == 0) return null;
|
||||||
|
|
||||||
|
var rest = entry.Substring(colonIdx + 1).Trim();
|
||||||
|
var assignIdx = FindTopLevelAssign(rest);
|
||||||
|
if (assignIdx >= 0) rest = rest.Substring(0, assignIdx).Trim();
|
||||||
|
|
||||||
|
int? arrayDim = null;
|
||||||
|
var bracketOpen = rest.IndexOf('[');
|
||||||
|
if (bracketOpen >= 0)
|
||||||
|
{
|
||||||
|
var bracketClose = rest.IndexOf(']', bracketOpen + 1);
|
||||||
|
if (bracketClose > bracketOpen)
|
||||||
|
{
|
||||||
|
var dimText = rest.Substring(bracketOpen + 1, bracketClose - bracketOpen - 1).Trim();
|
||||||
|
if (int.TryParse(dimText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dim))
|
||||||
|
arrayDim = dim;
|
||||||
|
rest = (rest.Substring(0, bracketOpen) + rest.Substring(bracketClose + 1)).Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string typePart;
|
||||||
|
var attributes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var openParen = rest.IndexOf('(');
|
||||||
|
if (openParen >= 0)
|
||||||
|
{
|
||||||
|
typePart = rest.Substring(0, openParen).Trim();
|
||||||
|
var closeParen = rest.LastIndexOf(')');
|
||||||
|
if (closeParen > openParen)
|
||||||
|
{
|
||||||
|
var attrBody = rest.Substring(openParen + 1, closeParen - openParen - 1);
|
||||||
|
ParseAttributeList(attrBody, attributes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
typePart = rest.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typePart.Length == 0) return null;
|
||||||
|
var externalAccess = attributes.TryGetValue("ExternalAccess", out var ea) ? ea.Trim() : null;
|
||||||
|
var description = attributes.TryGetValue("Description", out var d) ? Unquote(d) : null;
|
||||||
|
// PR abcip-2.6 — Usage attribute on AOI parameters (Input / Output / InOut). Plain UDT
|
||||||
|
// members don't carry it; null on a regular DATATYPE MEMBER is the default + maps to Local
|
||||||
|
// in the ingest layer.
|
||||||
|
var usage = attributes.TryGetValue("Usage", out var u) ? u.Trim() : null;
|
||||||
|
return new L5kMember(name, typePart, arrayDim, externalAccess, description, usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- AOI block ---------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR abcip-2.6 — parse <c>ADD_ON_INSTRUCTION_DEFINITION ... END_ADD_ON_INSTRUCTION_DEFINITION</c>
|
||||||
|
/// blocks. Body is structured around PARAMETER entries (each carrying a <c>Usage</c>
|
||||||
|
/// attribute) and optional LOCAL_TAGS / ROUTINE blocks. We extract the parameters as
|
||||||
|
/// <see cref="L5kMember"/> rows + leave routines alone — only the surface API matters for
|
||||||
|
/// tag-discovery fan-out. The L5K format encloses parameters either inside a
|
||||||
|
/// <c>PARAMETERS ... END_PARAMETERS</c> block or as bare <c>PARAMETER ... ;</c> lines at
|
||||||
|
/// the AOI top level depending on Studio 5000 export options; this parser accepts both.
|
||||||
|
/// </summary>
|
||||||
|
private static int ParseAoiDefinitionBlock(string[] lines, int start, List<L5kDataType> into)
|
||||||
|
{
|
||||||
|
var first = lines[start].Trim();
|
||||||
|
var head = first.Substring("ADD_ON_INSTRUCTION_DEFINITION".Length).Trim();
|
||||||
|
var name = ExtractFirstQuotedOrToken(head);
|
||||||
|
var members = new List<L5kMember>();
|
||||||
|
var i = start + 1;
|
||||||
|
var inLocalsBlock = false;
|
||||||
|
var inRoutineBlock = false;
|
||||||
|
while (i < lines.Length)
|
||||||
|
{
|
||||||
|
var line = lines[i].Trim();
|
||||||
|
if (StartsWithKeyword(line, "END_ADD_ON_INSTRUCTION_DEFINITION"))
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
|
||||||
|
return i - start + 1;
|
||||||
|
}
|
||||||
|
if (line.Length == 0) { i++; continue; }
|
||||||
|
|
||||||
|
// Skip routine bodies — they hold ladder / ST / FBD code we don't care about for
|
||||||
|
// tag-discovery, and their own END_ROUTINE / END_LOCAL_TAGS tokens close them out.
|
||||||
|
if (StartsWithKeyword(line, "ROUTINE")) { inRoutineBlock = true; i++; continue; }
|
||||||
|
if (StartsWithKeyword(line, "END_ROUTINE")) { inRoutineBlock = false; i++; continue; }
|
||||||
|
if (StartsWithKeyword(line, "LOCAL_TAGS")) { inLocalsBlock = true; i++; continue; }
|
||||||
|
if (StartsWithKeyword(line, "END_LOCAL_TAGS")) { inLocalsBlock = false; i++; continue; }
|
||||||
|
if (inRoutineBlock || inLocalsBlock) { i++; continue; }
|
||||||
|
|
||||||
|
// PARAMETERS / END_PARAMETERS wrappers are skipped — bare PARAMETER lines drive parsing.
|
||||||
|
if (StartsWithKeyword(line, "PARAMETERS")) { i++; continue; }
|
||||||
|
if (StartsWithKeyword(line, "END_PARAMETERS")) { i++; continue; }
|
||||||
|
|
||||||
|
if (StartsWithKeyword(line, "PARAMETER"))
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder(line);
|
||||||
|
while (!sb.ToString().TrimEnd().EndsWith(';') && i + 1 < lines.Length)
|
||||||
|
{
|
||||||
|
var peek = lines[i + 1].Trim();
|
||||||
|
if (StartsWithKeyword(peek, "END_ADD_ON_INSTRUCTION_DEFINITION")) break;
|
||||||
|
i++;
|
||||||
|
sb.Append(' ').Append(peek);
|
||||||
|
}
|
||||||
|
var entry = sb.ToString().TrimEnd(';').Trim();
|
||||||
|
entry = entry.Substring("PARAMETER".Length).Trim();
|
||||||
|
var member = ParseMemberEntry(entry);
|
||||||
|
if (member is not null) members.Add(member);
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
|
||||||
|
return i - start;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
private static bool StartsWithKeyword(string line, string keyword)
|
||||||
|
{
|
||||||
|
if (line.Length < keyword.Length) return false;
|
||||||
|
if (!line.StartsWith(keyword, StringComparison.OrdinalIgnoreCase)) return false;
|
||||||
|
if (line.Length == keyword.Length) return true;
|
||||||
|
var next = line[keyword.Length];
|
||||||
|
return !char.IsLetterOrDigit(next) && next != '_';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractFirstQuotedOrToken(string fragment)
|
||||||
|
{
|
||||||
|
var trimmed = fragment.TrimStart();
|
||||||
|
if (trimmed.Length == 0) return string.Empty;
|
||||||
|
if (trimmed[0] == '"' || trimmed[0] == '\'')
|
||||||
|
{
|
||||||
|
var quote = trimmed[0];
|
||||||
|
var end = trimmed.IndexOf(quote, 1);
|
||||||
|
if (end > 0) return trimmed.Substring(1, end - 1);
|
||||||
|
}
|
||||||
|
var k = 0;
|
||||||
|
while (k < trimmed.Length)
|
||||||
|
{
|
||||||
|
var c = trimmed[k];
|
||||||
|
if (char.IsWhiteSpace(c) || c == '(' || c == ',' || c == ';') break;
|
||||||
|
k++;
|
||||||
|
}
|
||||||
|
return trimmed.Substring(0, k);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Unquote(string s)
|
||||||
|
{
|
||||||
|
s = s.Trim();
|
||||||
|
if (s.Length >= 2 && (s[0] == '"' || s[0] == '\'') && s[s.Length - 1] == s[0])
|
||||||
|
return s.Substring(1, s.Length - 2);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripBlockComments(string text)
|
||||||
|
{
|
||||||
|
// L5K comments: `(* ... *)`. Strip so the line scanner doesn't trip on tokens inside.
|
||||||
|
var pattern = new Regex(@"\(\*.*?\*\)", RegexOptions.Singleline);
|
||||||
|
return pattern.Replace(text, string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Output of <see cref="L5kParser.Parse(IL5kSource)"/>.</summary>
|
||||||
|
public sealed record L5kDocument(IReadOnlyList<L5kTag> Tags, IReadOnlyList<L5kDataType> DataTypes);
|
||||||
|
|
||||||
|
/// <summary>One L5K tag entry (controller- or program-scope).</summary>
|
||||||
|
public sealed record L5kTag(
|
||||||
|
string Name,
|
||||||
|
string DataType,
|
||||||
|
string? ProgramScope,
|
||||||
|
string? ExternalAccess,
|
||||||
|
string? Description,
|
||||||
|
string? AliasFor);
|
||||||
|
|
||||||
|
/// <summary>One UDT definition extracted from a <c>DATATYPE ... END_DATATYPE</c> block.</summary>
|
||||||
|
public sealed record L5kDataType(string Name, IReadOnlyList<L5kMember> Members);
|
||||||
|
|
||||||
|
/// <summary>One member line inside a UDT definition or AOI parameter list.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// PR abcip-2.6 — <see cref="Usage"/> carries the AOI <c>Usage</c> attribute (<c>Input</c> /
|
||||||
|
/// <c>Output</c> / <c>InOut</c>) raw text. Plain UDT members + L5K AOI <c>LOCAL_TAGS</c> leave
|
||||||
|
/// it null; the ingest layer maps null → <see cref="AoiQualifier.Local"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record L5kMember(
|
||||||
|
string Name,
|
||||||
|
string DataType,
|
||||||
|
int? ArrayDim,
|
||||||
|
string? ExternalAccess,
|
||||||
|
string? Description = null,
|
||||||
|
string? Usage = null);
|
||||||
237
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5xParser.cs
Normal file
237
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5xParser.cs
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Xml;
|
||||||
|
using System.Xml.XPath;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// XML-format parser for Studio 5000 L5X controller exports. L5X is the XML sibling of L5K
|
||||||
|
/// and carries the same tag / datatype / program shape, plus richer metadata (notably the
|
||||||
|
/// AddOnInstructionDefinition catalogue and explicit <c>TagType</c> attributes).
|
||||||
|
/// <para>
|
||||||
|
/// This parser produces the same <see cref="L5kDocument"/> bundle as
|
||||||
|
/// <see cref="L5kParser"/> so <see cref="L5kIngest"/> consumes both formats interchangeably.
|
||||||
|
/// The two parsers share the post-parse downstream layer; the only difference is how the
|
||||||
|
/// bundle is materialized from the source bytes.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// AOIs (<c>AddOnInstructionDefinition</c>) are surfaced as L5K-style UDT entries — their
|
||||||
|
/// parameters become <see cref="L5kMember"/> rows so AOI-typed tags pick up a member layout
|
||||||
|
/// the same way UDT-typed tags do. Full Inputs/Outputs/InOut directional metadata + per-call
|
||||||
|
/// parameter scoping is deferred to PR 2.6 per plan; this PR keeps AOIs visible without
|
||||||
|
/// attempting to model their call semantics.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Uses <see cref="System.Xml.XPath"/> with an <see cref="XPathDocument"/> for read-only
|
||||||
|
/// traversal. L5X exports are typically <50 MB, so a single in-memory navigator beats
|
||||||
|
/// forward-only <c>XmlReader</c> on simplicity for the same throughput at this size class.
|
||||||
|
/// The parser is permissive about missing optional attributes — a real export always has
|
||||||
|
/// <c>Name</c> + <c>DataType</c>, but <c>ExternalAccess</c> defaults to <c>Read/Write</c>
|
||||||
|
/// when absent (matching Studio 5000's own default for new tags).
|
||||||
|
/// </remarks>
|
||||||
|
public static class L5xParser
|
||||||
|
{
|
||||||
|
public static L5kDocument Parse(IL5kSource source)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
|
var xml = source.ReadAll();
|
||||||
|
|
||||||
|
using var reader = XmlReader.Create(
|
||||||
|
new System.IO.StringReader(xml),
|
||||||
|
new XmlReaderSettings
|
||||||
|
{
|
||||||
|
// L5X exports never include a DOCTYPE, but disable DTD processing defensively.
|
||||||
|
DtdProcessing = DtdProcessing.Prohibit,
|
||||||
|
IgnoreWhitespace = true,
|
||||||
|
IgnoreComments = true,
|
||||||
|
});
|
||||||
|
var doc = new XPathDocument(reader);
|
||||||
|
var nav = doc.CreateNavigator();
|
||||||
|
|
||||||
|
var tags = new List<L5kTag>();
|
||||||
|
var datatypes = new List<L5kDataType>();
|
||||||
|
|
||||||
|
// Controller-scope tags: /RSLogix5000Content/Controller/Tags/Tag
|
||||||
|
foreach (XPathNavigator tagNode in nav.Select("/RSLogix5000Content/Controller/Tags/Tag"))
|
||||||
|
{
|
||||||
|
var t = ReadTag(tagNode, programScope: null);
|
||||||
|
if (t is not null) tags.Add(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Program-scope tags: /RSLogix5000Content/Controller/Programs/Program/Tags/Tag
|
||||||
|
foreach (XPathNavigator programNode in nav.Select("/RSLogix5000Content/Controller/Programs/Program"))
|
||||||
|
{
|
||||||
|
var programName = programNode.GetAttribute("Name", string.Empty);
|
||||||
|
if (string.IsNullOrEmpty(programName)) continue;
|
||||||
|
foreach (XPathNavigator tagNode in programNode.Select("Tags/Tag"))
|
||||||
|
{
|
||||||
|
var t = ReadTag(tagNode, programName);
|
||||||
|
if (t is not null) tags.Add(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDTs: /RSLogix5000Content/Controller/DataTypes/DataType
|
||||||
|
foreach (XPathNavigator dtNode in nav.Select("/RSLogix5000Content/Controller/DataTypes/DataType"))
|
||||||
|
{
|
||||||
|
var udt = ReadDataType(dtNode);
|
||||||
|
if (udt is not null) datatypes.Add(udt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AOIs: surfaced as L5kDataType entries so AOI-typed tags pick up a member layout.
|
||||||
|
// Per the plan, full directional Input/Output/InOut modelling is deferred to PR 2.6.
|
||||||
|
foreach (XPathNavigator aoiNode in nav.Select("/RSLogix5000Content/Controller/AddOnInstructionDefinitions/AddOnInstructionDefinition"))
|
||||||
|
{
|
||||||
|
var aoi = ReadAddOnInstruction(aoiNode);
|
||||||
|
if (aoi is not null) datatypes.Add(aoi);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new L5kDocument(tags, datatypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static L5kTag? ReadTag(XPathNavigator tagNode, string? programScope)
|
||||||
|
{
|
||||||
|
var name = tagNode.GetAttribute("Name", string.Empty);
|
||||||
|
if (string.IsNullOrEmpty(name)) return null;
|
||||||
|
|
||||||
|
var tagType = tagNode.GetAttribute("TagType", string.Empty); // Base | Alias | Produced | Consumed
|
||||||
|
var dataType = tagNode.GetAttribute("DataType", string.Empty);
|
||||||
|
var aliasFor = tagNode.GetAttribute("AliasFor", string.Empty);
|
||||||
|
var externalAccess = tagNode.GetAttribute("ExternalAccess", string.Empty);
|
||||||
|
|
||||||
|
// Alias tags often omit DataType (it's inherited from the target). Surface them with
|
||||||
|
// an empty type — L5kIngest skips alias entries before TryMapAtomic ever sees the type.
|
||||||
|
if (string.IsNullOrEmpty(dataType)
|
||||||
|
&& !string.Equals(tagType, "Alias", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description child — L5X wraps description text in <Description> (sometimes inside CDATA).
|
||||||
|
string? description = null;
|
||||||
|
var descNode = tagNode.SelectSingleNode("Description");
|
||||||
|
if (descNode is not null)
|
||||||
|
{
|
||||||
|
var raw = descNode.Value;
|
||||||
|
if (!string.IsNullOrEmpty(raw)) description = raw.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new L5kTag(
|
||||||
|
Name: name,
|
||||||
|
DataType: string.IsNullOrEmpty(dataType) ? string.Empty : dataType,
|
||||||
|
ProgramScope: programScope,
|
||||||
|
ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess,
|
||||||
|
Description: description,
|
||||||
|
AliasFor: string.IsNullOrEmpty(aliasFor) ? null : aliasFor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static L5kDataType? ReadDataType(XPathNavigator dtNode)
|
||||||
|
{
|
||||||
|
var name = dtNode.GetAttribute("Name", string.Empty);
|
||||||
|
if (string.IsNullOrEmpty(name)) return null;
|
||||||
|
|
||||||
|
var members = new List<L5kMember>();
|
||||||
|
foreach (XPathNavigator memberNode in dtNode.Select("Members/Member"))
|
||||||
|
{
|
||||||
|
var m = ReadMember(memberNode);
|
||||||
|
if (m is not null) members.Add(m);
|
||||||
|
}
|
||||||
|
return new L5kDataType(name, members);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static L5kMember? ReadMember(XPathNavigator memberNode)
|
||||||
|
{
|
||||||
|
var name = memberNode.GetAttribute("Name", string.Empty);
|
||||||
|
if (string.IsNullOrEmpty(name)) return null;
|
||||||
|
|
||||||
|
// Skip auto-inserted hidden host members for backing storage of BOOL packing — they're
|
||||||
|
// emitted by RSLogix as members named with the ZZZZZZZZZZ prefix and aren't useful to
|
||||||
|
// surface as OPC UA variables.
|
||||||
|
if (name.StartsWith("ZZZZZZZZZZ", StringComparison.Ordinal)) return null;
|
||||||
|
|
||||||
|
var dataType = memberNode.GetAttribute("DataType", string.Empty);
|
||||||
|
if (string.IsNullOrEmpty(dataType)) return null;
|
||||||
|
|
||||||
|
var externalAccess = memberNode.GetAttribute("ExternalAccess", string.Empty);
|
||||||
|
|
||||||
|
int? arrayDim = null;
|
||||||
|
var dimText = memberNode.GetAttribute("Dimension", string.Empty);
|
||||||
|
if (!string.IsNullOrEmpty(dimText)
|
||||||
|
&& int.TryParse(dimText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dim)
|
||||||
|
&& dim > 0)
|
||||||
|
{
|
||||||
|
arrayDim = dim;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description child — same shape as on Tag nodes; sometimes wrapped in CDATA.
|
||||||
|
string? description = null;
|
||||||
|
var descNode = memberNode.SelectSingleNode("Description");
|
||||||
|
if (descNode is not null)
|
||||||
|
{
|
||||||
|
var raw = descNode.Value;
|
||||||
|
if (!string.IsNullOrEmpty(raw)) description = raw.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new L5kMember(
|
||||||
|
Name: name,
|
||||||
|
DataType: dataType,
|
||||||
|
ArrayDim: arrayDim,
|
||||||
|
ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess,
|
||||||
|
Description: description);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static L5kDataType? ReadAddOnInstruction(XPathNavigator aoiNode)
|
||||||
|
{
|
||||||
|
var name = aoiNode.GetAttribute("Name", string.Empty);
|
||||||
|
if (string.IsNullOrEmpty(name)) return null;
|
||||||
|
|
||||||
|
var members = new List<L5kMember>();
|
||||||
|
foreach (XPathNavigator paramNode in aoiNode.Select("Parameters/Parameter"))
|
||||||
|
{
|
||||||
|
var paramName = paramNode.GetAttribute("Name", string.Empty);
|
||||||
|
if (string.IsNullOrEmpty(paramName)) continue;
|
||||||
|
|
||||||
|
// RSLogix marks the implicit EnableIn / EnableOut parameters as Hidden=true.
|
||||||
|
// Skip them — they aren't part of the AOI's user-facing surface.
|
||||||
|
var hidden = paramNode.GetAttribute("Hidden", string.Empty);
|
||||||
|
if (string.Equals(hidden, "true", StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
|
||||||
|
var dataType = paramNode.GetAttribute("DataType", string.Empty);
|
||||||
|
if (string.IsNullOrEmpty(dataType)) continue;
|
||||||
|
|
||||||
|
var externalAccess = paramNode.GetAttribute("ExternalAccess", string.Empty);
|
||||||
|
|
||||||
|
int? arrayDim = null;
|
||||||
|
var dimText = paramNode.GetAttribute("Dimension", string.Empty);
|
||||||
|
if (!string.IsNullOrEmpty(dimText)
|
||||||
|
&& int.TryParse(dimText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dim)
|
||||||
|
&& dim > 0)
|
||||||
|
{
|
||||||
|
arrayDim = dim;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? paramDescription = null;
|
||||||
|
var paramDescNode = paramNode.SelectSingleNode("Description");
|
||||||
|
if (paramDescNode is not null)
|
||||||
|
{
|
||||||
|
var raw = paramDescNode.Value;
|
||||||
|
if (!string.IsNullOrEmpty(raw)) paramDescription = raw.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// PR abcip-2.6 — capture the AOI Usage attribute (Input / Output / InOut). RSLogix
|
||||||
|
// also serialises Local AOI tags inside <LocalTags>, but those don't go through this
|
||||||
|
// path — only <Parameters>/<Parameter> entries do — so any Usage value on a parameter
|
||||||
|
// is one of the directional buckets.
|
||||||
|
var usage = paramNode.GetAttribute("Usage", string.Empty);
|
||||||
|
|
||||||
|
members.Add(new L5kMember(
|
||||||
|
Name: paramName,
|
||||||
|
DataType: dataType,
|
||||||
|
ArrayDim: arrayDim,
|
||||||
|
ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess,
|
||||||
|
Description: paramDescription,
|
||||||
|
Usage: string.IsNullOrEmpty(usage) ? null : usage));
|
||||||
|
}
|
||||||
|
return new L5kDataType(name, members);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,17 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
|||||||
Name = p.TagName,
|
Name = p.TagName,
|
||||||
Timeout = p.Timeout,
|
Timeout = p.Timeout,
|
||||||
};
|
};
|
||||||
|
// PR abcip-1.2 — Logix STRINGnn variant decoding. When the caller pins a non-default
|
||||||
|
// DATA-array capacity (STRING_20 / STRING_40 / STRING_80 etc.), forward it to libplctag
|
||||||
|
// via the StringMaxCapacity attribute so GetString / SetString truncate at the right
|
||||||
|
// boundary. Null leaves libplctag at its default 82-byte STRING for back-compat.
|
||||||
|
if (p.StringMaxCapacity is int cap && cap > 0)
|
||||||
|
_tag.StringMaxCapacity = (uint)cap;
|
||||||
|
// PR abcip-1.3 — slice reads. Setting ElementCount tells libplctag to allocate a buffer
|
||||||
|
// covering N consecutive elements; the array-read planner pairs this with TagName=Tag[N]
|
||||||
|
// to issue one Rockwell array read for a [N..M] slice.
|
||||||
|
if (p.ElementCount is int n && n > 0)
|
||||||
|
_tag.ElementCount = n;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
|
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
|
||||||
@@ -50,7 +61,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
|||||||
AbCipDataType.Real => _tag.GetFloat32(offset),
|
AbCipDataType.Real => _tag.GetFloat32(offset),
|
||||||
AbCipDataType.LReal => _tag.GetFloat64(offset),
|
AbCipDataType.LReal => _tag.GetFloat64(offset),
|
||||||
AbCipDataType.String => _tag.GetString(offset),
|
AbCipDataType.String => _tag.GetString(offset),
|
||||||
AbCipDataType.Dt => _tag.GetInt32(offset),
|
AbCipDataType.Dt => _tag.GetInt64(offset),
|
||||||
AbCipDataType.Structure => null,
|
AbCipDataType.Structure => null,
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
@@ -105,7 +116,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
|||||||
_tag.SetString(0, Convert.ToString(value) ?? string.Empty);
|
_tag.SetString(0, Convert.ToString(value) ?? string.Empty);
|
||||||
break;
|
break;
|
||||||
case AbCipDataType.Dt:
|
case AbCipDataType.Dt:
|
||||||
_tag.SetInt32(0, Convert.ToInt32(value));
|
_tag.SetInt64(0, Convert.ToInt64(value));
|
||||||
break;
|
break;
|
||||||
case AbCipDataType.Structure:
|
case AbCipDataType.Structure:
|
||||||
throw new NotSupportedException("Whole-UDT writes land in PR 6.");
|
throw new NotSupportedException("Whole-UDT writes land in PR 6.");
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base for every AB Legacy CLI command. Carries the PCCC-specific endpoint options
|
||||||
|
/// (<c>--gateway</c> + <c>--plc-type</c>) on top of <see cref="DriverCommandBase"/>'s
|
||||||
|
/// shared verbose + timeout + logging helpers.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class AbLegacyCommandBase : DriverCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("gateway", 'g', Description =
|
||||||
|
"Canonical AB Legacy gateway: ab://host[:port]/cip-path. Port defaults to 44818. " +
|
||||||
|
"cip-path depends on the family: SLC 5/05 + PLC-5 typically '1,0'; MicroLogix " +
|
||||||
|
"1100/1400 takes an empty path (direct EIP, no backplane).",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string Gateway { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("plc-type", 'P', Description =
|
||||||
|
"Slc500 / MicroLogix / Plc5 / LogixPccc (default Slc500).")]
|
||||||
|
public AbLegacyPlcFamily PlcType { get; init; } = AbLegacyPlcFamily.Slc500;
|
||||||
|
|
||||||
|
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||||
|
public int TimeoutMs { get; init; } = 5000;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override TimeSpan Timeout
|
||||||
|
{
|
||||||
|
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||||
|
init { /* driven by TimeoutMs */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build an <see cref="AbLegacyDriverOptions"/> with the device + tag list a subclass
|
||||||
|
/// supplies. Probe disabled for CLI one-shot runs.
|
||||||
|
/// </summary>
|
||||||
|
protected AbLegacyDriverOptions BuildOptions(IReadOnlyList<AbLegacyTagDefinition> tags) => new()
|
||||||
|
{
|
||||||
|
Devices = [new AbLegacyDeviceOptions(
|
||||||
|
HostAddress: Gateway,
|
||||||
|
PlcFamily: PlcType,
|
||||||
|
DeviceName: $"cli-{PlcType}")],
|
||||||
|
Tags = tags,
|
||||||
|
Timeout = Timeout,
|
||||||
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||||
|
};
|
||||||
|
|
||||||
|
protected string DriverInstanceId => $"ablegacy-cli-{Gateway}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Probes an AB Legacy (PCCC) endpoint: reads one N-file word + reports driver health.
|
||||||
|
/// Default probe address <c>N7:0</c> matches the integration-fixture seed so operators
|
||||||
|
/// can point the CLI at the ab_server Docker container + real hardware interchangeably.
|
||||||
|
/// </summary>
|
||||||
|
[Command("probe", Description = "Verify the AB Legacy endpoint is reachable and a sample PCCC read succeeds.")]
|
||||||
|
public sealed class ProbeCommand : AbLegacyCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description =
|
||||||
|
"PCCC address to probe (default N7:0). Use S:0 for the status file when you want " +
|
||||||
|
"the pre-populated register every SLC / MicroLogix / PLC-5 ships with.")]
|
||||||
|
public string Address { get; init; } = "N7:0";
|
||||||
|
|
||||||
|
[CommandOption("type", Description =
|
||||||
|
"PCCC data type of the probe address (default Int — matches N files).")]
|
||||||
|
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var probeTag = new AbLegacyTagDefinition(
|
||||||
|
Name: "__probe",
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([probeTag]);
|
||||||
|
|
||||||
|
await using var driver = new AbLegacyDriver(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($"PLC type: {PlcType}");
|
||||||
|
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(Address, snapshot[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read one PCCC address (N7:0, F8:0, B3:0/3, L19:0, ST17:0, T4:0.ACC, etc.).
|
||||||
|
/// </summary>
|
||||||
|
[Command("read", Description = "Read a single PCCC file address.")]
|
||||||
|
public sealed class ReadCommand : AbLegacyCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description =
|
||||||
|
"PCCC file address. File letter implies storage; bit-within-word via slash " +
|
||||||
|
"(B3:0/3 or N7:0/5). Sub-element access for timers/counters/controls uses " +
|
||||||
|
"dot notation (T4:0.ACC, C5:0.PRE, R6:0.LEN).",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string Address { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
|
||||||
|
"ControlElement (default Int).")]
|
||||||
|
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = SynthesiseTagName(Address, DataType);
|
||||||
|
var tag = new AbLegacyTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new AbLegacyDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Tag-name key the driver uses internally. Address+type is already unique.</summary>
|
||||||
|
internal static string SynthesiseTagName(string address, AbLegacyDataType type)
|
||||||
|
=> $"{address}:{type}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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.AbLegacy.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Watch a PCCC file address via polled subscription until Ctrl+C. Mirrors the Modbus /
|
||||||
|
/// AB CIP subscribe shape — PollGroupEngine handles the tick loop.
|
||||||
|
/// </summary>
|
||||||
|
[Command("subscribe", Description = "Watch a PCCC file address via polled subscription until Ctrl+C.")]
|
||||||
|
public sealed class SubscribeCommand : AbLegacyCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description = "PCCC file address — same format as `read`.", IsRequired = true)]
|
||||||
|
public string Address { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
|
||||||
|
"ControlElement (default Int).")]
|
||||||
|
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
|
||||||
|
|
||||||
|
[CommandOption("interval-ms", 'i', Description =
|
||||||
|
"Publishing interval in milliseconds (default 1000).")]
|
||||||
|
public int IntervalMs { get; init; } = 1000;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
||||||
|
var tag = new AbLegacyTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new AbLegacyDriver(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 {Address} @ {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,81 @@
|
|||||||
|
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.AbLegacy.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write one value to a PCCC file address. Writes to timer / counter / control
|
||||||
|
/// sub-elements go through at the wire level but land on the integer field of the
|
||||||
|
/// sub-element — the PLC's runtime semantics (edge-triggered EN/DN bits, preset reloads)
|
||||||
|
/// are PLC-managed, not CLI-manipulable; write these with caution.
|
||||||
|
/// </summary>
|
||||||
|
[Command("write", Description = "Write a single PCCC file address.")]
|
||||||
|
public sealed class WriteCommand : AbLegacyCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description =
|
||||||
|
"PCCC file address — same format as `read`.", IsRequired = true)]
|
||||||
|
public string Address { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bit / Int / Long / Float / AnalogInt / String / TimerElement / CounterElement / " +
|
||||||
|
"ControlElement (default Int).")]
|
||||||
|
public AbLegacyDataType DataType { get; init; } = AbLegacyDataType.Int;
|
||||||
|
|
||||||
|
[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();
|
||||||
|
|
||||||
|
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
||||||
|
var tag = new AbLegacyTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: Gateway,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: true);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
var parsed = ParseValue(Value, DataType);
|
||||||
|
|
||||||
|
await using var driver = new AbLegacyDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parse <c>--value</c> per <see cref="AbLegacyDataType"/>, invariant culture.</summary>
|
||||||
|
internal static object ParseValue(string raw, AbLegacyDataType type) => type switch
|
||||||
|
{
|
||||||
|
AbLegacyDataType.Bit => ParseBool(raw),
|
||||||
|
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => short.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbLegacyDataType.Long => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbLegacyDataType.Float => float.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
AbLegacyDataType.String => raw,
|
||||||
|
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
||||||
|
or AbLegacyDataType.ControlElement => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
_ => 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.AbLegacy.Cli/Program.cs
Normal file
11
src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Program.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
using CliFx;
|
||||||
|
|
||||||
|
return await new CliApplicationBuilder()
|
||||||
|
.AddCommandsFromThisAssembly()
|
||||||
|
.SetExecutableName("otopcua-ablegacy-cli")
|
||||||
|
.SetDescription(
|
||||||
|
"OtOpcUa AB Legacy test-client — ad-hoc probe + PCCC N/F/B/L-file reads/writes + " +
|
||||||
|
"polled subscriptions against SLC 500 / MicroLogix / PLC-5 devices via libplctag. " +
|
||||||
|
"Addresses use PCCC convention: N7:0, F8:0, B3:0/3, L19:0, ST17:0.")
|
||||||
|
.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.AbLegacy.Cli</RootNamespace>
|
||||||
|
<AssemblyName>otopcua-ablegacy-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.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -30,35 +32,87 @@ public sealed record AbLegacyAddress(
|
|||||||
int? FileNumber,
|
int? FileNumber,
|
||||||
int WordNumber,
|
int WordNumber,
|
||||||
int? BitIndex,
|
int? BitIndex,
|
||||||
string? SubElement)
|
string? SubElement,
|
||||||
|
AbLegacyAddress? IndirectFileSource = null,
|
||||||
|
AbLegacyAddress? IndirectWordSource = null)
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// True when either the file number or the word number is sourced from another PCCC
|
||||||
|
/// address evaluated at runtime (PLC-5 / SLC indirect addressing — <c>N7:[N7:0]</c> or
|
||||||
|
/// <c>N[N7:0]:5</c>). libplctag PCCC does not natively decode bracket-form indirection,
|
||||||
|
/// so the runtime layer must resolve the inner address first and rewrite the tag name
|
||||||
|
/// before issuing the actual read/write. See <see cref="ToLibplctagName"/>.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsIndirect => IndirectFileSource is not null || IndirectWordSource is not null;
|
||||||
|
|
||||||
public string ToLibplctagName()
|
public string ToLibplctagName()
|
||||||
{
|
{
|
||||||
var file = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
|
// Re-emit using bracket form when indirect. libplctag's PCCC text decoder does not
|
||||||
var wordPart = $"{file}:{WordNumber}";
|
// accept the bracket form directly — callers that need a libplctag-ready name must
|
||||||
|
// resolve the inner addresses first and substitute concrete numbers. Driver runtime
|
||||||
|
// path (TODO: resolve-then-read) is gated on IsIndirect.
|
||||||
|
string filePart;
|
||||||
|
if (IndirectFileSource is not null)
|
||||||
|
{
|
||||||
|
filePart = $"{FileLetter}[{IndirectFileSource.ToLibplctagName()}]";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
filePart = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
|
||||||
|
}
|
||||||
|
|
||||||
|
string wordSegment = IndirectWordSource is not null
|
||||||
|
? $"[{IndirectWordSource.ToLibplctagName()}]"
|
||||||
|
: WordNumber.ToString();
|
||||||
|
|
||||||
|
var wordPart = $"{filePart}:{wordSegment}";
|
||||||
if (SubElement is not null) wordPart += $".{SubElement}";
|
if (SubElement is not null) wordPart += $".{SubElement}";
|
||||||
if (BitIndex is not null) wordPart += $"/{BitIndex}";
|
if (BitIndex is not null) wordPart += $"/{BitIndex}";
|
||||||
return wordPart;
|
return wordPart;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static AbLegacyAddress? TryParse(string? value)
|
public static AbLegacyAddress? TryParse(string? value) => TryParse(value, family: null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Family-aware parser. PLC-5 (RSLogix 5) displays the word + bit indices on
|
||||||
|
/// <c>I:</c>/<c>O:</c> file references as octal — <c>I:001/17</c> is rack 1, bit 15.
|
||||||
|
/// Pass the device's family so the parser can interpret those digits as octal when the
|
||||||
|
/// family's <see cref="AbLegacyPlcFamilyProfile.OctalIoAddressing"/> is true. The parsed
|
||||||
|
/// record stores decimal values; <see cref="ToLibplctagName"/> emits decimal too, which
|
||||||
|
/// is what libplctag's PCCC layer expects.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Also accepts indirect / indexed forms (Issue #247): <c>N7:[N7:0]</c> reads file 7,
|
||||||
|
/// word=value-of(N7:0); <c>N[N7:0]:5</c> reads file=value-of(N7:0), word 5. Recursion
|
||||||
|
/// depth is capped at 1 — the inner address must be a plain direct PCCC address.
|
||||||
|
/// </remarks>
|
||||||
|
public static AbLegacyAddress? TryParse(string? value, AbLegacyPlcFamily? family)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||||
var src = value.Trim();
|
var src = value.Trim();
|
||||||
|
|
||||||
// BitIndex: trailing /N
|
var profile = family is null ? null : AbLegacyPlcFamilyProfile.ForFamily(family.Value);
|
||||||
int? bitIndex = null;
|
|
||||||
var slashIdx = src.IndexOf('/');
|
// BitIndex: trailing /N. Defer numeric parsing until the file letter is known — PLC-5
|
||||||
if (slashIdx >= 0)
|
// I:/O: bit indices are octal in RSLogix 5, everything else is decimal.
|
||||||
|
string? bitText = null;
|
||||||
|
var slashIdx = src.LastIndexOf('/');
|
||||||
|
if (slashIdx >= 0 && slashIdx > src.LastIndexOf(']'))
|
||||||
{
|
{
|
||||||
if (!int.TryParse(src[(slashIdx + 1)..], out var bit) || bit < 0 || bit > 31) return null;
|
bitText = src[(slashIdx + 1)..];
|
||||||
bitIndex = bit;
|
|
||||||
src = src[..slashIdx];
|
src = src[..slashIdx];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return ParseTail(src, bitText, profile, allowIndirect: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AbLegacyAddress? ParseTail(string src, string? bitText, AbLegacyPlcFamilyProfile? profile, bool allowIndirect)
|
||||||
|
{
|
||||||
// SubElement: trailing .NAME (ACC / PRE / EN / DN / TT / CU / CD / FD / etc.)
|
// SubElement: trailing .NAME (ACC / PRE / EN / DN / TT / CU / CD / FD / etc.)
|
||||||
|
// Only consider dots OUTSIDE of any bracketed inner address — the inner address may
|
||||||
|
// itself contain a sub-element dot (e.g. N[T4:0.ACC]:5).
|
||||||
string? subElement = null;
|
string? subElement = null;
|
||||||
var dotIdx = src.LastIndexOf('.');
|
var dotIdx = LastIndexOfTopLevel(src, '.');
|
||||||
if (dotIdx >= 0)
|
if (dotIdx >= 0)
|
||||||
{
|
{
|
||||||
var candidate = src[(dotIdx + 1)..];
|
var candidate = src[(dotIdx + 1)..];
|
||||||
@@ -69,29 +123,149 @@ public sealed record AbLegacyAddress(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var colonIdx = src.IndexOf(':');
|
var colonIdx = IndexOfTopLevel(src, ':');
|
||||||
if (colonIdx <= 0) return null;
|
if (colonIdx <= 0) return null;
|
||||||
var filePart = src[..colonIdx];
|
var filePart = src[..colonIdx];
|
||||||
var wordPart = src[(colonIdx + 1)..];
|
var wordPart = src[(colonIdx + 1)..];
|
||||||
if (!int.TryParse(wordPart, out var word) || word < 0) return null;
|
|
||||||
|
|
||||||
// File letter + optional file number (single letter for I/O/S, letter+number otherwise).
|
// File letter (always literal) + optional file number — either decimal digits or a
|
||||||
|
// bracketed indirect address like N[N7:0].
|
||||||
if (filePart.Length == 0 || !char.IsLetter(filePart[0])) return null;
|
if (filePart.Length == 0 || !char.IsLetter(filePart[0])) return null;
|
||||||
var letterEnd = 1;
|
var letterEnd = 1;
|
||||||
while (letterEnd < filePart.Length && char.IsLetter(filePart[letterEnd])) letterEnd++;
|
while (letterEnd < filePart.Length && char.IsLetter(filePart[letterEnd])) letterEnd++;
|
||||||
|
|
||||||
var letter = filePart[..letterEnd].ToUpperInvariant();
|
var letter = filePart[..letterEnd].ToUpperInvariant();
|
||||||
int? fileNumber = null;
|
int? fileNumber = null;
|
||||||
|
AbLegacyAddress? indirectFile = null;
|
||||||
if (letterEnd < filePart.Length)
|
if (letterEnd < filePart.Length)
|
||||||
{
|
{
|
||||||
if (!int.TryParse(filePart[letterEnd..], out var fn) || fn < 0) return null;
|
var fileTail = filePart[letterEnd..];
|
||||||
fileNumber = fn;
|
if (fileTail.Length >= 2 && fileTail[0] == '[' && fileTail[^1] == ']')
|
||||||
|
{
|
||||||
|
if (!allowIndirect) return null;
|
||||||
|
var inner = fileTail[1..^1];
|
||||||
|
indirectFile = ParseInner(inner, profile);
|
||||||
|
if (indirectFile is null) return null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!int.TryParse(fileTail, out var fn) || fn < 0) return null;
|
||||||
|
fileNumber = fn;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject unknown file letters — these cover SLC/ML/PLC-5 canonical families.
|
// Reject unknown file letters — these cover SLC/ML/PLC-5 canonical families.
|
||||||
if (!IsKnownFileLetter(letter)) return null;
|
// Function-file letters (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI) are MicroLogix-only.
|
||||||
|
// Structure-file letters (PD/MG/PLS/BT) are gated per family — PD/MG are common on
|
||||||
|
// SLC500 + PLC-5; PLS/BT are PLC-5 only. MicroLogix and LogixPccc reject them.
|
||||||
|
if (!IsKnownFileLetter(letter))
|
||||||
|
{
|
||||||
|
if (IsFunctionFileLetter(letter))
|
||||||
|
{
|
||||||
|
if (profile?.SupportsFunctionFiles != true) return null;
|
||||||
|
}
|
||||||
|
else if (IsStructureFileLetter(letter))
|
||||||
|
{
|
||||||
|
if (!StructureFileSupported(letter, profile)) return null;
|
||||||
|
}
|
||||||
|
else return null;
|
||||||
|
}
|
||||||
|
|
||||||
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement);
|
var octalForIo = profile?.OctalIoAddressing == true && (letter == "I" || letter == "O");
|
||||||
|
|
||||||
|
// Word part: either a numeric literal (octal-aware for PLC-5 I:/O:) or a bracketed
|
||||||
|
// indirect address.
|
||||||
|
int word = 0;
|
||||||
|
AbLegacyAddress? indirectWord = null;
|
||||||
|
if (wordPart.Length >= 2 && wordPart[0] == '[' && wordPart[^1] == ']')
|
||||||
|
{
|
||||||
|
if (!allowIndirect) return null;
|
||||||
|
var inner = wordPart[1..^1];
|
||||||
|
indirectWord = ParseInner(inner, profile);
|
||||||
|
if (indirectWord is null) return null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!TryParseIndex(wordPart, octalForIo, out word) || word < 0) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? bitIndex = null;
|
||||||
|
if (bitText is not null)
|
||||||
|
{
|
||||||
|
if (!TryParseIndex(bitText, octalForIo, out var bit) || bit < 0 || bit > 31) return null;
|
||||||
|
bitIndex = bit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement, indirectFile, indirectWord);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse an inner (bracketed) PCCC address with depth-1 cap. The inner address itself
|
||||||
|
/// must NOT be indirect — nesting beyond one level is rejected.
|
||||||
|
/// </summary>
|
||||||
|
private static AbLegacyAddress? ParseInner(string inner, AbLegacyPlcFamilyProfile? profile)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(inner)) return null;
|
||||||
|
var src = inner.Trim();
|
||||||
|
// Reject any further bracket — depth cap at 1.
|
||||||
|
if (src.IndexOf('[') >= 0 || src.IndexOf(']') >= 0) return null;
|
||||||
|
|
||||||
|
string? bitText = null;
|
||||||
|
var slashIdx = src.LastIndexOf('/');
|
||||||
|
if (slashIdx >= 0)
|
||||||
|
{
|
||||||
|
bitText = src[(slashIdx + 1)..];
|
||||||
|
src = src[..slashIdx];
|
||||||
|
}
|
||||||
|
return ParseTail(src, bitText, profile, allowIndirect: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int IndexOfTopLevel(string s, char c)
|
||||||
|
{
|
||||||
|
var depth = 0;
|
||||||
|
for (var i = 0; i < s.Length; i++)
|
||||||
|
{
|
||||||
|
if (s[i] == '[') depth++;
|
||||||
|
else if (s[i] == ']') depth--;
|
||||||
|
else if (depth == 0 && s[i] == c) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int LastIndexOfTopLevel(string s, char c)
|
||||||
|
{
|
||||||
|
var depth = 0;
|
||||||
|
var last = -1;
|
||||||
|
for (var i = 0; i < s.Length; i++)
|
||||||
|
{
|
||||||
|
if (s[i] == '[') depth++;
|
||||||
|
else if (s[i] == ']') depth--;
|
||||||
|
else if (depth == 0 && s[i] == c) last = i;
|
||||||
|
}
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseIndex(string text, bool octal, out int value)
|
||||||
|
{
|
||||||
|
if (octal)
|
||||||
|
{
|
||||||
|
// Octal accepts only digits 0-7. Reject 8/9 explicitly.
|
||||||
|
if (text.Length == 0) { value = 0; return false; }
|
||||||
|
var start = 0;
|
||||||
|
var sign = 1;
|
||||||
|
if (text[0] == '-') { sign = -1; start = 1; }
|
||||||
|
if (start >= text.Length) { value = 0; return false; }
|
||||||
|
var acc = 0;
|
||||||
|
for (var i = start; i < text.Length; i++)
|
||||||
|
{
|
||||||
|
var c = text[i];
|
||||||
|
if (c < '0' || c > '7') { value = 0; return false; }
|
||||||
|
acc = (acc * 8) + (c - '0');
|
||||||
|
}
|
||||||
|
value = sign * acc;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return int.TryParse(text, out value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsKnownFileLetter(string letter) => letter switch
|
private static bool IsKnownFileLetter(string letter) => letter switch
|
||||||
@@ -99,4 +273,38 @@ public sealed record AbLegacyAddress(
|
|||||||
"N" or "F" or "B" or "L" or "ST" or "T" or "C" or "R" or "I" or "O" or "S" or "A" => true,
|
"N" or "F" or "B" or "L" or "ST" or "T" or "C" or "R" or "I" or "O" or "S" or "A" => true,
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MicroLogix 1100/1400 function-file prefixes. Each maps to a single fixed instance with a
|
||||||
|
/// known sub-element catalogue (see <see cref="AbLegacyDataType"/>).
|
||||||
|
/// </summary>
|
||||||
|
internal static bool IsFunctionFileLetter(string letter) => letter switch
|
||||||
|
{
|
||||||
|
"RTC" or "HSC" or "DLS" or "MMI" or "PTO" or "PWM" or "STI" or "EII" or "IOS" or "BHI" => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Structure-file prefixes added in #248: PD (PID), MG (Message), PLS (Programmable Limit
|
||||||
|
/// Switch), BT (Block Transfer). Per-family availability is gated by the matching
|
||||||
|
/// <c>Supports*File</c> flag on <see cref="AbLegacyPlcFamilyProfile"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool IsStructureFileLetter(string letter) => letter switch
|
||||||
|
{
|
||||||
|
"PD" or "MG" or "PLS" or "BT" => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool StructureFileSupported(string letter, AbLegacyPlcFamilyProfile? profile)
|
||||||
|
{
|
||||||
|
if (profile is null) return false;
|
||||||
|
return letter switch
|
||||||
|
{
|
||||||
|
"PD" => profile.SupportsPidFile,
|
||||||
|
"MG" => profile.SupportsMessageFile,
|
||||||
|
"PLS" => profile.SupportsPlsFile,
|
||||||
|
"BT" => profile.SupportsBlockTransferFile,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,96 @@ public enum AbLegacyDataType
|
|||||||
CounterElement,
|
CounterElement,
|
||||||
/// <summary>Control sub-element — caller addresses <c>.LEN</c>, <c>.POS</c>, <c>.EN</c>, <c>.DN</c>, <c>.ER</c>.</summary>
|
/// <summary>Control sub-element — caller addresses <c>.LEN</c>, <c>.POS</c>, <c>.EN</c>, <c>.DN</c>, <c>.ER</c>.</summary>
|
||||||
ControlElement,
|
ControlElement,
|
||||||
|
/// <summary>
|
||||||
|
/// MicroLogix 1100/1400 function-file sub-element (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI).
|
||||||
|
/// Sub-element catalogue lives in <see cref="AbLegacyFunctionFile.SubElementType"/>.
|
||||||
|
/// </summary>
|
||||||
|
MicroLogixFunctionFile,
|
||||||
|
/// <summary>
|
||||||
|
/// PD-file (PID) sub-element — caller addresses <c>.SP</c>, <c>.PV</c>, <c>.CV</c>,
|
||||||
|
/// <c>.KP</c>, <c>.KI</c>, <c>.KD</c>, <c>.MAXS</c>, <c>.MINS</c>, <c>.DB</c>, <c>.OUT</c>
|
||||||
|
/// (Float) and <c>.EN</c>, <c>.DN</c>, <c>.MO</c>, <c>.PE</c>, <c>.AUTO</c>, <c>.MAN</c>
|
||||||
|
/// (Boolean status bits in word 0).
|
||||||
|
/// </summary>
|
||||||
|
PidElement,
|
||||||
|
/// <summary>
|
||||||
|
/// MG-file (Message) sub-element — caller addresses <c>.RBE</c>, <c>.MS</c>, <c>.SIZE</c>,
|
||||||
|
/// <c>.LEN</c> (Int32) and <c>.EN</c>, <c>.EW</c>, <c>.ER</c>, <c>.DN</c>, <c>.ST</c>,
|
||||||
|
/// <c>.CO</c>, <c>.NR</c>, <c>.TO</c> (Boolean status bits).
|
||||||
|
/// </summary>
|
||||||
|
MessageElement,
|
||||||
|
/// <summary>
|
||||||
|
/// PLS-file (Programmable Limit Switch) sub-element — caller addresses <c>.LEN</c>
|
||||||
|
/// (Int32). Bit semantics vary by PLC; unknown sub-elements fall back to Int32.
|
||||||
|
/// </summary>
|
||||||
|
PlsElement,
|
||||||
|
/// <summary>
|
||||||
|
/// BT-file (Block Transfer) sub-element — caller addresses <c>.RLEN</c>, <c>.DLEN</c>
|
||||||
|
/// (Int32) and <c>.EN</c>, <c>.ST</c>, <c>.DN</c>, <c>.ER</c>, <c>.CO</c>, <c>.EW</c>,
|
||||||
|
/// <c>.TO</c>, <c>.NR</c> (Boolean status bits in word 0).
|
||||||
|
/// </summary>
|
||||||
|
BlockTransferElement,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MicroLogix function-file sub-element catalogue. Covers the most-commonly-addressed members
|
||||||
|
/// per file — not exhaustive (Rockwell defines 30+ on RTC alone). Unknown sub-elements fall
|
||||||
|
/// back to <see cref="DriverDataType.Int32"/> at the <see cref="AbLegacyDataTypeExtensions"/>
|
||||||
|
/// boundary so the driver never refuses a tag the customer happens to know about.
|
||||||
|
/// </summary>
|
||||||
|
public static class AbLegacyFunctionFile
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Driver-surface type for <paramref name="fileLetter"/>.<paramref name="subElement"/>.
|
||||||
|
/// Returns <see cref="DriverDataType.Int32"/> if the sub-element is unrecognised — keeps
|
||||||
|
/// the driver permissive without forcing every quirk into the catalogue.
|
||||||
|
/// </summary>
|
||||||
|
public static DriverDataType SubElementType(string fileLetter, string? subElement)
|
||||||
|
{
|
||||||
|
if (subElement is null) return DriverDataType.Int32;
|
||||||
|
var key = (fileLetter.ToUpperInvariant(), subElement.ToUpperInvariant());
|
||||||
|
return key switch
|
||||||
|
{
|
||||||
|
// Real-time clock — all stored as Int16 (year is 4-digit Int16).
|
||||||
|
("RTC", "HR") or ("RTC", "MIN") or ("RTC", "SEC") or
|
||||||
|
("RTC", "MON") or ("RTC", "DAY") or ("RTC", "YR") or ("RTC", "DOW") => DriverDataType.Int32,
|
||||||
|
("RTC", "DS") or ("RTC", "BL") or ("RTC", "EN") => DriverDataType.Boolean,
|
||||||
|
|
||||||
|
// High-speed counter — accumulator/preset are Int32, status flags are bits.
|
||||||
|
("HSC", "ACC") or ("HSC", "PRE") or ("HSC", "OVF") or ("HSC", "UNF") => DriverDataType.Int32,
|
||||||
|
("HSC", "EN") or ("HSC", "UF") or ("HSC", "IF") or
|
||||||
|
("HSC", "IN") or ("HSC", "IH") or ("HSC", "IL") or
|
||||||
|
("HSC", "DN") or ("HSC", "CD") or ("HSC", "CU") => DriverDataType.Boolean,
|
||||||
|
|
||||||
|
// Daylight saving + memory module info.
|
||||||
|
("DLS", "STR") or ("DLS", "STD") => DriverDataType.Int32,
|
||||||
|
("DLS", "EN") => DriverDataType.Boolean,
|
||||||
|
("MMI", "FT") or ("MMI", "LBN") => DriverDataType.Int32,
|
||||||
|
("MMI", "MP") or ("MMI", "MCP") => DriverDataType.Boolean,
|
||||||
|
|
||||||
|
// Pulse-train / PWM output blocks.
|
||||||
|
("PTO", "ACC") or ("PTO", "OF") or ("PTO", "IDA") or ("PTO", "ODA") => DriverDataType.Int32,
|
||||||
|
("PTO", "EN") or ("PTO", "DN") or ("PTO", "EH") or ("PTO", "ED") or
|
||||||
|
("PTO", "RP") or ("PTO", "OUT") => DriverDataType.Boolean,
|
||||||
|
("PWM", "ACC") or ("PWM", "OF") or ("PWM", "PE") or ("PWM", "PD") => DriverDataType.Int32,
|
||||||
|
("PWM", "EN") or ("PWM", "DN") or ("PWM", "EH") or ("PWM", "ED") or
|
||||||
|
("PWM", "RP") or ("PWM", "OUT") => DriverDataType.Boolean,
|
||||||
|
|
||||||
|
// Selectable timed interrupt + event input interrupt.
|
||||||
|
("STI", "SPM") or ("STI", "ER") or ("STI", "PFN") => DriverDataType.Int32,
|
||||||
|
("STI", "EN") or ("STI", "TIE") or ("STI", "DN") or
|
||||||
|
("STI", "PS") or ("STI", "ED") => DriverDataType.Boolean,
|
||||||
|
("EII", "PFN") or ("EII", "ER") => DriverDataType.Int32,
|
||||||
|
("EII", "EN") or ("EII", "TIE") or ("EII", "PE") or
|
||||||
|
("EII", "ES") or ("EII", "ED") => DriverDataType.Boolean,
|
||||||
|
|
||||||
|
// I/O status + base hardware info — mostly status flags + a few counters.
|
||||||
|
("IOS", "ID") or ("IOS", "TYP") => DriverDataType.Int32,
|
||||||
|
("BHI", "OS") or ("BHI", "FRN") or ("BHI", "BSN") or ("BHI", "CC") => DriverDataType.Int32,
|
||||||
|
|
||||||
|
_ => DriverDataType.Int32,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Map a PCCC data type to the driver-surface <see cref="DriverDataType"/>.</summary>
|
/// <summary>Map a PCCC data type to the driver-surface <see cref="DriverDataType"/>.</summary>
|
||||||
@@ -40,6 +130,196 @@ public static class AbLegacyDataTypeExtensions
|
|||||||
AbLegacyDataType.String => DriverDataType.String,
|
AbLegacyDataType.String => DriverDataType.String,
|
||||||
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
||||||
or AbLegacyDataType.ControlElement => DriverDataType.Int32,
|
or AbLegacyDataType.ControlElement => DriverDataType.Int32,
|
||||||
|
AbLegacyDataType.MicroLogixFunctionFile => DriverDataType.Int32,
|
||||||
|
// PD/MG/PLS/BT default to Int32 at the parent-element level. The sub-element-aware
|
||||||
|
// EffectiveDriverDataType refines specific members (Float for PID gains, Boolean for
|
||||||
|
// status bits).
|
||||||
|
AbLegacyDataType.PidElement or AbLegacyDataType.MessageElement
|
||||||
|
or AbLegacyDataType.PlsElement or AbLegacyDataType.BlockTransferElement
|
||||||
|
=> DriverDataType.Int32,
|
||||||
_ => DriverDataType.Int32,
|
_ => DriverDataType.Int32,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sub-element-aware driver type. Timer/Counter/Control elements expose Boolean status
|
||||||
|
/// bits (<c>.DN</c>, <c>.EN</c>, <c>.TT</c>, <c>.CU</c>, <c>.CD</c>, <c>.OV</c>,
|
||||||
|
/// <c>.UN</c>, <c>.ER</c>, etc.) and Int32 word members (<c>.PRE</c>, <c>.ACC</c>,
|
||||||
|
/// <c>.LEN</c>, <c>.POS</c>). Unknown sub-elements fall back to
|
||||||
|
/// <see cref="ToDriverDataType"/> so the driver remains permissive.
|
||||||
|
/// </summary>
|
||||||
|
public static DriverDataType EffectiveDriverDataType(AbLegacyDataType t, string? subElement)
|
||||||
|
{
|
||||||
|
if (subElement is null) return t.ToDriverDataType();
|
||||||
|
var key = subElement.ToUpperInvariant();
|
||||||
|
return t switch
|
||||||
|
{
|
||||||
|
AbLegacyDataType.TimerElement => key switch
|
||||||
|
{
|
||||||
|
"EN" or "TT" or "DN" => DriverDataType.Boolean,
|
||||||
|
"PRE" or "ACC" => DriverDataType.Int32,
|
||||||
|
_ => t.ToDriverDataType(),
|
||||||
|
},
|
||||||
|
AbLegacyDataType.CounterElement => key switch
|
||||||
|
{
|
||||||
|
"CU" or "CD" or "DN" or "OV" or "UN" => DriverDataType.Boolean,
|
||||||
|
"PRE" or "ACC" => DriverDataType.Int32,
|
||||||
|
_ => t.ToDriverDataType(),
|
||||||
|
},
|
||||||
|
AbLegacyDataType.ControlElement => key switch
|
||||||
|
{
|
||||||
|
"EN" or "EU" or "DN" or "EM" or "ER" or "UL" or "IN" or "FD" => DriverDataType.Boolean,
|
||||||
|
"LEN" or "POS" => DriverDataType.Int32,
|
||||||
|
_ => t.ToDriverDataType(),
|
||||||
|
},
|
||||||
|
// PD-file (PID): SP/PV/CV/KP/KI/KD/MAXS/MINS/DB/OUT are 32-bit floats; EN/DN/MO/PE/
|
||||||
|
// AUTO/MAN/SP_VAL/SP_LL/SP_HL are status bits in word 0.
|
||||||
|
AbLegacyDataType.PidElement => key switch
|
||||||
|
{
|
||||||
|
"SP" or "PV" or "CV" or "KP" or "KI" or "KD"
|
||||||
|
or "MAXS" or "MINS" or "DB" or "OUT" => DriverDataType.Float32,
|
||||||
|
"EN" or "DN" or "MO" or "PE"
|
||||||
|
or "AUTO" or "MAN" or "SP_VAL" or "SP_LL" or "SP_HL" => DriverDataType.Boolean,
|
||||||
|
_ => t.ToDriverDataType(),
|
||||||
|
},
|
||||||
|
// MG-file (Message): RBE/MS/SIZE/LEN are control words; EN/EW/ER/DN/ST/CO/NR/TO are
|
||||||
|
// status bits.
|
||||||
|
AbLegacyDataType.MessageElement => key switch
|
||||||
|
{
|
||||||
|
"RBE" or "MS" or "SIZE" or "LEN" => DriverDataType.Int32,
|
||||||
|
"EN" or "EW" or "ER" or "DN" or "ST" or "CO" or "NR" or "TO" => DriverDataType.Boolean,
|
||||||
|
_ => t.ToDriverDataType(),
|
||||||
|
},
|
||||||
|
// PLS-file (Programmable Limit Switch): LEN is a length word; bit semantics vary by
|
||||||
|
// PLC so unknown sub-elements stay Int32.
|
||||||
|
AbLegacyDataType.PlsElement => key switch
|
||||||
|
{
|
||||||
|
"LEN" => DriverDataType.Int32,
|
||||||
|
_ => t.ToDriverDataType(),
|
||||||
|
},
|
||||||
|
// BT-file (Block Transfer, PLC-5): RLEN/DLEN are length words; EN/ST/DN/ER/CO/EW/
|
||||||
|
// TO/NR are status bits in word 0.
|
||||||
|
AbLegacyDataType.BlockTransferElement => key switch
|
||||||
|
{
|
||||||
|
"RLEN" or "DLEN" => DriverDataType.Int32,
|
||||||
|
"EN" or "ST" or "DN" or "ER" or "CO" or "EW" or "TO" or "NR" => DriverDataType.Boolean,
|
||||||
|
_ => t.ToDriverDataType(),
|
||||||
|
},
|
||||||
|
_ => t.ToDriverDataType(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bit position within the parent control word for Timer/Counter/Control status bits.
|
||||||
|
/// Returns <c>null</c> if the sub-element is not a known bit member of the given element
|
||||||
|
/// type. Bit numbering follows Rockwell DTAM / PCCC documentation.
|
||||||
|
/// </summary>
|
||||||
|
public static int? StatusBitIndex(AbLegacyDataType t, string? subElement)
|
||||||
|
{
|
||||||
|
if (subElement is null) return null;
|
||||||
|
var key = subElement.ToUpperInvariant();
|
||||||
|
return t switch
|
||||||
|
{
|
||||||
|
// T4 element word 0: bit 13=DN, 14=TT, 15=EN.
|
||||||
|
AbLegacyDataType.TimerElement => key switch
|
||||||
|
{
|
||||||
|
"DN" => 13,
|
||||||
|
"TT" => 14,
|
||||||
|
"EN" => 15,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
// C5 element word 0: bit 10=UN, 11=OV, 12=DN, 13=CD, 14=CU.
|
||||||
|
AbLegacyDataType.CounterElement => key switch
|
||||||
|
{
|
||||||
|
"UN" => 10,
|
||||||
|
"OV" => 11,
|
||||||
|
"DN" => 12,
|
||||||
|
"CD" => 13,
|
||||||
|
"CU" => 14,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
// R6 element word 0: bit 8=FD, 9=IN, 10=UL, 11=ER, 12=EM, 13=DN, 14=EU, 15=EN.
|
||||||
|
AbLegacyDataType.ControlElement => key switch
|
||||||
|
{
|
||||||
|
"FD" => 8,
|
||||||
|
"IN" => 9,
|
||||||
|
"UL" => 10,
|
||||||
|
"ER" => 11,
|
||||||
|
"EM" => 12,
|
||||||
|
"DN" => 13,
|
||||||
|
"EU" => 14,
|
||||||
|
"EN" => 15,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
// PD element word 0 (SLC 5/02+ PID, 1747-RM001 / PLC-5 PID-RM): bit 0=EN, 1=PE,
|
||||||
|
// 2=DN, 3=MO (manual mode), 4=AUTO, 5=MAN, 6=SP_VAL, 7=SP_LL, 8=SP_HL. Bits 4–8 are
|
||||||
|
// the SP-validity / SP-limit flags exposed in RSLogix 5 / 500.
|
||||||
|
AbLegacyDataType.PidElement => key switch
|
||||||
|
{
|
||||||
|
"EN" => 0,
|
||||||
|
"PE" => 1,
|
||||||
|
"DN" => 2,
|
||||||
|
"MO" => 3,
|
||||||
|
"AUTO" => 4,
|
||||||
|
"MAN" => 5,
|
||||||
|
"SP_VAL" => 6,
|
||||||
|
"SP_LL" => 7,
|
||||||
|
"SP_HL" => 8,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
// MG element word 0 (PLC-5 MSG / SLC 5/05 MSG, 1785-6.5.12 / 1747-RM001):
|
||||||
|
// bit 15=EN, 14=ST, 13=DN, 12=ER, 11=CO, 10=EW, 9=NR, 8=TO.
|
||||||
|
AbLegacyDataType.MessageElement => key switch
|
||||||
|
{
|
||||||
|
"TO" => 8,
|
||||||
|
"NR" => 9,
|
||||||
|
"EW" => 10,
|
||||||
|
"CO" => 11,
|
||||||
|
"ER" => 12,
|
||||||
|
"DN" => 13,
|
||||||
|
"ST" => 14,
|
||||||
|
"EN" => 15,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
// BT element word 0 (PLC-5 chassis BTR/BTW, 1785-6.5.12):
|
||||||
|
// bit 15=EN, 14=ST, 13=DN, 12=ER, 11=CO, 10=EW, 9=NR, 8=TO. Same layout as MG.
|
||||||
|
AbLegacyDataType.BlockTransferElement => key switch
|
||||||
|
{
|
||||||
|
"TO" => 8,
|
||||||
|
"NR" => 9,
|
||||||
|
"EW" => 10,
|
||||||
|
"CO" => 11,
|
||||||
|
"ER" => 12,
|
||||||
|
"DN" => 13,
|
||||||
|
"ST" => 14,
|
||||||
|
"EN" => 15,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PLC-set status bits — read-only from the OPC UA side. Operator-controllable bits
|
||||||
|
/// (e.g. <c>.EN</c> on a timer/counter, <c>.CU</c>/<c>.CD</c> rung-driven inputs) are
|
||||||
|
/// omitted so they keep default writable behaviour.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsPlcSetStatusBit(AbLegacyDataType t, string? subElement)
|
||||||
|
{
|
||||||
|
if (subElement is null) return false;
|
||||||
|
var key = subElement.ToUpperInvariant();
|
||||||
|
return t switch
|
||||||
|
{
|
||||||
|
AbLegacyDataType.TimerElement => key is "DN" or "TT",
|
||||||
|
AbLegacyDataType.CounterElement => key is "DN" or "OV" or "UN",
|
||||||
|
AbLegacyDataType.ControlElement => key is "DN" or "EM" or "ER" or "FD" or "UL" or "IN",
|
||||||
|
// PID: PE (PID-error), DN (process-done), SP_VAL/SP_LL/SP_HL are PLC-set status.
|
||||||
|
// EN/MO/AUTO/MAN are operator-controllable via the .EN bit / mode select.
|
||||||
|
AbLegacyDataType.PidElement => key is "PE" or "DN" or "SP_VAL" or "SP_LL" or "SP_HL",
|
||||||
|
// MG/BT: ST (started), DN (done), ER (error), CO (continuous), EW (enabled-waiting),
|
||||||
|
// NR (no-response), TO (timeout) are PLC-set. EN is operator-driven via the rung.
|
||||||
|
AbLegacyDataType.MessageElement => key is "ST" or "DN" or "ER" or "CO" or "EW" or "NR" or "TO",
|
||||||
|
AbLegacyDataType.BlockTransferElement => key is "ST" or "DN" or "ER" or "CO" or "EW" or "NR" or "TO",
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,8 +140,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
||||||
var value = runtime.DecodeValue(def.DataType, parsed?.BitIndex);
|
// Timer/Counter/Control status bits route through GetBit at the parent-word
|
||||||
|
// address — translate the .DN/.EN/etc. sub-element to its standard bit position
|
||||||
|
// and pass it down to the runtime as a synthetic bitIndex.
|
||||||
|
var decodeBit = parsed?.BitIndex
|
||||||
|
?? AbLegacyDataTypeExtensions.StatusBitIndex(def.DataType, parsed?.SubElement);
|
||||||
|
var value = runtime.DecodeValue(def.DataType, decodeBit);
|
||||||
results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
|
results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
|
||||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||||
}
|
}
|
||||||
@@ -186,7 +191,16 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
||||||
|
|
||||||
|
// Timer/Counter/Control PLC-set status bits (DN, TT, OV, UN, FD, ER, EM, UL,
|
||||||
|
// IN) are read-only — the PLC sets them; any client write would be silently
|
||||||
|
// overwritten on the next scan. Reject up front with BadNotWritable.
|
||||||
|
if (AbLegacyDataTypeExtensions.IsPlcSetStatusBit(def.DataType, parsed?.SubElement))
|
||||||
|
{
|
||||||
|
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotWritable);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// PCCC bit-within-word writes — task #181 pass 2. RMW against a parallel
|
// PCCC bit-within-word writes — task #181 pass 2. RMW against a parallel
|
||||||
// parent-word runtime (strip the /N bit suffix). Per-parent-word lock serialises
|
// parent-word runtime (strip the /N bit suffix). Per-parent-word lock serialises
|
||||||
@@ -223,6 +237,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
{
|
{
|
||||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
|
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
|
||||||
}
|
}
|
||||||
|
catch (ArgumentOutOfRangeException)
|
||||||
|
{
|
||||||
|
// ST-file string writes exceeding the 82-byte fixed element. Surfaces from
|
||||||
|
// LibplctagLegacyTagRuntime.EncodeValue's length guard; mapped to BadOutOfRange so
|
||||||
|
// the OPC UA client sees a clean rejection rather than a silent truncation.
|
||||||
|
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
|
results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
|
||||||
@@ -247,12 +268,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||||
foreach (var tag in tagsForDevice)
|
foreach (var tag in tagsForDevice)
|
||||||
{
|
{
|
||||||
|
var parsed = AbLegacyAddress.TryParse(tag.Address, device.PlcFamily);
|
||||||
|
// Timer/Counter/Control sub-elements (.DN/.EN/.TT/.PRE/.ACC/etc.) refine the
|
||||||
|
// base element's Int32 to Boolean for status bits and Int32 for word members.
|
||||||
|
var effectiveType = AbLegacyDataTypeExtensions.EffectiveDriverDataType(
|
||||||
|
tag.DataType, parsed?.SubElement);
|
||||||
|
var plcSetBit = AbLegacyDataTypeExtensions.IsPlcSetStatusBit(
|
||||||
|
tag.DataType, parsed?.SubElement);
|
||||||
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
||||||
FullName: tag.Name,
|
FullName: tag.Name,
|
||||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
DriverDataType: effectiveType,
|
||||||
IsArray: false,
|
IsArray: false,
|
||||||
ArrayDim: null,
|
ArrayDim: null,
|
||||||
SecurityClass: tag.Writable
|
SecurityClass: tag.Writable && !plcSetBit
|
||||||
? SecurityClassification.Operate
|
? SecurityClassification.Operate
|
||||||
: SecurityClassification.ViewOnly,
|
: SecurityClassification.ViewOnly,
|
||||||
IsHistorized: false,
|
IsHistorized: false,
|
||||||
@@ -413,10 +441,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
|||||||
{
|
{
|
||||||
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
|
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
|
||||||
|
|
||||||
var parsed = AbLegacyAddress.TryParse(def.Address)
|
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");
|
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");
|
||||||
|
|
||||||
|
// TODO(#247): libplctag's PCCC text decoder does not natively accept the bracket-form
|
||||||
|
// indirect address. Resolving N7:[N7:0] requires reading the inner address first, then
|
||||||
|
// rewriting the tag name with the resolved word number, then issuing the actual read.
|
||||||
|
// For now we surface a clear runtime error rather than letting libplctag fail with an
|
||||||
|
// opaque parser error.
|
||||||
|
if (parsed.IsIndirect)
|
||||||
|
throw new NotSupportedException(
|
||||||
|
$"AbLegacy tag '{def.Name}' uses indirect addressing ('{def.Address}'); runtime resolution is not yet implemented.");
|
||||||
|
|
||||||
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
|
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
|
||||||
Gateway: device.ParsedAddress.Gateway,
|
Gateway: device.ParsedAddress.Gateway,
|
||||||
Port: device.ParsedAddress.Port,
|
Port: device.ParsedAddress.Port,
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Static factory registration helper for <see cref="AbLegacyDriver"/>. Server's Program.cs
|
||||||
|
/// calls <see cref="Register"/> once at startup; the bootstrapper (task #248) then
|
||||||
|
/// materialises AB Legacy DriverInstance rows from the central config DB into live
|
||||||
|
/// driver instances. Mirrors <c>GalaxyProxyDriverFactoryExtensions</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static class AbLegacyDriverFactoryExtensions
|
||||||
|
{
|
||||||
|
public const string DriverTypeName = "AbLegacy";
|
||||||
|
|
||||||
|
public static void Register(DriverFactoryRegistry registry)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(registry);
|
||||||
|
registry.Register(DriverTypeName, CreateInstance);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static AbLegacyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
|
|
||||||
|
var dto = JsonSerializer.Deserialize<AbLegacyDriverConfigDto>(driverConfigJson, JsonOptions)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"AB Legacy driver config for '{driverInstanceId}' deserialised to null");
|
||||||
|
|
||||||
|
var options = new AbLegacyDriverOptions
|
||||||
|
{
|
||||||
|
Devices = dto.Devices is { Count: > 0 }
|
||||||
|
? [.. dto.Devices.Select(d => new AbLegacyDeviceOptions(
|
||||||
|
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
|
||||||
|
$"AB Legacy config for '{driverInstanceId}' has a device missing HostAddress"),
|
||||||
|
PlcFamily: ParseEnum<AbLegacyPlcFamily>(d.PlcFamily, driverInstanceId, "PlcFamily",
|
||||||
|
fallback: AbLegacyPlcFamily.Slc500),
|
||||||
|
DeviceName: d.DeviceName))]
|
||||||
|
: [],
|
||||||
|
Tags = dto.Tags is { Count: > 0 }
|
||||||
|
? [.. dto.Tags.Select(t => new AbLegacyTagDefinition(
|
||||||
|
Name: t.Name ?? throw new InvalidOperationException(
|
||||||
|
$"AB Legacy config for '{driverInstanceId}' has a tag missing Name"),
|
||||||
|
DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
|
||||||
|
$"AB Legacy tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
|
||||||
|
Address: t.Address ?? throw new InvalidOperationException(
|
||||||
|
$"AB Legacy tag '{t.Name}' in '{driverInstanceId}' missing Address"),
|
||||||
|
DataType: ParseEnum<AbLegacyDataType>(t.DataType, driverInstanceId, "DataType",
|
||||||
|
tagName: t.Name),
|
||||||
|
Writable: t.Writable ?? true,
|
||||||
|
WriteIdempotent: t.WriteIdempotent ?? false))]
|
||||||
|
: [],
|
||||||
|
Probe = new AbLegacyProbeOptions
|
||||||
|
{
|
||||||
|
Enabled = dto.Probe?.Enabled ?? true,
|
||||||
|
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
|
||||||
|
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
|
||||||
|
},
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
|
||||||
|
};
|
||||||
|
|
||||||
|
return new AbLegacyDriver(options, driverInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T ParseEnum<T>(string? raw, string driverInstanceId, string field,
|
||||||
|
string? tagName = null, T? fallback = null) where T : struct, Enum
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
if (fallback.HasValue) return fallback.Value;
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"AB Legacy {(tagName is null ? "config" : $"tag '{tagName}'")} in '{driverInstanceId}' missing {field}");
|
||||||
|
}
|
||||||
|
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
|
||||||
|
? v
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
$"AB Legacy {(tagName is null ? "config" : $"tag '{tagName}'")} has unknown {field} '{raw}'. " +
|
||||||
|
$"Expected one of {string.Join(", ", Enum.GetNames<T>())}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
|
AllowTrailingCommas = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
internal sealed class AbLegacyDriverConfigDto
|
||||||
|
{
|
||||||
|
public int? TimeoutMs { get; init; }
|
||||||
|
public List<AbLegacyDeviceDto>? Devices { get; init; }
|
||||||
|
public List<AbLegacyTagDto>? Tags { get; init; }
|
||||||
|
public AbLegacyProbeDto? Probe { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class AbLegacyDeviceDto
|
||||||
|
{
|
||||||
|
public string? HostAddress { get; init; }
|
||||||
|
public string? PlcFamily { get; init; }
|
||||||
|
public string? DeviceName { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class AbLegacyTagDto
|
||||||
|
{
|
||||||
|
public string? Name { get; init; }
|
||||||
|
public string? DeviceHostAddress { get; init; }
|
||||||
|
public string? Address { get; init; }
|
||||||
|
public string? DataType { get; init; }
|
||||||
|
public bool? Writable { get; init; }
|
||||||
|
public bool? WriteIdempotent { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class AbLegacyProbeDto
|
||||||
|
{
|
||||||
|
public bool? Enabled { get; init; }
|
||||||
|
public int? IntervalMs { get; init; }
|
||||||
|
public int? TimeoutMs { get; init; }
|
||||||
|
public string? ProbeAddress { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ public sealed record AbLegacyDeviceOptions(
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One PCCC-backed OPC UA variable. <paramref name="Address"/> is the canonical PCCC
|
/// One PCCC-backed OPC UA variable. <paramref name="Address"/> is the canonical PCCC
|
||||||
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse"/>.
|
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse(string?)"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record AbLegacyTagDefinition(
|
public sealed record AbLegacyTagDefinition(
|
||||||
string Name,
|
string Name,
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
|||||||
{
|
{
|
||||||
private readonly Tag _tag;
|
private readonly Tag _tag;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum payload length for an ST (string) file element on SLC / MicroLogix / PLC-5.
|
||||||
|
/// The on-wire layout is a 1-word length prefix followed by 82 ASCII bytes — libplctag's
|
||||||
|
/// <c>SetString</c> handles the framing internally, but it does NOT validate length, so a
|
||||||
|
/// 93-byte source string would silently truncate. We reject up-front so the OPC UA client
|
||||||
|
/// gets a clean <c>BadOutOfRange</c> rather than a corrupted PLC value.
|
||||||
|
/// </summary>
|
||||||
|
internal const int StFileMaxStringLength = 82;
|
||||||
|
|
||||||
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
|
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
|
||||||
{
|
{
|
||||||
_tag = new Tag
|
_tag = new Tag
|
||||||
@@ -40,8 +49,25 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
|||||||
AbLegacyDataType.Long => _tag.GetInt32(0),
|
AbLegacyDataType.Long => _tag.GetInt32(0),
|
||||||
AbLegacyDataType.Float => _tag.GetFloat32(0),
|
AbLegacyDataType.Float => _tag.GetFloat32(0),
|
||||||
AbLegacyDataType.String => _tag.GetString(0),
|
AbLegacyDataType.String => _tag.GetString(0),
|
||||||
|
// Timer/Counter/Control sub-elements: bitIndex is the status bit position within the
|
||||||
|
// parent control word (encoded by AbLegacyDriver from the .DN / .EN / etc. sub-element
|
||||||
|
// name). Word members (.PRE / .ACC / .LEN / .POS) come through with bitIndex=null and
|
||||||
|
// decode as Int32 like before.
|
||||||
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
||||||
or AbLegacyDataType.ControlElement => _tag.GetInt32(0),
|
or AbLegacyDataType.ControlElement => bitIndex is int statusBit
|
||||||
|
? _tag.GetBit(statusBit)
|
||||||
|
: _tag.GetInt32(0),
|
||||||
|
// PD-file (PID): non-bit members (SP/PV/CV/KP/KI/KD/MAXS/MINS/DB/OUT) are 32-bit floats.
|
||||||
|
// Status bits (EN/DN/MO/PE/AUTO/MAN/SP_VAL/SP_LL/SP_HL) live in the parent control word
|
||||||
|
// and read through GetBit — the driver encodes the position via StatusBitIndex.
|
||||||
|
AbLegacyDataType.PidElement => bitIndex is int pidBit
|
||||||
|
? _tag.GetBit(pidBit)
|
||||||
|
: _tag.GetFloat32(0),
|
||||||
|
// MG/BT/PLS: non-bit members (RBE/MS/SIZE/LEN, RLEN/DLEN) are word-sized integers.
|
||||||
|
AbLegacyDataType.MessageElement or AbLegacyDataType.BlockTransferElement
|
||||||
|
or AbLegacyDataType.PlsElement => bitIndex is int statusBit2
|
||||||
|
? _tag.GetBit(statusBit2)
|
||||||
|
: _tag.GetInt32(0),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,13 +96,32 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
|||||||
_tag.SetFloat32(0, Convert.ToSingle(value));
|
_tag.SetFloat32(0, Convert.ToSingle(value));
|
||||||
break;
|
break;
|
||||||
case AbLegacyDataType.String:
|
case AbLegacyDataType.String:
|
||||||
_tag.SetString(0, Convert.ToString(value) ?? string.Empty);
|
{
|
||||||
|
var s = Convert.ToString(value) ?? string.Empty;
|
||||||
|
if (s.Length > StFileMaxStringLength)
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(value),
|
||||||
|
$"ST string write exceeds {StFileMaxStringLength}-byte file element capacity (was {s.Length}).");
|
||||||
|
_tag.SetString(0, s);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case AbLegacyDataType.TimerElement:
|
case AbLegacyDataType.TimerElement:
|
||||||
case AbLegacyDataType.CounterElement:
|
case AbLegacyDataType.CounterElement:
|
||||||
case AbLegacyDataType.ControlElement:
|
case AbLegacyDataType.ControlElement:
|
||||||
_tag.SetInt32(0, Convert.ToInt32(value));
|
_tag.SetInt32(0, Convert.ToInt32(value));
|
||||||
break;
|
break;
|
||||||
|
// PD-file non-bit writes route to the Float backing store. Status-bit writes within
|
||||||
|
// the parent word are blocked at the driver layer (PLC-set bits are read-only and
|
||||||
|
// operator-controllable bits go through the bit-RMW path with the parent word typed
|
||||||
|
// as Int).
|
||||||
|
case AbLegacyDataType.PidElement:
|
||||||
|
_tag.SetFloat32(0, Convert.ToSingle(value));
|
||||||
|
break;
|
||||||
|
case AbLegacyDataType.MessageElement:
|
||||||
|
case AbLegacyDataType.BlockTransferElement:
|
||||||
|
case AbLegacyDataType.PlsElement:
|
||||||
|
_tag.SetInt32(0, Convert.ToInt32(value));
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new NotSupportedException($"AbLegacyDataType {type} not writable.");
|
throw new NotSupportedException($"AbLegacyDataType {type} not writable.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ public sealed record AbLegacyPlcFamilyProfile(
|
|||||||
string DefaultCipPath,
|
string DefaultCipPath,
|
||||||
int MaxTagBytes,
|
int MaxTagBytes,
|
||||||
bool SupportsStringFile,
|
bool SupportsStringFile,
|
||||||
bool SupportsLongFile)
|
bool SupportsLongFile,
|
||||||
|
bool OctalIoAddressing,
|
||||||
|
bool SupportsFunctionFiles,
|
||||||
|
bool SupportsPidFile,
|
||||||
|
bool SupportsMessageFile,
|
||||||
|
bool SupportsPlsFile,
|
||||||
|
bool SupportsBlockTransferFile)
|
||||||
{
|
{
|
||||||
public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch
|
public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch
|
||||||
{
|
{
|
||||||
@@ -25,21 +31,39 @@ public sealed record AbLegacyPlcFamilyProfile(
|
|||||||
DefaultCipPath: "1,0",
|
DefaultCipPath: "1,0",
|
||||||
MaxTagBytes: 240, // SLC 5/05 PCCC max packet data
|
MaxTagBytes: 240, // SLC 5/05 PCCC max packet data
|
||||||
SupportsStringFile: true, // ST file available SLC 5/04+
|
SupportsStringFile: true, // ST file available SLC 5/04+
|
||||||
SupportsLongFile: true); // L file available SLC 5/05+
|
SupportsLongFile: true, // L file available SLC 5/05+
|
||||||
|
OctalIoAddressing: false, // SLC500 I:/O: indices are decimal in RSLogix 500
|
||||||
|
SupportsFunctionFiles: false, // SLC500 has no function files
|
||||||
|
SupportsPidFile: true, // SLC 5/02+ supports PD via PID instruction
|
||||||
|
SupportsMessageFile: true, // SLC 5/02+ supports MG via MSG instruction
|
||||||
|
SupportsPlsFile: false, // SLC500 has no native PLS file (uses SQO/SQC instead)
|
||||||
|
SupportsBlockTransferFile: false); // SLC500 has no BT file (BT is PLC-5 ChassisIO only)
|
||||||
|
|
||||||
public static readonly AbLegacyPlcFamilyProfile MicroLogix = new(
|
public static readonly AbLegacyPlcFamilyProfile MicroLogix = new(
|
||||||
LibplctagPlcAttribute: "micrologix",
|
LibplctagPlcAttribute: "micrologix",
|
||||||
DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path
|
DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path
|
||||||
MaxTagBytes: 232,
|
MaxTagBytes: 232,
|
||||||
SupportsStringFile: true,
|
SupportsStringFile: true,
|
||||||
SupportsLongFile: false); // ML 1100/1200/1400 don't ship L files
|
SupportsLongFile: false, // ML 1100/1200/1400 don't ship L files
|
||||||
|
OctalIoAddressing: false, // MicroLogix follows SLC-style decimal I/O addressing
|
||||||
|
SupportsFunctionFiles: true, // ML 1100/1400 expose RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI
|
||||||
|
SupportsPidFile: false, // MicroLogix 1100/1400 use PID-instruction-only addressing — no PD file type
|
||||||
|
SupportsMessageFile: false, // No MG file — MSG instruction control words live in standard files
|
||||||
|
SupportsPlsFile: false,
|
||||||
|
SupportsBlockTransferFile: false);
|
||||||
|
|
||||||
public static readonly AbLegacyPlcFamilyProfile Plc5 = new(
|
public static readonly AbLegacyPlcFamilyProfile Plc5 = new(
|
||||||
LibplctagPlcAttribute: "plc5",
|
LibplctagPlcAttribute: "plc5",
|
||||||
DefaultCipPath: "1,0",
|
DefaultCipPath: "1,0",
|
||||||
MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower
|
MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower
|
||||||
SupportsStringFile: true,
|
SupportsStringFile: true,
|
||||||
SupportsLongFile: false); // PLC-5 predates L files
|
SupportsLongFile: false, // PLC-5 predates L files
|
||||||
|
OctalIoAddressing: true, // RSLogix 5 displays I:/O: word + bit indices as octal
|
||||||
|
SupportsFunctionFiles: false,
|
||||||
|
SupportsPidFile: true, // PLC-5 PID instruction needs PD file
|
||||||
|
SupportsMessageFile: true, // PLC-5 MSG instruction needs MG file
|
||||||
|
SupportsPlsFile: true, // PLC-5 has PLS (programmable limit switch) file
|
||||||
|
SupportsBlockTransferFile: true); // PLC-5 chassis I/O block transfer (BTR/BTW) needs BT file
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
|
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
|
||||||
@@ -51,7 +75,15 @@ public sealed record AbLegacyPlcFamilyProfile(
|
|||||||
DefaultCipPath: "1,0",
|
DefaultCipPath: "1,0",
|
||||||
MaxTagBytes: 240,
|
MaxTagBytes: 240,
|
||||||
SupportsStringFile: true,
|
SupportsStringFile: true,
|
||||||
SupportsLongFile: true);
|
SupportsLongFile: true,
|
||||||
|
OctalIoAddressing: false, // Logix natively uses decimal arrays even via the PCCC bridge
|
||||||
|
SupportsFunctionFiles: false,
|
||||||
|
// Logix native UDTs (PID_ENHANCED / MESSAGE) replace the legacy PD/MG file types — the
|
||||||
|
// PCCC bridge does not expose them as letter-prefixed files.
|
||||||
|
SupportsPidFile: false,
|
||||||
|
SupportsMessageFile: false,
|
||||||
|
SupportsPlsFile: false,
|
||||||
|
SupportsBlockTransferFile: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Which PCCC PLC family the device is.</summary>
|
/// <summary>Which PCCC PLC family the device is.</summary>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
60
src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/DriverCommandBase.cs
Normal file
60
src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/DriverCommandBase.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
using CliFx;
|
||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared base for every driver test-client command (Modbus / AB CIP / AB Legacy / S7 /
|
||||||
|
/// TwinCAT). Carries the options that are meaningful regardless of protocol — verbose
|
||||||
|
/// logging + the standard timeout — plus helpers every command implementation wants:
|
||||||
|
/// Serilog configuration + cancellation-token capture.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Each driver CLI sub-classes this with its own protocol-specific base (e.g.
|
||||||
|
/// <c>ModbusCommandBase</c>) that adds host/port/unit-id + a <c>BuildDriver()</c>
|
||||||
|
/// factory. That second layer is the point where the driver's <c>{Driver}DriverOptions</c>
|
||||||
|
/// type plugs in; keeping it out of this common base lets each driver CLI stay a thin
|
||||||
|
/// executable with no dependency on the other drivers' projects.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Why a shared base at all — without this every CLI re-authored the same ~40 lines
|
||||||
|
/// of Serilog wiring + cancel-token plumbing + verbose flag.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public abstract class DriverCommandBase : ICommand
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Enable Serilog debug-level output. Leave off for clean one-line-per-call output;
|
||||||
|
/// switch on when diagnosing a connect / PDU-framing / retry problem.
|
||||||
|
/// </summary>
|
||||||
|
[CommandOption("verbose", Description = "Enable verbose/debug Serilog output")]
|
||||||
|
public bool Verbose { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request-level timeout used by the driver's <c>Initialize</c> / <c>Read</c> /
|
||||||
|
/// <c>Write</c> / probe calls. Defaults per-protocol (Modbus: 2s, AB: 5s, S7: 5s,
|
||||||
|
/// TwinCAT: 5s) — each driver CLI overrides this property with the appropriate
|
||||||
|
/// <c>[CommandOption]</c> default.
|
||||||
|
/// </summary>
|
||||||
|
public abstract TimeSpan Timeout { get; init; }
|
||||||
|
|
||||||
|
public abstract ValueTask ExecuteAsync(IConsole console);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the process-global Serilog logger. Commands call this at the top of
|
||||||
|
/// <see cref="ExecuteAsync"/> so driver-internal <c>Log.Logger</c> writes land on the
|
||||||
|
/// same sink as the CLI's operator-facing output.
|
||||||
|
/// </summary>
|
||||||
|
protected void ConfigureLogging()
|
||||||
|
{
|
||||||
|
var config = new LoggerConfiguration();
|
||||||
|
if (Verbose)
|
||||||
|
config.MinimumLevel.Debug().WriteTo.Console();
|
||||||
|
else
|
||||||
|
config.MinimumLevel.Warning().WriteTo.Console();
|
||||||
|
Log.Logger = config.CreateLogger();
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs
Normal file
131
src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders <see cref="DataValueSnapshot"/> + <see cref="WriteResult"/> payloads as the
|
||||||
|
/// plain-text lines every driver CLI prints to its console. Matches the one-field-per-line
|
||||||
|
/// style the existing OPC UA <c>otopcua-cli</c> uses so combined runs (read a tag via both
|
||||||
|
/// CLIs side-by-side) look coherent.
|
||||||
|
/// </summary>
|
||||||
|
public static class SnapshotFormatter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Single-tag multi-line render. Shape:
|
||||||
|
/// <code>
|
||||||
|
/// Tag: <name>
|
||||||
|
/// Value: <value>
|
||||||
|
/// Status: 0x... (Good|BadCommunicationError|...)
|
||||||
|
/// Source Time: 2026-04-21T12:34:56.789Z
|
||||||
|
/// Server Time: 2026-04-21T12:34:56.790Z
|
||||||
|
/// </code>
|
||||||
|
/// </summary>
|
||||||
|
public static string Format(string tagName, DataValueSnapshot snapshot)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshot);
|
||||||
|
var lines = new[]
|
||||||
|
{
|
||||||
|
$"Tag: {tagName}",
|
||||||
|
$"Value: {FormatValue(snapshot.Value)}",
|
||||||
|
$"Status: {FormatStatus(snapshot.StatusCode)}",
|
||||||
|
$"Source Time: {FormatTimestamp(snapshot.SourceTimestampUtc)}",
|
||||||
|
$"Server Time: {FormatTimestamp(snapshot.ServerTimestampUtc)}",
|
||||||
|
};
|
||||||
|
return string.Join(Environment.NewLine, lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write-result render, one line: <c>Write <tag>: 0x... (Good|...)</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static string FormatWrite(string tagName, WriteResult result)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(result);
|
||||||
|
return $"Write {tagName}: {FormatStatus(result.StatusCode)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Table-style render for batch reads. Emits an aligned 4-column layout:
|
||||||
|
/// tag / value / status / source-time.
|
||||||
|
/// </summary>
|
||||||
|
public static string FormatTable(
|
||||||
|
IReadOnlyList<string> tagNames, IReadOnlyList<DataValueSnapshot> snapshots)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(tagNames);
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshots);
|
||||||
|
if (tagNames.Count != snapshots.Count)
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"tagNames ({tagNames.Count}) and snapshots ({snapshots.Count}) must be the same length");
|
||||||
|
|
||||||
|
var rows = tagNames.Select((t, i) => new
|
||||||
|
{
|
||||||
|
Tag = t,
|
||||||
|
Value = FormatValue(snapshots[i].Value),
|
||||||
|
Status = FormatStatus(snapshots[i].StatusCode),
|
||||||
|
Time = FormatTimestamp(snapshots[i].SourceTimestampUtc),
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
int tagW = Math.Max("TAG".Length, rows.Max(r => r.Tag.Length));
|
||||||
|
int valW = Math.Max("VALUE".Length, rows.Max(r => r.Value.Length));
|
||||||
|
int statW = Math.Max("STATUS".Length, rows.Max(r => r.Status.Length));
|
||||||
|
// source-time column is fixed-width (ISO-8601 to ms) so no max-measurement needed.
|
||||||
|
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
sb.Append("TAG".PadRight(tagW)).Append(" ")
|
||||||
|
.Append("VALUE".PadRight(valW)).Append(" ")
|
||||||
|
.Append("STATUS".PadRight(statW)).Append(" ")
|
||||||
|
.Append("SOURCE TIME").AppendLine();
|
||||||
|
sb.Append(new string('-', tagW)).Append(" ")
|
||||||
|
.Append(new string('-', valW)).Append(" ")
|
||||||
|
.Append(new string('-', statW)).Append(" ")
|
||||||
|
.Append(new string('-', "SOURCE TIME".Length)).AppendLine();
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
sb.Append(r.Tag.PadRight(tagW)).Append(" ")
|
||||||
|
.Append(r.Value.PadRight(valW)).Append(" ")
|
||||||
|
.Append(r.Status.PadRight(statW)).Append(" ")
|
||||||
|
.Append(r.Time).AppendLine();
|
||||||
|
}
|
||||||
|
return sb.ToString().TrimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string FormatValue(object? value) => value switch
|
||||||
|
{
|
||||||
|
null => "<null>",
|
||||||
|
bool b => b ? "true" : "false",
|
||||||
|
string s => $"\"{s}\"",
|
||||||
|
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
|
||||||
|
_ => value.ToString() ?? "<null>",
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string FormatStatus(uint statusCode)
|
||||||
|
{
|
||||||
|
// Match the OPC UA shorthand for the statuses most-likely to land in a CLI run.
|
||||||
|
// Anything outside this short-list surfaces as hex — operators can cross-reference
|
||||||
|
// against OPC UA Part 6 § 7.34 (StatusCode tables) or Core.Abstractions status mappers.
|
||||||
|
var name = statusCode switch
|
||||||
|
{
|
||||||
|
0x00000000u => "Good",
|
||||||
|
0x80000000u => "Bad",
|
||||||
|
0x80050000u => "BadCommunicationError",
|
||||||
|
0x80060000u => "BadTimeout",
|
||||||
|
0x80070000u => "BadNoCommunication",
|
||||||
|
0x80080000u => "BadWaitingForInitialData",
|
||||||
|
0x80340000u => "BadNodeIdUnknown",
|
||||||
|
0x80350000u => "BadNodeIdInvalid",
|
||||||
|
0x80740000u => "BadTypeMismatch",
|
||||||
|
0x40000000u => "Uncertain",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
return name is null
|
||||||
|
? $"0x{statusCode:X8}"
|
||||||
|
: $"0x{statusCode:X8} ({name})";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string FormatTimestamp(DateTime? ts)
|
||||||
|
{
|
||||||
|
if (ts is null) return "-";
|
||||||
|
var utc = ts.Value.Kind == DateTimeKind.Utc ? ts.Value : ts.Value.ToUniversalTime();
|
||||||
|
return utc.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
|
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Cli.Common</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="CliFx" Version="2.3.6"/>
|
||||||
|
<PackageReference Include="Serilog" Version="4.2.0"/>
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Probes a Fanuc CNC: opens a FOCAS session + reads one PMC address. No public
|
||||||
|
/// simulator exists — this command only produces meaningful results against a real
|
||||||
|
/// CNC with Fwlib32.dll present. Against a dev box it surfaces
|
||||||
|
/// <c>BadCommunicationError</c> (DLL missing) which is still a useful signal that
|
||||||
|
/// the CLI wire-up is correct.
|
||||||
|
/// </summary>
|
||||||
|
[Command("probe", Description = "Verify the CNC is reachable + a sample FOCAS read succeeds.")]
|
||||||
|
public sealed class ProbeCommand : FocasCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description =
|
||||||
|
"FOCAS address to probe (default R100 — PMC R-file register 100).")]
|
||||||
|
public string Address { get; init; } = "R100";
|
||||||
|
|
||||||
|
[CommandOption("type", Description = "Data type (default Int16).")]
|
||||||
|
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var probeTag = new FocasTagDefinition(
|
||||||
|
Name: "__probe",
|
||||||
|
DeviceHostAddress: HostAddress,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([probeTag]);
|
||||||
|
|
||||||
|
await using var driver = new FocasDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||||
|
var health = driver.GetHealth();
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync($"CNC: {CncHost}:{CncPort}");
|
||||||
|
await console.Output.WriteLineAsync($"Series: {Series}");
|
||||||
|
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(Address, snapshot[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read one FOCAS address (PMC R/G/F file, parameter, macro, axis register).
|
||||||
|
/// </summary>
|
||||||
|
[Command("read", Description = "Read a single FOCAS address.")]
|
||||||
|
public sealed class ReadCommand : FocasCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description =
|
||||||
|
"FOCAS address. Examples: R100 (PMC R-file word); X0.0 (PMC X-bit); " +
|
||||||
|
"PARAM:1815/0 (parameter 1815, axis 0); MACRO:500 (macro variable 500).",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string Address { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
|
||||||
|
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = SynthesiseTagName(Address, DataType);
|
||||||
|
var tag = new FocasTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: HostAddress,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new FocasDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string SynthesiseTagName(string address, FocasDataType type)
|
||||||
|
=> $"{address}:{type}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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.FOCAS.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Watch a FOCAS address via polled subscription until Ctrl+C. FOCAS has no push
|
||||||
|
/// model; <c>PollGroupEngine</c> handles the tick loop.
|
||||||
|
/// </summary>
|
||||||
|
[Command("subscribe", Description = "Watch a FOCAS address via polled subscription until Ctrl+C.")]
|
||||||
|
public sealed class SubscribeCommand : FocasCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description = "FOCAS address — same format as `read`.", IsRequired = true)]
|
||||||
|
public string Address { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
|
||||||
|
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
|
||||||
|
|
||||||
|
[CommandOption("interval-ms", 'i', Description = "Publishing interval ms (default 1000).")]
|
||||||
|
public int IntervalMs { get; init; } = 1000;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
ConfigureLogging();
|
||||||
|
var ct = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
||||||
|
var tag = new FocasTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: HostAddress,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: false);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
await using var driver = new FocasDriver(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 {Address} @ {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,77 @@
|
|||||||
|
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.FOCAS.Cli.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write one value to a FOCAS address. PMC G/R writes are real — be careful
|
||||||
|
/// which file you hit on a running machine. Parameter writes may require the
|
||||||
|
/// CNC to be in MDI mode + the parameter-write switch enabled.
|
||||||
|
/// </summary>
|
||||||
|
[Command("write", Description = "Write a single FOCAS address.")]
|
||||||
|
public sealed class WriteCommand : FocasCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("address", 'a', Description = "FOCAS address — same format as `read`.", IsRequired = true)]
|
||||||
|
public string Address { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("type", 't', Description =
|
||||||
|
"Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
|
||||||
|
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
|
||||||
|
|
||||||
|
[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();
|
||||||
|
|
||||||
|
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
|
||||||
|
var tag = new FocasTagDefinition(
|
||||||
|
Name: tagName,
|
||||||
|
DeviceHostAddress: HostAddress,
|
||||||
|
Address: Address,
|
||||||
|
DataType: DataType,
|
||||||
|
Writable: true);
|
||||||
|
var options = BuildOptions([tag]);
|
||||||
|
|
||||||
|
var parsed = ParseValue(Value, DataType);
|
||||||
|
|
||||||
|
await using var driver = new FocasDriver(options, DriverInstanceId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await driver.InitializeAsync("{}", ct);
|
||||||
|
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||||
|
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await driver.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static object ParseValue(string raw, FocasDataType type) => type switch
|
||||||
|
{
|
||||||
|
FocasDataType.Bit => ParseBool(raw),
|
||||||
|
FocasDataType.Byte => sbyte.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
FocasDataType.Int16 => short.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
FocasDataType.Int32 => int.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
FocasDataType.Float32 => float.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
FocasDataType.Float64 => double.Parse(raw, CultureInfo.InvariantCulture),
|
||||||
|
FocasDataType.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."),
|
||||||
|
};
|
||||||
|
}
|
||||||
58
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
Normal file
58
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
using CliFx.Attributes;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base for every FOCAS CLI command. Carries the CNC endpoint options
|
||||||
|
/// (host / port / series) + exposes <see cref="BuildOptions"/> so each command
|
||||||
|
/// can synthesise a <see cref="FocasDriverOptions"/> with one device + one tag.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class FocasCommandBase : DriverCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("cnc-host", 'h', Description =
|
||||||
|
"CNC IP address or hostname. FOCAS-over-EIP listens on port 8193 by default.",
|
||||||
|
IsRequired = true)]
|
||||||
|
public string CncHost { get; init; } = default!;
|
||||||
|
|
||||||
|
[CommandOption("cnc-port", 'p', Description = "FOCAS TCP port (default 8193).")]
|
||||||
|
public int CncPort { get; init; } = 8193;
|
||||||
|
|
||||||
|
[CommandOption("series", 's', Description =
|
||||||
|
"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 (default Unknown).")]
|
||||||
|
public FocasCncSeries Series { get; init; } = FocasCncSeries.Unknown;
|
||||||
|
|
||||||
|
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 2000).")]
|
||||||
|
public int TimeoutMs { get; init; } = 2000;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override TimeSpan Timeout
|
||||||
|
{
|
||||||
|
get => TimeSpan.FromMilliseconds(TimeoutMs);
|
||||||
|
init { /* driven by TimeoutMs */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Canonical FOCAS host-address string, shape <c>focas://host:port</c>.</summary>
|
||||||
|
protected string HostAddress => $"focas://{CncHost}:{CncPort}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a <see cref="FocasDriverOptions"/> with the CNC target this base collected
|
||||||
|
/// + the tag list a subclass supplies. Probe disabled; the default
|
||||||
|
/// <see cref="FwlibFocasClientFactory"/> attempts <c>Fwlib32.dll</c> P/Invoke, which
|
||||||
|
/// throws <see cref="DllNotFoundException"/> at first call when the DLL is absent —
|
||||||
|
/// surfaced through the driver as <c>BadCommunicationError</c>.
|
||||||
|
/// </summary>
|
||||||
|
protected FocasDriverOptions BuildOptions(IReadOnlyList<FocasTagDefinition> tags) => new()
|
||||||
|
{
|
||||||
|
Devices = [new FocasDeviceOptions(
|
||||||
|
HostAddress: HostAddress,
|
||||||
|
DeviceName: $"cli-{CncHost}:{CncPort}",
|
||||||
|
Series: Series)],
|
||||||
|
Tags = tags,
|
||||||
|
Timeout = Timeout,
|
||||||
|
Probe = new FocasProbeOptions { Enabled = false },
|
||||||
|
};
|
||||||
|
|
||||||
|
protected string DriverInstanceId => $"focas-cli-{CncHost}:{CncPort}";
|
||||||
|
}
|
||||||
12
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Program.cs
Normal file
12
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Program.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using CliFx;
|
||||||
|
|
||||||
|
return await new CliApplicationBuilder()
|
||||||
|
.AddCommandsFromThisAssembly()
|
||||||
|
.SetExecutableName("otopcua-focas-cli")
|
||||||
|
.SetDescription(
|
||||||
|
"OtOpcUa FOCAS test-client — ad-hoc probe + PMC/param/macro reads/writes + polled " +
|
||||||
|
"subscriptions against Fanuc CNCs via the FOCAS/2 protocol. Requires a real CNC + a " +
|
||||||
|
"licensed Fwlib32.dll on PATH (or next to the executable) — no public simulator " +
|
||||||
|
"exists. Addresses use FocasAddressParser syntax: R100, X0.0, PARAM:1815/0, MACRO:500.")
|
||||||
|
.Build()
|
||||||
|
.RunAsync(args);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<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.FOCAS.Cli</RootNamespace>
|
||||||
|
<AssemblyName>otopcua-focas-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.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -1,35 +1,57 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parsed FOCAS address covering the three addressing spaces a driver touches:
|
/// Parsed FOCAS address covering the four addressing spaces a driver touches:
|
||||||
/// <see cref="FocasAreaKind.Pmc"/> (letter + byte + optional bit — <c>X0.0</c>, <c>R100</c>,
|
/// <see cref="FocasAreaKind.Pmc"/> (letter + byte + optional bit — <c>X0.0</c>, <c>R100</c>,
|
||||||
/// <c>F20.3</c>), <see cref="FocasAreaKind.Parameter"/> (CNC parameter number —
|
/// <c>F20.3</c>), <see cref="FocasAreaKind.Parameter"/> (CNC parameter number —
|
||||||
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), and <see cref="FocasAreaKind.Macro"/>
|
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), <see cref="FocasAreaKind.Macro"/>
|
||||||
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>).
|
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>), and
|
||||||
|
/// <see cref="FocasAreaKind.Diagnostic"/> (CNC diagnostic number, optionally per-axis —
|
||||||
|
/// <c>DIAG:1031</c>, <c>DIAG:280/2</c>) routed through <c>cnc_rddiag</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
|
/// PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
|
||||||
/// relay), <c>D</c> (data table), <c>C</c> (counter), <c>K</c> (keep relay), <c>A</c>
|
/// relay), <c>D</c> (data table), <c>C</c> (counter), <c>K</c> (keep relay), <c>A</c>
|
||||||
/// (message display), <c>E</c> (extended relay), <c>T</c> (timer). Byte numbering is 0-based;
|
/// (message display), <c>E</c> (extended relay), <c>T</c> (timer). Byte numbering is 0-based;
|
||||||
/// bit index when present is 0–7 and uses <c>.N</c> for PMC or <c>/N</c> for parameters.
|
/// bit index when present is 0–7 and uses <c>.N</c> for PMC or <c>/N</c> for parameters.
|
||||||
|
/// Diagnostic addresses reuse the <c>/N</c> form to encode an axis index — <c>BitIndex</c>
|
||||||
|
/// carries the 1-based axis number (0 = whole-CNC diagnostic).
|
||||||
|
/// <para>
|
||||||
|
/// Multi-path / multi-channel CNCs (e.g. lathe + sub-spindle, dual-turret) expose multiple
|
||||||
|
/// "paths"; <see cref="PathId"/> selects which one a given address is read from. Encoded
|
||||||
|
/// as a trailing <c>@N</c> after the address body but before any bit / axis suffix —
|
||||||
|
/// <c>R100@2</c>, <c>PARAM:1815@2</c>, <c>PARAM:1815@2/0</c>, <c>MACRO:500@3</c>,
|
||||||
|
/// <c>DIAG:280@2/1</c>. Defaults to <c>1</c> for back-compat (single-path CNCs).
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed record FocasAddress(
|
public sealed record FocasAddress(
|
||||||
FocasAreaKind Kind,
|
FocasAreaKind Kind,
|
||||||
string? PmcLetter,
|
string? PmcLetter,
|
||||||
int Number,
|
int Number,
|
||||||
int? BitIndex)
|
int? BitIndex,
|
||||||
|
int PathId = 1)
|
||||||
{
|
{
|
||||||
public string Canonical => Kind switch
|
public string Canonical
|
||||||
{
|
{
|
||||||
FocasAreaKind.Pmc => BitIndex is null
|
get
|
||||||
? $"{PmcLetter}{Number}"
|
{
|
||||||
: $"{PmcLetter}{Number}.{BitIndex}",
|
var pathSuffix = PathId == 1 ? string.Empty : $"@{PathId}";
|
||||||
FocasAreaKind.Parameter => BitIndex is null
|
return Kind switch
|
||||||
? $"PARAM:{Number}"
|
{
|
||||||
: $"PARAM:{Number}/{BitIndex}",
|
FocasAreaKind.Pmc => BitIndex is null
|
||||||
FocasAreaKind.Macro => $"MACRO:{Number}",
|
? $"{PmcLetter}{Number}{pathSuffix}"
|
||||||
_ => $"?{Number}",
|
: $"{PmcLetter}{Number}{pathSuffix}.{BitIndex}",
|
||||||
};
|
FocasAreaKind.Parameter => BitIndex is null
|
||||||
|
? $"PARAM:{Number}{pathSuffix}"
|
||||||
|
: $"PARAM:{Number}{pathSuffix}/{BitIndex}",
|
||||||
|
FocasAreaKind.Macro => $"MACRO:{Number}{pathSuffix}",
|
||||||
|
FocasAreaKind.Diagnostic => BitIndex is null or 0
|
||||||
|
? $"DIAG:{Number}{pathSuffix}"
|
||||||
|
: $"DIAG:{Number}{pathSuffix}/{BitIndex}",
|
||||||
|
_ => $"?{Number}",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static FocasAddress? TryParse(string? value)
|
public static FocasAddress? TryParse(string? value)
|
||||||
{
|
{
|
||||||
@@ -42,7 +64,10 @@ public sealed record FocasAddress(
|
|||||||
if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase))
|
if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase))
|
||||||
return ParseScoped(src["MACRO:".Length..], FocasAreaKind.Macro, bitSeparator: null);
|
return ParseScoped(src["MACRO:".Length..], FocasAreaKind.Macro, bitSeparator: null);
|
||||||
|
|
||||||
// PMC path: letter + digits + optional .bit
|
if (src.StartsWith("DIAG:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return ParseScoped(src["DIAG:".Length..], FocasAreaKind.Diagnostic, bitSeparator: '/');
|
||||||
|
|
||||||
|
// PMC path: letter + digits + optional @path + optional .bit
|
||||||
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
|
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
|
||||||
var letter = src[0..1].ToUpperInvariant();
|
var letter = src[0..1].ToUpperInvariant();
|
||||||
if (!IsValidPmcLetter(letter)) return null;
|
if (!IsValidPmcLetter(letter)) return null;
|
||||||
@@ -57,8 +82,15 @@ public sealed record FocasAddress(
|
|||||||
bit = bitValue;
|
bit = bitValue;
|
||||||
remainder = remainder[..dotIdx];
|
remainder = remainder[..dotIdx];
|
||||||
}
|
}
|
||||||
|
var pmcPath = 1;
|
||||||
|
var atIdx = remainder.IndexOf('@');
|
||||||
|
if (atIdx >= 0)
|
||||||
|
{
|
||||||
|
if (!TryParsePathId(remainder[(atIdx + 1)..], out pmcPath)) return null;
|
||||||
|
remainder = remainder[..atIdx];
|
||||||
|
}
|
||||||
if (!int.TryParse(remainder, out var number) || number < 0) return null;
|
if (!int.TryParse(remainder, out var number) || number < 0) return null;
|
||||||
return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit);
|
return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit, pmcPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static FocasAddress? ParseScoped(string body, FocasAreaKind kind, char? bitSeparator)
|
private static FocasAddress? ParseScoped(string body, FocasAreaKind kind, char? bitSeparator)
|
||||||
@@ -75,8 +107,30 @@ public sealed record FocasAddress(
|
|||||||
body = body[..slashIdx];
|
body = body[..slashIdx];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Path suffix (@N) sits between the body number and any bit/axis (which has already
|
||||||
|
// been peeled off above): PARAM:1815@2/0 → body="1815@2", bit=0.
|
||||||
|
var path = 1;
|
||||||
|
var atIdx = body.IndexOf('@');
|
||||||
|
if (atIdx >= 0)
|
||||||
|
{
|
||||||
|
if (!TryParsePathId(body[(atIdx + 1)..], out path)) return null;
|
||||||
|
body = body[..atIdx];
|
||||||
|
}
|
||||||
if (!int.TryParse(body, out var number) || number < 0) return null;
|
if (!int.TryParse(body, out var number) || number < 0) return null;
|
||||||
return new FocasAddress(kind, PmcLetter: null, number, bit);
|
return new FocasAddress(kind, PmcLetter: null, number, bit, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParsePathId(string text, out int pathId)
|
||||||
|
{
|
||||||
|
// Path 0 is reserved (FOCAS path numbering is 1-based); upper-bound is the FWLIB
|
||||||
|
// ceiling — Fanuc spec lists 10 paths max even on the largest 30i-B configurations.
|
||||||
|
if (int.TryParse(text, out var v) && v is >= 1 and <= 10)
|
||||||
|
{
|
||||||
|
pathId = v;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
pathId = 0;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsValidPmcLetter(string letter) => letter switch
|
private static bool IsValidPmcLetter(string letter) => letter switch
|
||||||
@@ -92,4 +146,12 @@ public enum FocasAreaKind
|
|||||||
Pmc,
|
Pmc,
|
||||||
Parameter,
|
Parameter,
|
||||||
Macro,
|
Macro,
|
||||||
|
/// <summary>
|
||||||
|
/// CNC diagnostic number routed through <c>cnc_rddiag</c>. <c>DIAG:nnn</c> is a
|
||||||
|
/// whole-CNC diagnostic (axis = 0); <c>DIAG:nnn/axis</c> is per-axis (axis is the
|
||||||
|
/// 1-based FANUC axis index). Like parameters, diagnostics span Int / Float /
|
||||||
|
/// Bit shapes — the driver picks the wire shape based on the configured tag's
|
||||||
|
/// <see cref="FocasDataType"/>.
|
||||||
|
/// </summary>
|
||||||
|
Diagnostic,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ public static class FocasCapabilityMatrix
|
|||||||
|
|
||||||
return address.Kind switch
|
return address.Kind switch
|
||||||
{
|
{
|
||||||
FocasAreaKind.Macro => ValidateMacro(series, address.Number),
|
FocasAreaKind.Macro => ValidateMacro(series, address.Number),
|
||||||
FocasAreaKind.Parameter => ValidateParameter(series, address.Number),
|
FocasAreaKind.Parameter => ValidateParameter(series, address.Number),
|
||||||
FocasAreaKind.Pmc => ValidatePmc(series, address.PmcLetter, address.Number),
|
FocasAreaKind.Pmc => ValidatePmc(series, address.PmcLetter, address.Number),
|
||||||
|
FocasAreaKind.Diagnostic => ValidateDiagnostic(series, address.Number),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -73,11 +74,35 @@ public static class FocasCapabilityMatrix
|
|||||||
_ => (0, int.MaxValue),
|
_ => (0, int.MaxValue),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>PMC letters accepted per series. Legacy controllers omit F/M/C
|
/// <summary>
|
||||||
/// signal groups that 30i-family ladder programs use.</summary>
|
/// CNC diagnostic number range accepted by a series; from <c>cnc_rddiag</c>
|
||||||
|
/// (and <c>cnc_rddiagdgn</c> for axis-scoped reads). Returning <c>null</c>
|
||||||
|
/// means the series doesn't support <c>cnc_rddiag</c> at all — the driver
|
||||||
|
/// rejects every <c>DIAG:</c> address on that series. Conservative ceilings
|
||||||
|
/// per the FOCAS Developer Kit: legacy 16i-family caps at 499; modern 0i-F
|
||||||
|
/// family at 999; 30i / 31i / 32i extend to 1023. Power Motion i has a
|
||||||
|
/// narrow diagnostic surface (0..255).
|
||||||
|
/// </summary>
|
||||||
|
internal static (int min, int max)? DiagnosticRange(FocasCncSeries series) => series switch
|
||||||
|
{
|
||||||
|
FocasCncSeries.Sixteen_i => (0, 499),
|
||||||
|
FocasCncSeries.Zero_i_D => (0, 499),
|
||||||
|
FocasCncSeries.Zero_i_F or
|
||||||
|
FocasCncSeries.Zero_i_MF or
|
||||||
|
FocasCncSeries.Zero_i_TF => (0, 999),
|
||||||
|
FocasCncSeries.Thirty_i or
|
||||||
|
FocasCncSeries.ThirtyOne_i or
|
||||||
|
FocasCncSeries.ThirtyTwo_i => (0, 1023),
|
||||||
|
FocasCncSeries.PowerMotion_i => (0, 255),
|
||||||
|
_ => (0, int.MaxValue),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>PMC letters accepted per series. Legacy 16i ladders use X/Y/F/G
|
||||||
|
/// for handshakes plus R/D for retained/data; M/C/E/A/K/T are the 0i-F /
|
||||||
|
/// 30i-family extensions.</summary>
|
||||||
internal static IReadOnlySet<string> PmcLetters(FocasCncSeries series) => series switch
|
internal static IReadOnlySet<string> PmcLetters(FocasCncSeries series) => series switch
|
||||||
{
|
{
|
||||||
FocasCncSeries.Sixteen_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D" },
|
FocasCncSeries.Sixteen_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "F", "G", "R", "D" },
|
||||||
FocasCncSeries.Zero_i_D => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D", "E", "A" },
|
FocasCncSeries.Zero_i_D => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D", "E", "A" },
|
||||||
FocasCncSeries.Zero_i_F or
|
FocasCncSeries.Zero_i_F or
|
||||||
FocasCncSeries.Zero_i_MF or
|
FocasCncSeries.Zero_i_MF or
|
||||||
@@ -106,6 +131,27 @@ public static class FocasCapabilityMatrix
|
|||||||
_ => int.MaxValue,
|
_ => int.MaxValue,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the FOCAS driver should expose the per-device <c>Tooling/</c>
|
||||||
|
/// fixed-tree subfolder for a given <paramref name="series"/>. Backed by
|
||||||
|
/// <c>cnc_rdtnum</c>, which is documented for every modern Fanuc series
|
||||||
|
/// (0i / 16i / 30i families) — defaulting to <c>true</c>. The capability
|
||||||
|
/// hook exists so a future controller without <c>cnc_rdtnum</c> can opt
|
||||||
|
/// out without touching the driver. <see cref="FocasCncSeries.Unknown"/>
|
||||||
|
/// stays permissive (matches the modal / override fixed-tree precedent in
|
||||||
|
/// issue #259). Issue #260.
|
||||||
|
/// </summary>
|
||||||
|
public static bool SupportsTooling(FocasCncSeries series) => true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the FOCAS driver should expose the per-device <c>Offsets/</c>
|
||||||
|
/// fixed-tree subfolder for a given <paramref name="series"/>. Backed by
|
||||||
|
/// <c>cnc_rdzofs(n=1..6)</c> for the standard G54..G59 surfaces; extended
|
||||||
|
/// G54.1 P1..P48 surfaces are deferred to a follow-up. Same permissive
|
||||||
|
/// policy as <see cref="SupportsTooling"/>. Issue #260.
|
||||||
|
/// </summary>
|
||||||
|
public static bool SupportsWorkOffsets(FocasCncSeries series) => true;
|
||||||
|
|
||||||
private static string? ValidateMacro(FocasCncSeries series, int number)
|
private static string? ValidateMacro(FocasCncSeries series, int number)
|
||||||
{
|
{
|
||||||
var (min, max) = MacroRange(series);
|
var (min, max) = MacroRange(series);
|
||||||
@@ -122,6 +168,16 @@ public static class FocasCapabilityMatrix
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? ValidateDiagnostic(FocasCncSeries series, int number)
|
||||||
|
{
|
||||||
|
if (DiagnosticRange(series) is not { } range)
|
||||||
|
return $"Diagnostic addresses are not supported on {series} (no documented cnc_rddiag range).";
|
||||||
|
var (min, max) = range;
|
||||||
|
return (number < min || number > max)
|
||||||
|
? $"Diagnostic #{number} is outside the documented range [{min}, {max}] for {series}."
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
private static string? ValidatePmc(FocasCncSeries series, string? letter, int number)
|
private static string? ValidatePmc(FocasCncSeries series, string? letter, int number)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(letter)) return "PMC address is missing its letter prefix.";
|
if (string.IsNullOrEmpty(letter)) return "PMC address is missing its letter prefix.";
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user