Compare commits
122 Commits
phase-7-fu
...
auto/drive
| Author | SHA1 | Date | |
|---|---|---|---|
| 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/
|
||||
|
||||
.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.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.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"/>
|
||||
</Folder>
|
||||
<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.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.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.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"/>
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
- `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.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.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` 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:
|
||||
|
||||
|
||||
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)`
|
||||
- `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`
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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 |
|
||||
| [IncrementalSync.md](IncrementalSync.md) | Address-space rebuild on redeploy + `sp_ComputeGenerationDiff` |
|
||||
| [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
|
||||
|
||||
| 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-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 |
|
||||
|-----|--------|
|
||||
| [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 |
|
||||
| [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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
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)
|
||||
|
||||
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`.
|
||||
|
||||
### 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 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
|
||||
|
||||
```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
|
||||
|
||||
1. **Fix ab_server PCCC coverage upstream** — the scaffold lands the
|
||||
Docker infrastructure; the wire-level round-trip gap is in ab_server
|
||||
itself. Filing a patch to `libplctag/libplctag` to expand PCCC
|
||||
server-side opcode coverage would make the scaffolded smoke tests
|
||||
pass without a golden-box tier.
|
||||
1. **Expand ab_server PCCC coverage** — the smoke suite passes today
|
||||
for N (Int16), F (Float32), and L (Int32) files across SLC500 /
|
||||
MicroLogix / PLC-5 modes with the `/1,0` cip-path workaround in
|
||||
place. Known residual gap: bit-file writes (`B3:0/5`) surface
|
||||
`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
|
||||
for SLC/MicroLogix/PLC-5. Would close UDT-equivalent (integer-file
|
||||
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`
|
||||
— TCP probe + skip attributes + env-var parsing
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs`
|
||||
— three wire-level smoke tests (currently blocked by ab_server PCCC gap)
|
||||
— 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`
|
||||
— compose profiles reusing AB CIP Dockerfile
|
||||
- `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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -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
|
||||
- [Modbus](Modbus-Test-Fixture.md) — Dockerized `pymodbus` + per-family JSON profiles (4 compose profiles); best-covered driver, gaps are error-path-shaped
|
||||
- [Siemens S7](S7-Test-Fixture.md) — Dockerized `python-snap7` server; DB/MB read + write round-trip verified end-to-end on `:1102`
|
||||
- [AB Legacy](AbLegacy-Test-Fixture.md) — Docker scaffold via `ab_server` PCCC mode (task #224); wire-level round-trip currently blocked by ab_server's PCCC coverage gap, docs call out RSEmulate 500 + lab-rig resolution paths
|
||||
- [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
|
||||
- [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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# 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)
|
||||
|
||||
|
||||
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"/> —
|
||||
/// stable logical id the ScriptedAlarmEngine addresses by. Null otherwise.
|
||||
/// </param>
|
||||
/// <param name="Description">
|
||||
/// Human-readable description for this attribute. When non-null + non-empty the generic
|
||||
/// node-manager surfaces the value as the OPC UA <c>Description</c> attribute on the
|
||||
/// Variable node so SCADA / engineering clients see the field comment from the source
|
||||
/// project (Studio 5000 tag descriptions, Galaxy attribute help text, etc.). Defaults to
|
||||
/// null so drivers that don't carry descriptions are unaffected.
|
||||
/// </param>
|
||||
public sealed record DriverAttributeInfo(
|
||||
string FullName,
|
||||
DriverDataType DriverDataType,
|
||||
@@ -56,7 +63,8 @@ public sealed record DriverAttributeInfo(
|
||||
bool WriteIdempotent = false,
|
||||
NodeSourceKind Source = NodeSourceKind.Driver,
|
||||
string? VirtualTagId = null,
|
||||
string? ScriptedAlarmId = null);
|
||||
string? ScriptedAlarmId = null,
|
||||
string? Description = null);
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
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,
|
||||
|
||||
/// <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>
|
||||
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="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="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(
|
||||
DriverState State,
|
||||
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>
|
||||
public enum DriverState
|
||||
|
||||
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,
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
@@ -30,7 +52,7 @@ public interface ISubscribable
|
||||
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
|
||||
{
|
||||
/// <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>
|
||||
/// <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="Snapshot">New value + quality + timestamps.</param>
|
||||
public sealed record DataChangeEventArgs(
|
||||
ISubscriptionHandle SubscriptionHandle,
|
||||
string FullReference,
|
||||
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.SInt or AbCipDataType.Int or AbCipDataType.DInt => 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.LReal => DriverDataType.Float64,
|
||||
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
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||
|
||||
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>
|
||||
/// </remarks>
|
||||
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 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, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly AbCipAlarmProjection _alarmProjection;
|
||||
private readonly SemaphoreSlim _discoverySemaphore = new(1, 1);
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
@@ -121,7 +123,42 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily);
|
||||
_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;
|
||||
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}",
|
||||
DataType: member.DataType,
|
||||
Writable: member.Writable,
|
||||
WriteIdempotent: member.WriteIdempotent);
|
||||
WriteIdempotent: member.WriteIdempotent,
|
||||
StringLength: member.StringLength);
|
||||
_tagsByName[memberTag.Name] = memberTag;
|
||||
}
|
||||
}
|
||||
@@ -160,6 +198,84 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
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)
|
||||
{
|
||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -357,6 +473,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
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
|
||||
{
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
|
||||
@@ -372,8 +499,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
return;
|
||||
}
|
||||
|
||||
var tagPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
var bitIndex = tagPath?.BitIndex;
|
||||
var bitIndex = parsedPath?.BitIndex;
|
||||
var value = runtime.DecodeValue(def.DataType, bitIndex);
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
@@ -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>
|
||||
/// 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
|
||||
@@ -451,100 +660,184 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// ---- IWritable ----
|
||||
|
||||
/// <summary>
|
||||
/// Write each request in order. Writes are NOT auto-retried by the driver — per plan
|
||||
/// decisions #44, #45, #143 the caller opts in via <see cref="AbCipTagDefinition.WriteIdempotent"/>
|
||||
/// and the resilience pipeline (layered above the driver) decides whether to replay.
|
||||
/// Non-writable configurations surface as <c>BadNotWritable</c>; type-conversion failures
|
||||
/// as <c>BadTypeMismatch</c>; transport errors as <c>BadCommunicationError</c>.
|
||||
/// Write each request in the batch. Writes are NOT auto-retried by the driver — per
|
||||
/// plan decisions #44, #45, #143 the caller opts in via
|
||||
/// <see cref="AbCipTagDefinition.WriteIdempotent"/> and the resilience pipeline (layered
|
||||
/// above the driver) decides whether to replay. Non-writable configurations surface as
|
||||
/// <c>BadNotWritable</c>; type-conversion failures as <c>BadTypeMismatch</c>; transport
|
||||
/// errors as <c>BadCommunicationError</c>.
|
||||
/// </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(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(writes);
|
||||
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 (!_tagsByName.TryGetValue(w.FullReference, out var def))
|
||||
if (!_devices.TryGetValue(plan.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
foreach (var e in plan.Packable) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
foreach (var e in plan.BitRmw) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
// Bit-RMW writes always serialise per-parent — never packed.
|
||||
foreach (var entry in plan.BitRmw)
|
||||
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
|
||||
// runtime. Dispatching here keeps the normal EncodeValue path clean; the
|
||||
// per-parent lock prevents two concurrent bit writes to the same DINT from
|
||||
// losing one another's update.
|
||||
if (def.DataType == AbCipDataType.Bool && parsedPath?.BitIndex is int bit)
|
||||
if (plan.Packable.Count == 0) continue;
|
||||
|
||||
if (plan.Profile.SupportsRequestPacking && plan.Packable.Count > 1)
|
||||
{
|
||||
// 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(
|
||||
await WriteBitInDIntAsync(device, parsedPath, bit, w.Value, cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
if (results[i].StatusCode == AbCipStatusMapper.Good)
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
continue;
|
||||
var entry = plan.Packable[i];
|
||||
tasks[i] = ExecutePackableWriteAsync(device, entry, cancellationToken);
|
||||
}
|
||||
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
|
||||
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);
|
||||
var outcomes = await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||
foreach (var (idx, code) in outcomes)
|
||||
results[idx] = new WriteResult(code);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
else
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||
}
|
||||
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);
|
||||
// Single-write groups + Micro800 (SupportsRequestPacking=false) — sequential.
|
||||
foreach (var entry in plan.Packable)
|
||||
{
|
||||
var code = await ExecutePackableWriteAsync(device, entry, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
results[entry.OriginalIndex] = new WriteResult(code.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
/// Read-modify-write one bit within a DINT parent. Creates / reuses a parallel
|
||||
/// 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,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: parsed.ToLibplctagName(),
|
||||
Timeout: _options.Timeout));
|
||||
Timeout: _options.Timeout,
|
||||
StringMaxCapacity: def.DataType == AbCipDataType.String ? def.StringLength : null));
|
||||
try
|
||||
{
|
||||
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)
|
||||
{
|
||||
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");
|
||||
|
||||
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 })
|
||||
{
|
||||
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)
|
||||
{
|
||||
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}";
|
||||
udtFolder.Variable(member.Name, member.Name, new DriverAttributeInfo(
|
||||
parentFolder.Variable(member.Name, member.Name, new DriverAttributeInfo(
|
||||
FullName: memberFullName,
|
||||
DriverDataType: member.DataType.ToDriverDataType(),
|
||||
IsArray: false,
|
||||
@@ -707,7 +1059,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: member.WriteIdempotent));
|
||||
WriteIdempotent: member.WriteIdempotent,
|
||||
Description: member.Description));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -767,7 +1120,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: tag.WriteIdempotent);
|
||||
WriteIdempotent: tag.WriteIdempotent,
|
||||
Description: tag.Description);
|
||||
|
||||
/// <summary>Count of registered devices — exposed for diagnostics + tests.</summary>
|
||||
internal int DeviceCount => _devices.Count;
|
||||
@@ -781,6 +1135,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
_discoverySemaphore.Dispose();
|
||||
}
|
||||
|
||||
/// <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>
|
||||
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>
|
||||
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
|
||||
/// rejected by the PLC anyway. Surfaces the intent explicitly instead of relying on the
|
||||
/// 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(
|
||||
string Name,
|
||||
string DeviceHostAddress,
|
||||
@@ -100,7 +142,9 @@ public sealed record AbCipTagDefinition(
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false,
|
||||
IReadOnlyList<AbCipStructureMember>? Members = null,
|
||||
bool SafetyTag = false);
|
||||
bool SafetyTag = false,
|
||||
int? StringLength = null,
|
||||
string? Description = null);
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// (class 0x6C) that would auto-discover member layouts lands as a follow-up PR.
|
||||
/// </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(
|
||||
string Name,
|
||||
AbCipDataType DataType,
|
||||
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>
|
||||
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(
|
||||
string? ProgramScope,
|
||||
IReadOnlyList<AbCipTagPathSegment> Segments,
|
||||
int? BitIndex)
|
||||
int? BitIndex,
|
||||
AbCipTagPathSlice? Slice = null)
|
||||
{
|
||||
/// <summary>Rebuild the canonical Logix tag string.</summary>
|
||||
public string ToLibplctagName()
|
||||
@@ -37,10 +38,39 @@ public sealed record AbCipTagPath(
|
||||
if (seg.Subscripts.Count > 0)
|
||||
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);
|
||||
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>
|
||||
/// 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
|
||||
@@ -91,8 +121,10 @@ public sealed record AbCipTagPath(
|
||||
}
|
||||
|
||||
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('[');
|
||||
if (bracketIdx < 0)
|
||||
{
|
||||
@@ -104,6 +136,25 @@ public sealed record AbCipTagPath(
|
||||
var name = part[..bracketIdx];
|
||||
if (!IsValidIdent(name)) return null;
|
||||
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>();
|
||||
foreach (var tok in inner.Split(','))
|
||||
{
|
||||
@@ -115,7 +166,7 @@ public sealed record AbCipTagPath(
|
||||
}
|
||||
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)
|
||||
@@ -130,3 +181,15 @@ public sealed record AbCipTagPath(
|
||||
|
||||
/// <summary>One path segment: a member name plus any numeric subscripts.</summary>
|
||||
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="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="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(
|
||||
string Gateway,
|
||||
int Port,
|
||||
string CipPath,
|
||||
string LibplctagPlcAttribute,
|
||||
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,
|
||||
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);
|
||||
@@ -50,7 +61,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
AbCipDataType.Real => _tag.GetFloat32(offset),
|
||||
AbCipDataType.LReal => _tag.GetFloat64(offset),
|
||||
AbCipDataType.String => _tag.GetString(offset),
|
||||
AbCipDataType.Dt => _tag.GetInt32(offset),
|
||||
AbCipDataType.Dt => _tag.GetInt64(offset),
|
||||
AbCipDataType.Structure => null,
|
||||
_ => null,
|
||||
};
|
||||
@@ -105,7 +116,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
_tag.SetString(0, Convert.ToString(value) ?? string.Empty);
|
||||
break;
|
||||
case AbCipDataType.Dt:
|
||||
_tag.SetInt32(0, Convert.ToInt32(value));
|
||||
_tag.SetInt64(0, Convert.ToInt64(value));
|
||||
break;
|
||||
case AbCipDataType.Structure:
|
||||
throw new NotSupportedException("Whole-UDT writes land in PR 6.");
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
@@ -30,35 +32,87 @@ public sealed record AbLegacyAddress(
|
||||
int? FileNumber,
|
||||
int WordNumber,
|
||||
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()
|
||||
{
|
||||
var file = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
|
||||
var wordPart = $"{file}:{WordNumber}";
|
||||
// Re-emit using bracket form when indirect. libplctag's PCCC text decoder does not
|
||||
// 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 (BitIndex is not null) wordPart += $"/{BitIndex}";
|
||||
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;
|
||||
var src = value.Trim();
|
||||
|
||||
// BitIndex: trailing /N
|
||||
int? bitIndex = null;
|
||||
var slashIdx = src.IndexOf('/');
|
||||
if (slashIdx >= 0)
|
||||
var profile = family is null ? null : AbLegacyPlcFamilyProfile.ForFamily(family.Value);
|
||||
|
||||
// BitIndex: trailing /N. Defer numeric parsing until the file letter is known — PLC-5
|
||||
// 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;
|
||||
bitIndex = bit;
|
||||
bitText = src[(slashIdx + 1)..];
|
||||
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.)
|
||||
// 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;
|
||||
var dotIdx = src.LastIndexOf('.');
|
||||
var dotIdx = LastIndexOfTopLevel(src, '.');
|
||||
if (dotIdx >= 0)
|
||||
{
|
||||
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;
|
||||
var filePart = src[..colonIdx];
|
||||
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;
|
||||
var letterEnd = 1;
|
||||
while (letterEnd < filePart.Length && char.IsLetter(filePart[letterEnd])) letterEnd++;
|
||||
|
||||
var letter = filePart[..letterEnd].ToUpperInvariant();
|
||||
int? fileNumber = null;
|
||||
AbLegacyAddress? indirectFile = null;
|
||||
if (letterEnd < filePart.Length)
|
||||
{
|
||||
if (!int.TryParse(filePart[letterEnd..], out var fn) || fn < 0) return null;
|
||||
fileNumber = fn;
|
||||
var fileTail = filePart[letterEnd..];
|
||||
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.
|
||||
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
|
||||
@@ -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,
|
||||
_ => 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,
|
||||
/// <summary>Control sub-element — caller addresses <c>.LEN</c>, <c>.POS</c>, <c>.EN</c>, <c>.DN</c>, <c>.ER</c>.</summary>
|
||||
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>
|
||||
@@ -40,6 +130,196 @@ public static class AbLegacyDataTypeExtensions
|
||||
AbLegacyDataType.String => DriverDataType.String,
|
||||
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
||||
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,
|
||||
};
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
||||
var value = runtime.DecodeValue(def.DataType, parsed?.BitIndex);
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
||||
// 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);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
@@ -186,7 +191,16 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
|
||||
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
|
||||
// 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);
|
||||
}
|
||||
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)
|
||||
{
|
||||
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));
|
||||
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(
|
||||
FullName: tag.Name,
|
||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
||||
DriverDataType: effectiveType,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: tag.Writable
|
||||
SecurityClass: tag.Writable && !plcSetBit
|
||||
? SecurityClassification.Operate
|
||||
: SecurityClassification.ViewOnly,
|
||||
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;
|
||||
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address)
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily)
|
||||
?? throw new InvalidOperationException(
|
||||
$"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(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
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>
|
||||
/// 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>
|
||||
public sealed record AbLegacyTagDefinition(
|
||||
string Name,
|
||||
|
||||
@@ -12,6 +12,15 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
{
|
||||
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)
|
||||
{
|
||||
_tag = new Tag
|
||||
@@ -40,8 +49,25 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
AbLegacyDataType.Long => _tag.GetInt32(0),
|
||||
AbLegacyDataType.Float => _tag.GetFloat32(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
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -70,13 +96,32 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
_tag.SetFloat32(0, Convert.ToSingle(value));
|
||||
break;
|
||||
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;
|
||||
case AbLegacyDataType.TimerElement:
|
||||
case AbLegacyDataType.CounterElement:
|
||||
case AbLegacyDataType.ControlElement:
|
||||
_tag.SetInt32(0, Convert.ToInt32(value));
|
||||
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:
|
||||
throw new NotSupportedException($"AbLegacyDataType {type} not writable.");
|
||||
}
|
||||
|
||||
@@ -9,7 +9,13 @@ public sealed record AbLegacyPlcFamilyProfile(
|
||||
string DefaultCipPath,
|
||||
int MaxTagBytes,
|
||||
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
|
||||
{
|
||||
@@ -25,21 +31,39 @@ public sealed record AbLegacyPlcFamilyProfile(
|
||||
DefaultCipPath: "1,0",
|
||||
MaxTagBytes: 240, // SLC 5/05 PCCC max packet data
|
||||
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(
|
||||
LibplctagPlcAttribute: "micrologix",
|
||||
DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path
|
||||
MaxTagBytes: 232,
|
||||
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(
|
||||
LibplctagPlcAttribute: "plc5",
|
||||
DefaultCipPath: "1,0",
|
||||
MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower
|
||||
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>
|
||||
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
|
||||
@@ -51,7 +75,15 @@ public sealed record AbLegacyPlcFamilyProfile(
|
||||
DefaultCipPath: "1,0",
|
||||
MaxTagBytes: 240,
|
||||
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>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
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>
|
||||
@@ -106,6 +106,27 @@ public static class FocasCapabilityMatrix
|
||||
_ => 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)
|
||||
{
|
||||
var (min, max) = MacroRange(series);
|
||||
|
||||
@@ -24,8 +24,96 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, FocasTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, (string Host, string Field)> _statusNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, (string Host, string Field)> _productionNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, (string Host, string Field)> _modalNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, (string Host, string Field)> _overrideNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string> _toolingNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, (string Host, string Slot, string Axis)> _offsetNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string> _messagesNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string> _currentBlockNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, (string Host, string Field)> _diagnosticsNodesByName =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>
|
||||
/// Names of the 9 fixed-tree <c>Status/</c> child nodes per device, mirroring the 9
|
||||
/// fields of Fanuc's <c>cnc_rdcncstat</c> ODBST struct (issue #257). Order matters for
|
||||
/// deterministic discovery output.
|
||||
/// </summary>
|
||||
private static readonly string[] StatusFieldNames =
|
||||
[
|
||||
"Tmmode", "Aut", "Run", "Motion", "Mstb", "EmergencyStop", "Alarm", "Edit", "Dummy",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Names of the 4 fixed-tree <c>Production/</c> child nodes per device — parts
|
||||
/// produced/required/total via <c>cnc_rdparam(6711/6712/6713)</c> + cycle-time
|
||||
/// seconds (issue #258). Order matters for deterministic discovery output.
|
||||
/// </summary>
|
||||
private static readonly string[] ProductionFieldNames =
|
||||
[
|
||||
"PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Names of the active modal aux-code child nodes per device — M/S/T/B from
|
||||
/// <c>cnc_modal(type=100..103)</c> (issue #259). G-group decoding is a deferred
|
||||
/// follow-up because the FWLIB <c>ODBMDL</c> union varies per series + group.
|
||||
/// </summary>
|
||||
private static readonly string[] ModalFieldNames = ["MCode", "SCode", "TCode", "BCode"];
|
||||
|
||||
/// <summary>
|
||||
/// Names of the four operator-override child nodes per device — Feed / Rapid /
|
||||
/// Spindle / Jog from <c>cnc_rdparam</c> with MTB-specific parameter numbers
|
||||
/// (issue #259). A device whose <c>FocasOverrideParameters</c> entry is null for a
|
||||
/// given field has the matching node omitted from the address space.
|
||||
/// </summary>
|
||||
private static readonly string[] OverrideFieldNames = ["Feed", "Rapid", "Spindle", "Jog"];
|
||||
|
||||
/// <summary>
|
||||
/// Names of the standard work-coordinate offset slots surfaced under
|
||||
/// <c>Offsets/</c> per device — G54..G59 from <c>cnc_rdzofs(n=1..6)</c>
|
||||
/// (issue #260). Extended G54.1 P1..P48 surfaces are deferred to a follow-up
|
||||
/// PR because <c>cnc_rdzofsr</c> uses a different range surface.
|
||||
/// </summary>
|
||||
private static readonly string[] WorkOffsetSlotNames =
|
||||
[
|
||||
"G54", "G55", "G56", "G57", "G58", "G59",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Axis columns surfaced under each <c>Offsets/{slot}/</c> folder. Per the F1-d
|
||||
/// plan a fixed 3-axis (X/Y/Z) view is used; lathes / mills with extra rotational
|
||||
/// offsets get those columns exposed as 0.0 until a follow-up extends the surface.
|
||||
/// </summary>
|
||||
private static readonly string[] WorkOffsetAxisNames = ["X", "Y", "Z"];
|
||||
|
||||
/// <summary>
|
||||
/// Names of the five fixed-tree <c>Diagnostics/</c> child nodes per device — runtime
|
||||
/// counters surfaced for operator visibility (issue #262). Order matters for
|
||||
/// deterministic discovery output.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>ReadCount</c> (Int64) — successful probe ticks since init</item>
|
||||
/// <item><c>ReadFailureCount</c> (Int64) — failed probe ticks since init</item>
|
||||
/// <item><c>LastErrorMessage</c> (String) — text of the last probe / read failure</item>
|
||||
/// <item><c>LastSuccessfulRead</c> (DateTime) — UTC timestamp of the last good probe tick</item>
|
||||
/// <item><c>ReconnectCount</c> (Int64) — wire reconnects observed since init</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private static readonly string[] DiagnosticsFieldNames =
|
||||
[
|
||||
"ReadCount", "ReadFailureCount", "LastErrorMessage", "LastSuccessfulRead", "ReconnectCount",
|
||||
];
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
@@ -76,6 +164,67 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_tagsByName[tag.Name] = tag;
|
||||
}
|
||||
|
||||
// Per-device fixed-tree Status nodes — issue #257. Names are deterministic so
|
||||
// ReadAsync can dispatch on the synthetic full-reference without extra metadata.
|
||||
foreach (var device in _devices.Values)
|
||||
{
|
||||
foreach (var field in StatusFieldNames)
|
||||
_statusNodesByName[StatusReferenceFor(device.Options.HostAddress, field)] =
|
||||
(device.Options.HostAddress, field);
|
||||
foreach (var field in ProductionFieldNames)
|
||||
_productionNodesByName[ProductionReferenceFor(device.Options.HostAddress, field)] =
|
||||
(device.Options.HostAddress, field);
|
||||
foreach (var field in ModalFieldNames)
|
||||
_modalNodesByName[ModalReferenceFor(device.Options.HostAddress, field)] =
|
||||
(device.Options.HostAddress, field);
|
||||
if (device.Options.OverrideParameters is { } op)
|
||||
{
|
||||
foreach (var field in OverrideFieldNames)
|
||||
{
|
||||
if (OverrideParamFor(op, field) is null) continue;
|
||||
_overrideNodesByName[OverrideReferenceFor(device.Options.HostAddress, field)] =
|
||||
(device.Options.HostAddress, field);
|
||||
}
|
||||
}
|
||||
|
||||
// Tooling/CurrentTool — single Int16 node per device (issue #260). Tool
|
||||
// life + active offset index are deferred per the F1-d plan; they need
|
||||
// ODBTLIFE* unions whose shape varies per series.
|
||||
if (FocasCapabilityMatrix.SupportsTooling(device.Options.Series))
|
||||
{
|
||||
_toolingNodesByName[ToolingReferenceFor(device.Options.HostAddress, "CurrentTool")] =
|
||||
device.Options.HostAddress;
|
||||
}
|
||||
|
||||
// Offsets/{G54..G59}/{X|Y|Z} — fixed 3-axis view of the standard work-
|
||||
// coordinate offsets (issue #260). Capability matrix gates by series so
|
||||
// legacy CNCs that don't support cnc_rdzofs don't produce the subtree.
|
||||
if (FocasCapabilityMatrix.SupportsWorkOffsets(device.Options.Series))
|
||||
{
|
||||
foreach (var slot in WorkOffsetSlotNames)
|
||||
foreach (var axis in WorkOffsetAxisNames)
|
||||
{
|
||||
_offsetNodesByName[OffsetReferenceFor(device.Options.HostAddress, slot, axis)] =
|
||||
(device.Options.HostAddress, slot, axis);
|
||||
}
|
||||
}
|
||||
|
||||
// Messages/External/Latest + Program/CurrentBlock — single String nodes per
|
||||
// device backed by cnc_rdopmsg3 + cnc_rdactpt caches refreshed on the probe
|
||||
// tick (issue #261). Permissive across series (no capability gate yet).
|
||||
_messagesNodesByName[MessagesLatestReferenceFor(device.Options.HostAddress)] =
|
||||
device.Options.HostAddress;
|
||||
_currentBlockNodesByName[CurrentBlockReferenceFor(device.Options.HostAddress)] =
|
||||
device.Options.HostAddress;
|
||||
|
||||
// Diagnostics/{ReadCount, ReadFailureCount, LastErrorMessage,
|
||||
// LastSuccessfulRead, ReconnectCount} — runtime counters surfaced for
|
||||
// operator visibility (issue #262). Permissive across all CNC series.
|
||||
foreach (var field in DiagnosticsFieldNames)
|
||||
_diagnosticsNodesByName[DiagnosticsReferenceFor(device.Options.HostAddress, field)] =
|
||||
(device.Options.HostAddress, field);
|
||||
}
|
||||
|
||||
if (_options.Probe.Enabled)
|
||||
{
|
||||
foreach (var state in _devices.Values)
|
||||
@@ -113,6 +262,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
_devices.Clear();
|
||||
_tagsByName.Clear();
|
||||
_statusNodesByName.Clear();
|
||||
_productionNodesByName.Clear();
|
||||
_modalNodesByName.Clear();
|
||||
_overrideNodesByName.Clear();
|
||||
_toolingNodesByName.Clear();
|
||||
_offsetNodesByName.Clear();
|
||||
_messagesNodesByName.Clear();
|
||||
_currentBlockNodesByName.Clear();
|
||||
_diagnosticsNodesByName.Clear();
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
@@ -136,6 +294,73 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var reference = fullReferences[i];
|
||||
|
||||
// Fixed-tree Status/ nodes — served from the per-device cached ODBST struct
|
||||
// refreshed on the probe tick (issue #257). No wire call here.
|
||||
if (_statusNodesByName.TryGetValue(reference, out var statusKey))
|
||||
{
|
||||
results[i] = ReadStatusField(statusKey.Host, statusKey.Field, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fixed-tree Production/ nodes — served from the per-device cached production
|
||||
// snapshot refreshed on the probe tick (issue #258). No wire call here.
|
||||
if (_productionNodesByName.TryGetValue(reference, out var prodKey))
|
||||
{
|
||||
results[i] = ReadProductionField(prodKey.Host, prodKey.Field, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fixed-tree Modal/ + Override/ nodes — served from per-device cached snapshots
|
||||
// refreshed on the probe tick (issue #259). Same cache-or-Bad policy as Status/.
|
||||
if (_modalNodesByName.TryGetValue(reference, out var modalKey))
|
||||
{
|
||||
results[i] = ReadModalField(modalKey.Host, modalKey.Field, now);
|
||||
continue;
|
||||
}
|
||||
if (_overrideNodesByName.TryGetValue(reference, out var overrideKey))
|
||||
{
|
||||
results[i] = ReadOverrideField(overrideKey.Host, overrideKey.Field, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fixed-tree Tooling/CurrentTool — served from cached cnc_rdtnum snapshot
|
||||
// refreshed on the probe tick (issue #260). No wire call here.
|
||||
if (_toolingNodesByName.TryGetValue(reference, out var toolingHost))
|
||||
{
|
||||
results[i] = ReadToolingField(toolingHost, "CurrentTool", now);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fixed-tree Offsets/{slot}/{axis} — served from cached cnc_rdzofs(1..6)
|
||||
// snapshot refreshed on the probe tick (issue #260). No wire call here.
|
||||
if (_offsetNodesByName.TryGetValue(reference, out var offsetKey))
|
||||
{
|
||||
results[i] = ReadOffsetField(offsetKey.Host, offsetKey.Slot, offsetKey.Axis, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fixed-tree Messages/External/Latest + Program/CurrentBlock — served from
|
||||
// cnc_rdopmsg3 + cnc_rdactpt caches refreshed on the probe tick (issue #261).
|
||||
if (_messagesNodesByName.TryGetValue(reference, out var messagesHost))
|
||||
{
|
||||
results[i] = ReadMessagesLatestField(messagesHost, now);
|
||||
continue;
|
||||
}
|
||||
if (_currentBlockNodesByName.TryGetValue(reference, out var blockHost))
|
||||
{
|
||||
results[i] = ReadCurrentBlockField(blockHost, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fixed-tree Diagnostics/ nodes — runtime counters maintained by the probe
|
||||
// loop (issue #262). No wire call here.
|
||||
if (_diagnosticsNodesByName.TryGetValue(reference, out var diagKey))
|
||||
{
|
||||
results[i] = ReadDiagnosticsField(diagKey.Host, diagKey.Field, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
@@ -257,10 +482,267 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: tag.WriteIdempotent));
|
||||
}
|
||||
|
||||
// Fixed-tree Status/ subfolder — 9 read-only Int16 nodes mirroring the ODBST
|
||||
// fields (issue #257). Cached on the probe tick + served from DeviceState.LastStatus.
|
||||
var statusFolder = deviceFolder.Folder("Status", "Status");
|
||||
foreach (var field in StatusFieldNames)
|
||||
{
|
||||
var fullRef = StatusReferenceFor(device.HostAddress, field);
|
||||
statusFolder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullRef,
|
||||
DriverDataType: DriverDataType.Int16,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
|
||||
// Fixed-tree Production/ subfolder — 4 read-only Int32 nodes: parts produced /
|
||||
// required / total + cycle-time seconds (issue #258). Cached on the probe tick
|
||||
// + served from DeviceState.LastProduction.
|
||||
var productionFolder = deviceFolder.Folder("Production", "Production");
|
||||
foreach (var field in ProductionFieldNames)
|
||||
{
|
||||
var fullRef = ProductionReferenceFor(device.HostAddress, field);
|
||||
productionFolder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullRef,
|
||||
DriverDataType: DriverDataType.Int32,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
|
||||
// Fixed-tree Modal/ subfolder — 4 read-only Int16 nodes for the universally-
|
||||
// present aux modal codes M/S/T/B from cnc_modal(type=100..103). G-group
|
||||
// surfaces are deferred to a follow-up because the FWLIB ODBMDL union varies
|
||||
// per series + group (issue #259, plan PR F1-c).
|
||||
var modalFolder = deviceFolder.Folder("Modal", "Modal");
|
||||
foreach (var field in ModalFieldNames)
|
||||
{
|
||||
var fullRef = ModalReferenceFor(device.HostAddress, field);
|
||||
modalFolder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullRef,
|
||||
DriverDataType: DriverDataType.Int16,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
|
||||
// Fixed-tree Override/ subfolder — Feed / Rapid / Spindle / Jog from
|
||||
// cnc_rdparam at MTB-specific parameter numbers (issue #259). Suppressed when
|
||||
// OverrideParameters is null; per-field nodes whose parameter is null are
|
||||
// omitted so a deployment can hide overrides their MTB doesn't wire up.
|
||||
if (device.OverrideParameters is { } overrideParams)
|
||||
{
|
||||
var overrideFolder = deviceFolder.Folder("Override", "Override");
|
||||
foreach (var field in OverrideFieldNames)
|
||||
{
|
||||
if (OverrideParamFor(overrideParams, field) is null) continue;
|
||||
var fullRef = OverrideReferenceFor(device.HostAddress, field);
|
||||
overrideFolder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullRef,
|
||||
DriverDataType: DriverDataType.Int16,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed-tree Tooling/ subfolder — single Int16 CurrentTool node from
|
||||
// cnc_rdtnum (issue #260). Tool life + active offset index are deferred
|
||||
// per the F1-d plan because the FWLIB ODBTLIFE* unions vary per series.
|
||||
if (FocasCapabilityMatrix.SupportsTooling(device.Series))
|
||||
{
|
||||
var toolingFolder = deviceFolder.Folder("Tooling", "Tooling");
|
||||
var toolingRef = ToolingReferenceFor(device.HostAddress, "CurrentTool");
|
||||
toolingFolder.Variable("CurrentTool", "CurrentTool", new DriverAttributeInfo(
|
||||
FullName: toolingRef,
|
||||
DriverDataType: DriverDataType.Int16,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
|
||||
// Fixed-tree Offsets/ subfolder — G54..G59 each with X/Y/Z Float64 axes
|
||||
// from cnc_rdzofs(n=1..6) (issue #260). Capability matrix gates the surface
|
||||
// by series so legacy controllers without cnc_rdzofs support don't expose
|
||||
// dead nodes. Extended G54.1 P1..P48 surfaces are deferred to a follow-up.
|
||||
if (FocasCapabilityMatrix.SupportsWorkOffsets(device.Series))
|
||||
{
|
||||
var offsetsFolder = deviceFolder.Folder("Offsets", "Offsets");
|
||||
foreach (var slot in WorkOffsetSlotNames)
|
||||
{
|
||||
var slotFolder = offsetsFolder.Folder(slot, slot);
|
||||
foreach (var axis in WorkOffsetAxisNames)
|
||||
{
|
||||
var fullRef = OffsetReferenceFor(device.HostAddress, slot, axis);
|
||||
slotFolder.Variable(axis, axis, new DriverAttributeInfo(
|
||||
FullName: fullRef,
|
||||
DriverDataType: DriverDataType.Float64,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed-tree Messages/External/Latest — single String node per device backed
|
||||
// by cnc_rdopmsg3 across the four FANUC operator-message classes (issue #261).
|
||||
// The issue body permits this minimal "latest message" surface in the first
|
||||
// cut over a full ring-buffer of all four slots.
|
||||
var messagesFolder = deviceFolder.Folder("Messages", "Messages");
|
||||
var externalFolder = messagesFolder.Folder("External", "External");
|
||||
var messagesRef = MessagesLatestReferenceFor(device.HostAddress);
|
||||
externalFolder.Variable("Latest", "Latest", new DriverAttributeInfo(
|
||||
FullName: messagesRef,
|
||||
DriverDataType: DriverDataType.String,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
|
||||
// Fixed-tree Program/CurrentBlock — single String node per device backed by
|
||||
// cnc_rdactpt (issue #261). Trim-stable round-trip per the issue body.
|
||||
var programFolder = deviceFolder.Folder("Program", "Program");
|
||||
var blockRef = CurrentBlockReferenceFor(device.HostAddress);
|
||||
programFolder.Variable("CurrentBlock", "CurrentBlock", new DriverAttributeInfo(
|
||||
FullName: blockRef,
|
||||
DriverDataType: DriverDataType.String,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
|
||||
// Fixed-tree Diagnostics/ subfolder — 5 read-only counters surfaced for
|
||||
// operator visibility (issue #262). ReadCount / ReadFailureCount /
|
||||
// ReconnectCount are Int64; LastErrorMessage is String;
|
||||
// LastSuccessfulRead is DateTime. Permissive across CNC series — every
|
||||
// device gets the same shape.
|
||||
var diagnosticsFolder = deviceFolder.Folder("Diagnostics", "Diagnostics");
|
||||
foreach (var field in DiagnosticsFieldNames)
|
||||
{
|
||||
var fullRef = DiagnosticsReferenceFor(device.HostAddress, field);
|
||||
diagnosticsFolder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullRef,
|
||||
DriverDataType: DiagnosticsFieldType(field),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static DriverDataType DiagnosticsFieldType(string field) => field switch
|
||||
{
|
||||
"ReadCount" or "ReadFailureCount" or "ReconnectCount" => DriverDataType.Int64,
|
||||
"LastErrorMessage" => DriverDataType.String,
|
||||
"LastSuccessfulRead" => DriverDataType.DateTime,
|
||||
_ => DriverDataType.String,
|
||||
};
|
||||
|
||||
private static string StatusReferenceFor(string hostAddress, string field) =>
|
||||
$"{hostAddress}::Status/{field}";
|
||||
|
||||
private static string ProductionReferenceFor(string hostAddress, string field) =>
|
||||
$"{hostAddress}::Production/{field}";
|
||||
|
||||
private static string ModalReferenceFor(string hostAddress, string field) =>
|
||||
$"{hostAddress}::Modal/{field}";
|
||||
|
||||
private static string OverrideReferenceFor(string hostAddress, string field) =>
|
||||
$"{hostAddress}::Override/{field}";
|
||||
|
||||
private static string ToolingReferenceFor(string hostAddress, string field) =>
|
||||
$"{hostAddress}::Tooling/{field}";
|
||||
|
||||
private static string OffsetReferenceFor(string hostAddress, string slot, string axis) =>
|
||||
$"{hostAddress}::Offsets/{slot}/{axis}";
|
||||
|
||||
private static string MessagesLatestReferenceFor(string hostAddress) =>
|
||||
$"{hostAddress}::Messages/External/Latest";
|
||||
|
||||
private static string CurrentBlockReferenceFor(string hostAddress) =>
|
||||
$"{hostAddress}::Program/CurrentBlock";
|
||||
|
||||
private static string DiagnosticsReferenceFor(string hostAddress, string field) =>
|
||||
$"{hostAddress}::Diagnostics/{field}";
|
||||
|
||||
private static ushort? OverrideParamFor(FocasOverrideParameters p, string field) => field switch
|
||||
{
|
||||
"Feed" => p.FeedParam,
|
||||
"Rapid" => p.RapidParam,
|
||||
"Spindle" => p.SpindleParam,
|
||||
"Jog" => p.JogParam,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static short? PickStatusField(FocasStatusInfo s, string field) => field switch
|
||||
{
|
||||
"Tmmode" => s.Tmmode,
|
||||
"Aut" => s.Aut,
|
||||
"Run" => s.Run,
|
||||
"Motion" => s.Motion,
|
||||
"Mstb" => s.Mstb,
|
||||
"EmergencyStop" => s.EmergencyStop,
|
||||
"Alarm" => s.Alarm,
|
||||
"Edit" => s.Edit,
|
||||
"Dummy" => s.Dummy,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static int? PickProductionField(FocasProductionInfo p, string field) => field switch
|
||||
{
|
||||
"PartsProduced" => p.PartsProduced,
|
||||
"PartsRequired" => p.PartsRequired,
|
||||
"PartsTotal" => p.PartsTotal,
|
||||
"CycleTimeSeconds" => p.CycleTimeSeconds,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static short? PickModalField(FocasModalInfo m, string field) => field switch
|
||||
{
|
||||
"MCode" => m.MCode,
|
||||
"SCode" => m.SCode,
|
||||
"TCode" => m.TCode,
|
||||
"BCode" => m.BCode,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static short? PickOverrideField(FocasOverrideInfo o, string field) => field switch
|
||||
{
|
||||
"Feed" => o.Feed,
|
||||
"Rapid" => o.Rapid,
|
||||
"Spindle" => o.Spindle,
|
||||
"Jog" => o.Jog,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
@@ -283,13 +765,123 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var success = false;
|
||||
string? failureMessage = null;
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
|
||||
success = await client.ProbeAsync(ct).ConfigureAwait(false);
|
||||
if (success)
|
||||
{
|
||||
// Refresh figure-scaling cache once per session (issue #262). The
|
||||
// increment system rarely changes mid-session; re-reading every probe
|
||||
// tick would waste a wire call. Best-effort — null result leaves the
|
||||
// previous good map in place.
|
||||
if (state.FigureScaling is null)
|
||||
{
|
||||
var fig = await client.GetFigureScalingAsync(ct).ConfigureAwait(false);
|
||||
if (fig is not null) state.FigureScaling = fig;
|
||||
}
|
||||
|
||||
// Refresh the cached ODBST status snapshot on every probe tick — this is
|
||||
// what the Status/ fixed-tree nodes serve from. Best-effort: a null result
|
||||
// (older IFocasClient impls without GetStatusAsync) just leaves the cache
|
||||
// unchanged so the previous good snapshot keeps serving until refreshed.
|
||||
var snapshot = await client.GetStatusAsync(ct).ConfigureAwait(false);
|
||||
if (snapshot is not null)
|
||||
{
|
||||
state.LastStatus = snapshot;
|
||||
state.LastStatusUtc = DateTime.UtcNow;
|
||||
}
|
||||
// Refresh the cached production snapshot too — same best-effort policy
|
||||
// as Status/: a null result leaves the previous good snapshot in place
|
||||
// so reads keep serving until the next successful refresh (issue #258).
|
||||
var production = await client.GetProductionAsync(ct).ConfigureAwait(false);
|
||||
if (production is not null)
|
||||
{
|
||||
state.LastProduction = production;
|
||||
state.LastProductionUtc = DateTime.UtcNow;
|
||||
}
|
||||
// Modal aux M/S/T/B + per-device operator overrides — same best-effort
|
||||
// policy as Status/ + Production/. Override snapshot is suppressed when
|
||||
// the device has no OverrideParameters configured (issue #259).
|
||||
var modal = await client.GetModalAsync(ct).ConfigureAwait(false);
|
||||
if (modal is not null)
|
||||
{
|
||||
state.LastModal = modal;
|
||||
state.LastModalUtc = DateTime.UtcNow;
|
||||
}
|
||||
if (state.Options.OverrideParameters is { } overrideParams)
|
||||
{
|
||||
var ov = await client.GetOverrideAsync(overrideParams, ct).ConfigureAwait(false);
|
||||
if (ov is not null)
|
||||
{
|
||||
state.LastOverride = ov;
|
||||
state.LastOverrideUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
// Tooling/CurrentTool + Offsets/{G54..G59}/{X|Y|Z} — same best-
|
||||
// effort policy as the other fixed-tree caches (issue #260). A
|
||||
// null result leaves the previous good snapshot in place so reads
|
||||
// keep serving until the next successful refresh.
|
||||
if (FocasCapabilityMatrix.SupportsTooling(state.Options.Series))
|
||||
{
|
||||
var tooling = await client.GetToolingAsync(ct).ConfigureAwait(false);
|
||||
if (tooling is not null)
|
||||
{
|
||||
state.LastTooling = tooling;
|
||||
state.LastToolingUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
if (FocasCapabilityMatrix.SupportsWorkOffsets(state.Options.Series))
|
||||
{
|
||||
var offsets = await client.GetWorkOffsetsAsync(ct).ConfigureAwait(false);
|
||||
if (offsets is not null)
|
||||
{
|
||||
state.LastWorkOffsets = offsets;
|
||||
state.LastWorkOffsetsUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
// Operator messages + currently-executing block — same best-effort
|
||||
// policy as the other fixed-tree caches (issue #261). A null result
|
||||
// leaves the previous good snapshot in place so reads keep serving
|
||||
// until the next successful refresh.
|
||||
var messages = await client.GetOperatorMessagesAsync(ct).ConfigureAwait(false);
|
||||
if (messages is not null)
|
||||
{
|
||||
state.LastMessages = messages;
|
||||
state.LastMessagesUtc = DateTime.UtcNow;
|
||||
}
|
||||
var block = await client.GetCurrentBlockAsync(ct).ConfigureAwait(false);
|
||||
if (block is not null)
|
||||
{
|
||||
state.LastCurrentBlock = block;
|
||||
state.LastCurrentBlockUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch { /* connect-failure path already disposed + cleared the client */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
failureMessage = ex.Message;
|
||||
/* connect-failure path already disposed + cleared the client */
|
||||
}
|
||||
|
||||
// Diagnostics counters refreshed per probe tick (issue #262). Successful
|
||||
// ticks bump ReadCount + LastSuccessfulRead; failed ticks bump
|
||||
// ReadFailureCount + LastErrorMessage. The reconnect counter is bumped in
|
||||
// EnsureConnectedAsync's connect path so a wedged probe doesn't double-count.
|
||||
if (success)
|
||||
{
|
||||
Interlocked.Increment(ref state.ReadCount);
|
||||
state.LastSuccessfulReadUtc = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
Interlocked.Increment(ref state.ReadFailureCount);
|
||||
if (!string.IsNullOrEmpty(failureMessage))
|
||||
state.LastErrorMessage = failureMessage;
|
||||
}
|
||||
|
||||
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
|
||||
|
||||
@@ -298,6 +890,161 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadStatusField(string hostAddress, string field, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
if (device.LastStatus is not { } snap)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
var value = PickStatusField(snap, field);
|
||||
if (value is null)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
|
||||
device.LastStatusUtc, now);
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadProductionField(string hostAddress, string field, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
if (device.LastProduction is not { } snap)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
var value = PickProductionField(snap, field);
|
||||
if (value is null)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
return new DataValueSnapshot((int)value, FocasStatusMapper.Good,
|
||||
device.LastProductionUtc, now);
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadModalField(string hostAddress, string field, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
if (device.LastModal is not { } snap)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
var value = PickModalField(snap, field);
|
||||
if (value is null)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
|
||||
device.LastModalUtc, now);
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadOverrideField(string hostAddress, string field, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
if (device.LastOverride is not { } snap)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
var value = PickOverrideField(snap, field);
|
||||
if (value is null)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
return new DataValueSnapshot((short)value, FocasStatusMapper.Good,
|
||||
device.LastOverrideUtc, now);
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadToolingField(string hostAddress, string field, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
if (device.LastTooling is not { } snap)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
return field switch
|
||||
{
|
||||
"CurrentTool" => new DataValueSnapshot(snap.CurrentTool, FocasStatusMapper.Good,
|
||||
device.LastToolingUtc, now),
|
||||
_ => new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now),
|
||||
};
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadOffsetField(string hostAddress, string slot, string axis, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
if (device.LastWorkOffsets is not { } snap)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
var match = snap.Offsets.FirstOrDefault(o =>
|
||||
string.Equals(o.Name, slot, StringComparison.OrdinalIgnoreCase));
|
||||
if (match is null)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
var value = axis switch
|
||||
{
|
||||
"X" => (double?)match.X,
|
||||
"Y" => match.Y,
|
||||
"Z" => match.Z,
|
||||
_ => null,
|
||||
};
|
||||
if (value is null)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
return new DataValueSnapshot(value.Value, FocasStatusMapper.Good,
|
||||
device.LastWorkOffsetsUtc, now);
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadMessagesLatestField(string hostAddress, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
if (device.LastMessages is not { } snap)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
// Snapshot is the trimmed list of active classes. "Latest" surfaces the last
|
||||
// (most-recent) entry — the issue body permits this minimal "latest message"
|
||||
// surface in lieu of a full ring buffer of all 4 classes.
|
||||
var latest = snap.Messages.Count == 0
|
||||
? string.Empty
|
||||
: snap.Messages[snap.Messages.Count - 1].Text;
|
||||
return new DataValueSnapshot(latest, FocasStatusMapper.Good,
|
||||
device.LastMessagesUtc, now);
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadCurrentBlockField(string hostAddress, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
if (device.LastCurrentBlock is not { } snap)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
return new DataValueSnapshot(snap.Text, FocasStatusMapper.Good,
|
||||
device.LastCurrentBlockUtc, now);
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadDiagnosticsField(string hostAddress, string field, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
// Diagnostics counters are always Good — they're driver-internal state, not wire
|
||||
// reads. LastSuccessfulRead surfaces DateTime.MinValue before the first probe
|
||||
// tick rather than null because OPC UA's DateTime variant has no "unset" sentinel
|
||||
// a generic client can interpret (issue #262).
|
||||
object? value = field switch
|
||||
{
|
||||
"ReadCount" => Interlocked.Read(ref device.ReadCount),
|
||||
"ReadFailureCount" => Interlocked.Read(ref device.ReadFailureCount),
|
||||
"ReconnectCount" => Interlocked.Read(ref device.ReconnectCount),
|
||||
"LastErrorMessage" => device.LastErrorMessage ?? string.Empty,
|
||||
"LastSuccessfulRead" => device.LastSuccessfulReadUtc,
|
||||
_ => null,
|
||||
};
|
||||
if (value is null)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply <c>cnc_getfigure</c>-derived decimal scaling to a raw position value.
|
||||
/// Returns <paramref name="raw"/> divided by <c>10^decimalPlaces</c> when the
|
||||
/// device has a cached scaling entry for <paramref name="axisName"/> AND
|
||||
/// <see cref="FocasFixedTreeOptions.ApplyFigureScaling"/> is on; otherwise
|
||||
/// returns the raw value as a <c>double</c>. Forward-looking — surfaced for
|
||||
/// future PRs that wire up <c>Axes/{name}/AbsolutePosition</c> etc. so they
|
||||
/// don't need to re-derive the policy (issue #262).
|
||||
/// </summary>
|
||||
internal double ApplyFigureScaling(string hostAddress, string axisName, long raw)
|
||||
{
|
||||
if (!_options.FixedTree.ApplyFigureScaling) return raw;
|
||||
if (!_devices.TryGetValue(hostAddress, out var device)) return raw;
|
||||
if (device.FigureScaling is not { } map) return raw;
|
||||
if (!map.TryGetValue(axisName, out var dec) || dec <= 0) return raw;
|
||||
return raw / Math.Pow(10.0, dec);
|
||||
}
|
||||
|
||||
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
@@ -324,6 +1071,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private async Task<IFocasClient> EnsureConnectedAsync(DeviceState device, CancellationToken ct)
|
||||
{
|
||||
if (device.Client is { IsConnected: true } c) return c;
|
||||
// Reconnect counter bumps before the connect call — a successful first connect
|
||||
// counts as one "establishment" so the field is non-zero from session start
|
||||
// (issue #262, mirrors the convention from the AbCip / TwinCAT diagnostics).
|
||||
Interlocked.Increment(ref device.ReconnectCount);
|
||||
device.Client ??= _clientFactory.Create();
|
||||
try
|
||||
{
|
||||
@@ -352,6 +1103,90 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public DateTime HostStateChangedUtc { get; set; } = DateTime.UtcNow;
|
||||
public CancellationTokenSource? ProbeCts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_rdcncstat</c> snapshot, refreshed on every probe tick. Reads of
|
||||
/// the per-device <c>Status/<field></c> fixed-tree nodes serve from this cache
|
||||
/// so they don't pile extra wire traffic on top of the user-driven tag reads.
|
||||
/// </summary>
|
||||
public FocasStatusInfo? LastStatus { get; set; }
|
||||
public DateTime LastStatusUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_rdparam(6711/6712/6713)</c> + cycle-time snapshot, refreshed on
|
||||
/// every probe tick. Reads of the per-device <c>Production/<field></c>
|
||||
/// fixed-tree nodes serve from this cache so they don't pile extra wire traffic
|
||||
/// on top of the user-driven tag reads (issue #258).
|
||||
/// </summary>
|
||||
public FocasProductionInfo? LastProduction { get; set; }
|
||||
public DateTime LastProductionUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_modal</c> M/S/T/B snapshot, refreshed on every probe tick.
|
||||
/// Reads of the per-device <c>Modal/<field></c> nodes serve from this cache
|
||||
/// so they don't pile extra wire traffic on top of user-driven reads (issue #259).
|
||||
/// </summary>
|
||||
public FocasModalInfo? LastModal { get; set; }
|
||||
public DateTime LastModalUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_rdparam</c> override snapshot, refreshed on every probe tick.
|
||||
/// Suppressed when the device's <see cref="FocasDeviceOptions.OverrideParameters"/>
|
||||
/// is null (no <c>Override/</c> nodes are exposed in that case — issue #259).
|
||||
/// </summary>
|
||||
public FocasOverrideInfo? LastOverride { get; set; }
|
||||
public DateTime LastOverrideUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_rdtnum</c> snapshot — current tool number — refreshed on
|
||||
/// every probe tick. Reads of <c>Tooling/CurrentTool</c> serve from this
|
||||
/// cache so they don't pile extra wire traffic on top of user-driven
|
||||
/// reads (issue #260).
|
||||
/// </summary>
|
||||
public FocasToolingInfo? LastTooling { get; set; }
|
||||
public DateTime LastToolingUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_rdzofs(1..6)</c> snapshot — G54..G59 work-coordinate
|
||||
/// offsets — refreshed on every probe tick. Reads of
|
||||
/// <c>Offsets/{slot}/{X|Y|Z}</c> serve from this cache (issue #260).
|
||||
/// </summary>
|
||||
public FocasWorkOffsetsInfo? LastWorkOffsets { get; set; }
|
||||
public DateTime LastWorkOffsetsUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_rdopmsg3</c> snapshot — active operator messages across
|
||||
/// the four FANUC classes — refreshed on every probe tick. Reads of
|
||||
/// <c>Messages/External/Latest</c> serve from this cache (issue #261).
|
||||
/// </summary>
|
||||
public FocasOperatorMessagesInfo? LastMessages { get; set; }
|
||||
public DateTime LastMessagesUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_rdactpt</c> snapshot — currently-executing block text —
|
||||
/// refreshed on every probe tick. Reads of <c>Program/CurrentBlock</c>
|
||||
/// serve from this cache (issue #261).
|
||||
/// </summary>
|
||||
public FocasCurrentBlockInfo? LastCurrentBlock { get; set; }
|
||||
public DateTime LastCurrentBlockUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached per-axis decimal-place counts from <c>cnc_getfigure</c> (issue #262).
|
||||
/// Populated once per session (the increment system rarely changes mid-run);
|
||||
/// served by <see cref="FocasDriver.ApplyFigureScaling"/> when a future PR
|
||||
/// surfaces position values that need scaling. Keys are axis names (or
|
||||
/// fallback <c>"axis{n}"</c> until <c>cnc_rdaxisname</c> integration lands).
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, int>? FigureScaling { get; set; }
|
||||
|
||||
// Diagnostics counters per device — surfaced under Diagnostics/ subtree (issue
|
||||
// #262). Public fields rather than properties so Interlocked.Increment can
|
||||
// operate on them directly. Long-typed for the OPC UA Int64 surface.
|
||||
public long ReadCount;
|
||||
public long ReadFailureCount;
|
||||
public long ReconnectCount;
|
||||
public string? LastErrorMessage;
|
||||
public DateTime LastSuccessfulReadUtc;
|
||||
|
||||
public void DisposeClient()
|
||||
{
|
||||
Client?.Dispose();
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Static factory registration helper for <see cref="FocasDriver"/>. Server's Program.cs
|
||||
/// calls <see cref="Register"/> once at startup; the bootstrapper (task #248) then
|
||||
/// materialises FOCAS DriverInstance rows from the central config DB into live driver
|
||||
/// instances. Mirrors <c>GalaxyProxyDriverFactoryExtensions</c>; no dependency on
|
||||
/// Microsoft.Extensions.DependencyInjection so the driver project stays DI-free.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The DriverConfig JSON selects the <see cref="IFocasClientFactory"/> backend:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>"Backend": "ipc"</c> (default) — wires <see cref="IpcFocasClientFactory"/>
|
||||
/// against a named-pipe <see cref="FocasIpcClient"/> talking to a separate
|
||||
/// <c>Driver.FOCAS.Host</c> process (Tier-C isolation). Requires <c>PipeName</c> +
|
||||
/// <c>SharedSecret</c>.</item>
|
||||
/// <item><c>"Backend": "fwlib"</c> — direct in-process Fwlib32.dll P/Invoke via
|
||||
/// <see cref="FwlibFocasClientFactory"/>. Use only when the main server is licensed
|
||||
/// for FOCAS and you accept the native-crash blast-radius trade-off.</item>
|
||||
/// <item><c>"Backend": "unimplemented"</c> — returns the no-op factory; useful for
|
||||
/// scaffolding DriverInstance rows before the Host is deployed so the server boots.</item>
|
||||
/// </list>
|
||||
/// Devices / Tags / Probe / Timeout / Series come from the same JSON and feed directly
|
||||
/// into <see cref="FocasDriverOptions"/>.
|
||||
/// </remarks>
|
||||
public static class FocasDriverFactoryExtensions
|
||||
{
|
||||
public const string DriverTypeName = "FOCAS";
|
||||
|
||||
/// <summary>
|
||||
/// Register the FOCAS driver factory in the supplied <see cref="DriverFactoryRegistry"/>.
|
||||
/// Throws if 'FOCAS' is already registered — single-instance per process.
|
||||
/// </summary>
|
||||
public static void Register(DriverFactoryRegistry registry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, CreateInstance);
|
||||
}
|
||||
|
||||
internal static FocasDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||
|
||||
var dto = JsonSerializer.Deserialize<FocasDriverConfigDto>(driverConfigJson, JsonOptions)
|
||||
?? throw new InvalidOperationException(
|
||||
$"FOCAS driver config for '{driverInstanceId}' deserialised to null");
|
||||
|
||||
// Eager-validate top-level Series so a typo fails fast regardless of whether Devices
|
||||
// are populated yet (common during rollout when rows are seeded before CNCs arrive).
|
||||
_ = ParseSeries(dto.Series);
|
||||
|
||||
var options = new FocasDriverOptions
|
||||
{
|
||||
Devices = dto.Devices is { Count: > 0 }
|
||||
? [.. dto.Devices.Select(d => new FocasDeviceOptions(
|
||||
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
|
||||
$"FOCAS config for '{driverInstanceId}' has a device missing HostAddress"),
|
||||
DeviceName: d.DeviceName,
|
||||
Series: ParseSeries(d.Series ?? dto.Series)))]
|
||||
: [],
|
||||
Tags = dto.Tags is { Count: > 0 }
|
||||
? [.. dto.Tags.Select(t => new FocasTagDefinition(
|
||||
Name: t.Name ?? throw new InvalidOperationException(
|
||||
$"FOCAS config for '{driverInstanceId}' has a tag missing Name"),
|
||||
DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
|
||||
$"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
|
||||
Address: t.Address ?? throw new InvalidOperationException(
|
||||
$"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing Address"),
|
||||
DataType: ParseDataType(t.DataType, t.Name!, driverInstanceId),
|
||||
Writable: t.Writable ?? true,
|
||||
WriteIdempotent: t.WriteIdempotent ?? false))]
|
||||
: [],
|
||||
Probe = new FocasProbeOptions
|
||||
{
|
||||
Enabled = dto.Probe?.Enabled ?? true,
|
||||
Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
|
||||
},
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
|
||||
};
|
||||
|
||||
var clientFactory = BuildClientFactory(dto, driverInstanceId);
|
||||
return new FocasDriver(options, driverInstanceId, clientFactory);
|
||||
}
|
||||
|
||||
internal static IFocasClientFactory BuildClientFactory(
|
||||
FocasDriverConfigDto dto, string driverInstanceId)
|
||||
{
|
||||
var backend = (dto.Backend ?? "ipc").Trim().ToLowerInvariant();
|
||||
return backend switch
|
||||
{
|
||||
"ipc" => BuildIpcFactory(dto, driverInstanceId),
|
||||
"fwlib" or "fwlib32" => new FwlibFocasClientFactory(),
|
||||
"unimplemented" or "none" or "stub" => new UnimplementedFocasClientFactory(),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"FOCAS driver config for '{driverInstanceId}' has unknown Backend '{dto.Backend}'. " +
|
||||
"Expected one of: ipc, fwlib, unimplemented."),
|
||||
};
|
||||
}
|
||||
|
||||
private static IpcFocasClientFactory BuildIpcFactory(
|
||||
FocasDriverConfigDto dto, string driverInstanceId)
|
||||
{
|
||||
var pipeName = dto.PipeName
|
||||
?? throw new InvalidOperationException(
|
||||
$"FOCAS driver config for '{driverInstanceId}' missing required PipeName (Tier-C ipc backend)");
|
||||
var sharedSecret = dto.SharedSecret
|
||||
?? throw new InvalidOperationException(
|
||||
$"FOCAS driver config for '{driverInstanceId}' missing required SharedSecret (Tier-C ipc backend)");
|
||||
var connectTimeout = TimeSpan.FromMilliseconds(dto.ConnectTimeoutMs ?? 10_000);
|
||||
var series = ParseSeries(dto.Series);
|
||||
|
||||
// Each IFocasClientFactory.Create() call opens a fresh pipe to the Host — matches the
|
||||
// driver's one-client-per-device invariant. FocasIpcClient.ConnectAsync is awaited
|
||||
// synchronously via GetAwaiter().GetResult() because IFocasClientFactory.Create is a
|
||||
// sync contract; the blocking call lands inside FocasDriver.EnsureConnectedAsync,
|
||||
// which immediately awaits IFocasClient.ConnectAsync afterwards so the perceived
|
||||
// latency is identical to a fully-async factory.
|
||||
return new IpcFocasClientFactory(
|
||||
ipcClientFactory: () => FocasIpcClient.ConnectAsync(
|
||||
pipeName: pipeName,
|
||||
sharedSecret: sharedSecret,
|
||||
connectTimeout: connectTimeout,
|
||||
ct: CancellationToken.None).GetAwaiter().GetResult(),
|
||||
series: series);
|
||||
}
|
||||
|
||||
private static FocasCncSeries ParseSeries(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return FocasCncSeries.Unknown;
|
||||
return Enum.TryParse<FocasCncSeries>(raw, ignoreCase: true, out var s)
|
||||
? s
|
||||
: throw new InvalidOperationException(
|
||||
$"FOCAS Series '{raw}' is not one of {string.Join(", ", Enum.GetNames<FocasCncSeries>())}");
|
||||
}
|
||||
|
||||
private static FocasDataType ParseDataType(string? raw, string tagName, string driverInstanceId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
throw new InvalidOperationException(
|
||||
$"FOCAS tag '{tagName}' in '{driverInstanceId}' missing DataType");
|
||||
return Enum.TryParse<FocasDataType>(raw, ignoreCase: true, out var dt)
|
||||
? dt
|
||||
: throw new InvalidOperationException(
|
||||
$"FOCAS tag '{tagName}' has unknown DataType '{raw}'. " +
|
||||
$"Expected one of {string.Join(", ", Enum.GetNames<FocasDataType>())}");
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
internal sealed class FocasDriverConfigDto
|
||||
{
|
||||
public string? Backend { get; init; }
|
||||
public string? PipeName { get; init; }
|
||||
public string? SharedSecret { get; init; }
|
||||
public int? ConnectTimeoutMs { get; init; }
|
||||
public string? Series { get; init; }
|
||||
public int? TimeoutMs { get; init; }
|
||||
public List<FocasDeviceDto>? Devices { get; init; }
|
||||
public List<FocasTagDto>? Tags { get; init; }
|
||||
public FocasProbeDto? Probe { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class FocasDeviceDto
|
||||
{
|
||||
public string? HostAddress { get; init; }
|
||||
public string? DeviceName { get; init; }
|
||||
public string? Series { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class FocasTagDto
|
||||
{
|
||||
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 FocasProbeDto
|
||||
{
|
||||
public bool? Enabled { get; init; }
|
||||
public int? IntervalMs { get; init; }
|
||||
public int? TimeoutMs { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -11,17 +11,49 @@ public sealed class FocasDriverOptions
|
||||
public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = [];
|
||||
public FocasProbeOptions Probe { get; init; } = new();
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// Fixed-tree behaviour knobs (issue #262, plan PR F1-f). Carries the
|
||||
/// <c>ApplyFigureScaling</c> toggle that gates the <c>cnc_getfigure</c>
|
||||
/// decimal-place division applied to position values before publishing.
|
||||
/// </summary>
|
||||
public FocasFixedTreeOptions FixedTree { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-driver fixed-tree options. New installs default <see cref="ApplyFigureScaling"/>
|
||||
/// to <c>true</c> so position values surface in user units (mm / inch). Existing
|
||||
/// deployments that already published raw scaled integers can flip this to <c>false</c>
|
||||
/// for migration parity — the operator-facing concern is that switching the flag
|
||||
/// mid-deployment changes the values clients see, so the migration path is
|
||||
/// documentation-only (issue #262).
|
||||
/// </summary>
|
||||
public sealed record FocasFixedTreeOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// When <c>true</c> (default), position values from <c>cnc_absolute</c> /
|
||||
/// <c>cnc_machine</c> / <c>cnc_relative</c> / <c>cnc_distance</c> /
|
||||
/// <c>cnc_actf</c> are divided by <c>10^decimalPlaces</c> per axis using the
|
||||
/// <c>cnc_getfigure</c> snapshot cached at probe time. When <c>false</c>, the
|
||||
/// raw integer values are published unchanged — used for migrations from
|
||||
/// older drivers that didn't apply the scaling.
|
||||
/// </summary>
|
||||
public bool ApplyFigureScaling { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One CNC the driver talks to. <paramref name="Series"/> enables per-series
|
||||
/// address validation at <see cref="FocasDriver.InitializeAsync"/>; leave as
|
||||
/// <see cref="FocasCncSeries.Unknown"/> to skip validation (legacy behaviour).
|
||||
/// <paramref name="OverrideParameters"/> declares the four MTB-specific override
|
||||
/// <c>cnc_rdparam</c> numbers surfaced under <c>Override/</c>; pass <c>null</c> to
|
||||
/// suppress the entire <c>Override/</c> subfolder for that device (issue #259).
|
||||
/// </summary>
|
||||
public sealed record FocasDeviceOptions(
|
||||
string HostAddress,
|
||||
string? DeviceName = null,
|
||||
FocasCncSeries Series = FocasCncSeries.Unknown);
|
||||
FocasCncSeries Series = FocasCncSeries.Unknown,
|
||||
FocasOverrideParameters? OverrideParameters = null);
|
||||
|
||||
/// <summary>
|
||||
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user