Compare commits
98 Commits
v2
...
auto/drive
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a071b6d5a | |||
|
|
931049b5a7 | ||
| fa2fbb404d | |||
|
|
17faf76ea7 | ||
| 5432c49364 | |||
|
|
d7633fe36f | ||
| 69d9a6fbb5 | |||
|
|
07abee5f6d | ||
| 0f3abed4c7 | |||
|
|
cc21281cbb | ||
| 5e164dc965 | |||
|
|
02d1c85190 | ||
| 1d3e9a3237 | |||
|
|
0f509fbd3a | ||
| e879b3ae90 | |||
|
|
4d3ee47235 | ||
| 9ebe5bd523 | |||
|
|
63099115bf | ||
| 7042b11f34 | |||
|
|
2f3eeecd17 | ||
| 3b82f4f5fb | |||
|
|
451b37a632 | ||
| 6743d51db8 | |||
|
|
0044603902 | ||
| 2fc71d288e | |||
|
|
286ab3ba41 | ||
| 5ca2ad83cd | |||
|
|
e3c0750f7d | ||
| 177d75784b | |||
|
|
6e244e0c01 | ||
| 27878d0faf | |||
|
|
08d8a104bb | ||
| 7ee0cbc3f4 | |||
|
|
e5299cda5a | ||
| e5b192fcb3 | |||
|
|
cfcaf5c1d3 | ||
| 2731318c81 | |||
|
|
86407e6ca2 | ||
| 2266dd9ad5 | |||
|
|
0df14ab94a | ||
| 448a97d67f | |||
|
|
b699052324 | ||
| e6a55add20 | |||
|
|
fcf89618cd | ||
| f83c467647 | |||
|
|
80b2d7f8c3 | ||
| 8286255ae5 | |||
|
|
615ab25680 | ||
| 545cc74ec8 | |||
|
|
e5122c546b | ||
| 6737edbad2 | |||
|
|
ce98c2ada3 | ||
| 676eebd5e4 | |||
|
|
2b66cec582 | ||
| b751c1c096 | |||
|
|
316f820eff | ||
| 38eb909f69 | |||
|
|
d1699af609 | ||
| c6c694b69e | |||
|
|
4a3860ae92 | ||
| d57e24a7fa | |||
|
|
bb1ab47b68 | ||
| a04ba2af7a | |||
|
|
494fdf2358 | ||
| 9f1e033e83 | |||
|
|
fae00749ca | ||
| bf200e813e | |||
|
|
7209364c35 | ||
| 8314c273e7 | |||
|
|
1abf743a9f | ||
| 63a79791cd | |||
|
|
cc757855e6 | ||
| 84913638b1 | |||
|
|
9ec92a9082 | ||
| 49fc23adc6 | |||
|
|
3c2c4f29ea | ||
| ae7cc15178 | |||
|
|
3d9697b918 | ||
| 329e222aa2 | |||
|
|
551494d223 | ||
| 5b4925e61a | |||
|
|
4ff4cc5899 | ||
| b95eaacc05 | |||
|
|
c89f5bb3b9 | ||
| 07235d3b66 | |||
|
|
f2bc36349e | ||
| ccf2e3a9c0 | |||
|
|
8f7265186d | ||
| 651d6c005c | |||
|
|
36b2929780 | ||
| 345ac97c43 | |||
|
|
767ac4aec5 | ||
| 29edd835a3 | |||
|
|
d78a471e90 | ||
| 1d9e40236b | |||
|
|
2e6228a243 | ||
|
|
21e0fdd4cd | ||
|
|
5fc596a9a1 |
@@ -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:
|
||||
|
||||
|
||||
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.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Driver test-client CLIs
|
||||
|
||||
Five shell-level ad-hoc validation tools, one per native-protocol driver family.
|
||||
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.
|
||||
@@ -12,6 +12,7 @@ the **same driver** the OtOpcUa server uses — so "does the CLI see it?" and
|
||||
| `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`.
|
||||
@@ -32,11 +33,11 @@ Every driver CLI exposes the same four verbs:
|
||||
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).
|
||||
(`PollGroupEngine`) where the protocol has no push (Modbus, AB, S7, FOCAS).
|
||||
|
||||
## Shared infrastructure
|
||||
|
||||
All five CLIs depend on `src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/`:
|
||||
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
|
||||
@@ -48,9 +49,9 @@ All five CLIs depend on `src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/`:
|
||||
with a shortlist for `Good` / `Bad*` / `Uncertain`; unknown codes fall
|
||||
back to hex.
|
||||
|
||||
Writing a sixth CLI (hypothetical Galaxy / FOCAS) costs roughly 150 lines:
|
||||
a `{Family}CommandBase` + four thin command classes that hand their flag
|
||||
values to the already-shipped driver.
|
||||
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
|
||||
|
||||
@@ -86,7 +87,9 @@ values to the already-shipped driver.
|
||||
|
||||
## Tracking
|
||||
|
||||
Tasks #249 / #250 / #251 shipped the suite. 122 unit tests cumulative
|
||||
(16 shared-lib + 106 across the five CLIs) — run
|
||||
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 |
|
||||
|
||||
@@ -62,6 +71,7 @@ For Modbus / S7 / AB CIP / AB Legacy / TwinCAT / FOCAS / OPC UA Client specifics
|
||||
| [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
|
||||
@@ -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 |
|
||||
|
||||
@@ -125,6 +125,35 @@ back an `IAlarmSource`, but shipping that is a separate feature.
|
||||
| "Do notifications coalesce under load?" | no | yes (required) |
|
||||
| "Does a TC2 PLC work the same as TC3?" | no | yes (required) |
|
||||
|
||||
## Performance
|
||||
|
||||
PR 2.1 (Sum-read / Sum-write, IndexGroup `0xF080..0xF084`) replaced the per-tag
|
||||
`ReadValueAsync` loop in `TwinCATDriver.ReadAsync` / `WriteAsync` with a
|
||||
bucketed bulk dispatch — N tags addressed against the same device flow through a
|
||||
single ADS sum-command round-trip via `SumInstancePathAnyTypeRead` (read) and
|
||||
`SumWriteBySymbolPath` (write). Whole-array tags + bit-extracted BOOL tags
|
||||
remain on the per-tag fallback path because the sum surface only marshals
|
||||
scalars and bit-RMW writes need the per-parent serialisation lock.
|
||||
|
||||
**Baseline → Sum-command delta** (dev box, 1000 × DINT, XAR VM over LAN):
|
||||
|
||||
| Path | Round-trips | Wall-clock |
|
||||
| --- | --- | --- |
|
||||
| Per-tag loop (pre-PR 2.1) | 1000 | ~5–8 s |
|
||||
| Sum-command bulk (PR 2.1) | 1 | ~250–600 ms |
|
||||
| Ratio | — | ≥ 10× typical, ≥ 5× CI floor |
|
||||
|
||||
The perf-tier test
|
||||
`TwinCATSumCommandPerfTests.Driver_sum_read_1000_tags_beats_loop_baseline_by_5x`
|
||||
asserts the ratio with a conservative 5× lower bound that survives noisy CI /
|
||||
VM scheduling. It is gated behind both `TWINCAT_TARGET_NETID` (XAR reachable)
|
||||
and `TWINCAT_PERF=1` (operator opt-in) — perf runs aren't part of the default
|
||||
integration pass because they hit the wire heavily.
|
||||
|
||||
The required fixture state (1000-DINT GVL + churn POU) is documented in
|
||||
`TwinCatProject/README.md §Performance scenarios`; XAE-form sources land at
|
||||
`TwinCatProject/PLC/GVLs/GVL_Perf.TcGVL` + `TwinCatProject/PLC/POUs/FB_PerfChurn.TcPOU`.
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
1. **XAR VM live-population** — scaffolding is in place (this PR); the
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -450,6 +450,104 @@ Test names:
|
||||
- **ET 200SP CPU (1510SP / 1512SP)**: behaves as S7-1500 from `MB_SERVER`
|
||||
perspective. No known deltas [3].
|
||||
|
||||
## Performance (native S7comm driver)
|
||||
|
||||
This section covers the native S7comm driver (`ZB.MOM.WW.OtOpcUa.Driver.S7`),
|
||||
not the Modbus-on-S7 quirks above. Both share a CPU but use different ports,
|
||||
different libraries, and different optimization levers.
|
||||
|
||||
### Block-read coalescing
|
||||
|
||||
The S7 driver runs a coalescing planner before every read pass: same-area /
|
||||
same-DB tags are sorted by byte offset and merged into single
|
||||
`Plc.ReadBytesAsync` requests when the gap between them is small. Reading
|
||||
`DB1.DBW0`, `DB1.DBW2`, `DB1.DBW4` issues **one** 6-byte byte-range read
|
||||
covering offsets 0..6, sliced client-side instead of three multi-var items
|
||||
(let alone three individual `Plc.ReadAsync` round-trips). On a 50-tag
|
||||
contiguous workload this reduces wire traffic from 50 single reads (or 3
|
||||
multi-var batches at the 19-item PDU ceiling) to **1 byte-range PDU**.
|
||||
|
||||
#### Default 16-byte gap-merge threshold
|
||||
|
||||
The planner merges two adjacent ranges when the gap between them is at most
|
||||
16 bytes. The default reflects the cost arithmetic on a 240-byte default
|
||||
PDU: an S7 request frame is ~30 bytes and a per-item response header is
|
||||
~12 bytes, so over-fetching 16 bytes (which decode-time discards) is
|
||||
cheaper than paying for one extra PDU round-trip.
|
||||
|
||||
The math also holds for 480/960-byte PDUs but the relative cost flips —
|
||||
on a 960-byte PDU you can fit a much larger request and the over-fetch
|
||||
ceiling is less of a concern. Sites running the extended PDU on S7-1500
|
||||
can safely raise the threshold (see operator guidance below).
|
||||
|
||||
#### Opaque-size opt-out for STRING / array / structured-timestamp tags
|
||||
|
||||
Variable-width and header-prefixed tag types **never** participate in
|
||||
coalescing:
|
||||
|
||||
- **STRING / WSTRING** carry a 2-byte (or 4-byte) length header, and the
|
||||
per-tag width depends on the configured `StringLength`.
|
||||
- **CHAR / WCHAR** are routed through the dedicated `S7StringCodec` decode
|
||||
path, which expects an exact byte slice, not an offset into a larger
|
||||
buffer.
|
||||
- **DTL / DT / S5TIME / TIME / TOD / DATE-as-DateTime** route through
|
||||
`S7DateTimeCodec` for the same reason.
|
||||
- **Arrays** (`ElementCount > 1`) carry a per-tag width of `N × elementBytes`
|
||||
and would silently mis-decode if the slice landed mid-block.
|
||||
|
||||
Each opaque-size tag emits its own standalone `Plc.ReadBytesAsync` call.
|
||||
A STRING in the middle of a contiguous run of DBWs will split the
|
||||
neighbour reads into "before STRING" and "after STRING" merged ranges
|
||||
without straddling the STRING's bytes — verified by the
|
||||
`S7BlockCoalescingPlannerTests` unit suite.
|
||||
|
||||
#### Operator tuning: `BlockCoalescingGapBytes`
|
||||
|
||||
Surface knob in the driver options:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"Host": "10.0.0.50",
|
||||
"Port": 102,
|
||||
"CpuType": "S71500",
|
||||
"BlockCoalescingGapBytes": 16, // default
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Tuning guidance:
|
||||
|
||||
- **Raise the threshold (32-64 bytes)** when the PLC has chatty firmware
|
||||
(S7-1200 with default 240-byte PDU and many DBs scattered every few
|
||||
bytes). One fewer PDU round-trip beats over-fetching a kilobyte.
|
||||
- **Lower the threshold (4-8 bytes)** when DBs are sparsely populated
|
||||
with hot tags far apart — over-fetching dead bytes wastes the PDU
|
||||
envelope and the saved round-trip never materialises.
|
||||
- **Set to 0** to disable gap merging entirely (only literally adjacent
|
||||
ranges with `gap == 0` coalesce). Useful as a debugging knob: if a
|
||||
driver is misreading values you can flip the threshold to 0 to confirm
|
||||
the slice math isn't the culprit.
|
||||
- **Per-DB tuning isn't supported yet** — the knob is global per driver
|
||||
instance. If a site needs different policies for two DBs they live in
|
||||
different drivers (different `Host:Port` rows in the config DB).
|
||||
|
||||
#### Diagnostics counters
|
||||
|
||||
The driver surfaces three coalescing counters via `DriverHealth.Diagnostics`
|
||||
under the standard `<DriverType>.<Counter>` naming convention:
|
||||
|
||||
- `S7.TotalBlockReads` — number of `Plc.ReadBytesAsync` calls issued by
|
||||
the coalesced path. A fully-coalesced contiguous workload bumps this
|
||||
by 1 per `ReadAsync`.
|
||||
- `S7.TotalMultiVarBatches` — `Plc.ReadMultipleVarsAsync` batches issued
|
||||
for residual singletons that didn't merge. With perfect coalescing this
|
||||
stays at 0.
|
||||
- `S7.TotalSingleReads` — per-tag fallbacks (strings, dates, arrays,
|
||||
64-bit ints, anything that bypasses both the coalescer and the packer).
|
||||
|
||||
Observe via the `driver-diagnostics` RPC (`/api/v2/drivers/{id}/diagnostics`)
|
||||
or the Admin UI's per-driver dashboard.
|
||||
|
||||
## References
|
||||
|
||||
1. Siemens Industry Online Support, *Modbus/TCP Communication between SIMATIC S7-1500 / S7-1200 and Modbus/TCP Controllers with Instructions `MB_CLIENT` and `MB_SERVER`*, Entry ID 102020340, V6 (Feb 2021). https://cache.industry.siemens.com/dl/files/340/102020340/att_118119/v6/net_modbus_tcp_s7-1500_s7-1200_en.pdf
|
||||
|
||||
@@ -133,6 +133,7 @@ section to skip it.
|
||||
| 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) |
|
||||
|
||||
@@ -310,6 +310,109 @@ function Test-SubscribeSeesChange {
|
||||
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.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -49,6 +49,17 @@
|
||||
"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",
|
||||
|
||||
@@ -172,6 +172,23 @@ 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 =="
|
||||
|
||||
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 }
|
||||
@@ -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
|
||||
|
||||
@@ -35,8 +35,159 @@ public interface IAddressSpaceBuilder
|
||||
/// <c>_base</c> equipment-class template).
|
||||
/// </summary>
|
||||
void AddProperty(string browseName, DriverDataType dataType, object? value);
|
||||
|
||||
/// <summary>
|
||||
/// Register a type-definition node (ObjectType / VariableType / DataType / ReferenceType)
|
||||
/// mirrored from an upstream OPC UA server. Optional surface — drivers that don't mirror
|
||||
/// types simply never call it; address-space builders that don't materialise upstream
|
||||
/// types can leave the default no-op in place. Default implementation drops the call so
|
||||
/// adding this method doesn't break existing <see cref="IAddressSpaceBuilder"/>
|
||||
/// implementations.
|
||||
/// </summary>
|
||||
/// <param name="info">Metadata describing the type-definition node to mirror.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The OPC UA Client driver is the primary caller — it walks <c>i=86</c>
|
||||
/// (TypesFolder) during <c>DiscoverAsync</c> when
|
||||
/// <c>OpcUaClientDriverOptions.MirrorTypeDefinitions</c> is set so downstream clients
|
||||
/// see the upstream type system instead of rendering structured-type values as opaque
|
||||
/// strings.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The default no-op is intentional — most builders (Galaxy, Modbus, FOCAS, S7,
|
||||
/// TwinCAT, AB-CIP) don't have a meaningful type folder to project into and would
|
||||
/// otherwise need empty-stub overrides.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
void RegisterTypeNode(MirroredTypeNodeInfo info) { /* default: no-op */ }
|
||||
|
||||
/// <summary>
|
||||
/// Register a method node mirrored from an upstream OPC UA server. The method is
|
||||
/// registered as a child of the current builder scope (i.e. the folder representing
|
||||
/// the upstream Object that owns the method). Optional surface — drivers that don't
|
||||
/// mirror methods simply never call it; address-space builders that don't materialise
|
||||
/// method nodes can leave the default no-op in place. Default implementation drops
|
||||
/// the call so adding this method doesn't break existing
|
||||
/// <see cref="IAddressSpaceBuilder"/> implementations.
|
||||
/// </summary>
|
||||
/// <param name="info">Metadata describing the method node, including input/output argument schemas.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The OPC UA Client driver is the primary caller — it picks up
|
||||
/// <c>NodeClass.Method</c> nodes during the <c>HierarchicalReferences</c> browse
|
||||
/// pass, then walks each method's <c>HasProperty</c> references to harvest the
|
||||
/// <c>InputArguments</c> / <c>OutputArguments</c> property values.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The OPC UA server-side <c>DriverNodeManager</c> overrides this to materialize
|
||||
/// a real <c>MethodNode</c> in the local address space and wire its
|
||||
/// <c>OnCallMethod</c> handler to the driver's
|
||||
/// <see cref="IMethodInvoker.CallMethodAsync"/>. Other builders (Galaxy, Modbus,
|
||||
/// FOCAS, S7, TwinCAT, AB-CIP, AB-Legacy) ignore the projection because their
|
||||
/// backends don't expose method nodes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
void RegisterMethodNode(MirroredMethodNodeInfo info) { /* default: no-op */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata describing a single method node mirrored from an upstream OPC UA server.
|
||||
/// Built by the OPC UA Client driver during the discovery browse pass and consumed by
|
||||
/// <see cref="IAddressSpaceBuilder.RegisterMethodNode"/>.
|
||||
/// </summary>
|
||||
/// <param name="BrowseName">OPC UA BrowseName segment from the upstream BrowseName.</param>
|
||||
/// <param name="DisplayName">Human-readable display name; falls back to <paramref name="BrowseName"/>.</param>
|
||||
/// <param name="ObjectNodeId">
|
||||
/// Stringified NodeId of the parent Object that owns this method — the <c>ObjectId</c>
|
||||
/// argument the dispatcher passes back to <see cref="IMethodInvoker.CallMethodAsync"/>.
|
||||
/// </param>
|
||||
/// <param name="MethodNodeId">
|
||||
/// Stringified NodeId of the method node itself — the <c>MethodId</c> argument.
|
||||
/// </param>
|
||||
/// <param name="InputArguments">
|
||||
/// Declaration of the method's input arguments, in order. <c>null</c> or empty when the
|
||||
/// method takes no inputs (or the upstream property couldn't be read).
|
||||
/// </param>
|
||||
/// <param name="OutputArguments">
|
||||
/// Declaration of the method's output arguments, in order. <c>null</c> or empty when the
|
||||
/// method returns no outputs (or the upstream property couldn't be read).
|
||||
/// </param>
|
||||
public sealed record MirroredMethodNodeInfo(
|
||||
string BrowseName,
|
||||
string DisplayName,
|
||||
string ObjectNodeId,
|
||||
string MethodNodeId,
|
||||
IReadOnlyList<MethodArgumentInfo>? InputArguments,
|
||||
IReadOnlyList<MethodArgumentInfo>? OutputArguments);
|
||||
|
||||
/// <summary>
|
||||
/// One row of an OPC UA Argument array — name + data type + array hint. Mirrors the
|
||||
/// <c>Opc.Ua.Argument</c> structure but without the SDK-only types so this DTO can live
|
||||
/// in <c>Core.Abstractions</c>.
|
||||
/// </summary>
|
||||
/// <param name="Name">Argument name from the upstream Argument structure.</param>
|
||||
/// <param name="DriverDataType">
|
||||
/// Mapped local <see cref="DriverDataType"/>. Unknown / structured upstream types fall
|
||||
/// through to <see cref="DriverDataType.String"/> — same convention as variable mirroring.
|
||||
/// </param>
|
||||
/// <param name="ValueRank">
|
||||
/// OPC UA ValueRank: <c>-1</c> = scalar, <c>0</c> = OneOrMoreDimensions, <c>1+</c> = array
|
||||
/// dimensions. Driven directly from the upstream Argument's ValueRank.
|
||||
/// </param>
|
||||
/// <param name="Description">
|
||||
/// Human-readable description from the upstream Argument structure; <c>null</c> when the
|
||||
/// upstream doesn't carry one.
|
||||
/// </param>
|
||||
public sealed record MethodArgumentInfo(
|
||||
string Name,
|
||||
DriverDataType DriverDataType,
|
||||
int ValueRank,
|
||||
string? Description);
|
||||
|
||||
/// <summary>
|
||||
/// Categorises a mirrored type-definition node so the receiving builder can route it into
|
||||
/// the right OPC UA standard subtree (<c>ObjectTypesFolder</c>, <c>VariableTypesFolder</c>,
|
||||
/// <c>DataTypesFolder</c>, <c>ReferenceTypesFolder</c>) when projecting upstream types into
|
||||
/// the local address space.
|
||||
/// </summary>
|
||||
public enum MirroredTypeKind
|
||||
{
|
||||
ObjectType,
|
||||
VariableType,
|
||||
DataType,
|
||||
ReferenceType,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata describing a single type-definition node mirrored from an upstream OPC UA
|
||||
/// server. Built by the OPC UA Client driver during type-mirror pass and consumed by
|
||||
/// <see cref="IAddressSpaceBuilder.RegisterTypeNode"/>.
|
||||
/// </summary>
|
||||
/// <param name="Kind">Type category — drives which standard sub-folder the node lives under.</param>
|
||||
/// <param name="UpstreamNodeId">
|
||||
/// Stringified upstream NodeId (e.g. <c>"ns=2;i=1234"</c>) — preserves the original identity
|
||||
/// so a builder that wants to project the type with a stable cross-namespace reference can do
|
||||
/// so. The driver applies any configured namespace remap before stamping this field.
|
||||
/// </param>
|
||||
/// <param name="BrowseName">OPC UA BrowseName segment from the upstream BrowseName.</param>
|
||||
/// <param name="DisplayName">Human-readable display name; falls back to <paramref name="BrowseName"/>.</param>
|
||||
/// <param name="SuperTypeNodeId">
|
||||
/// Stringified upstream NodeId of the super-type (parent type), or <c>null</c> when the node
|
||||
/// sits directly under the root (e.g. <c>BaseObjectType</c>, <c>BaseVariableType</c>). Lets
|
||||
/// the builder reconstruct the inheritance chain.
|
||||
/// </param>
|
||||
/// <param name="IsAbstract">
|
||||
/// <c>true</c> when the upstream node has the <c>IsAbstract</c> flag set (Object / Variable /
|
||||
/// ReferenceType). DataTypes also expose this — the driver passes it through verbatim.
|
||||
/// </param>
|
||||
public sealed record MirroredTypeNodeInfo(
|
||||
MirroredTypeKind Kind,
|
||||
string UpstreamNodeId,
|
||||
string BrowseName,
|
||||
string DisplayName,
|
||||
string? SuperTypeNodeId,
|
||||
bool IsAbstract);
|
||||
|
||||
/// <summary>Opaque handle for a registered variable. Used by Core for subscription routing.</summary>
|
||||
public interface IVariableHandle
|
||||
{
|
||||
|
||||
27
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverControl.cs
Normal file
27
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverControl.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Optional control-plane capability — drivers whose backend exposes a way to refresh
|
||||
/// the symbol table on-demand (without tearing the driver down) implement this so the
|
||||
/// Admin UI / CLI can trigger a re-walk in response to an operator action.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Distinct from <see cref="IRediscoverable"/>: that interface is the driver telling Core
|
||||
/// a refresh is needed; this one is Core asking the driver to refresh now. For drivers that
|
||||
/// implement both, the typical wiring is "operator clicks Rebrowse → Core calls
|
||||
/// <see cref="RebrowseAsync"/> → driver re-walks → driver fires
|
||||
/// <c>OnRediscoveryNeeded</c> so the address space is rebuilt".
|
||||
///
|
||||
/// For AB CIP this is the "force re-walk of @tags" hook — useful after a controller
|
||||
/// program download added new tags but the static config still drives the address space.
|
||||
/// </remarks>
|
||||
public interface IDriverControl
|
||||
{
|
||||
/// <summary>
|
||||
/// Re-run the driver's discovery pass against live backend state and stream the
|
||||
/// resulting nodes through the supplied builder. Implementations must be safe to call
|
||||
/// concurrently with reads / writes; they typically serialize internally so a second
|
||||
/// concurrent rebrowse waits for the first to complete rather than racing it.
|
||||
/// </summary>
|
||||
Task RebrowseAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken);
|
||||
}
|
||||
82
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IMethodInvoker.cs
Normal file
82
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IMethodInvoker.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Driver capability for invoking OPC UA Methods on the upstream backend (the OPC UA
|
||||
/// <c>Call</c> service). Optional — only drivers whose backends carry method nodes
|
||||
/// implement it. Currently the OPC UA Client driver is the only implementer; tag-based
|
||||
/// drivers (Modbus, S7, FOCAS, Galaxy, AB-CIP, AB-Legacy, TwinCAT) don't expose method
|
||||
/// nodes so they don't need this surface.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #4 (composable capability interfaces) — the
|
||||
/// server-side <c>DriverNodeManager</c> discovers method-bearing drivers via an
|
||||
/// <c>is IMethodInvoker</c> check and routes <c>OnCallMethod</c> handlers to
|
||||
/// <see cref="CallMethodAsync"/>. Drivers that don't implement the interface simply
|
||||
/// never have method nodes registered for them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The address-space mirror is driven by <see cref="IAddressSpaceBuilder.RegisterMethodNode"/>
|
||||
/// — drivers register the method node + its <c>InputArguments</c> /
|
||||
/// <c>OutputArguments</c> properties during discovery, then invocations land back on
|
||||
/// <see cref="CallMethodAsync"/> via the server-side dispatcher.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IMethodInvoker
|
||||
{
|
||||
/// <summary>
|
||||
/// Invoke an upstream OPC UA Method. The driver translates input arguments into the
|
||||
/// wire-level <c>CallMethodRequest</c>, dispatches via the active session, and packs
|
||||
/// the response back into a <see cref="MethodCallResult"/>. Per-argument validation
|
||||
/// errors flow through <see cref="MethodCallResult.InputArgumentResults"/>; method-level
|
||||
/// errors (<c>BadMethodInvalid</c>, <c>BadUserAccessDenied</c>, etc.) flow through
|
||||
/// <see cref="MethodCallResult.StatusCode"/>.
|
||||
/// </summary>
|
||||
/// <param name="objectNodeId">
|
||||
/// Stringified NodeId of the OPC UA Object that owns the method (the <c>ObjectId</c>
|
||||
/// field of <c>CallMethodRequest</c>). Same serialization as <c>IReadable</c>'s
|
||||
/// <c>fullReference</c> — <c>ns=2;s=…</c> / <c>i=…</c> / <c>nsu=…;…</c>.
|
||||
/// </param>
|
||||
/// <param name="methodNodeId">
|
||||
/// Stringified NodeId of the Method node itself (the <c>MethodId</c> field).
|
||||
/// </param>
|
||||
/// <param name="inputs">
|
||||
/// Input arguments in declaration order. The driver wraps each value as a
|
||||
/// <c>Variant</c>; callers pass CLR primitives (plus arrays) — the wire-level
|
||||
/// encoding is the driver's concern.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Per-call cancellation.</param>
|
||||
/// <returns>
|
||||
/// Result of the call — see <see cref="MethodCallResult"/>. Never throws for a
|
||||
/// <c>Bad</c> upstream status; the bad code is surfaced via the result so the caller
|
||||
/// can map it onto an OPC UA service-result for downstream clients.
|
||||
/// </returns>
|
||||
Task<MethodCallResult> CallMethodAsync(
|
||||
string objectNodeId,
|
||||
string methodNodeId,
|
||||
object[] inputs,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a single OPC UA <c>Call</c> service invocation.
|
||||
/// </summary>
|
||||
/// <param name="StatusCode">
|
||||
/// Method-level status. <c>0</c> = Good. Bad codes pass through verbatim from the
|
||||
/// upstream so downstream clients see the canonical OPC UA error (e.g.
|
||||
/// <c>BadMethodInvalid</c>, <c>BadUserAccessDenied</c>, <c>BadArgumentsMissing</c>).
|
||||
/// </param>
|
||||
/// <param name="Outputs">
|
||||
/// Output argument values in declaration order. <c>null</c> when the upstream returned
|
||||
/// no output arguments (or returned a Bad status before producing any).
|
||||
/// </param>
|
||||
/// <param name="InputArgumentResults">
|
||||
/// Per-input-argument status codes. <c>null</c> when the upstream didn't surface
|
||||
/// per-argument validation results (typical for Good calls). Each entry is the OPC UA
|
||||
/// status code for the matching input argument — drivers can use this to surface
|
||||
/// <c>BadTypeMismatch</c>, <c>BadOutOfRange</c>, etc. on a specific argument.
|
||||
/// </param>
|
||||
public sealed record MethodCallResult(
|
||||
uint StatusCode,
|
||||
object[]? Outputs,
|
||||
uint[]? InputArgumentResults);
|
||||
@@ -20,7 +20,29 @@ public interface ISubscribable
|
||||
TimeSpan publishingInterval,
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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,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() },
|
||||
};
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,35 +1,57 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed FOCAS address covering the three addressing spaces a driver touches:
|
||||
/// Parsed FOCAS address covering the four addressing spaces a driver touches:
|
||||
/// <see cref="FocasAreaKind.Pmc"/> (letter + byte + optional bit — <c>X0.0</c>, <c>R100</c>,
|
||||
/// <c>F20.3</c>), <see cref="FocasAreaKind.Parameter"/> (CNC parameter number —
|
||||
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), and <see cref="FocasAreaKind.Macro"/>
|
||||
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>).
|
||||
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), <see cref="FocasAreaKind.Macro"/>
|
||||
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>), and
|
||||
/// <see cref="FocasAreaKind.Diagnostic"/> (CNC diagnostic number, optionally per-axis —
|
||||
/// <c>DIAG:1031</c>, <c>DIAG:280/2</c>) routed through <c>cnc_rddiag</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
|
||||
/// relay), <c>D</c> (data table), <c>C</c> (counter), <c>K</c> (keep relay), <c>A</c>
|
||||
/// (message display), <c>E</c> (extended relay), <c>T</c> (timer). Byte numbering is 0-based;
|
||||
/// bit index when present is 0–7 and uses <c>.N</c> for PMC or <c>/N</c> for parameters.
|
||||
/// Diagnostic addresses reuse the <c>/N</c> form to encode an axis index — <c>BitIndex</c>
|
||||
/// carries the 1-based axis number (0 = whole-CNC diagnostic).
|
||||
/// <para>
|
||||
/// Multi-path / multi-channel CNCs (e.g. lathe + sub-spindle, dual-turret) expose multiple
|
||||
/// "paths"; <see cref="PathId"/> selects which one a given address is read from. Encoded
|
||||
/// as a trailing <c>@N</c> after the address body but before any bit / axis suffix —
|
||||
/// <c>R100@2</c>, <c>PARAM:1815@2</c>, <c>PARAM:1815@2/0</c>, <c>MACRO:500@3</c>,
|
||||
/// <c>DIAG:280@2/1</c>. Defaults to <c>1</c> for back-compat (single-path CNCs).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record FocasAddress(
|
||||
FocasAreaKind Kind,
|
||||
string? PmcLetter,
|
||||
int Number,
|
||||
int? BitIndex)
|
||||
int? BitIndex,
|
||||
int PathId = 1)
|
||||
{
|
||||
public string Canonical => Kind switch
|
||||
public string Canonical
|
||||
{
|
||||
FocasAreaKind.Pmc => BitIndex is null
|
||||
? $"{PmcLetter}{Number}"
|
||||
: $"{PmcLetter}{Number}.{BitIndex}",
|
||||
FocasAreaKind.Parameter => BitIndex is null
|
||||
? $"PARAM:{Number}"
|
||||
: $"PARAM:{Number}/{BitIndex}",
|
||||
FocasAreaKind.Macro => $"MACRO:{Number}",
|
||||
_ => $"?{Number}",
|
||||
};
|
||||
get
|
||||
{
|
||||
var pathSuffix = PathId == 1 ? string.Empty : $"@{PathId}";
|
||||
return Kind switch
|
||||
{
|
||||
FocasAreaKind.Pmc => BitIndex is null
|
||||
? $"{PmcLetter}{Number}{pathSuffix}"
|
||||
: $"{PmcLetter}{Number}{pathSuffix}.{BitIndex}",
|
||||
FocasAreaKind.Parameter => BitIndex is null
|
||||
? $"PARAM:{Number}{pathSuffix}"
|
||||
: $"PARAM:{Number}{pathSuffix}/{BitIndex}",
|
||||
FocasAreaKind.Macro => $"MACRO:{Number}{pathSuffix}",
|
||||
FocasAreaKind.Diagnostic => BitIndex is null or 0
|
||||
? $"DIAG:{Number}{pathSuffix}"
|
||||
: $"DIAG:{Number}{pathSuffix}/{BitIndex}",
|
||||
_ => $"?{Number}",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static FocasAddress? TryParse(string? value)
|
||||
{
|
||||
@@ -42,7 +64,10 @@ public sealed record FocasAddress(
|
||||
if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase))
|
||||
return ParseScoped(src["MACRO:".Length..], FocasAreaKind.Macro, bitSeparator: null);
|
||||
|
||||
// PMC path: letter + digits + optional .bit
|
||||
if (src.StartsWith("DIAG:", StringComparison.OrdinalIgnoreCase))
|
||||
return ParseScoped(src["DIAG:".Length..], FocasAreaKind.Diagnostic, bitSeparator: '/');
|
||||
|
||||
// PMC path: letter + digits + optional @path + optional .bit
|
||||
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
|
||||
var letter = src[0..1].ToUpperInvariant();
|
||||
if (!IsValidPmcLetter(letter)) return null;
|
||||
@@ -57,8 +82,15 @@ public sealed record FocasAddress(
|
||||
bit = bitValue;
|
||||
remainder = remainder[..dotIdx];
|
||||
}
|
||||
var pmcPath = 1;
|
||||
var atIdx = remainder.IndexOf('@');
|
||||
if (atIdx >= 0)
|
||||
{
|
||||
if (!TryParsePathId(remainder[(atIdx + 1)..], out pmcPath)) return null;
|
||||
remainder = remainder[..atIdx];
|
||||
}
|
||||
if (!int.TryParse(remainder, out var number) || number < 0) return null;
|
||||
return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit);
|
||||
return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit, pmcPath);
|
||||
}
|
||||
|
||||
private static FocasAddress? ParseScoped(string body, FocasAreaKind kind, char? bitSeparator)
|
||||
@@ -75,8 +107,30 @@ public sealed record FocasAddress(
|
||||
body = body[..slashIdx];
|
||||
}
|
||||
}
|
||||
// Path suffix (@N) sits between the body number and any bit/axis (which has already
|
||||
// been peeled off above): PARAM:1815@2/0 → body="1815@2", bit=0.
|
||||
var path = 1;
|
||||
var atIdx = body.IndexOf('@');
|
||||
if (atIdx >= 0)
|
||||
{
|
||||
if (!TryParsePathId(body[(atIdx + 1)..], out path)) return null;
|
||||
body = body[..atIdx];
|
||||
}
|
||||
if (!int.TryParse(body, out var number) || number < 0) return null;
|
||||
return new FocasAddress(kind, PmcLetter: null, number, bit);
|
||||
return new FocasAddress(kind, PmcLetter: null, number, bit, path);
|
||||
}
|
||||
|
||||
private static bool TryParsePathId(string text, out int pathId)
|
||||
{
|
||||
// Path 0 is reserved (FOCAS path numbering is 1-based); upper-bound is the FWLIB
|
||||
// ceiling — Fanuc spec lists 10 paths max even on the largest 30i-B configurations.
|
||||
if (int.TryParse(text, out var v) && v is >= 1 and <= 10)
|
||||
{
|
||||
pathId = v;
|
||||
return true;
|
||||
}
|
||||
pathId = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsValidPmcLetter(string letter) => letter switch
|
||||
@@ -92,4 +146,12 @@ public enum FocasAreaKind
|
||||
Pmc,
|
||||
Parameter,
|
||||
Macro,
|
||||
/// <summary>
|
||||
/// CNC diagnostic number routed through <c>cnc_rddiag</c>. <c>DIAG:nnn</c> is a
|
||||
/// whole-CNC diagnostic (axis = 0); <c>DIAG:nnn/axis</c> is per-axis (axis is the
|
||||
/// 1-based FANUC axis index). Like parameters, diagnostics span Int / Float /
|
||||
/// Bit shapes — the driver picks the wire shape based on the configured tag's
|
||||
/// <see cref="FocasDataType"/>.
|
||||
/// </summary>
|
||||
Diagnostic,
|
||||
}
|
||||
|
||||
@@ -32,9 +32,10 @@ public static class FocasCapabilityMatrix
|
||||
|
||||
return address.Kind switch
|
||||
{
|
||||
FocasAreaKind.Macro => ValidateMacro(series, address.Number),
|
||||
FocasAreaKind.Parameter => ValidateParameter(series, address.Number),
|
||||
FocasAreaKind.Pmc => ValidatePmc(series, address.PmcLetter, address.Number),
|
||||
FocasAreaKind.Macro => ValidateMacro(series, address.Number),
|
||||
FocasAreaKind.Parameter => ValidateParameter(series, address.Number),
|
||||
FocasAreaKind.Pmc => ValidatePmc(series, address.PmcLetter, address.Number),
|
||||
FocasAreaKind.Diagnostic => ValidateDiagnostic(series, address.Number),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -73,11 +74,35 @@ public static class FocasCapabilityMatrix
|
||||
_ => (0, int.MaxValue),
|
||||
};
|
||||
|
||||
/// <summary>PMC letters accepted per series. Legacy controllers omit F/M/C
|
||||
/// signal groups that 30i-family ladder programs use.</summary>
|
||||
/// <summary>
|
||||
/// CNC diagnostic number range accepted by a series; from <c>cnc_rddiag</c>
|
||||
/// (and <c>cnc_rddiagdgn</c> for axis-scoped reads). Returning <c>null</c>
|
||||
/// means the series doesn't support <c>cnc_rddiag</c> at all — the driver
|
||||
/// rejects every <c>DIAG:</c> address on that series. Conservative ceilings
|
||||
/// per the FOCAS Developer Kit: legacy 16i-family caps at 499; modern 0i-F
|
||||
/// family at 999; 30i / 31i / 32i extend to 1023. Power Motion i has a
|
||||
/// narrow diagnostic surface (0..255).
|
||||
/// </summary>
|
||||
internal static (int min, int max)? DiagnosticRange(FocasCncSeries series) => series switch
|
||||
{
|
||||
FocasCncSeries.Sixteen_i => (0, 499),
|
||||
FocasCncSeries.Zero_i_D => (0, 499),
|
||||
FocasCncSeries.Zero_i_F or
|
||||
FocasCncSeries.Zero_i_MF or
|
||||
FocasCncSeries.Zero_i_TF => (0, 999),
|
||||
FocasCncSeries.Thirty_i or
|
||||
FocasCncSeries.ThirtyOne_i or
|
||||
FocasCncSeries.ThirtyTwo_i => (0, 1023),
|
||||
FocasCncSeries.PowerMotion_i => (0, 255),
|
||||
_ => (0, int.MaxValue),
|
||||
};
|
||||
|
||||
/// <summary>PMC letters accepted per series. Legacy 16i ladders use X/Y/F/G
|
||||
/// for handshakes plus R/D for retained/data; M/C/E/A/K/T are the 0i-F /
|
||||
/// 30i-family extensions.</summary>
|
||||
internal static IReadOnlySet<string> PmcLetters(FocasCncSeries series) => series switch
|
||||
{
|
||||
FocasCncSeries.Sixteen_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D" },
|
||||
FocasCncSeries.Sixteen_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "F", "G", "R", "D" },
|
||||
FocasCncSeries.Zero_i_D => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D", "E", "A" },
|
||||
FocasCncSeries.Zero_i_F or
|
||||
FocasCncSeries.Zero_i_MF or
|
||||
@@ -106,6 +131,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);
|
||||
@@ -122,6 +168,16 @@ public static class FocasCapabilityMatrix
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? ValidateDiagnostic(FocasCncSeries series, int number)
|
||||
{
|
||||
if (DiagnosticRange(series) is not { } range)
|
||||
return $"Diagnostic addresses are not supported on {series} (no documented cnc_rddiag range).";
|
||||
var (min, max) = range;
|
||||
return (number < min || number > max)
|
||||
? $"Diagnostic #{number} is outside the documented range [{min}, {max}] for {series}."
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? ValidatePmc(FocasCncSeries series, string? letter, int number)
|
||||
{
|
||||
if (string.IsNullOrEmpty(letter)) return "PMC address is missing its letter prefix.";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,22 +11,55 @@ 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
|
||||
/// address string that parses via <see cref="FocasAddress.TryParse"/> —
|
||||
/// <c>X0.0</c> / <c>R100</c> / <c>PARAM:1815/0</c> / <c>MACRO:500</c>.
|
||||
/// <c>X0.0</c> / <c>R100</c> / <c>PARAM:1815/0</c> / <c>MACRO:500</c> /
|
||||
/// <c>DIAG:1031</c> / <c>DIAG:280/2</c>.
|
||||
/// </summary>
|
||||
public sealed record FocasTagDefinition(
|
||||
string Name,
|
||||
|
||||
@@ -59,10 +59,20 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
FocasAreaKind.Pmc => Task.FromResult(ReadPmc(address, type)),
|
||||
FocasAreaKind.Parameter => Task.FromResult(ReadParameter(address, type)),
|
||||
FocasAreaKind.Macro => Task.FromResult(ReadMacro(address)),
|
||||
FocasAreaKind.Diagnostic => Task.FromResult(
|
||||
ReadDiagnostic(address.Number, address.BitIndex ?? 0, type)),
|
||||
_ => Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported)),
|
||||
};
|
||||
}
|
||||
|
||||
public Task<(object? value, uint status)> ReadDiagnosticAsync(
|
||||
int diagNumber, int axisOrZero, FocasDataType type, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadCommunicationError));
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(ReadDiagnostic(diagNumber, axisOrZero, type));
|
||||
}
|
||||
|
||||
public async Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -129,6 +139,26 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
}
|
||||
}
|
||||
|
||||
public Task<int> GetPathCountAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult(1);
|
||||
var buf = new FwlibNative.ODBPATH();
|
||||
var ret = FwlibNative.RdPathNum(_handle, ref buf);
|
||||
// EW_FUNC / EW_NOOPT on single-path controllers — fall back to 1 rather than failing.
|
||||
if (ret != 0 || buf.MaxPath < 1) return Task.FromResult(1);
|
||||
return Task.FromResult((int)buf.MaxPath);
|
||||
}
|
||||
|
||||
public Task SetPathAsync(int pathId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.CompletedTask;
|
||||
var ret = FwlibNative.SetPath(_handle, (short)pathId);
|
||||
if (ret != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"FWLIB cnc_setpath failed with EW_{ret} switching to path {pathId}.");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult(false);
|
||||
@@ -137,6 +167,256 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
return Task.FromResult(ret == 0);
|
||||
}
|
||||
|
||||
public Task<FocasStatusInfo?> GetStatusAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasStatusInfo?>(null);
|
||||
var buf = new FwlibNative.ODBST();
|
||||
var ret = FwlibNative.StatInfo(_handle, ref buf);
|
||||
if (ret != 0) return Task.FromResult<FocasStatusInfo?>(null);
|
||||
return Task.FromResult<FocasStatusInfo?>(new FocasStatusInfo(
|
||||
Dummy: buf.Dummy,
|
||||
Tmmode: buf.TmMode,
|
||||
Aut: buf.Aut,
|
||||
Run: buf.Run,
|
||||
Motion: buf.Motion,
|
||||
Mstb: buf.Mstb,
|
||||
EmergencyStop: buf.Emergency,
|
||||
Alarm: buf.Alarm,
|
||||
Edit: buf.Edit));
|
||||
}
|
||||
|
||||
public Task<FocasProductionInfo?> GetProductionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasProductionInfo?>(null);
|
||||
if (!TryReadInt32Param(6711, out var produced) ||
|
||||
!TryReadInt32Param(6712, out var required) ||
|
||||
!TryReadInt32Param(6713, out var total))
|
||||
{
|
||||
return Task.FromResult<FocasProductionInfo?>(null);
|
||||
}
|
||||
// Cycle-time timer (type=2). Total seconds = minute*60 + msec/1000. Best-effort:
|
||||
// a non-zero return leaves cycle-time at 0 rather than failing the whole snapshot
|
||||
// — the parts counters are still useful even when cycle-time isn't supported.
|
||||
var cycleSeconds = 0;
|
||||
var tmrBuf = new FwlibNative.IODBTMR();
|
||||
if (FwlibNative.RdTimer(_handle, type: 2, ref tmrBuf) == 0)
|
||||
cycleSeconds = checked(tmrBuf.Minute * 60 + tmrBuf.Msec / 1000);
|
||||
return Task.FromResult<FocasProductionInfo?>(new FocasProductionInfo(
|
||||
PartsProduced: produced,
|
||||
PartsRequired: required,
|
||||
PartsTotal: total,
|
||||
CycleTimeSeconds: cycleSeconds));
|
||||
}
|
||||
|
||||
private bool TryReadInt32Param(ushort number, out int value)
|
||||
{
|
||||
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
|
||||
var ret = FwlibNative.RdParam(_handle, number, axis: 0, length: 4 + 4, ref buf);
|
||||
if (ret != 0) { value = 0; return false; }
|
||||
value = BinaryPrimitives.ReadInt32LittleEndian(buf.Data);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryReadInt16Param(ushort number, out short value)
|
||||
{
|
||||
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
|
||||
var ret = FwlibNative.RdParam(_handle, number, axis: 0, length: 4 + 2, ref buf);
|
||||
if (ret != 0) { value = 0; return false; }
|
||||
value = BinaryPrimitives.ReadInt16LittleEndian(buf.Data);
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<FocasModalInfo?> GetModalAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasModalInfo?>(null);
|
||||
// type 100/101/102/103 = M/S/T/B (single auxiliary code, active modal block 0).
|
||||
// Best-effort — if any single read fails we still surface the others as 0; the
|
||||
// probe loop only updates the cache on a non-null return so a partial snapshot
|
||||
// is preferable to throwing away every successful field.
|
||||
return Task.FromResult<FocasModalInfo?>(new FocasModalInfo(
|
||||
MCode: ReadModalAux(type: 100),
|
||||
SCode: ReadModalAux(type: 101),
|
||||
TCode: ReadModalAux(type: 102),
|
||||
BCode: ReadModalAux(type: 103)));
|
||||
}
|
||||
|
||||
private short ReadModalAux(short type)
|
||||
{
|
||||
var buf = new FwlibNative.ODBMDL { Data = new byte[8] };
|
||||
var ret = FwlibNative.Modal(_handle, type, block: 0, ref buf);
|
||||
if (ret != 0) return 0;
|
||||
// For aux types (100..103) the union holds the code at offset 0 as a 2-byte
|
||||
// value (<c>aux_data</c>). Reading as Int16 keeps the surface identical to the
|
||||
// record contract; oversized values would have been truncated by FWLIB anyway.
|
||||
return BinaryPrimitives.ReadInt16LittleEndian(buf.Data);
|
||||
}
|
||||
|
||||
public Task<FocasOverrideInfo?> GetOverrideAsync(
|
||||
FocasOverrideParameters parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasOverrideInfo?>(null);
|
||||
// Each parameter is independently nullable — a null parameter number keeps the
|
||||
// corresponding field at null + skips the wire call. A successful read on at
|
||||
// least one parameter is enough to publish a snapshot; this matches the
|
||||
// best-effort policy used by GetProductionAsync (issue #259).
|
||||
var feed = TryReadOverride(parameters.FeedParam);
|
||||
var rapid = TryReadOverride(parameters.RapidParam);
|
||||
var spindle = TryReadOverride(parameters.SpindleParam);
|
||||
var jog = TryReadOverride(parameters.JogParam);
|
||||
return Task.FromResult<FocasOverrideInfo?>(new FocasOverrideInfo(feed, rapid, spindle, jog));
|
||||
}
|
||||
|
||||
private short? TryReadOverride(ushort? param)
|
||||
{
|
||||
if (param is null) return null;
|
||||
return TryReadInt16Param(param.Value, out var v) ? v : null;
|
||||
}
|
||||
|
||||
public Task<FocasToolingInfo?> GetToolingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasToolingInfo?>(null);
|
||||
var buf = new FwlibNative.IODBTNUM();
|
||||
var ret = FwlibNative.RdToolNumber(_handle, ref buf);
|
||||
if (ret != 0) return Task.FromResult<FocasToolingInfo?>(null);
|
||||
// FWLIB returns long; clamp to short for the surfaced Int16 (T-codes
|
||||
// overflowing 32767 are vanishingly rare on Fanuc tool tables).
|
||||
var t = buf.Data;
|
||||
if (t > short.MaxValue) t = short.MaxValue;
|
||||
else if (t < short.MinValue) t = short.MinValue;
|
||||
return Task.FromResult<FocasToolingInfo?>(new FocasToolingInfo((short)t));
|
||||
}
|
||||
|
||||
public Task<FocasWorkOffsetsInfo?> GetWorkOffsetsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasWorkOffsetsInfo?>(null);
|
||||
|
||||
// 1..6 = G54..G59. Extended G54.1 P1..P48 use cnc_rdzofsr and are deferred.
|
||||
// Pass axis=-1 so FWLIB fills every axis it has; we read the first 3 (X/Y/Z).
|
||||
// Length = 4-byte header + 3 axes * 10-byte OFSB = 34. We request 4 + 8*10 = 84
|
||||
// (the buffer ceiling) so a CNC with more axes still completes the call.
|
||||
var slots = new List<FocasWorkOffset>(6);
|
||||
string[] names = ["G54", "G55", "G56", "G57", "G58", "G59"];
|
||||
for (short n = 1; n <= 6; n++)
|
||||
{
|
||||
var buf = new FwlibNative.IODBZOFS { Data = new byte[80] };
|
||||
var ret = FwlibNative.RdWorkOffset(_handle, n, axis: -1, length: 4 + 8 * 10, ref buf);
|
||||
if (ret != 0)
|
||||
{
|
||||
// Best-effort — a single-slot failure leaves the slot at 0.0; the cache
|
||||
// still publishes so reads on the other offsets serve Good. The probe
|
||||
// loop will retry on the next tick.
|
||||
slots.Add(new FocasWorkOffset(names[n - 1], 0, 0, 0));
|
||||
continue;
|
||||
}
|
||||
slots.Add(new FocasWorkOffset(
|
||||
Name: names[n - 1],
|
||||
X: DecodeOfsbAxis(buf.Data, axisIndex: 0),
|
||||
Y: DecodeOfsbAxis(buf.Data, axisIndex: 1),
|
||||
Z: DecodeOfsbAxis(buf.Data, axisIndex: 2)));
|
||||
}
|
||||
return Task.FromResult<FocasWorkOffsetsInfo?>(new FocasWorkOffsetsInfo(slots));
|
||||
}
|
||||
|
||||
public Task<FocasOperatorMessagesInfo?> GetOperatorMessagesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasOperatorMessagesInfo?>(null);
|
||||
// type 0..3 = OPMSG / MACRO / EXTERN / REJ-EXT (issue #261). Single-slot read
|
||||
// (length 4 + 256 = 260) returns the most-recent message in each class — best-
|
||||
// effort: a single-class failure leaves that class out of the snapshot rather
|
||||
// than failing the whole call, mirroring GetProductionAsync's policy.
|
||||
var list = new List<FocasOperatorMessage>(4);
|
||||
string[] classNames = ["OPMSG", "MACRO", "EXTERN", "REJ-EXT"];
|
||||
for (short t = 0; t < 4; t++)
|
||||
{
|
||||
var buf = new FwlibNative.OPMSG3 { Data = new byte[256] };
|
||||
var ret = FwlibNative.RdOpMsg3(_handle, t, length: 4 + 256, ref buf);
|
||||
if (ret != 0) continue;
|
||||
var text = TrimAnsiPadding(buf.Data);
|
||||
if (string.IsNullOrEmpty(text)) continue;
|
||||
list.Add(new FocasOperatorMessage(buf.Datano, classNames[t], text));
|
||||
}
|
||||
return Task.FromResult<FocasOperatorMessagesInfo?>(new FocasOperatorMessagesInfo(list));
|
||||
}
|
||||
|
||||
public Task<FocasCurrentBlockInfo?> GetCurrentBlockAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasCurrentBlockInfo?>(null);
|
||||
var buf = new FwlibNative.ODBACTPT { Data = new byte[256] };
|
||||
var ret = FwlibNative.RdActPt(_handle, ref buf);
|
||||
if (ret != 0) return Task.FromResult<FocasCurrentBlockInfo?>(null);
|
||||
return Task.FromResult<FocasCurrentBlockInfo?>(
|
||||
new FocasCurrentBlockInfo(TrimAnsiPadding(buf.Data)));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyDictionary<string, int>?> GetFigureScalingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<IReadOnlyDictionary<string, int>?>(null);
|
||||
// kind=0 → position figures (absolute/relative/machine/distance share the same
|
||||
// increment system per axis). cnc_rdaxisname is deferred — the wire impl keys
|
||||
// by fallback "axis{n}" (1-based), the driver re-keys when it gains axis-name
|
||||
// discovery in a follow-up. Issue #262, plan PR F1-f.
|
||||
short count = 0;
|
||||
var buf = new FwlibNative.IODBAXIS { Data = new byte[FwlibNative.MAX_AXIS * 8] };
|
||||
var ret = FwlibNative.GetFigure(_handle, kind: 0, ref count, ref buf);
|
||||
if (ret != 0) return Task.FromResult<IReadOnlyDictionary<string, int>?>(null);
|
||||
return Task.FromResult<IReadOnlyDictionary<string, int>?>(DecodeFigureScaling(buf.Data, count));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode the per-axis decimal-place counts from a <c>cnc_getfigure</c> reply
|
||||
/// buffer. Each axis entry per <c>fwlib32.h</c> is 8 bytes laid out as
|
||||
/// <c>short dec</c> + <c>short unit</c> + 4 reserved bytes; we read only
|
||||
/// <c>dec</c>. Keys are 1-based <c>"axis{n}"</c> placeholders — a follow-up
|
||||
/// PR can rewire to <c>cnc_rdaxisname</c> once that surface lands without
|
||||
/// changing the cache contract (issue #262).
|
||||
/// </summary>
|
||||
internal static IReadOnlyDictionary<string, int> DecodeFigureScaling(byte[] data, short count)
|
||||
{
|
||||
var clamped = Math.Max((short)0, Math.Min(count, (short)FwlibNative.MAX_AXIS));
|
||||
var result = new Dictionary<string, int>(clamped, StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < clamped; i++)
|
||||
{
|
||||
var offset = i * 8;
|
||||
if (offset + 2 > data.Length) break;
|
||||
var dec = BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(offset, 2));
|
||||
if (dec < 0 || dec > 9) dec = 0;
|
||||
result[$"axis{i + 1}"] = dec;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode + trim a Fanuc ANSI byte buffer. The CNC right-pads block text + opmsg
|
||||
/// bodies with nulls or spaces; trim them so the round-trip through the OPC UA
|
||||
/// address space stays stable (issue #261). Stops at the first NUL so any wire
|
||||
/// buffer that gets reused doesn't leak old bytes.
|
||||
/// </summary>
|
||||
internal static string TrimAnsiPadding(byte[] data)
|
||||
{
|
||||
if (data is null) return string.Empty;
|
||||
var len = 0;
|
||||
for (; len < data.Length; len++)
|
||||
if (data[len] == 0) break;
|
||||
return System.Text.Encoding.ASCII.GetString(data, 0, len).TrimEnd(' ', '\0');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode one OFSB axis block from a <c>cnc_rdzofs</c> data buffer. Each axis
|
||||
/// occupies 10 bytes per <c>fwlib32.h</c>: <c>int data</c> + <c>short dec</c> +
|
||||
/// <c>short unit</c> + <c>short disp</c>. The user-facing offset is
|
||||
/// <c>data / 10^dec</c> — same convention as <c>cnc_rdmacro</c>.
|
||||
/// </summary>
|
||||
internal static double DecodeOfsbAxis(byte[] data, int axisIndex)
|
||||
{
|
||||
const int blockSize = 10;
|
||||
var offset = axisIndex * blockSize;
|
||||
if (offset + blockSize > data.Length) return 0;
|
||||
var raw = BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(offset, 4));
|
||||
var dec = BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(offset + 4, 2));
|
||||
if (dec < 0 || dec > 9) dec = 0;
|
||||
return raw / Math.Pow(10.0, dec);
|
||||
}
|
||||
|
||||
// ---- PMC ----
|
||||
|
||||
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)
|
||||
@@ -165,6 +445,42 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
return (value, FocasStatusMapper.Good);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Range read for the PMC coalescer (issue #266). FWLIB's <c>pmc_rdpmcrng</c>
|
||||
/// payload is capped at 40 bytes (the IODBPMC.Data union width), so requested
|
||||
/// ranges larger than that are chunked into 32-byte sub-calls internally —
|
||||
/// callers still see one logical range, which matches the
|
||||
/// <see cref="Wire.FocasPmcCoalescer"/>'s "one wire call per group" semantics.
|
||||
/// </summary>
|
||||
public Task<(byte[]? buffer, uint status)> ReadPmcRangeAsync(
|
||||
string letter, int pathId, int startByte, int byteCount, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<(byte[]?, uint)>((null, FocasStatusMapper.BadCommunicationError));
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (byteCount <= 0) return Task.FromResult<(byte[]?, uint)>((Array.Empty<byte>(), FocasStatusMapper.Good));
|
||||
|
||||
var addrType = FocasPmcAddrType.FromLetter(letter)
|
||||
?? throw new InvalidOperationException($"Unknown PMC letter '{letter}'.");
|
||||
var result = new byte[byteCount];
|
||||
const int chunkBytes = 32;
|
||||
var offset = 0;
|
||||
while (offset < byteCount)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var thisChunk = Math.Min(chunkBytes, byteCount - offset);
|
||||
var buf = new FwlibNative.IODBPMC { Data = new byte[40] };
|
||||
var ret = FwlibNative.PmcRdPmcRng(
|
||||
_handle, addrType, FocasPmcDataType.Byte,
|
||||
(ushort)(startByte + offset),
|
||||
(ushort)(startByte + offset + thisChunk - 1),
|
||||
(ushort)(8 + thisChunk), ref buf);
|
||||
if (ret != 0) return Task.FromResult<(byte[]?, uint)>((null, FocasStatusMapper.MapFocasReturn(ret)));
|
||||
Array.Copy(buf.Data, 0, result, offset, thisChunk);
|
||||
offset += thisChunk;
|
||||
}
|
||||
return Task.FromResult<(byte[]?, uint)>((result, FocasStatusMapper.Good));
|
||||
}
|
||||
|
||||
private uint WritePmc(FocasAddress address, FocasDataType type, object? value)
|
||||
{
|
||||
var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "") ?? (short)0;
|
||||
@@ -217,6 +533,36 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
|
||||
}
|
||||
|
||||
private (object? value, uint status) ReadDiagnostic(int diagNumber, int axisOrZero, FocasDataType type)
|
||||
{
|
||||
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
|
||||
var length = DiagnosticReadLength(type);
|
||||
var ret = FwlibNative.RdDiag(_handle, (ushort)diagNumber, (short)axisOrZero, (short)length, ref buf);
|
||||
if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
|
||||
|
||||
var value = type switch
|
||||
{
|
||||
FocasDataType.Bit => (object)ExtractBit(buf.Data[0], 0),
|
||||
FocasDataType.Byte => (object)(sbyte)buf.Data[0],
|
||||
FocasDataType.Int16 => (object)BinaryPrimitives.ReadInt16LittleEndian(buf.Data),
|
||||
FocasDataType.Int32 => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
|
||||
FocasDataType.Float32 => (object)BinaryPrimitives.ReadSingleLittleEndian(buf.Data),
|
||||
FocasDataType.Float64 => (object)BinaryPrimitives.ReadDoubleLittleEndian(buf.Data),
|
||||
_ => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
|
||||
};
|
||||
return (value, FocasStatusMapper.Good);
|
||||
}
|
||||
|
||||
private static int DiagnosticReadLength(FocasDataType type) => type switch
|
||||
{
|
||||
FocasDataType.Bit or FocasDataType.Byte => 4 + 1,
|
||||
FocasDataType.Int16 => 4 + 2,
|
||||
FocasDataType.Int32 => 4 + 4,
|
||||
FocasDataType.Float32 => 4 + 4,
|
||||
FocasDataType.Float64 => 4 + 8,
|
||||
_ => 4 + 4,
|
||||
};
|
||||
|
||||
private (object? value, uint status) ReadMacro(FocasAddress address)
|
||||
{
|
||||
var buf = new FwlibNative.ODBM();
|
||||
|
||||
@@ -88,6 +88,144 @@ internal static class FwlibNative
|
||||
[DllImport(Library, EntryPoint = "cnc_statinfo", ExactSpelling = true)]
|
||||
public static extern short StatInfo(ushort handle, ref ODBST buffer);
|
||||
|
||||
// ---- Timers ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rdtimer</c> — read CNC running timers. <paramref name="type"/>: 0 = power-on
|
||||
/// time (ms), 1 = operating time (ms), 2 = cycle time (ms), 3 = cutting time (ms).
|
||||
/// Only the cycle-time variant is consumed today (issue #258); the call is generic
|
||||
/// so the surface can grow without another P/Invoke.
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_rdtimer", ExactSpelling = true)]
|
||||
public static extern short RdTimer(ushort handle, short type, ref IODBTMR buffer);
|
||||
|
||||
// ---- Modal codes ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_modal</c> — read modal information for one G-group or auxiliary code.
|
||||
/// <paramref name="type"/>: 1..21 = G-group N (single group), 100 = M, 101 = S,
|
||||
/// 102 = T, 103 = B (per Fanuc FOCAS reference). <paramref name="block"/>: 0 =
|
||||
/// active modal commands. We only consume types 100..103 today (M/S/T/B); the
|
||||
/// G-group decode is deferred to a follow-up because the <c>ODBMDL</c> union
|
||||
/// varies by group + series (issue #259).
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_modal", ExactSpelling = true)]
|
||||
public static extern short Modal(ushort handle, short type, short block, ref ODBMDL buffer);
|
||||
|
||||
// ---- Tooling ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rdtnum</c> — read the currently selected tool number. Returns
|
||||
/// <c>EW_OK</c> + populates <see cref="IODBTNUM.Data"/> with the active T-code.
|
||||
/// Tool life + current offset index reads (<c>cnc_rdtlinfo</c>/<c>cnc_rdtlsts</c>/
|
||||
/// <c>cnc_rdtofs</c>) are deferred per the F1-d plan — those calls use ODBTLIFE*
|
||||
/// unions whose shape varies per series.
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_rdtnum", ExactSpelling = true)]
|
||||
public static extern short RdToolNumber(ushort handle, ref IODBTNUM buffer);
|
||||
|
||||
// ---- Work coordinate offsets ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rdzofs</c> — read one work-coordinate offset slot. <paramref name="number"/>:
|
||||
/// 1..6 = G54..G59 (standard). Extended <c>G54.1 P1..P48</c> use <c>cnc_rdzofsr</c>
|
||||
/// and are deferred. <paramref name="axis"/>: -1 = all axes returned, 1..N = single
|
||||
/// axis. <paramref name="length"/>: 12 + (N axes * 8) — we request -1 and let FWLIB
|
||||
/// fill up to <see cref="IODBZOFS.Data"/>'s 8-axis ceiling.
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_rdzofs", ExactSpelling = true)]
|
||||
public static extern short RdWorkOffset(
|
||||
ushort handle,
|
||||
short number,
|
||||
short axis,
|
||||
short length,
|
||||
ref IODBZOFS buffer);
|
||||
|
||||
// ---- Operator messages ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rdopmsg3</c> — read FANUC operator messages by class. <paramref name="type"/>:
|
||||
/// 0 = OPMSG (op-msg ladder/macro), 1 = MACRO, 2 = EXTERN (external operator message),
|
||||
/// 3 = REJ-EXT (rejected EXTERN). <paramref name="length"/>: per <c>fwlib32.h</c> the
|
||||
/// buffer is <c>4 + 256 = 260</c> bytes per message slot — single-slot reads (length 260)
|
||||
/// return the most-recent message in that class. Issue #261, plan PR F1-e.
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_rdopmsg3", CharSet = CharSet.Ansi, ExactSpelling = true)]
|
||||
public static extern short RdOpMsg3(
|
||||
ushort handle,
|
||||
short type,
|
||||
short length,
|
||||
ref OPMSG3 buffer);
|
||||
|
||||
// ---- Figure (per-axis decimal scaling) ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_getfigure</c> — read per-axis figure info (decimal-place counts + units).
|
||||
/// <paramref name="kind"/>: 0 = absolute / relative / machine position figures,
|
||||
/// 1 = work-coord shift figures (per Fanuc reference). The reply struct holds
|
||||
/// up to <see cref="MAX_AXIS"/> axis entries; the managed side reads the count
|
||||
/// out via <paramref name="outCount"/>. Position values from <c>cnc_absolute</c>
|
||||
/// / <c>cnc_machine</c> / <c>cnc_relative</c> / <c>cnc_distance</c> / <c>cnc_actf</c>
|
||||
/// are scaled integers — divide by <c>10^figureinfo[axis].dec</c> for user units
|
||||
/// (issue #262, plan PR F1-f).
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_getfigure", ExactSpelling = true)]
|
||||
public static extern short GetFigure(
|
||||
ushort handle,
|
||||
short kind,
|
||||
ref short outCount,
|
||||
ref IODBAXIS figureinfo);
|
||||
|
||||
// ---- Diagnostics ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rddiag</c> — read a CNC diagnostic value. <paramref name="number"/> is the
|
||||
/// diagnostic number (e.g. 1031 = current alarm cause); <paramref name="axis"/> is 0
|
||||
/// for whole-CNC diagnostics or the 1-based axis index for per-axis diagnostics.
|
||||
/// <paramref name="length"/> is sized like <see cref="RdParam"/> — 4-byte header +
|
||||
/// widest payload (8 bytes for Float64). The shape of the payload depends on the
|
||||
/// diagnostic; the managed side decodes via <see cref="FocasDataType"/> on the
|
||||
/// configured tag (issue #263).
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_rddiag", ExactSpelling = true)]
|
||||
public static extern short RdDiag(
|
||||
ushort handle,
|
||||
ushort number,
|
||||
short axis,
|
||||
short length,
|
||||
ref IODBPSD buffer);
|
||||
|
||||
// ---- Multi-path / multi-channel ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rdpathnum</c> — read the number of CNC paths (channels) the controller
|
||||
/// exposes + the currently-active path. Multi-path CNCs (lathe + sub-spindle,
|
||||
/// dual-turret) return 2..N; single-path CNCs return 1. The driver caches
|
||||
/// <see cref="ODBPATH.MaxPath"/> at connect and uses it to validate per-tag
|
||||
/// <c>PathId</c> values (issue #264).
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_rdpathnum", ExactSpelling = true)]
|
||||
public static extern short RdPathNum(ushort handle, ref ODBPATH buffer);
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_setpath</c> — switch the active CNC path (channel) for subsequent
|
||||
/// calls. <paramref name="path"/> is 1-based. The driver issues this before
|
||||
/// every read whose path differs from the last one set on the session;
|
||||
/// single-path tags (PathId=1 only) skip the call entirely (issue #264).
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_setpath", ExactSpelling = true)]
|
||||
public static extern short SetPath(ushort handle, short path);
|
||||
|
||||
// ---- Currently-executing block ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rdactpt</c> — read the currently-executing program block text. The
|
||||
/// reply struct holds the program / sequence numbers + the active block as a
|
||||
/// null-padded ASCII string. Issue #261, plan PR F1-e.
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_rdactpt", CharSet = CharSet.Ansi, ExactSpelling = true)]
|
||||
public static extern short RdActPt(ushort handle, ref ODBACTPT buffer);
|
||||
|
||||
// ---- Structs ----
|
||||
|
||||
/// <summary>
|
||||
@@ -129,6 +267,134 @@ internal static class FwlibNative
|
||||
public short DecVal; // decimal-point count
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IODBTMR — running-timer read buffer per <c>fwlib32.h</c>. Minute portion in
|
||||
/// <see cref="Minute"/>; sub-minute remainder in milliseconds in <see cref="Msec"/>.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct IODBTMR
|
||||
{
|
||||
public int Minute;
|
||||
public int Msec;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ODBMDL — single-group modal read buffer. 4-byte header + a 4-byte union which we
|
||||
/// marshal as a fixed byte array. For type=100..103 (M/S/T/B) the union holds an
|
||||
/// <c>int aux_data</c> at offset 0; we read the first <c>short</c> for symmetry with
|
||||
/// the FWLIB <c>g_modal.aux_data</c> width on G-group reads. The G-group decode
|
||||
/// (type=1..21) is deferred — see <see cref="Modal"/> for context (issue #259).
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBMDL
|
||||
{
|
||||
public short Datano;
|
||||
public short Type;
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 8)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IODBTNUM — current tool number read buffer. <see cref="Data"/> holds the active
|
||||
/// T-code (Fanuc reference uses <c>long</c>; we narrow to <c>short</c> on the
|
||||
/// managed side because <see cref="FocasToolingInfo.CurrentTool"/> surfaces as
|
||||
/// <c>Int16</c>). Issue #260, F1-d.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct IODBTNUM
|
||||
{
|
||||
public short Datano;
|
||||
public short Type;
|
||||
public int Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IODBZOFS — work-coordinate offset read buffer. 4-byte header + per-axis
|
||||
/// <c>OFSB</c> blocks (8 bytes each: 4-byte signed integer <c>data</c> + 2-byte
|
||||
/// <c>dec</c> decimal-point count + 2-byte <c>unit</c> + 2-byte <c>disp</c>).
|
||||
/// We marshal a fixed ceiling of 8 axes (= 64 bytes); the managed side reads
|
||||
/// only the first 3 (X / Y / Z) per the F1-d effort sizing.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct IODBZOFS
|
||||
{
|
||||
public short Datano;
|
||||
public short Type;
|
||||
// Up to 8 axes * 8 bytes per OFSB = 64 bytes. Each block: int data, short dec,
|
||||
// short unit, short disp (10 bytes per fwlib32.h). We size for the worst case.
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 80)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OPMSG3 — single-slot operator-message read buffer per <c>fwlib32.h</c>. Per Fanuc
|
||||
/// reference: <c>short datano</c> + <c>short type</c> + <c>char data[256]</c>. The
|
||||
/// text is null-terminated + space-padded; the managed side trims trailing nulls /
|
||||
/// spaces before publishing. Length = 4 + 256 = 260 bytes; total 256 wide enough
|
||||
/// for the longest documented operator message body (issue #261).
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct OPMSG3
|
||||
{
|
||||
public short Datano;
|
||||
public short Type;
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 256)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ODBACTPT — current-block read buffer per <c>fwlib32.h</c>. Per Fanuc reference:
|
||||
/// <c>long o_no</c> (currently active O-number) + <c>long n_no</c> (sequence) +
|
||||
/// <c>char data[256]</c> (active block text). The text is null-terminated +
|
||||
/// space-padded; trimmed before publishing for stable round-trip (issue #261).
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBACTPT
|
||||
{
|
||||
public int ONo;
|
||||
public int NNo;
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 256)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maximum axis count per the FWLIB <c>fwlib32.h</c> ceiling for figure-info reads.
|
||||
/// Real Fanuc CNCs cap at 8 simultaneous axes for most series; we marshal an
|
||||
/// 8-entry array (matches <see cref="IODBAXIS"/>) so the call completes regardless
|
||||
/// of the deployment's axis count (issue #262).
|
||||
/// </summary>
|
||||
public const int MAX_AXIS = 8;
|
||||
|
||||
/// <summary>
|
||||
/// IODBAXIS — per-axis figure info read buffer for <c>cnc_getfigure</c>. Each
|
||||
/// axis entry carries the decimal-place count (<c>dec</c>) the CNC reports for
|
||||
/// that axis's increment system + a unit code. The managed side reads the first
|
||||
/// <c>outCount</c> entries returned by FWLIB; we marshal a fixed 8-entry ceiling
|
||||
/// (issue #262, plan PR F1-f).
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct IODBAXIS
|
||||
{
|
||||
// Each entry per fwlib32.h is { short dec, short unit, short reserved, short reserved2 }
|
||||
// = 8 bytes. 8 axes * 8 bytes = 64 bytes; we marshal a fixed byte buffer + decode on
|
||||
// the managed side so axis-count growth doesn't churn the P/Invoke surface.
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 8 * 8)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ODBPATH — <c>cnc_rdpathnum</c> reply. <see cref="PathNo"/> is the currently-active
|
||||
/// path (1-based); <see cref="MaxPath"/> is the controller's path count. We consume
|
||||
/// <see cref="MaxPath"/> at bootstrap to validate per-tag PathId; runtime path
|
||||
/// selection happens via <see cref="SetPath"/> (issue #264).
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBPATH
|
||||
{
|
||||
public short PathNo;
|
||||
public short MaxPath;
|
||||
}
|
||||
|
||||
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBST
|
||||
|
||||
@@ -48,8 +48,311 @@ public interface IFocasClient : IDisposable
|
||||
/// responds with any valid status.
|
||||
/// </summary>
|
||||
Task<bool> ProbeAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read the full <c>cnc_rdcncstat</c> ODBST struct (9 small-int status flags). The
|
||||
/// boolean <see cref="ProbeAsync"/> is preserved for cheap reachability checks; this
|
||||
/// method exposes the per-field detail used by the FOCAS driver's <c>Status/</c>
|
||||
/// fixed-tree nodes (see issue #257). Returns <c>null</c> if the wire client cannot
|
||||
/// supply the struct (e.g. transport/IPC variant where the contract has not been
|
||||
/// extended yet) — callers fall back to surfacing Bad on the per-field nodes.
|
||||
/// </summary>
|
||||
Task<FocasStatusInfo?> GetStatusAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasStatusInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the per-CNC production counters (parts produced / required / total via
|
||||
/// <c>cnc_rdparam(6711/6712/6713)</c>) plus the current cycle-time seconds counter
|
||||
/// (<c>cnc_rdtimer(2)</c>). Surfaced on the FOCAS driver's <c>Production/</c>
|
||||
/// fixed-tree per device (issue #258). Returns <c>null</c> when the wire client
|
||||
/// cannot supply the snapshot (e.g. older transport variant) — the driver leaves
|
||||
/// the cache untouched and the per-field nodes report Bad until the first refresh.
|
||||
/// </summary>
|
||||
Task<FocasProductionInfo?> GetProductionAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasProductionInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the active modal M/S/T/B codes via <c>cnc_modal</c>. G-group decoding is
|
||||
/// deferred — the FWLIB <c>ODBMDL</c> union differs per series + group and the
|
||||
/// issue body permits surfacing only the universally-present M/S/T/B fields in
|
||||
/// the first cut (issue #259). Returns <c>null</c> when the wire client cannot
|
||||
/// supply the snapshot.
|
||||
/// </summary>
|
||||
Task<FocasModalInfo?> GetModalAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasModalInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the four operator override values (feed / rapid / spindle / jog) via
|
||||
/// <c>cnc_rdparam</c>. The parameter numbers are MTB-specific so the caller passes
|
||||
/// them in via <paramref name="parameters"/>; a <c>null</c> entry suppresses that
|
||||
/// field's read (the corresponding node is also omitted from the address space).
|
||||
/// Returns <c>null</c> when the wire client cannot supply the snapshot (issue #259).
|
||||
/// </summary>
|
||||
Task<FocasOverrideInfo?> GetOverrideAsync(
|
||||
FocasOverrideParameters parameters, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasOverrideInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the current tool number via <c>cnc_rdtnum</c>. Surfaced on the FOCAS driver's
|
||||
/// <c>Tooling/</c> fixed-tree per device (issue #260). Tool life + current offset
|
||||
/// index are deferred — <c>cnc_rdtlinfo</c>/<c>cnc_rdtlsts</c> vary heavily across
|
||||
/// CNC series + the FWLIB <c>ODBTLIFE*</c> unions need per-series shape handling
|
||||
/// that exceeds the L-sized scope of this PR. Returns <c>null</c> when the wire
|
||||
/// client cannot supply the snapshot (e.g. older transport variant).
|
||||
/// </summary>
|
||||
Task<FocasToolingInfo?> GetToolingAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasToolingInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the standard G54..G59 work-coordinate offsets via
|
||||
/// <c>cnc_rdzofs(handle, n=1..6)</c>. Returns one <see cref="FocasWorkOffset"/>
|
||||
/// per slot (issue #260). Extended G54.1 P1..P48 offsets are deferred — they use
|
||||
/// a different FOCAS call (<c>cnc_rdzofsr</c>) + different range handling. Each
|
||||
/// offset surfaces a fixed X/Y/Z view; lathes/mills with extra rotational axes
|
||||
/// have those columns reported as 0.0. Returns <c>null</c> when the wire client
|
||||
/// cannot supply the snapshot.
|
||||
/// </summary>
|
||||
Task<FocasWorkOffsetsInfo?> GetWorkOffsetsAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasWorkOffsetsInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the four FANUC operator-message classes via <c>cnc_rdopmsg3</c> (issue #261).
|
||||
/// The call returns up to 4 active messages per class; the driver collapses the
|
||||
/// latest non-empty message per class onto the <c>Messages/External/Latest</c>
|
||||
/// fixed-tree node — the issue body permits this minimal surface in the first cut.
|
||||
/// Trailing nulls / spaces are trimmed before publishing so the same message
|
||||
/// round-trips with stable text. Returns <c>null</c> when the wire client cannot
|
||||
/// supply the snapshot (older transport variant).
|
||||
/// </summary>
|
||||
Task<FocasOperatorMessagesInfo?> GetOperatorMessagesAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasOperatorMessagesInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the currently-executing block text via <c>cnc_rdactpt</c> (issue #261).
|
||||
/// The call returns the active block of the running program; surfaced as
|
||||
/// <c>Program/CurrentBlock</c> Float-trimmed string. Returns <c>null</c> when the
|
||||
/// wire client cannot supply the snapshot.
|
||||
/// </summary>
|
||||
Task<FocasCurrentBlockInfo?> GetCurrentBlockAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasCurrentBlockInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the per-axis decimal-place counts via <c>cnc_getfigure</c> (issue #262).
|
||||
/// Returned dictionary maps axis name (or fallback <c>"axis{n}"</c> when
|
||||
/// <c>cnc_rdaxisname</c> isn't available) to the decimal-place count the CNC
|
||||
/// reports for that axis's increment system. Cached at bootstrap by the driver +
|
||||
/// applied to position values before publishing — raw integer / 10^decimalPlaces.
|
||||
/// Returns <c>null</c> when the wire client cannot supply the snapshot (older
|
||||
/// transport variant) — the driver leaves the cache untouched and falls back to
|
||||
/// publishing raw values.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, int>?> GetFigureScalingAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyDictionary<string, int>?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read a CNC diagnostic value via <c>cnc_rddiag</c>. <paramref name="diagNumber"/> is
|
||||
/// the diagnostic number (validated against <see cref="FocasCapabilityMatrix.DiagnosticRange"/>
|
||||
/// by <see cref="FocasDriver.InitializeAsync"/>). <paramref name="axisOrZero"/>
|
||||
/// is 0 for whole-CNC diagnostics or the 1-based axis index for per-axis diagnostics.
|
||||
/// The shape of the returned value depends on the diagnostic — Int / Float / Bit are
|
||||
/// all possible. Returns <c>null</c> on default (transport variants that haven't yet
|
||||
/// implemented diagnostics) so the driver falls back to BadNotSupported on those nodes
|
||||
/// until the wire client is extended (issue #263).
|
||||
/// </summary>
|
||||
Task<(object? value, uint status)> ReadDiagnosticAsync(
|
||||
int diagNumber, int axisOrZero, FocasDataType type, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported));
|
||||
|
||||
/// <summary>
|
||||
/// Discover the number of CNC paths (channels) the controller exposes via
|
||||
/// <c>cnc_rdpathnum</c>. Multi-path CNCs (lathe + sub-spindle, dual-turret,
|
||||
/// etc.) report 2..N; single-path CNCs return 1. The driver caches the result
|
||||
/// once per device after connect + uses it to validate per-tag <c>PathId</c>
|
||||
/// values (issue #264). Default returns 1 so transports that haven't extended
|
||||
/// their wire surface keep behaving as single-path.
|
||||
/// </summary>
|
||||
Task<int> GetPathCountAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(1);
|
||||
|
||||
/// <summary>
|
||||
/// Switch the active CNC path (channel) for subsequent reads via
|
||||
/// <c>cnc_setpath</c>. Called by the driver before every read whose
|
||||
/// <c>FocasAddress.PathId</c> differs from the path most recently set on the
|
||||
/// session — single-path devices (PathId=1 only) skip the wire call entirely.
|
||||
/// Default is a no-op so transports that haven't extended their wire surface
|
||||
/// simply read whatever path the CNC has selected (issue #264).
|
||||
/// </summary>
|
||||
Task SetPathAsync(int pathId, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Read a contiguous range of PMC bytes in a single wire call (FOCAS
|
||||
/// <c>pmc_rdpmcrng</c> with byte data type) for the given <paramref name="letter"/>
|
||||
/// (<c>R</c>, <c>D</c>, <c>X</c>, etc.) starting at <paramref name="startByte"/> and
|
||||
/// spanning <paramref name="byteCount"/> bytes. Returned tuple has the byte buffer
|
||||
/// (length <paramref name="byteCount"/> on success) + the OPC UA status mapped through
|
||||
/// <see cref="FocasStatusMapper"/>. Used by <see cref="FocasDriver"/> to coalesce
|
||||
/// same-letter/same-path PMC reads in a batch into one round trip per range
|
||||
/// (issue #266 — see <see cref="Wire.FocasPmcCoalescer"/>).
|
||||
/// <para>
|
||||
/// Default falls back to per-byte <see cref="ReadAsync(FocasAddress, FocasDataType, CancellationToken)"/>
|
||||
/// calls so transport variants that haven't extended their wire surface still work
|
||||
/// correctly — they just won't see the round-trip reduction. The fallback short-circuits
|
||||
/// on the first non-Good status so a partial buffer isn't returned with a Good code.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
async Task<(byte[]? buffer, uint status)> ReadPmcRangeAsync(
|
||||
string letter, int pathId, int startByte, int byteCount, CancellationToken cancellationToken)
|
||||
{
|
||||
if (byteCount <= 0) return (Array.Empty<byte>(), FocasStatusMapper.Good);
|
||||
var buf = new byte[byteCount];
|
||||
for (var i = 0; i < byteCount; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var addr = new FocasAddress(FocasAreaKind.Pmc, letter, startByte + i, BitIndex: null, PathId: pathId);
|
||||
var (value, status) = await ReadAsync(addr, FocasDataType.Byte, cancellationToken).ConfigureAwait(false);
|
||||
if (status != FocasStatusMapper.Good) return (null, status);
|
||||
buf[i] = value switch
|
||||
{
|
||||
sbyte s => unchecked((byte)s),
|
||||
byte b => b,
|
||||
int n => unchecked((byte)n),
|
||||
short s => unchecked((byte)s),
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
return (buf, FocasStatusMapper.Good);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the 9 fields returned by Fanuc's <c>cnc_rdcncstat</c> (ODBST). All fields
|
||||
/// are <c>short</c> per the FWLIB header — small enums whose meaning is documented in the
|
||||
/// Fanuc FOCAS reference (e.g. <c>emergency</c>: 0=released, 1=stop, 2=reset). Surfaced as
|
||||
/// <c>Int16</c> in the OPC UA address space rather than mapped enums so operators see
|
||||
/// exactly what the CNC reported.
|
||||
/// </summary>
|
||||
public sealed record FocasStatusInfo(
|
||||
short Dummy,
|
||||
short Tmmode,
|
||||
short Aut,
|
||||
short Run,
|
||||
short Motion,
|
||||
short Mstb,
|
||||
short EmergencyStop,
|
||||
short Alarm,
|
||||
short Edit);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of per-CNC production counters refreshed on the probe tick (issue #258).
|
||||
/// Sourced from <c>cnc_rdparam(6711/6712/6713)</c> for the parts counts + the cycle-time
|
||||
/// timer counter (FWLIB <c>cnc_rdtimer</c> when available). All values surfaced as
|
||||
/// <c>Int32</c> in the OPC UA address space.
|
||||
/// </summary>
|
||||
public sealed record FocasProductionInfo(
|
||||
int PartsProduced,
|
||||
int PartsRequired,
|
||||
int PartsTotal,
|
||||
int CycleTimeSeconds);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the active modal M/S/T/B codes (issue #259). G-group decoding is a
|
||||
/// deferred follow-up — the FWLIB <c>ODBMDL</c> union differs per series + group, and
|
||||
/// the issue body permits the first cut to surface only the universally-present
|
||||
/// M/S/T/B fields. <c>short</c> matches the FWLIB <c>aux_data</c> width.
|
||||
/// </summary>
|
||||
public sealed record FocasModalInfo(
|
||||
short MCode,
|
||||
short SCode,
|
||||
short TCode,
|
||||
short BCode);
|
||||
|
||||
/// <summary>
|
||||
/// MTB-specific FOCAS parameter numbers for the four operator overrides (issue #259).
|
||||
/// Defaults match Fanuc 30i — Feed=6010, Rapid=6011, Spindle=6014, Jog=6015. A
|
||||
/// <c>null</c> entry suppresses that field's read on the wire and removes the matching
|
||||
/// node from the address space; this lets a deployment hide overrides their MTB doesn't
|
||||
/// wire up rather than always serving Bad.
|
||||
/// </summary>
|
||||
public sealed record FocasOverrideParameters(
|
||||
ushort? FeedParam,
|
||||
ushort? RapidParam,
|
||||
ushort? SpindleParam,
|
||||
ushort? JogParam)
|
||||
{
|
||||
/// <summary>Stock 30i defaults — Feed=6010, Rapid=6011, Spindle=6014, Jog=6015.</summary>
|
||||
public static FocasOverrideParameters Default { get; } = new(6010, 6011, 6014, 6015);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the four operator overrides (issue #259). Each value is a percentage
|
||||
/// surfaced as <c>Int16</c>; a value of <c>null</c> means the corresponding parameter
|
||||
/// was not configured (suppressed at <see cref="FocasOverrideParameters"/>). All four
|
||||
/// fields nullable so the driver can omit nodes whose MTB parameter is unset.
|
||||
/// </summary>
|
||||
public sealed record FocasOverrideInfo(
|
||||
short? Feed,
|
||||
short? Rapid,
|
||||
short? Spindle,
|
||||
short? Jog);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the currently selected tool number (issue #260). Sourced from
|
||||
/// <c>cnc_rdtnum</c>. The active offset index is deferred — most modern CNCs
|
||||
/// interleave tool number and offset H/D codes through different FOCAS calls
|
||||
/// (<c>cnc_rdtofs</c> against a specific slot) and the issue body permits
|
||||
/// surfacing tool number alone in the first cut. Surfaced as <c>Int16</c> in
|
||||
/// the OPC UA address space.
|
||||
/// </summary>
|
||||
public sealed record FocasToolingInfo(short CurrentTool);
|
||||
|
||||
/// <summary>
|
||||
/// One work-coordinate offset slot (G54..G59). Three axis columns are surfaced
|
||||
/// (X / Y / Z) — the issue body permits a fixed 3-axis view because lathes and
|
||||
/// mills typically don't expose extended rotational offsets via the standard
|
||||
/// <c>cnc_rdzofs</c> call. Extended <c>G54.1 Pn</c> offsets via <c>cnc_rdzofsr</c>
|
||||
/// are deferred to a follow-up PR. Values surfaced as <c>Float64</c> in microns
|
||||
/// converted to user units (the FWLIB <c>data</c> field is an integer + decimal-
|
||||
/// point count, decoded the same way <c>cnc_rdmacro</c> values are).
|
||||
/// </summary>
|
||||
public sealed record FocasWorkOffset(string Name, double X, double Y, double Z);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the six standard work-coordinate offsets (G54..G59). Refreshed on
|
||||
/// the probe tick + served from the per-device cache by reads of the
|
||||
/// <c>Offsets/{name}/{X|Y|Z}</c> fixed-tree nodes (issue #260).
|
||||
/// </summary>
|
||||
public sealed record FocasWorkOffsetsInfo(IReadOnlyList<FocasWorkOffset> Offsets);
|
||||
|
||||
/// <summary>
|
||||
/// One FANUC operator message — the <see cref="Number"/> + <see cref="Class"/>
|
||||
/// + <see cref="Text"/> tuple returned by <c>cnc_rdopmsg3</c> for a single
|
||||
/// active message slot. <see cref="Class"/> is one of <c>"OPMSG"</c> /
|
||||
/// <c>"MACRO"</c> / <c>"EXTERN"</c> / <c>"REJ-EXT"</c> per the FOCAS reference
|
||||
/// for the four message types. <see cref="Text"/> is trimmed of trailing
|
||||
/// nulls + spaces so round-trips through the OPC UA address space stay stable
|
||||
/// (issue #261).
|
||||
/// </summary>
|
||||
public sealed record FocasOperatorMessage(short Number, string Class, string Text);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of all active FANUC operator messages across the four message
|
||||
/// classes (issue #261). Surfaced under the FOCAS driver's
|
||||
/// <c>Messages/External/Latest</c> fixed-tree node — the latest non-empty
|
||||
/// message in the list is what gets published. Empty list means the CNC
|
||||
/// reported no active messages; the node publishes an empty string in that
|
||||
/// case.
|
||||
/// </summary>
|
||||
public sealed record FocasOperatorMessagesInfo(IReadOnlyList<FocasOperatorMessage> Messages);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the currently-executing program block text via
|
||||
/// <c>cnc_rdactpt</c> (issue #261). <see cref="Text"/> is trimmed of trailing
|
||||
/// nulls + spaces so the same block round-trips with stable text. Surfaced
|
||||
/// as a String node at <c>Program/CurrentBlock</c>.
|
||||
/// </summary>
|
||||
public sealed record FocasCurrentBlockInfo(string Text);
|
||||
|
||||
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
|
||||
public interface IFocasClientFactory
|
||||
{
|
||||
|
||||
152
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasPmcCoalescer.cs
Normal file
152
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasPmcCoalescer.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
/// <summary>
|
||||
/// One PMC byte-level read request a caller wants to satisfy. <see cref="ByteWidth"/> is
|
||||
/// how many consecutive PMC bytes the caller's tag occupies (e.g. Bit/Byte = 1, Int16 = 2,
|
||||
/// Int32/Float32 = 4, Float64 = 8). <see cref="OriginalIndex"/> is the caller's row index
|
||||
/// in the batch — the coalescer carries it through to the planned group so the driver can
|
||||
/// fan-out the slice back to the original snapshot slot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Bit-addressed PMC tags (e.g. <c>R100.3</c>) supply their parent byte (<c>100</c>) +
|
||||
/// <c>ByteWidth = 1</c>; the slice-then-mask happens in the existing decode path, so
|
||||
/// the coalescer doesn't need to know about the bit index.
|
||||
/// </remarks>
|
||||
public sealed record PmcAddressRequest(
|
||||
string Letter,
|
||||
int PathId,
|
||||
int ByteNumber,
|
||||
int ByteWidth,
|
||||
int OriginalIndex);
|
||||
|
||||
/// <summary>
|
||||
/// One member of a coalesced PMC range — the original index + the byte offset within
|
||||
/// the planned range buffer where the member's bytes start. The caller slices
|
||||
/// <c>buffer[Offset .. Offset + ByteWidth]</c> to recover the per-tag wire-shape bytes.
|
||||
/// </summary>
|
||||
public sealed record PmcRangeMember(int OriginalIndex, int Offset, int ByteWidth);
|
||||
|
||||
/// <summary>
|
||||
/// One coalesced PMC range — a single FOCAS <c>pmc_rdpmcrng</c> wire call satisfies
|
||||
/// every member. <see cref="ByteCount"/> is bounded by <see cref="FocasPmcCoalescer.MaxRangeBytes"/>.
|
||||
/// </summary>
|
||||
public sealed record PmcRangeGroup(
|
||||
string Letter,
|
||||
int PathId,
|
||||
int StartByte,
|
||||
int ByteCount,
|
||||
IReadOnlyList<PmcRangeMember> Members);
|
||||
|
||||
/// <summary>
|
||||
/// Plans one or more coalesced PMC range reads from a flat batch of per-tag requests.
|
||||
/// Same-letter / same-path requests whose byte ranges overlap or whose gap is no larger
|
||||
/// than <see cref="MaxBridgeGap"/> are merged into a single wire call up to a
|
||||
/// <see cref="MaxRangeBytes"/> cap (issue #266).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The cap matches the conservative ceiling Fanuc spec lists for
|
||||
/// <c>pmc_rdpmcrng</c> — most controllers accept larger ranges but 256 is the lowest
|
||||
/// common denominator across 0i / 16i / 30i firmware. Splitting on the cap is fine —
|
||||
/// each partition still saves N-1 round trips relative to per-byte reads.</para>
|
||||
///
|
||||
/// <para>The <see cref="MaxBridgeGap"/> = 16 mirrors the Modbus coalescer's bridge
|
||||
/// policy: small gaps are cheaper to over-read (one extra wire call vs. several short
|
||||
/// ones) but unbounded bridging would pull large unused regions over the wire on sparse
|
||||
/// PMC layouts.</para>
|
||||
/// </remarks>
|
||||
public static class FocasPmcCoalescer
|
||||
{
|
||||
/// <summary>Maximum bytes per coalesced range — conservative ceiling for older Fanuc firmware.</summary>
|
||||
public const int MaxRangeBytes = 256;
|
||||
|
||||
/// <summary>Maximum gap (in bytes) bridged between consecutive sub-requests within a group.</summary>
|
||||
public const int MaxBridgeGap = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Plan range reads from <paramref name="addresses"/>. Group key is
|
||||
/// <c>(Letter, PathId)</c>. Within a group, requests are sorted by start byte then
|
||||
/// greedily packed into ranges that respect <see cref="MaxRangeBytes"/> +
|
||||
/// <see cref="MaxBridgeGap"/>.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PmcRangeGroup> Plan(IEnumerable<PmcAddressRequest> addresses)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(addresses);
|
||||
|
||||
var groups = new List<PmcRangeGroup>();
|
||||
var byKey = addresses
|
||||
.Where(a => !string.IsNullOrEmpty(a.Letter) && a.ByteWidth > 0 && a.ByteNumber >= 0)
|
||||
.GroupBy(a => (Letter: a.Letter.ToUpperInvariant(), a.PathId));
|
||||
|
||||
foreach (var key in byKey)
|
||||
{
|
||||
var sorted = key.OrderBy(a => a.ByteNumber).ThenBy(a => a.OriginalIndex).ToList();
|
||||
var pending = new List<PmcAddressRequest>();
|
||||
var rangeStart = -1;
|
||||
var rangeEnd = -1;
|
||||
|
||||
void Flush()
|
||||
{
|
||||
if (pending.Count == 0) return;
|
||||
var members = pending.Select(p =>
|
||||
new PmcRangeMember(p.OriginalIndex, p.ByteNumber - rangeStart, p.ByteWidth)).ToList();
|
||||
groups.Add(new PmcRangeGroup(key.Key.Letter, key.Key.PathId, rangeStart,
|
||||
rangeEnd - rangeStart + 1, members));
|
||||
pending.Clear();
|
||||
rangeStart = -1;
|
||||
rangeEnd = -1;
|
||||
}
|
||||
|
||||
foreach (var req in sorted)
|
||||
{
|
||||
var reqStart = req.ByteNumber;
|
||||
var reqEnd = req.ByteNumber + req.ByteWidth - 1;
|
||||
if (pending.Count == 0)
|
||||
{
|
||||
rangeStart = reqStart;
|
||||
rangeEnd = reqEnd;
|
||||
pending.Add(req);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bridge if the gap between the existing range end + this request's start is
|
||||
// within the bridge cap. Overlapping or contiguous ranges always bridge
|
||||
// (gap <= 0). The cap is enforced on the projected union: extending the range
|
||||
// must not exceed MaxRangeBytes from rangeStart.
|
||||
var gap = reqStart - rangeEnd - 1;
|
||||
var projectedEnd = Math.Max(rangeEnd, reqEnd);
|
||||
var projectedSize = projectedEnd - rangeStart + 1;
|
||||
if (gap <= MaxBridgeGap && projectedSize <= MaxRangeBytes)
|
||||
{
|
||||
rangeEnd = projectedEnd;
|
||||
pending.Add(req);
|
||||
}
|
||||
else
|
||||
{
|
||||
Flush();
|
||||
rangeStart = reqStart;
|
||||
rangeEnd = reqEnd;
|
||||
pending.Add(req);
|
||||
}
|
||||
}
|
||||
|
||||
Flush();
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The number of consecutive PMC bytes a tag of <paramref name="type"/> occupies on
|
||||
/// the wire. Used by the driver to populate <see cref="PmcAddressRequest.ByteWidth"/>
|
||||
/// before planning. Bit-addressed tags supply <c>1</c> here — the bit-extract happens
|
||||
/// in the decode path after the slice.
|
||||
/// </summary>
|
||||
public static int ByteWidth(FocasDataType type) => type switch
|
||||
{
|
||||
FocasDataType.Bit or FocasDataType.Byte => 1,
|
||||
FocasDataType.Int16 => 2,
|
||||
FocasDataType.Int32 or FocasDataType.Float32 => 4,
|
||||
FocasDataType.Float64 => 8,
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// Per-driver counters surfaced via <see cref="Core.Abstractions.DriverHealth.Diagnostics"/>
|
||||
/// for the <c>driver-diagnostics</c> RPC (task #276). Hot-path increments use
|
||||
/// <see cref="Interlocked"/> so they're lock-free; the read path snapshots into a
|
||||
/// <see cref="IReadOnlyDictionary{TKey, TValue}"/> keyed by stable counter names.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The counters are operational metrics, not config — they reset to zero when the
|
||||
/// driver instance is recreated (Reinitialize tear-down + rebuild) and there is no
|
||||
/// persistence across process restarts. NotificationsPerSecond is a simple decay-EWMA
|
||||
/// so a quiet subscription doesn't latch the value at the last burst rate.
|
||||
/// </remarks>
|
||||
internal sealed class OpcUaClientDiagnostics
|
||||
{
|
||||
// ---- Hot-path counters (Interlocked) ----
|
||||
|
||||
private long _publishRequestCount;
|
||||
private long _notificationCount;
|
||||
private long _missingPublishRequestCount;
|
||||
private long _droppedNotificationCount;
|
||||
private long _sessionResetCount;
|
||||
|
||||
// ---- EWMA state for NotificationsPerSecond ----
|
||||
//
|
||||
// Use ticks (long) for the timestamp so we can swap atomically. The rate is a double
|
||||
// updated under a tight lock — the EWMA arithmetic (load, blend, store) isn't naturally
|
||||
// atomic on doubles, and the spinlock is held only for arithmetic so contention is
|
||||
// bounded. A subscription firing at 10 kHz with one driver instance is dominated by
|
||||
// the SDK's notification path, not this lock.
|
||||
private readonly object _ewmaLock = new();
|
||||
private double _notificationsPerSecond;
|
||||
private long _lastNotificationTicks;
|
||||
|
||||
/// <summary>Half-life ~5 seconds — recent activity dominates but a paused subscription decays toward zero.</summary>
|
||||
private static readonly TimeSpan EwmaHalfLife = TimeSpan.FromSeconds(5);
|
||||
|
||||
// ---- Reconnect state (lock-free, single-writer in OnReconnectComplete) ----
|
||||
private long _lastReconnectUtcTicks;
|
||||
|
||||
public long PublishRequestCount => Interlocked.Read(ref _publishRequestCount);
|
||||
public long NotificationCount => Interlocked.Read(ref _notificationCount);
|
||||
public long MissingPublishRequestCount => Interlocked.Read(ref _missingPublishRequestCount);
|
||||
public long DroppedNotificationCount => Interlocked.Read(ref _droppedNotificationCount);
|
||||
public long SessionResetCount => Interlocked.Read(ref _sessionResetCount);
|
||||
|
||||
public DateTime? LastReconnectUtc
|
||||
{
|
||||
get
|
||||
{
|
||||
var ticks = Interlocked.Read(ref _lastReconnectUtcTicks);
|
||||
return ticks == 0 ? null : new DateTime(ticks, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
|
||||
public double NotificationsPerSecond
|
||||
{
|
||||
get { lock (_ewmaLock) return _notificationsPerSecond; }
|
||||
}
|
||||
|
||||
public void IncrementPublishRequest() => Interlocked.Increment(ref _publishRequestCount);
|
||||
|
||||
public void IncrementMissingPublishRequest() => Interlocked.Increment(ref _missingPublishRequestCount);
|
||||
|
||||
public void IncrementDroppedNotification() => Interlocked.Increment(ref _droppedNotificationCount);
|
||||
|
||||
/// <summary>Records one delivered notification (any monitored item) + folds the inter-arrival into the EWMA rate.</summary>
|
||||
public void RecordNotification() => RecordNotification(DateTime.UtcNow);
|
||||
|
||||
internal void RecordNotification(DateTime nowUtc)
|
||||
{
|
||||
Interlocked.Increment(ref _notificationCount);
|
||||
|
||||
// EWMA over instantaneous rate. instRate = 1 / dt (events per second since last sample).
|
||||
// Decay factor a = 2^(-dt/halfLife) puts a five-second window on the smoothing — recent
|
||||
// bursts win, idle periods bleed back to zero.
|
||||
var nowTicks = nowUtc.Ticks;
|
||||
lock (_ewmaLock)
|
||||
{
|
||||
if (_lastNotificationTicks == 0)
|
||||
{
|
||||
_lastNotificationTicks = nowTicks;
|
||||
// First sample: seed at 0 — we don't know the prior rate. The next sample
|
||||
// produces a real instRate.
|
||||
return;
|
||||
}
|
||||
var dtTicks = nowTicks - _lastNotificationTicks;
|
||||
if (dtTicks <= 0)
|
||||
{
|
||||
// Same-tick collisions on bursts: treat as no time elapsed for rate purposes
|
||||
// (count was already incremented above) so we don't divide by zero or feed
|
||||
// an absurd instRate spike.
|
||||
return;
|
||||
}
|
||||
var dtSeconds = (double)dtTicks / TimeSpan.TicksPerSecond;
|
||||
var instRate = 1.0 / dtSeconds;
|
||||
var alpha = System.Math.Pow(0.5, dtSeconds / EwmaHalfLife.TotalSeconds);
|
||||
_notificationsPerSecond = (alpha * _notificationsPerSecond) + ((1.0 - alpha) * instRate);
|
||||
_lastNotificationTicks = nowTicks;
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordSessionReset(DateTime nowUtc)
|
||||
{
|
||||
Interlocked.Increment(ref _sessionResetCount);
|
||||
Interlocked.Exchange(ref _lastReconnectUtcTicks, nowUtc.Ticks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot the counters into the dictionary shape <see cref="Core.Abstractions.DriverHealth.Diagnostics"/>
|
||||
/// surfaces. Numeric-only (so the RPC can render generically); LastReconnectUtc is
|
||||
/// emitted as ticks to keep the value type uniform.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, double> Snapshot()
|
||||
{
|
||||
var dict = new Dictionary<string, double>(7, System.StringComparer.Ordinal)
|
||||
{
|
||||
["PublishRequestCount"] = PublishRequestCount,
|
||||
["NotificationCount"] = NotificationCount,
|
||||
["NotificationsPerSecond"] = NotificationsPerSecond,
|
||||
["MissingPublishRequestCount"] = MissingPublishRequestCount,
|
||||
["DroppedNotificationCount"] = DroppedNotificationCount,
|
||||
["SessionResetCount"] = SessionResetCount,
|
||||
};
|
||||
var last = LastReconnectUtc;
|
||||
if (last is not null)
|
||||
dict["LastReconnectUtcTicks"] = last.Value.Ticks;
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,34 @@ public sealed class OpcUaClientDriverOptions
|
||||
/// </summary>
|
||||
public TimeSpan PerEndpointConnectTimeout { get; init; } = TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <summary>
|
||||
/// Optional discovery URL pointing at a Local Discovery Server (LDS) or a server's
|
||||
/// own discovery endpoint. When set, the driver runs <c>FindServers</c> +
|
||||
/// <c>GetEndpoints</c> against this URL during <see cref="OpcUaClientDriver.InitializeAsync"/>
|
||||
/// and prepends the discovered endpoint URLs to the failover candidate list. When
|
||||
/// <see cref="EndpointUrls"/> is empty (and only <see cref="EndpointUrl"/> is set as
|
||||
/// a fallback), the discovered URLs replace the candidate list entirely so a
|
||||
/// discovery-driven deployment can be configured without specifying any endpoints
|
||||
/// up front. Discovery failures are non-fatal — the driver logs and falls back to the
|
||||
/// statically configured candidates.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>FindServers requires SecurityMode=None on the discovery channel</b> per the
|
||||
/// OPC UA spec — discovery is unauthenticated even when the data channel uses
|
||||
/// <c>Sign</c> or <c>SignAndEncrypt</c>. The driver opens the discovery channel
|
||||
/// unsecured regardless of <see cref="SecurityMode"/>; only the resulting data
|
||||
/// session is bound to the configured policy.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Endpoints returned by discovery are filtered to those matching
|
||||
/// <see cref="SecurityPolicy"/> + <see cref="SecurityMode"/> before being added to
|
||||
/// the candidate list, so a discovery sweep against a multi-policy server only
|
||||
/// surfaces endpoints the driver could actually connect to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string? DiscoveryUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Security policy to require when selecting an endpoint. Either a
|
||||
/// <see cref="OpcUaSecurityPolicy"/> enum constant or a free-form string (for
|
||||
@@ -134,8 +162,198 @@ public sealed class OpcUaClientDriverOptions
|
||||
/// browse forever.
|
||||
/// </summary>
|
||||
public int MaxBrowseDepth { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Per-subscription tuning knobs applied when the driver creates data + alarm
|
||||
/// subscriptions on the upstream session. Defaults preserve the previous hard-coded
|
||||
/// values so existing deployments see no behaviour change.
|
||||
/// </summary>
|
||||
public OpcUaSubscriptionDefaults Subscriptions { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Server-certificate validation knobs applied during the
|
||||
/// <c>CertificateValidator.CertificateValidation</c> callback. Surfaces explicit
|
||||
/// handling for revoked certs (always rejected, never auto-accepted), unknown
|
||||
/// revocation status (rejected only when <see cref="OpcUaCertificateValidationOptions.RejectUnknownRevocationStatus"/>
|
||||
/// is set), SHA-1 signature rejection, and minimum RSA key size. Defaults preserve
|
||||
/// existing behaviour wherever possible — the one tightening is
|
||||
/// <see cref="OpcUaCertificateValidationOptions.RejectSHA1SignedCertificates"/>=true
|
||||
/// since SHA-1 is spec-deprecated for OPC UA.
|
||||
/// </summary>
|
||||
public OpcUaCertificateValidationOptions CertificateValidation { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Curation rules applied to the upstream address space during
|
||||
/// <c>DiscoverAsync</c>. Lets operators trim the mirrored tree to the subset their
|
||||
/// downstream clients actually need, rename namespace URIs so the local-side metadata
|
||||
/// stays consistent across upstream-server swaps, and override the default
|
||||
/// <c>"Remote"</c> root folder name. Defaults are empty / null which preserves the
|
||||
/// pre-curation behaviour exactly — empty include = include all.
|
||||
/// </summary>
|
||||
public OpcUaClientCurationOptions Curation { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, <c>DiscoverAsync</c> runs an additional pass that walks the upstream
|
||||
/// <c>TypesFolder</c> (<c>i=86</c>) — ObjectTypes (<c>i=88</c>), VariableTypes
|
||||
/// (<c>i=89</c>), DataTypes (<c>i=90</c>), ReferenceTypes (<c>i=91</c>) — and projects the
|
||||
/// discovered type-definition nodes into the local address space via
|
||||
/// <c>IAddressSpaceBuilder.RegisterTypeNode</c>. Default <c>false</c> — opt-in so
|
||||
/// existing deployments don't suddenly see a flood of type nodes after upgrade. Enable
|
||||
/// when downstream clients need the upstream type system to render structured values or
|
||||
/// decode custom event fields.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The type-mirror pass uses <c>Session.FetchTypeTreeAsync</c> on each of the four
|
||||
/// root type nodes so the SDK's local TypeTree cache is populated efficiently (one
|
||||
/// batched browse per root rather than per-node round trips). This PR ships the
|
||||
/// <i>structural</i> mirror only — every type node is registered with its identity,
|
||||
/// super-type chain, and IsAbstract flag, but structured-type binary encodings are
|
||||
/// NOT primed. (The OPCFoundation SDK removed
|
||||
/// <c>ISession.LoadDataTypeSystem(NodeId, CancellationToken)</c> from the public
|
||||
/// surface in 1.5.378+; loading binary encodings now requires per-node walks of
|
||||
/// <c>HasEncoding</c> + dictionary nodes which is tracked as a follow-up.) Clients
|
||||
/// that need structured-type decoding can still consume
|
||||
/// <c>Variant<ExtensionObject></c> on the wire.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="OpcUaClientCurationOptions.IncludePaths"/> +
|
||||
/// <see cref="OpcUaClientCurationOptions.ExcludePaths"/> still apply to the type
|
||||
/// walk; paths are slash-joined under their root (e.g.
|
||||
/// <c>"ObjectTypes/BaseObjectType/SomeType"</c>). Most operators want all types so
|
||||
/// empty include = include all is the right default.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool MirrorTypeDefinitions { get; init; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selective import + namespace remap rules for the OPC UA Client driver. Pure local
|
||||
/// filtering inside <c>BrowseRecursiveAsync</c> + <c>EnrichAndRegisterVariablesAsync</c>;
|
||||
/// no new SDK calls.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Glob semantics</b>: patterns are matched against the slash-joined BrowseName
|
||||
/// segments accumulated during the browse pass (e.g. <c>"Server/Diagnostics/SessionsDiagnosticsArray"</c>).
|
||||
/// Two wildcards are supported — <c>*</c> matches any sequence of characters
|
||||
/// (including empty / slashes) and <c>?</c> matches exactly one character. No
|
||||
/// character classes, no <c>**</c>, no escapes — keep the surface tight so the doc
|
||||
/// + behaviour stay simple.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Empty <see cref="IncludePaths"/> = include all (existing behaviour).
|
||||
/// <see cref="ExcludePaths"/> wins over <see cref="IncludePaths"/> when both match.
|
||||
/// Folders pruned by the rules are skipped wholesale — their descendants don't get
|
||||
/// browsed, which keeps the wire cost down on large servers.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="IncludePaths">
|
||||
/// Glob patterns matched against the BrowsePath segment list. Empty = include all
|
||||
/// (default — preserves pre-curation behaviour).
|
||||
/// </param>
|
||||
/// <param name="ExcludePaths">
|
||||
/// Glob patterns matched against the BrowsePath segment list. Wins over
|
||||
/// <see cref="IncludePaths"/> — useful for "include everything under <c>Plant/*</c>
|
||||
/// except <c>Plant/Diagnostics</c>" rules.
|
||||
/// </param>
|
||||
/// <param name="NamespaceRemap">
|
||||
/// Upstream-namespace-URI → local-namespace-URI translation table applied to the
|
||||
/// <c>FullName</c> field of <c>DriverAttributeInfo</c> when registering variables.
|
||||
/// The driver's stored <c>FullName</c> swaps the prefix before persisting so downstream
|
||||
/// clients see the remapped URI. Lookup is case-sensitive — match the upstream URI
|
||||
/// exactly. Defaults to empty (no remap).
|
||||
/// </param>
|
||||
/// <param name="RootAlias">
|
||||
/// Replaces the default <c>"Remote"</c> folder name at the top of the mirrored tree.
|
||||
/// Useful when multiple OPC UA Client drivers are aggregated and operators need to
|
||||
/// distinguish them in the local browse tree. Default <c>null</c> = use <c>"Remote"</c>.
|
||||
/// </param>
|
||||
public sealed record OpcUaClientCurationOptions(
|
||||
IReadOnlyList<string>? IncludePaths = null,
|
||||
IReadOnlyList<string>? ExcludePaths = null,
|
||||
IReadOnlyDictionary<string, string>? NamespaceRemap = null,
|
||||
string? RootAlias = null);
|
||||
|
||||
/// <summary>
|
||||
/// Knobs governing the server-certificate validation callback. Plumbed onto
|
||||
/// <see cref="OpcUaClientDriverOptions.CertificateValidation"/> rather than the top-level
|
||||
/// options to keep cert-related config grouped together.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>CRL discovery:</b> the OPC UA SDK reads CRL files automatically from the
|
||||
/// <c>crl/</c> sub-directory of each cert store (own, trusted, issuers). Drop the
|
||||
/// issuer's <c>.crl</c> in that folder and the SDK picks it up — no driver-side wiring
|
||||
/// required. When the directory is absent or empty, the SDK reports
|
||||
/// <c>BadCertificateRevocationUnknown</c>, which this driver gates with
|
||||
/// <see cref="RejectUnknownRevocationStatus"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="RejectSHA1SignedCertificates">
|
||||
/// Reject server certificates whose signature uses SHA-1. Default <c>true</c> — SHA-1 was
|
||||
/// deprecated by the OPC UA spec and is treated as a hard fail in production. Flip to
|
||||
/// <c>false</c> only for short-term interop with legacy controllers.
|
||||
/// </param>
|
||||
/// <param name="RejectUnknownRevocationStatus">
|
||||
/// When the SDK can't determine revocation status (no CRL present, or stale CRL),
|
||||
/// reject the cert if <c>true</c>; allow if <c>false</c>. Default <c>false</c> — many
|
||||
/// plant deployments don't run CRL infrastructure, and a hard-fail default would break
|
||||
/// them on first connection. Set <c>true</c> in environments with a managed PKI.
|
||||
/// </param>
|
||||
/// <param name="MinimumCertificateKeySize">
|
||||
/// Minimum RSA key size (bits) accepted. Certs with shorter keys are rejected. Default
|
||||
/// <c>2048</c> matches the current OPC UA spec floor; raise to 3072 or 4096 for stricter
|
||||
/// deployments. Non-RSA keys (ECC) bypass this check.
|
||||
/// </param>
|
||||
public sealed record OpcUaCertificateValidationOptions(
|
||||
bool RejectSHA1SignedCertificates = true,
|
||||
bool RejectUnknownRevocationStatus = false,
|
||||
int MinimumCertificateKeySize = 2048);
|
||||
|
||||
/// <summary>
|
||||
/// Tuning surface for OPC UA subscriptions created by <see cref="OpcUaClientDriver"/>.
|
||||
/// Lifted from the per-call hard-coded literals so operators can tune publish cadence,
|
||||
/// keep-alive ratio, and alarm-vs-data prioritisation without recompiling the driver.
|
||||
/// Defaults match the original hard-coded values (KeepAlive=10, Lifetime=1000,
|
||||
/// MaxNotifications=0 unlimited, Priority=0, MinPublishingInterval=50ms).
|
||||
/// </summary>
|
||||
/// <param name="KeepAliveCount">
|
||||
/// Number of consecutive empty publish cycles before the server sends a keep-alive
|
||||
/// response. Default 10 — high enough to suppress idle traffic, low enough that the
|
||||
/// client notices a stalled subscription within ~5x the publish interval.
|
||||
/// </param>
|
||||
/// <param name="LifetimeCount">
|
||||
/// Number of consecutive missed publish responses before the server tears down the
|
||||
/// subscription. Must be ≥3×<see cref="KeepAliveCount"/> per OPC UA spec; default 1000
|
||||
/// gives ~100 keep-alives of slack which is conservative on flaky networks.
|
||||
/// </param>
|
||||
/// <param name="MaxNotificationsPerPublish">
|
||||
/// Cap on notifications returned per publish response. <c>0</c> = unlimited (the OPC UA
|
||||
/// spec sentinel). Lower this to bound publish-message size on bursty servers.
|
||||
/// </param>
|
||||
/// <param name="Priority">
|
||||
/// Subscription priority for data subscriptions (0..255). Higher = scheduled ahead of
|
||||
/// lower. Default 0 matches the SDK's default for ordinary tag subscriptions.
|
||||
/// </param>
|
||||
/// <param name="MinPublishingIntervalMs">
|
||||
/// Floor (ms) applied to <c>publishingInterval</c> requests. Sub-floor values are
|
||||
/// clamped up so wire-side negotiations don't waste round-trips on intervals the server
|
||||
/// will only round up anyway. Default 50ms.
|
||||
/// </param>
|
||||
/// <param name="AlarmsPriority">
|
||||
/// Subscription priority for the alarm subscription (0..255). Higher than
|
||||
/// <see cref="Priority"/> by default (1 vs 0) so alarm publishes aren't starved during
|
||||
/// data-tag bursts.
|
||||
/// </param>
|
||||
public sealed record OpcUaSubscriptionDefaults(
|
||||
int KeepAliveCount = 10,
|
||||
uint LifetimeCount = 1000,
|
||||
uint MaxNotificationsPerPublish = 0,
|
||||
byte Priority = 0,
|
||||
int MinPublishingIntervalMs = 50,
|
||||
byte AlarmsPriority = 1);
|
||||
|
||||
/// <summary>OPC UA message security mode.</summary>
|
||||
public enum OpcUaSecurityMode
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using S7NetCpuType = global::S7.Net.CpuType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
@@ -26,10 +28,12 @@ public enum S7Size
|
||||
Byte, // B
|
||||
Word, // W — 16-bit
|
||||
DWord, // D — 32-bit
|
||||
LWord, // LD / DBL — 64-bit (LInt/ULInt/LReal). S7.Net has no native size suffix; the
|
||||
// driver issues an 8-byte ReadBytes and converts big-endian in-process.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed form of an S7 tag-address string. Produced by <see cref="S7AddressParser.Parse"/>.
|
||||
/// Parsed form of an S7 tag-address string. Produced by <see cref="S7AddressParser.Parse(string)"/>.
|
||||
/// </summary>
|
||||
/// <param name="Area">Memory area (DB, M, I, Q, T, C).</param>
|
||||
/// <param name="DbNumber">Data block number; only meaningful when <paramref name="Area"/> is <see cref="S7Area.DataBlock"/>.</param>
|
||||
@@ -48,9 +52,12 @@ public readonly record struct S7ParsedAddress(
|
||||
/// Siemens TIA-Portal / STEP 7 Classic syntax documented in <c>docs/v2/driver-specs.md</c> §5:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>DB{n}.DB{X|B|W|D}{offset}[.bit]</c> — e.g. <c>DB1.DBX0.0</c>, <c>DB1.DBW0</c>, <c>DB1.DBD4</c></item>
|
||||
/// <item><c>DB{n}.{DBLD|DBL}{offset}</c> — 64-bit (LInt / ULInt / LReal) e.g. <c>DB1.DBLD0</c>, <c>DB1.DBL8</c></item>
|
||||
/// <item><c>M{B|W|D}{offset}</c> or <c>M{offset}.{bit}</c> — e.g. <c>MB0</c>, <c>MW0</c>, <c>MD4</c>, <c>M0.0</c></item>
|
||||
/// <item><c>M{LD}{offset}</c> — 64-bit Merker, e.g. <c>MLD0</c></item>
|
||||
/// <item><c>I{B|W|D}{offset}</c> or <c>I{offset}.{bit}</c> — e.g. <c>IB0</c>, <c>IW0</c>, <c>ID0</c>, <c>I0.0</c></item>
|
||||
/// <item><c>Q{B|W|D}{offset}</c> or <c>Q{offset}.{bit}</c> — e.g. <c>QB0</c>, <c>QW0</c>, <c>QD0</c>, <c>Q0.0</c></item>
|
||||
/// <item><c>I{LD}{offset}</c> / <c>Q{LD}{offset}</c> — 64-bit Input/Output, e.g. <c>ILD0</c>, <c>QLD0</c></item>
|
||||
/// <item><c>T{n}</c> — e.g. <c>T0</c>, <c>T15</c></item>
|
||||
/// <item><c>C{n}</c> — e.g. <c>C0</c>, <c>C10</c></item>
|
||||
/// </list>
|
||||
@@ -69,7 +76,29 @@ public static class S7AddressParser
|
||||
/// the offending input echoed in the message so operators can correlate to the tag
|
||||
/// config that produced the fault.
|
||||
/// </summary>
|
||||
public static S7ParsedAddress Parse(string address)
|
||||
/// <remarks>
|
||||
/// The CPU-agnostic overload rejects the <c>V</c> area letter; <c>V</c> is only
|
||||
/// meaningful on S7-200 / S7-200 Smart / LOGO! where it maps to a fixed DB number
|
||||
/// (DB1 by convention) — call <see cref="Parse(string, S7NetCpuType?)"/> with the
|
||||
/// device's CPU family for V-memory tags.
|
||||
/// </remarks>
|
||||
public static S7ParsedAddress Parse(string address) => Parse(address, cpuType: null);
|
||||
|
||||
/// <summary>
|
||||
/// Parse an S7 address with knowledge of the device's CPU family. Required for the
|
||||
/// <c>V</c> area letter (S7-200 / S7-200 Smart / LOGO! V-memory), which maps to
|
||||
/// DataBlock DB1 on those families. On S7-300 / S7-400 / S7-1200 / S7-1500 the
|
||||
/// <c>V</c> letter is rejected because it has no equivalent — those families use
|
||||
/// explicit <c>DB{n}.DB...</c> addressing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// LOGO! firmware bands map V-memory to different underlying DB numbers in some
|
||||
/// 0BA editions; the driver currently uses DB1 (the most common LOGO! 8 / 0BA8
|
||||
/// mapping). If a future site ships a firmware band where VM lives in a different
|
||||
/// DB, the mapping table in <see cref="VMemoryDbNumberFor"/> is the single point
|
||||
/// to extend. Live LOGO! testing is out of scope for the initial PR.
|
||||
/// </remarks>
|
||||
public static S7ParsedAddress Parse(string address, S7NetCpuType? cpuType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(address))
|
||||
throw new FormatException("S7 address must not be empty");
|
||||
@@ -92,21 +121,28 @@ public static class S7AddressParser
|
||||
case 'Q': return ParseMIQ(S7Area.Output, rest, address);
|
||||
case 'T': return ParseTimerOrCounter(S7Area.Timer, rest, address);
|
||||
case 'C': return ParseTimerOrCounter(S7Area.Counter, rest, address);
|
||||
case 'V': return ParseV(rest, address, cpuType);
|
||||
default:
|
||||
throw new FormatException($"S7 address '{address}' starts with unknown area '{areaChar}' (expected DB/M/I/Q/T/C)");
|
||||
throw new FormatException($"S7 address '{address}' starts with unknown area '{areaChar}' (expected DB/M/I/Q/T/C/V)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try-parse variant for callers that can't afford an exception on bad input (e.g.
|
||||
/// config validation pages in the Admin UI). Returns <c>false</c> for any input that
|
||||
/// would throw from <see cref="Parse"/>.
|
||||
/// would throw from <see cref="Parse(string)"/>.
|
||||
/// </summary>
|
||||
public static bool TryParse(string address, out S7ParsedAddress result)
|
||||
=> TryParse(address, cpuType: null, out result);
|
||||
|
||||
/// <summary>
|
||||
/// Try-parse variant that accepts a CPU family for V-memory addressing.
|
||||
/// </summary>
|
||||
public static bool TryParse(string address, S7NetCpuType? cpuType, out S7ParsedAddress result)
|
||||
{
|
||||
try
|
||||
{
|
||||
result = Parse(address);
|
||||
result = Parse(address, cpuType);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
@@ -130,18 +166,36 @@ public static class S7AddressParser
|
||||
throw new FormatException($"S7 DB number in '{s}' must be a positive integer");
|
||||
|
||||
if (!tail.StartsWith("DB") || tail.Length < 4)
|
||||
throw new FormatException($"S7 DB address tail '{tail}' must start with DB{{X|B|W|D}}");
|
||||
throw new FormatException($"S7 DB address tail '{tail}' must start with DB{{X|B|W|D|LD|L}}");
|
||||
|
||||
var sizeChar = tail[2];
|
||||
var offsetStart = 3;
|
||||
var size = sizeChar switch
|
||||
// 64-bit suffixes are two-letter (LD or DBL-as-prefix). Detect them up front so the
|
||||
// single-char switch below stays readable. "DBLD" is the symmetric extension of
|
||||
// DBX/DBB/DBW/DBD; "DBL" is the shorter Siemens "long" alias accepted as an alternate.
|
||||
S7Size size;
|
||||
int offsetStart;
|
||||
if (tail.Length >= 5 && tail[2] == 'L' && tail[3] == 'D')
|
||||
{
|
||||
'X' => S7Size.Bit,
|
||||
'B' => S7Size.Byte,
|
||||
'W' => S7Size.Word,
|
||||
'D' => S7Size.DWord,
|
||||
_ => throw new FormatException($"S7 DB size '{sizeChar}' in '{s}' must be X/B/W/D"),
|
||||
};
|
||||
size = S7Size.LWord;
|
||||
offsetStart = 4;
|
||||
}
|
||||
else if (tail.Length >= 4 && tail[2] == 'L')
|
||||
{
|
||||
size = S7Size.LWord;
|
||||
offsetStart = 3;
|
||||
}
|
||||
else
|
||||
{
|
||||
var sizeChar = tail[2];
|
||||
offsetStart = 3;
|
||||
size = sizeChar switch
|
||||
{
|
||||
'X' => S7Size.Bit,
|
||||
'B' => S7Size.Byte,
|
||||
'W' => S7Size.Word,
|
||||
'D' => S7Size.DWord,
|
||||
_ => throw new FormatException($"S7 DB size '{sizeChar}' in '{s}' must be X/B/W/D/LD/L"),
|
||||
};
|
||||
}
|
||||
|
||||
var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(tail, offsetStart, size, s);
|
||||
result = new S7ParsedAddress(S7Area.DataBlock, dbNumber, size, byteOffset, bitOffset);
|
||||
@@ -156,23 +210,73 @@ public static class S7AddressParser
|
||||
var first = rest[0];
|
||||
S7Size size;
|
||||
int offsetStart;
|
||||
switch (first)
|
||||
// Two-char "LD" prefix (8-byte LWord) checked first so it doesn't get swallowed by
|
||||
// the single-letter cases below.
|
||||
if (rest.Length >= 2 && first == 'L' && rest[1] == 'D')
|
||||
{
|
||||
case 'B': size = S7Size.Byte; offsetStart = 1; break;
|
||||
case 'W': size = S7Size.Word; offsetStart = 1; break;
|
||||
case 'D': size = S7Size.DWord; offsetStart = 1; break;
|
||||
default:
|
||||
// No size prefix => bit-level address requires explicit .bit. Size stays Bit;
|
||||
// ParseOffsetAndOptionalBit will demand the dot.
|
||||
size = S7Size.Bit;
|
||||
offsetStart = 0;
|
||||
break;
|
||||
size = S7Size.LWord;
|
||||
offsetStart = 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
switch (first)
|
||||
{
|
||||
case 'B': size = S7Size.Byte; offsetStart = 1; break;
|
||||
case 'W': size = S7Size.Word; offsetStart = 1; break;
|
||||
case 'D': size = S7Size.DWord; offsetStart = 1; break;
|
||||
default:
|
||||
// No size prefix => bit-level address requires explicit .bit. Size stays Bit;
|
||||
// ParseOffsetAndOptionalBit will demand the dot.
|
||||
size = S7Size.Bit;
|
||||
offsetStart = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(rest, offsetStart, size, original);
|
||||
return new S7ParsedAddress(area, DbNumber: 0, size, byteOffset, bitOffset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a <c>V</c>-area address (S7-200 / S7-200 Smart / LOGO! V-memory). Same width
|
||||
/// suffixes as M/I/Q (<c>VB</c>, <c>VW</c>, <c>VD</c>, <c>V0.0</c>) but rewritten as
|
||||
/// a DataBlock access so the rest of the driver — which speaks S7.Net's DB-centric
|
||||
/// API — needs no special-casing downstream.
|
||||
/// </summary>
|
||||
private static S7ParsedAddress ParseV(string rest, string original, S7NetCpuType? cpuType)
|
||||
{
|
||||
var dbNumber = VMemoryDbNumberFor(cpuType, original);
|
||||
// Reuse the M/I/Q grammar — V's size suffixes are identical (B/W/D/LD or .bit).
|
||||
var parsed = ParseMIQ(S7Area.Memory, rest, original);
|
||||
return parsed with { Area = S7Area.DataBlock, DbNumber = dbNumber };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map a CPU family to the underlying DB number that backs V-memory. Returns DB1
|
||||
/// for S7-200, S7-200 Smart, and LOGO! 0BA8 (the only LOGO! the S7.Net <c>CpuType</c>
|
||||
/// enum surfaces). Throws for families that have no V-area concept.
|
||||
/// </summary>
|
||||
private static int VMemoryDbNumberFor(S7NetCpuType? cpuType, string original)
|
||||
{
|
||||
if (cpuType is null)
|
||||
throw new FormatException(
|
||||
$"S7 V-memory address '{original}' requires a CPU family (S7-200 / S7-200 Smart / LOGO!) — " +
|
||||
"the CPU-agnostic Parse overload cannot resolve V-memory to a DB number");
|
||||
|
||||
return cpuType.Value switch
|
||||
{
|
||||
S7NetCpuType.S7200 => 1,
|
||||
S7NetCpuType.S7200Smart => 1,
|
||||
// LOGO! 8 / 0BA8 firmware bands typically expose VM as DB1 over S7comm. Older
|
||||
// 0BA editions can differ; the mapping is centralised here for easy extension
|
||||
// once a site provides a non-DB1 firmware band to test against.
|
||||
S7NetCpuType.Logo0BA8 => 1,
|
||||
_ => throw new FormatException(
|
||||
$"S7 V-memory address '{original}' is only valid on S7-200 / S7-200 Smart / LOGO! " +
|
||||
$"(got CpuType={cpuType.Value}); use explicit DB{{n}}.DB... addressing on this family"),
|
||||
};
|
||||
}
|
||||
|
||||
private static S7ParsedAddress ParseTimerOrCounter(S7Area area, string rest, string original)
|
||||
{
|
||||
if (rest.Length == 0)
|
||||
|
||||
241
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7BlockCoalescingPlanner.cs
Normal file
241
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7BlockCoalescingPlanner.cs
Normal file
@@ -0,0 +1,241 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
/// Block-read coalescing planner for the S7 driver (PR-S7-B2). Where the
|
||||
/// <see cref="S7ReadPacker"/> coalesces N scalar tags into ⌈N/19⌉
|
||||
/// <c>Plc.ReadMultipleVarsAsync</c> PDUs, this planner takes one further pass:
|
||||
/// it groups same-area, same-DB tags by contiguous byte range and folds them
|
||||
/// into a single <c>Plc.ReadBytesAsync</c> covering the merged span. The
|
||||
/// response is sliced client-side per tag so the per-tag decode path is
|
||||
/// unchanged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why coalesce</b>: Reading <c>DB1.DBW0</c> + <c>DB1.DBW2</c> +
|
||||
/// <c>DB1.DBW4</c> as three multi-var items still uses three slots in a
|
||||
/// single PDU; coalescing into one 6-byte byte-range read drops the per-item
|
||||
/// framing entirely and makes the request fit in fewer (sometimes zero
|
||||
/// additional) PDUs. On a typical contiguous DB the wire-level reduction is
|
||||
/// 50:1 for 50 contiguous DBWs.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Gap-merge threshold</b>: The planner merges adjacent tag ranges when
|
||||
/// the gap between them is at most the <c>gapMergeBytes</c> argument to
|
||||
/// <see cref="Plan"/>. The default <see cref="DefaultGapMergeBytes"/> is
|
||||
/// 16 bytes — over-fetching 16 bytes is cheaper than one extra PDU
|
||||
/// (240-byte default PDU envelope, ~18 bytes per request frame). Operators
|
||||
/// can tune the threshold per driver instance via
|
||||
/// <see cref="S7DriverOptions.BlockCoalescingGapBytes"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Opaque-size opt-out</b>: STRING / WSTRING / CHAR / WCHAR and DTL /
|
||||
/// DT / S5TIME / TIME / TOD / DATE-as-DateTime tags carry a header (or
|
||||
/// have a per-tag width that varies with <c>StringLength</c>) and are
|
||||
/// flagged <c>OpaqueSize=true</c>. The planner emits these as standalone
|
||||
/// single-tag ranges and never merges them into a sibling block — the
|
||||
/// per-tag decode path needs an exact byte slice and a wrong slice from
|
||||
/// a coalesced read would silently corrupt every neighbour.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Order-preserving</b>: Each <see cref="BlockReadRange"/> carries a list
|
||||
/// of <see cref="TagSlice"/> values pointing back at the original
|
||||
/// caller-index. The driver's <c>ReadAsync</c> uses the index to write the
|
||||
/// decoded value into the correct slot of the result array, so caller
|
||||
/// ordering of the input <c>fullReferences</c> is preserved across the
|
||||
/// coalescing step.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class S7BlockCoalescingPlanner
|
||||
{
|
||||
/// <summary>Default gap-merge threshold in bytes.</summary>
|
||||
internal const int DefaultGapMergeBytes = 16;
|
||||
|
||||
/// <summary>
|
||||
/// One coalesced byte-range request. The driver issues a single
|
||||
/// <c>Plc.ReadBytesAsync</c> covering <see cref="StartByte"/>..
|
||||
/// <see cref="StartByte"/>+<see cref="ByteCount"/>; each entry in
|
||||
/// <see cref="Tags"/> carries the offset within the response buffer to
|
||||
/// slice for that tag.
|
||||
/// </summary>
|
||||
internal sealed record BlockReadRange(
|
||||
S7Area Area,
|
||||
int DbNumber,
|
||||
int StartByte,
|
||||
int ByteCount,
|
||||
IReadOnlyList<TagSlice> Tags);
|
||||
|
||||
/// <summary>
|
||||
/// One tag's slot inside a <see cref="BlockReadRange"/>. <see cref="OffsetInBlock"/>
|
||||
/// is the byte offset within the coalesced buffer; <see cref="ByteCount"/> is the
|
||||
/// per-tag width that the slice covers.
|
||||
/// </summary>
|
||||
/// <param name="CallerIndex">Original index in the caller's <c>fullReferences</c> list.</param>
|
||||
/// <param name="OffsetInBlock">Byte offset into <see cref="BlockReadRange"/>'s buffer.</param>
|
||||
/// <param name="ByteCount">Bytes the tag claims from the buffer.</param>
|
||||
internal sealed record TagSlice(int CallerIndex, int OffsetInBlock, int ByteCount);
|
||||
|
||||
/// <summary>
|
||||
/// Input row. Captures everything the planner needs to make a coalescing
|
||||
/// decision without needing the full <see cref="S7TagDefinition"/> graph.
|
||||
/// </summary>
|
||||
/// <param name="CallerIndex">Caller-supplied stable index used to thread the decoded value back.</param>
|
||||
/// <param name="Area">Memory area; M and DB never merge into the same range.</param>
|
||||
/// <param name="DbNumber">DB number when <see cref="Area"/> is DataBlock; 0 otherwise.</param>
|
||||
/// <param name="StartByte">Byte offset in the area where the tag's storage begins.</param>
|
||||
/// <param name="ByteCount">On-wire byte width of the tag.</param>
|
||||
/// <param name="OpaqueSize">
|
||||
/// True for tags whose effective decode width is variable / header-prefixed
|
||||
/// (STRING/WSTRING/CHAR/WCHAR and structured timestamps DTL/DT/etc.) so the
|
||||
/// planner skips them — they emit standalone reads and never merge with
|
||||
/// neighbours.
|
||||
/// </param>
|
||||
internal sealed record TagSpec(
|
||||
int CallerIndex,
|
||||
S7Area Area,
|
||||
int DbNumber,
|
||||
int StartByte,
|
||||
int ByteCount,
|
||||
bool OpaqueSize);
|
||||
|
||||
/// <summary>
|
||||
/// Plan a list of byte-range reads from <paramref name="tags"/>. Same-area /
|
||||
/// same-DB rows are sorted by <see cref="TagSpec.StartByte"/> then merged
|
||||
/// greedily when the gap between their byte ranges is <=
|
||||
/// <paramref name="gapMergeBytes"/>. Opaque-size rows always emit as their
|
||||
/// own single-tag range and never extend a sibling block.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Order of returned ranges is not significant — the driver issues them
|
||||
/// sequentially against the same connection gate so wire-level ordering is
|
||||
/// determined by the loop, not by this list. The planner DOES preserve
|
||||
/// the caller-index inside each range so the per-tag decode result lands
|
||||
/// in the correct slot of the response array.
|
||||
/// </remarks>
|
||||
internal static List<BlockReadRange> Plan(IReadOnlyList<TagSpec> tags, int gapMergeBytes = DefaultGapMergeBytes)
|
||||
{
|
||||
if (gapMergeBytes < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(gapMergeBytes), "Gap-merge threshold must be non-negative.");
|
||||
var ranges = new List<BlockReadRange>(tags.Count);
|
||||
if (tags.Count == 0) return ranges;
|
||||
|
||||
// Phase 1: opaque rows emit as standalone single-tag ranges. Strip them
|
||||
// out of the merge candidate set so neighbour ranges don't accidentally
|
||||
// straddle a STRING header / DTL block.
|
||||
var mergeable = new List<TagSpec>(tags.Count);
|
||||
foreach (var t in tags)
|
||||
{
|
||||
if (t.OpaqueSize)
|
||||
{
|
||||
ranges.Add(new BlockReadRange(
|
||||
t.Area, t.DbNumber, t.StartByte, t.ByteCount,
|
||||
[new TagSlice(t.CallerIndex, OffsetInBlock: 0, t.ByteCount)]));
|
||||
}
|
||||
else
|
||||
{
|
||||
mergeable.Add(t);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: bucket by (Area, DbNumber). Memory M and DataBlock DB1 (etc.)
|
||||
// share neither the wire request type nor an addressable space, so they
|
||||
// can never coalesce.
|
||||
var groups = mergeable.GroupBy(t => (t.Area, t.DbNumber));
|
||||
foreach (var group in groups)
|
||||
{
|
||||
// Sort ascending by start byte so the greedy merge below is O(n).
|
||||
// Stable secondary sort on caller index keeps tag-slice ordering
|
||||
// deterministic for tags with identical byte offsets.
|
||||
var sorted = group
|
||||
.OrderBy(t => t.StartByte)
|
||||
.ThenBy(t => t.CallerIndex)
|
||||
.ToList();
|
||||
|
||||
var blockStart = sorted[0].StartByte;
|
||||
var blockEnd = sorted[0].StartByte + sorted[0].ByteCount;
|
||||
var blockSlices = new List<TagSlice>
|
||||
{
|
||||
new(sorted[0].CallerIndex, 0, sorted[0].ByteCount),
|
||||
};
|
||||
|
||||
for (var i = 1; i < sorted.Count; i++)
|
||||
{
|
||||
var t = sorted[i];
|
||||
var gap = t.StartByte - blockEnd;
|
||||
// gap < 0 means the next tag overlaps with the current block — treat
|
||||
// as zero-gap merge (overlap is fine, the slice just reuses earlier
|
||||
// bytes). gap <= threshold = merge; otherwise close the current
|
||||
// block and start a new one.
|
||||
if (gap <= gapMergeBytes)
|
||||
{
|
||||
var newEnd = Math.Max(blockEnd, t.StartByte + t.ByteCount);
|
||||
blockSlices.Add(new TagSlice(t.CallerIndex, t.StartByte - blockStart, t.ByteCount));
|
||||
blockEnd = newEnd;
|
||||
}
|
||||
else
|
||||
{
|
||||
ranges.Add(new BlockReadRange(
|
||||
group.Key.Area, group.Key.DbNumber, blockStart, blockEnd - blockStart, blockSlices));
|
||||
blockStart = t.StartByte;
|
||||
blockEnd = t.StartByte + t.ByteCount;
|
||||
blockSlices = [new TagSlice(t.CallerIndex, 0, t.ByteCount)];
|
||||
}
|
||||
}
|
||||
ranges.Add(new BlockReadRange(
|
||||
group.Key.Area, group.Key.DbNumber, blockStart, blockEnd - blockStart, blockSlices));
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="tag"/>'s on-wire width is variable / header-prefixed.
|
||||
/// Such tags MUST NOT participate in block coalescing because the slice into a
|
||||
/// coalesced byte buffer would land at a wrong offset for any neighbour.
|
||||
/// </summary>
|
||||
internal static bool IsOpaqueSize(S7TagDefinition tag)
|
||||
{
|
||||
// Variable-width string types — STRING/WSTRING carry a 2-byte (or 4-byte)
|
||||
// header and the actual length depends on the runtime value, not the
|
||||
// declared StringLength. CHAR/WCHAR are fixed-width (1 / 2 bytes) but
|
||||
// routed via the per-tag string codec path, so coalescing them would
|
||||
// bypass the codec; treat them as opaque to keep the decode surface
|
||||
// unchanged.
|
||||
if (tag.DataType is S7DataType.String or S7DataType.WString
|
||||
or S7DataType.Char or S7DataType.WChar)
|
||||
return true;
|
||||
|
||||
// Structured timestamps — DTL is 12 bytes, DT is 8 bytes BCD-encoded;
|
||||
// both decode through S7DateTimeCodec and would silently mis-decode if
|
||||
// the slice landed mid-block. S5TIME/TIME/TOD/DATE are fixed-width 2/4
|
||||
// bytes but currently flow through the per-tag codec path; treat them
|
||||
// all as opaque so the planner emits a single-tag range and the existing
|
||||
// codec dispatch stays the source of truth for date/time decode.
|
||||
if (tag.DataType is S7DataType.Dtl or S7DataType.DateAndTime
|
||||
or S7DataType.S5Time or S7DataType.Time or S7DataType.TimeOfDay or S7DataType.Date)
|
||||
return true;
|
||||
|
||||
// Arrays opt out: per-tag width is N × elementBytes, the slice must be
|
||||
// exact. Routing them as opaque keeps the array-aware byte-range read
|
||||
// path in S7Driver.ReadOneAsync.
|
||||
if (tag.ElementCount is int n && n > 1)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Byte width of a packable scalar tag for byte-range coalescing. Mirrors the
|
||||
/// size suffix the address grammar carried (<see cref="S7Size.Bit"/>=1 byte
|
||||
/// because reading a single bit still requires reading the containing byte;
|
||||
/// bit-extraction happens in the slice step).
|
||||
/// </summary>
|
||||
internal static int ScalarByteCount(S7Size size) => size switch
|
||||
{
|
||||
S7Size.Bit => 1,
|
||||
S7Size.Byte => 1,
|
||||
S7Size.Word => 2,
|
||||
S7Size.DWord => 4,
|
||||
S7Size.LWord => 8,
|
||||
_ => throw new InvalidOperationException($"Unknown S7Size {size}"),
|
||||
};
|
||||
}
|
||||
358
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DateTimeCodec.cs
Normal file
358
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DateTimeCodec.cs
Normal file
@@ -0,0 +1,358 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
/// Byte-level codecs for the six Siemens S7 date/time-shaped types: DTL, DATE_AND_TIME
|
||||
/// (DT), S5TIME, TIME, TIME_OF_DAY (TOD), DATE. Pulled out of <see cref="S7Driver"/> so
|
||||
/// the encoding rules are unit-testable against golden byte vectors without standing
|
||||
/// up a Plc instance — same pattern as <see cref="S7StringCodec"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire formats (all big-endian, matching S7's native byte order):
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <b>DTL</b> (12 bytes): year UInt16 BE / month / day / day-of-week / hour /
|
||||
/// minute / second (1 byte each) / nanoseconds UInt32 BE. Year range 1970-2554.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>DATE_AND_TIME (DT)</b> (8 bytes BCD): year-since-1990 / month / day / hour /
|
||||
/// minute / second (1 BCD byte each) + ms (3 BCD digits packed in 1.5 bytes) +
|
||||
/// day-of-week (1 BCD digit, 1=Sunday..7=Saturday). Years 90-99 → 1990-1999;
|
||||
/// years 00-89 → 2000-2089.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>S5TIME</b> (16 bits): bits 15..14 reserved (0), bits 13..12 timebase
|
||||
/// (00=10ms, 01=100ms, 10=1s, 11=10s), bits 11..0 = 3-digit BCD count (0-999).
|
||||
/// Total range 0..9990s.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>TIME</b> (Int32 ms BE): signed milliseconds. Negative durations allowed.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>TOD</b> (UInt32 ms BE): milliseconds since midnight, 0..86399999.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>DATE</b> (UInt16 BE): days since 1990-01-01. Range 0..65535 (1990-2168).
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// <b>Uninitialized PLC bytes</b>: an all-zero DTL or DT buffer (year 0 / month 0)
|
||||
/// is rejected as <see cref="InvalidDataException"/> rather than decoded as
|
||||
/// year-0001 garbage — operators see "BadOutOfRange" instead of a misleading
|
||||
/// valid-but-wrong timestamp.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class S7DateTimeCodec
|
||||
{
|
||||
// ---- DTL (12 bytes) ----
|
||||
|
||||
/// <summary>Wire size of an S7 DTL value.</summary>
|
||||
public const int DtlSize = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Decode a 12-byte DTL buffer into a DateTime. Throws
|
||||
/// <see cref="InvalidDataException"/> when the buffer is uninitialized
|
||||
/// (all-zero year+month) or when components are out of range.
|
||||
/// </summary>
|
||||
public static DateTime DecodeDtl(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length != DtlSize)
|
||||
throw new InvalidDataException($"S7 DTL expected {DtlSize} bytes, got {bytes.Length}");
|
||||
|
||||
int year = BinaryPrimitives.ReadUInt16BigEndian(bytes.Slice(0, 2));
|
||||
int month = bytes[2];
|
||||
int day = bytes[3];
|
||||
// bytes[4] = day-of-week (1=Sunday..7=Saturday); ignored on read — the .NET
|
||||
// DateTime carries its own and the PLC value can be inconsistent on uninit data.
|
||||
int hour = bytes[5];
|
||||
int minute = bytes[6];
|
||||
int second = bytes[7];
|
||||
uint nanos = BinaryPrimitives.ReadUInt32BigEndian(bytes.Slice(8, 4));
|
||||
|
||||
if (year == 0 && month == 0 && day == 0)
|
||||
throw new InvalidDataException("S7 DTL is uninitialized (all-zero year/month/day)");
|
||||
if (year is < 1970 or > 2554)
|
||||
throw new InvalidDataException($"S7 DTL year {year} out of range 1970..2554");
|
||||
if (month is < 1 or > 12)
|
||||
throw new InvalidDataException($"S7 DTL month {month} out of range 1..12");
|
||||
if (day is < 1 or > 31)
|
||||
throw new InvalidDataException($"S7 DTL day {day} out of range 1..31");
|
||||
if (hour > 23) throw new InvalidDataException($"S7 DTL hour {hour} out of range 0..23");
|
||||
if (minute > 59) throw new InvalidDataException($"S7 DTL minute {minute} out of range 0..59");
|
||||
if (second > 59) throw new InvalidDataException($"S7 DTL second {second} out of range 0..59");
|
||||
if (nanos > 999_999_999)
|
||||
throw new InvalidDataException($"S7 DTL nanoseconds {nanos} out of range 0..999999999");
|
||||
|
||||
// .NET DateTime resolution is 100 ns ticks (1 tick = 100 ns).
|
||||
var dt = new DateTime(year, month, day, hour, minute, second, DateTimeKind.Unspecified);
|
||||
return dt.AddTicks(nanos / 100);
|
||||
}
|
||||
|
||||
/// <summary>Encode a DateTime as a 12-byte DTL buffer.</summary>
|
||||
public static byte[] EncodeDtl(DateTime value)
|
||||
{
|
||||
if (value.Year is < 1970 or > 2554)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 DTL year must be 1970..2554");
|
||||
|
||||
var buf = new byte[DtlSize];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), (ushort)value.Year);
|
||||
buf[2] = (byte)value.Month;
|
||||
buf[3] = (byte)value.Day;
|
||||
// S7 day-of-week: 1=Sunday..7=Saturday. .NET DayOfWeek: Sunday=0..Saturday=6.
|
||||
buf[4] = (byte)((int)value.DayOfWeek + 1);
|
||||
buf[5] = (byte)value.Hour;
|
||||
buf[6] = (byte)value.Minute;
|
||||
buf[7] = (byte)value.Second;
|
||||
|
||||
// Sub-second portion → nanoseconds. 1 tick = 100 ns, so ticks % 10_000_000 gives
|
||||
// the fractional second in ticks; multiply by 100 for nanoseconds.
|
||||
long fracTicks = value.Ticks % TimeSpan.TicksPerSecond;
|
||||
uint nanos = (uint)(fracTicks * 100);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(buf.AsSpan(8, 4), nanos);
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ---- DATE_AND_TIME / DT (8 bytes BCD) ----
|
||||
|
||||
/// <summary>Wire size of an S7 DATE_AND_TIME value.</summary>
|
||||
public const int DtSize = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Decode an 8-byte DATE_AND_TIME (BCD) buffer into a DateTime. Year encoding:
|
||||
/// 90..99 → 1990..1999, 00..89 → 2000..2089 (per Siemens spec).
|
||||
/// </summary>
|
||||
public static DateTime DecodeDt(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length != DtSize)
|
||||
throw new InvalidDataException($"S7 DATE_AND_TIME expected {DtSize} bytes, got {bytes.Length}");
|
||||
|
||||
int yy = FromBcd(bytes[0]);
|
||||
int month = FromBcd(bytes[1]);
|
||||
int day = FromBcd(bytes[2]);
|
||||
int hour = FromBcd(bytes[3]);
|
||||
int minute = FromBcd(bytes[4]);
|
||||
int second = FromBcd(bytes[5]);
|
||||
|
||||
// bytes[6] and high nibble of bytes[7] = milliseconds (3 BCD digits).
|
||||
// Low nibble of bytes[7] = day-of-week (1=Sunday..7=Saturday); ignored on read.
|
||||
int msHigh = (bytes[6] >> 4) & 0xF;
|
||||
int msMid = bytes[6] & 0xF;
|
||||
int msLow = (bytes[7] >> 4) & 0xF;
|
||||
if (msHigh > 9 || msMid > 9 || msLow > 9)
|
||||
throw new InvalidDataException($"S7 DT ms BCD digits invalid: {msHigh:X}{msMid:X}{msLow:X}");
|
||||
int ms = msHigh * 100 + msMid * 10 + msLow;
|
||||
|
||||
if (yy == 0 && month == 0 && day == 0)
|
||||
throw new InvalidDataException("S7 DT is uninitialized (all-zero year/month/day)");
|
||||
|
||||
int year = yy >= 90 ? 1900 + yy : 2000 + yy;
|
||||
if (month is < 1 or > 12) throw new InvalidDataException($"S7 DT month {month} out of range 1..12");
|
||||
if (day is < 1 or > 31) throw new InvalidDataException($"S7 DT day {day} out of range 1..31");
|
||||
if (hour > 23) throw new InvalidDataException($"S7 DT hour {hour} out of range 0..23");
|
||||
if (minute > 59) throw new InvalidDataException($"S7 DT minute {minute} out of range 0..59");
|
||||
if (second > 59) throw new InvalidDataException($"S7 DT second {second} out of range 0..59");
|
||||
|
||||
return new DateTime(year, month, day, hour, minute, second, ms, DateTimeKind.Unspecified);
|
||||
}
|
||||
|
||||
/// <summary>Encode a DateTime as an 8-byte DATE_AND_TIME (BCD) buffer.</summary>
|
||||
public static byte[] EncodeDt(DateTime value)
|
||||
{
|
||||
if (value.Year is < 1990 or > 2089)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 DATE_AND_TIME year must be 1990..2089");
|
||||
|
||||
int yy = value.Year >= 2000 ? value.Year - 2000 : value.Year - 1900;
|
||||
int ms = value.Millisecond;
|
||||
// S7 day-of-week: 1=Sunday..7=Saturday.
|
||||
int dow = (int)value.DayOfWeek + 1;
|
||||
|
||||
var buf = new byte[DtSize];
|
||||
buf[0] = ToBcd(yy);
|
||||
buf[1] = ToBcd(value.Month);
|
||||
buf[2] = ToBcd(value.Day);
|
||||
buf[3] = ToBcd(value.Hour);
|
||||
buf[4] = ToBcd(value.Minute);
|
||||
buf[5] = ToBcd(value.Second);
|
||||
// ms = 3 digits packed across bytes [6] (high+mid nibbles) and [7] high nibble.
|
||||
buf[6] = (byte)(((ms / 100) << 4) | ((ms / 10) % 10));
|
||||
buf[7] = (byte)((((ms % 10) & 0xF) << 4) | (dow & 0xF));
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ---- S5TIME (16 bits BCD) ----
|
||||
|
||||
/// <summary>Wire size of an S7 S5TIME value.</summary>
|
||||
public const int S5TimeSize = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Decode a 2-byte S5TIME buffer into a TimeSpan. Layout:
|
||||
/// <c>0000 TTBB BBBB BBBB</c> where TT is the timebase (00=10ms, 01=100ms,
|
||||
/// 10=1s, 11=10s) and BBB is the 3-digit BCD count (0..999).
|
||||
/// </summary>
|
||||
public static TimeSpan DecodeS5Time(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length != S5TimeSize)
|
||||
throw new InvalidDataException($"S7 S5TIME expected {S5TimeSize} bytes, got {bytes.Length}");
|
||||
|
||||
int hi = bytes[0];
|
||||
int lo = bytes[1];
|
||||
int tb = (hi >> 4) & 0x3;
|
||||
int d2 = hi & 0xF;
|
||||
int d1 = (lo >> 4) & 0xF;
|
||||
int d0 = lo & 0xF;
|
||||
if (d2 > 9 || d1 > 9 || d0 > 9)
|
||||
throw new InvalidDataException($"S7 S5TIME BCD digits invalid: {d2:X}{d1:X}{d0:X}");
|
||||
|
||||
int count = d2 * 100 + d1 * 10 + d0;
|
||||
long unitMs = tb switch
|
||||
{
|
||||
0 => 10L,
|
||||
1 => 100L,
|
||||
2 => 1000L,
|
||||
3 => 10_000L,
|
||||
_ => throw new InvalidDataException($"S7 S5TIME timebase {tb} invalid"),
|
||||
};
|
||||
return TimeSpan.FromMilliseconds(count * unitMs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encode a TimeSpan as a 2-byte S5TIME. Picks the smallest timebase that fits
|
||||
/// <paramref name="value"/> in 999 units. Rejects negative or > 9990s durations
|
||||
/// and any value not a multiple of the chosen timebase.
|
||||
/// </summary>
|
||||
public static byte[] EncodeS5Time(TimeSpan value)
|
||||
{
|
||||
if (value < TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 S5TIME must be non-negative");
|
||||
long totalMs = (long)value.TotalMilliseconds;
|
||||
if (totalMs > 9_990_000)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 S5TIME max is 9990 seconds");
|
||||
|
||||
int tb;
|
||||
long unit;
|
||||
if (totalMs <= 9_990 && totalMs % 10 == 0) { tb = 0; unit = 10; }
|
||||
else if (totalMs <= 99_900 && totalMs % 100 == 0) { tb = 1; unit = 100; }
|
||||
else if (totalMs <= 999_000 && totalMs % 1000 == 0) { tb = 2; unit = 1_000; }
|
||||
else if (totalMs % 10_000 == 0) { tb = 3; unit = 10_000; }
|
||||
else
|
||||
throw new ArgumentException(
|
||||
$"S7 S5TIME duration {value} cannot be represented in any timebase without truncation",
|
||||
nameof(value));
|
||||
|
||||
long count = totalMs / unit;
|
||||
if (count > 999)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 S5TIME count exceeds 999 in chosen timebase");
|
||||
|
||||
int d2 = (int)(count / 100);
|
||||
int d1 = (int)((count / 10) % 10);
|
||||
int d0 = (int)(count % 10);
|
||||
|
||||
var buf = new byte[2];
|
||||
buf[0] = (byte)(((tb & 0x3) << 4) | (d2 & 0xF));
|
||||
buf[1] = (byte)(((d1 & 0xF) << 4) | (d0 & 0xF));
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ---- TIME (Int32 ms BE) ----
|
||||
|
||||
/// <summary>Wire size of an S7 TIME value.</summary>
|
||||
public const int TimeSize = 4;
|
||||
|
||||
/// <summary>Decode a 4-byte TIME buffer into a TimeSpan (signed milliseconds).</summary>
|
||||
public static TimeSpan DecodeTime(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length != TimeSize)
|
||||
throw new InvalidDataException($"S7 TIME expected {TimeSize} bytes, got {bytes.Length}");
|
||||
int ms = BinaryPrimitives.ReadInt32BigEndian(bytes);
|
||||
return TimeSpan.FromMilliseconds(ms);
|
||||
}
|
||||
|
||||
/// <summary>Encode a TimeSpan as a 4-byte TIME (signed Int32 milliseconds, big-endian).</summary>
|
||||
public static byte[] EncodeTime(TimeSpan value)
|
||||
{
|
||||
long totalMs = (long)value.TotalMilliseconds;
|
||||
if (totalMs is < int.MinValue or > int.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 TIME exceeds Int32 ms range");
|
||||
var buf = new byte[TimeSize];
|
||||
BinaryPrimitives.WriteInt32BigEndian(buf, (int)totalMs);
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ---- TOD / TIME_OF_DAY (UInt32 ms BE, 0..86399999) ----
|
||||
|
||||
/// <summary>Wire size of an S7 TIME_OF_DAY value.</summary>
|
||||
public const int TodSize = 4;
|
||||
|
||||
/// <summary>Decode a 4-byte TOD buffer into a TimeSpan (ms since midnight).</summary>
|
||||
public static TimeSpan DecodeTod(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length != TodSize)
|
||||
throw new InvalidDataException($"S7 TOD expected {TodSize} bytes, got {bytes.Length}");
|
||||
uint ms = BinaryPrimitives.ReadUInt32BigEndian(bytes);
|
||||
if (ms > 86_399_999)
|
||||
throw new InvalidDataException($"S7 TOD value {ms} exceeds 86399999 ms (one day)");
|
||||
return TimeSpan.FromMilliseconds(ms);
|
||||
}
|
||||
|
||||
/// <summary>Encode a TimeSpan as a 4-byte TOD (UInt32 ms since midnight, big-endian).</summary>
|
||||
public static byte[] EncodeTod(TimeSpan value)
|
||||
{
|
||||
if (value < TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 TOD must be non-negative");
|
||||
long totalMs = (long)value.TotalMilliseconds;
|
||||
if (totalMs > 86_399_999)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 TOD max is 86399999 ms (23:59:59.999)");
|
||||
var buf = new byte[TodSize];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(buf, (uint)totalMs);
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ---- DATE (UInt16 BE, days since 1990-01-01) ----
|
||||
|
||||
/// <summary>Wire size of an S7 DATE value.</summary>
|
||||
public const int DateSize = 2;
|
||||
|
||||
/// <summary>S7 DATE epoch — 1990-01-01 (UTC-unspecified per Siemens spec).</summary>
|
||||
public static readonly DateTime DateEpoch = new(1990, 1, 1, 0, 0, 0, DateTimeKind.Unspecified);
|
||||
|
||||
/// <summary>Decode a 2-byte DATE buffer into a DateTime.</summary>
|
||||
public static DateTime DecodeDate(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length != DateSize)
|
||||
throw new InvalidDataException($"S7 DATE expected {DateSize} bytes, got {bytes.Length}");
|
||||
ushort days = BinaryPrimitives.ReadUInt16BigEndian(bytes);
|
||||
return DateEpoch.AddDays(days);
|
||||
}
|
||||
|
||||
/// <summary>Encode a DateTime as a 2-byte DATE (UInt16 days since 1990-01-01, big-endian).</summary>
|
||||
public static byte[] EncodeDate(DateTime value)
|
||||
{
|
||||
var days = (value.Date - DateEpoch).TotalDays;
|
||||
if (days is < 0 or > ushort.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 DATE must be 1990-01-01..2168-06-06");
|
||||
var buf = new byte[DateSize];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf, (ushort)days);
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ---- BCD helpers ----
|
||||
|
||||
/// <summary>Decode a single BCD byte (each nibble must be a decimal digit 0-9).</summary>
|
||||
private static int FromBcd(byte b)
|
||||
{
|
||||
int hi = (b >> 4) & 0xF;
|
||||
int lo = b & 0xF;
|
||||
if (hi > 9 || lo > 9)
|
||||
throw new InvalidDataException($"S7 BCD byte 0x{b:X2} has non-decimal nibble");
|
||||
return hi * 10 + lo;
|
||||
}
|
||||
|
||||
/// <summary>Encode a 0-99 value as a single BCD byte.</summary>
|
||||
private static byte ToBcd(int value)
|
||||
{
|
||||
if (value is < 0 or > 99)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "BCD byte source must be 0..99");
|
||||
return (byte)(((value / 10) << 4) | (value % 10));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using S7.Net;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
@@ -53,6 +55,15 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
/// <summary>OPC UA StatusCode used when S7 returns <c>ErrorCode.WrongCPU</c> / PUT/GET disabled.</summary>
|
||||
private const uint StatusBadDeviceFailure = 0x80550000u;
|
||||
|
||||
/// <summary>
|
||||
/// Hard upper bound on <see cref="S7TagDefinition.ElementCount"/>. The S7 PDU envelope
|
||||
/// for negotiated default 240-byte and extended 960-byte payloads cannot fit a single
|
||||
/// byte-range read larger than ~960 bytes, so a Float64 array of more than ~120
|
||||
/// elements is already lossy. 8000 is an order-of-magnitude generous ceiling that still
|
||||
/// rejects obvious config typos (e.g. ElementCount = 65535) at init time.
|
||||
/// </summary>
|
||||
internal const int MaxArrayElements = 8000;
|
||||
|
||||
private readonly Dictionary<string, S7TagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, S7ParsedAddress> _parsedByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -76,6 +87,31 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
private bool _disposed;
|
||||
|
||||
// ---- Block-read coalescing diagnostics (PR-S7-B2) ----
|
||||
//
|
||||
// Counters surface through DriverHealth.Diagnostics so the driver-diagnostics
|
||||
// RPC and integration tests can verify wire-level reduction without needing
|
||||
// access to the underlying S7.Net PDU stream. Names match the
|
||||
// "<DriverType>.<Counter>" convention adopted for the modbus and opcuaclient
|
||||
// drivers — see decision #154.
|
||||
private long _totalBlockReads; // Plc.ReadBytesAsync calls issued by the coalesced path
|
||||
private long _totalMultiVarBatches; // Plc.ReadMultipleVarsAsync calls issued
|
||||
private long _totalSingleReads; // per-tag ReadOneAsync fallbacks
|
||||
|
||||
/// <summary>
|
||||
/// Total <c>Plc.ReadBytesAsync</c> calls the coalesced byte-range path issued.
|
||||
/// Test-only entry point for the integration assertion that 50 contiguous DBWs
|
||||
/// coalesce into exactly 1 byte-range read.
|
||||
/// </summary>
|
||||
internal long TotalBlockReads => Interlocked.Read(ref _totalBlockReads);
|
||||
|
||||
/// <summary>
|
||||
/// Total <c>Plc.ReadMultipleVarsAsync</c> batches issued. For a fully-coalesced
|
||||
/// contiguous workload this stays at 0 — every tag flows through the byte-range
|
||||
/// path instead.
|
||||
/// </summary>
|
||||
internal long TotalMultiVarBatches => Interlocked.Read(ref _totalMultiVarBatches);
|
||||
|
||||
public string DriverInstanceId => driverInstanceId;
|
||||
public string DriverType => "S7";
|
||||
|
||||
@@ -84,6 +120,34 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
// Parse + validate every tag before opening the TCP socket so config bugs
|
||||
// (bad address, oversized array, unsupported array element) surface as
|
||||
// FormatException without waiting on a connect timeout. Per the v1 driver-config
|
||||
// story this lets the Admin UI's "Save" round-trip stay sub-second on bad input.
|
||||
_tagsByName.Clear();
|
||||
_parsedByName.Clear();
|
||||
foreach (var t in _options.Tags)
|
||||
{
|
||||
// Pass CpuType so V-memory addresses (S7-200 / S7-200 Smart / LOGO!) resolve
|
||||
// against the device's family-specific DB mapping.
|
||||
var parsed = S7AddressParser.Parse(t.Address, _options.CpuType); // throws FormatException
|
||||
if (t.ElementCount is int n && n > 1)
|
||||
{
|
||||
// Array sanity: cap at S7 PDU realistic limit, reject variable-width
|
||||
// element types and BOOL (packed-bit layout) up-front so a config typo
|
||||
// fails at init instead of surfacing as BadInternalError on every read.
|
||||
if (n > MaxArrayElements)
|
||||
throw new FormatException(
|
||||
$"S7 tag '{t.Name}' ElementCount {n} exceeds S7 PDU realistic limit ({MaxArrayElements})");
|
||||
if (!IsArrayElementSupported(t.DataType))
|
||||
throw new FormatException(
|
||||
$"S7 tag '{t.Name}' DataType {t.DataType} not supported as an array element " +
|
||||
$"(variable-width string types and BOOL packed-bit arrays are a follow-up)");
|
||||
}
|
||||
_tagsByName[t.Name] = t;
|
||||
_parsedByName[t.Name] = parsed;
|
||||
}
|
||||
|
||||
var plc = new Plc(_options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot);
|
||||
// S7netplus writes timeouts into the underlying TcpClient via Plc.WriteTimeout /
|
||||
// Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself
|
||||
@@ -97,18 +161,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
|
||||
Plc = plc;
|
||||
|
||||
// Parse every tag's address once at init so config typos fail fast here instead
|
||||
// of surfacing as BadInternalError on every Read against the bad tag. The parser
|
||||
// also rejects bit-offset > 7, DB 0, unknown area letters, etc.
|
||||
_tagsByName.Clear();
|
||||
_parsedByName.Clear();
|
||||
foreach (var t in _options.Tags)
|
||||
{
|
||||
var parsed = S7AddressParser.Parse(t.Address); // throws FormatException
|
||||
_tagsByName[t.Name] = t;
|
||||
_parsedByName[t.Name] = parsed;
|
||||
}
|
||||
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
|
||||
// Kick off the probe loop once the connection is up. Initial HostState stays
|
||||
@@ -179,6 +231,14 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Phase 1: classify each request into (a) unknown / not-found, (b) packable
|
||||
// scalar (Bool/Byte/Int16/UInt16/Int32/UInt32/Float32/Float64) which can
|
||||
// potentially coalesce into a byte-range read, or (c) per-tag fallback
|
||||
// (arrays, strings, dates, 64-bit ints, UDT-fanout). Packable tags feed
|
||||
// the block-coalescing planner first (PR-S7-B2); whatever survives as a
|
||||
// singleton range falls through to the multi-var packer (PR-S7-B1).
|
||||
var packableIndexes = new List<int>(fullReferences.Count);
|
||||
var fallbackIndexes = new List<int>();
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var name = fullReferences[i];
|
||||
@@ -187,39 +247,415 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
var addr = _parsedByName[name];
|
||||
if (S7ReadPacker.IsPackable(tag, addr)) packableIndexes.Add(i);
|
||||
else fallbackIndexes.Add(i);
|
||||
}
|
||||
|
||||
// Phase 2a: block-read coalescing — group same-area / same-DB packable
|
||||
// tags into contiguous byte ranges (gap-merge threshold from
|
||||
// S7DriverOptions.BlockCoalescingGapBytes, default 16). Multi-tag ranges
|
||||
// dispatch via Plc.ReadBytesAsync; singleton ranges fall through to the
|
||||
// multi-var packer below.
|
||||
var singletons = new List<int>();
|
||||
if (packableIndexes.Count > 0)
|
||||
{
|
||||
var specs = new List<S7BlockCoalescingPlanner.TagSpec>(packableIndexes.Count);
|
||||
foreach (var idx in packableIndexes)
|
||||
{
|
||||
var value = await ReadOneAsync(plc, tag, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new DataValueSnapshot(value, 0u, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
var tag = _tagsByName[fullReferences[idx]];
|
||||
var addr = _parsedByName[fullReferences[idx]];
|
||||
specs.Add(new S7BlockCoalescingPlanner.TagSpec(
|
||||
CallerIndex: idx,
|
||||
Area: addr.Area,
|
||||
DbNumber: addr.DbNumber,
|
||||
StartByte: addr.ByteOffset,
|
||||
ByteCount: S7BlockCoalescingPlanner.ScalarByteCount(addr.Size),
|
||||
OpaqueSize: false));
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(specs, _options.BlockCoalescingGapBytes);
|
||||
|
||||
foreach (var range in ranges)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, StatusBadNotSupported, null, now);
|
||||
if (range.Tags.Count == 1)
|
||||
{
|
||||
// Singleton — let the multi-var packer batch it with other
|
||||
// singletons in the same ReadAsync call. Cheaper than its
|
||||
// own one-tag ReadBytesAsync round-trip.
|
||||
singletons.Add(range.Tags[0].CallerIndex);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ReadCoalescedRangeAsync(plc, range, fullReferences, results, now, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (global::S7.Net.PlcException pex)
|
||||
}
|
||||
|
||||
// Phase 2b: bin-pack residual singletons through ReadMultipleVarsAsync.
|
||||
// On a per-batch S7.Net failure the whole batch falls back to ReadOneAsync
|
||||
// per tag — that way one bad item doesn't poison the rest of the batch
|
||||
// and each tag still gets its own per-item StatusCode (BadDeviceFailure
|
||||
// for PUT/GET refusal, BadCommunicationError for transport faults).
|
||||
if (singletons.Count > 0)
|
||||
{
|
||||
var budget = S7ReadPacker.ItemBudget(S7ReadPacker.DefaultPduSize);
|
||||
var batches = S7ReadPacker.BinPack(singletons, budget);
|
||||
foreach (var batch in batches)
|
||||
{
|
||||
// S7.Net's PlcException carries an ErrorCode; PUT/GET-disabled on
|
||||
// S7-1200/1500 surfaces here. Map to BadDeviceFailure so operators see a
|
||||
// device-config problem (toggle PUT/GET in TIA Portal) rather than a
|
||||
// transient fault — per driver-specs.md §5.
|
||||
results[i] = new DataValueSnapshot(null, StatusBadDeviceFailure, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
await ReadBatchAsync(plc, batch, fullReferences, results, now, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: per-tag fallback for everything that can't pack into a single
|
||||
// DataItem. Keeps the existing decode path as the source of truth for
|
||||
// string/date/array/64-bit semantics.
|
||||
foreach (var i in fallbackIndexes)
|
||||
{
|
||||
var tag = _tagsByName[fullReferences[i]];
|
||||
results[i] = await ReadOneAsSnapshotAsync(plc, tag, now, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issue one coalesced <c>Plc.ReadBytesAsync</c> covering
|
||||
/// <paramref name="range"/> and slice the response per tag. On a transport
|
||||
/// fault the whole range falls back to per-tag <see cref="ReadOneAsSnapshotAsync"/>
|
||||
/// so a single bad slot doesn't poison N-1 good neighbours.
|
||||
/// </summary>
|
||||
private async Task ReadCoalescedRangeAsync(
|
||||
global::S7.Net.Plc plc,
|
||||
S7BlockCoalescingPlanner.BlockReadRange range,
|
||||
IReadOnlyList<string> fullReferences,
|
||||
DataValueSnapshot[] results,
|
||||
DateTime now,
|
||||
CancellationToken ct)
|
||||
{
|
||||
byte[]? buf;
|
||||
try
|
||||
{
|
||||
Interlocked.Increment(ref _totalBlockReads);
|
||||
buf = await plc.ReadBytesAsync(MapArea(range.Area), range.DbNumber, range.StartByte, range.ByteCount, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Block read fault → fan out per-tag so a bad address in the block
|
||||
// surfaces its own StatusCode and good neighbours can still retry
|
||||
// through the per-tag fallback path.
|
||||
foreach (var slice in range.Tags)
|
||||
{
|
||||
var tag = _tagsByName[fullReferences[slice.CallerIndex]];
|
||||
results[slice.CallerIndex] = await ReadOneAsSnapshotAsync(plc, tag, now, ct).ConfigureAwait(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (buf is null || buf.Length != range.ByteCount)
|
||||
{
|
||||
// Short / truncated PDU — same fan-out semantics as a transport fault.
|
||||
foreach (var slice in range.Tags)
|
||||
{
|
||||
results[slice.CallerIndex] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var slice in range.Tags)
|
||||
{
|
||||
var name = fullReferences[slice.CallerIndex];
|
||||
var tag = _tagsByName[name];
|
||||
var addr = _parsedByName[name];
|
||||
try
|
||||
{
|
||||
var value = DecodeScalarFromBlock(buf, slice.OffsetInBlock, tag, addr);
|
||||
results[slice.CallerIndex] = new DataValueSnapshot(value, 0u, now, now);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[slice.CallerIndex] = new DataValueSnapshot(null, StatusBadInternalError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null, BuildDiagnostics());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode one packable scalar from a coalesced byte buffer. Mirrors the
|
||||
/// reinterpret table in <see cref="S7ReadPacker.DecodePackedValue"/> so the
|
||||
/// coalesced and per-tag-batch paths produce identical .NET types for the
|
||||
/// same wire bytes.
|
||||
/// </summary>
|
||||
private static object DecodeScalarFromBlock(byte[] buf, int offset, S7TagDefinition tag, S7ParsedAddress addr)
|
||||
{
|
||||
return (tag.DataType, addr.Size) switch
|
||||
{
|
||||
(S7DataType.Bool, S7Size.Bit) => ((buf[offset] >> addr.BitOffset) & 0x1) == 1,
|
||||
(S7DataType.Byte, S7Size.Byte) => buf[offset],
|
||||
(S7DataType.UInt16, S7Size.Word) => BinaryPrimitives.ReadUInt16BigEndian(buf.AsSpan(offset, 2)),
|
||||
(S7DataType.Int16, S7Size.Word) => BinaryPrimitives.ReadInt16BigEndian(buf.AsSpan(offset, 2)),
|
||||
(S7DataType.UInt32, S7Size.DWord) => BinaryPrimitives.ReadUInt32BigEndian(buf.AsSpan(offset, 4)),
|
||||
(S7DataType.Int32, S7Size.DWord) => BinaryPrimitives.ReadInt32BigEndian(buf.AsSpan(offset, 4)),
|
||||
(S7DataType.Float32, S7Size.DWord) =>
|
||||
BitConverter.UInt32BitsToSingle(BinaryPrimitives.ReadUInt32BigEndian(buf.AsSpan(offset, 4))),
|
||||
(S7DataType.Float64, S7Size.LWord) =>
|
||||
BitConverter.UInt64BitsToDouble(BinaryPrimitives.ReadUInt64BigEndian(buf.AsSpan(offset, 8))),
|
||||
_ => throw new System.IO.InvalidDataException(
|
||||
$"S7 block-decode: tag '{tag.Name}' declared {tag.DataType} but address parsed Size={addr.Size}"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the wire-level coalescing counters surfaced through
|
||||
/// <see cref="DriverHealth.Diagnostics"/>. Names follow the
|
||||
/// <c>"<DriverType>.<Counter>"</c> convention so the driver-diagnostics
|
||||
/// RPC can render them in the Admin UI alongside Modbus / OPC UA Client
|
||||
/// metrics without a per-driver special-case.
|
||||
/// </summary>
|
||||
private IReadOnlyDictionary<string, double> BuildDiagnostics() => new Dictionary<string, double>
|
||||
{
|
||||
["S7.TotalBlockReads"] = Interlocked.Read(ref _totalBlockReads),
|
||||
["S7.TotalMultiVarBatches"] = Interlocked.Read(ref _totalMultiVarBatches),
|
||||
["S7.TotalSingleReads"] = Interlocked.Read(ref _totalSingleReads),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Read one packed batch via <c>Plc.ReadMultipleVarsAsync</c>. On batch
|
||||
/// success each <c>DataItem.Value</c> decodes into its tag's snapshot
|
||||
/// slot; on batch failure each tag in the batch falls back to
|
||||
/// <see cref="ReadOneAsSnapshotAsync"/> so the failure fans out per-tag instead
|
||||
/// of poisoning the whole batch with one StatusCode.
|
||||
/// </summary>
|
||||
private async Task ReadBatchAsync(
|
||||
global::S7.Net.Plc plc,
|
||||
IReadOnlyList<int> batchIndexes,
|
||||
IReadOnlyList<string> fullReferences,
|
||||
DataValueSnapshot[] results,
|
||||
DateTime now,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var items = new List<global::S7.Net.Types.DataItem>(batchIndexes.Count);
|
||||
foreach (var idx in batchIndexes)
|
||||
{
|
||||
var name = fullReferences[idx];
|
||||
items.Add(S7ReadPacker.BuildDataItem(_tagsByName[name], _parsedByName[name]));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Interlocked.Increment(ref _totalMultiVarBatches);
|
||||
var responses = await plc.ReadMultipleVarsAsync(items, ct).ConfigureAwait(false);
|
||||
// S7.Net mutates the input list in place and also returns it; iterate by
|
||||
// index against the input list so we are agnostic to either contract.
|
||||
for (var k = 0; k < batchIndexes.Count; k++)
|
||||
{
|
||||
var idx = batchIndexes[k];
|
||||
var tag = _tagsByName[fullReferences[idx]];
|
||||
var raw = (responses != null && k < responses.Count ? responses[k] : items[k]).Value;
|
||||
if (raw is null)
|
||||
{
|
||||
results[idx] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
var decoded = S7ReadPacker.DecodePackedValue(tag, raw);
|
||||
results[idx] = new DataValueSnapshot(decoded, 0u, now, now);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[idx] = new DataValueSnapshot(null, StatusBadInternalError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null, BuildDiagnostics());
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Batch-level fault: most likely a single bad address poisoned the
|
||||
// multi-var response. Fall back to ReadOneAsync per tag in the batch so
|
||||
// good tags still surface a value and the offender gets its own StatusCode.
|
||||
foreach (var idx in batchIndexes)
|
||||
{
|
||||
var tag = _tagsByName[fullReferences[idx]];
|
||||
results[idx] = await ReadOneAsSnapshotAsync(plc, tag, now, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single-tag read wrapped as a <see cref="DataValueSnapshot"/> with the same
|
||||
/// exception-to-StatusCode mapping the legacy per-tag loop applied. Shared
|
||||
/// between the fallback path and the post-batch retry path so the failure
|
||||
/// surface stays identical.
|
||||
/// </summary>
|
||||
private async Task<DataValueSnapshot> ReadOneAsSnapshotAsync(
|
||||
global::S7.Net.Plc plc, S7TagDefinition tag, DateTime now, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
Interlocked.Increment(ref _totalSingleReads);
|
||||
var value = await ReadOneAsync(plc, tag, ct).ConfigureAwait(false);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
return new DataValueSnapshot(value, 0u, now, now);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
return new DataValueSnapshot(null, StatusBadNotSupported, null, now);
|
||||
}
|
||||
catch (global::S7.Net.PlcException pex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
|
||||
return new DataValueSnapshot(null, StatusBadDeviceFailure, null, now);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
return new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<object> ReadOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, CancellationToken ct)
|
||||
{
|
||||
var addr = _parsedByName[tag.Name];
|
||||
|
||||
// 1-D array path: one byte-range read covering N×elementBytes, sliced client-side.
|
||||
// Init-time validation guarantees only fixed-width element types reach here.
|
||||
if (tag.ElementCount is int n && n > 1)
|
||||
{
|
||||
var elemBytes = ArrayElementBytes(tag.DataType);
|
||||
var totalBytes = checked(n * elemBytes);
|
||||
if (addr.Size == S7Size.Bit)
|
||||
throw new System.IO.InvalidDataException(
|
||||
$"S7 Read type-mismatch: tag '{tag.Name}' is array of {tag.DataType} but address '{tag.Address}' " +
|
||||
$"parsed as bit-access; arrays require byte-addressing");
|
||||
|
||||
var arrBytes = await plc.ReadBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, totalBytes, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (arrBytes is null || arrBytes.Length != totalBytes)
|
||||
throw new System.IO.InvalidDataException(
|
||||
$"S7.Net returned {arrBytes?.Length ?? 0} bytes for array '{tag.Address}' (n={n}), expected {totalBytes}");
|
||||
|
||||
return SliceArray(arrBytes, tag.DataType, n, elemBytes);
|
||||
}
|
||||
|
||||
// String-shaped types (STRING/WSTRING/CHAR/WCHAR): S7.Net's string-keyed ReadAsync
|
||||
// has no syntax for these, so the driver issues a raw byte read and decodes via
|
||||
// S7StringCodec. Wire order is big-endian for the WSTRING/WCHAR UTF-16 payload.
|
||||
if (tag.DataType is S7DataType.String or S7DataType.WString or S7DataType.Char or S7DataType.WChar)
|
||||
{
|
||||
if (addr.Size == S7Size.Bit)
|
||||
throw new System.IO.InvalidDataException(
|
||||
$"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||
$"parsed as bit-access; string-shaped types require byte-addressing (e.g. DBB / MB / IB / QB)");
|
||||
|
||||
var (area, dbNum, off) = (addr.Area, addr.DbNumber, addr.ByteOffset);
|
||||
switch (tag.DataType)
|
||||
{
|
||||
case S7DataType.Char:
|
||||
{
|
||||
var b = await plc.ReadBytesAsync(MapArea(area), dbNum, off, 1, ct).ConfigureAwait(false);
|
||||
if (b is null || b.Length != 1)
|
||||
throw new System.IO.InvalidDataException($"S7.Net returned {b?.Length ?? 0} bytes for CHAR '{tag.Address}', expected 1");
|
||||
return S7StringCodec.DecodeChar(b);
|
||||
}
|
||||
case S7DataType.WChar:
|
||||
{
|
||||
var b = await plc.ReadBytesAsync(MapArea(area), dbNum, off, 2, ct).ConfigureAwait(false);
|
||||
if (b is null || b.Length != 2)
|
||||
throw new System.IO.InvalidDataException($"S7.Net returned {b?.Length ?? 0} bytes for WCHAR '{tag.Address}', expected 2");
|
||||
return S7StringCodec.DecodeWChar(b);
|
||||
}
|
||||
case S7DataType.String:
|
||||
{
|
||||
var max = tag.StringLength;
|
||||
var size = S7StringCodec.StringBufferSize(max);
|
||||
var b = await plc.ReadBytesAsync(MapArea(area), dbNum, off, size, ct).ConfigureAwait(false);
|
||||
if (b is null || b.Length != size)
|
||||
throw new System.IO.InvalidDataException($"S7.Net returned {b?.Length ?? 0} bytes for STRING '{tag.Address}', expected {size}");
|
||||
return S7StringCodec.DecodeString(b, max);
|
||||
}
|
||||
case S7DataType.WString:
|
||||
{
|
||||
var max = tag.StringLength;
|
||||
var size = S7StringCodec.WStringBufferSize(max);
|
||||
var b = await plc.ReadBytesAsync(MapArea(area), dbNum, off, size, ct).ConfigureAwait(false);
|
||||
if (b is null || b.Length != size)
|
||||
throw new System.IO.InvalidDataException($"S7.Net returned {b?.Length ?? 0} bytes for WSTRING '{tag.Address}', expected {size}");
|
||||
return S7StringCodec.DecodeWString(b, max);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Date/time-shaped types (DTL/DT/S5TIME/TIME/TOD/DATE): S7.Net has no native size
|
||||
// suffix for any of these, so the driver issues a raw byte read at the address's
|
||||
// ByteOffset and decodes via S7DateTimeCodec. All require byte-addressing — bit-
|
||||
// access against a date/time tag is a config bug worth surfacing as a hard error.
|
||||
if (tag.DataType is S7DataType.Dtl or S7DataType.DateAndTime or S7DataType.S5Time
|
||||
or S7DataType.Time or S7DataType.TimeOfDay or S7DataType.Date)
|
||||
{
|
||||
if (addr.Size == S7Size.Bit)
|
||||
throw new System.IO.InvalidDataException(
|
||||
$"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||
$"parsed as bit-access; date/time types require byte-addressing");
|
||||
|
||||
int size = tag.DataType switch
|
||||
{
|
||||
S7DataType.Dtl => S7DateTimeCodec.DtlSize,
|
||||
S7DataType.DateAndTime => S7DateTimeCodec.DtSize,
|
||||
S7DataType.S5Time => S7DateTimeCodec.S5TimeSize,
|
||||
S7DataType.Time => S7DateTimeCodec.TimeSize,
|
||||
S7DataType.TimeOfDay => S7DateTimeCodec.TodSize,
|
||||
S7DataType.Date => S7DateTimeCodec.DateSize,
|
||||
_ => throw new InvalidOperationException(),
|
||||
};
|
||||
|
||||
var b = await plc.ReadBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, size, ct).ConfigureAwait(false);
|
||||
if (b is null || b.Length != size)
|
||||
throw new System.IO.InvalidDataException(
|
||||
$"S7.Net returned {b?.Length ?? 0} bytes for {tag.DataType} '{tag.Address}', expected {size}");
|
||||
|
||||
return tag.DataType switch
|
||||
{
|
||||
S7DataType.Dtl => S7DateTimeCodec.DecodeDtl(b),
|
||||
S7DataType.DateAndTime => S7DateTimeCodec.DecodeDt(b),
|
||||
// S5TIME/TIME/TOD surface as Int32 ms — DriverDataType has no Duration type;
|
||||
// OPC UA clients see a millisecond integer matching the IEC-1131 convention.
|
||||
S7DataType.S5Time => (int)S7DateTimeCodec.DecodeS5Time(b).TotalMilliseconds,
|
||||
S7DataType.Time => (int)S7DateTimeCodec.DecodeTime(b).TotalMilliseconds,
|
||||
S7DataType.TimeOfDay => (int)S7DateTimeCodec.DecodeTod(b).TotalMilliseconds,
|
||||
S7DataType.Date => S7DateTimeCodec.DecodeDate(b),
|
||||
_ => throw new InvalidOperationException(),
|
||||
};
|
||||
}
|
||||
|
||||
// 64-bit types: S7.Net's string-based ReadAsync has no LWord size suffix, so issue an
|
||||
// 8-byte ReadBytesAsync and convert big-endian in-process. Wire order on S7 is BE.
|
||||
if (tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64)
|
||||
{
|
||||
if (addr.Size != S7Size.LWord)
|
||||
throw new System.IO.InvalidDataException(
|
||||
$"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||
$"parsed as Size={addr.Size}; 64-bit types require an LD/DBL/DBLD suffix");
|
||||
|
||||
var bytes = await plc.ReadBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, 8, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (bytes is null || bytes.Length != 8)
|
||||
throw new System.IO.InvalidDataException($"S7.Net returned {bytes?.Length ?? 0} bytes for '{tag.Address}', expected 8");
|
||||
return tag.DataType switch
|
||||
{
|
||||
S7DataType.Int64 => BinaryPrimitives.ReadInt64BigEndian(bytes),
|
||||
S7DataType.UInt64 => BinaryPrimitives.ReadUInt64BigEndian(bytes),
|
||||
S7DataType.Float64 => BitConverter.UInt64BitsToDouble(BinaryPrimitives.ReadUInt64BigEndian(bytes)),
|
||||
_ => throw new InvalidOperationException(),
|
||||
};
|
||||
}
|
||||
|
||||
// S7.Net's string-based ReadAsync returns object where the boxed .NET type depends on
|
||||
// the size suffix: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. Our S7DataType enum
|
||||
// specifies the SEMANTIC type (Int16 vs UInt16 vs Float32 etc.); the reinterpret below
|
||||
@@ -238,10 +674,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
(S7DataType.Int32, S7Size.DWord, uint u32) => unchecked((int)u32),
|
||||
(S7DataType.Float32, S7Size.DWord, uint u32) => BitConverter.UInt32BitsToSingle(u32),
|
||||
|
||||
(S7DataType.Int64, _, _) => throw new NotSupportedException("S7 Int64 reads land in a follow-up PR"),
|
||||
(S7DataType.UInt64, _, _) => throw new NotSupportedException("S7 UInt64 reads land in a follow-up PR"),
|
||||
(S7DataType.Float64, _, _) => throw new NotSupportedException("S7 Float64 (LReal) reads land in a follow-up PR"),
|
||||
(S7DataType.String, _, _) => throw new NotSupportedException("S7 STRING reads land in a follow-up PR"),
|
||||
(S7DataType.DateTime, _, _) => throw new NotSupportedException("S7 DateTime reads land in a follow-up PR"),
|
||||
|
||||
_ => throw new System.IO.InvalidDataException(
|
||||
@@ -250,6 +682,18 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Map driver-internal <see cref="S7Area"/> to S7.Net's <see cref="global::S7.Net.DataType"/>.</summary>
|
||||
private static global::S7.Net.DataType MapArea(S7Area area) => area switch
|
||||
{
|
||||
S7Area.DataBlock => global::S7.Net.DataType.DataBlock,
|
||||
S7Area.Memory => global::S7.Net.DataType.Memory,
|
||||
S7Area.Input => global::S7.Net.DataType.Input,
|
||||
S7Area.Output => global::S7.Net.DataType.Output,
|
||||
S7Area.Timer => global::S7.Net.DataType.Timer,
|
||||
S7Area.Counter => global::S7.Net.DataType.Counter,
|
||||
_ => throw new InvalidOperationException($"Unknown S7Area {area}"),
|
||||
};
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
@@ -299,6 +743,102 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
|
||||
private async Task WriteOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
|
||||
{
|
||||
// 1-D array path: pack all N elements into a single buffer then push via WriteBytesAsync.
|
||||
// Init-time validation guarantees only fixed-width element types reach here.
|
||||
if (tag.ElementCount is int n && n > 1)
|
||||
{
|
||||
var addr = _parsedByName[tag.Name];
|
||||
if (addr.Size == S7Size.Bit)
|
||||
throw new InvalidOperationException(
|
||||
$"S7 Write type-mismatch: tag '{tag.Name}' is array of {tag.DataType} but address '{tag.Address}' " +
|
||||
$"parsed as bit-access; arrays require byte-addressing");
|
||||
if (value is null)
|
||||
throw new ArgumentNullException(nameof(value));
|
||||
var elemBytes = ArrayElementBytes(tag.DataType);
|
||||
var buf = PackArray(value, tag.DataType, n, elemBytes, tag.Name);
|
||||
await plc.WriteBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, buf, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// String-shaped types: encode via S7StringCodec then push via WriteBytesAsync. The
|
||||
// codec rejects out-of-range lengths and non-ASCII for CHAR — we let the resulting
|
||||
// ArgumentException bubble out so the WriteAsync caller maps it to BadInternalError.
|
||||
if (tag.DataType is S7DataType.String or S7DataType.WString or S7DataType.Char or S7DataType.WChar)
|
||||
{
|
||||
var addr = _parsedByName[tag.Name];
|
||||
if (addr.Size == S7Size.Bit)
|
||||
throw new InvalidOperationException(
|
||||
$"S7 Write type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||
$"parsed as bit-access; string-shaped types require byte-addressing (e.g. DBB / MB / IB / QB)");
|
||||
|
||||
byte[] payload = tag.DataType switch
|
||||
{
|
||||
S7DataType.Char => S7StringCodec.EncodeChar(Convert.ToChar(value ?? throw new ArgumentNullException(nameof(value)))),
|
||||
S7DataType.WChar => S7StringCodec.EncodeWChar(Convert.ToChar(value ?? throw new ArgumentNullException(nameof(value)))),
|
||||
S7DataType.String => S7StringCodec.EncodeString(Convert.ToString(value) ?? string.Empty, tag.StringLength),
|
||||
S7DataType.WString => S7StringCodec.EncodeWString(Convert.ToString(value) ?? string.Empty, tag.StringLength),
|
||||
_ => throw new InvalidOperationException(),
|
||||
};
|
||||
await plc.WriteBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, payload, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Date/time-shaped types: encode via S7DateTimeCodec and push as raw bytes. S5TIME /
|
||||
// TIME / TOD accept an integer-ms input (matching the read surface); DTL / DT / DATE
|
||||
// accept a DateTime. ArgumentException from the codec bubbles to BadInternalError.
|
||||
if (tag.DataType is S7DataType.Dtl or S7DataType.DateAndTime or S7DataType.S5Time
|
||||
or S7DataType.Time or S7DataType.TimeOfDay or S7DataType.Date)
|
||||
{
|
||||
var addr = _parsedByName[tag.Name];
|
||||
if (addr.Size == S7Size.Bit)
|
||||
throw new InvalidOperationException(
|
||||
$"S7 Write type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||
$"parsed as bit-access; date/time types require byte-addressing");
|
||||
if (value is null)
|
||||
throw new ArgumentNullException(nameof(value));
|
||||
|
||||
byte[] payload = tag.DataType switch
|
||||
{
|
||||
S7DataType.Dtl => S7DateTimeCodec.EncodeDtl(Convert.ToDateTime(value)),
|
||||
S7DataType.DateAndTime => S7DateTimeCodec.EncodeDt(Convert.ToDateTime(value)),
|
||||
S7DataType.S5Time => S7DateTimeCodec.EncodeS5Time(value is TimeSpan ts1 ? ts1 : TimeSpan.FromMilliseconds(Convert.ToInt32(value))),
|
||||
S7DataType.Time => S7DateTimeCodec.EncodeTime(value is TimeSpan ts2 ? ts2 : TimeSpan.FromMilliseconds(Convert.ToInt32(value))),
|
||||
S7DataType.TimeOfDay => S7DateTimeCodec.EncodeTod(value is TimeSpan ts3 ? ts3 : TimeSpan.FromMilliseconds(Convert.ToInt64(value))),
|
||||
S7DataType.Date => S7DateTimeCodec.EncodeDate(Convert.ToDateTime(value)),
|
||||
_ => throw new InvalidOperationException(),
|
||||
};
|
||||
await plc.WriteBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, payload, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 64-bit types: S7.Net has no LWord-aware WriteAsync(string, object) overload, so emit
|
||||
// the value as 8 big-endian bytes via WriteBytesAsync. Wire order on S7 is BE so a
|
||||
// BinaryPrimitives.Write*BigEndian round-trips with the matching ReadOneAsync path.
|
||||
if (tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64)
|
||||
{
|
||||
var addr = _parsedByName[tag.Name];
|
||||
if (addr.Size != S7Size.LWord)
|
||||
throw new InvalidOperationException(
|
||||
$"S7 Write type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||
$"parsed as Size={addr.Size}; 64-bit types require an LD/DBL/DBLD suffix");
|
||||
|
||||
var buf = new byte[8];
|
||||
switch (tag.DataType)
|
||||
{
|
||||
case S7DataType.Int64:
|
||||
BinaryPrimitives.WriteInt64BigEndian(buf, Convert.ToInt64(value));
|
||||
break;
|
||||
case S7DataType.UInt64:
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buf, Convert.ToUInt64(value));
|
||||
break;
|
||||
case S7DataType.Float64:
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buf, BitConverter.DoubleToUInt64Bits(Convert.ToDouble(value)));
|
||||
break;
|
||||
}
|
||||
await plc.WriteBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, buf, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// S7.Net's Plc.WriteAsync(string address, object value) expects the boxed value to
|
||||
// match the address's size-suffix type: DBX=bool, DBB=byte, DBW=ushort, DBD=uint.
|
||||
// Our S7DataType lets the caller pass short/int/float; convert to the unsigned
|
||||
@@ -313,10 +853,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
S7DataType.Int32 => (object)unchecked((uint)Convert.ToInt32(value)),
|
||||
S7DataType.Float32 => (object)BitConverter.SingleToUInt32Bits(Convert.ToSingle(value)),
|
||||
|
||||
S7DataType.Int64 => throw new NotSupportedException("S7 Int64 writes land in a follow-up PR"),
|
||||
S7DataType.UInt64 => throw new NotSupportedException("S7 UInt64 writes land in a follow-up PR"),
|
||||
S7DataType.Float64 => throw new NotSupportedException("S7 Float64 (LReal) writes land in a follow-up PR"),
|
||||
S7DataType.String => throw new NotSupportedException("S7 STRING writes land in a follow-up PR"),
|
||||
S7DataType.DateTime => throw new NotSupportedException("S7 DateTime writes land in a follow-up PR"),
|
||||
_ => throw new InvalidOperationException($"Unknown S7DataType {tag.DataType}"),
|
||||
};
|
||||
@@ -334,11 +870,12 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
var folder = builder.Folder("S7", "S7");
|
||||
foreach (var t in _options.Tags)
|
||||
{
|
||||
var isArr = t.ElementCount is int ec && ec > 1;
|
||||
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
|
||||
FullName: t.Name,
|
||||
DriverDataType: MapDataType(t.DataType),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
IsArray: isArr,
|
||||
ArrayDim: isArr ? (uint)t.ElementCount!.Value : null,
|
||||
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
@@ -347,16 +884,198 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="t"/> can be used as an array element. Variable-width string
|
||||
/// types and BOOL (packed-bit layout) are rejected — both need bespoke addressing
|
||||
/// beyond a flat <c>N × elementBytes</c> byte-range read and ship as a follow-up.
|
||||
/// </summary>
|
||||
internal static bool IsArrayElementSupported(S7DataType t) => t is
|
||||
S7DataType.Byte or
|
||||
S7DataType.Int16 or S7DataType.UInt16 or
|
||||
S7DataType.Int32 or S7DataType.UInt32 or
|
||||
S7DataType.Int64 or S7DataType.UInt64 or
|
||||
S7DataType.Float32 or S7DataType.Float64 or
|
||||
S7DataType.Date or S7DataType.Time or S7DataType.TimeOfDay;
|
||||
|
||||
/// <summary>
|
||||
/// On-wire bytes per array element for the supported fixed-width element types. DATE
|
||||
/// is a 16-bit days-since-1990 counter, TIME and TOD are 32-bit ms counters.
|
||||
/// </summary>
|
||||
internal static int ArrayElementBytes(S7DataType t) => t switch
|
||||
{
|
||||
S7DataType.Byte => 1,
|
||||
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.Date => 2,
|
||||
S7DataType.Int32 or S7DataType.UInt32 or S7DataType.Float32
|
||||
or S7DataType.Time or S7DataType.TimeOfDay => 4,
|
||||
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 => 8,
|
||||
_ => throw new InvalidOperationException($"S7 array element bytes undefined for {t}"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Slice a flat S7 byte buffer into a typed array using the existing big-endian scalar
|
||||
/// codec for each element. Returns the typed array boxed as <c>object</c> so the
|
||||
/// <see cref="DataValueSnapshot"/> surface can carry it without further conversion.
|
||||
/// </summary>
|
||||
internal static object SliceArray(byte[] bytes, S7DataType t, int n, int elemBytes)
|
||||
{
|
||||
switch (t)
|
||||
{
|
||||
case S7DataType.Byte:
|
||||
{
|
||||
var a = new byte[n];
|
||||
Buffer.BlockCopy(bytes, 0, a, 0, n);
|
||||
return a;
|
||||
}
|
||||
case S7DataType.Int16:
|
||||
{
|
||||
var a = new short[n];
|
||||
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt16BigEndian(bytes.AsSpan(i * elemBytes, 2));
|
||||
return a;
|
||||
}
|
||||
case S7DataType.UInt16:
|
||||
{
|
||||
var a = new ushort[n];
|
||||
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(i * elemBytes, 2));
|
||||
return a;
|
||||
}
|
||||
case S7DataType.Int32:
|
||||
{
|
||||
var a = new int[n];
|
||||
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt32BigEndian(bytes.AsSpan(i * elemBytes, 4));
|
||||
return a;
|
||||
}
|
||||
case S7DataType.UInt32:
|
||||
{
|
||||
var a = new uint[n];
|
||||
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(i * elemBytes, 4));
|
||||
return a;
|
||||
}
|
||||
case S7DataType.Int64:
|
||||
{
|
||||
var a = new long[n];
|
||||
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt64BigEndian(bytes.AsSpan(i * elemBytes, 8));
|
||||
return a;
|
||||
}
|
||||
case S7DataType.UInt64:
|
||||
{
|
||||
var a = new ulong[n];
|
||||
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt64BigEndian(bytes.AsSpan(i * elemBytes, 8));
|
||||
return a;
|
||||
}
|
||||
case S7DataType.Float32:
|
||||
{
|
||||
var a = new float[n];
|
||||
for (var i = 0; i < n; i++)
|
||||
a[i] = BitConverter.UInt32BitsToSingle(BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(i * elemBytes, 4)));
|
||||
return a;
|
||||
}
|
||||
case S7DataType.Float64:
|
||||
{
|
||||
var a = new double[n];
|
||||
for (var i = 0; i < n; i++)
|
||||
a[i] = BitConverter.UInt64BitsToDouble(BinaryPrimitives.ReadUInt64BigEndian(bytes.AsSpan(i * elemBytes, 8)));
|
||||
return a;
|
||||
}
|
||||
case S7DataType.Date:
|
||||
{
|
||||
var a = new DateTime[n];
|
||||
for (var i = 0; i < n; i++)
|
||||
a[i] = S7DateTimeCodec.DecodeDate(bytes.AsSpan(i * elemBytes, 2));
|
||||
return a;
|
||||
}
|
||||
case S7DataType.Time:
|
||||
{
|
||||
// Surface as Int32 ms — matches the scalar Time read path (driver-specs §5).
|
||||
var a = new int[n];
|
||||
for (var i = 0; i < n; i++)
|
||||
a[i] = (int)S7DateTimeCodec.DecodeTime(bytes.AsSpan(i * elemBytes, 4)).TotalMilliseconds;
|
||||
return a;
|
||||
}
|
||||
case S7DataType.TimeOfDay:
|
||||
{
|
||||
var a = new int[n];
|
||||
for (var i = 0; i < n; i++)
|
||||
a[i] = (int)S7DateTimeCodec.DecodeTod(bytes.AsSpan(i * elemBytes, 4)).TotalMilliseconds;
|
||||
return a;
|
||||
}
|
||||
default:
|
||||
throw new InvalidOperationException($"S7 array slice undefined for {t}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack a caller-supplied array (object) into the on-wire S7 byte layout for
|
||||
/// <paramref name="elementType"/>. Accepts both the strongly-typed array
|
||||
/// (<c>short[]</c>, <c>int[]</c>, ...) and a generic <c>System.Array</c> / <c>IEnumerable</c>
|
||||
/// so OPC UA Variant-boxed values flow through unchanged.
|
||||
/// </summary>
|
||||
internal static byte[] PackArray(object value, S7DataType elementType, int n, int elemBytes, string tagName)
|
||||
{
|
||||
if (value is not System.Collections.IEnumerable enumerable)
|
||||
throw new ArgumentException($"S7 Write tag '{tagName}' is array but value is not enumerable (got {value.GetType().Name})", nameof(value));
|
||||
|
||||
var buf = new byte[n * elemBytes];
|
||||
var i = 0;
|
||||
foreach (var raw in enumerable)
|
||||
{
|
||||
if (i >= n)
|
||||
throw new ArgumentException($"S7 Write tag '{tagName}': value has more than ElementCount={n} elements", nameof(value));
|
||||
var span = buf.AsSpan(i * elemBytes, elemBytes);
|
||||
switch (elementType)
|
||||
{
|
||||
case S7DataType.Byte: span[0] = Convert.ToByte(raw); break;
|
||||
case S7DataType.Int16: BinaryPrimitives.WriteInt16BigEndian(span, Convert.ToInt16(raw)); break;
|
||||
case S7DataType.UInt16: BinaryPrimitives.WriteUInt16BigEndian(span, Convert.ToUInt16(raw)); break;
|
||||
case S7DataType.Int32: BinaryPrimitives.WriteInt32BigEndian(span, Convert.ToInt32(raw)); break;
|
||||
case S7DataType.UInt32: BinaryPrimitives.WriteUInt32BigEndian(span, Convert.ToUInt32(raw)); break;
|
||||
case S7DataType.Int64: BinaryPrimitives.WriteInt64BigEndian(span, Convert.ToInt64(raw)); break;
|
||||
case S7DataType.UInt64: BinaryPrimitives.WriteUInt64BigEndian(span, Convert.ToUInt64(raw)); break;
|
||||
case S7DataType.Float32: BinaryPrimitives.WriteUInt32BigEndian(span, BitConverter.SingleToUInt32Bits(Convert.ToSingle(raw))); break;
|
||||
case S7DataType.Float64: BinaryPrimitives.WriteUInt64BigEndian(span, BitConverter.DoubleToUInt64Bits(Convert.ToDouble(raw))); break;
|
||||
case S7DataType.Date:
|
||||
S7DateTimeCodec.EncodeDate(Convert.ToDateTime(raw)).CopyTo(span);
|
||||
break;
|
||||
case S7DataType.Time:
|
||||
S7DateTimeCodec.EncodeTime(raw is TimeSpan ts ? ts : TimeSpan.FromMilliseconds(Convert.ToInt32(raw))).CopyTo(span);
|
||||
break;
|
||||
case S7DataType.TimeOfDay:
|
||||
S7DateTimeCodec.EncodeTod(raw is TimeSpan tod ? tod : TimeSpan.FromMilliseconds(Convert.ToInt64(raw))).CopyTo(span);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"S7 array pack undefined for {elementType}");
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (i != n)
|
||||
throw new ArgumentException($"S7 Write tag '{tagName}': value had {i} elements, expected ElementCount={n}", nameof(value));
|
||||
return buf;
|
||||
}
|
||||
|
||||
private static DriverDataType MapDataType(S7DataType t) => t switch
|
||||
{
|
||||
S7DataType.Bool => DriverDataType.Boolean,
|
||||
S7DataType.Byte => DriverDataType.Int32, // no 8-bit in DriverDataType yet
|
||||
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.Int32 or S7DataType.UInt32 => DriverDataType.Int32,
|
||||
S7DataType.Int64 or S7DataType.UInt64 => DriverDataType.Int32, // widens; lossy for >2^31-1
|
||||
S7DataType.Int16 => DriverDataType.Int16,
|
||||
S7DataType.UInt16 => DriverDataType.UInt16,
|
||||
S7DataType.Int32 => DriverDataType.Int32,
|
||||
S7DataType.UInt32 => DriverDataType.UInt32,
|
||||
S7DataType.Int64 => DriverDataType.Int64,
|
||||
S7DataType.UInt64 => DriverDataType.UInt64,
|
||||
S7DataType.Float32 => DriverDataType.Float32,
|
||||
S7DataType.Float64 => DriverDataType.Float64,
|
||||
S7DataType.String => DriverDataType.String,
|
||||
S7DataType.WString => DriverDataType.String,
|
||||
S7DataType.Char => DriverDataType.String,
|
||||
S7DataType.WChar => DriverDataType.String,
|
||||
S7DataType.DateTime => DriverDataType.DateTime,
|
||||
S7DataType.Dtl => DriverDataType.DateTime,
|
||||
S7DataType.DateAndTime => DriverDataType.DateTime,
|
||||
S7DataType.Date => DriverDataType.DateTime,
|
||||
// S5TIME/TIME/TOD have no Duration type in DriverDataType — surface as Int32 ms
|
||||
// (matching the IEC-1131 representation).
|
||||
S7DataType.S5Time => DriverDataType.Int32,
|
||||
S7DataType.Time => DriverDataType.Int32,
|
||||
S7DataType.TimeOfDay => DriverDataType.Int32,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
|
||||
|
||||
@@ -63,6 +63,24 @@ public sealed class S7DriverOptions
|
||||
/// Running ↔ Stopped transitions.
|
||||
/// </summary>
|
||||
public S7ProbeOptions Probe { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Block-read coalescing gap-merge threshold (bytes). When two same-DB tags are
|
||||
/// within this many bytes of each other the planner folds them into a single
|
||||
/// <c>Plc.ReadBytesAsync</c> request and slices the response client-side. The
|
||||
/// default <see cref="S7BlockCoalescingPlanner.DefaultGapMergeBytes"/> = 16 bytes
|
||||
/// trades a minor over-fetch for one fewer PDU round-trip — over-fetching 16
|
||||
/// bytes is cheaper than the ~30-byte S7 request frame.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Raise the threshold for chatty PLCs where PDU round-trips dominate latency
|
||||
/// (S7-1200 with default 240-byte PDU); lower it when DBs are sparsely populated
|
||||
/// so the over-fetch cost outweighs the saved PDU. Setting to 0 disables gap
|
||||
/// merging entirely — only literally adjacent ranges (gap == 0) coalesce.
|
||||
/// STRING / WSTRING / CHAR / WCHAR / structured-timestamp / array tags always
|
||||
/// opt out of merging regardless of this knob.
|
||||
/// </remarks>
|
||||
public int BlockCoalescingGapBytes { get; init; } = S7BlockCoalescingPlanner.DefaultGapMergeBytes;
|
||||
}
|
||||
|
||||
public sealed class S7ProbeOptions
|
||||
@@ -95,13 +113,23 @@ public sealed class S7ProbeOptions
|
||||
/// value can be written again without side-effects. Unsafe: M (merker) bits or Q (output)
|
||||
/// coils that drive edge-triggered routines in the PLC program.
|
||||
/// </param>
|
||||
/// <param name="ElementCount">
|
||||
/// Optional 1-D array length. <c>null</c> (or <c>1</c>) = scalar tag; <c>> 1</c> = array.
|
||||
/// The driver issues one byte-range read covering <c>ElementCount × bytes-per-element</c>
|
||||
/// and slices client-side via the existing scalar codec. Multi-dim arrays are deferred;
|
||||
/// array-of-UDT lands with PR-S7-D2. Variable-width element types
|
||||
/// (STRING/WSTRING/CHAR/WCHAR) and BOOL (packed bits) are rejected at init time —
|
||||
/// they need bespoke layout handling and are tracked as a follow-up. Capped at 8000 to
|
||||
/// keep the byte-range request inside a single S7 PDU envelope.
|
||||
/// </param>
|
||||
public sealed record S7TagDefinition(
|
||||
string Name,
|
||||
string Address,
|
||||
S7DataType DataType,
|
||||
bool Writable = true,
|
||||
int StringLength = 254,
|
||||
bool WriteIdempotent = false);
|
||||
bool WriteIdempotent = false,
|
||||
int? ElementCount = null);
|
||||
|
||||
public enum S7DataType
|
||||
{
|
||||
@@ -116,5 +144,23 @@ public enum S7DataType
|
||||
Float32,
|
||||
Float64,
|
||||
String,
|
||||
/// <summary>S7 WSTRING: 4-byte header (max-len + actual-len, both UInt16 big-endian) followed by N×2 UTF-16BE bytes; total wire length = 4 + 2 × StringLength.</summary>
|
||||
WString,
|
||||
/// <summary>S7 CHAR: single ASCII byte.</summary>
|
||||
Char,
|
||||
/// <summary>S7 WCHAR: two bytes UTF-16 big-endian.</summary>
|
||||
WChar,
|
||||
DateTime,
|
||||
/// <summary>S7 DTL — 12-byte structured timestamp with year/mon/day/dow/h/m/s/ns; year range 1970-2554.</summary>
|
||||
Dtl,
|
||||
/// <summary>S7 DATE_AND_TIME (DT) — 8-byte BCD timestamp; year range 1990-2089.</summary>
|
||||
DateAndTime,
|
||||
/// <summary>S7 S5TIME — 16-bit BCD duration with 2-bit timebase; range 0..9990s. Surfaced as Int32 ms.</summary>
|
||||
S5Time,
|
||||
/// <summary>S7 TIME — signed Int32 ms big-endian. Surfaced as Int32 ms (negative durations allowed).</summary>
|
||||
Time,
|
||||
/// <summary>S7 TIME_OF_DAY (TOD) — UInt32 ms since midnight big-endian; range 0..86399999. Surfaced as Int32 ms.</summary>
|
||||
TimeOfDay,
|
||||
/// <summary>S7 DATE — UInt16 days since 1990-01-01 big-endian. Surfaced as DateTime.</summary>
|
||||
Date,
|
||||
}
|
||||
|
||||
190
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ReadPacker.cs
Normal file
190
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ReadPacker.cs
Normal file
@@ -0,0 +1,190 @@
|
||||
using S7.Net;
|
||||
using S7.Net.Types;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
/// Multi-variable PDU packer for S7 reads. Replaces the per-tag <c>Plc.ReadAsync</c>
|
||||
/// loop with batched <c>Plc.ReadMultipleVarsAsync</c> calls so that N scalar tags fit
|
||||
/// into ⌈N / 19⌉ PDU round-trips on a default 240-byte negotiated PDU instead of N.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Packing budget</b>: Siemens S7 read response budget is
|
||||
/// <c>negotiatedPduSize - 18 - 12·N</c>, where the 18 bytes cover the response
|
||||
/// header / parameter headers and 12 bytes per item carry the per-variable item
|
||||
/// response (return code + data header + value). For a 240-byte PDU the absolute
|
||||
/// ceiling is ~19 items per request before the response overflows; we apply that
|
||||
/// as a conservative cap regardless of negotiated PDU since S7.Net does not
|
||||
/// expose the negotiated size and 240 is the default for every CPU family.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Packable types only</b>: only fixed-width scalars where the wire layout
|
||||
/// maps 1-to-1 onto an <see cref="VarType"/> the multi-var path natively decodes
|
||||
/// (Bool, Byte, Int16/UInt16, Int32/UInt32, Float32, Float64). Strings, dates,
|
||||
/// arrays, 64-bit ints, and UDT-shaped types stay on the per-tag
|
||||
/// <c>ReadOneAsync</c> path because their decode requires
|
||||
/// <c>Plc.ReadBytesAsync</c> + bespoke codec rather than a single
|
||||
/// <see cref="DataItem"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class S7ReadPacker
|
||||
{
|
||||
/// <summary>
|
||||
/// Default negotiated S7 PDU size (bytes). Every S7 CPU family negotiates 240 by
|
||||
/// default; the extended-PDU 480 / 960 byte settings need an explicit COTP
|
||||
/// parameter that S7.Net does not expose. Stay conservative.
|
||||
/// </summary>
|
||||
internal const int DefaultPduSize = 240;
|
||||
|
||||
/// <summary>
|
||||
/// Per-item response overhead in bytes — return code + data type code + length
|
||||
/// field. The S7 spec calls this 4 bytes minimum but rounds up to 12 once the
|
||||
/// payload alignment + worst-case 8-byte LReal value field are included.
|
||||
/// </summary>
|
||||
internal const int PerItemResponseBytes = 12;
|
||||
|
||||
/// <summary>Fixed response-header bytes regardless of item count.</summary>
|
||||
internal const int ResponseHeaderBytes = 18;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum items per PDU at the default 240-byte negotiated size. Derived from
|
||||
/// <c>floor((240 - 18) / 12) = 18.5</c> rounded down to 18 plus 1 for a
|
||||
/// response-header slack the S7 spec rounds up; the practical Siemens limit
|
||||
/// documented in TIA Portal is 19 items per <c>PUT</c>/<c>GET</c> call so we cap
|
||||
/// at 19 and rely on the budget calculation only when a non-default PDU is in
|
||||
/// play.
|
||||
/// </summary>
|
||||
internal const int MaxItemsPerPdu240 = 19;
|
||||
|
||||
/// <summary>
|
||||
/// Compute how many items can fit in one <c>Plc.ReadMultipleVarsAsync</c>
|
||||
/// call at the given negotiated PDU size, capped at the practical Siemens
|
||||
/// ceiling of 19 items.
|
||||
/// </summary>
|
||||
internal static int ItemBudget(int negotiatedPduSize)
|
||||
{
|
||||
if (negotiatedPduSize <= ResponseHeaderBytes + PerItemResponseBytes)
|
||||
return 1;
|
||||
var byBudget = (negotiatedPduSize - ResponseHeaderBytes) / PerItemResponseBytes;
|
||||
return Math.Min(byBudget, MaxItemsPerPdu240);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if the tag can be packed into a single <see cref="DataItem"/> for
|
||||
/// <c>Plc.ReadMultipleVarsAsync</c>. Returns false for everything that
|
||||
/// needs a custom byte-range decode (strings, dates, arrays, UDTs, 64-bit ints
|
||||
/// where S7.Net's <see cref="VarType"/> has no entry).
|
||||
/// </summary>
|
||||
internal static bool IsPackable(S7TagDefinition tag, S7ParsedAddress addr)
|
||||
{
|
||||
if (tag.ElementCount is int n && n > 1) return false; // arrays go through ReadOneAsync
|
||||
return tag.DataType switch
|
||||
{
|
||||
S7DataType.Bool when addr.Size == S7Size.Bit => true,
|
||||
S7DataType.Byte when addr.Size == S7Size.Byte => true,
|
||||
S7DataType.Int16 or S7DataType.UInt16 when addr.Size == S7Size.Word => true,
|
||||
S7DataType.Int32 or S7DataType.UInt32 when addr.Size == S7Size.DWord => true,
|
||||
S7DataType.Float32 when addr.Size == S7Size.DWord => true,
|
||||
S7DataType.Float64 when addr.Size == S7Size.LWord => true,
|
||||
// Int64 / UInt64 have no native VarType; S7.Net's multi-var path can't decode
|
||||
// them without falling back to byte-range reads. Route to ReadOneAsync.
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="DataItem"/> for a packable tag. <see cref="VarType"/> is
|
||||
/// chosen so that S7.Net's multi-var path decodes the wire bytes into a .NET type
|
||||
/// this driver can reinterpret without a second PLC round-trip
|
||||
/// (Word→ushort, DWord→uint, etc.).
|
||||
/// </summary>
|
||||
internal static DataItem BuildDataItem(S7TagDefinition tag, S7ParsedAddress addr)
|
||||
{
|
||||
var dataType = MapArea(addr.Area);
|
||||
var varType = tag.DataType switch
|
||||
{
|
||||
S7DataType.Bool => VarType.Bit,
|
||||
S7DataType.Byte => VarType.Byte,
|
||||
// Int16 read via Word (UInt16 wire) and reinterpreted to short in
|
||||
// DecodePackedValue; gives identical wire behaviour to the single-tag path.
|
||||
S7DataType.Int16 => VarType.Word,
|
||||
S7DataType.UInt16 => VarType.Word,
|
||||
S7DataType.Int32 => VarType.DWord,
|
||||
S7DataType.UInt32 => VarType.DWord,
|
||||
S7DataType.Float32 => VarType.Real,
|
||||
S7DataType.Float64 => VarType.LReal,
|
||||
_ => throw new InvalidOperationException(
|
||||
$"S7ReadPacker: tag '{tag.Name}' DataType {tag.DataType} is not packable; IsPackable check skipped"),
|
||||
};
|
||||
return new DataItem
|
||||
{
|
||||
DataType = dataType,
|
||||
VarType = varType,
|
||||
DB = addr.DbNumber,
|
||||
StartByteAdr = addr.ByteOffset,
|
||||
BitAdr = (byte)addr.BitOffset,
|
||||
Count = 1,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert the boxed value S7.Net's multi-var path returns into the .NET type
|
||||
/// declared by <paramref name="tag"/>. Mirrors the reinterpret table in
|
||||
/// <c>S7Driver.ReadOneAsync</c> so packed reads and single-tag reads produce
|
||||
/// identical snapshots for the same input.
|
||||
/// </summary>
|
||||
internal static object DecodePackedValue(S7TagDefinition tag, object raw)
|
||||
{
|
||||
return (tag.DataType, raw) switch
|
||||
{
|
||||
(S7DataType.Bool, bool b) => b,
|
||||
(S7DataType.Byte, byte by) => by,
|
||||
(S7DataType.UInt16, ushort u16) => u16,
|
||||
(S7DataType.Int16, ushort u16) => unchecked((short)u16),
|
||||
(S7DataType.UInt32, uint u32) => u32,
|
||||
(S7DataType.Int32, uint u32) => unchecked((int)u32),
|
||||
(S7DataType.Float32, float f) => f,
|
||||
(S7DataType.Float64, double d) => d,
|
||||
// S7.Net occasionally hands back the underlying integer type for Real/LReal
|
||||
// when the bytes were marshalled raw — reinterpret defensively.
|
||||
(S7DataType.Float32, uint u32) => BitConverter.UInt32BitsToSingle(u32),
|
||||
(S7DataType.Float64, ulong u64) => BitConverter.UInt64BitsToDouble(u64),
|
||||
_ => throw new System.IO.InvalidDataException(
|
||||
$"S7ReadPacker: tag '{tag.Name}' declared {tag.DataType} but multi-var returned {raw.GetType().Name}"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bin-pack <paramref name="indices"/> into batches of at most
|
||||
/// <paramref name="itemBudget"/> items. Order within each batch matches the
|
||||
/// input order so the per-item response from S7.Net maps back 1-to-1.
|
||||
/// </summary>
|
||||
internal static List<List<int>> BinPack(IReadOnlyList<int> indices, int itemBudget)
|
||||
{
|
||||
var batches = new List<List<int>>();
|
||||
var current = new List<int>(itemBudget);
|
||||
foreach (var idx in indices)
|
||||
{
|
||||
current.Add(idx);
|
||||
if (current.Count >= itemBudget)
|
||||
{
|
||||
batches.Add(current);
|
||||
current = new List<int>(itemBudget);
|
||||
}
|
||||
}
|
||||
if (current.Count > 0) batches.Add(current);
|
||||
return batches;
|
||||
}
|
||||
|
||||
private static DataType MapArea(S7Area area) => area switch
|
||||
{
|
||||
S7Area.DataBlock => DataType.DataBlock,
|
||||
S7Area.Memory => DataType.Memory,
|
||||
S7Area.Input => DataType.Input,
|
||||
S7Area.Output => DataType.Output,
|
||||
S7Area.Timer => DataType.Timer,
|
||||
S7Area.Counter => DataType.Counter,
|
||||
_ => throw new InvalidOperationException($"Unknown S7Area {area}"),
|
||||
};
|
||||
}
|
||||
166
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7StringCodec.cs
Normal file
166
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7StringCodec.cs
Normal file
@@ -0,0 +1,166 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
/// Byte-level codecs for the four Siemens S7 string-shaped types: STRING, WSTRING,
|
||||
/// CHAR, WCHAR. Pulled out of <see cref="S7Driver"/> so the encoding rules are
|
||||
/// unit-testable against golden byte vectors without standing up a Plc instance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire formats (all big-endian, matching S7's native byte order):
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <b>STRING</b>: 2-byte header (<c>maxLen</c> byte, <c>actualLen</c> byte) +
|
||||
/// N ASCII bytes. Total slot size on the PLC = <c>2 + maxLen</c>. Bytes past
|
||||
/// <c>actualLen</c> are unspecified — the codec ignores them on read.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>WSTRING</b>: 4-byte header (<c>maxLen</c> UInt16 BE, <c>actualLen</c>
|
||||
/// UInt16 BE) + N × 2 UTF-16BE bytes. Total slot size on the PLC =
|
||||
/// <c>4 + 2 × maxLen</c>.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>CHAR</b>: 1 ASCII byte.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>WCHAR</b>: 2 UTF-16BE bytes.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// <b>Header-bug clamp</b>: certain S7 firmware revisions write
|
||||
/// <c>actualLen > maxLen</c> (observed with NULL-padded buffers from older
|
||||
/// CP-modules). On <i>read</i> the codec clamps the effective length so it never
|
||||
/// walks past the wire buffer. On <i>write</i> the codec rejects the input
|
||||
/// outright — silently truncating produces silent data loss.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class S7StringCodec
|
||||
{
|
||||
/// <summary>Buffer size for a STRING tag with the given declared <paramref name="maxLen"/>.</summary>
|
||||
public static int StringBufferSize(int maxLen) => 2 + maxLen;
|
||||
|
||||
/// <summary>Buffer size for a WSTRING tag with the given declared <paramref name="maxLen"/>.</summary>
|
||||
public static int WStringBufferSize(int maxLen) => 4 + (2 * maxLen);
|
||||
|
||||
/// <summary>
|
||||
/// Decode an S7 STRING wire buffer into a .NET string. <paramref name="bytes"/>
|
||||
/// must be exactly <c>2 + maxLen</c> long. <c>actualLen</c> is clamped to the
|
||||
/// declared <paramref name="maxLen"/> if the firmware reported an out-of-spec
|
||||
/// value (header-bug tolerance).
|
||||
/// </summary>
|
||||
public static string DecodeString(ReadOnlySpan<byte> bytes, int maxLen)
|
||||
{
|
||||
if (maxLen is < 1 or > 254)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxLen), maxLen, "S7 STRING max length must be 1-254");
|
||||
var expected = StringBufferSize(maxLen);
|
||||
if (bytes.Length != expected)
|
||||
throw new InvalidDataException($"S7 STRING expected {expected} bytes, got {bytes.Length}");
|
||||
|
||||
// bytes[0] = declared max-length (advisory; we trust the caller-provided maxLen).
|
||||
// bytes[1] = actual length. Clamp on read — firmware bug fallback.
|
||||
int actual = bytes[1];
|
||||
if (actual > maxLen) actual = maxLen;
|
||||
if (actual == 0) return string.Empty;
|
||||
return Encoding.ASCII.GetString(bytes.Slice(2, actual));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encode a .NET string into an S7 STRING wire buffer of length
|
||||
/// <c>2 + maxLen</c>. ASCII only — non-ASCII characters are encoded as <c>?</c>
|
||||
/// by <see cref="Encoding.ASCII"/>. Throws if <paramref name="value"/> is longer
|
||||
/// than <paramref name="maxLen"/>.
|
||||
/// </summary>
|
||||
public static byte[] EncodeString(string value, int maxLen)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
if (maxLen is < 1 or > 254)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxLen), maxLen, "S7 STRING max length must be 1-254");
|
||||
if (value.Length > maxLen)
|
||||
throw new ArgumentException(
|
||||
$"S7 STRING value of length {value.Length} exceeds declared max {maxLen}", nameof(value));
|
||||
|
||||
var buf = new byte[StringBufferSize(maxLen)];
|
||||
buf[0] = (byte)maxLen;
|
||||
buf[1] = (byte)value.Length;
|
||||
Encoding.ASCII.GetBytes(value, 0, value.Length, buf, 2);
|
||||
// Trailing bytes [2 + value.Length .. end] left as 0x00; S7 PLCs treat them as
|
||||
// don't-care because actualLen bounds the readable region.
|
||||
return buf;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode an S7 WSTRING wire buffer into a .NET string. <paramref name="bytes"/>
|
||||
/// must be exactly <c>4 + 2 × maxLen</c> long. <c>actualLen</c> is clamped to
|
||||
/// <paramref name="maxLen"/> on read.
|
||||
/// </summary>
|
||||
public static string DecodeWString(ReadOnlySpan<byte> bytes, int maxLen)
|
||||
{
|
||||
if (maxLen < 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxLen), maxLen, "S7 WSTRING max length must be >= 1");
|
||||
var expected = WStringBufferSize(maxLen);
|
||||
if (bytes.Length != expected)
|
||||
throw new InvalidDataException($"S7 WSTRING expected {expected} bytes, got {bytes.Length}");
|
||||
|
||||
// Header is two UInt16 BE: declared max-len and actual-len (both in characters).
|
||||
int actual = BinaryPrimitives.ReadUInt16BigEndian(bytes.Slice(2, 2));
|
||||
if (actual > maxLen) actual = maxLen;
|
||||
if (actual == 0) return string.Empty;
|
||||
return Encoding.BigEndianUnicode.GetString(bytes.Slice(4, actual * 2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encode a .NET string into an S7 WSTRING wire buffer of length
|
||||
/// <c>4 + 2 × maxLen</c>. Throws if <paramref name="value"/> has more than
|
||||
/// <paramref name="maxLen"/> UTF-16 code units.
|
||||
/// </summary>
|
||||
public static byte[] EncodeWString(string value, int maxLen)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
if (maxLen < 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxLen), maxLen, "S7 WSTRING max length must be >= 1");
|
||||
if (value.Length > maxLen)
|
||||
throw new ArgumentException(
|
||||
$"S7 WSTRING value of length {value.Length} exceeds declared max {maxLen}", nameof(value));
|
||||
|
||||
var buf = new byte[WStringBufferSize(maxLen)];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), (ushort)maxLen);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(2, 2), (ushort)value.Length);
|
||||
if (value.Length > 0)
|
||||
Encoding.BigEndianUnicode.GetBytes(value, 0, value.Length, buf, 4);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/// <summary>Decode a single S7 CHAR (one ASCII byte).</summary>
|
||||
public static char DecodeChar(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length != 1)
|
||||
throw new InvalidDataException($"S7 CHAR expected 1 byte, got {bytes.Length}");
|
||||
return (char)bytes[0];
|
||||
}
|
||||
|
||||
/// <summary>Encode a single ASCII char into an S7 CHAR (one byte). Non-ASCII rejected.</summary>
|
||||
public static byte[] EncodeChar(char value)
|
||||
{
|
||||
if (value > 0x7F)
|
||||
throw new ArgumentException($"S7 CHAR value '{value}' (U+{(int)value:X4}) is not ASCII", nameof(value));
|
||||
return [(byte)value];
|
||||
}
|
||||
|
||||
/// <summary>Decode a single S7 WCHAR (two bytes UTF-16 big-endian).</summary>
|
||||
public static char DecodeWChar(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length != 2)
|
||||
throw new InvalidDataException($"S7 WCHAR expected 2 bytes, got {bytes.Length}");
|
||||
return (char)BinaryPrimitives.ReadUInt16BigEndian(bytes);
|
||||
}
|
||||
|
||||
/// <summary>Encode a single char into an S7 WCHAR (two bytes UTF-16 big-endian).</summary>
|
||||
public static byte[] EncodeWChar(char value)
|
||||
{
|
||||
var buf = new byte[2];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf, value);
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.S7.Tests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using TwinCAT;
|
||||
using TwinCAT.Ads;
|
||||
using TwinCAT.Ads.SumCommand;
|
||||
using TwinCAT.Ads.TypeSystem;
|
||||
using TwinCAT.TypeSystem;
|
||||
|
||||
@@ -24,6 +26,11 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
private readonly AdsClient _client = new();
|
||||
private readonly ConcurrentDictionary<uint, NotificationRegistration> _notifications = new();
|
||||
|
||||
// Per-parent-symbol RMW locks. Keys are bounded by the writable-bit-tag cardinality
|
||||
// and are intentionally never removed — a leaking-but-bounded dictionary is simpler
|
||||
// than tracking liveness, matching the AbCip / Modbus / FOCAS pattern from #181.
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _bitWriteLocks = new();
|
||||
|
||||
public AdsTwinCATClient()
|
||||
{
|
||||
_client.AdsNotificationEx += OnAdsNotificationEx;
|
||||
@@ -44,20 +51,30 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
int[]? arrayDimensions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var clrType = MapToClrType(type);
|
||||
var result = await _client.ReadValueAsync(symbolPath, clrType, cancellationToken)
|
||||
var readType = IsWholeArray(arrayDimensions) ? clrType.MakeArrayType() : clrType;
|
||||
|
||||
var result = await _client.ReadValueAsync(symbolPath, readType, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result.ErrorCode != AdsErrorCode.NoError)
|
||||
return (null, TwinCATStatusMapper.MapAdsError((uint)result.ErrorCode));
|
||||
|
||||
var value = result.Value;
|
||||
if (IsWholeArray(arrayDimensions))
|
||||
{
|
||||
value = PostProcessArray(type, value);
|
||||
return (value, TwinCATStatusMapper.Good);
|
||||
}
|
||||
|
||||
if (bitIndex is int bit && type == TwinCATDataType.Bool && value is not bool)
|
||||
value = ExtractBit(value, bit);
|
||||
value = PostProcessIecTime(type, value);
|
||||
|
||||
return (value, TwinCATStatusMapper.Good);
|
||||
}
|
||||
@@ -67,16 +84,43 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsWholeArray(int[]? arrayDimensions) =>
|
||||
arrayDimensions is { Length: > 0 } && arrayDimensions.All(d => d > 0);
|
||||
|
||||
/// <summary>Apply per-element IEC TIME/DATE post-processing to a flat array result.</summary>
|
||||
private static object? PostProcessArray(TwinCATDataType type, object? value)
|
||||
{
|
||||
if (value is not Array arr) return value;
|
||||
var elementProjector = type switch
|
||||
{
|
||||
TwinCATDataType.Time or TwinCATDataType.TimeOfDay
|
||||
or TwinCATDataType.Date or TwinCATDataType.DateTime
|
||||
=> (Func<object?, object?>)(v => PostProcessIecTime(type, v)),
|
||||
_ => null,
|
||||
};
|
||||
if (elementProjector is null) return arr;
|
||||
// IEC time post-processing changes the CLR element type (uint -> TimeSpan / DateTime).
|
||||
// Project into an object[] so the array element type matches the projected values.
|
||||
var projected = new object?[arr.Length];
|
||||
for (var i = 0; i < arr.Length; i++)
|
||||
projected[i] = elementProjector(arr.GetValue(i));
|
||||
return projected;
|
||||
}
|
||||
|
||||
public async Task<uint> WriteValueAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
int[]? arrayDimensions,
|
||||
object? value,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (bitIndex is int && type == TwinCATDataType.Bool)
|
||||
throw new NotSupportedException(
|
||||
"BOOL-within-word writes require read-modify-write; tracked in task #181.");
|
||||
if (IsWholeArray(arrayDimensions))
|
||||
return TwinCATStatusMapper.BadNotSupported; // PR-1.4 ships read-only whole-array
|
||||
|
||||
if (bitIndex is int bit && type == TwinCATDataType.Bool)
|
||||
return await WriteBitInWordAsync(symbolPath, bit, value, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -93,6 +137,69 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-modify-write a single bit within an integer parent word. <paramref name="symbolPath"/>
|
||||
/// is the bit-selector path (e.g. <c>Flags.3</c>); the parent is the same path with the
|
||||
/// <c>.N</c> suffix stripped and is read/written as a UDINT — TwinCAT handles narrower
|
||||
/// parents (BYTE/WORD) implicitly through the UDINT projection.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Concurrent bit writers against the same parent are serialised through a per-parent
|
||||
/// <see cref="SemaphoreSlim"/> to prevent torn reads/writes. Mirrors the AbCip / Modbus /
|
||||
/// FOCAS bit-RMW pattern.
|
||||
/// </remarks>
|
||||
private async Task<uint> WriteBitInWordAsync(
|
||||
string symbolPath, int bit, object? value, CancellationToken cancellationToken)
|
||||
{
|
||||
var parentPath = TryGetParentSymbolPath(symbolPath);
|
||||
if (parentPath is null) return TwinCATStatusMapper.BadNotSupported;
|
||||
|
||||
var setBit = Convert.ToBoolean(value);
|
||||
var rmwLock = _bitWriteLocks.GetOrAdd(parentPath, _ => new SemaphoreSlim(1, 1));
|
||||
await rmwLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var read = await _client.ReadValueAsync(parentPath, typeof(uint), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (read.ErrorCode != AdsErrorCode.NoError)
|
||||
return TwinCATStatusMapper.MapAdsError((uint)read.ErrorCode);
|
||||
|
||||
var current = Convert.ToUInt32(read.Value ?? 0u);
|
||||
var updated = ApplyBit(current, bit, setBit);
|
||||
|
||||
var write = await _client.WriteValueAsync(parentPath, updated, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return write.ErrorCode == AdsErrorCode.NoError
|
||||
? TwinCATStatusMapper.Good
|
||||
: TwinCATStatusMapper.MapAdsError((uint)write.ErrorCode);
|
||||
}
|
||||
catch (AdsErrorException ex)
|
||||
{
|
||||
return TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
rmwLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strip the trailing <c>.N</c> bit selector from a TwinCAT symbol path. Returns
|
||||
/// <c>null</c> when the path has no parent (single segment / leading dot).
|
||||
/// </summary>
|
||||
internal static string? TryGetParentSymbolPath(string symbolPath)
|
||||
{
|
||||
var dot = symbolPath.LastIndexOf('.');
|
||||
return dot <= 0 ? null : symbolPath.Substring(0, dot);
|
||||
}
|
||||
|
||||
/// <summary>Set or clear bit <paramref name="bit"/> in <paramref name="word"/>.</summary>
|
||||
internal static uint ApplyBit(uint word, int bit, bool setBit)
|
||||
{
|
||||
var mask = 1u << bit;
|
||||
return setBit ? (word | mask) : (word & ~mask);
|
||||
}
|
||||
|
||||
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -143,6 +250,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
var value = args.Value;
|
||||
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool)
|
||||
value = ExtractBit(value, bit);
|
||||
value = PostProcessIecTime(reg.Type, value);
|
||||
try { reg.OnChange(reg.SymbolPath, value); } catch { /* consumer-side errors don't crash the ADS thread */ }
|
||||
}
|
||||
|
||||
@@ -166,12 +274,50 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
foreach (ISymbol symbol in loader.Symbols)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) yield break;
|
||||
var mapped = MapSymbolTypeName(symbol.DataType?.Name);
|
||||
var mapped = ResolveSymbolDataType(symbol.DataType);
|
||||
var readOnly = !IsSymbolWritable(symbol);
|
||||
yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve an IEC atomic <see cref="TwinCATDataType"/> for a TwinCAT symbol's data type.
|
||||
/// ENUMs surface as their underlying integer (the enum's <c>BaseType</c>); ALIAS chains
|
||||
/// are walked recursively via <see cref="IAliasType.BaseType"/> until an atomic primitive
|
||||
/// is reached. POINTER / REFERENCE / INTERFACE / UNION / STRUCT / FB / array types remain
|
||||
/// out of scope and surface as <c>null</c> so the caller skips them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Recursion is bounded at <see cref="MaxAliasDepth"/> as a defence against pathological
|
||||
/// cycles in the type graph — TwinCAT shouldn't emit those, but this is cheap insurance.
|
||||
/// </remarks>
|
||||
internal const int MaxAliasDepth = 16;
|
||||
|
||||
internal static TwinCATDataType? ResolveSymbolDataType(IDataType? dataType)
|
||||
{
|
||||
var current = dataType;
|
||||
for (var depth = 0; current is not null && depth < MaxAliasDepth; depth++)
|
||||
{
|
||||
switch (current.Category)
|
||||
{
|
||||
case DataTypeCategory.Primitive:
|
||||
case DataTypeCategory.String:
|
||||
return MapSymbolTypeName(current.Name);
|
||||
case DataTypeCategory.Enum:
|
||||
case DataTypeCategory.Alias:
|
||||
// IEnumType : IAliasType, so BaseType walk handles both. For an enum the
|
||||
// base type is the underlying integer; for alias chains it's the next link.
|
||||
if (current is IAliasType alias) { current = alias.BaseType; continue; }
|
||||
return null;
|
||||
default:
|
||||
// POINTER / REFERENCE / INTERFACE / UNION / STRUCT / ARRAY / FB / Program —
|
||||
// explicitly out of scope at this PR.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static TwinCATDataType? MapSymbolTypeName(string? typeName) => typeName switch
|
||||
{
|
||||
"BOOL" or "BIT" => TwinCATDataType.Bool,
|
||||
@@ -203,6 +349,111 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<(object? value, uint status)>> ReadValuesAsync(
|
||||
IReadOnlyList<TwinCATBulkReadItem> reads, CancellationToken cancellationToken)
|
||||
{
|
||||
if (reads.Count == 0) return Array.Empty<(object?, uint)>();
|
||||
|
||||
// Build the (path, AnyTypeSpecifier) request envelope. SumInstancePathAnyTypeRead
|
||||
// batches all paths into a single ADS Sum-read round-trip (IndexGroup 0xF080 = read
|
||||
// multiple items by symbol name with ANY-type marshalling).
|
||||
var typeSpecs = new List<(string instancePath, AnyTypeSpecifier spec)>(reads.Count);
|
||||
foreach (var r in reads)
|
||||
typeSpecs.Add((r.SymbolPath, BuildAnyTypeSpecifier(r.Type, r.StringLength)));
|
||||
|
||||
var sumCmd = new SumInstancePathAnyTypeRead(_client, typeSpecs);
|
||||
|
||||
try
|
||||
{
|
||||
var sumResult = await sumCmd.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// ResultSumValues2.ValueResults is a per-item array with Source / Value /
|
||||
// ErrorCode. Even when the overall ADS request succeeds, individual sub-items can
|
||||
// carry their own ADS error (e.g. SymbolNotFound).
|
||||
var output = new (object? value, uint status)[reads.Count];
|
||||
var valueResults = sumResult.ValueResults;
|
||||
for (var i = 0; i < reads.Count; i++)
|
||||
{
|
||||
var vr = valueResults[i];
|
||||
if (vr.ErrorCode != 0)
|
||||
{
|
||||
output[i] = (null, TwinCATStatusMapper.MapAdsError((uint)vr.ErrorCode));
|
||||
continue;
|
||||
}
|
||||
var raw = vr.Value;
|
||||
output[i] = (PostProcessIecTime(reads[i].Type, raw), TwinCATStatusMapper.Good);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
catch (AdsErrorException ex)
|
||||
{
|
||||
// Whole-batch failure (no symbol-server ack, router unreachable, etc.). Map the
|
||||
// overall ADS status onto every entry so callers see uniform status — partial-
|
||||
// success marshalling lives in the success branch above.
|
||||
var status = TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode);
|
||||
var failed = new (object? value, uint status)[reads.Count];
|
||||
for (var i = 0; i < reads.Count; i++) failed[i] = (null, status);
|
||||
return failed;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<uint>> WriteValuesAsync(
|
||||
IReadOnlyList<TwinCATBulkWriteItem> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
if (writes.Count == 0) return Array.Empty<uint>();
|
||||
|
||||
// SumWriteBySymbolPath internally requests symbol handles + issues a single sum-write
|
||||
// (IndexGroup 0xF081) carrying all values. One AMS round-trip for N writes.
|
||||
var paths = new List<string>(writes.Count);
|
||||
var values = new object[writes.Count];
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
paths.Add(writes[i].SymbolPath);
|
||||
values[i] = ConvertForWrite(writes[i].Type, writes[i].Value);
|
||||
}
|
||||
|
||||
var sumCmd = new SumWriteBySymbolPath(_client, paths);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await sumCmd.WriteAsync(values, cancellationToken).ConfigureAwait(false);
|
||||
var output = new uint[writes.Count];
|
||||
var subErrors = result.SubErrors;
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
// SubErrors can be null when the overall request failed before sub-dispatch —
|
||||
// surface the OverallError on every slot in that case.
|
||||
var code = subErrors is { Length: > 0 } && i < subErrors.Length
|
||||
? (uint)subErrors[i]
|
||||
: (uint)result.ErrorCode;
|
||||
output[i] = TwinCATStatusMapper.MapAdsError(code);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
catch (AdsErrorException ex)
|
||||
{
|
||||
var status = TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode);
|
||||
var failed = new uint[writes.Count];
|
||||
for (var i = 0; i < writes.Count; i++) failed[i] = status;
|
||||
return failed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build an <see cref="AnyTypeSpecifier"/> for one bulk-read entry. STRING uses ASCII +
|
||||
/// the supplied <paramref name="stringLength"/>; WSTRING uses Unicode (UTF-16). All other
|
||||
/// types resolve to a primitive CLR type via <see cref="MapToClrType"/>. IEC time/date
|
||||
/// symbols flow as their underlying UDINT (matching the per-tag path in
|
||||
/// <see cref="ReadValueAsync"/>) and are post-processed CLR-side after the sum-read.
|
||||
/// </summary>
|
||||
private static AnyTypeSpecifier BuildAnyTypeSpecifier(TwinCATDataType type, int stringLength) =>
|
||||
type switch
|
||||
{
|
||||
TwinCATDataType.String => new AnyTypeSpecifier(typeof(string), stringLength, Encoding.ASCII),
|
||||
TwinCATDataType.WString => new AnyTypeSpecifier(typeof(string), stringLength, Encoding.Unicode),
|
||||
_ => new AnyTypeSpecifier(MapToClrType(type)),
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client.AdsNotificationEx -= OnAdsNotificationEx;
|
||||
@@ -249,7 +500,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
_ => typeof(int),
|
||||
};
|
||||
|
||||
private static object ConvertForWrite(TwinCATDataType type, object? value) => type switch
|
||||
internal static object ConvertForWrite(TwinCATDataType type, object? value) => type switch
|
||||
{
|
||||
TwinCATDataType.Bool => Convert.ToBoolean(value),
|
||||
TwinCATDataType.SInt => Convert.ToSByte(value),
|
||||
@@ -263,11 +514,79 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
TwinCATDataType.Real => Convert.ToSingle(value),
|
||||
TwinCATDataType.LReal => Convert.ToDouble(value),
|
||||
TwinCATDataType.String or TwinCATDataType.WString => Convert.ToString(value) ?? string.Empty,
|
||||
TwinCATDataType.Time or TwinCATDataType.Date
|
||||
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => Convert.ToUInt32(value),
|
||||
// IEC durations (TIME / TOD) accept TimeSpan / Duration-as-Double-ms / raw UDINT.
|
||||
// IEC timestamps (DATE / DT) accept DateTime (UTC) / raw UDINT seconds-since-epoch.
|
||||
TwinCATDataType.Time or TwinCATDataType.TimeOfDay => DurationToUDInt(value),
|
||||
TwinCATDataType.Date or TwinCATDataType.DateTime => DateTimeToUDInt(value),
|
||||
_ => throw new NotSupportedException($"TwinCATDataType {type} not writable."),
|
||||
};
|
||||
|
||||
// IEC 61131-3 epoch is 1970-01-01 UTC for DATE / DT; TIME / TOD are unsigned ms counters.
|
||||
private static readonly DateTime IecEpochUtc = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>
|
||||
/// Convert the raw UDINT wire value for IEC TIME/DATE/DT/TOD into the native CLR type
|
||||
/// surfaced upstream — TimeSpan for durations, DateTime (UTC) for timestamps. Other
|
||||
/// types pass through unchanged.
|
||||
/// </summary>
|
||||
internal static object? PostProcessIecTime(TwinCATDataType type, object? value)
|
||||
{
|
||||
if (value is null) return null;
|
||||
var raw = TryGetUInt32(value);
|
||||
if (raw is null) return value;
|
||||
return type switch
|
||||
{
|
||||
// TIME / TOD — UDINT milliseconds.
|
||||
TwinCATDataType.Time or TwinCATDataType.TimeOfDay
|
||||
=> TimeSpan.FromMilliseconds(raw.Value),
|
||||
// DT — UDINT seconds since 1970-01-01 UTC.
|
||||
TwinCATDataType.DateTime
|
||||
=> IecEpochUtc.AddSeconds(raw.Value),
|
||||
// DATE — UDINT seconds since 1970-01-01 UTC, but TwinCAT runtimes pin the time
|
||||
// component to midnight; pass through the same conversion so we get a date-only
|
||||
// value at midnight UTC.
|
||||
TwinCATDataType.Date
|
||||
=> IecEpochUtc.AddSeconds(raw.Value),
|
||||
_ => value,
|
||||
};
|
||||
}
|
||||
|
||||
private static uint? TryGetUInt32(object value) => value switch
|
||||
{
|
||||
uint u => u,
|
||||
int i when i >= 0 => (uint)i,
|
||||
ushort us => (uint)us,
|
||||
short s when s >= 0 => (uint)s,
|
||||
long l when l >= 0 && l <= uint.MaxValue => (uint)l,
|
||||
ulong ul when ul <= uint.MaxValue => (uint)ul,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static uint DurationToUDInt(object? value) => value switch
|
||||
{
|
||||
TimeSpan ts => (uint)Math.Max(0, ts.TotalMilliseconds),
|
||||
// OPC UA Duration on the wire is a Double in milliseconds.
|
||||
double d => (uint)Math.Max(0, d),
|
||||
float f => (uint)Math.Max(0, f),
|
||||
_ => Convert.ToUInt32(value),
|
||||
};
|
||||
|
||||
private static uint DateTimeToUDInt(object? value)
|
||||
{
|
||||
if (value is DateTime dt)
|
||||
{
|
||||
var utc = dt.Kind == DateTimeKind.Unspecified
|
||||
? DateTime.SpecifyKind(dt, DateTimeKind.Utc)
|
||||
: dt.ToUniversalTime();
|
||||
var seconds = (long)(utc - IecEpochUtc).TotalSeconds;
|
||||
if (seconds < 0 || seconds > uint.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(value),
|
||||
"DATE/DT value out of UDINT epoch range (1970-01-01..2106-02-07 UTC).");
|
||||
return (uint)seconds;
|
||||
}
|
||||
return Convert.ToUInt32(value);
|
||||
}
|
||||
|
||||
private static bool ExtractBit(object? rawWord, int bit) => rawWord switch
|
||||
{
|
||||
short s => (s & (1 << bit)) != 0,
|
||||
|
||||
@@ -22,25 +22,64 @@ public interface ITwinCATClient : IDisposable
|
||||
/// <summary>
|
||||
/// Read a symbolic value. Returns a boxed .NET value matching the requested
|
||||
/// <paramref name="type"/>, or <c>null</c> when the read produced no data; the
|
||||
/// <c>status</c> tuple member carries the mapped OPC UA status (0 = Good).
|
||||
/// <c>status</c> tuple member carries the mapped OPC UA status (0 = Good). When
|
||||
/// <paramref name="arrayDimensions"/> is non-null + non-empty, the symbol is treated
|
||||
/// as a whole-array read and the boxed value is a flat 1-D CLR
|
||||
/// <see cref="Array"/> sized to <c>product(arrayDimensions)</c>.
|
||||
/// </summary>
|
||||
Task<(object? value, uint status)> ReadValueAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
int[]? arrayDimensions,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Write a symbolic value. Returns the mapped OPC UA status for the operation
|
||||
/// (0 = Good, non-zero = error mapped via <see cref="TwinCATStatusMapper"/>).
|
||||
/// <paramref name="arrayDimensions"/> mirrors <see cref="ReadValueAsync"/>; PR-1.4
|
||||
/// ships read-only whole-array support so writers may surface <c>BadNotSupported</c>.
|
||||
/// </summary>
|
||||
Task<uint> WriteValueAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
int[]? arrayDimensions,
|
||||
object? value,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Bulk-read N scalar symbols in a single AMS request via Beckhoff's ADS Sum-command
|
||||
/// family (IndexGroup <c>0xF080..0xF084</c>). The result is a parallel array preserving
|
||||
/// <paramref name="reads"/> ordering — element <c>i</c>'s outcome maps to request <c>i</c>.
|
||||
/// Empty input returns an empty result without a wire round-trip.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>This is the throughput-optimised path used by <see cref="TwinCATDriver.ReadAsync"/>
|
||||
/// to replace the per-tag <see cref="ReadValueAsync"/> loop — one ADS sum-read for N
|
||||
/// symbols beats N individual round-trips by ~10× on the typical PLC link.</para>
|
||||
///
|
||||
/// <para>Whole-array reads + bit-extracted BOOL reads stay on the per-tag path because
|
||||
/// the Sum-command surface only marshals scalars + bitIndex needs CLR-side post-processing.
|
||||
/// Callers should pre-filter or fall back as appropriate.</para>
|
||||
/// </remarks>
|
||||
Task<IReadOnlyList<(object? value, uint status)>> ReadValuesAsync(
|
||||
IReadOnlyList<TwinCATBulkReadItem> reads,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Bulk-write N scalar symbols in a single AMS request via Beckhoff's
|
||||
/// <c>SumWriteBySymbolPath</c>. Result is a parallel status array preserving
|
||||
/// <paramref name="writes"/> ordering. Empty input returns an empty result.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Whole-array writes + bit-RMW writes are not in scope for the bulk path — those continue
|
||||
/// through the per-tag <see cref="WriteValueAsync"/> path. The driver layer pre-filters.
|
||||
/// </remarks>
|
||||
Task<IReadOnlyList<uint>> WriteValuesAsync(
|
||||
IReadOnlyList<TwinCATBulkWriteItem> writes,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Cheap health probe — returns <c>true</c> when the target's AMS state is reachable.
|
||||
/// Used by <see cref="Core.Abstractions.IHostConnectivityProbe"/>'s probe loop.
|
||||
@@ -98,3 +137,19 @@ public interface ITwinCATClientFactory
|
||||
{
|
||||
ITwinCATClient Create();
|
||||
}
|
||||
|
||||
/// <summary>One element of an <see cref="ITwinCATClient.ReadValuesAsync"/> request — the symbol path
|
||||
/// + the IEC type for marshalling. Strings carry an explicit <paramref name="StringLength"/> for
|
||||
/// fixed-size <c>STRING(n)</c> declarations (defaults to <c>80</c> matching IEC 61131-3).</summary>
|
||||
public sealed record TwinCATBulkReadItem(
|
||||
string SymbolPath,
|
||||
TwinCATDataType Type,
|
||||
int StringLength = 80);
|
||||
|
||||
/// <summary>One element of an <see cref="ITwinCATClient.WriteValuesAsync"/> request.
|
||||
/// Mirror of <see cref="TwinCATBulkReadItem"/> with the value to push.</summary>
|
||||
public sealed record TwinCATBulkWriteItem(
|
||||
string SymbolPath,
|
||||
TwinCATDataType Type,
|
||||
object? Value,
|
||||
int StringLength = 80);
|
||||
|
||||
@@ -37,12 +37,16 @@ public static class TwinCATDataTypeExtensions
|
||||
TwinCATDataType.SInt or TwinCATDataType.USInt
|
||||
or TwinCATDataType.Int or TwinCATDataType.UInt
|
||||
or TwinCATDataType.DInt or TwinCATDataType.UDInt => DriverDataType.Int32,
|
||||
TwinCATDataType.LInt or TwinCATDataType.ULInt => DriverDataType.Int32, // matches Int64 gap
|
||||
TwinCATDataType.LInt => DriverDataType.Int64,
|
||||
TwinCATDataType.ULInt => DriverDataType.UInt64,
|
||||
TwinCATDataType.Real => DriverDataType.Float32,
|
||||
TwinCATDataType.LReal => DriverDataType.Float64,
|
||||
TwinCATDataType.String or TwinCATDataType.WString => DriverDataType.String,
|
||||
TwinCATDataType.Time or TwinCATDataType.Date
|
||||
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => DriverDataType.Int32,
|
||||
// IEC 61131-3 TIME / TOD are durations (ms); DATE / DT are absolute timestamps.
|
||||
// The wire form is UDINT but the driver post-processes into TimeSpan / DateTime so the
|
||||
// address space surfaces native UA Duration / DateTime instead of opaque integers.
|
||||
TwinCATDataType.Time or TwinCATDataType.TimeOfDay => DriverDataType.Duration,
|
||||
TwinCATDataType.Date or TwinCATDataType.DateTime => DriverDataType.DateTime,
|
||||
TwinCATDataType.Structure => DriverDataType.String,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
|
||||
@@ -108,6 +108,14 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
|
||||
// ---- IReadable ----
|
||||
|
||||
/// <summary>
|
||||
/// Read the supplied tag references in as few AMS round-trips as possible.
|
||||
/// Tags resolved to the same <c>DeviceHostAddress</c> are bucketed + sent as one
|
||||
/// ADS Sum-read (<see cref="ITwinCATClient.ReadValuesAsync"/>) — N tags in one
|
||||
/// request beats N individual <c>ReadValueAsync</c> calls by ~10× for typical PLC
|
||||
/// loads. Tags with bit-extracted BOOL or whole-array shape stay on the per-tag
|
||||
/// path because the sum-read surface only marshals scalars.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -115,6 +123,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
|
||||
// Resolve tag definitions + bucket bulk-eligible reads by device. Anything that
|
||||
// doesn't fit the bulk surface (unknown ref, bit BOOL, whole-array) is processed
|
||||
// through the per-tag path inline so we still return a full result array in
|
||||
// request order.
|
||||
var bulkBuckets = new Dictionary<string, List<(int origIndex, string symbol, TwinCATTagDefinition def, int? bitIndex)>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var reference = fullReferences[i];
|
||||
@@ -123,31 +137,66 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out _))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||
var bitIndex = parsed?.BitIndex;
|
||||
var isWholeArray = def.ArrayDimensions is { Length: > 0 };
|
||||
var isBitBool = bitIndex is int && def.DataType == TwinCATDataType.Bool;
|
||||
|
||||
if (isWholeArray || isBitBool)
|
||||
{
|
||||
// Per-tag fallback path — preserves bit-extract / whole-array logic in
|
||||
// AdsTwinCATClient.ReadValueAsync.
|
||||
results[i] = await ReadOneAsync(reference, def, symbolName, bitIndex, cancellationToken, now)
|
||||
.ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!bulkBuckets.TryGetValue(def.DeviceHostAddress, out var bucket))
|
||||
{
|
||||
bucket = new List<(int, string, TwinCATTagDefinition, int?)>();
|
||||
bulkBuckets[def.DeviceHostAddress] = bucket;
|
||||
}
|
||||
bucket.Add((i, symbolName, def, bitIndex));
|
||||
}
|
||||
|
||||
// One sum-read per device bucket. Ordering inside a bucket is preserved by the
|
||||
// (origIndex, ...) tuple — the result array entry comes from the parallel index.
|
||||
foreach (var (hostAddress, bucket) in bulkBuckets)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device)) continue;
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||
var (value, status) = await client.ReadValueAsync(
|
||||
symbolName, def.DataType, parsed?.BitIndex, cancellationToken).ConfigureAwait(false);
|
||||
var items = new TwinCATBulkReadItem[bucket.Count];
|
||||
for (var k = 0; k < bucket.Count; k++)
|
||||
items[k] = new TwinCATBulkReadItem(bucket[k].symbol, bucket[k].def.DataType);
|
||||
|
||||
results[i] = new DataValueSnapshot(value, status, now, now);
|
||||
if (status == TwinCATStatusMapper.Good)
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
else
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"ADS status {status:X8} reading {reference}");
|
||||
var bulk = await client.ReadValuesAsync(items, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
for (var k = 0; k < bucket.Count; k++)
|
||||
{
|
||||
var (origIndex, _, def, _) = bucket[k];
|
||||
var (value, status) = bulk[k];
|
||||
results[origIndex] = new DataValueSnapshot(value, status, now, now);
|
||||
if (status == TwinCATStatusMapper.Good)
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
else
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"ADS status {status:X8} reading {fullReferences[origIndex]}");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
|
||||
foreach (var (origIndex, _, _, _) in bucket)
|
||||
results[origIndex] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -155,14 +204,53 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<DataValueSnapshot> ReadOneAsync(
|
||||
string reference, TwinCATTagDefinition def, string symbolName, int? bitIndex,
|
||||
CancellationToken cancellationToken, DateTime timestamp)
|
||||
{
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
return new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, timestamp);
|
||||
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var (value, status) = await client.ReadValueAsync(
|
||||
symbolName, def.DataType, bitIndex, def.ArrayDimensions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (status == TwinCATStatusMapper.Good)
|
||||
_health = new DriverHealth(DriverState.Healthy, timestamp, null);
|
||||
else
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"ADS status {status:X8} reading {reference}");
|
||||
|
||||
return new DataValueSnapshot(value, status, timestamp, timestamp);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
return new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
/// <summary>
|
||||
/// Write the supplied requests, bucketing scalar writes by device + dispatching
|
||||
/// each bucket as one ADS Sum-write. Bit-RMW BOOL writes + whole-array writes use
|
||||
/// the per-tag <see cref="ITwinCATClient.WriteValueAsync"/> path so the per-parent
|
||||
/// RMW lock stays in play.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(writes);
|
||||
var results = new WriteResult[writes.Count];
|
||||
|
||||
// Bucket scalar writes by device. Bit-BOOL + whole-array writes route through the
|
||||
// per-tag fallback below.
|
||||
var bulkBuckets = new Dictionary<string, List<(int origIndex, string symbol, TwinCATTagDefinition def, object? value)>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
var w = writes[i];
|
||||
@@ -176,38 +264,68 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
results[i] = new WriteResult(TwinCATStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out _))
|
||||
{
|
||||
results[i] = new WriteResult(TwinCATStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
|
||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||
var bitIndex = parsed?.BitIndex;
|
||||
var isWholeArray = def.ArrayDimensions is { Length: > 0 };
|
||||
var isBitBool = bitIndex is int && def.DataType == TwinCATDataType.Bool;
|
||||
|
||||
if (isWholeArray || isBitBool)
|
||||
{
|
||||
results[i] = await WriteOneAsync(def, symbolName, bitIndex, w.Value, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!bulkBuckets.TryGetValue(def.DeviceHostAddress, out var bucket))
|
||||
{
|
||||
bucket = new List<(int, string, TwinCATTagDefinition, object?)>();
|
||||
bulkBuckets[def.DeviceHostAddress] = bucket;
|
||||
}
|
||||
bucket.Add((i, symbolName, def, w.Value));
|
||||
}
|
||||
|
||||
foreach (var (hostAddress, bucket) in bulkBuckets)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device)) continue;
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||
var status = await client.WriteValueAsync(
|
||||
symbolName, def.DataType, parsed?.BitIndex, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new WriteResult(status);
|
||||
var items = new TwinCATBulkWriteItem[bucket.Count];
|
||||
for (var k = 0; k < bucket.Count; k++)
|
||||
items[k] = new TwinCATBulkWriteItem(bucket[k].symbol, bucket[k].def.DataType, bucket[k].value);
|
||||
|
||||
var bulk = await client.WriteValuesAsync(items, cancellationToken).ConfigureAwait(false);
|
||||
for (var k = 0; k < bucket.Count; k++)
|
||||
results[bucket[k].origIndex] = new WriteResult(bulk[k]);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
results[i] = new WriteResult(TwinCATStatusMapper.BadNotSupported);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException)
|
||||
{
|
||||
results[i] = new WriteResult(TwinCATStatusMapper.BadTypeMismatch);
|
||||
foreach (var (origIndex, _, _, _) in bucket)
|
||||
results[origIndex] = new WriteResult(TwinCATStatusMapper.BadTypeMismatch);
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
results[i] = new WriteResult(TwinCATStatusMapper.BadOutOfRange);
|
||||
foreach (var (origIndex, _, _, _) in bucket)
|
||||
results[origIndex] = new WriteResult(TwinCATStatusMapper.BadOutOfRange);
|
||||
}
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
foreach (var (origIndex, _, _, _) in bucket)
|
||||
results[origIndex] = new WriteResult(TwinCATStatusMapper.BadNotSupported);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new WriteResult(TwinCATStatusMapper.BadCommunicationError);
|
||||
foreach (var (origIndex, _, _, _) in bucket)
|
||||
results[origIndex] = new WriteResult(TwinCATStatusMapper.BadCommunicationError);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -215,6 +333,40 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<WriteResult> WriteOneAsync(
|
||||
TwinCATTagDefinition def, string symbolName, int? bitIndex, object? value, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
return new WriteResult(TwinCATStatusMapper.BadNodeIdUnknown);
|
||||
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var status = await client.WriteValueAsync(
|
||||
symbolName, def.DataType, bitIndex, def.ArrayDimensions, value, cancellationToken).ConfigureAwait(false);
|
||||
return new WriteResult(status);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||
return new WriteResult(TwinCATStatusMapper.BadNotSupported);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException)
|
||||
{
|
||||
return new WriteResult(TwinCATStatusMapper.BadTypeMismatch);
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
return new WriteResult(TwinCATStatusMapper.BadOutOfRange);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
return new WriteResult(TwinCATStatusMapper.BadCommunicationError);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
@@ -231,11 +383,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||
foreach (var tag in tagsForDevice)
|
||||
{
|
||||
var (isArray, arrayDim) = ResolveArrayShape(tag.ArrayDimensions);
|
||||
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
||||
FullName: tag.Name,
|
||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
IsArray: isArray,
|
||||
ArrayDim: arrayDim,
|
||||
SecurityClass: tag.Writable
|
||||
? SecurityClassification.Operate
|
||||
: SecurityClassification.ViewOnly,
|
||||
@@ -310,6 +463,9 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
{
|
||||
if (!_tagsByName.TryGetValue(reference, out var def)) continue;
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
|
||||
// Whole-array tags don't fit the per-element AdsNotificationEx callback shape —
|
||||
// skip the native path so the OPC UA layer falls through to a polled snapshot.
|
||||
if (def.ArrayDimensions is { Length: > 0 }) continue;
|
||||
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||
@@ -428,6 +584,25 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
return device.Client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Project a TwinCAT <see cref="TwinCATTagDefinition.ArrayDimensions"/> shape onto the
|
||||
/// core <see cref="DriverAttributeInfo"/> 1-D surface. Multi-dim arrays flatten to the
|
||||
/// product element count — the OPC UA address-space layer surfaces the rank via its own
|
||||
/// <c>ArrayDimensions</c> metadata at variable build time.
|
||||
/// </summary>
|
||||
internal static (bool isArray, uint? arrayDim) ResolveArrayShape(int[]? dimensions)
|
||||
{
|
||||
if (dimensions is null || dimensions.Length == 0) return (false, null);
|
||||
long product = 1;
|
||||
foreach (var d in dimensions)
|
||||
{
|
||||
if (d <= 0) return (false, null); // invalid shape; surface as scalar to fail safe
|
||||
product *= d;
|
||||
if (product > uint.MaxValue) return (true, uint.MaxValue);
|
||||
}
|
||||
return (true, (uint)product);
|
||||
}
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
|
||||
@@ -43,8 +43,12 @@ public sealed record TwinCATDeviceOptions(
|
||||
string? DeviceName = null);
|
||||
|
||||
/// <summary>
|
||||
/// One TwinCAT-backed OPC UA variable. <paramref name="SymbolPath"/> is the full TwinCAT
|
||||
/// symbolic name (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>, <c>Motor1.Status.Running</c>).
|
||||
/// One TwinCAT-backed OPC UA variable. <c>SymbolPath</c> is the full TwinCAT symbolic name
|
||||
/// (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>, <c>Motor1.Status.Running</c>). When
|
||||
/// <c>ArrayDimensions</c> is non-null + non-empty the symbol is treated as a whole-array
|
||||
/// read of <c>product(dims)</c> elements rather than a single scalar — PR-1.4 ships read-
|
||||
/// only whole-array support; multi-dim shapes flatten to the product on the wire and the
|
||||
/// OPC UA layer reflects the rank via its own <c>ArrayDimensions</c> metadata.
|
||||
/// </summary>
|
||||
public sealed record TwinCATTagDefinition(
|
||||
string Name,
|
||||
@@ -52,7 +56,8 @@ public sealed record TwinCATTagDefinition(
|
||||
string SymbolPath,
|
||||
TwinCATDataType DataType,
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false);
|
||||
bool WriteIdempotent = false,
|
||||
int[]? ArrayDimensions = null);
|
||||
|
||||
public sealed class TwinCATProbeOptions
|
||||
{
|
||||
|
||||
@@ -178,6 +178,13 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
NodeId = new NodeId(attributeInfo.FullName, NamespaceIndex),
|
||||
BrowseName = new QualifiedName(browseName, NamespaceIndex),
|
||||
DisplayName = new LocalizedText(displayName),
|
||||
// Per Task #231 — surface the driver-supplied tag description as the OPC UA
|
||||
// Description attribute on the Variable node. Drivers that don't carry
|
||||
// descriptions pass null, leaving Description unset (the stack defaults to
|
||||
// an empty LocalizedText, matching prior behaviour).
|
||||
Description = string.IsNullOrEmpty(attributeInfo.Description)
|
||||
? null
|
||||
: new LocalizedText(attributeInfo.Description),
|
||||
DataType = MapDataType(attributeInfo.DriverDataType),
|
||||
ValueRank = attributeInfo.IsArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
|
||||
// Historized attributes get the HistoryRead access bit so the stack dispatches
|
||||
@@ -310,6 +317,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
DriverDataType.Float64 => DataTypeIds.Double,
|
||||
DriverDataType.String => DataTypeIds.String,
|
||||
DriverDataType.DateTime => DataTypeIds.DateTime,
|
||||
DriverDataType.Duration => DataTypeIds.Duration,
|
||||
_ => DataTypeIds.BaseDataType,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipArrayReadPlannerTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
private static AbCipTagCreateParams BaseParams(string tagName) => new(
|
||||
Gateway: "10.0.0.5",
|
||||
Port: 44818,
|
||||
CipPath: "1,0",
|
||||
LibplctagPlcAttribute: "controllogix",
|
||||
TagName: tagName,
|
||||
Timeout: TimeSpan.FromSeconds(5));
|
||||
|
||||
[Fact]
|
||||
public void TryBuild_emits_single_tag_create_with_element_count()
|
||||
{
|
||||
var def = new AbCipTagDefinition("DataSlice", Device, "Data[0..15]", AbCipDataType.DInt);
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||
|
||||
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..15]"));
|
||||
|
||||
plan.ShouldNotBeNull();
|
||||
plan.ElementType.ShouldBe(AbCipDataType.DInt);
|
||||
plan.Stride.ShouldBe(4);
|
||||
plan.Slice.Count.ShouldBe(16);
|
||||
plan.CreateParams.ElementCount.ShouldBe(16);
|
||||
// Anchored at the slice start; libplctag reads N consecutive elements from there.
|
||||
plan.CreateParams.TagName.ShouldBe("Data[0]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBuild_returns_null_when_path_has_no_slice()
|
||||
{
|
||||
var def = new AbCipTagDefinition("Plain", Device, "Data[3]", AbCipDataType.DInt);
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||
|
||||
AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[3]")).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbCipDataType.Bool)]
|
||||
[InlineData(AbCipDataType.String)]
|
||||
[InlineData(AbCipDataType.Structure)]
|
||||
public void TryBuild_returns_null_for_unsupported_element_types(AbCipDataType type)
|
||||
{
|
||||
var def = new AbCipTagDefinition("Slice", Device, "Data[0..3]", type);
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||
|
||||
AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..3]")).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbCipDataType.SInt, 1)]
|
||||
[InlineData(AbCipDataType.Int, 2)]
|
||||
[InlineData(AbCipDataType.DInt, 4)]
|
||||
[InlineData(AbCipDataType.Real, 4)]
|
||||
[InlineData(AbCipDataType.LInt, 8)]
|
||||
[InlineData(AbCipDataType.LReal, 8)]
|
||||
public void TryBuild_uses_natural_stride_per_element_type(AbCipDataType type, int expectedStride)
|
||||
{
|
||||
var def = new AbCipTagDefinition("Slice", Device, "Data[0..3]", type);
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||
|
||||
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..3]"))!;
|
||||
plan.Stride.ShouldBe(expectedStride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_walks_buffer_at_element_stride()
|
||||
{
|
||||
var def = new AbCipTagDefinition("DataSlice", Device, "Data[0..3]", AbCipDataType.DInt);
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..3]"))!;
|
||||
|
||||
var fake = new FakeAbCipTag(plan.CreateParams);
|
||||
// Stride == 4 for DInt, so offsets 0/4/8/12 hold the four element values.
|
||||
fake.ValuesByOffset[0] = 100;
|
||||
fake.ValuesByOffset[4] = 200;
|
||||
fake.ValuesByOffset[8] = 300;
|
||||
fake.ValuesByOffset[12] = 400;
|
||||
|
||||
var decoded = AbCipArrayReadPlanner.Decode(plan, fake);
|
||||
|
||||
decoded.Length.ShouldBe(4);
|
||||
decoded.ShouldBe(new object?[] { 100, 200, 300, 400 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_preserves_slice_count_for_real_arrays()
|
||||
{
|
||||
var def = new AbCipTagDefinition("FloatSlice", Device, "Floats[2..5]", AbCipDataType.Real);
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Floats[2]"))!;
|
||||
|
||||
var fake = new FakeAbCipTag(plan.CreateParams);
|
||||
fake.ValuesByOffset[0] = 1.5f;
|
||||
fake.ValuesByOffset[4] = 2.5f;
|
||||
fake.ValuesByOffset[8] = 3.5f;
|
||||
fake.ValuesByOffset[12] = 4.5f;
|
||||
|
||||
var decoded = AbCipArrayReadPlanner.Decode(plan, fake);
|
||||
|
||||
decoded.ShouldBe(new object?[] { 1.5f, 2.5f, 3.5f, 4.5f });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task #231 — verifies that tag/member descriptions parsed from L5K and L5X exports thread
|
||||
/// through <see cref="AbCipTagDefinition.Description"/> /
|
||||
/// <see cref="AbCipStructureMember.Description"/> + land on
|
||||
/// <see cref="DriverAttributeInfo.Description"/> on the produced address-space variables, so
|
||||
/// downstream OPC UA Variable nodes carry the source-project comment as their Description
|
||||
/// attribute.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipDescriptionThreadingTests
|
||||
{
|
||||
private const string DeviceHost = "ab://10.0.0.5/1,0";
|
||||
|
||||
[Fact]
|
||||
public void L5kParser_captures_member_description_from_attribute_block()
|
||||
{
|
||||
const string body = """
|
||||
DATATYPE MyUdt
|
||||
MEMBER Speed : DINT (Description := "Belt speed in RPM");
|
||||
END_DATATYPE
|
||||
""";
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var member = doc.DataTypes.Single().Members.Single();
|
||||
member.Name.ShouldBe("Speed");
|
||||
member.Description.ShouldBe("Belt speed in RPM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void L5xParser_captures_member_description_child_node()
|
||||
{
|
||||
const string xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RSLogix5000Content>
|
||||
<Controller>
|
||||
<DataTypes>
|
||||
<DataType Name="MyUdt">
|
||||
<Members>
|
||||
<Member Name="Speed" DataType="DINT" Dimension="0" ExternalAccess="Read/Write">
|
||||
<Description><![CDATA[Belt speed in RPM]]></Description>
|
||||
</Member>
|
||||
</Members>
|
||||
</DataType>
|
||||
</DataTypes>
|
||||
</Controller>
|
||||
</RSLogix5000Content>
|
||||
""";
|
||||
var doc = L5xParser.Parse(new StringL5kSource(xml));
|
||||
|
||||
doc.DataTypes.Single().Members.Single().Description.ShouldBe("Belt speed in RPM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void L5kIngest_threads_tag_and_member_descriptions_into_AbCipTagDefinition()
|
||||
{
|
||||
const string body = """
|
||||
DATATYPE MotorBlock
|
||||
MEMBER Speed : DINT (Description := "Setpoint RPM");
|
||||
MEMBER Status : DINT;
|
||||
END_DATATYPE
|
||||
TAG
|
||||
Motor1 : MotorBlock (Description := "Conveyor motor 1") := [];
|
||||
END_TAG
|
||||
""";
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||
|
||||
var tag = result.Tags.Single();
|
||||
tag.Description.ShouldBe("Conveyor motor 1");
|
||||
tag.Members.ShouldNotBeNull();
|
||||
var members = tag.Members!.ToDictionary(m => m.Name);
|
||||
members["Speed"].Description.ShouldBe("Setpoint RPM");
|
||||
members["Status"].Description.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_sets_Description_on_DriverAttributeInfo_for_atomic_tag()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(DeviceHost)],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition(
|
||||
Name: "Speed",
|
||||
DeviceHostAddress: DeviceHost,
|
||||
TagPath: "Motor1.Speed",
|
||||
DataType: AbCipDataType.DInt,
|
||||
Description: "Belt speed in RPM"),
|
||||
new AbCipTagDefinition(
|
||||
Name: "NoDescription",
|
||||
DeviceHostAddress: DeviceHost,
|
||||
TagPath: "X",
|
||||
DataType: AbCipDataType.DInt),
|
||||
],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Single(v => v.BrowseName == "Speed").Info.Description
|
||||
.ShouldBe("Belt speed in RPM");
|
||||
// Tags without descriptions leave Info.Description null (back-compat path).
|
||||
builder.Variables.Single(v => v.BrowseName == "NoDescription").Info.Description
|
||||
.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_sets_Description_on_DriverAttributeInfo_for_UDT_members()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(DeviceHost)],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition(
|
||||
Name: "Motor1",
|
||||
DeviceHostAddress: DeviceHost,
|
||||
TagPath: "Motor1",
|
||||
DataType: AbCipDataType.Structure,
|
||||
Members:
|
||||
[
|
||||
new AbCipStructureMember(
|
||||
Name: "Speed",
|
||||
DataType: AbCipDataType.DInt,
|
||||
Description: "Setpoint RPM"),
|
||||
new AbCipStructureMember(
|
||||
Name: "Status",
|
||||
DataType: AbCipDataType.DInt),
|
||||
]),
|
||||
],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Single(v => v.BrowseName == "Speed").Info.Description
|
||||
.ShouldBe("Setpoint RPM");
|
||||
builder.Variables.Single(v => v.BrowseName == "Status").Info.Description
|
||||
.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,55 @@ public sealed class AbCipDriverReadTests
|
||||
p.TagName.ShouldBe("Program:P.Counter");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Slice_tag_reads_one_array_and_decodes_n_elements()
|
||||
{
|
||||
// PR abcip-1.3 — `Data[0..3]` slice routes through AbCipArrayReadPlanner: one libplctag
|
||||
// tag-create at TagName="Data[0]" with ElementCount=4, single PLC read, contiguous
|
||||
// buffer decoded at element stride into one snapshot whose Value is an object?[].
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("DataSlice", "ab://10.0.0.5/1,0", "Data[0..3]", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p =>
|
||||
{
|
||||
var t = new FakeAbCipTag(p);
|
||||
t.ValuesByOffset[0] = 10;
|
||||
t.ValuesByOffset[4] = 20;
|
||||
t.ValuesByOffset[8] = 30;
|
||||
t.ValuesByOffset[12] = 40;
|
||||
return t;
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["DataSlice"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
var values = snapshots.Single().Value.ShouldBeOfType<object?[]>();
|
||||
values.ShouldBe(new object?[] { 10, 20, 30, 40 });
|
||||
|
||||
// Exactly ONE libplctag tag was created — anchored at the slice start with
|
||||
// ElementCount=4. Without the planner this would have been four scalar reads.
|
||||
factory.Tags.Count.ShouldBe(1);
|
||||
factory.Tags.ShouldContainKey("Data[0]");
|
||||
factory.Tags["Data[0]"].CreationParams.ElementCount.ShouldBe(4);
|
||||
factory.Tags["Data[0]"].ReadCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Slice_tag_with_unsupported_element_type_returns_BadNotSupported()
|
||||
{
|
||||
// BOOL slices can't be laid out from the declaration alone (Logix packs BOOLs into a
|
||||
// hidden host byte). The planner refuses; the driver surfaces BadNotSupported instead
|
||||
// of attempting a best-effort decode.
|
||||
var (drv, _) = NewDriver(
|
||||
new AbCipTagDefinition("BoolSlice", "ab://10.0.0.5/1,0", "Flags[0..7]", AbCipDataType.Bool));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["BoolSlice"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
|
||||
snapshots.Single().Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_propagates_from_read()
|
||||
{
|
||||
@@ -211,4 +260,79 @@ public sealed class AbCipDriverReadTests
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||
factory.Tags["Nope"].Disposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// PR abcip-1.2 — STRINGnn variant decoding. Threading <see cref="AbCipTagDefinition.StringLength"/>
|
||||
// through libplctag's StringMaxCapacity attribute lets STRING_20 / STRING_40 / STRING_80 UDTs
|
||||
// decode against the right DATA-array size; null preserves the default 82-byte STRING.
|
||||
|
||||
[Fact]
|
||||
public async Task StringLength_threads_into_TagCreateParams_StringMaxCapacity()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Banner", "ab://10.0.0.5/1,0", "Banner", AbCipDataType.String,
|
||||
StringLength: 40));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = "hello" };
|
||||
|
||||
await drv.ReadAsync(["Banner"], CancellationToken.None);
|
||||
|
||||
factory.Tags["Banner"].CreationParams.StringMaxCapacity.ShouldBe(40);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StringLength_null_leaves_StringMaxCapacity_null_for_back_compat()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("LegacyStr", "ab://10.0.0.5/1,0", "LegacyStr", AbCipDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = "world" };
|
||||
|
||||
await drv.ReadAsync(["LegacyStr"], CancellationToken.None);
|
||||
|
||||
factory.Tags["LegacyStr"].CreationParams.StringMaxCapacity.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StringLength_ignored_for_non_String_data_types()
|
||||
{
|
||||
// StringLength on a DINT-typed tag must not flow into StringMaxCapacity — libplctag would
|
||||
// otherwise re-shape the buffer and corrupt the read. EnsureTagRuntimeAsync gates on the
|
||||
// declared DataType.
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt,
|
||||
StringLength: 80));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = 7 };
|
||||
|
||||
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
|
||||
factory.Tags["Speed"].CreationParams.StringMaxCapacity.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UDT_member_StringLength_threads_through_to_member_runtime()
|
||||
{
|
||||
// STRINGnn members of a UDT — declaration-driven fan-out copies StringLength from
|
||||
// AbCipStructureMember onto the synthesised member AbCipTagDefinition; the per-member
|
||||
// runtime then receives the right StringMaxCapacity.
|
||||
var udt = new AbCipTagDefinition(
|
||||
Name: "Recipe",
|
||||
DeviceHostAddress: "ab://10.0.0.5/1,0",
|
||||
TagPath: "Recipe",
|
||||
DataType: AbCipDataType.Structure,
|
||||
Members: [
|
||||
new AbCipStructureMember("Name", AbCipDataType.String, StringLength: 20),
|
||||
new AbCipStructureMember("Description", AbCipDataType.String, StringLength: 80),
|
||||
new AbCipStructureMember("Code", AbCipDataType.DInt),
|
||||
]);
|
||||
var (drv, factory) = NewDriver(udt);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = "x" };
|
||||
|
||||
await drv.ReadAsync(["Recipe.Name", "Recipe.Description", "Recipe.Code"], CancellationToken.None);
|
||||
|
||||
factory.Tags["Recipe.Name"].CreationParams.StringMaxCapacity.ShouldBe(20);
|
||||
factory.Tags["Recipe.Description"].CreationParams.StringMaxCapacity.ShouldBe(80);
|
||||
factory.Tags["Recipe.Code"].CreationParams.StringMaxCapacity.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,8 +124,11 @@ public sealed class AbCipDriverTests
|
||||
{
|
||||
AbCipDataType.Bool.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
|
||||
AbCipDataType.DInt.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
AbCipDataType.LInt.ToDriverDataType().ShouldBe(DriverDataType.Int64);
|
||||
AbCipDataType.ULInt.ToDriverDataType().ShouldBe(DriverDataType.UInt64);
|
||||
AbCipDataType.Real.ToDriverDataType().ShouldBe(DriverDataType.Float32);
|
||||
AbCipDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
|
||||
AbCipDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||
AbCipDataType.Dt.ToDriverDataType().ShouldBe(DriverDataType.Int64);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-1.4 — multi-tag write packing. Validates that <see cref="AbCipDriver.WriteAsync"/>
|
||||
/// groups writes by device, dispatches packable writes for request-packing-capable
|
||||
/// families concurrently, falls back to sequential writes on Micro800, keeps BOOL-RMW
|
||||
/// writes on the per-parent semaphore path, and fans per-tag StatusCodes out to the
|
||||
/// correct positions on partial failures.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipMultiWritePackingTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Writes_get_grouped_by_device()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
|
||||
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("A1", "ab://10.0.0.5/1,0", "A1", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("A2", "ab://10.0.0.5/1,0", "A2", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("B1", "ab://10.0.0.6/1,0", "B1", AbCipDataType.DInt),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("A1", 1),
|
||||
new WriteRequest("B1", 100),
|
||||
new WriteRequest("A2", 2),
|
||||
], CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(3);
|
||||
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
results[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
results[2].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
// Per-device handles materialised — A1/A2 share device A, B1 lives on device B.
|
||||
factory.Tags["A1"].CreationParams.Gateway.ShouldBe("10.0.0.5");
|
||||
factory.Tags["A2"].CreationParams.Gateway.ShouldBe("10.0.0.5");
|
||||
factory.Tags["B1"].CreationParams.Gateway.ShouldBe("10.0.0.6");
|
||||
factory.Tags["A1"].WriteCount.ShouldBe(1);
|
||||
factory.Tags["A2"].WriteCount.ShouldBe(1);
|
||||
factory.Tags["B1"].WriteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ControlLogix_packs_concurrently_within_a_device()
|
||||
{
|
||||
// ControlLogix has SupportsRequestPacking=true → a multi-write batch is dispatched in
|
||||
// parallel. The fake's WriteAsync gates on a TaskCompletionSource so we can prove that
|
||||
// both writes are in flight at the same time before either completes.
|
||||
var gate = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var inFlight = 0;
|
||||
var maxInFlight = 0;
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new GatedWriteFake(p, gate, () =>
|
||||
{
|
||||
var current = Interlocked.Increment(ref inFlight);
|
||||
var observed = maxInFlight;
|
||||
while (current > observed
|
||||
&& Interlocked.CompareExchange(ref maxInFlight, current, observed) != observed)
|
||||
observed = maxInFlight;
|
||||
}, () => Interlocked.Decrement(ref inFlight)),
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix)],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.DInt),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var writeTask = drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("A", 1),
|
||||
new WriteRequest("B", 2),
|
||||
new WriteRequest("C", 3),
|
||||
], CancellationToken.None);
|
||||
|
||||
// Wait until all three writes have entered WriteAsync simultaneously, then release.
|
||||
await WaitForAsync(() => Volatile.Read(ref inFlight) >= 3, TimeSpan.FromSeconds(2));
|
||||
gate.SetResult(0);
|
||||
|
||||
var results = await writeTask;
|
||||
results.Count.ShouldBe(3);
|
||||
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
results[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
results[2].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
maxInFlight.ShouldBeGreaterThanOrEqualTo(2,
|
||||
"ControlLogix supports request packing — packable writes should run concurrently within the device.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Micro800_falls_back_to_sequential_writes()
|
||||
{
|
||||
// Micro800 has SupportsRequestPacking=false → writes go one-at-a-time; the gated fake
|
||||
// never sees more than one in-flight at a time.
|
||||
var gate = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
gate.SetResult(0); // No need to gate — we just observe concurrency.
|
||||
var inFlight = 0;
|
||||
var maxInFlight = 0;
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new GatedWriteFake(p, gate, () =>
|
||||
{
|
||||
var current = Interlocked.Increment(ref inFlight);
|
||||
var observed = maxInFlight;
|
||||
while (current > observed
|
||||
&& Interlocked.CompareExchange(ref maxInFlight, current, observed) != observed)
|
||||
observed = maxInFlight;
|
||||
}, () => Interlocked.Decrement(ref inFlight)),
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/", AbCipPlcFamily.Micro800)],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("A", "ab://10.0.0.5/", "A", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("B", "ab://10.0.0.5/", "B", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("C", "ab://10.0.0.5/", "C", AbCipDataType.DInt),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("A", 1),
|
||||
new WriteRequest("B", 2),
|
||||
new WriteRequest("C", 3),
|
||||
], CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(3);
|
||||
results.ShouldAllBe(r => r.StatusCode == AbCipStatusMapper.Good);
|
||||
maxInFlight.ShouldBe(1,
|
||||
"Micro800 disables request packing — writes must execute sequentially.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_in_dint_writes_still_route_through_RMW_path()
|
||||
{
|
||||
// BOOL-with-bitIndex must hit the per-parent RMW semaphore — it must NOT go through
|
||||
// the packable per-tag runtime path. We prove this by checking that:
|
||||
// (a) the per-tag "bit-selector" runtime is never created (it would throw via
|
||||
// LibplctagTagRuntime's NotSupportedException had the bypass happened);
|
||||
// (b) the parent-DINT runtime got both a Read and a Write.
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Flags.3", AbCipDataType.Bool),
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("Flag3", true),
|
||||
new WriteRequest("Speed", 99),
|
||||
], CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(2);
|
||||
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
results[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
|
||||
// Parent runtime created lazily for Flags (no .3 suffix) — drove the RMW.
|
||||
factory.Tags.ShouldContainKey("Flags");
|
||||
factory.Tags["Flags"].ReadCount.ShouldBe(1);
|
||||
factory.Tags["Flags"].WriteCount.ShouldBe(1);
|
||||
// Speed went through the packable path.
|
||||
factory.Tags["Speed"].WriteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Per_tag_status_code_fan_out_works_on_partial_failure()
|
||||
{
|
||||
// Mix Good + BadTimeout + BadNotWritable + BadNodeIdUnknown across two devices to
|
||||
// exercise the original-index preservation through the per-device plan + concurrent
|
||||
// dispatch.
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => p.TagName == "B"
|
||||
? new FakeAbCipTag(p) { Status = -5 /* timeout */ }
|
||||
: new FakeAbCipTag(p),
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
|
||||
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("RO", "ab://10.0.0.5/1,0", "RO", AbCipDataType.DInt, Writable: false),
|
||||
new AbCipTagDefinition("C", "ab://10.0.0.6/1,0", "C", AbCipDataType.DInt),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("A", 1),
|
||||
new WriteRequest("B", 2),
|
||||
new WriteRequest("RO", 3),
|
||||
new WriteRequest("UnknownTag", 4),
|
||||
new WriteRequest("C", 5),
|
||||
], CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(5);
|
||||
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
results[1].StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout);
|
||||
results[2].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||
results[3].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
results[4].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> predicate, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!predicate())
|
||||
{
|
||||
if (DateTime.UtcNow >= deadline)
|
||||
throw new TimeoutException("predicate did not become true within timeout");
|
||||
await Task.Delay(10).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test fake whose <see cref="WriteAsync"/> blocks on a shared
|
||||
/// <see cref="TaskCompletionSource"/> so the test can observe how many writes are
|
||||
/// simultaneously in flight inside the driver.
|
||||
/// </summary>
|
||||
private sealed class GatedWriteFake : FakeAbCipTag
|
||||
{
|
||||
private readonly TaskCompletionSource<int> _gate;
|
||||
private readonly Action _onEnter;
|
||||
private readonly Action _onExit;
|
||||
|
||||
public GatedWriteFake(AbCipTagCreateParams p, TaskCompletionSource<int> gate,
|
||||
Action onEnter, Action onExit) : base(p)
|
||||
{
|
||||
_gate = gate;
|
||||
_onEnter = onEnter;
|
||||
_onExit = onExit;
|
||||
}
|
||||
|
||||
public override async Task WriteAsync(CancellationToken ct)
|
||||
{
|
||||
_onEnter();
|
||||
try
|
||||
{
|
||||
await _gate.Task.ConfigureAwait(false);
|
||||
await base.WriteAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_onExit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
141
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRebrowseTests.cs
Normal file
141
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRebrowseTests.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #233 — RebrowseAsync forces a re-walk of the controller symbol table without
|
||||
/// restarting the driver. Tests cover the call-counting contract (each invocation issues
|
||||
/// a fresh enumeration pass), the IDriverControl interface implementation, and that the
|
||||
/// UDT template cache is dropped so stale shapes don't survive a program-download.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipRebrowseTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RebrowseAsync_runs_enumerator_once_per_call()
|
||||
{
|
||||
var factory = new CountingEnumeratorFactory(
|
||||
new AbCipDiscoveredTag("Pressure", null, AbCipDataType.Real, ReadOnly: false));
|
||||
|
||||
await using var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", enumeratorFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.RebrowseAsync(new RecordingBuilder(), CancellationToken.None);
|
||||
factory.CreateCount.ShouldBe(1);
|
||||
factory.EnumerationCount.ShouldBe(1);
|
||||
|
||||
await drv.RebrowseAsync(new RecordingBuilder(), CancellationToken.None);
|
||||
factory.CreateCount.ShouldBe(2);
|
||||
factory.EnumerationCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RebrowseAsync_emits_discovered_tags_through_supplied_builder()
|
||||
{
|
||||
var factory = new CountingEnumeratorFactory(
|
||||
new AbCipDiscoveredTag("NewTag", null, AbCipDataType.DInt, ReadOnly: false));
|
||||
|
||||
await using var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", enumeratorFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var builder = new RecordingBuilder();
|
||||
await drv.RebrowseAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Select(v => v.Info.FullName).ShouldContain("NewTag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RebrowseAsync_clears_template_cache()
|
||||
{
|
||||
await using var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.TemplateCache.Put("ab://10.0.0.5/1,0", 42, new AbCipUdtShape("T", 4, []));
|
||||
drv.TemplateCache.Count.ShouldBe(1);
|
||||
|
||||
await drv.RebrowseAsync(new RecordingBuilder(), CancellationToken.None);
|
||||
|
||||
drv.TemplateCache.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AbCipDriver_implements_IDriverControl()
|
||||
{
|
||||
await using var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
|
||||
drv.ShouldBeAssignableTo<IDriverControl>();
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks both <see cref="Create"/> calls (one per discovery / rebrowse pass) and
|
||||
/// <see cref="EnumerationCount"/> (incremented when the resulting enumerator is
|
||||
/// actually iterated). Two consecutive RebrowseAsync calls must bump both counters.
|
||||
/// </summary>
|
||||
private sealed class CountingEnumeratorFactory : IAbCipTagEnumeratorFactory
|
||||
{
|
||||
private readonly AbCipDiscoveredTag[] _tags;
|
||||
public int CreateCount { get; private set; }
|
||||
public int EnumerationCount { get; private set; }
|
||||
|
||||
public CountingEnumeratorFactory(params AbCipDiscoveredTag[] tags) => _tags = tags;
|
||||
|
||||
public IAbCipTagEnumerator Create()
|
||||
{
|
||||
CreateCount++;
|
||||
return new CountingEnumerator(this);
|
||||
}
|
||||
|
||||
private sealed class CountingEnumerator(CountingEnumeratorFactory outer) : IAbCipTagEnumerator
|
||||
{
|
||||
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
outer.EnumerationCount++;
|
||||
await Task.CompletedTask;
|
||||
foreach (var t in outer._tags) yield return t;
|
||||
}
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,61 @@ public sealed class AbCipTagPathTests
|
||||
AbCipTagPath.TryParse("_private_tag")!.Segments.Single().Name.ShouldBe("_private_tag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Slice_basic_inclusive_range()
|
||||
{
|
||||
var p = AbCipTagPath.TryParse("Data[0..15]");
|
||||
p.ShouldNotBeNull();
|
||||
p.Slice.ShouldNotBeNull();
|
||||
p.Slice!.Start.ShouldBe(0);
|
||||
p.Slice.End.ShouldBe(15);
|
||||
p.Slice.Count.ShouldBe(16);
|
||||
p.BitIndex.ShouldBeNull();
|
||||
p.Segments.Single().Name.ShouldBe("Data");
|
||||
p.Segments.Single().Subscripts.ShouldBeEmpty();
|
||||
p.ToLibplctagName().ShouldBe("Data[0..15]");
|
||||
// Slice array name omits the `..End` so libplctag sees an anchored read at the start
|
||||
// index; pair with ElementCount to cover the whole range.
|
||||
p.ToLibplctagSliceArrayName().ShouldBe("Data[0]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Slice_with_program_scope_and_member_chain()
|
||||
{
|
||||
var p = AbCipTagPath.TryParse("Program:MainProgram.Motors.Data[3..7]");
|
||||
p.ShouldNotBeNull();
|
||||
p.ProgramScope.ShouldBe("MainProgram");
|
||||
p.Segments.Select(s => s.Name).ShouldBe(["Motors", "Data"]);
|
||||
p.Slice!.Start.ShouldBe(3);
|
||||
p.Slice.End.ShouldBe(7);
|
||||
p.ToLibplctagName().ShouldBe("Program:MainProgram.Motors.Data[3..7]");
|
||||
p.ToLibplctagSliceArrayName().ShouldBe("Program:MainProgram.Motors.Data[3]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Slice_zero_length_single_element_allowed()
|
||||
{
|
||||
// [5..5] is a one-element slice — degenerate but legal (a single read of one element).
|
||||
var p = AbCipTagPath.TryParse("Data[5..5]");
|
||||
p.ShouldNotBeNull();
|
||||
p.Slice!.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Data[5..3]")] // M < N
|
||||
[InlineData("Data[-1..5]")] // negative start
|
||||
[InlineData("Data[0..15].Member")] // slice + sub-element
|
||||
[InlineData("Data[0..15].3")] // slice + bit index
|
||||
[InlineData("Data[0..15,1]")] // slice cannot be multi-dim
|
||||
[InlineData("Data[0..15,2..3]")] // multi-dim slice not supported
|
||||
[InlineData("Data[..5]")] // missing start
|
||||
[InlineData("Data[5..]")] // missing end
|
||||
[InlineData("Data[a..5]")] // non-numeric start
|
||||
public void Invalid_slice_shapes_return_null(string input)
|
||||
{
|
||||
AbCipTagPath.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLibplctagName_recomposes_round_trip()
|
||||
{
|
||||
|
||||
@@ -167,6 +167,83 @@ public sealed class AbCipUdtMemberTests
|
||||
builder.Variables.ShouldContain(v => v.BrowseName == "EmptyUdt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AOI_typed_tag_groups_members_under_directional_subfolders()
|
||||
{
|
||||
// PR abcip-2.6 — 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 so
|
||||
// the browse tree mirrors Studio 5000's AOI parameter tabs. Plain UDT tags (every member
|
||||
// Local) keep the pre-2.6 flat layout.
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition(
|
||||
Name: "Valve_001",
|
||||
DeviceHostAddress: "ab://10.0.0.5/1,0",
|
||||
TagPath: "Valve_001",
|
||||
DataType: AbCipDataType.Structure,
|
||||
Members:
|
||||
[
|
||||
new AbCipStructureMember("Cmd", AbCipDataType.Bool, AoiQualifier: AoiQualifier.Input),
|
||||
new AbCipStructureMember("Status", AbCipDataType.DInt, Writable: false, AoiQualifier: AoiQualifier.Output),
|
||||
new AbCipStructureMember("Buffer", AbCipDataType.DInt, AoiQualifier: AoiQualifier.InOut),
|
||||
new AbCipStructureMember("LocalVar", AbCipDataType.DInt, AoiQualifier: AoiQualifier.Local),
|
||||
]),
|
||||
],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
// Sub-folders for each directional bucket land in the recorder; the AOI parent folder
|
||||
// and the Local member's lack of a sub-folder confirm only directional members get
|
||||
// bucketed. Folder names are intentionally simple (Inputs / Outputs / InOut) — clients
|
||||
// that browse "Valve_001/Inputs/Cmd" see exactly that path.
|
||||
builder.Folders.Select(f => f.BrowseName).ShouldContain("Valve_001");
|
||||
builder.Folders.Select(f => f.BrowseName).ShouldContain("Inputs");
|
||||
builder.Folders.Select(f => f.BrowseName).ShouldContain("Outputs");
|
||||
builder.Folders.Select(f => f.BrowseName).ShouldContain("InOut");
|
||||
|
||||
// Variables emitted under the right full names — full reference still {Tag}.{Member}
|
||||
// so the read/write paths stay unchanged from the flat-UDT case.
|
||||
var variables = builder.Variables.Select(v => (v.BrowseName, v.Info.FullName)).ToList();
|
||||
variables.ShouldContain(("Cmd", "Valve_001.Cmd"));
|
||||
variables.ShouldContain(("Status", "Valve_001.Status"));
|
||||
variables.ShouldContain(("Buffer", "Valve_001.Buffer"));
|
||||
variables.ShouldContain(("LocalVar", "Valve_001.LocalVar"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Plain_UDT_keeps_flat_layout_when_every_member_is_Local()
|
||||
{
|
||||
// Plain UDTs (no Usage attributes anywhere) stay on the pre-2.6 flat layout — no
|
||||
// Inputs/Outputs/InOut sub-folders should appear since there are no directional members.
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Tank1", "ab://10.0.0.5/1,0", "Tank1", AbCipDataType.Structure,
|
||||
Members:
|
||||
[
|
||||
new AbCipStructureMember("Level", AbCipDataType.Real),
|
||||
new AbCipStructureMember("Pressure", AbCipDataType.Real),
|
||||
]),
|
||||
],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.Select(f => f.BrowseName).ShouldNotContain("Inputs");
|
||||
builder.Folders.Select(f => f.BrowseName).ShouldNotContain("Outputs");
|
||||
builder.Folders.Select(f => f.BrowseName).ShouldNotContain("InOut");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UDT_members_mixed_with_flat_tags_coexist()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CsvTagImporterTests
|
||||
{
|
||||
private const string DeviceHost = "ab://10.10.10.1/0,1";
|
||||
|
||||
[Fact]
|
||||
public void Imports_Kepware_format_controller_tag_with_RW_access()
|
||||
{
|
||||
const string csv = """
|
||||
Tag Name,Address,Data Type,Respect Data Type,Client Access,Scan Rate,Description,Scaling
|
||||
Motor1_Speed,Motor1_Speed,DINT,1,Read/Write,100,Drive speed setpoint,None
|
||||
""";
|
||||
|
||||
var importer = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost };
|
||||
var result = importer.Import(csv);
|
||||
|
||||
result.Tags.Count.ShouldBe(1);
|
||||
var t = result.Tags[0];
|
||||
t.Name.ShouldBe("Motor1_Speed");
|
||||
t.TagPath.ShouldBe("Motor1_Speed");
|
||||
t.DataType.ShouldBe(AbCipDataType.DInt);
|
||||
t.Writable.ShouldBeTrue();
|
||||
t.Description.ShouldBe("Drive speed setpoint");
|
||||
t.DeviceHostAddress.ShouldBe(DeviceHost);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_Only_access_yields_non_writable_tag()
|
||||
{
|
||||
const string csv = """
|
||||
Tag Name,Address,Data Type,Client Access,Description
|
||||
Sensor,Sensor,REAL,Read Only,Pressure sensor
|
||||
""";
|
||||
|
||||
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||
|
||||
result.Tags.Single().Writable.ShouldBeFalse();
|
||||
result.Tags.Single().DataType.ShouldBe(AbCipDataType.Real);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blank_rows_and_section_markers_are_skipped()
|
||||
{
|
||||
const string csv = """
|
||||
; Kepware Server Tag Export
|
||||
|
||||
Tag Name,Address,Data Type,Client Access
|
||||
|
||||
; group: Motors
|
||||
Motor1,Motor1,DINT,Read/Write
|
||||
|
||||
Motor2,Motor2,DINT,Read/Write
|
||||
""";
|
||||
|
||||
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||
|
||||
result.Tags.Count.ShouldBe(2);
|
||||
result.Tags.Select(t => t.Name).ShouldBe(["Motor1", "Motor2"]);
|
||||
result.SkippedBlankCount.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quoted_field_with_embedded_comma_is_parsed()
|
||||
{
|
||||
const string csv = """
|
||||
Tag Name,Address,Data Type,Client Access,Description
|
||||
Motor1,Motor1,DINT,Read/Write,"Speed, RPM"
|
||||
""";
|
||||
|
||||
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||
|
||||
result.Tags.Single().Description.ShouldBe("Speed, RPM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quoted_field_with_escaped_quote_is_parsed()
|
||||
{
|
||||
const string csv = "Tag Name,Address,Data Type,Client Access,Description\r\n"
|
||||
+ "Tag1,Tag1,DINT,Read Only,\"He said \"\"hi\"\"\"\r\n";
|
||||
|
||||
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||
|
||||
result.Tags.Single().Description.ShouldBe("He said \"hi\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NamePrefix_is_applied()
|
||||
{
|
||||
const string csv = """
|
||||
Tag Name,Address,Data Type,Client Access
|
||||
Speed,Speed,DINT,Read/Write
|
||||
""";
|
||||
|
||||
var result = new CsvTagImporter
|
||||
{
|
||||
DefaultDeviceHostAddress = DeviceHost,
|
||||
NamePrefix = "PLC1_",
|
||||
}.Import(csv);
|
||||
|
||||
result.Tags.Single().Name.ShouldBe("PLC1_Speed");
|
||||
result.Tags.Single().TagPath.ShouldBe("Speed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_data_type_falls_through_as_Structure()
|
||||
{
|
||||
const string csv = """
|
||||
Tag Name,Address,Data Type,Client Access
|
||||
Mystery,Mystery,SomeUnknownType,Read/Write
|
||||
""";
|
||||
|
||||
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||
|
||||
result.Tags.Single().DataType.ShouldBe(AbCipDataType.Structure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Throws_when_DefaultDeviceHostAddress_missing()
|
||||
{
|
||||
const string csv = "Tag Name,Address,Data Type,Client Access\nA,A,DINT,Read/Write\n";
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => new CsvTagImporter().Import(csv));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_load_export_reparse_is_stable()
|
||||
{
|
||||
var original = new[]
|
||||
{
|
||||
new AbCipTagDefinition("Motor1", DeviceHost, "Motor1", AbCipDataType.DInt,
|
||||
Writable: true, Description: "Drive speed"),
|
||||
new AbCipTagDefinition("Sensor", DeviceHost, "Sensor", AbCipDataType.Real,
|
||||
Writable: false, Description: "Pressure, kPa"),
|
||||
new AbCipTagDefinition("Tag3", DeviceHost, "Program:Main.Tag3", AbCipDataType.Bool,
|
||||
Writable: true, Description: null),
|
||||
};
|
||||
|
||||
var csv = CsvTagExporter.ToCsv(original);
|
||||
var reparsed = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv).Tags;
|
||||
|
||||
reparsed.Count.ShouldBe(original.Length);
|
||||
for (var i = 0; i < original.Length; i++)
|
||||
{
|
||||
reparsed[i].Name.ShouldBe(original[i].Name);
|
||||
reparsed[i].TagPath.ShouldBe(original[i].TagPath);
|
||||
reparsed[i].DataType.ShouldBe(original[i].DataType);
|
||||
reparsed[i].Writable.ShouldBe(original[i].Writable);
|
||||
reparsed[i].Description.ShouldBe(original[i].Description);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reordered_columns_are_honoured_via_header_lookup()
|
||||
{
|
||||
const string csv = """
|
||||
Description,Address,Tag Name,Client Access,Data Type
|
||||
Drive speed,Motor1,Motor1,Read/Write,DINT
|
||||
""";
|
||||
|
||||
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||
|
||||
var t = result.Tags.Single();
|
||||
t.Name.ShouldBe("Motor1");
|
||||
t.TagPath.ShouldBe("Motor1");
|
||||
t.DataType.ShouldBe(AbCipDataType.DInt);
|
||||
t.Description.ShouldBe("Drive speed");
|
||||
t.Writable.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
250
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kIngestTests.cs
Normal file
250
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kIngestTests.cs
Normal file
@@ -0,0 +1,250 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class L5kIngestTests
|
||||
{
|
||||
private const string DeviceHost = "ab://10.10.10.1/0,1";
|
||||
|
||||
[Fact]
|
||||
public void Atomic_controller_scope_tag_becomes_AbCipTagDefinition()
|
||||
{
|
||||
const string body = """
|
||||
TAG
|
||||
Motor1_Speed : DINT (ExternalAccess := Read/Write) := 0;
|
||||
END_TAG
|
||||
""";
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var ingest = new L5kIngest { DefaultDeviceHostAddress = DeviceHost };
|
||||
var result = ingest.Ingest(doc);
|
||||
|
||||
result.Tags.Count.ShouldBe(1);
|
||||
var tag = result.Tags[0];
|
||||
tag.Name.ShouldBe("Motor1_Speed");
|
||||
tag.DeviceHostAddress.ShouldBe(DeviceHost);
|
||||
tag.TagPath.ShouldBe("Motor1_Speed");
|
||||
tag.DataType.ShouldBe(AbCipDataType.DInt);
|
||||
tag.Writable.ShouldBeTrue();
|
||||
tag.Members.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_scope_tag_uses_Program_prefix_and_compound_name()
|
||||
{
|
||||
const string body = """
|
||||
PROGRAM MainProgram
|
||||
TAG
|
||||
StepIndex : DINT := 0;
|
||||
END_TAG
|
||||
END_PROGRAM
|
||||
""";
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||
|
||||
result.Tags.Count.ShouldBe(1);
|
||||
result.Tags[0].Name.ShouldBe("MainProgram.StepIndex");
|
||||
result.Tags[0].TagPath.ShouldBe("Program:MainProgram.StepIndex");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Alias_tag_is_skipped()
|
||||
{
|
||||
const string body = """
|
||||
TAG
|
||||
Real : DINT := 0;
|
||||
Aliased : DINT (AliasFor := "Real");
|
||||
END_TAG
|
||||
""";
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||
|
||||
result.SkippedAliasCount.ShouldBe(1);
|
||||
result.Tags.Count.ShouldBe(1);
|
||||
result.Tags.ShouldAllBe(t => t.Name != "Aliased");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExternalAccess_None_tag_is_skipped()
|
||||
{
|
||||
const string body = """
|
||||
TAG
|
||||
Hidden : DINT (ExternalAccess := None) := 0;
|
||||
Visible : DINT := 0;
|
||||
END_TAG
|
||||
""";
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||
|
||||
result.SkippedNoAccessCount.ShouldBe(1);
|
||||
result.Tags.Single().Name.ShouldBe("Visible");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExternalAccess_ReadOnly_tag_becomes_non_writable()
|
||||
{
|
||||
const string body = """
|
||||
TAG
|
||||
Sensor : REAL (ExternalAccess := Read Only) := 0.0;
|
||||
END_TAG
|
||||
""";
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||
|
||||
result.Tags.Single().Writable.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UDT_typed_tag_picks_up_member_layout_from_DATATYPE_block()
|
||||
{
|
||||
const string body = """
|
||||
DATATYPE TankUDT
|
||||
MEMBER Level : REAL := 0.0;
|
||||
MEMBER Active : BOOL := 0;
|
||||
END_DATATYPE
|
||||
TAG
|
||||
Tank1 : TankUDT := [0.0, 0];
|
||||
END_TAG
|
||||
""";
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||
|
||||
var tag = result.Tags.Single();
|
||||
tag.Name.ShouldBe("Tank1");
|
||||
tag.DataType.ShouldBe(AbCipDataType.Structure);
|
||||
tag.Members.ShouldNotBeNull();
|
||||
tag.Members!.Count.ShouldBe(2);
|
||||
tag.Members[0].Name.ShouldBe("Level");
|
||||
tag.Members[0].DataType.ShouldBe(AbCipDataType.Real);
|
||||
tag.Members[1].Name.ShouldBe("Active");
|
||||
tag.Members[1].DataType.ShouldBe(AbCipDataType.Bool);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_datatype_falls_through_as_structure_with_no_members()
|
||||
{
|
||||
const string body = """
|
||||
TAG
|
||||
Mystery : SomeUnknownType := 0;
|
||||
END_TAG
|
||||
""";
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||
|
||||
var tag = result.Tags.Single();
|
||||
tag.DataType.ShouldBe(AbCipDataType.Structure);
|
||||
tag.Members.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ingest_throws_when_DefaultDeviceHostAddress_missing()
|
||||
{
|
||||
var doc = new L5kDocument(new[] { new L5kTag("X", "DINT", null, null, null, null) }, Array.Empty<L5kDataType>());
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => new L5kIngest().Ingest(doc));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AOI_member_Usage_maps_to_AoiQualifier_through_ingest()
|
||||
{
|
||||
// PR abcip-2.6 — L5K AOI parameters carry a Usage := Input / Output / InOut attribute.
|
||||
// Ingest must map those values onto AbCipStructureMember.AoiQualifier so the discovery
|
||||
// layer can group AOI members under sub-folders. Plain DATATYPE members get Local.
|
||||
const string body = """
|
||||
ADD_ON_INSTRUCTION_DEFINITION ValveAoi
|
||||
PARAMETERS
|
||||
PARAMETER Cmd : BOOL (Usage := Input) := 0;
|
||||
PARAMETER Status : DINT (Usage := Output) := 0;
|
||||
PARAMETER Buffer : DINT (Usage := InOut) := 0;
|
||||
PARAMETER Local1 : DINT := 0;
|
||||
END_PARAMETERS
|
||||
END_ADD_ON_INSTRUCTION_DEFINITION
|
||||
DATATYPE PlainUdt
|
||||
MEMBER Speed : DINT := 0;
|
||||
END_DATATYPE
|
||||
TAG
|
||||
Valve_001 : ValveAoi;
|
||||
Tank1 : PlainUdt;
|
||||
END_TAG
|
||||
""";
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||
|
||||
var aoiTag = result.Tags.Single(t => t.Name == "Valve_001");
|
||||
aoiTag.Members.ShouldNotBeNull();
|
||||
aoiTag.Members!.Single(m => m.Name == "Cmd").AoiQualifier.ShouldBe(AoiQualifier.Input);
|
||||
aoiTag.Members.Single(m => m.Name == "Status").AoiQualifier.ShouldBe(AoiQualifier.Output);
|
||||
aoiTag.Members.Single(m => m.Name == "Buffer").AoiQualifier.ShouldBe(AoiQualifier.InOut);
|
||||
aoiTag.Members.Single(m => m.Name == "Local1").AoiQualifier.ShouldBe(AoiQualifier.Local);
|
||||
|
||||
// Plain UDT members default to Local — no Usage attribute to map.
|
||||
var plainTag = result.Tags.Single(t => t.Name == "Tank1");
|
||||
plainTag.Members.ShouldNotBeNull();
|
||||
plainTag.Members!.Single().AoiQualifier.ShouldBe(AoiQualifier.Local);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void L5x_AOI_member_Usage_maps_to_AoiQualifier_through_ingest()
|
||||
{
|
||||
// Same mapping as the L5K case above, exercised through the L5X parser to confirm both
|
||||
// formats land at the same downstream representation.
|
||||
const string body = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RSLogix5000Content>
|
||||
<Controller Name="C">
|
||||
<AddOnInstructionDefinitions>
|
||||
<AddOnInstructionDefinition Name="MyAoi">
|
||||
<Parameters>
|
||||
<Parameter Name="Cmd" DataType="BOOL" Usage="Input" />
|
||||
<Parameter Name="Status" DataType="DINT" Usage="Output" />
|
||||
<Parameter Name="Buffer" DataType="DINT" Usage="InOut" />
|
||||
</Parameters>
|
||||
</AddOnInstructionDefinition>
|
||||
</AddOnInstructionDefinitions>
|
||||
<Tags>
|
||||
<Tag Name="Valve_001" TagType="Base" DataType="MyAoi" />
|
||||
</Tags>
|
||||
</Controller>
|
||||
</RSLogix5000Content>
|
||||
""";
|
||||
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||
|
||||
var aoiTag = result.Tags.Single();
|
||||
aoiTag.Members.ShouldNotBeNull();
|
||||
aoiTag.Members!.Single(m => m.Name == "Cmd").AoiQualifier.ShouldBe(AoiQualifier.Input);
|
||||
aoiTag.Members.Single(m => m.Name == "Status").AoiQualifier.ShouldBe(AoiQualifier.Output);
|
||||
aoiTag.Members.Single(m => m.Name == "Buffer").AoiQualifier.ShouldBe(AoiQualifier.InOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NamePrefix_is_applied_to_imported_tags()
|
||||
{
|
||||
const string body = """
|
||||
TAG
|
||||
Speed : DINT := 0;
|
||||
END_TAG
|
||||
""";
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var result = new L5kIngest
|
||||
{
|
||||
DefaultDeviceHostAddress = DeviceHost,
|
||||
NamePrefix = "PLC1_",
|
||||
}.Ingest(doc);
|
||||
|
||||
result.Tags.Single().Name.ShouldBe("PLC1_Speed");
|
||||
result.Tags.Single().TagPath.ShouldBe("Speed"); // path on the PLC stays unchanged
|
||||
}
|
||||
}
|
||||
198
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kParserTests.cs
Normal file
198
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kParserTests.cs
Normal file
@@ -0,0 +1,198 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class L5kParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Controller_scope_TAG_block_parses_name_datatype_externalaccess()
|
||||
{
|
||||
const string body = """
|
||||
TAG
|
||||
Motor1_Speed : DINT (Description := "Motor 1 set point", ExternalAccess := Read/Write) := 0;
|
||||
Tank_Level : REAL (ExternalAccess := Read Only) := 0.0;
|
||||
END_TAG
|
||||
""";
|
||||
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
doc.Tags.Count.ShouldBe(2);
|
||||
doc.Tags[0].Name.ShouldBe("Motor1_Speed");
|
||||
doc.Tags[0].DataType.ShouldBe("DINT");
|
||||
doc.Tags[0].ProgramScope.ShouldBeNull();
|
||||
doc.Tags[0].ExternalAccess.ShouldBe("Read/Write");
|
||||
doc.Tags[0].Description.ShouldBe("Motor 1 set point");
|
||||
doc.Tags[0].AliasFor.ShouldBeNull();
|
||||
|
||||
doc.Tags[1].Name.ShouldBe("Tank_Level");
|
||||
doc.Tags[1].DataType.ShouldBe("REAL");
|
||||
doc.Tags[1].ExternalAccess.ShouldBe("Read Only");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_scope_TAG_block_carries_program_name()
|
||||
{
|
||||
const string body = """
|
||||
PROGRAM MainProgram (Class := Standard)
|
||||
TAG
|
||||
StepIndex : DINT := 0;
|
||||
Running : BOOL := 0;
|
||||
END_TAG
|
||||
END_PROGRAM
|
||||
""";
|
||||
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
doc.Tags.Count.ShouldBe(2);
|
||||
doc.Tags.ShouldAllBe(t => t.ProgramScope == "MainProgram");
|
||||
doc.Tags.Select(t => t.Name).ShouldBe(["StepIndex", "Running"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Alias_tag_is_flagged()
|
||||
{
|
||||
const string body = """
|
||||
TAG
|
||||
Motor1 : DINT := 0;
|
||||
Motor1_Alias : DINT (AliasFor := "Motor1", ExternalAccess := Read/Write);
|
||||
END_TAG
|
||||
""";
|
||||
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var alias = doc.Tags.Single(t => t.Name == "Motor1_Alias");
|
||||
alias.AliasFor.ShouldBe("Motor1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DATATYPE_block_collects_member_lines()
|
||||
{
|
||||
const string body = """
|
||||
DATATYPE TankUDT (FamilyType := NoFamily)
|
||||
MEMBER Level : REAL (ExternalAccess := Read/Write) := 0.0;
|
||||
MEMBER Pressure : REAL := 0.0;
|
||||
MEMBER Active : BOOL := 0;
|
||||
END_DATATYPE
|
||||
""";
|
||||
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
doc.DataTypes.Count.ShouldBe(1);
|
||||
var udt = doc.DataTypes[0];
|
||||
udt.Name.ShouldBe("TankUDT");
|
||||
udt.Members.Count.ShouldBe(3);
|
||||
udt.Members[0].Name.ShouldBe("Level");
|
||||
udt.Members[0].DataType.ShouldBe("REAL");
|
||||
udt.Members[0].ExternalAccess.ShouldBe("Read/Write");
|
||||
udt.Members[1].Name.ShouldBe("Pressure");
|
||||
udt.Members[2].Name.ShouldBe("Active");
|
||||
udt.Members[2].DataType.ShouldBe("BOOL");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DATATYPE_member_with_array_dim_keeps_type_clean()
|
||||
{
|
||||
const string body = """
|
||||
DATATYPE BatchUDT
|
||||
MEMBER Recipe : DINT[16] := 0;
|
||||
MEMBER Name : STRING := "";
|
||||
END_DATATYPE
|
||||
""";
|
||||
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var udt = doc.DataTypes[0];
|
||||
var recipe = udt.Members.First(m => m.Name == "Recipe");
|
||||
recipe.DataType.ShouldBe("DINT");
|
||||
recipe.ArrayDim.ShouldBe(16);
|
||||
|
||||
var nameMember = udt.Members.First(m => m.Name == "Name");
|
||||
nameMember.DataType.ShouldBe("STRING");
|
||||
nameMember.ArrayDim.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Block_comments_are_stripped_before_parsing()
|
||||
{
|
||||
const string body = """
|
||||
(* This is a long
|
||||
multi-line comment with TAG and END_TAG inside, parser must skip *)
|
||||
TAG
|
||||
Real_Tag : DINT := 0;
|
||||
END_TAG
|
||||
""";
|
||||
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
doc.Tags.Count.ShouldBe(1);
|
||||
doc.Tags[0].Name.ShouldBe("Real_Tag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_sections_are_skipped_silently()
|
||||
{
|
||||
const string body = """
|
||||
CONFIG SomeConfig (Class := Standard)
|
||||
ConfigData := 0;
|
||||
END_CONFIG
|
||||
MOTION_GROUP Motion1
|
||||
Member := whatever;
|
||||
END_MOTION_GROUP
|
||||
TAG
|
||||
Real_Tag : DINT := 0;
|
||||
END_TAG
|
||||
""";
|
||||
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
doc.Tags.Count.ShouldBe(1);
|
||||
doc.Tags[0].Name.ShouldBe("Real_Tag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AOI_definition_block_collects_parameters_with_Usage()
|
||||
{
|
||||
// PR abcip-2.6 — ADD_ON_INSTRUCTION_DEFINITION blocks with PARAMETER entries carrying
|
||||
// Usage := Input / Output / InOut. The parser surfaces them as L5kDataType members so
|
||||
// AOI-typed tags pick up a layout the same way UDT-typed tags do.
|
||||
const string body = """
|
||||
ADD_ON_INSTRUCTION_DEFINITION MyValveAoi (Revision := "1.0")
|
||||
PARAMETERS
|
||||
PARAMETER Cmd : BOOL (Usage := Input) := 0;
|
||||
PARAMETER Status : DINT (Usage := Output, ExternalAccess := Read Only) := 0;
|
||||
PARAMETER Buffer : DINT (Usage := InOut) := 0;
|
||||
PARAMETER Internal : DINT := 0;
|
||||
END_PARAMETERS
|
||||
LOCAL_TAGS
|
||||
Working : DINT := 0;
|
||||
END_LOCAL_TAGS
|
||||
END_ADD_ON_INSTRUCTION_DEFINITION
|
||||
""";
|
||||
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var aoi = doc.DataTypes.Single(d => d.Name == "MyValveAoi");
|
||||
aoi.Members.Count.ShouldBe(4);
|
||||
aoi.Members.Single(m => m.Name == "Cmd").Usage.ShouldBe("Input");
|
||||
aoi.Members.Single(m => m.Name == "Status").Usage.ShouldBe("Output");
|
||||
aoi.Members.Single(m => m.Name == "Buffer").Usage.ShouldBe("InOut");
|
||||
aoi.Members.Single(m => m.Name == "Internal").Usage.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multi_line_TAG_entry_is_concatenated()
|
||||
{
|
||||
const string body = """
|
||||
TAG
|
||||
Motor1 : DINT (Description := "Long description spanning",
|
||||
ExternalAccess := Read/Write) := 0;
|
||||
END_TAG
|
||||
""";
|
||||
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
doc.Tags.Count.ShouldBe(1);
|
||||
doc.Tags[0].Description.ShouldBe("Long description spanning");
|
||||
doc.Tags[0].ExternalAccess.ShouldBe("Read/Write");
|
||||
}
|
||||
}
|
||||
259
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5xParserTests.cs
Normal file
259
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5xParserTests.cs
Normal file
@@ -0,0 +1,259 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class L5xParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Controller_scope_Tag_elements_parse_with_metadata()
|
||||
{
|
||||
const string body = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RSLogix5000Content SchemaRevision="1.0" SoftwareRevision="32.00">
|
||||
<Controller Name="MyController" ProcessorType="1756-L83E">
|
||||
<Tags>
|
||||
<Tag Name="Motor1_Speed" TagType="Base" DataType="DINT" ExternalAccess="Read/Write">
|
||||
<Description><![CDATA[Motor 1 set point]]></Description>
|
||||
</Tag>
|
||||
<Tag Name="Tank_Level" TagType="Base" DataType="REAL" ExternalAccess="Read Only" />
|
||||
</Tags>
|
||||
</Controller>
|
||||
</RSLogix5000Content>
|
||||
""";
|
||||
|
||||
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||
|
||||
doc.Tags.Count.ShouldBe(2);
|
||||
doc.Tags[0].Name.ShouldBe("Motor1_Speed");
|
||||
doc.Tags[0].DataType.ShouldBe("DINT");
|
||||
doc.Tags[0].ExternalAccess.ShouldBe("Read/Write");
|
||||
doc.Tags[0].Description.ShouldBe("Motor 1 set point");
|
||||
doc.Tags[0].ProgramScope.ShouldBeNull();
|
||||
doc.Tags[0].AliasFor.ShouldBeNull();
|
||||
|
||||
doc.Tags[1].Name.ShouldBe("Tank_Level");
|
||||
doc.Tags[1].ExternalAccess.ShouldBe("Read Only");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_scope_Tag_elements_carry_program_name()
|
||||
{
|
||||
const string body = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RSLogix5000Content>
|
||||
<Controller Name="C">
|
||||
<Programs>
|
||||
<Program Name="MainProgram" Class="Standard">
|
||||
<Tags>
|
||||
<Tag Name="StepIndex" TagType="Base" DataType="DINT" />
|
||||
<Tag Name="Running" TagType="Base" DataType="BOOL" />
|
||||
</Tags>
|
||||
</Program>
|
||||
</Programs>
|
||||
</Controller>
|
||||
</RSLogix5000Content>
|
||||
""";
|
||||
|
||||
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||
|
||||
doc.Tags.Count.ShouldBe(2);
|
||||
doc.Tags.ShouldAllBe(t => t.ProgramScope == "MainProgram");
|
||||
doc.Tags.Select(t => t.Name).ShouldBe(["StepIndex", "Running"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Alias_tag_carries_AliasFor_and_is_skipped_on_ingest()
|
||||
{
|
||||
const string body = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RSLogix5000Content>
|
||||
<Controller Name="C">
|
||||
<Tags>
|
||||
<Tag Name="Real" TagType="Base" DataType="DINT" />
|
||||
<Tag Name="Aliased" TagType="Alias" AliasFor="Real" ExternalAccess="Read/Write" />
|
||||
</Tags>
|
||||
</Controller>
|
||||
</RSLogix5000Content>
|
||||
""";
|
||||
|
||||
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||
var alias = doc.Tags.Single(t => t.Name == "Aliased");
|
||||
alias.AliasFor.ShouldBe("Real");
|
||||
|
||||
var ingestResult = new L5kIngest { DefaultDeviceHostAddress = "ab://10.0.0.1/0,1" }.Ingest(doc);
|
||||
ingestResult.SkippedAliasCount.ShouldBe(1);
|
||||
ingestResult.Tags.ShouldAllBe(t => t.Name != "Aliased");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DataType_block_collects_member_elements_and_skips_hidden_zzzz_host()
|
||||
{
|
||||
const string body = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RSLogix5000Content>
|
||||
<Controller Name="C">
|
||||
<DataTypes>
|
||||
<DataType Name="TankUDT" Class="User">
|
||||
<Members>
|
||||
<Member Name="ZZZZZZZZZZTankUDT0" DataType="SINT" Hidden="true" />
|
||||
<Member Name="Level" DataType="REAL" ExternalAccess="Read/Write" />
|
||||
<Member Name="Active" DataType="BIT" Target="ZZZZZZZZZZTankUDT0" BitNumber="0" />
|
||||
</Members>
|
||||
</DataType>
|
||||
</DataTypes>
|
||||
</Controller>
|
||||
</RSLogix5000Content>
|
||||
""";
|
||||
|
||||
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||
|
||||
doc.DataTypes.Count.ShouldBe(1);
|
||||
var udt = doc.DataTypes[0];
|
||||
udt.Name.ShouldBe("TankUDT");
|
||||
udt.Members.Count.ShouldBe(2);
|
||||
udt.Members.ShouldContain(m => m.Name == "Level" && m.DataType == "REAL");
|
||||
udt.Members.ShouldContain(m => m.Name == "Active");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UDT_typed_tag_picks_up_member_layout_through_ingest()
|
||||
{
|
||||
const string body = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RSLogix5000Content>
|
||||
<Controller Name="C">
|
||||
<DataTypes>
|
||||
<DataType Name="TankUDT" Class="User">
|
||||
<Members>
|
||||
<Member Name="Level" DataType="REAL" />
|
||||
<Member Name="Pressure" DataType="REAL" />
|
||||
</Members>
|
||||
</DataType>
|
||||
</DataTypes>
|
||||
<Tags>
|
||||
<Tag Name="Tank1" TagType="Base" DataType="TankUDT" />
|
||||
</Tags>
|
||||
</Controller>
|
||||
</RSLogix5000Content>
|
||||
""";
|
||||
|
||||
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = "ab://10.0.0.1/0,1" }.Ingest(doc);
|
||||
|
||||
var tag = result.Tags.Single();
|
||||
tag.Name.ShouldBe("Tank1");
|
||||
tag.DataType.ShouldBe(AbCipDataType.Structure);
|
||||
tag.Members.ShouldNotBeNull();
|
||||
tag.Members!.Count.ShouldBe(2);
|
||||
tag.Members.Select(m => m.Name).ShouldBe(["Level", "Pressure"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AOI_definition_surfaces_as_datatype_with_visible_parameters()
|
||||
{
|
||||
// EnableIn / EnableOut on real exports carry Hidden="true" — the parser must skip those
|
||||
// so AOI-typed tags don't end up with phantom EnableIn/EnableOut members.
|
||||
const string body = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RSLogix5000Content>
|
||||
<Controller Name="C">
|
||||
<AddOnInstructionDefinitions>
|
||||
<AddOnInstructionDefinition Name="MyValveAoi" Revision="1.0">
|
||||
<Parameters>
|
||||
<Parameter Name="EnableIn" TagType="Base" DataType="BOOL" Usage="Input" Hidden="true" />
|
||||
<Parameter Name="EnableOut" TagType="Base" DataType="BOOL" Usage="Output" Hidden="true" />
|
||||
<Parameter Name="Cmd" TagType="Base" DataType="BOOL" Usage="Input" />
|
||||
<Parameter Name="Status" TagType="Base" DataType="DINT" Usage="Output" ExternalAccess="Read Only" />
|
||||
</Parameters>
|
||||
</AddOnInstructionDefinition>
|
||||
</AddOnInstructionDefinitions>
|
||||
<Tags>
|
||||
<Tag Name="Valve_001" TagType="Base" DataType="MyValveAoi" />
|
||||
</Tags>
|
||||
</Controller>
|
||||
</RSLogix5000Content>
|
||||
""";
|
||||
|
||||
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||
// AOI definition should appear as a "DataType" entry alongside any UDTs.
|
||||
var aoi = doc.DataTypes.Single(d => d.Name == "MyValveAoi");
|
||||
aoi.Members.Count.ShouldBe(2);
|
||||
aoi.Members.Select(m => m.Name).ShouldBe(["Cmd", "Status"]);
|
||||
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = "ab://10.0.0.1/0,1" }.Ingest(doc);
|
||||
var tag = result.Tags.Single();
|
||||
tag.Name.ShouldBe("Valve_001");
|
||||
tag.DataType.ShouldBe(AbCipDataType.Structure);
|
||||
tag.Members.ShouldNotBeNull();
|
||||
tag.Members!.Select(m => m.Name).ShouldBe(["Cmd", "Status"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AOI_parameter_Usage_attribute_is_captured()
|
||||
{
|
||||
// PR abcip-2.6 — Usage attribute on <Parameter> elements (Input / Output / InOut) flows
|
||||
// through to L5kMember.Usage so the ingest layer can map it to AoiQualifier.
|
||||
const string body = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RSLogix5000Content>
|
||||
<Controller Name="C">
|
||||
<AddOnInstructionDefinitions>
|
||||
<AddOnInstructionDefinition Name="MyAoi" Revision="1.0">
|
||||
<Parameters>
|
||||
<Parameter Name="Cmd" DataType="BOOL" Usage="Input" />
|
||||
<Parameter Name="Status" DataType="DINT" Usage="Output" />
|
||||
<Parameter Name="Buffer" DataType="DINT" Usage="InOut" />
|
||||
</Parameters>
|
||||
</AddOnInstructionDefinition>
|
||||
</AddOnInstructionDefinitions>
|
||||
</Controller>
|
||||
</RSLogix5000Content>
|
||||
""";
|
||||
|
||||
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||
var aoi = doc.DataTypes.Single(d => d.Name == "MyAoi");
|
||||
aoi.Members.Single(m => m.Name == "Cmd").Usage.ShouldBe("Input");
|
||||
aoi.Members.Single(m => m.Name == "Status").Usage.ShouldBe("Output");
|
||||
aoi.Members.Single(m => m.Name == "Buffer").Usage.ShouldBe("InOut");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_or_minimal_document_returns_empty_bundle_without_throwing()
|
||||
{
|
||||
const string body = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RSLogix5000Content>
|
||||
<Controller Name="C" />
|
||||
</RSLogix5000Content>
|
||||
""";
|
||||
|
||||
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||
doc.Tags.Count.ShouldBe(0);
|
||||
doc.DataTypes.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_external_access_defaults_to_writable_through_ingest()
|
||||
{
|
||||
// L5X: ExternalAccess attribute absent → ingest treats as default (writable, not skipped).
|
||||
const string body = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RSLogix5000Content>
|
||||
<Controller Name="C">
|
||||
<Tags>
|
||||
<Tag Name="Plain" TagType="Base" DataType="DINT" />
|
||||
</Tags>
|
||||
</Controller>
|
||||
</RSLogix5000Content>
|
||||
""";
|
||||
|
||||
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = "ab://10.0.0.1/0,1" }.Ingest(doc);
|
||||
|
||||
result.Tags.Single().Writable.ShouldBeTrue();
|
||||
result.SkippedNoAccessCount.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
@@ -65,4 +66,437 @@ public sealed class AbLegacyAddressTests
|
||||
a.ShouldNotBeNull();
|
||||
a.ToLibplctagName().ShouldBe(input);
|
||||
}
|
||||
|
||||
// ---- PLC-5 octal I:/O: addressing (Issue #244) ----
|
||||
//
|
||||
// RSLogix 5 displays I:/O: word + bit indices as octal. `I:001/17` means rack 1, bit 15
|
||||
// (octal 17). Other PCCC families (SLC500, MicroLogix, LogixPccc) keep decimal indices.
|
||||
// Non-I/O file letters are always decimal regardless of family.
|
||||
|
||||
[Theory]
|
||||
[InlineData("I:001/17", 1, 15)] // octal 17 → bit 15
|
||||
[InlineData("I:0/0", 0, 0)] // boundary: octal 0
|
||||
[InlineData("O:1/2", 1, 2)] // octal 1, 2 happen to match decimal
|
||||
[InlineData("I:010/10", 8, 8)] // octal 10 → 8 (both word + bit)
|
||||
[InlineData("I:007/7", 7, 7)] // boundary: largest single octal digit
|
||||
public void TryParse_Plc5_parses_io_indices_as_octal(string input, int expectedWord, int expectedBit)
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5);
|
||||
a.ShouldNotBeNull();
|
||||
a.WordNumber.ShouldBe(expectedWord);
|
||||
a.BitIndex.ShouldBe(expectedBit);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("I:8/0")] // word digit 8 illegal in octal
|
||||
[InlineData("I:0/9")] // bit digit 9 illegal in octal
|
||||
[InlineData("O:128/0")] // contains digit 8
|
||||
[InlineData("I:0/18")] // bit field octal-illegal because of '8'
|
||||
public void TryParse_Plc5_rejects_octal_invalid_io_digits(string input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// Non-I/O files stay decimal even on PLC-5 (e.g. N7:8 is integer 7, word 8).
|
||||
[InlineData("N7:8", 7, 8)]
|
||||
[InlineData("F8:9", 8, 9)]
|
||||
public void TryParse_Plc5_keeps_non_io_indices_decimal(string input, int? expectedFile, int expectedWord)
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5);
|
||||
a.ShouldNotBeNull();
|
||||
a.FileNumber.ShouldBe(expectedFile);
|
||||
a.WordNumber.ShouldBe(expectedWord);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_Slc500_keeps_io_indices_decimal_back_compat()
|
||||
{
|
||||
// SLC500 has OctalIoAddressing=false — the digits are decimal as before.
|
||||
var a = AbLegacyAddress.TryParse("I:10/15", AbLegacyPlcFamily.Slc500);
|
||||
a.ShouldNotBeNull();
|
||||
a.WordNumber.ShouldBe(10);
|
||||
a.BitIndex.ShouldBe(15);
|
||||
|
||||
// Decimal '8' that PLC-5 would reject is fine on SLC500.
|
||||
var b = AbLegacyAddress.TryParse("I:8/0", AbLegacyPlcFamily.Slc500);
|
||||
b.ShouldNotBeNull();
|
||||
b.WordNumber.ShouldBe(8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_MicroLogix_and_LogixPccc_keep_io_indices_decimal()
|
||||
{
|
||||
AbLegacyAddress.TryParse("I:9/0", AbLegacyPlcFamily.MicroLogix).ShouldNotBeNull();
|
||||
AbLegacyAddress.TryParse("I:9/0", AbLegacyPlcFamily.LogixPccc).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plc5Profile_advertises_octal_io_addressing()
|
||||
{
|
||||
AbLegacyPlcFamilyProfile.Plc5.OctalIoAddressing.ShouldBeTrue();
|
||||
AbLegacyPlcFamilyProfile.Slc500.OctalIoAddressing.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.MicroLogix.OctalIoAddressing.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.LogixPccc.OctalIoAddressing.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---- MicroLogix function-file letters (Issue #245) ----
|
||||
//
|
||||
// MicroLogix 1100/1400 expose RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI function files. Other
|
||||
// PCCC families (SLC500 / PLC-5 / LogixPccc) reject those file letters.
|
||||
|
||||
[Theory]
|
||||
[InlineData("RTC:0.HR", "RTC", "HR")]
|
||||
[InlineData("RTC:0.MIN", "RTC", "MIN")]
|
||||
[InlineData("RTC:0.YR", "RTC", "YR")]
|
||||
[InlineData("HSC:0.ACC", "HSC", "ACC")]
|
||||
[InlineData("HSC:0.PRE", "HSC", "PRE")]
|
||||
[InlineData("HSC:0.EN", "HSC", "EN")]
|
||||
[InlineData("DLS:0.STR", "DLS", "STR")]
|
||||
[InlineData("PTO:0.OF", "PTO", "OF")]
|
||||
[InlineData("PWM:0.EN", "PWM", "EN")]
|
||||
[InlineData("STI:0.SPM", "STI", "SPM")]
|
||||
[InlineData("EII:0.PFN", "EII", "PFN")]
|
||||
[InlineData("MMI:0.FT", "MMI", "FT")]
|
||||
[InlineData("BHI:0.OS", "BHI", "OS")]
|
||||
[InlineData("IOS:0.ID", "IOS", "ID")]
|
||||
public void TryParse_MicroLogix_accepts_function_files(string input, string expectedLetter, string expectedSub)
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.MicroLogix);
|
||||
a.ShouldNotBeNull();
|
||||
a.FileLetter.ShouldBe(expectedLetter);
|
||||
a.SubElement.ShouldBe(expectedSub);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("RTC:0.HR")]
|
||||
[InlineData("HSC:0.ACC")]
|
||||
[InlineData("PTO:0.OF")]
|
||||
[InlineData("BHI:0.OS")]
|
||||
public void TryParse_Slc500_rejects_function_files(string input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Slc500).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("RTC:0.HR")]
|
||||
[InlineData("HSC:0.ACC")]
|
||||
public void TryParse_Plc5_and_LogixPccc_reject_function_files(string input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5).ShouldBeNull();
|
||||
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.LogixPccc).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_Default_overload_rejects_function_files()
|
||||
{
|
||||
// Without a family the parser cannot allow MicroLogix-only letters — back-compat with
|
||||
// the family-less overload from before #244.
|
||||
AbLegacyAddress.TryParse("RTC:0.HR").ShouldBeNull();
|
||||
AbLegacyAddress.TryParse("HSC:0.ACC").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MicroLogixProfile_advertises_function_file_support()
|
||||
{
|
||||
AbLegacyPlcFamilyProfile.MicroLogix.SupportsFunctionFiles.ShouldBeTrue();
|
||||
AbLegacyPlcFamilyProfile.Slc500.SupportsFunctionFiles.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.Plc5.SupportsFunctionFiles.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.LogixPccc.SupportsFunctionFiles.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---- Indirect / indexed addressing (Issue #247) ----
|
||||
//
|
||||
// PLC-5 / SLC permit `N7:[N7:0]` (word number sourced from another address) and
|
||||
// `N[N7:0]:5` (file number sourced from another address). Recursion is capped at 1 — the
|
||||
// inner address must itself be a plain direct PCCC reference.
|
||||
|
||||
[Fact]
|
||||
public void TryParse_accepts_indirect_word_source()
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse("N7:[N7:0]");
|
||||
a.ShouldNotBeNull();
|
||||
a.FileLetter.ShouldBe("N");
|
||||
a.FileNumber.ShouldBe(7);
|
||||
a.IndirectFileSource.ShouldBeNull();
|
||||
a.IndirectWordSource.ShouldNotBeNull();
|
||||
a.IndirectWordSource!.FileLetter.ShouldBe("N");
|
||||
a.IndirectWordSource.FileNumber.ShouldBe(7);
|
||||
a.IndirectWordSource.WordNumber.ShouldBe(0);
|
||||
a.IsIndirect.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_accepts_indirect_file_source()
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse("N[N7:0]:5");
|
||||
a.ShouldNotBeNull();
|
||||
a.FileLetter.ShouldBe("N");
|
||||
a.FileNumber.ShouldBeNull();
|
||||
a.WordNumber.ShouldBe(5);
|
||||
a.IndirectFileSource.ShouldNotBeNull();
|
||||
a.IndirectFileSource!.FileLetter.ShouldBe("N");
|
||||
a.IndirectFileSource.FileNumber.ShouldBe(7);
|
||||
a.IndirectFileSource.WordNumber.ShouldBe(0);
|
||||
a.IndirectWordSource.ShouldBeNull();
|
||||
a.IsIndirect.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_accepts_both_indirect_file_and_word()
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse("N[N7:0]:[N7:1]");
|
||||
a.ShouldNotBeNull();
|
||||
a.IndirectFileSource.ShouldNotBeNull();
|
||||
a.IndirectWordSource.ShouldNotBeNull();
|
||||
a.IndirectWordSource!.WordNumber.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("N[N[N7:0]:0]:5")] // depth-2 file source
|
||||
[InlineData("N7:[N[N7:0]:0]")] // depth-2 word source
|
||||
[InlineData("N7:[N7:[N7:0]]")] // depth-2 word source (nested word)
|
||||
public void TryParse_rejects_depth_greater_than_one(string input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("N7:[")] // unbalanced bracket
|
||||
[InlineData("N7:]")] // unbalanced bracket
|
||||
[InlineData("N[:5")] // empty inner file source
|
||||
[InlineData("N7:[]")] // empty inner word source
|
||||
[InlineData("N[X9:0]:5")] // unknown file letter inside
|
||||
public void TryParse_rejects_malformed_indirect(string input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLibplctagName_reemits_indirect_word_source()
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse("N7:[N7:0]");
|
||||
a.ShouldNotBeNull();
|
||||
a.ToLibplctagName().ShouldBe("N7:[N7:0]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLibplctagName_reemits_indirect_file_source()
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse("N[N7:0]:5");
|
||||
a.ShouldNotBeNull();
|
||||
a.ToLibplctagName().ShouldBe("N[N7:0]:5");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_indirect_with_bit_outside_brackets()
|
||||
{
|
||||
// Outer bit applies to the resolved word; inner address is still depth-1.
|
||||
var a = AbLegacyAddress.TryParse("N7:[N7:0]/3");
|
||||
a.ShouldNotBeNull();
|
||||
a.BitIndex.ShouldBe(3);
|
||||
a.IndirectWordSource.ShouldNotBeNull();
|
||||
a.ToLibplctagName().ShouldBe("N7:[N7:0]/3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_Plc5_indirect_inner_address_obeys_octal()
|
||||
{
|
||||
// Inner I:/O: indices on PLC-5 must obey octal rules even when nested in brackets.
|
||||
var a = AbLegacyAddress.TryParse("N7:[I:010/10]", AbLegacyPlcFamily.Plc5);
|
||||
a.ShouldNotBeNull();
|
||||
a.IndirectWordSource.ShouldNotBeNull();
|
||||
a.IndirectWordSource!.WordNumber.ShouldBe(8); // octal 010 → 8
|
||||
a.IndirectWordSource.BitIndex.ShouldBe(8); // octal 10 → 8
|
||||
|
||||
// Octal-illegal digit '8' inside an inner I: address is rejected on PLC-5.
|
||||
AbLegacyAddress.TryParse("N7:[I:8/0]", AbLegacyPlcFamily.Plc5).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_indirect_inner_cannot_itself_be_indirect()
|
||||
{
|
||||
AbLegacyAddress.TryParse("N7:[N7:[N7:0]]").ShouldBeNull();
|
||||
AbLegacyAddress.TryParse("N[N[N7:0]:5]:5").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("RTC", "HR", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
|
||||
[InlineData("RTC", "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
[InlineData("HSC", "ACC", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
|
||||
[InlineData("HSC", "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
[InlineData("DLS", "STR", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
|
||||
[InlineData("DLS", "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
[InlineData("PWM", "OUT", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
public void FunctionFile_subelement_catalogue_maps_to_expected_driver_type(
|
||||
string letter, string sub, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType expected)
|
||||
{
|
||||
AbLegacyFunctionFile.SubElementType(letter, sub).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---- Structure files PD/MG/PLS/BT (Issue #248) ----
|
||||
//
|
||||
// PD (PID), MG (Message), PLS (Programmable Limit Switch), BT (Block Transfer) — accepted on
|
||||
// SLC500 + PLC-5 for PD/MG, PLC-5 only for PLS/BT. MicroLogix and LogixPccc reject all four.
|
||||
|
||||
[Theory]
|
||||
[InlineData("PD10:0.SP")]
|
||||
[InlineData("PD10:0.PV")]
|
||||
[InlineData("PD10:0.KP")]
|
||||
[InlineData("PD10:0.EN")]
|
||||
[InlineData("MG11:0.LEN")]
|
||||
[InlineData("MG11:0.DN")]
|
||||
public void TryParse_Slc500_accepts_pd_and_mg(string input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Slc500).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PLS12:0.LEN")]
|
||||
[InlineData("BT13:0.RLEN")]
|
||||
[InlineData("BT13:0.EN")]
|
||||
public void TryParse_Slc500_rejects_pls_and_bt(string input)
|
||||
{
|
||||
// PLS/BT are PLC-5 only; SLC500 must reject.
|
||||
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Slc500).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PD10:0.KP", "PD", "KP")]
|
||||
[InlineData("MG11:0.EN", "MG", "EN")]
|
||||
[InlineData("PLS12:0.LEN", "PLS", "LEN")]
|
||||
[InlineData("BT13:0.RLEN", "BT", "RLEN")]
|
||||
[InlineData("BT13:0.DN", "BT", "DN")]
|
||||
public void TryParse_Plc5_accepts_all_structure_files(string input, string letter, string sub)
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5);
|
||||
a.ShouldNotBeNull();
|
||||
a.FileLetter.ShouldBe(letter);
|
||||
a.SubElement.ShouldBe(sub);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PD10:0.SP")]
|
||||
[InlineData("MG11:0.LEN")]
|
||||
[InlineData("PLS12:0.LEN")]
|
||||
[InlineData("BT13:0.RLEN")]
|
||||
public void TryParse_MicroLogix_rejects_all_structure_files(string input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.MicroLogix).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PD10:0.SP")]
|
||||
[InlineData("MG11:0.LEN")]
|
||||
[InlineData("PLS12:0.LEN")]
|
||||
[InlineData("BT13:0.RLEN")]
|
||||
public void TryParse_LogixPccc_rejects_all_structure_files(string input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.LogixPccc).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_Default_overload_rejects_structure_files()
|
||||
{
|
||||
// Without a family the parser cannot allow structure-file letters.
|
||||
AbLegacyAddress.TryParse("PD10:0.SP").ShouldBeNull();
|
||||
AbLegacyAddress.TryParse("MG11:0.LEN").ShouldBeNull();
|
||||
AbLegacyAddress.TryParse("PLS12:0.LEN").ShouldBeNull();
|
||||
AbLegacyAddress.TryParse("BT13:0.RLEN").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Profiles_advertise_structure_file_support_per_family()
|
||||
{
|
||||
AbLegacyPlcFamilyProfile.Slc500.SupportsPidFile.ShouldBeTrue();
|
||||
AbLegacyPlcFamilyProfile.Slc500.SupportsMessageFile.ShouldBeTrue();
|
||||
AbLegacyPlcFamilyProfile.Slc500.SupportsPlsFile.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.Slc500.SupportsBlockTransferFile.ShouldBeFalse();
|
||||
|
||||
AbLegacyPlcFamilyProfile.Plc5.SupportsPidFile.ShouldBeTrue();
|
||||
AbLegacyPlcFamilyProfile.Plc5.SupportsMessageFile.ShouldBeTrue();
|
||||
AbLegacyPlcFamilyProfile.Plc5.SupportsPlsFile.ShouldBeTrue();
|
||||
AbLegacyPlcFamilyProfile.Plc5.SupportsBlockTransferFile.ShouldBeTrue();
|
||||
|
||||
AbLegacyPlcFamilyProfile.MicroLogix.SupportsPidFile.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.MicroLogix.SupportsMessageFile.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.MicroLogix.SupportsPlsFile.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.MicroLogix.SupportsBlockTransferFile.ShouldBeFalse();
|
||||
|
||||
AbLegacyPlcFamilyProfile.LogixPccc.SupportsPidFile.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.LogixPccc.SupportsMessageFile.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.LogixPccc.SupportsPlsFile.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.LogixPccc.SupportsBlockTransferFile.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// PID Float members.
|
||||
[InlineData(AbLegacyDataType.PidElement, "SP", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float32)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "PV", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float32)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "KP", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float32)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "KI", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float32)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "KD", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float32)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "OUT", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float32)]
|
||||
// PID status bits.
|
||||
[InlineData(AbLegacyDataType.PidElement, "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "DN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "MO", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "PE", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
// MG Int32 control words.
|
||||
[InlineData(AbLegacyDataType.MessageElement, "RBE", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
|
||||
[InlineData(AbLegacyDataType.MessageElement, "LEN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
|
||||
// MG status bits.
|
||||
[InlineData(AbLegacyDataType.MessageElement, "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.MessageElement, "DN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.MessageElement, "TO", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
// PLS LEN.
|
||||
[InlineData(AbLegacyDataType.PlsElement, "LEN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
|
||||
// BT control words + status bits.
|
||||
[InlineData(AbLegacyDataType.BlockTransferElement, "RLEN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
|
||||
[InlineData(AbLegacyDataType.BlockTransferElement, "DLEN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
|
||||
[InlineData(AbLegacyDataType.BlockTransferElement, "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.BlockTransferElement, "DN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
public void Structure_subelements_resolve_to_expected_driver_type(
|
||||
AbLegacyDataType type, string sub, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType expected)
|
||||
{
|
||||
AbLegacyDataTypeExtensions.EffectiveDriverDataType(type, sub).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// PD bits in word 0.
|
||||
[InlineData(AbLegacyDataType.PidElement, "EN", 0)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "PE", 1)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "DN", 2)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "MO", 3)]
|
||||
// MG/BT share the same 8..15 layout.
|
||||
[InlineData(AbLegacyDataType.MessageElement, "TO", 8)]
|
||||
[InlineData(AbLegacyDataType.MessageElement, "EN", 15)]
|
||||
[InlineData(AbLegacyDataType.BlockTransferElement, "TO", 8)]
|
||||
[InlineData(AbLegacyDataType.BlockTransferElement, "EN", 15)]
|
||||
public void Structure_status_bit_indices_match_rockwell(
|
||||
AbLegacyDataType type, string sub, int expectedBit)
|
||||
{
|
||||
AbLegacyDataTypeExtensions.StatusBitIndex(type, sub).ShouldBe(expectedBit);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// PD: PE + DN + SP_VAL/SP_LL/SP_HL are PLC-set (read-only); EN + MO + AUTO + MAN are
|
||||
// operator-controllable.
|
||||
[InlineData(AbLegacyDataType.PidElement, "PE", true)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "DN", true)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "SP_VAL", true)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "EN", false)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "MO", false)]
|
||||
// MG/BT: ST/DN/ER/CO/EW/NR/TO are PLC-set; EN is operator-driven.
|
||||
[InlineData(AbLegacyDataType.MessageElement, "DN", true)]
|
||||
[InlineData(AbLegacyDataType.MessageElement, "ER", true)]
|
||||
[InlineData(AbLegacyDataType.MessageElement, "TO", true)]
|
||||
[InlineData(AbLegacyDataType.MessageElement, "EN", false)]
|
||||
[InlineData(AbLegacyDataType.BlockTransferElement, "DN", true)]
|
||||
[InlineData(AbLegacyDataType.BlockTransferElement, "EN", false)]
|
||||
public void Structure_plc_set_status_bits_are_marked_read_only(
|
||||
AbLegacyDataType type, string sub, bool expected)
|
||||
{
|
||||
AbLegacyDataTypeExtensions.IsPlcSetStatusBit(type, sub).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,4 +102,96 @@ public sealed class AbLegacyDriverTests
|
||||
AbLegacyDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||
AbLegacyDataType.TimerElement.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "EN", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "TT", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "DN", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "PRE", DriverDataType.Int32)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "ACC", DriverDataType.Int32)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "CU", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "CD", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "DN", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "OV", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "UN", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "PRE", DriverDataType.Int32)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "ACC", DriverDataType.Int32)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EN", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EU", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "DN", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EM", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "ER", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "UL", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "IN", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "FD", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "LEN", DriverDataType.Int32)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "POS", DriverDataType.Int32)]
|
||||
public void EffectiveDriverDataType_resolves_subelements(
|
||||
AbLegacyDataType dataType, string subElement, DriverDataType expected)
|
||||
{
|
||||
AbLegacyDataTypeExtensions.EffectiveDriverDataType(dataType, subElement).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EffectiveDriverDataType_unknown_subelement_falls_back_to_base()
|
||||
{
|
||||
// Permissive — keeps the driver from refusing tags whose sub-element we don't catalogue.
|
||||
AbLegacyDataTypeExtensions.EffectiveDriverDataType(AbLegacyDataType.TimerElement, "BOGUS")
|
||||
.ShouldBe(DriverDataType.Int32);
|
||||
AbLegacyDataTypeExtensions.EffectiveDriverDataType(AbLegacyDataType.TimerElement, null)
|
||||
.ShouldBe(DriverDataType.Int32);
|
||||
AbLegacyDataTypeExtensions.EffectiveDriverDataType(AbLegacyDataType.Int, "DN")
|
||||
.ShouldBe(DriverDataType.Int32);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "DN", 13)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "TT", 14)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "EN", 15)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "UN", 10)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "OV", 11)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "DN", 12)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "CD", 13)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "CU", 14)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "FD", 8)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "IN", 9)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "UL", 10)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "ER", 11)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EM", 12)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "DN", 13)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EU", 14)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EN", 15)]
|
||||
public void StatusBitIndex_maps_to_standard_pccc_positions(
|
||||
AbLegacyDataType dataType, string subElement, int expectedBit)
|
||||
{
|
||||
AbLegacyDataTypeExtensions.StatusBitIndex(dataType, subElement).ShouldBe(expectedBit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StatusBitIndex_for_word_subelements_is_null()
|
||||
{
|
||||
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.TimerElement, "PRE").ShouldBeNull();
|
||||
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.CounterElement, "ACC").ShouldBeNull();
|
||||
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.ControlElement, "LEN").ShouldBeNull();
|
||||
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.TimerElement, null).ShouldBeNull();
|
||||
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.Int, "DN").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "DN", true)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "TT", true)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "EN", false)] // operator-controllable
|
||||
[InlineData(AbLegacyDataType.CounterElement, "DN", true)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "OV", true)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "UN", true)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "CU", false)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "DN", true)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "ER", true)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EM", true)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EN", false)]
|
||||
public void IsPlcSetStatusBit_classifies_writable_vs_status_bits(
|
||||
AbLegacyDataType dataType, string subElement, bool expected)
|
||||
{
|
||||
AbLegacyDataTypeExtensions.IsPlcSetStatusBit(dataType, subElement).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,4 +256,113 @@ public sealed class AbLegacyReadWriteTests
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Timer / Counter / Control sub-element bit semantics (issue #246) ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("T4:0.DN", 13)]
|
||||
[InlineData("T4:0.TT", 14)]
|
||||
[InlineData("T4:0.EN", 15)]
|
||||
public async Task Timer_status_bit_decodes_correct_position(string address, int bitPos)
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, AbLegacyDataType.TimerElement));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
// Seed a parent-word with only the target bit set.
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << bitPos };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().Value.ShouldBe(true);
|
||||
// The driver must have asked the runtime for the right bit position.
|
||||
factory.Tags[address].LastDecodeBitIndex.ShouldBe(bitPos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timer_PRE_subelement_decodes_as_int_word()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("Pre", "ab://10.0.0.5/1,0", "T4:0.PRE", AbLegacyDataType.TimerElement));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 5000 };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Pre"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().Value.ShouldBe(5000);
|
||||
factory.Tags["T4:0.PRE"].LastDecodeBitIndex.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("C5:0.UN", 10)]
|
||||
[InlineData("C5:0.OV", 11)]
|
||||
[InlineData("C5:0.DN", 12)]
|
||||
[InlineData("C5:0.CD", 13)]
|
||||
[InlineData("C5:0.CU", 14)]
|
||||
public async Task Counter_status_bit_decodes_correct_position(string address, int bitPos)
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, AbLegacyDataType.CounterElement));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << bitPos };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().Value.ShouldBe(true);
|
||||
factory.Tags[address].LastDecodeBitIndex.ShouldBe(bitPos);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("R6:0.FD", 8)]
|
||||
[InlineData("R6:0.IN", 9)]
|
||||
[InlineData("R6:0.UL", 10)]
|
||||
[InlineData("R6:0.ER", 11)]
|
||||
[InlineData("R6:0.EM", 12)]
|
||||
[InlineData("R6:0.DN", 13)]
|
||||
[InlineData("R6:0.EU", 14)]
|
||||
[InlineData("R6:0.EN", 15)]
|
||||
public async Task Control_status_bit_decodes_correct_position(string address, int bitPos)
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, AbLegacyDataType.ControlElement));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << bitPos };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().Value.ShouldBe(true);
|
||||
factory.Tags[address].LastDecodeBitIndex.ShouldBe(bitPos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_bit_returns_false_when_parent_word_bit_is_clear()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("Done", "ab://10.0.0.5/1,0", "T4:0.DN", AbLegacyDataType.TimerElement));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
// Bit 14 (TT) set, bit 13 (DN) clear.
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << 14 };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Done"], CancellationToken.None);
|
||||
snapshots.Single().Value.ShouldBe(false);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("T4:0.DN", AbLegacyDataType.TimerElement)]
|
||||
[InlineData("T4:0.TT", AbLegacyDataType.TimerElement)]
|
||||
[InlineData("C5:0.DN", AbLegacyDataType.CounterElement)]
|
||||
[InlineData("C5:0.OV", AbLegacyDataType.CounterElement)]
|
||||
[InlineData("C5:0.UN", AbLegacyDataType.CounterElement)]
|
||||
[InlineData("R6:0.ER", AbLegacyDataType.ControlElement)]
|
||||
[InlineData("R6:0.EM", AbLegacyDataType.ControlElement)]
|
||||
[InlineData("R6:0.DN", AbLegacyDataType.ControlElement)]
|
||||
[InlineData("R6:0.FD", AbLegacyDataType.ControlElement)]
|
||||
public async Task Writes_to_PLC_set_status_bits_return_BadNotWritable(
|
||||
string address, AbLegacyDataType dataType)
|
||||
{
|
||||
var (drv, _) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, dataType));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("X", true)], CancellationToken.None);
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotWritable);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #249 — verify ST string read/write round-trips through the driver. The wire format
|
||||
/// (1-word length prefix + 82 ASCII bytes) is owned by libplctag's <c>GetString</c>/
|
||||
/// <c>SetString</c>; this test fixture pins the driver-level guarantees:
|
||||
/// <list type="bullet">
|
||||
/// <item>Reads round-trip strings of any length up to the 82-char ST cap.</item>
|
||||
/// <item>Writes longer than 82 chars are rejected with <c>BadOutOfRange</c> at the driver
|
||||
/// level — preventing libplctag from silently truncating.</item>
|
||||
/// <item>Embedded nulls and non-ASCII characters flow through without throwing — the latter
|
||||
/// is libplctag's responsibility to round-trip or degrade.</item>
|
||||
/// <item>Both Slc500 and Plc5 families share the 82-byte ST file convention.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyStringEncodingTests
|
||||
{
|
||||
private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver(
|
||||
AbLegacyPlcFamily family,
|
||||
params AbLegacyTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", family)],
|
||||
Tags = tags,
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
// ---- Read round-trip ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500, "")]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500, "Hello")]
|
||||
[InlineData(AbLegacyPlcFamily.Plc5, "Hello")]
|
||||
public async Task Read_returns_string_value_unchanged(AbLegacyPlcFamily family, string value)
|
||||
{
|
||||
var (drv, factory) = NewDriver(family,
|
||||
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = value };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Msg"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe(value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_returns_full_82_char_string_at_ST_capacity()
|
||||
{
|
||||
var full = new string('A', 82);
|
||||
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
|
||||
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = full };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Msg"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
var actual = snapshots.Single().Value.ShouldBeOfType<string>();
|
||||
actual.Length.ShouldBe(82);
|
||||
actual.ShouldBe(full);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_preserves_embedded_null_byte()
|
||||
{
|
||||
// libplctag returns the C-string as the .NET String with whatever bytes the PLC stored.
|
||||
// We assert the driver doesn't strip or truncate at an embedded NUL.
|
||||
var withNull = "AB\0CD";
|
||||
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
|
||||
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = withNull };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Msg"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe(withNull);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_preserves_extended_latin_payload()
|
||||
{
|
||||
// PLC ST files are byte-oriented; non-ASCII passes through whatever round-trip libplctag
|
||||
// applies. The driver itself must not transform.
|
||||
var latin = "café résumé";
|
||||
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
|
||||
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = latin };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Msg"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe(latin);
|
||||
}
|
||||
|
||||
// ---- Write round-trip ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500, "")]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500, "Short msg")]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500, "AB\0CD")] // embedded NUL
|
||||
[InlineData(AbLegacyPlcFamily.Plc5, "Hello PLC5")]
|
||||
public async Task Write_succeeds_and_forwards_string_to_runtime(AbLegacyPlcFamily family, string value)
|
||||
{
|
||||
var (drv, factory) = NewDriver(family,
|
||||
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Msg", value)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
factory.Tags["ST9:0"].Value.ShouldBe(value);
|
||||
factory.Tags["ST9:0"].WriteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_succeeds_for_41_char_mid_length_string()
|
||||
{
|
||||
var mid = new string('M', 41);
|
||||
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
|
||||
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Msg", mid)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
factory.Tags["ST9:0"].Value.ShouldBe(mid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_succeeds_at_82_char_boundary()
|
||||
{
|
||||
var full = new string('Z', 82);
|
||||
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
|
||||
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Msg", full)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
((string)factory.Tags["ST9:0"].Value!).Length.ShouldBe(82);
|
||||
}
|
||||
|
||||
// ---- Length guard ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500, 83)]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500, 100)]
|
||||
[InlineData(AbLegacyPlcFamily.Plc5, 200)]
|
||||
public async Task Write_over_82_chars_returns_BadOutOfRange(AbLegacyPlcFamily family, int len)
|
||||
{
|
||||
// The runtime layer (LibplctagLegacyTagRuntime.EncodeValue) rejects with
|
||||
// ArgumentOutOfRangeException; the driver maps that to BadOutOfRange so the OPC UA client
|
||||
// gets a clean failure rather than a silent libplctag truncation. We use the production
|
||||
// runtime for the encode step but stub the I/O via a delegating factory so the test does
|
||||
// not need a real PLC.
|
||||
var oversized = new string('X', len);
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
// Reuse the production EncodeValue by routing through a fake that delegates the
|
||||
// length check itself — we model the runtime contract: > 82 chars must throw.
|
||||
Customise = p => new EncodeOnlyLengthCheckingFake(p),
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", family)],
|
||||
Tags =
|
||||
[
|
||||
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Msg", oversized)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
|
||||
// The write must NOT have reached libplctag's WriteAsync — guard fires before flush.
|
||||
factory.Tags["ST9:0"].WriteCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_at_exactly_82_chars_does_not_trip_length_guard()
|
||||
{
|
||||
var atBoundary = new string('B', 82);
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => new EncodeOnlyLengthCheckingFake(p),
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Slc500)],
|
||||
Tags =
|
||||
[
|
||||
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Msg", atBoundary)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
factory.Tags["ST9:0"].WriteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test fake that mirrors <see cref="LibplctagLegacyTagRuntime"/>'s ST length guard so we
|
||||
/// can assert the driver-level mapping (ArgumentOutOfRangeException → BadOutOfRange)
|
||||
/// without instantiating a real libplctag <c>Tag</c> (which would try to open a TCP
|
||||
/// connection in <c>InitializeAsync</c>).
|
||||
/// </summary>
|
||||
private sealed class EncodeOnlyLengthCheckingFake(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public override void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value)
|
||||
{
|
||||
if (type == AbLegacyDataType.String)
|
||||
{
|
||||
var s = Convert.ToString(value) ?? string.Empty;
|
||||
if (s.Length > 82)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(value),
|
||||
$"ST string write exceeds 82-byte file element capacity (was {s.Length}).");
|
||||
}
|
||||
base.EncodeValue(type, bitIndex, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,25 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
|
||||
}
|
||||
|
||||
public virtual int GetStatus() => Status;
|
||||
public virtual object? DecodeValue(AbLegacyDataType type, int? bitIndex) => Value;
|
||||
public int? LastDecodeBitIndex { get; private set; }
|
||||
public AbLegacyDataType? LastDecodeType { get; private set; }
|
||||
public virtual object? DecodeValue(AbLegacyDataType type, int? bitIndex)
|
||||
{
|
||||
LastDecodeType = type;
|
||||
LastDecodeBitIndex = bitIndex;
|
||||
// If the test seeded a parent-word value (ushort/short/int) and the driver asked for a
|
||||
// specific status bit, mask it out so we can assert the correct bit reaches the client.
|
||||
if (bitIndex is int bit && Value is not null and not bool)
|
||||
{
|
||||
try
|
||||
{
|
||||
var word = Convert.ToInt32(Value);
|
||||
return ((word >> bit) & 1) != 0;
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException) { }
|
||||
}
|
||||
return Value;
|
||||
}
|
||||
public virtual void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => Value = value;
|
||||
public virtual void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
@@ -46,8 +46,67 @@ internal class FakeFocasClient : IFocasClient
|
||||
return Task.FromResult(status);
|
||||
}
|
||||
|
||||
public List<(int number, int axis, FocasDataType type)> DiagnosticReads { get; } = new();
|
||||
|
||||
public virtual Task<(object? value, uint status)> ReadDiagnosticAsync(
|
||||
int diagNumber, int axisOrZero, FocasDataType type, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
|
||||
DiagnosticReads.Add((diagNumber, axisOrZero, type));
|
||||
var key = axisOrZero == 0 ? $"DIAG:{diagNumber}" : $"DIAG:{diagNumber}/{axisOrZero}";
|
||||
var status = ReadStatuses.TryGetValue(key, out var s) ? s : FocasStatusMapper.Good;
|
||||
var value = Values.TryGetValue(key, out var v) ? v : null;
|
||||
return Task.FromResult((value, status));
|
||||
}
|
||||
|
||||
public virtual Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult);
|
||||
|
||||
/// <summary>
|
||||
/// Configurable path count surfaced via <see cref="GetPathCountAsync"/> — defaults to
|
||||
/// 1 (single-path controller). Tests asserting multi-path behaviour set this to 2..N
|
||||
/// so the driver's PathId validation + cnc_setpath dispatch can be exercised
|
||||
/// without a live CNC (issue #264).
|
||||
/// </summary>
|
||||
public int PathCount { get; set; } = 1;
|
||||
|
||||
/// <summary>Ordered log of <c>cnc_setpath</c> calls observed on this fake session.</summary>
|
||||
public List<int> SetPathLog { get; } = new();
|
||||
|
||||
public virtual Task<int> GetPathCountAsync(CancellationToken ct) => Task.FromResult(PathCount);
|
||||
|
||||
public virtual Task SetPathAsync(int pathId, CancellationToken ct)
|
||||
{
|
||||
SetPathLog.Add(pathId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-letter / per-path byte storage the coalesced range path reads from. Tests
|
||||
/// populate <c>PmcByteRanges[("R", 1)] = new byte[size]</c> + the corresponding values to
|
||||
/// drive both the per-tag <see cref="ReadAsync"/> + the coalesced
|
||||
/// <see cref="ReadPmcRangeAsync"/> path against the same source of truth (issue #266).
|
||||
/// </summary>
|
||||
public Dictionary<(string Letter, int PathId), byte[]> PmcByteRanges { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Ordered log of <c>pmc_rdpmcrng</c>-shaped range calls observed on this fake
|
||||
/// session — one entry per coalesced wire call. Tests assert this count to verify
|
||||
/// coalescing actually collapsed N per-byte reads into one range read (issue #266).
|
||||
/// </summary>
|
||||
public List<(string Letter, int PathId, int StartByte, int ByteCount)> RangeReadLog { get; } = new();
|
||||
|
||||
public virtual Task<(byte[]? buffer, uint status)> ReadPmcRangeAsync(
|
||||
string letter, int pathId, int startByte, int byteCount, CancellationToken ct)
|
||||
{
|
||||
RangeReadLog.Add((letter, pathId, startByte, byteCount));
|
||||
if (!PmcByteRanges.TryGetValue((letter.ToUpperInvariant(), pathId), out var src))
|
||||
return Task.FromResult<(byte[]?, uint)>((new byte[byteCount], FocasStatusMapper.Good));
|
||||
var buf = new byte[byteCount];
|
||||
var copy = Math.Min(byteCount, Math.Max(0, src.Length - startByte));
|
||||
if (copy > 0) Array.Copy(src, startByte, buf, 0, copy);
|
||||
return Task.FromResult<(byte[]?, uint)>((buf, FocasStatusMapper.Good));
|
||||
}
|
||||
|
||||
public virtual void Dispose()
|
||||
{
|
||||
DisposeCount++;
|
||||
|
||||
@@ -57,8 +57,9 @@ public sealed class FocasCapabilityMatrixTests
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "X", true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "Y", true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "R", true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "F", false)] // 16i has no F/G signal groups
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "G", false)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "F", true)] // #265: F/G handshakes are documented on 16i ladders
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "G", true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "M", false)] // M/C/K/T still 0i-F / 30i-only
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "K", false)]
|
||||
[InlineData(FocasCncSeries.Zero_i_D, "E", true)] // widened since 0i-D
|
||||
[InlineData(FocasCncSeries.Zero_i_D, "F", false)] // still no F on 0i-D
|
||||
|
||||
@@ -31,8 +31,10 @@ public sealed class FocasCapabilityTests
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "FOCAS");
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "focas://10.0.0.5:8193" && f.DisplayName == "Lathe-1");
|
||||
builder.Variables.Single(v => v.BrowseName == "Run").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
|
||||
builder.Variables.Single(v => v.BrowseName == "Alarm").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
// Per-tag and Status/ fields can share a BrowseName ("Run", "Alarm") under different
|
||||
// parent folders — disambiguate by FullName, which is unique per node.
|
||||
builder.Variables.Single(v => v.Info.FullName == "Run").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
|
||||
builder.Variables.Single(v => v.Info.FullName == "Alarm").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
// ---- ISubscribable ----
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for the <c>DIAG:</c> address scheme — parser, capability matrix,
|
||||
/// driver dispatch (issue #263, plan PR F2-a). DIAG: addresses route to
|
||||
/// <c>cnc_rddiag</c> on the wire; the driver validates against
|
||||
/// <see cref="FocasCapabilityMatrix.DiagnosticRange"/> at init time + dispatches
|
||||
/// <see cref="FocasAreaKind.Diagnostic"/> reads through
|
||||
/// <see cref="IFocasClient.ReadDiagnosticAsync"/> at runtime.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasDiagnosticAddressTests
|
||||
{
|
||||
// ---- Parser positive ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("DIAG:1000", 1000, 0)]
|
||||
[InlineData("DIAG:280/2", 280, 2)]
|
||||
[InlineData("DIAG:0", 0, 0)]
|
||||
[InlineData("diag:500", 500, 0)] // case-insensitive prefix
|
||||
[InlineData("DIAG:1023/8", 1023, 8)]
|
||||
public void TryParse_accepts_DIAG_forms(string input, int expectedNumber, int expectedAxis)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Kind.ShouldBe(FocasAreaKind.Diagnostic);
|
||||
parsed.Number.ShouldBe(expectedNumber);
|
||||
(parsed.BitIndex ?? 0).ShouldBe(expectedAxis);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DIAG:abc")]
|
||||
[InlineData("DIAG:")]
|
||||
[InlineData("DIAG:-1")]
|
||||
[InlineData("DIAG:100/-1")]
|
||||
[InlineData("DIAG:100/99")] // axis > 31 (parser ceiling)
|
||||
public void TryParse_rejects_malformed_DIAG(string input)
|
||||
{
|
||||
FocasAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Canonical_round_trip_for_DIAG_whole_CNC()
|
||||
{
|
||||
var parsed = FocasAddress.TryParse("DIAG:1000");
|
||||
parsed!.Canonical.ShouldBe("DIAG:1000");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Canonical_round_trip_for_DIAG_per_axis()
|
||||
{
|
||||
var parsed = FocasAddress.TryParse("DIAG:280/2");
|
||||
parsed!.Canonical.ShouldBe("DIAG:280/2");
|
||||
}
|
||||
|
||||
// ---- Capability matrix ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(FocasCncSeries.Thirty_i, 1023, true)]
|
||||
[InlineData(FocasCncSeries.Thirty_i, 1024, false)]
|
||||
[InlineData(FocasCncSeries.ThirtyOne_i, 500, true)]
|
||||
[InlineData(FocasCncSeries.ThirtyTwo_i, 0, true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, 499, true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, 500, false)] // 16i caps lower
|
||||
[InlineData(FocasCncSeries.Zero_i_F, 999, true)]
|
||||
[InlineData(FocasCncSeries.Zero_i_F, 1000, false)]
|
||||
[InlineData(FocasCncSeries.Zero_i_D, 280, true)]
|
||||
[InlineData(FocasCncSeries.Zero_i_D, 600, false)]
|
||||
[InlineData(FocasCncSeries.PowerMotion_i, 255, true)]
|
||||
[InlineData(FocasCncSeries.PowerMotion_i, 256, false)]
|
||||
public void Diagnostic_range_matches_series(FocasCncSeries series, int number, bool accepted)
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Diagnostic, null, number, null);
|
||||
var result = FocasCapabilityMatrix.Validate(series, address);
|
||||
(result is null).ShouldBe(accepted,
|
||||
$"DIAG:{number} on {series}: expected {(accepted ? "accept" : "reject")}, got '{result}'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_series_accepts_any_diagnostic_number()
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Diagnostic, null, 99_999, null);
|
||||
FocasCapabilityMatrix.Validate(FocasCncSeries.Unknown, address).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diagnostic_rejection_message_names_series_and_limit()
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Diagnostic, null, 5_000, null);
|
||||
var reason = FocasCapabilityMatrix.Validate(FocasCncSeries.Sixteen_i, address);
|
||||
reason.ShouldNotBeNull();
|
||||
reason.ShouldContain("5000");
|
||||
reason.ShouldContain("Sixteen_i");
|
||||
reason.ShouldContain("499");
|
||||
}
|
||||
|
||||
// ---- Driver dispatch ----
|
||||
|
||||
private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver(
|
||||
FocasCncSeries series,
|
||||
params FocasTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", Series: series)],
|
||||
Tags = tags,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DIAG_read_routes_through_ReadDiagnosticAsync_with_axis_zero()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
FocasCncSeries.Thirty_i,
|
||||
new FocasTagDefinition("AlarmCause", "focas://10.0.0.5:8193", "DIAG:1000", FocasDataType.Int32));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () =>
|
||||
{
|
||||
var c = new FakeFocasClient();
|
||||
c.Values["DIAG:1000"] = 42;
|
||||
return c;
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["AlarmCause"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe(42);
|
||||
var fake = factory.Clients.Single();
|
||||
fake.DiagnosticReads.Single().ShouldBe((1000, 0, FocasDataType.Int32));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DIAG_per_axis_read_threads_axis_index_through()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
FocasCncSeries.Thirty_i,
|
||||
new FocasTagDefinition("ServoLoad2", "focas://10.0.0.5:8193", "DIAG:280/2", FocasDataType.Int16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () =>
|
||||
{
|
||||
var c = new FakeFocasClient();
|
||||
c.Values["DIAG:280/2"] = (short)17;
|
||||
return c;
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["ServoLoad2"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe((short)17);
|
||||
factory.Clients.Single().DiagnosticReads.Single().ShouldBe((280, 2, FocasDataType.Int16));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DIAG_out_of_range_for_series_rejected_at_init()
|
||||
{
|
||||
var (drv, _) = NewDriver(
|
||||
FocasCncSeries.Sixteen_i,
|
||||
new FocasTagDefinition("Bad", "focas://10.0.0.5:8193", "DIAG:5000", FocasDataType.Int32));
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => drv.InitializeAsync("{}", CancellationToken.None));
|
||||
ex.Message.ShouldContain("5000");
|
||||
ex.Message.ShouldContain("Sixteen_i");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DIAG_default_interface_method_surfaces_BadNotSupported()
|
||||
{
|
||||
// Stand-in client that does NOT override ReadDiagnosticAsync — falls through to
|
||||
// the IFocasClient default returning BadNotSupported. Models a transport variant
|
||||
// (e.g. older IPC contract) that hasn't extended its wire surface to diagnostics.
|
||||
var factory = new BareFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", Series: FocasCncSeries.Unknown)],
|
||||
Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "DIAG:100", FocasDataType.Int32)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotSupported);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test stand-in that overrides every interface method we need EXCEPT
|
||||
/// <see cref="IFocasClient.ReadDiagnosticAsync"/> — exercising the default
|
||||
/// implementation that returns <c>BadNotSupported</c> for transports that
|
||||
/// haven't extended their wire surface yet.
|
||||
/// </summary>
|
||||
private sealed class FakeWithoutDiagnosticOverride : IFocasClient
|
||||
{
|
||||
public bool IsConnected { get; private set; }
|
||||
public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
|
||||
{ IsConnected = true; return Task.CompletedTask; }
|
||||
public Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken ct) =>
|
||||
Task.FromResult<(object?, uint)>((null, FocasStatusMapper.Good));
|
||||
public Task<uint> WriteAsync(FocasAddress address, FocasDataType type, object? value, CancellationToken ct) =>
|
||||
Task.FromResult(FocasStatusMapper.Good);
|
||||
public Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(true);
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private sealed class BareFocasClientFactory : IFocasClientFactory
|
||||
{
|
||||
public IFocasClient Create() => new FakeWithoutDiagnosticOverride();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasFigureScalingDiagnosticsTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.7:8193";
|
||||
|
||||
/// <summary>
|
||||
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
|
||||
/// per-axis figure scaling for the F1-f cache + diagnostics surface
|
||||
/// (issue #262).
|
||||
/// </summary>
|
||||
private sealed class FigureAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
||||
{
|
||||
public IReadOnlyDictionary<string, int>? Scaling { get; set; }
|
||||
|
||||
Task<IReadOnlyDictionary<string, int>?> IFocasClient.GetFigureScalingAsync(CancellationToken ct) =>
|
||||
Task.FromResult(Scaling);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_Diagnostics_subtree_with_five_counters()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-diag", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Diagnostics" && f.DisplayName == "Diagnostics");
|
||||
var diagVars = builder.Variables.Where(v =>
|
||||
v.Info.FullName.Contains("::Diagnostics/")).ToList();
|
||||
diagVars.Count.ShouldBe(5);
|
||||
|
||||
// Verify per-field types match the documented surface (Int64 counters,
|
||||
// String error message, DateTime last-success timestamp).
|
||||
diagVars.Single(v => v.BrowseName == "ReadCount")
|
||||
.Info.DriverDataType.ShouldBe(DriverDataType.Int64);
|
||||
diagVars.Single(v => v.BrowseName == "ReadFailureCount")
|
||||
.Info.DriverDataType.ShouldBe(DriverDataType.Int64);
|
||||
diagVars.Single(v => v.BrowseName == "ReconnectCount")
|
||||
.Info.DriverDataType.ShouldBe(DriverDataType.Int64);
|
||||
diagVars.Single(v => v.BrowseName == "LastErrorMessage")
|
||||
.Info.DriverDataType.ShouldBe(DriverDataType.String);
|
||||
diagVars.Single(v => v.BrowseName == "LastSuccessfulRead")
|
||||
.Info.DriverDataType.ShouldBe(DriverDataType.DateTime);
|
||||
|
||||
foreach (var v in diagVars)
|
||||
v.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_publishes_diagnostics_counters_after_probe_ticks()
|
||||
{
|
||||
// Probe enabled — successful ticks bump ReadCount + LastSuccessfulRead;
|
||||
// ReconnectCount bumps once on the initial connect (issue #262).
|
||||
var fake = new FakeFocasClient { ProbeResult = true };
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) },
|
||||
}, "drv-diag-read", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Wait for at least 2 successful probe ticks so ReadCount > 0 deterministically.
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Diagnostics/ReadCount"], CancellationToken.None)).Single();
|
||||
return snap.Value is long n && n >= 2;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var refs = new[]
|
||||
{
|
||||
$"{Host}::Diagnostics/ReadCount",
|
||||
$"{Host}::Diagnostics/ReadFailureCount",
|
||||
$"{Host}::Diagnostics/ReconnectCount",
|
||||
$"{Host}::Diagnostics/LastErrorMessage",
|
||||
$"{Host}::Diagnostics/LastSuccessfulRead",
|
||||
};
|
||||
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
((long)snaps[0].Value!).ShouldBeGreaterThanOrEqualTo(2);
|
||||
((long)snaps[1].Value!).ShouldBe(0); // no failures on a healthy probe
|
||||
((long)snaps[2].Value!).ShouldBe(1); // one initial connect
|
||||
snaps[3].Value.ShouldBe(string.Empty);
|
||||
((DateTime)snaps[4].Value!).ShouldBeGreaterThan(DateTime.MinValue);
|
||||
|
||||
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_increments_ReadFailureCount_when_probe_returns_false()
|
||||
{
|
||||
// ProbeResult=false → success branch is skipped, ReadFailureCount bumps each
|
||||
// tick. The connect itself succeeded so ReconnectCount is 1.
|
||||
var fake = new FakeFocasClient { ProbeResult = false };
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) },
|
||||
}, "drv-diag-fail", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Diagnostics/ReadFailureCount"], CancellationToken.None)).Single();
|
||||
return snap.Value is long n && n >= 2;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var snaps = await drv.ReadAsync(
|
||||
[$"{Host}::Diagnostics/ReadCount", $"{Host}::Diagnostics/ReadFailureCount"],
|
||||
CancellationToken.None);
|
||||
((long)snaps[0].Value!).ShouldBe(0);
|
||||
((long)snaps[1].Value!).ShouldBeGreaterThanOrEqualTo(2);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyFigureScaling_divides_raw_position_by_ten_to_the_decimal_places()
|
||||
{
|
||||
// Cache populated via probe-tick GetFigureScalingAsync. ApplyFigureScaling
|
||||
// default is true → rawValue / 10^dec for the named axis (issue #262).
|
||||
var fake = new FigureAwareFakeFocasClient
|
||||
{
|
||||
Scaling = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["axis1"] = 3, // X-axis: 3 decimal places (mm * 1000)
|
||||
["axis2"] = 4, // Y-axis: 4 decimal places
|
||||
},
|
||||
};
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) },
|
||||
}, "drv-fig", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Wait for the probe-tick path to populate the cache (one successful tick is
|
||||
// enough — the figure-scaling read happens whenever the cache is null).
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Diagnostics/ReadCount"], CancellationToken.None)).Single();
|
||||
return snap.Value is long n && n >= 1;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
// 100000 / 10^3 = 100.0 mm
|
||||
drv.ApplyFigureScaling(Host, "axis1", 100000).ShouldBe(100.0);
|
||||
// 250000 / 10^4 = 25.0 mm
|
||||
drv.ApplyFigureScaling(Host, "axis2", 250000).ShouldBe(25.0);
|
||||
// Unknown axis → raw value passes through.
|
||||
drv.ApplyFigureScaling(Host, "axis3", 42).ShouldBe(42.0);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyFigureScaling_returns_raw_when_FixedTreeApplyFigureScaling_is_false()
|
||||
{
|
||||
// ApplyFigureScaling=false short-circuits before the cache lookup so the raw
|
||||
// integer is published unchanged. Migration parity for deployments that already
|
||||
// surfaced raw values from older drivers (issue #262).
|
||||
var fake = new FigureAwareFakeFocasClient
|
||||
{
|
||||
Scaling = new Dictionary<string, int> { ["axis1"] = 3 },
|
||||
};
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) },
|
||||
FixedTree = new FocasFixedTreeOptions { ApplyFigureScaling = false },
|
||||
}, "drv-fig-off", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Diagnostics/ReadCount"], CancellationToken.None)).Single();
|
||||
return snap.Value is long n && n >= 1;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
// Even though the cache has axis1 → 3 decimal places, ApplyFigureScaling=false
|
||||
// means the raw value passes through unchanged.
|
||||
drv.ApplyFigureScaling(Host, "axis1", 100000).ShouldBe(100000.0);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FwlibFocasClient_GetFigureScaling_returns_null_when_disconnected()
|
||||
{
|
||||
// Construction is licence-safe (no DLL load); the unconnected client must
|
||||
// short-circuit before P/Invoke so the driver leaves the cache untouched.
|
||||
var client = new FwlibFocasClient();
|
||||
(await client.GetFigureScalingAsync(CancellationToken.None)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeFigureScaling_extracts_per_axis_decimal_places_from_buffer()
|
||||
{
|
||||
// Build an IODBAXIS-shaped buffer: 3 axes, decimal places = 3, 4, 0. Per
|
||||
// fwlib32.h each axis entry is { short dec, short unit, short reserved,
|
||||
// short reserved2 } = 8 bytes; we only read dec.
|
||||
var buf = new byte[FwlibNative.MAX_AXIS * 8];
|
||||
// Axis 1: dec=3
|
||||
buf[0] = 3; buf[1] = 0;
|
||||
// Axis 2: dec=4
|
||||
buf[8] = 4; buf[9] = 0;
|
||||
// Axis 3: dec=0 (already zero)
|
||||
|
||||
var map = FwlibFocasClient.DecodeFigureScaling(buf, count: 3);
|
||||
map.Count.ShouldBe(3);
|
||||
map["axis1"].ShouldBe(3);
|
||||
map["axis2"].ShouldBe(4);
|
||||
map["axis3"].ShouldBe(0);
|
||||
|
||||
// Out-of-range count clamps to MAX_AXIS so a malformed CNC reply doesn't
|
||||
// overrun the buffer.
|
||||
var clamped = FwlibFocasClient.DecodeFigureScaling(buf, count: 99);
|
||||
clamped.Count.ShouldBe(FwlibNative.MAX_AXIS);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!await condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
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,231 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasMessagesBlockTextFixedTreeTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.7:8193";
|
||||
|
||||
/// <summary>
|
||||
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
|
||||
/// <see cref="FocasOperatorMessagesInfo"/> + <see cref="FocasCurrentBlockInfo"/>
|
||||
/// snapshots for the F1-e Messages/External/Latest + Program/CurrentBlock
|
||||
/// fixed-tree (issue #261).
|
||||
/// </summary>
|
||||
private sealed class MessagesAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
||||
{
|
||||
public FocasOperatorMessagesInfo? Messages { get; set; }
|
||||
public FocasCurrentBlockInfo? CurrentBlock { get; set; }
|
||||
|
||||
Task<FocasOperatorMessagesInfo?> IFocasClient.GetOperatorMessagesAsync(CancellationToken ct) =>
|
||||
Task.FromResult(Messages);
|
||||
|
||||
Task<FocasCurrentBlockInfo?> IFocasClient.GetCurrentBlockAsync(CancellationToken ct) =>
|
||||
Task.FromResult(CurrentBlock);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_Messages_External_Latest_and_Program_CurrentBlock_nodes()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-msg", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Messages" && f.DisplayName == "Messages");
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "External" && f.DisplayName == "External");
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Program" && f.DisplayName == "Program");
|
||||
|
||||
var latest = builder.Variables.SingleOrDefault(v =>
|
||||
v.Info.FullName == $"{Host}::Messages/External/Latest");
|
||||
latest.BrowseName.ShouldBe("Latest");
|
||||
latest.Info.DriverDataType.ShouldBe(DriverDataType.String);
|
||||
latest.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
|
||||
var block = builder.Variables.SingleOrDefault(v =>
|
||||
v.Info.FullName == $"{Host}::Program/CurrentBlock");
|
||||
block.BrowseName.ShouldBe("CurrentBlock");
|
||||
block.Info.DriverDataType.ShouldBe(DriverDataType.String);
|
||||
block.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_serves_Messages_Latest_and_CurrentBlock_from_cached_snapshot()
|
||||
{
|
||||
var fake = new MessagesAwareFakeFocasClient
|
||||
{
|
||||
Messages = new FocasOperatorMessagesInfo(
|
||||
[
|
||||
new FocasOperatorMessage(2001, "OPMSG", "TOOL CHANGE READY"),
|
||||
new FocasOperatorMessage(3010, "EXTERN", "DOOR OPEN"),
|
||||
]),
|
||||
CurrentBlock = new FocasCurrentBlockInfo("G01 X100. Y200. F500."),
|
||||
};
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
||||
}, "drv-msg-read", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Program/CurrentBlock"], CancellationToken.None)).Single();
|
||||
return snap.StatusCode == FocasStatusMapper.Good;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var refs = new[]
|
||||
{
|
||||
$"{Host}::Messages/External/Latest",
|
||||
$"{Host}::Program/CurrentBlock",
|
||||
};
|
||||
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
// "Latest" surfaces the last entry in the message snapshot — issue #261 permits
|
||||
// this minimal "latest message" surface in lieu of full ring-buffer coverage.
|
||||
snaps[0].Value.ShouldBe("DOOR OPEN");
|
||||
snaps[1].Value.ShouldBe("G01 X100. Y200. F500.");
|
||||
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_returns_BadCommunicationError_when_caches_are_empty()
|
||||
{
|
||||
// Probe disabled — neither cache populates; the nodes still resolve as known
|
||||
// references but report Bad until the first poll. Mirrors the f1a/f1b/f1c/f1d
|
||||
// policy.
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-msg-empty", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snaps = await drv.ReadAsync(
|
||||
[$"{Host}::Messages/External/Latest", $"{Host}::Program/CurrentBlock"],
|
||||
CancellationToken.None);
|
||||
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_publishes_empty_string_when_message_snapshot_is_empty()
|
||||
{
|
||||
// Empty snapshot (CNC reported no active messages) still publishes Good +
|
||||
// empty string — operators distinguish "no messages" from "Bad" without
|
||||
// having to read separate availability nodes.
|
||||
var fake = new MessagesAwareFakeFocasClient
|
||||
{
|
||||
Messages = new FocasOperatorMessagesInfo([]),
|
||||
CurrentBlock = new FocasCurrentBlockInfo(""),
|
||||
};
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
||||
}, "drv-msg-empty-snap", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Messages/External/Latest"], CancellationToken.None)).Single();
|
||||
return snap.StatusCode == FocasStatusMapper.Good;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var snaps = await drv.ReadAsync(
|
||||
[$"{Host}::Messages/External/Latest", $"{Host}::Program/CurrentBlock"],
|
||||
CancellationToken.None);
|
||||
snaps[0].Value.ShouldBe(string.Empty);
|
||||
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snaps[1].Value.ShouldBe(string.Empty);
|
||||
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FwlibFocasClient_GetOperatorMessages_and_GetCurrentBlock_return_null_when_disconnected()
|
||||
{
|
||||
// Construction is licence-safe (no DLL load); the unconnected client must
|
||||
// short-circuit before P/Invoke. Returns null → driver leaves the cache
|
||||
// untouched, matching the policy in f1a/f1b/f1c/f1d.
|
||||
var client = new FwlibFocasClient();
|
||||
(await client.GetOperatorMessagesAsync(CancellationToken.None)).ShouldBeNull();
|
||||
(await client.GetCurrentBlockAsync(CancellationToken.None)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrimAnsiPadding_strips_trailing_nulls_and_spaces_for_round_trip()
|
||||
{
|
||||
// The CNC right-pads block text + opmsg bodies with NULs or spaces; the
|
||||
// managed side trims them so the same message round-trips with stable text
|
||||
// (issue #261). Stops at the first NUL so reused buffers don't leak old bytes.
|
||||
var buf = new byte[16];
|
||||
var bytes = System.Text.Encoding.ASCII.GetBytes("G01 X10 ");
|
||||
Array.Copy(bytes, buf, bytes.Length);
|
||||
FwlibFocasClient.TrimAnsiPadding(buf).ShouldBe("G01 X10");
|
||||
|
||||
// NUL-terminated mid-buffer with trailing spaces beyond the NUL — trim stops
|
||||
// at the NUL so leftover bytes in the rest of the buffer are ignored.
|
||||
var buf2 = new byte[32];
|
||||
var bytes2 = System.Text.Encoding.ASCII.GetBytes("OPMSG TEXT");
|
||||
Array.Copy(bytes2, buf2, bytes2.Length);
|
||||
// After NUL the buffer has zeros — already invisible — but explicit space
|
||||
// padding before the NUL should be trimmed.
|
||||
var buf3 = new byte[32];
|
||||
var bytes3 = System.Text.Encoding.ASCII.GetBytes("HELLO ");
|
||||
Array.Copy(bytes3, buf3, bytes3.Length);
|
||||
FwlibFocasClient.TrimAnsiPadding(buf2).ShouldBe("OPMSG TEXT");
|
||||
FwlibFocasClient.TrimAnsiPadding(buf3).ShouldBe("HELLO");
|
||||
|
||||
// Empty buffer → empty string (no exception).
|
||||
FwlibFocasClient.TrimAnsiPadding(new byte[8]).ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!await condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
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,231 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasModalOverrideFixedTreeTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.6:8193";
|
||||
|
||||
/// <summary>
|
||||
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
|
||||
/// <see cref="FocasModalInfo"/> + <see cref="FocasOverrideInfo"/> snapshots.
|
||||
/// </summary>
|
||||
private sealed class ModalAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
||||
{
|
||||
public FocasModalInfo? Modal { get; set; }
|
||||
public FocasOverrideInfo? Override { get; set; }
|
||||
public FocasOverrideParameters? LastOverrideParams { get; private set; }
|
||||
|
||||
Task<FocasModalInfo?> IFocasClient.GetModalAsync(CancellationToken ct) =>
|
||||
Task.FromResult(Modal);
|
||||
|
||||
Task<FocasOverrideInfo?> IFocasClient.GetOverrideAsync(
|
||||
FocasOverrideParameters parameters, CancellationToken ct)
|
||||
{
|
||||
LastOverrideParams = parameters;
|
||||
return Task.FromResult(Override);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_Modal_folder_with_4_Int16_codes_per_device()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "Lathe-2")],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-modal", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Modal" && f.DisplayName == "Modal");
|
||||
var modalVars = builder.Variables.Where(v =>
|
||||
v.Info.FullName.Contains("::Modal/")).ToList();
|
||||
modalVars.Count.ShouldBe(4);
|
||||
string[] expected = ["MCode", "SCode", "TCode", "BCode"];
|
||||
foreach (var name in expected)
|
||||
{
|
||||
var node = modalVars.SingleOrDefault(v => v.BrowseName == name);
|
||||
node.BrowseName.ShouldBe(name);
|
||||
node.Info.DriverDataType.ShouldBe(DriverDataType.Int16);
|
||||
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
node.Info.FullName.ShouldBe($"{Host}::Modal/{name}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_omits_Override_folder_when_no_parameters_configured()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)], // OverrideParameters defaults to null
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-no-overrides", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldNotContain(f => f.BrowseName == "Override");
|
||||
builder.Variables.ShouldNotContain(v => v.Info.FullName.Contains("::Override/"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_only_configured_Override_fields()
|
||||
{
|
||||
// Spindle + Jog suppressed (null parameters) — only Feed + Rapid show up.
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions(Host,
|
||||
OverrideParameters: new FocasOverrideParameters(6010, 6011, null, null)),
|
||||
],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-partial-overrides", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Override");
|
||||
var overrideVars = builder.Variables.Where(v =>
|
||||
v.Info.FullName.Contains("::Override/")).ToList();
|
||||
overrideVars.Count.ShouldBe(2);
|
||||
overrideVars.ShouldContain(v => v.BrowseName == "Feed");
|
||||
overrideVars.ShouldContain(v => v.BrowseName == "Rapid");
|
||||
overrideVars.ShouldNotContain(v => v.BrowseName == "Spindle");
|
||||
overrideVars.ShouldNotContain(v => v.BrowseName == "Jog");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_serves_Modal_and_Override_fields_from_cached_snapshot()
|
||||
{
|
||||
var fake = new ModalAwareFakeFocasClient
|
||||
{
|
||||
Modal = new FocasModalInfo(MCode: 8, SCode: 1200, TCode: 101, BCode: 0),
|
||||
Override = new FocasOverrideInfo(Feed: 100, Rapid: 50, Spindle: 110, Jog: 25),
|
||||
};
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions(Host,
|
||||
OverrideParameters: FocasOverrideParameters.Default),
|
||||
],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
||||
}, "drv-modal-read", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Wait for at least one probe tick to populate both caches.
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Modal/MCode"], CancellationToken.None)).Single();
|
||||
return snap.StatusCode == FocasStatusMapper.Good;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var refs = new[]
|
||||
{
|
||||
$"{Host}::Modal/MCode",
|
||||
$"{Host}::Modal/SCode",
|
||||
$"{Host}::Modal/TCode",
|
||||
$"{Host}::Modal/BCode",
|
||||
$"{Host}::Override/Feed",
|
||||
$"{Host}::Override/Rapid",
|
||||
$"{Host}::Override/Spindle",
|
||||
$"{Host}::Override/Jog",
|
||||
};
|
||||
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
snaps[0].Value.ShouldBe((short)8);
|
||||
snaps[1].Value.ShouldBe((short)1200);
|
||||
snaps[2].Value.ShouldBe((short)101);
|
||||
snaps[3].Value.ShouldBe((short)0);
|
||||
snaps[4].Value.ShouldBe((short)100);
|
||||
snaps[5].Value.ShouldBe((short)50);
|
||||
snaps[6].Value.ShouldBe((short)110);
|
||||
snaps[7].Value.ShouldBe((short)25);
|
||||
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
// The driver hands the device's configured override parameters to the wire client
|
||||
// verbatim — defaulting to 30i numbers.
|
||||
fake.LastOverrideParams.ShouldNotBeNull();
|
||||
fake.LastOverrideParams!.FeedParam.ShouldBe<ushort?>(6010);
|
||||
fake.LastOverrideParams.RapidParam.ShouldBe<ushort?>(6011);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_returns_BadCommunicationError_when_caches_are_empty()
|
||||
{
|
||||
// Probe disabled — neither modal nor override caches populate; the nodes still
|
||||
// resolve as known references but report Bad until the first successful poll.
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions(Host,
|
||||
OverrideParameters: FocasOverrideParameters.Default),
|
||||
],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-empty-cache", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snaps = await drv.ReadAsync(
|
||||
[$"{Host}::Modal/MCode", $"{Host}::Override/Feed"], CancellationToken.None);
|
||||
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FwlibFocasClient_GetModal_and_GetOverride_return_null_when_disconnected()
|
||||
{
|
||||
// Construction is licence-safe (no DLL load); the unconnected client must short-
|
||||
// circuit before P/Invoke. Returns null → driver leaves the cache untouched.
|
||||
var client = new FwlibFocasClient();
|
||||
(await client.GetModalAsync(CancellationToken.None)).ShouldBeNull();
|
||||
(await client.GetOverrideAsync(
|
||||
FocasOverrideParameters.Default, CancellationToken.None)).ShouldBeNull();
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!await condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
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,267 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for multi-path / multi-channel CNC support — parser, driver bootstrap,
|
||||
/// <c>cnc_setpath</c> dispatch (issue #264, plan PR F2-b). The <c>@N</c> suffix
|
||||
/// selects which path a given address is read from; default <c>PathId=1</c>
|
||||
/// preserves single-path back-compat.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasMultiPathTests
|
||||
{
|
||||
// ---- Parser positive ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("R100", "R", 100, null, 1)]
|
||||
[InlineData("R100@2", "R", 100, null, 2)]
|
||||
[InlineData("R100@3.0", "R", 100, 0, 3)]
|
||||
[InlineData("X0.7", "X", 0, 7, 1)]
|
||||
[InlineData("X0@2.7", "X", 0, 7, 2)]
|
||||
public void TryParse_PMC_supports_optional_path_suffix(
|
||||
string input, string letter, int number, int? bit, int expectedPath)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Kind.ShouldBe(FocasAreaKind.Pmc);
|
||||
parsed.PmcLetter.ShouldBe(letter);
|
||||
parsed.Number.ShouldBe(number);
|
||||
parsed.BitIndex.ShouldBe(bit);
|
||||
parsed.PathId.ShouldBe(expectedPath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PARAM:1815", 1815, null, 1)]
|
||||
[InlineData("PARAM:1815@2", 1815, null, 2)]
|
||||
[InlineData("PARAM:1815@2/0", 1815, 0, 2)]
|
||||
[InlineData("PARAM:1815/0", 1815, 0, 1)]
|
||||
public void TryParse_PARAM_supports_optional_path_suffix(
|
||||
string input, int number, int? bit, int expectedPath)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Kind.ShouldBe(FocasAreaKind.Parameter);
|
||||
parsed.Number.ShouldBe(number);
|
||||
parsed.BitIndex.ShouldBe(bit);
|
||||
parsed.PathId.ShouldBe(expectedPath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("MACRO:500", 500, 1)]
|
||||
[InlineData("MACRO:500@2", 500, 2)]
|
||||
[InlineData("MACRO:500@10", 500, 10)]
|
||||
public void TryParse_MACRO_supports_optional_path_suffix(string input, int number, int expectedPath)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Kind.ShouldBe(FocasAreaKind.Macro);
|
||||
parsed.Number.ShouldBe(number);
|
||||
parsed.PathId.ShouldBe(expectedPath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DIAG:280", 280, 0, 1)]
|
||||
[InlineData("DIAG:280@2", 280, 0, 2)]
|
||||
[InlineData("DIAG:280@2/1", 280, 1, 2)]
|
||||
[InlineData("DIAG:280/1", 280, 1, 1)]
|
||||
public void TryParse_DIAG_supports_optional_path_suffix(
|
||||
string input, int number, int axis, int expectedPath)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Kind.ShouldBe(FocasAreaKind.Diagnostic);
|
||||
parsed.Number.ShouldBe(number);
|
||||
(parsed.BitIndex ?? 0).ShouldBe(axis);
|
||||
parsed.PathId.ShouldBe(expectedPath);
|
||||
}
|
||||
|
||||
// ---- Parser negative ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("R100@0")] // path 0 — reserved (FOCAS path numbering is 1-based)
|
||||
[InlineData("R100@-1")] // negative path
|
||||
[InlineData("R100@11")] // above FWLIB ceiling
|
||||
[InlineData("R100@abc")] // non-numeric
|
||||
[InlineData("R100@")] // empty
|
||||
[InlineData("PARAM:1815@0")]
|
||||
[InlineData("PARAM:1815@99")]
|
||||
[InlineData("MACRO:500@0")]
|
||||
[InlineData("DIAG:280@0/1")]
|
||||
public void TryParse_rejects_invalid_path_suffix(string input)
|
||||
{
|
||||
FocasAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- Canonical round-trip ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("R100")]
|
||||
[InlineData("R100@2")]
|
||||
[InlineData("R100@3.0")]
|
||||
[InlineData("PARAM:1815")]
|
||||
[InlineData("PARAM:1815@2")]
|
||||
[InlineData("PARAM:1815@2/0")]
|
||||
[InlineData("MACRO:500")]
|
||||
[InlineData("MACRO:500@2")]
|
||||
[InlineData("DIAG:280")]
|
||||
[InlineData("DIAG:280@2")]
|
||||
[InlineData("DIAG:280@2/1")]
|
||||
public void Canonical_round_trips_through_parser(string input)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Canonical.ShouldBe(input);
|
||||
}
|
||||
|
||||
// ---- Driver dispatch ----
|
||||
|
||||
private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver(
|
||||
params FocasTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = tags,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Default_PathId_1_does_not_trigger_SetPath()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
PathCount = 2,
|
||||
Values = { ["R100"] = (sbyte)1 },
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
factory.Clients.Single().SetPathLog.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_default_PathId_calls_SetPath_before_read()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100@2", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
PathCount = 2,
|
||||
Values = { ["R100@2"] = (sbyte)7 },
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe((sbyte)7);
|
||||
factory.Clients.Single().SetPathLog.ShouldBe(new[] { 2 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeat_read_on_same_path_only_calls_SetPath_once()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100@2", FocasDataType.Byte),
|
||||
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101@2", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
PathCount = 2,
|
||||
Values = { ["R100@2"] = (sbyte)1, ["R101@2"] = (sbyte)2 },
|
||||
};
|
||||
|
||||
await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
||||
// Two reads on the same non-default path — SetPath should only fire on the first.
|
||||
factory.Clients.Single().SetPathLog.ShouldBe(new[] { 2 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Switching_paths_in_one_read_batch_logs_each_change()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100@2", FocasDataType.Byte),
|
||||
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R200@3", FocasDataType.Byte),
|
||||
new FocasTagDefinition("C", "focas://10.0.0.5:8193", "R300@2", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
PathCount = 3,
|
||||
Values =
|
||||
{
|
||||
["R100@2"] = (sbyte)1,
|
||||
["R200@3"] = (sbyte)2,
|
||||
["R300@2"] = (sbyte)3,
|
||||
},
|
||||
};
|
||||
|
||||
await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
||||
// Path 2 → 3 → 2 — each transition fires SetPath.
|
||||
factory.Clients.Single().SetPathLog.ShouldBe(new[] { 2, 3, 2 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PathId_above_PathCount_returns_BadOutOfRange()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100@5", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { PathCount = 2 };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadOutOfRange);
|
||||
// Out-of-range tag must not pollute the wire with a setpath call.
|
||||
factory.Clients.Single().SetPathLog.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Diagnostic_path_threads_through_SetPath_then_ReadDiagnosticAsync()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("ServoLoad", "focas://10.0.0.5:8193", "DIAG:280@2/1", FocasDataType.Int16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
PathCount = 2,
|
||||
Values = { ["DIAG:280/1"] = (short)42 },
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["ServoLoad"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe((short)42);
|
||||
var fake = factory.Clients.Single();
|
||||
fake.SetPathLog.ShouldBe(new[] { 2 });
|
||||
fake.DiagnosticReads.Single().ShouldBe((280, 1, FocasDataType.Int16));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Single_path_controller_with_default_addresses_never_calls_SetPath()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
|
||||
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "PARAM:1815", FocasDataType.Int32),
|
||||
new FocasTagDefinition("C", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
PathCount = 1,
|
||||
Values =
|
||||
{
|
||||
["R100"] = (sbyte)1,
|
||||
["PARAM:1815"] = 100,
|
||||
["MACRO:500"] = 1.5,
|
||||
},
|
||||
};
|
||||
|
||||
await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
||||
factory.Clients.Single().SetPathLog.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user