Compare commits
172 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cbc566db9 | |||
| c36903d6a0 | |||
| 2ee61c0999 | |||
| e3d7c65f61 | |||
| 45770e8d90 | |||
| 399257377b | |||
| 08a4db2952 | |||
| 1e3053c0d8 | |||
| 8ee65a75d2 | |||
| 9e157fc8a4 | |||
| 258ce8e937 | |||
| 561b0f9ea9 | |||
| 349aa5c6f4 | |||
| 0444cb699d | |||
| da6e19d07d | |||
| baf1d65875 | |||
| c9e28b881e | |||
| 5f8d84db43 | |||
| 7e62a1158f | |||
| a908dff7b5 | |||
| ac3fd45cc6 | |||
| 5c72deb839 | |||
| 9a3bc08e1c | |||
| 86f3fc2733 | |||
| d676b4056d | |||
| 54c09d4d5d | |||
| 0c967af645 | |||
| f48f31cfc7 | |||
| 71af554497 | |||
| 1bfe8fba0e | |||
| 6f1657b1c0 | |||
| 4e8df38bb2 | |||
| 4fdeef7a6c | |||
| 42472b5549 | |||
| 14876ea210 | |||
| c292dcc1db | |||
| 4ff1537d8a | |||
| e0e5e04e48 | |||
| e46e4de31f | |||
| 901a5b9b21 | |||
| 9c108cd00a | |||
| da9936f7f0 | |||
| 9202ebe5ef | |||
| b45713622f | |||
| e5c38a5a0e | |||
| 24a3cda56a | |||
| 30e39a752a | |||
| fb57717f6f | |||
| 621de94126 | |||
| 64a11ef285 | |||
| 4bc8aa2478 | |||
| 06b39a28fa | |||
| 8909302929 | |||
| 162c82b8d9 | |||
| ca3d4bf581 | |||
| 3b98e4d366 | |||
| bcf83bf39b | |||
| 6540bbe1ef | |||
| f469cf7e0d | |||
| ab3ed6b6a3 | |||
| eed5857aa9 | |||
| 7f9d6a778e | |||
| 1922b93bd5 | |||
| eb5286148e | |||
| 69069aa3be | |||
| c689ac58b1 | |||
| 05528bf71c | |||
| 01f4ee6b53 | |||
| 8a8dc1ee5a | |||
| 0c6a0d6e50 | |||
| 73ff10b595 | |||
| f6c26db609 | |||
| 7cbddd4b4a | |||
| 4098d72bbb | |||
| 569001364f | |||
| b67eb6c8d0 | |||
| 4a071b6d5a | |||
| 931049b5a7 | |||
| fa2fbb404d | |||
| 17faf76ea7 | |||
| 5432c49364 | |||
| d7633fe36f | |||
| 69d9a6fbb5 | |||
| 07abee5f6d | |||
| 0f3abed4c7 | |||
| cc21281cbb | |||
| 5e164dc965 | |||
| 02d1c85190 | |||
| 1d3e9a3237 | |||
| 0f509fbd3a | |||
| e879b3ae90 | |||
| 4d3ee47235 | |||
| 9ebe5bd523 | |||
| 63099115bf | |||
| 7042b11f34 | |||
| 2f3eeecd17 | |||
| 3b82f4f5fb | |||
| 451b37a632 | |||
| 6743d51db8 | |||
| 0044603902 | |||
| 2fc71d288e | |||
| 286ab3ba41 | |||
| 5ca2ad83cd | |||
| e3c0750f7d | |||
| 177d75784b | |||
| 6e244e0c01 | |||
| 27878d0faf | |||
| 08d8a104bb | |||
| 7ee0cbc3f4 | |||
| e5299cda5a | |||
| e5b192fcb3 | |||
| cfcaf5c1d3 | |||
| 2731318c81 | |||
| 86407e6ca2 | |||
| 2266dd9ad5 | |||
| 0df14ab94a | |||
| 448a97d67f | |||
| b699052324 | |||
| e6a55add20 | |||
| fcf89618cd | |||
| f83c467647 | |||
| 80b2d7f8c3 | |||
| 8286255ae5 | |||
| 615ab25680 | |||
| 545cc74ec8 | |||
| e5122c546b | |||
| 6737edbad2 | |||
| ce98c2ada3 | |||
| 676eebd5e4 | |||
| 2b66cec582 | |||
| b751c1c096 | |||
| 316f820eff | |||
| 38eb909f69 | |||
| d1699af609 | |||
| c6c694b69e | |||
| 4a3860ae92 | |||
| d57e24a7fa | |||
| bb1ab47b68 | |||
| a04ba2af7a | |||
| 494fdf2358 | |||
| 9f1e033e83 | |||
| fae00749ca | |||
| bf200e813e | |||
| 7209364c35 | |||
| 8314c273e7 | |||
| 1abf743a9f | |||
| 63a79791cd | |||
| cc757855e6 | |||
| 84913638b1 | |||
| 9ec92a9082 | |||
| 49fc23adc6 | |||
| 3c2c4f29ea | |||
| ae7cc15178 | |||
| 3d9697b918 | |||
| 329e222aa2 | |||
| 551494d223 | |||
| 5b4925e61a | |||
| 4ff4cc5899 | |||
| b95eaacc05 | |||
| c89f5bb3b9 | |||
| 07235d3b66 | |||
| f2bc36349e | |||
| ccf2e3a9c0 | |||
| 8f7265186d | |||
| 651d6c005c | |||
| 36b2929780 | |||
| 345ac97c43 | |||
| 767ac4aec5 | |||
| 29edd835a3 | |||
| d78a471e90 | |||
| 1d9e40236b | |||
| 2e6228a243 |
@@ -197,6 +197,20 @@ otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
|
||||
| `Start` | `AggregateFunction_Start` |
|
||||
| `End` | `AggregateFunction_End` |
|
||||
|
||||
#### Event-mode coverage
|
||||
|
||||
Drivers that implement the filter-aware
|
||||
`IHistoryProvider.ReadEventsAsync(fullReference, EventHistoryRequest, ct)`
|
||||
overload (currently the OPC UA Client gateway driver — Galaxy keeps the
|
||||
fixed-field fallback) honour `EventFilter` SelectClauses and a `WhereClause`
|
||||
when the server-side history facade forwards them. The CLI does not yet
|
||||
expose a dedicated `--events` flag — clients that need filter-aware event
|
||||
history call `HistoryReadEvents` through their own SDK; the CLI's
|
||||
`historyread` command stays focused on the data-history (Raw / Processed /
|
||||
AtTime) path. Adding `--events` is tracked as a follow-up — the wire path
|
||||
on the driver side is in place (see
|
||||
[`docs/drivers/OpcUaClient.md`](drivers/OpcUaClient.md#historyread-events)).
|
||||
|
||||
### alarms
|
||||
|
||||
Subscribes to alarm events on a node. Prints structured alarm output including source, condition, severity, active/acknowledged state, and message. Runs until Ctrl+C, then unsubscribes and disconnects cleanly.
|
||||
|
||||
@@ -20,6 +20,8 @@ dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli -- --help
|
||||
| `-g` / `--gateway` | **required** | Canonical `ab://host[:port]/cip-path` |
|
||||
| `-f` / `--family` | `ControlLogix` | ControlLogix / CompactLogix / Micro800 / GuardLogix |
|
||||
| `--timeout-ms` | `5000` | Per-operation timeout |
|
||||
| `--addressing-mode` | `Auto` | `Auto` / `Symbolic` / `Logical` — see [AbCip-Performance §Addressing mode](drivers/AbCip-Performance.md#addressing-mode). `Logical` against Micro800 silently falls back to Symbolic with a warning. |
|
||||
| `--partner` | _(unset)_ | PR abcip-5.1 — partner gateway URI for a ControlLogix HSBY pair (e.g. `ab://10.0.0.6/1,0`). When set, the driver runs a second role-probe loop against the partner and the [`hsby-status`](#hsby-status--which-chassis-is-active-now) command can surface which chassis is currently Active. See [AbCip-HSBY.md](drivers/AbCip-HSBY.md) for the full guide. |
|
||||
| `--verbose` | off | Serilog debug output |
|
||||
|
||||
Family ↔ CIP-path cheat sheet:
|
||||
@@ -55,6 +57,21 @@ otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t "Recipe[3]" --type Real
|
||||
otopcua-abcip-cli read -g ab://10.0.0.5/1,0 -t "Motor01.Speed" --type Real
|
||||
```
|
||||
|
||||
#### Diagnostic / system tags
|
||||
|
||||
PR abcip-4.3 exposes five read-only diagnostic variables per device under
|
||||
`AbCip/<device>/_System/` in the OPC UA address space (see
|
||||
[AbCip-Operability §System tags](drivers/AbCip-Operability.md#system-tags--_system-folder)
|
||||
for the full table). These are not reachable through the AB CIP CLI — they
|
||||
live on the OPC UA server side, not the libplctag wire — so to read one,
|
||||
point the **OPC UA client** CLI at the running OtOpcUa server:
|
||||
|
||||
```powershell
|
||||
# Read _ConnectionStatus for one device through the OPC UA server
|
||||
otopcua-client-cli read -u opc.tcp://localhost:4840 \
|
||||
-n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System/_ConnectionStatus"
|
||||
```
|
||||
|
||||
### `write` — single Logix tag
|
||||
|
||||
Same shape as `read` plus `-v`. Values parse per `--type` using invariant
|
||||
@@ -73,6 +90,65 @@ otopcua-abcip-cli write -g ab://10.0.0.5/1,0 -t StartCommand --type Bool -v true
|
||||
otopcua-abcip-cli subscribe -g ab://10.0.0.5/1,0 -t Motor01_Speed --type Real -i 500
|
||||
```
|
||||
|
||||
### `hsby-status` — which chassis is Active now?
|
||||
|
||||
PR abcip-5.1 — read the role tag (`WallClockTime.SyncStatus` by default,
|
||||
`S:34` for legacy SLC500 / PLC-5 fronts) on a ControlLogix HSBY pair and
|
||||
print which chassis is currently Active. Requires `--partner`.
|
||||
|
||||
```powershell
|
||||
otopcua-abcip-cli hsby-status -g ab://10.0.0.5/1,0 --partner ab://10.0.0.6/1,0
|
||||
|
||||
# Custom role tag (legacy fronts) and more samples
|
||||
otopcua-abcip-cli hsby-status -g ab://10.0.0.5/1,0 --partner ab://10.0.0.6/1,0 \
|
||||
--role-tag S:34 --samples 5
|
||||
```
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--role-tag` | `WallClockTime.SyncStatus` | Address of the role tag. Use `S:34` for SLC500 / PLC-5. |
|
||||
| `--samples` | `3` | Number of role-probe ticks to wait for before printing. |
|
||||
|
||||
The output prints the resolved roles + the address of whichever chassis the
|
||||
driver currently considers Active. PR abcip-5.1 only **reports** the role —
|
||||
PR abcip-5.2 will land the routing change so reads / writes flow to the
|
||||
Active chassis automatically.
|
||||
|
||||
See [AbCip-HSBY.md](drivers/AbCip-HSBY.md) for the role-tag detection matrix
|
||||
+ active-resolution rules + the feature-flag gate.
|
||||
|
||||
### `rebrowse` — force a controller-side `@tags` re-walk
|
||||
|
||||
PR abcip-2.5 (issue #233) added `RebrowseAsync` to drop the cached UDT
|
||||
template shapes and re-run the symbol-table enumerator without restarting
|
||||
the driver. The CLI variant builds a transient driver against the supplied
|
||||
gateway, runs the rebrowse, and prints the freshly discovered tag names —
|
||||
useful after a controller program-download to confirm the new tags are
|
||||
visible on the wire before wiring them through the OtOpcUa server.
|
||||
|
||||
```powershell
|
||||
otopcua-abcip-cli rebrowse -g ab://10.0.0.5/1,0
|
||||
```
|
||||
|
||||
## Refreshing the tag DB
|
||||
|
||||
Two operator-facing surfaces drive the same `RebrowseAsync` plumbing — pick
|
||||
the one that matches your context:
|
||||
|
||||
| Surface | When to use | Command |
|
||||
|---|---|---|
|
||||
| **CLI `rebrowse`** | Off-server validation. Spins up a transient driver against the gateway, prints the discovered tag list, no shared state with the live OtOpcUa server. | `otopcua-abcip-cli rebrowse -g ab://10.0.0.5/1,0` |
|
||||
| **OPC UA write to `_RefreshTagDb`** | Production / Admin-UI button (PR abcip-4.4). Forces the **live** driver to re-walk + clear its template cache. The `AbCip.RefreshTriggers` driver-diagnostics counter increments per truthy write. | `otopcua-client-cli write -u opc.tcp://localhost:4840 -n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System/_RefreshTagDb" -v true --type Boolean` |
|
||||
|
||||
Read-back semantics: `_RefreshTagDb` always reads back as `false` (Kepware-
|
||||
style "latches to idle the moment the dispatch returns") so a subscribed
|
||||
client sees a stable shape regardless of how many refreshes have fired.
|
||||
Falsy / unparseable writes are no-ops that still report `Good` so a UI
|
||||
template that resets the trigger flag after firing it doesn't see a phantom
|
||||
error. See
|
||||
[AbCip-Operability §System tags](drivers/AbCip-Operability.md#refreshing-the-tag-db-via-opc-ua-write)
|
||||
for the full semantics + the diagnostics counter wiring.
|
||||
|
||||
## Typical workflows
|
||||
|
||||
- **"Is the PLC reachable?"** → `probe`.
|
||||
@@ -81,3 +157,36 @@ otopcua-abcip-cli subscribe -g ab://10.0.0.5/1,0 -t Motor01_Speed --type Real -i
|
||||
- **"Is this GuardLogix safety tag writable from non-safety?"** → `write` and
|
||||
read the status code — safety tags surface `BadNotWritable` / CIP errors,
|
||||
non-safety tags surface `Good`.
|
||||
- **"Did my program download show up in the address space?"** → `rebrowse`
|
||||
(off-server) or write `true` to the live server's `_RefreshTagDb` system
|
||||
tag (in-server, PR abcip-4.4) — both drop the template cache + force a
|
||||
fresh `@tags` walk.
|
||||
|
||||
## Connection Size
|
||||
|
||||
PR abcip-3.1 introduced a per-device `ConnectionSize` override on the driver
|
||||
side (`AbCipDeviceOptions.ConnectionSize`, range `500..4002`). The CLI does
|
||||
not expose a flag for it — every CLI invocation uses the family-default
|
||||
Connection Size (4002 / 504 / 488 depending on `--family`). When a Forward
|
||||
Open is rejected with a CIP error like `0x01/0x113` ("connection request
|
||||
size invalid"), the symptom is almost always a **mismatch between the chosen
|
||||
family default and the controller firmware**:
|
||||
|
||||
- **v19-and-earlier ControlLogix** caps at 504 — pick `--family CompactLogix`
|
||||
on the CLI to fall back to that narrower default.
|
||||
- **5069-L1/L2/L3 CompactLogix** narrow-buffer parts also cap at 504, which
|
||||
is the family default already.
|
||||
- **FW20+ ControlLogix** accepts the full 4002.
|
||||
|
||||
For the warning *"AbCip device 'X' family 'Y' uses a narrow-buffer profile
|
||||
(default ConnectionSize Z); the configured ConnectionSize N exceeds the
|
||||
511-byte legacy-firmware cap..."* see
|
||||
[`docs/drivers/AbCip-Performance.md`](drivers/AbCip-Performance.md) — that
|
||||
warning is fired by the driver host, not the CLI.
|
||||
|
||||
## Related operability knobs
|
||||
|
||||
- [`docs/drivers/AbCip-Operability.md`](drivers/AbCip-Operability.md) — Phase 4
|
||||
per-tag knobs (per-tag scan rate, deadband, etc). The CLI does not expose
|
||||
these knobs directly; they're set in driver config JSON and consumed by the
|
||||
driver at subscribe time.
|
||||
|
||||
+193
-1
@@ -19,7 +19,11 @@ dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli -- --help
|
||||
|---|---|---|
|
||||
| `-g` / `--gateway` | **required** | Canonical `ab://host[:port]/cip-path` |
|
||||
| `-P` / `--plc-type` | `Slc500` | Slc500 / MicroLogix / Plc5 / LogixPccc |
|
||||
| `--timeout-ms` | `5000` | Per-operation timeout |
|
||||
| `--timeout-ms` | `5000` | Per-operation timeout — see precedence note below |
|
||||
| `--retries` | `0` | Retry count on transient `BadCommunicationError` (PR 9 / #252) |
|
||||
| `--demote-failure-threshold` | `3` | **PR ablegacy-12 / #255** — consecutive comm failures before the device is auto-demoted |
|
||||
| `--demote-for-ms` | `30000` | **PR ablegacy-12 / #255** — auto-demote cool-down window in ms |
|
||||
| `--no-demote` | off | **PR ablegacy-12 / #255** — disable auto-demote entirely (counters still tick) |
|
||||
| `--verbose` | off | Serilog debug output |
|
||||
|
||||
Family ↔ CIP-path cheat sheet:
|
||||
@@ -28,6 +32,52 @@ Family ↔ CIP-path cheat sheet:
|
||||
with no backplane
|
||||
- **LogixPccc** — `1,0` (Logix controller accessed via the PCCC compatibility
|
||||
layer; rare)
|
||||
- **PLC-5 via 1756-DHRIO bridge** — `1,<slot>,2,<station-octal>` (PLC-5 only).
|
||||
See [drivers/AbLegacy-DH-Bridging.md](drivers/AbLegacy-DH-Bridging.md) for
|
||||
the full DH+ syntax, octal-station reference (00..77 = 0..63), and manual
|
||||
hardware smoke procedure.
|
||||
|
||||
#### DHRIO worked example (PR ablegacy-13 / #256)
|
||||
|
||||
PLC-5 on DH+ node 7 (octal 07), DHRIO module in chassis slot 3,
|
||||
EtherNet/IP gateway 192.168.1.10:
|
||||
|
||||
```powershell
|
||||
otopcua-ablegacy-cli read `
|
||||
-g ab://192.168.1.10/1,3,2,07 `
|
||||
-P Plc5 -a N7:10 -t Int
|
||||
```
|
||||
|
||||
The parser validates `1,<slot>,2,<station>`: port-1 must be the backplane,
|
||||
slot must be 0..16, port-3 must be `2` (DH+), station must be octal 0..77 (so
|
||||
`80`, `90`, etc. are rejected). Combining a DH+ bridge path with a non-PLC-5
|
||||
family at startup throws `InvalidOperationException("DHRIO bridging is
|
||||
PLC-5-only")`.
|
||||
|
||||
### Per-device timeout / retry tuning (#252, PR 9)
|
||||
|
||||
The CLI's `--timeout-ms` is the **driver-wide default** when launched as a
|
||||
one-shot test client. In production (server-side, multi-device deployment)
|
||||
each `AbLegacyDeviceOptions` row carries its own optional `Timeout` /
|
||||
`Retries` that override the driver-wide value.
|
||||
|
||||
Precedence (highest → lowest): per-device override → driver-wide default →
|
||||
hard-coded fallback (2000 ms / 0 retries).
|
||||
|
||||
Tuning cheat sheet — start here, measure, then trim:
|
||||
|
||||
| Family | Recommended `Timeout` | Notes |
|
||||
|---|---|---|
|
||||
| SLC 5/01 (RS-232 / DH+ bridge) | **5000 ms** | Slowest of the bunch; serial round-trip plus DH+ hop |
|
||||
| SLC 5/02 / 5/03 (DH+) | 3000 ms | Bridged Ethernet → DH+ adds ~1 s |
|
||||
| **SLC 5/04 / 5/05** (Ethernet) | **2000 ms** | Fastest of the SLC family — direct EIP/PCCC |
|
||||
| MicroLogix 1100 / 1400 | **3000 ms** | Single-CPU, slow scan; no backplane |
|
||||
| PLC-5 (Ethernet I/F) | 2500 ms | Comparable to SLC 5/05 over EIP |
|
||||
| LogixPccc compat layer | 2000 ms | Logix CPU is fast; PCCC layer is the floor |
|
||||
|
||||
A small `--retries 1` (or `2` for slow chassis) is generally safe — the retry
|
||||
loop only fires on transient `BadCommunicationError`; terminal errors
|
||||
(`BadNodeIdUnknown`, `BadTypeMismatch`, …) surface on the first attempt.
|
||||
|
||||
## PCCC address primer
|
||||
|
||||
@@ -58,6 +108,37 @@ otopcua-ablegacy-cli probe -g ab://192.168.1.20/1,0
|
||||
otopcua-ablegacy-cli probe -g ab://192.168.1.30/ -P MicroLogix -a S:0
|
||||
```
|
||||
|
||||
`probe` output (PR ablegacy-12 / #255) reports both `Health` (driver health
|
||||
state) and `Host state`. The latter is sourced from `IHostConnectivityProbe`
|
||||
and surfaces `Demoted` when the auto-demote threshold has tripped — a fast
|
||||
visual signal that the CLI is short-circuiting future reads against this
|
||||
device until the cool-down expires:
|
||||
|
||||
```text
|
||||
Gateway: ab://192.168.1.20/1,0
|
||||
PLC type: Slc500
|
||||
Health: Degraded
|
||||
Host state: Demoted
|
||||
Last error: libplctag status -33 reading N7:0
|
||||
```
|
||||
|
||||
### Auto-demote knobs
|
||||
|
||||
```powershell
|
||||
# Trip after just one comm failure, hold for 60s.
|
||||
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a N7:0 -t Int `
|
||||
--demote-failure-threshold 1 --demote-for-ms 60000
|
||||
|
||||
# Opt out of auto-demote — stresses the link without short-circuiting.
|
||||
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a N7:0 -t Int --no-demote
|
||||
```
|
||||
|
||||
The CLI is a one-shot test client — auto-demote primarily matters in the
|
||||
server-side multi-device deployment, where a single demoted PLC can no
|
||||
longer block reads against its healthy peers. Use the CLI flags to
|
||||
reproduce a flapping-link scenario locally before tuning the server-side
|
||||
`appsettings.json` `Demote` block.
|
||||
|
||||
### `read`
|
||||
|
||||
```powershell
|
||||
@@ -75,8 +156,17 @@ otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a L19:0 -t Long
|
||||
|
||||
# Timer ACC
|
||||
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a T4:0.ACC -t TimerElement
|
||||
|
||||
# Diagnostic counter (PR ablegacy-10 / #253). The seven _Diagnostics/<name>
|
||||
# addresses live alongside user tags — short-circuit serves them straight from
|
||||
# the in-process counter store, so no PCCC frame is sent to the PLC.
|
||||
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 --address _Diagnostics/RequestCount
|
||||
```
|
||||
|
||||
The diagnostic surface auto-emits per device — no config required. See
|
||||
`docs/drivers/AbLegacy-Diagnostics.md` for the full counter table + reset
|
||||
semantics + collision-rejection rules.
|
||||
|
||||
### `write`
|
||||
|
||||
```powershell
|
||||
@@ -95,6 +185,108 @@ PLC-managed — use with caution.
|
||||
otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a N7:10 -t Int -i 500
|
||||
```
|
||||
|
||||
#### Deadband
|
||||
|
||||
PR 8 — per-tag absolute / percent change filter on top of the polled subscription. The driver
|
||||
caches the last *published* value per tag and suppresses `OnDataChange` notifications until the
|
||||
new sample crosses the configured threshold.
|
||||
|
||||
| Flag | Effect |
|
||||
|---|---|
|
||||
| `--deadband-absolute <value>` | Suppress until `|new - prev| >= value`. |
|
||||
| `--deadband-percent <value>` | Suppress until `|new - prev| >= |prev * value / 100|`. `prev == 0` always publishes (avoids div-by-zero). |
|
||||
|
||||
Booleans bypass the filter entirely (every transition publishes); strings + status changes
|
||||
always publish; first-seen always publishes; both flags set → either passing triggers a
|
||||
publish (Kepware-style logical OR).
|
||||
|
||||
```powershell
|
||||
# Float — drop sub-0.5 jitter from the noisy load-cell address.
|
||||
otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a F8:0 -t Float -i 500 `
|
||||
--deadband-absolute 0.5
|
||||
|
||||
# Integer — only fire on >= 5% deviation from the last reported value.
|
||||
otopcua-ablegacy-cli subscribe -g ab://192.168.1.20/1,0 -a N7:10 -t Int -i 500 `
|
||||
--deadband-percent 5
|
||||
```
|
||||
|
||||
## Array reads
|
||||
|
||||
PR 7 — one PCCC frame can carry up to ~120 words. Address an array tag with either the
|
||||
Rockwell-native `,N` suffix or the libplctag-native `[N]` suffix on the word number; both
|
||||
forms canonicalise to `[N]` when the driver hands the tag to libplctag, and the parser
|
||||
caps `N` at 120.
|
||||
|
||||
```powershell
|
||||
# Rockwell `,N` form — "10 consecutive words starting at N7:0"
|
||||
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "N7:0,10" -t Int
|
||||
|
||||
# libplctag `[N]` form — same wire result
|
||||
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "N7:0[10]" -t Int
|
||||
|
||||
# Float / Long arrays — same suffix syntax, narrower frame ceiling on Float (~60 elements)
|
||||
# and Long (~60 elements) because each element is 4 bytes vs Int's 2.
|
||||
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "F8:0,4" -t Float
|
||||
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "L19:0,4" -t Long
|
||||
|
||||
# --array-length override — pin the element count from config rather than the address
|
||||
# suffix. Wins over the parsed `,N` / `[N]` value when both are set; useful for keeping the
|
||||
# address string compact while bumping the element count from a tags config file.
|
||||
otopcua-ablegacy-cli read -g ab://192.168.1.20/1,0 -a "N7:0" --array-length 10 -t Int
|
||||
```
|
||||
|
||||
Array tags reject sub-element references (`T4:0,5.ACC`) and bit suffixes (`N7:0,10/3`) at
|
||||
parse time — both combinations are semantically meaningless against a contiguous block.
|
||||
|
||||
For `B`-files the Rockwell convention is "one BOOL per word, not per bit": `B3:0,10`
|
||||
returns `bool[10]` (one per word's non-zero state), not `bool[160]`.
|
||||
|
||||
### `import-rslogix`
|
||||
|
||||
ablegacy-11 / [#254](https://github.com/dohertj2/lmxopcua/issues/254) — bulk-import RSLogix
|
||||
500 / 5 CSV symbol exports into an `appsettings.json` tag fragment. Avoids hand-typing every
|
||||
`N7:0` / `F8:12` / `B3:0/5` row of a several-hundred-tag PLC. Binary `.RSS` / `.RSP` project
|
||||
files are out of scope; export to CSV first.
|
||||
|
||||
```powershell
|
||||
# Default: emit JSON fragment to stdout
|
||||
otopcua-ablegacy-cli import-rslogix `
|
||||
--file C:\plc\plc-export.csv `
|
||||
--device ab://192.168.1.20/1,0
|
||||
|
||||
# Write the fragment to a file + print a summary line to stdout
|
||||
otopcua-ablegacy-cli import-rslogix `
|
||||
--file C:\plc\plc-export.csv `
|
||||
--device ab://192.168.1.20/1,0 `
|
||||
--output tags.json
|
||||
|
||||
# Filter by Scope column — only import Local:1 program-scoped tags
|
||||
otopcua-ablegacy-cli import-rslogix `
|
||||
--file C:\plc\plc-export.csv `
|
||||
--device ab://192.168.1.20/1,0 `
|
||||
--scope Local:1
|
||||
|
||||
# Summary mode — one-line counter for CI / health checks
|
||||
otopcua-ablegacy-cli import-rslogix `
|
||||
--file C:\plc\plc-export.csv `
|
||||
--device ab://192.168.1.20/1,0 `
|
||||
--emit summary
|
||||
```
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `-f` / `--file` | **required** | RSLogix CSV path |
|
||||
| `-d` / `--device` | **required** | `ab://host[:port]/cip-path` every imported tag binds to |
|
||||
| `--emit` | `appsettings-fragment` | `appsettings-fragment` (JSON) or `summary` (one-line counter) |
|
||||
| `-o` / `--output` | stdout | Optional output file path |
|
||||
| `--scope` | none | Scope filter — `Global` / `Local:N` (case-insensitive); empty Scope counts as Global |
|
||||
| `--max-rows` | unlimited | Defensive cap on rows imported |
|
||||
| `--strict` | off | Fail-fast on first malformed row (default permissive: skip + log) |
|
||||
|
||||
See [drivers/AbLegacy-RSLogix-Import.md](drivers/AbLegacy-RSLogix-Import.md) for the full
|
||||
column reference, file-letter → `AbLegacyDataType` mapping, and the API surface
|
||||
(`IRsLogixImporter`, `AbLegacyDriverOptions.AddRsLogixImport`).
|
||||
|
||||
## Known caveat — ab_server upstream gap
|
||||
|
||||
The integration-fixture `ab_server` Docker container accepts TCP but its PCCC
|
||||
|
||||
@@ -51,6 +51,7 @@ Every command accepts:
|
||||
| `-p` / `--cnc-port` | `8193` | FOCAS TCP port (FOCAS-over-EIP default) |
|
||||
| `-s` / `--series` | `Unknown` | CNC series — `Unknown` / `Zero_i_D` / `Zero_i_F` / `Zero_i_MF` / `Zero_i_TF` / `Sixteen_i` / `Thirty_i` / `ThirtyOne_i` / `ThirtyTwo_i` / `PowerMotion_i` |
|
||||
| `--timeout-ms` | `2000` | Per-operation timeout |
|
||||
| `--cnc-password` | (none) | **F4-d (issue #271)** — optional CNC connection-level password emitted via `cnc_wrunlockparam` on connect. Required only by controllers that gate parameter writes / selected reads behind a password switch (16i + some 30i firmwares with parameter-protect on). **PASSWORD INVARIANT: never logged.** The CLI's Serilog config does not destructure this flag and `FocasDeviceOptions.ToString` redacts the value. See [`v2/focas-deployment.md`](v2/focas-deployment.md) § "FOCAS password handling". |
|
||||
| `--verbose` | off | Serilog debug output |
|
||||
|
||||
## Addressing
|
||||
@@ -110,16 +111,74 @@ Values parse per `--type` with invariant culture. Booleans accept
|
||||
```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
|
||||
|
||||
# MACRO: write — recipe / setpoint surface (server-side WriteOperate ACL)
|
||||
otopcua-focas-cli write -h 192.168.1.50 -a MACRO:500 -t Int32 -v 42
|
||||
|
||||
# PARAM: write — commissioning surface (server-side WriteConfigure ACL,
|
||||
# CNC must be in MDI mode + parameter-write switch enabled, else EW_PASSWD
|
||||
# surfaces as BadUserAccessDenied)
|
||||
otopcua-focas-cli write -h 192.168.1.50 -a PARAM:1815 -t Int32 -v 100
|
||||
```
|
||||
|
||||
> **WARNING — `write -a G50.3 -t Bit -v on` is a read-modify-write.**
|
||||
> The wire call `pmc_wrpmcrng` is byte-addressed; the driver reads the
|
||||
> parent byte at `G50` first, sets bit 3, and writes the byte back. Other
|
||||
> bits in `G50` that the ladder is concurrently updating may be clobbered
|
||||
> by the byte we read a millisecond ago. Coordinate via a ladder-side
|
||||
> handshake when this matters. **PMC writes also bypass the ladder's
|
||||
> normal MDI-mode protection** — a misdirected bit can move motion or
|
||||
> latch a feedhold the moment it lands. Verify e-stop is live and the
|
||||
> machine is in JOG mode before issuing the first PMC write of a
|
||||
> session. See [`docs/drivers/FOCAS.md`](drivers/FOCAS.md) "PMC bit-write
|
||||
> read-modify-write semantics" for the full RMW flow.
|
||||
|
||||
PMC G/R writes land on a running machine — be careful which file you hit.
|
||||
Parameter writes may require the CNC to be in MDI mode with the
|
||||
parameter-write switch enabled.
|
||||
|
||||
#### Server-enforced ACL — issue #269, plan PR F4-b
|
||||
|
||||
When the same write flows through the OtOpcUa server (rather than the CLI's
|
||||
direct-to-CNC path), the server-layer ACL gates by tag kind:
|
||||
|
||||
- `PARAM:` writes require **`WriteConfigure`** group membership — heavier
|
||||
ACL because a misdirected parameter write can put the CNC in a bad
|
||||
state.
|
||||
- `MACRO:` writes require **`WriteOperate`** — matches the standard HMI
|
||||
recipe / setpoint surface.
|
||||
- PMC R/G/F writes require **`WriteOperate`**.
|
||||
|
||||
The classification is declared by the FOCAS driver per tag and enforced by
|
||||
`DriverNodeManager`; the driver itself never inspects user identity. See
|
||||
[`docs/security.md`](security.md) for the full LDAP-group → permission
|
||||
mapping, [`docs/v2/acl-design.md`](v2/acl-design.md) for the design, and
|
||||
[`docs/v2/focas-deployment.md`](v2/focas-deployment.md) "Write safety" for
|
||||
the operator pre-check runbook (MDI mode, parameter-write switch).
|
||||
|
||||
**Writes are non-idempotent by default** — a timeout after the CNC already
|
||||
applied the write will NOT auto-retry (plan decisions #44 + #45).
|
||||
|
||||
#### Server-side `Writes` enforcement (issue #268 F4-a + #269 F4-b + #270 F4-c)
|
||||
|
||||
The OtOpcUa server gates every FOCAS write behind multiple independent
|
||||
opt-ins: `FocasDriverOptions.Writes.Enabled` (driver-level master switch),
|
||||
`Writes.AllowParameter` (PARAM kill switch — F4-b), `Writes.AllowMacro`
|
||||
(MACRO kill switch — F4-b), `Writes.AllowPmc` (PMC kill switch — F4-c),
|
||||
and `FocasTagDefinition.Writable` (per-tag). All default `false`; any one
|
||||
off short-circuits the server-side `WriteAsync` to `BadNotWritable` before
|
||||
the wire client is touched. See [`docs/drivers/FOCAS.md`](drivers/FOCAS.md)
|
||||
"Writes (opt-in, off by default)" subsection +
|
||||
[`docs/v2/decisions.md`](v2/decisions.md) for the decision record.
|
||||
|
||||
**The CLI bypasses the server-side flag.** `otopcua-focas-cli write` is a
|
||||
per-invocation operator tool — it sets `Writes.Enabled = true` locally for
|
||||
the lifetime of one process and creates the synthesised tag with
|
||||
`Writable = true`. This is intentional: the CLI is the operator's
|
||||
direct-to-CNC fallback, not a long-lived process bound to the central
|
||||
config DB. Configuring the server still requires both opt-ins to be set
|
||||
explicitly in the DriverInstance JSON.
|
||||
|
||||
### `subscribe` — watch an address until Ctrl+C
|
||||
|
||||
FOCAS has no push model; the shared `PollGroupEngine` handles the tick
|
||||
|
||||
@@ -22,6 +22,9 @@ dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli -- --help
|
||||
| `--rack` | `0` | Hardware rack (S7-400 distributed setups only) |
|
||||
| `--slot` | `0` | CPU slot (S7-300 = 2, S7-400 = 2 or 3, S7-1200/1500 = 0) |
|
||||
| `--timeout-ms` | `5000` | Per-operation timeout |
|
||||
| `--tsap-mode` | `Auto` | ISO-on-TCP connection class: `Auto` / `Pg` / `Op` / `S7Basic` / `Other`. Hardened S7-1500 / ET 200SP CPUs may require `Op` or `S7Basic`. See [s7.md TSAP / Connection Type](v2/s7.md#tsap--connection-type). |
|
||||
| `--local-tsap` | (unset) | Optional 16-bit local TSAP override (e.g. `0x0200`). Required when `--tsap-mode Other`; wins over class default under Pg/Op/S7Basic. |
|
||||
| `--remote-tsap` | (unset) | Optional 16-bit remote TSAP override. Required when `--tsap-mode Other`; wins over class default under Pg/Op/S7Basic. |
|
||||
| `--verbose` | off | Serilog debug output |
|
||||
|
||||
## PUT/GET must be enabled
|
||||
@@ -31,6 +34,28 @@ Enable it in TIA Portal: *Device config → Protection & Security → Connection
|
||||
mechanisms → "Permit access with PUT/GET communication from remote partner"*.
|
||||
Without it the CLI's first read will surface `BadNotSupported`.
|
||||
|
||||
### Pre-flight PUT/GET enablement (PR-S7-C5)
|
||||
|
||||
The driver issues a tiny 2-byte read against `Probe.ProbeAddress` (default
|
||||
`MW0`) immediately after `OpenAsync` and **fails `InitializeAsync` with a
|
||||
typed `S7PutGetDisabledException`** when the PLC rejects the read with the
|
||||
wire-level "function not allowed" response. The exception message names the
|
||||
exact TIA Portal toggle to flip — operators see the configuration fix at
|
||||
init time, not after the first per-tag read produces `BadDeviceFailure`.
|
||||
|
||||
Two opt-out knobs on the JSON `Probe` block:
|
||||
|
||||
- `ProbeAddress` — set to `""` (empty string) to skip the pre-flight read
|
||||
entirely. Useful when no fingerprint address has been wired.
|
||||
- `SkipPreflight` — set to `true` to defer the check to runtime while
|
||||
keeping the background liveness loop. Per-tag reads still surface
|
||||
`BadDeviceFailure` until PUT/GET is enabled, but Init succeeds and the
|
||||
driver becomes visible in the Admin UI.
|
||||
|
||||
See [s7.md "Pre-flight PUT/GET enablement"](v2/s7.md#pre-flight-putget-enablement)
|
||||
for the full rationale, classifier behaviour, and the wire-level
|
||||
`ErrorCode` matching.
|
||||
|
||||
## S7 address grammar cheat sheet
|
||||
|
||||
| Form | Meaning |
|
||||
@@ -83,6 +108,26 @@ otopcua-s7-cli write -h 192.168.1.30 -a M0.0 -t Bool -v true
|
||||
**Writes to M / Q are real** — they drive the PLC program. Be careful what you
|
||||
flip on a running machine.
|
||||
|
||||
### Hardened CPU — forcing OP-class TSAP
|
||||
|
||||
```powershell
|
||||
# Probe a hardened S7-1500 that rejects PG class but accepts OP.
|
||||
otopcua-s7-cli probe -h 10.50.12.30 --tsap-mode Op
|
||||
|
||||
# Read against the same CPU.
|
||||
otopcua-s7-cli read -h 10.50.12.30 --tsap-mode Op -a DB1.DBW0 -t Int16
|
||||
|
||||
# Manual TSAP override (e.g. site with a fixed proprietary TSAP gateway).
|
||||
otopcua-s7-cli probe -h 10.50.12.30 --tsap-mode Other --local-tsap 0x4D57 --remote-tsap 0x4D58
|
||||
```
|
||||
|
||||
Without `--tsap-mode`, the CLI uses S7netplus's CpuType-derived default (PG
|
||||
class for almost everything). The same connection-refused failure shape that a
|
||||
wrong `--slot` produces also shows up when the CPU rejects PG class — try
|
||||
`--tsap-mode Op` first when the handshake is failing on otherwise-correct
|
||||
endpoint config. See [s7.md TSAP / Connection Type](v2/s7.md#tsap--connection-type)
|
||||
for the byte table and motivation.
|
||||
|
||||
### `subscribe`
|
||||
|
||||
```powershell
|
||||
@@ -91,3 +136,42 @@ otopcua-s7-cli subscribe -h 192.168.1.30 -a DB1.DBW0 -t Int16 -i 500
|
||||
|
||||
S7comm has no native push — the CLI polls through `PollGroupEngine` just like
|
||||
Modbus / AB.
|
||||
|
||||
### `import-symbols`
|
||||
|
||||
PR-S7-D1 / [#299](https://github.com/dohertj2/lmxopcua/issues/299) — read a TIA
|
||||
Portal CSV ("Show all tags" export) or STEP 7 Classic `.AWL` file and emit a
|
||||
JSON tag fragment for `appsettings.json`, or a one-line summary. Mirrors the
|
||||
AB Legacy `import-rslogix` CLI in shape.
|
||||
|
||||
```powershell
|
||||
# TIA Portal CSV — emit JSON fragment to stdout
|
||||
otopcua-s7-cli import-symbols --file plc-export.csv --format tia
|
||||
|
||||
# STEP 7 Classic AWL — emit summary line
|
||||
otopcua-s7-cli import-symbols --file classic.awl --format awl --emit summary
|
||||
|
||||
# DE-locale CSV — auto-detected; output to file
|
||||
otopcua-s7-cli import-symbols `
|
||||
--file plc-de.csv `
|
||||
--format tia `
|
||||
--emit appsettings-fragment `
|
||||
--output tags.json
|
||||
|
||||
# Strict mode — fail-fast on the first malformed row (CI lint)
|
||||
otopcua-s7-cli import-symbols --file plc.csv --format tia --strict
|
||||
```
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `-f` / `--file` | **required** | Path to the TIA CSV or `.AWL` file |
|
||||
| `--format` | `tia` | `tia` (CSV) or `awl` (STEP 7 Classic) |
|
||||
| `-d` / `--device` | none | Optional documentation tag (held for symmetry with `import-rslogix`) |
|
||||
| `--emit` | `appsettings-fragment` | `appsettings-fragment` (JSON) or `summary` (one-line counter) |
|
||||
| `-o` / `--output` | stdout | Optional path; when set the JSON fragment is written there + summary line goes to stdout |
|
||||
| `--max-rows` | unlimited | Defensive cap on rows imported |
|
||||
| `--strict` | off | Fail-fast on the first malformed row (default permissive: skip + log) |
|
||||
|
||||
UDT-typed rows import as placeholder tags (data type forced to `Byte`); see
|
||||
[S7-TIA-Import.md](drivers/S7-TIA-Import.md) for the full format reference,
|
||||
locale auto-detection, and AWL position-based addressing rules.
|
||||
|
||||
+119
-1
@@ -28,6 +28,56 @@ sessions. Pick one:
|
||||
The CLI compiles + runs without a router, but every wire call fails with a
|
||||
transport error until one is reachable.
|
||||
|
||||
## UDT decomposition
|
||||
|
||||
PR 4.1 (issue #315) replaces the old "skip non-atomic symbols" behaviour
|
||||
of `BrowseSymbolsAsync` with a recursive type walker
|
||||
(`TwinCATTypeWalker`). When the OtOpcUa server's TwinCAT driver runs
|
||||
discovery with `EnableControllerBrowse=true`, struct / UDT / function-block
|
||||
typed symbols flatten into one OPC UA variable per atomic leaf. Browse
|
||||
addresses use the same dotted-instance form the PLC exposes:
|
||||
|
||||
| PLC declaration | OPC UA browse paths surfaced |
|
||||
|---|---|
|
||||
| `MAIN.bStart : BOOL` | `MAIN.bStart` |
|
||||
| `GVL.stMotor : ST_Motor` | `GVL.stMotor.bRunning`, `GVL.stMotor.nState`, `GVL.stMotor.rTemperature`, … |
|
||||
| `GVL.aRecipe : ARRAY[1..10] OF DINT` | `GVL.aRecipe[1]` … `GVL.aRecipe[10]` |
|
||||
| `GVL.aPairs : ARRAY[0..2] OF ST_Pair` | `GVL.aPairs[0].nCount`, `GVL.aPairs[0].rValue`, `GVL.aPairs[1].…` |
|
||||
| `GVL.aBig : ARRAY[1..5000] OF DINT` | `GVL.aBig` (single whole-array root — over the cap) |
|
||||
|
||||
The CLI's `read` / `write` / `subscribe` commands take dotted paths
|
||||
directly:
|
||||
|
||||
```powershell
|
||||
# Read a struct member
|
||||
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s GVL.stMotor.rTemperature -t Real
|
||||
|
||||
# Read an array element
|
||||
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s "GVL.aRecipe[3]" -t DInt
|
||||
```
|
||||
|
||||
### Array expansion bound
|
||||
|
||||
`TwinCATDriverOptions.MaxArrayExpansion` (default `1024`) caps how many
|
||||
elements an array contributes to the discovered address space. Arrays
|
||||
whose total element count exceeds the cap surface as a single
|
||||
whole-array root with `IsArrayRoot=true` instead of one variable per
|
||||
element. Raise the bound when operators routinely care about individual
|
||||
elements of large recipe / lookup tables; lower it to keep discovery
|
||||
cheap for symbol tables that ship multi-thousand-element scratch
|
||||
arrays. Pre-declared whole-array tags from the `Tags` config bypass the
|
||||
walker entirely — set `ArrayDimensions` on a `TwinCATTagDefinition` to
|
||||
keep array reads on the existing PR 1.4 read-array path.
|
||||
|
||||
### Cycle / depth guard
|
||||
|
||||
The walker tracks the visited-type set + a hard depth cap of 8 levels
|
||||
so a self-pointer (`POINTER TO ST_Self`) or pathological alias chain
|
||||
terminates rather than spinning. POINTER / REFERENCE members are
|
||||
skipped at the type-graph level — surfacing them would require
|
||||
dereferencing through the AMS routing layer which has its own access
|
||||
patterns.
|
||||
|
||||
## Common flags
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
@@ -58,6 +108,35 @@ otopcua-twincat-cli probe -n 127.0.0.1.1.1 -s "TwinCAT_SystemInfoVarList._AppInf
|
||||
otopcua-twincat-cli probe -n 192.168.1.40.1.1 -s MAIN.bRunning --type Bool
|
||||
```
|
||||
|
||||
#### Health probe
|
||||
|
||||
The OtOpcUa server's TwinCAT driver runs an internal probe loop (PR 3.2, issue #314)
|
||||
that — alongside the cheap `ReadStateAsync` reachability check — samples four
|
||||
well-known system symbols once per probe interval and surfaces the result through
|
||||
the cross-driver `driver-diagnostics` RPC (added for Modbus, task #154). The same
|
||||
symbols can be probed directly via the CLI for ad-hoc troubleshooting:
|
||||
|
||||
```powershell
|
||||
# Cycle time (UDINT, 100 ns ticks → ÷10000 for ms)
|
||||
otopcua-twincat-cli probe -n 192.168.1.40.1.1 -s "TwinCAT_SystemInfoVarList._TaskInfo[1].CycleTime" --type UDInt
|
||||
|
||||
# Last task execution wall-clock (UDINT, 100 ns ticks → ÷10000 for ms)
|
||||
otopcua-twincat-cli probe -n 192.168.1.40.1.1 -s "TwinCAT_SystemInfoVarList._TaskInfo[1].LastExecTime" --type UDInt
|
||||
|
||||
# Online-change count — increments on every accepted online change
|
||||
otopcua-twincat-cli probe -n 192.168.1.40.1.1 -s "TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt" --type UDInt
|
||||
|
||||
# Loaded PLC project name (STRING(80))
|
||||
otopcua-twincat-cli probe -n 192.168.1.40.1.1 -s "TwinCAT_SystemInfoVarList._AppInfo.AppName" --type String
|
||||
```
|
||||
|
||||
Within the running OtOpcUa server these four signals land on
|
||||
`DeviceState.LastDiagnostics` as a `TwinCATDeviceDiagnostics` record + are folded
|
||||
into `DriverHealth.Diagnostics` keyed `TwinCAT.CycleTimeMs`, `TwinCAT.LastExecTimeMs`,
|
||||
`TwinCAT.JitterMs` (computed `LastExecTimeMs - CycleTimeMs`),
|
||||
`TwinCAT.OnlineChangeCnt`, and `TwinCAT.OnlineChangeIncrements`. See
|
||||
`docs/drivers/TwinCAT-Test-Fixture.md §Diagnostics` for the full mapping.
|
||||
|
||||
### `read`
|
||||
|
||||
```powershell
|
||||
@@ -77,6 +156,14 @@ otopcua-twincat-cli read -n 192.168.1.40.1.1 -s "Recipe[3]" -t Real
|
||||
otopcua-twincat-cli read -n 192.168.1.40.1.1 -s GVL.sMessage -t WString
|
||||
```
|
||||
|
||||
ADS variable handles for `read` / `write` symbols are cached transparently
|
||||
inside the CLI's underlying `AdsTwinCATClient`. The first read of a symbol
|
||||
resolves a handle; repeats reuse the cached handle for smaller AMS payloads
|
||||
and skipped name resolution. The cache wipes on reconnect, on
|
||||
`DeviceSymbolVersionInvalid` (with a one-shot retry), and on CLI exit. See
|
||||
`docs/drivers/TwinCAT-Test-Fixture.md §Handle caching` for the full story
|
||||
including the staleness caveat after an online change.
|
||||
|
||||
### `write`
|
||||
|
||||
```powershell
|
||||
@@ -95,7 +182,38 @@ otopcua-twincat-cli subscribe -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -i 500
|
||||
|
||||
# Fall back to polling for runtimes where native notifications are constrained
|
||||
otopcua-twincat-cli subscribe -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -i 500 --poll-only
|
||||
|
||||
# Coalesce bursty changes — runtime buffers up to 500 ms before dispatch
|
||||
otopcua-twincat-cli subscribe -n 192.168.1.40.1.1 -s GVL.Counter -t DInt -i 50 --max-delay-ms 500
|
||||
```
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `-s` / `--symbol` | **required** | Symbol path — same format as `read` |
|
||||
| `-t` / `--type` | `DInt` | IEC type (see Data types section) |
|
||||
| `-i` / `--interval-ms` | `1000` | **Cycle time** — minimum interval between change checks the PLC runtime applies |
|
||||
| `--max-delay-ms` | `0` | **Max coalescing window** — upper bound on how long the runtime buffers change events before dispatch. `0` = fire ASAP, no coalescing |
|
||||
| `--poll-only` | off | Disable native notifications, use `PollGroupEngine` instead |
|
||||
|
||||
`-i` / `--interval-ms` and `--max-delay-ms` are different things and both flow
|
||||
into the Beckhoff `NotificationSettings` ctor:
|
||||
|
||||
- **`--interval-ms`** is the *cycle*: the runtime checks for value changes at
|
||||
most this often. Smaller = lower latency, higher CPU.
|
||||
- **`--max-delay-ms`** is the *coalescing ceiling*: once a change is detected,
|
||||
the runtime can hold it for up to this long before dispatching, which lets
|
||||
it batch a burst of changes into a single callback. Default `0` means
|
||||
every detected change fires immediately — same as the pre-PR-3.1 behaviour.
|
||||
|
||||
For high-frequency signals (a counter incrementing every 10 ms PLC cycle),
|
||||
pair a small `-i` (so latency stays bounded) with a non-zero `--max-delay-ms`
|
||||
(so the OPC UA queue downstream doesn't flood). For slow signals just leave
|
||||
`--max-delay-ms` at `0`.
|
||||
|
||||
The subscribe banner announces which mechanism is in play — "ADS notification"
|
||||
or "polling" — so it's obvious in screen-recorded bug reports.
|
||||
or "polling" — and includes the `max-delay` value when set, so it's obvious
|
||||
in screen-recorded bug reports.
|
||||
|
||||
`--poll-only` polls go through the same cached-handle path as `read`, so
|
||||
repeated polls of the same symbol carry only a 4-byte handle on the wire
|
||||
rather than the full symbolic path.
|
||||
|
||||
@@ -67,6 +67,19 @@ their flag values to the already-shipped driver.
|
||||
then the other. The plausible result identifies the correct setting
|
||||
for that device family. (Modbus, S7.)
|
||||
|
||||
## Family-specific commands
|
||||
|
||||
Most drivers ship the four shared verbs and nothing else. AB Legacy adds a
|
||||
fifth family-specific verb for bulk symbol-table import:
|
||||
|
||||
| Driver | Extra verb | Doc |
|
||||
|---|---|---|
|
||||
| AB Legacy | `import-rslogix` — read RSLogix 500/5 CSV symbol exports + emit a JSON tag fragment | [drivers/AbLegacy-RSLogix-Import.md](drivers/AbLegacy-RSLogix-Import.md) |
|
||||
|
||||
Binary RSLogix project files (`.RSS` / `.RSP`) are out of scope for v1 — the
|
||||
format is proprietary and undocumented; no parser ships in libplctag or any
|
||||
community library. Export to CSV first.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **AB Legacy cip-path quirk** — libplctag's ab_server requires a
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
# AbCip — ControlLogix HSBY paired-IP support
|
||||
|
||||
PR abcip-5.1 + 5.2 ship **non-transparent** HSBY (Hot-Standby) awareness
|
||||
to the AB CIP driver. Each device may declare a partner gateway; when both
|
||||
gateways are up the driver concurrently probes a role tag on each chassis,
|
||||
reports which one is currently Active, and routes reads / writes through
|
||||
that chassis automatically.
|
||||
|
||||
- **PR abcip-5.1** — gathers + reports the role of each chassis through
|
||||
driver diagnostics. See [Role-tag detection matrix](#role-tag-detection-matrix)
|
||||
+ [Active-resolution rules](#active-resolution-rules).
|
||||
- **PR abcip-5.2** — wires the resolved active address into
|
||||
`AbCipDriver.ResolveHost` and the runtime-cache lifecycle. See
|
||||
[Failover behaviour](#failover-behaviour-pr-52) +
|
||||
[Failure-mode walkthrough](#failure-mode-walkthrough).
|
||||
|
||||
## When to use HSBY paired IPs
|
||||
|
||||
You have a redundant **ControlLogix** chassis pair (1756-RM redundancy
|
||||
module, two CPUs, one acting + one standby) and the SCADA / OPC UA layer
|
||||
needs to keep talking to *whichever chassis is currently Active* without an
|
||||
operator manually re-pointing the connection.
|
||||
|
||||
Pre-5.1 the driver only knew about a single `HostAddress`. After a
|
||||
hot-standby switch-over, the standby (now Active) carried a **different IP**
|
||||
and the driver kept probing the dead-but-was-Active address until someone
|
||||
edited the config.
|
||||
|
||||
PR abcip-5.1 closes the visibility half of that gap by reading the role tag
|
||||
on both chassis. PR abcip-5.2 closes the routing half by re-pointing
|
||||
`ResolveHost` at the Active address each tick + invalidating the per-tag
|
||||
runtime cache + write-coalescer state on every flip.
|
||||
|
||||
## Configuration
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"Devices": [
|
||||
{
|
||||
"HostAddress": "ab://10.0.0.5/1,0",
|
||||
"PartnerHostAddress": "ab://10.0.0.6/1,0",
|
||||
"Hsby": {
|
||||
"Enabled": true,
|
||||
"RoleTagAddress": "WallClockTime.SyncStatus",
|
||||
"ProbeIntervalMs": 2000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Default | Notes |
|
||||
|---|---|---|
|
||||
| `PartnerHostAddress` | `null` | Canonical `ab://gateway[:port]/cip-path` of the partner chassis. `null` = no HSBY pair; the driver behaves exactly like every pre-5.1 build. |
|
||||
| `Hsby.Enabled` | `false` | Master switch. When `false` (or `Hsby` omitted) no role probing happens, even if `PartnerHostAddress` is set. |
|
||||
| `Hsby.RoleTagAddress` | `WallClockTime.SyncStatus` | Address of the role tag on each chassis. See [role-tag detection matrix](#role-tag-detection-matrix). |
|
||||
| `Hsby.ProbeIntervalMs` | `2000` | How often each chassis is sampled. 2 s is a good default — tight enough to detect a switch-over within one Admin-UI refresh, loose enough to leave headroom for the regular probe loop. |
|
||||
|
||||
## Feature-flag gate (`Redundancy.Hsby.Enabled`)
|
||||
|
||||
`Hsby.Enabled = false` (the default) is the off-switch for the entire
|
||||
feature. The role-probe loop never starts, the diagnostics keys are not
|
||||
emitted, and the driver behaves identically to a pre-5.1 build. This is the
|
||||
gate to flip when an operator wants to roll the feature out cautiously
|
||||
across a fleet — set `Hsby.Enabled = true` per-device in driver config (no
|
||||
build flag, no env var).
|
||||
|
||||
When the gate is on but the partner gateway is unreachable, the role-probe
|
||||
loop reports `HsbyRole.Unknown` for the partner each tick. The primary's
|
||||
role still drives the active-chassis resolution; the operator sees the
|
||||
partner's role as Unknown in the Admin UI / driver diagnostics, which is the
|
||||
correct surface for "we can't reach the standby chassis right now."
|
||||
|
||||
## Role-tag detection matrix
|
||||
|
||||
| Firmware / fronts | Address | Decode |
|
||||
|---|---|---|
|
||||
| **v20 / v24 / v32+ ControlLogix HSBY** | `WallClockTime.SyncStatus` (DINT) | `0` = Standby, `1` = Synchronized / Active, `2` = Disqualified, anything else = Unknown |
|
||||
| **PLC-5 / SLC500 status-byte fallback** | `S:34` Module Status word | bit 0 = "this chassis is Active". Bit set → `Active`; clear → `Standby` |
|
||||
| **Custom user role tag** | any DINT-typed CIP path | Same matrix as `WallClockTime.SyncStatus` (0 / 1 / 2). Out-of-range values → Unknown. |
|
||||
|
||||
`AbCipHsbyRoleProber.MapValueToRole` is the value-to-role mapper; unit tests
|
||||
in `tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipHsbyTests.cs` pin every
|
||||
row of the matrix.
|
||||
|
||||
## What gets reported
|
||||
|
||||
The driver surfaces three diagnostics counters per HSBY-enabled device
|
||||
(visible via `driver-diagnostics` RPC + the Admin UI):
|
||||
|
||||
| Counter | Value |
|
||||
|---|---|
|
||||
| `AbCip.HsbyActive` | `1` if primary is Active, `2` if partner is Active, `0` if neither (or HSBY off) |
|
||||
| `AbCip.HsbyPrimaryRole` | `(int)HsbyRole` — `0` = Unknown, `1` = Active, `2` = Standby, `3` = Disqualified |
|
||||
| `AbCip.HsbyPartnerRole` | Same encoding as `HsbyPrimaryRole`, observed on the partner chassis |
|
||||
| `AbCip.HsbyFailoverCount` (PR 5.2) | Total number of `ActiveAddress` transitions the probe loop has observed across every HSBY-enabled device on this driver. Each increment maps to one runtime-cache invalidation + write-coalescer reset. |
|
||||
|
||||
When more than one HSBY pair is configured on the same driver instance the
|
||||
flat keys are scoped per primary host: `AbCip.HsbyActive[ab://10.0.0.5/1,0]`,
|
||||
etc.
|
||||
|
||||
The `DeviceState.ActiveAddress` field (internal; surfaced via
|
||||
`HsbyActive` diagnostics) is the address PR 5.2 routes through
|
||||
`ResolveHost` + uses to scope the per-host bulkhead / breaker key.
|
||||
See [Failover behaviour](#failover-behaviour-pr-52) for the runtime
|
||||
implications.
|
||||
|
||||
### Active-resolution rules
|
||||
|
||||
| Primary role | Partner role | `ActiveAddress` resolution |
|
||||
|---|---|---|
|
||||
| Active | Standby / Disqualified / Unknown | primary |
|
||||
| Standby / Disqualified / Unknown | Active | partner |
|
||||
| Active | Active (split-brain) | **primary wins**, warning logged |
|
||||
| Standby + Standby | Standby + Standby | `null` — PR 5.2's `ResolveHost` falls back to the configured primary; the existing dial flow surfaces `BadCommunicationError` if the primary is also down. See [Both-stuck](#both-stuck-no-chassis-active). |
|
||||
| Unknown + Unknown | Unknown + Unknown | `null` (same fallback as Standby + Standby) |
|
||||
|
||||
Split-brain (both chassis claim Active simultaneously) is a real
|
||||
production failure mode — typically a redundancy-module misconfiguration or
|
||||
a partial network split. The driver picks primary deterministically + emits
|
||||
a warning through `AbCipDriverOptions.OnWarning` so operators see it in the
|
||||
log.
|
||||
|
||||
## CLI flags
|
||||
|
||||
The `otopcua-abcip-cli` tool exposes the HSBY plumbing through two surfaces
|
||||
(see [Driver.AbCip.Cli.md](../Driver.AbCip.Cli.md) for the full CLI guide):
|
||||
|
||||
- `--partner <gateway>` — global flag on every command. Sets
|
||||
`PartnerHostAddress` + auto-enables `Hsby.Enabled = true` so the role
|
||||
probe runs alongside any read / write / subscribe.
|
||||
- `hsby-status` — dedicated command that prints which chassis is
|
||||
currently Active. Reads the role tag on both gateways for a few ticks +
|
||||
prints the `(primary, partner, active)` tuple.
|
||||
|
||||
```powershell
|
||||
# Print which chassis is Active right now
|
||||
otopcua-abcip-cli hsby-status -g ab://10.0.0.5/1,0 --partner ab://10.0.0.6/1,0
|
||||
|
||||
# Subscribe through the active chassis (PR 5.2 follow-up — today the
|
||||
# subscribe stays pointed at the primary; the role probe runs alongside).
|
||||
otopcua-abcip-cli subscribe -g ab://10.0.0.5/1,0 --partner ab://10.0.0.6/1,0 \
|
||||
-t Motor01_Speed --type Real -i 500
|
||||
```
|
||||
|
||||
## Test coverage
|
||||
|
||||
- **Unit** (`tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipHsbyTests.cs`):
|
||||
- Pure `MapValueToRole` matrix (WallClockTime.SyncStatus + S:34 bit
|
||||
mask + Unknown values).
|
||||
- End-to-end driver loop: primary Active / partner Standby resolves to
|
||||
primary; both Active resolves to primary with a warning; both
|
||||
Standby clears `ActiveAddress`; primary read failure routes to
|
||||
partner.
|
||||
- Diagnostics surface (`AbCip.HsbyActive` / `HsbyPrimaryRole` /
|
||||
`HsbyPartnerRole`).
|
||||
- DTO JSON round-trip (`PartnerHostAddress` + `Hsby.{Enabled,
|
||||
RoleTagAddress, ProbeIntervalMs}` survive deserialise → driver →
|
||||
`DeviceState`).
|
||||
- `Hsby.Enabled = false` → no role probing.
|
||||
- **Integration** (`tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/`):
|
||||
- `AbCipHsbyRoleProberTests.cs` (PR 5.1) and
|
||||
`AbCipHsbyFailoverTests.cs` (PR 5.2) — both **skipped by default**
|
||||
(`Assert.Skip`). `ab_server` cannot emulate a ControlLogix HSBY
|
||||
pair (no `WallClockTime.SyncStatus`, no second chassis concept).
|
||||
The Docker `paired` profile (PR 5.1) brings up two `ab_server`
|
||||
instances + a stub `hsby-mux` sidecar so the topology is
|
||||
documented, but a patched `ab_server` image that actually serves
|
||||
the role tag is still on the follow-up list.
|
||||
- Trait `Category=Hsby` so `dotnet test --filter Category=Hsby`
|
||||
finds them once they're promoted.
|
||||
- **End-to-end** (`scripts/e2e/test-abcip-hsby.ps1`, PR 5.2):
|
||||
- Paired-fixture variant of `test-abcip.ps1`. Subscribes to a tag
|
||||
through the OPC UA server, flips the active chassis mid-stream
|
||||
via the `hsby-mux` sidecar's `POST /flip` endpoint, asserts the
|
||||
stream survives + `AbCip.HsbyFailoverCount` increments. Gated
|
||||
on operator-supplied `BridgeNodeId` + a running paired fixture;
|
||||
ships unwired into `test-all.ps1` until the patched `ab_server`
|
||||
lands.
|
||||
|
||||
## Failover behaviour (PR 5.2)
|
||||
|
||||
PR 5.2 wires `DeviceState.ActiveAddress` into the read / write hot path
|
||||
through `AbCipDriver.ResolveHost` and the runtime-cache lifecycle. After
|
||||
the role-probe loop (PR 5.1) detects an active-address transition the
|
||||
driver re-points every wire-level operation at the now-Active chassis
|
||||
without operator intervention.
|
||||
|
||||
### What flips on a failover
|
||||
|
||||
| Aspect | Pre-flip | Post-flip |
|
||||
|---|---|---|
|
||||
| `ResolveHost(tag)` return | primary `HostAddress` | the partner address (when partner is now Active) |
|
||||
| Per-tag libplctag handles in `DeviceState.Runtimes` | created against primary gateway | dropped on flip; lazily re-created against the partner gateway on next read / write |
|
||||
| Parent-DINT RMW handles in `DeviceState.ParentRuntimes` | primary gateway | dropped on flip; same re-create-on-demand path |
|
||||
| `AbCipWriteCoalescer` per-device cache | last-known-written values from the primary | reset; the first write of any value to the partner pays the full round-trip |
|
||||
| `LogicalInstanceMap` (Logical-mode `@tags` walk) | populated for primary | cleared; the next read on a Logical-mode device re-walks `@tags` against the partner |
|
||||
| Per-host bulkhead key (Polly bulkhead + breaker, plan decision #144) | keyed on primary `HostAddress` | keyed on the new active address — the partner gets its own fresh breaker state instead of inheriting a tripped breaker from the now-standby |
|
||||
| `AbCip.HsbyFailoverCount` diagnostic | 0 | incremented by 1 on every transition observed by the probe loop |
|
||||
|
||||
### How the invalidation runs
|
||||
|
||||
PR 5.2 introduces an internal `OnActiveAddressChanged` event raised by
|
||||
`HsbyProbeLoopAsync` on every `DeviceState.ActiveAddress` transition. The
|
||||
driver subscribes to it from its own constructor; the handler
|
||||
(`HandleActiveAddressChanged`) does the cache invalidation in one place:
|
||||
|
||||
1. Disposes every entry in `DeviceState.Runtimes` and
|
||||
`DeviceState.ParentRuntimes`, then clears both dicts. Disposed
|
||||
`IAbCipTagRuntime` instances release their underlying libplctag
|
||||
handles so the native heap doesn't leak.
|
||||
2. Clears `DeviceState.LogicalInstanceMap` and resets
|
||||
`LogicalWalkComplete = false` so the next read on a Logical-mode
|
||||
device re-fires the `@tags` symbol walk against the new chassis.
|
||||
3. Calls `AbCipWriteCoalescer.Reset(deviceHostAddress)` so cached
|
||||
"we already wrote 42" decisions don't stale-suppress the first
|
||||
partner-side write.
|
||||
4. Resets `DeviceState.RuntimesAddress = null` so subsequent
|
||||
diagnostics observers see a fresh stamp on the next runtime
|
||||
creation.
|
||||
5. `Interlocked.Increment` on the driver-wide
|
||||
`AbCip.HsbyFailoverCount` counter.
|
||||
|
||||
The handler is idempotent — a second event for the same address change
|
||||
is harmless because the dicts are already empty + the coalescer reset
|
||||
is itself idempotent.
|
||||
|
||||
### Bulkhead key semantics
|
||||
|
||||
The per-host resilience pipeline (Polly bulkhead + circuit breaker, plan
|
||||
decision #144) keys on whatever `IPerCallHostResolver.ResolveHost`
|
||||
returns. PR 5.2 changes that resolver so an HSBY-failed-over device
|
||||
returns the partner's address, which means:
|
||||
|
||||
- The **device-state lookup** (`_devices.TryGetValue`) keeps using the
|
||||
configured primary `HostAddress` as the dictionary key — that key
|
||||
never changes for the lifetime of a device, so multi-device
|
||||
configurations stay routable.
|
||||
- The **resilience pipeline** (Polly bulkhead, breaker, retry policies)
|
||||
receives the active address as the host-name dimension. The standby
|
||||
chassis's tripped breaker (if its primary went away) doesn't bleed
|
||||
over to the partner; the partner gets fresh limits + a closed
|
||||
breaker.
|
||||
|
||||
When HSBY is disabled (`Hsby.Enabled = false`) `ResolveHost` returns the
|
||||
configured primary `HostAddress` exactly as it always has — pre-5.2
|
||||
behaviour, no double-key risk.
|
||||
|
||||
## Failure-mode walkthrough
|
||||
|
||||
PR 5.2 adds three failover surface areas to reason about. The table
|
||||
below summarises the behaviour the driver reports + how an operator
|
||||
can inspect it.
|
||||
|
||||
### Primary-stuck (primary unreachable, partner Active)
|
||||
|
||||
The primary chassis goes away (network partition, power loss, a stuck
|
||||
Forward Open). The role-probe loop reads `HsbyRole.Unknown` for the
|
||||
primary and `HsbyRole.Active` for the partner.
|
||||
|
||||
| Surface | Behaviour |
|
||||
|---|---|
|
||||
| `DeviceState.ActiveAddress` | partner address |
|
||||
| `DeviceState.PrimaryRole` | `Unknown` |
|
||||
| `DeviceState.PartnerRole` | `Active` |
|
||||
| `ResolveHost(tag)` | partner address |
|
||||
| Reads / writes | route through partner gateway transparently |
|
||||
| `AbCip.HsbyFailoverCount` | incremented when the address transitioned away from the primary |
|
||||
| `AbCip.HsbyActive` | `2` (partner is the active chassis) |
|
||||
| Operator action | none required for routing; investigate why the primary is unreachable through the connectivity-probe loop's `_System/_ConnectionStatus` for the device |
|
||||
|
||||
### Secondary-stuck (partner unreachable, primary Active)
|
||||
|
||||
The partner chassis goes away (its OPC UA server is down, its IP is
|
||||
unreachable, the redundancy module unhitched it). The probe loop reads
|
||||
`HsbyRole.Active` for the primary and `HsbyRole.Unknown` for the partner.
|
||||
|
||||
| Surface | Behaviour |
|
||||
|---|---|
|
||||
| `DeviceState.ActiveAddress` | primary address (no transition; this is the steady state) |
|
||||
| `DeviceState.PrimaryRole` | `Active` |
|
||||
| `DeviceState.PartnerRole` | `Unknown` |
|
||||
| `ResolveHost(tag)` | primary address |
|
||||
| Reads / writes | route through primary gateway exactly as in a non-HSBY deployment |
|
||||
| `AbCip.HsbyFailoverCount` | unchanged — no flip happened |
|
||||
| `AbCip.HsbyActive` | `1` (primary is the active chassis) |
|
||||
| Operator action | investigate why the partner is unreachable; the operational risk is that a future primary-side outage has no fall-back |
|
||||
|
||||
### Both-stuck (no chassis Active)
|
||||
|
||||
Both chassis report `Standby` / `Disqualified` / `Unknown` (a
|
||||
redundancy-module misconfiguration, both controllers in Program mode,
|
||||
or both unreachable).
|
||||
|
||||
| Surface | Behaviour |
|
||||
|---|---|
|
||||
| `DeviceState.ActiveAddress` | `null` |
|
||||
| `ResolveHost(tag)` | falls back to the configured primary `HostAddress` |
|
||||
| Reads / writes | dispatched to the configured primary; a stuck primary surfaces `BadCommunicationError` per the existing dial flow |
|
||||
| `AbCip.HsbyActive` | `0` (no chassis Active) |
|
||||
| `AbCip.HsbyFailoverCount` | incremented when the transition `Active → null` happened |
|
||||
| Operator action | investigate the redundancy module / mode keys; the SCADA layer sees stuck-or-bad-quality reads, not incorrect routing |
|
||||
|
||||
The "fall back to primary on null Active" choice is deliberate. Routing
|
||||
all reads to a deterministic chassis (the configured primary) keeps the
|
||||
breaker key + bulkhead state stable while the operator diagnoses the
|
||||
double-down outage; the alternative (round-robin / partner) would just
|
||||
trip both breakers in turn and obscure which chassis is the real
|
||||
problem.
|
||||
|
||||
## Follow-ups (beyond PR 5.2)
|
||||
|
||||
- **Patched `ab_server` image** — add a writable `WallClockTime.SyncStatus`
|
||||
tag (or a separate Python shim) so the Docker `paired` profile can
|
||||
exercise the wire-level role probe + the
|
||||
`tests/.../IntegrationTests/AbCipHsbyFailoverTests.cs` scaffold can
|
||||
flip its `Assert.Skip` for a real integration assertion.
|
||||
- **`hsby-mux` REST endpoint** — `POST /flip {"active": "primary"}` writes
|
||||
`1` to the chosen chassis + `0` to the other so integration tests +
|
||||
`scripts/e2e/test-abcip-hsby.ps1` can drive switch-overs
|
||||
deterministically.
|
||||
- **GuardLogix HSBY** — same role-tag plumbing applies; verify against a
|
||||
real 1756-L8xS pair when one is on-site.
|
||||
|
||||
## See also
|
||||
|
||||
- [`docs/Driver.AbCip.Cli.md`](../Driver.AbCip.Cli.md) — `--partner` flag +
|
||||
`hsby-status` command reference
|
||||
- [`docs/drivers/AbServer-Test-Fixture.md`](AbServer-Test-Fixture.md) §"What
|
||||
it does NOT cover" — HSBY entry
|
||||
- [`docs/Redundancy.md`](../Redundancy.md) — server-level (OPC UA-stack)
|
||||
redundancy; HSBY is the **driver-level** companion
|
||||
@@ -0,0 +1,406 @@
|
||||
# AB CIP — Operability knobs
|
||||
|
||||
Phase 4 of the AB CIP driver plan introduces operator-tunable behaviour that
|
||||
changes how the driver schedules per-tag traffic, deduplicates updates, and
|
||||
surfaces health — knobs that an operator typically reaches for *after* the
|
||||
address space is in place and the deployment is past the green-field phase.
|
||||
The Phase 3 doc (`AbCip-Performance.md`) covers connection-shape and
|
||||
read-strategy knobs; this doc is the home for the per-tag scheduling and
|
||||
operability levers as PRs land.
|
||||
|
||||
PR abcip-4.1 ships the first knob: per-tag **Scan Rate** (Kepware-parity scan
|
||||
classes).
|
||||
|
||||
## Per-tag scan rate
|
||||
|
||||
### What it is
|
||||
|
||||
A per-tag override of the OPC UA subscription's `publishingInterval`. The AB
|
||||
CIP driver mirrors the Galaxy hierarchy as a single OPC UA address space, so
|
||||
every tag served from one driver normally ticks at the publishing interval the
|
||||
client requested when it created the Subscription. This knob lets specific
|
||||
tags publish at a different cadence — fast HMI tags at 100 ms, batch /
|
||||
historian tags at 1–10 s — without forcing the operator to split tags into
|
||||
separate subscriptions or driver instances.
|
||||
|
||||
It is the Kepware "scan classes" model expressed per-tag. The same shape is
|
||||
already shipped in the S7 driver (`S7TagDefinition.ScanGroup`) and the AB
|
||||
Legacy / TwinCAT drivers; AB CIP adopts a leaner per-tag-only form because the
|
||||
CIP single-connection model means the practical knob a deployment reaches for
|
||||
is "this one tag, faster", not "every tag in this group".
|
||||
|
||||
### How it interacts with OPC UA publishingInterval
|
||||
|
||||
OPC UA semantics:
|
||||
|
||||
- The Subscription's `publishingInterval` is the *upper bound* on how often
|
||||
the server publishes a NotificationMessage. Each MonitoredItem also has its
|
||||
own `samplingInterval`; that's where this knob lands.
|
||||
- A per-tag `samplingInterval` shorter than the Subscription's
|
||||
`publishingInterval` means the server samples faster but only publishes at
|
||||
the next Subscription tick — clients may receive multiple values for one
|
||||
tag in a single Publish response.
|
||||
- A per-tag `samplingInterval` longer than the Subscription's
|
||||
`publishingInterval` is legal too — the server simply skips ticks for that
|
||||
tag.
|
||||
|
||||
AB CIP-side: the driver's `SubscribeAsync` receives one `publishingInterval`
|
||||
plus a list of tag references. With per-tag `ScanRateMs` it buckets the input
|
||||
list by resolved interval and registers one `PollGroupEngine` subscription per
|
||||
bucket. Each bucket runs an independent timer, so a 100 ms tag never waits
|
||||
for a 1000 ms tag's `Task.Delay` to expire.
|
||||
|
||||
### Override knob
|
||||
|
||||
`AbCipTagDefinition.ScanRateMs` (`int?`, default `null`). `null` = use the
|
||||
subscription's default `publishingInterval` (legacy behaviour). Bind via
|
||||
driver config JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"Tags": [
|
||||
{
|
||||
"Name": "Motor1.Speed",
|
||||
"DeviceHostAddress": "ab://10.0.0.5/1,0",
|
||||
"TagPath": "Motor1.Speed",
|
||||
"DataType": "DInt",
|
||||
"ScanRateMs": 100
|
||||
},
|
||||
{
|
||||
"Name": "Motor1.RunHours",
|
||||
"DeviceHostAddress": "ab://10.0.0.5/1,0",
|
||||
"TagPath": "Motor1.RunHours",
|
||||
"DataType": "DInt",
|
||||
"ScanRateMs": 5000
|
||||
},
|
||||
{
|
||||
"Name": "Motor1.NamePlate",
|
||||
"DeviceHostAddress": "ab://10.0.0.5/1,0",
|
||||
"TagPath": "Motor1.NamePlate",
|
||||
"DataType": "String"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Result: three buckets — 100 ms, 5000 ms, and the subscription default for
|
||||
`NamePlate`. UDT members inherit the parent tag's `ScanRateMs` at fan-out
|
||||
time, so a UDT declared at 100 ms publishes every member at 100 ms without
|
||||
the operator having to repeat the override on each member.
|
||||
|
||||
### Floor and degenerate cases
|
||||
|
||||
- `PollGroupEngine` floors every bucket at **100 ms** — a `ScanRateMs: 25`
|
||||
is clamped up. The floor matches the Modbus / S7 / TwinCAT floors and
|
||||
protects the wire from sub-mailbox-scan polling.
|
||||
- `ScanRateMs: 0` and negative values are treated as unset — the tag falls
|
||||
back to the subscription default. Mis-typed config degrades, doesn't fault.
|
||||
- A `ScanRateMs` equal to the subscription default collapses into the same
|
||||
bucket as plain tags. The driver doesn't fragment poll loops when the
|
||||
override is redundant.
|
||||
- Tags whose names don't appear in the driver's tag map (typo / discovery
|
||||
miss) fall through to the subscription default — same "config typo
|
||||
degrades" stance as the rest of the driver.
|
||||
|
||||
### Wire impact
|
||||
|
||||
Per-bucket independent timers do **not** parallelise CIP traffic. The driver
|
||||
serializes wire-side reads through its per-device libplctag handles, so a
|
||||
fast bucket and a slow bucket trade off against each other on the wire — the
|
||||
multi-rate split decouples *cadence* (the 100 ms bucket isn't queued behind
|
||||
the 1000 ms bucket's `Task.Delay`), not *throughput*. The wire still moves
|
||||
one CIP request at a time per device.
|
||||
|
||||
If you're reading a large tag set and the slow bucket starves the fast
|
||||
bucket, the lever is `AbCipDeviceOptions.ConnectionSize` (Phase 3) — pack
|
||||
more tags into one CIP RTT so the slow bucket finishes faster. Per-tag scan
|
||||
rate is a *scheduling* knob, not a *throughput* knob.
|
||||
|
||||
### Comparison to Kepware scan classes
|
||||
|
||||
| Kepware concept | AB CIP equivalent |
|
||||
|---|---|
|
||||
| Scan class table (named groups → rate) | implicit: each distinct `ScanRateMs` value is its own bucket |
|
||||
| Default scan class | OPC UA Subscription's `publishingInterval` |
|
||||
| Per-tag scan class assignment | `AbCipTagDefinition.ScanRateMs` |
|
||||
| "Scan mode: Respect client" | always — the OPC UA `publishingInterval` is the default |
|
||||
| "Force write" / "Write through cache" | not exposed — AB CIP writes always go to the wire |
|
||||
|
||||
The leaner shape (per-tag rate, not named groups) keeps the JSON config flat
|
||||
and reflects how operators tend to use the knob in practice — a handful of
|
||||
"this specific tag needs to be fast" overrides on top of a sensible default,
|
||||
rather than a separate tier of scan-class definitions.
|
||||
|
||||
### Verification
|
||||
|
||||
- **Unit**: `AbCipPerTagScanRateTests` (`tests/.../AbCip.Tests`). Asserts
|
||||
bucketing math, default-rate collapse, UDT member inheritance, JSON DTO
|
||||
round-trip, and end-to-end cadence against the in-process fake.
|
||||
- **Integration**: `AbCipPerTagScanRateTests`
|
||||
(`tests/.../AbCip.IntegrationTests`). Drives two tags at 100 ms / 1000 ms
|
||||
against a live `ab_server` and asserts the bucket count + each tag receives
|
||||
the initial-data push.
|
||||
- **E2E**: `scripts/e2e/test-abcip.ps1` — see the *PerTagScanRate* assertion.
|
||||
|
||||
### Cross-references
|
||||
|
||||
- `docs/Driver.AbCip.Cli.md` — there is no CLI surface change for this knob;
|
||||
scan rate is a config-time concern.
|
||||
- `docs/drivers/AbCip-Performance.md` — Phase 3 throughput knobs that pair
|
||||
with per-tag scan rate when a slow bucket starves a fast one.
|
||||
- S7 driver `ScanGroup` model in `src/.../S7DriverOptions.cs` — the
|
||||
named-group form of the same idea.
|
||||
|
||||
## Write deadband / write-on-change
|
||||
|
||||
PR abcip-4.2 ships the second operability knob: per-tag write coalescing,
|
||||
the *write-side* companion to the read-side deadband already shipped at the
|
||||
OPC UA monitored-item layer. The driver remembers the value last
|
||||
successfully written for a tag and can suppress redundant or below-threshold
|
||||
follow-up writes — they return `Good` to the OPC UA client without ever
|
||||
hitting the wire.
|
||||
|
||||
### What it is
|
||||
|
||||
- **`AbCipTagDefinition.WriteDeadband`** (`double?`, default `null`) —
|
||||
numeric absolute-difference threshold. When set, a write whose
|
||||
`|new − last|` is below the deadband is suppressed.
|
||||
- **`AbCipTagDefinition.WriteOnChange`** (`bool`, default `false`) —
|
||||
equality gate. When set, a write whose value equals the last successfully
|
||||
written value is suppressed.
|
||||
|
||||
Both knobs combine on the same tag. For numerics, the deadband path takes
|
||||
priority; the equality fallback covers the cases the deadband doesn't (BOOL
|
||||
setpoints, STRING constants, `WriteDeadband=0`, etc).
|
||||
|
||||
### Worked setpoint-jitter example
|
||||
|
||||
A motor speed setpoint published from an HMI tends to wobble by a few
|
||||
ticks even when the operator hasn't touched it — UI rounding, Modbus
|
||||
gateway re-encoding, RPN script noise. With `WriteDeadband: 0.5`:
|
||||
|
||||
```json
|
||||
{
|
||||
"Tags": [
|
||||
{
|
||||
"Name": "Motor1.Speed.SP",
|
||||
"DeviceHostAddress": "ab://10.0.0.5/1,0",
|
||||
"TagPath": "Motor1.Speed.SP",
|
||||
"DataType": "Real",
|
||||
"WriteDeadband": 0.5
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Sequence of writes from the HMI (one every 100 ms, no operator input):
|
||||
|
||||
| Time | Value | `\|new − last\|` | Wire? |
|
||||
|---|---|---|---|
|
||||
| 0 ms | 50.0 | n/a (first) | yes |
|
||||
| 100 ms | 50.2 | 0.2 < 0.5 | suppressed |
|
||||
| 200 ms | 50.3 | 0.3 < 0.5 | suppressed |
|
||||
| 300 ms | 50.6 | 0.6 ≥ 0.5 | yes |
|
||||
| 400 ms | 50.6 | 0.0 < 0.5 | suppressed |
|
||||
| 500 ms | 51.5 | 0.9 ≥ 0.5 | yes |
|
||||
|
||||
Three writes hit the wire; three are suppressed. The OPC UA client sees
|
||||
`Good` on every call. The PLC sees only the values that actually crossed
|
||||
the deadband.
|
||||
|
||||
### Combining with WriteOnChange
|
||||
|
||||
A digital reset bit driven by a UI that pulses it at every cycle:
|
||||
|
||||
```json
|
||||
{
|
||||
"Name": "Conveyor.Reset",
|
||||
"DeviceHostAddress": "ab://10.0.0.5/1,0",
|
||||
"TagPath": "Conveyor.Reset",
|
||||
"DataType": "Bool",
|
||||
"WriteOnChange": true
|
||||
}
|
||||
```
|
||||
|
||||
Three consecutive `false → false → false` writes from the UI collapse to
|
||||
one wire write (`false`, the first). When the operator clicks the reset
|
||||
button (`true`), that write passes; subsequent `true → true` repeats
|
||||
suppress until the UI clears it back to `false`.
|
||||
|
||||
Numeric tags can also opt into both: `WriteDeadband: 0.5` plus
|
||||
`WriteOnChange: true` is well-defined — the deadband suppresses jitter, the
|
||||
equality gate suppresses exact repeats (which the deadband path also catches
|
||||
because `|0| < 0.5`, but having both set documents the operator's intent).
|
||||
|
||||
### Special cases
|
||||
|
||||
- **First write** always passes through. The coalescer has no prior value
|
||||
to compare against, so the first write of any tag pays the full
|
||||
round-trip and seeds the cache.
|
||||
- **NaN / Infinity** bypass deadband suppression. IEEE-754 comparisons
|
||||
against NaN are undefined and a stale `+Inf` shouldn't silently swallow
|
||||
a real reset; the wire decides. `WriteOnChange` equality on NaN still
|
||||
follows .NET semantics (`Equals(NaN, NaN) == true` for `double` boxed in
|
||||
`object`), so a `WriteOnChange` tag stuck on NaN will suppress repeats
|
||||
until something else writes a real value.
|
||||
- **Failed writes** do *not* seed the cache. If the wire write fails, the
|
||||
next attempt with the same value still hits the wire because the
|
||||
coalescer never recorded a "last successful value" for it.
|
||||
- **Reconnect drops the cache**. The driver's host-state probe transitions
|
||||
`Stopped → Running` after a reconnect; both transitions reset the
|
||||
per-device coalescer cache, so the first post-reconnect write of any
|
||||
value pays the full round-trip. The PLC may have been restarted while
|
||||
the driver was offline and our cached "we already wrote 42" is stale.
|
||||
- **Two devices, same tag address**. The cache is keyed on
|
||||
`(deviceHostAddress, tagAddress)` so two PLCs running the same Logix
|
||||
program keep independent caches — writing 42 to A doesn't suppress
|
||||
writing 42 to B.
|
||||
- **Bit-in-DINT writes** consult the coalescer too, so a UI that pulses
|
||||
`Flags.3` at every cycle benefits from the same `WriteOnChange`
|
||||
suppression as a plain BOOL tag.
|
||||
- **Plain back-compat tags** (no `WriteDeadband`, no `WriteOnChange`)
|
||||
take a fast-path through the coalescer that increments only the
|
||||
`WritesPassedThrough` counter — no dictionary lookup, no allocation. The
|
||||
knobs are zero-overhead opt-in.
|
||||
|
||||
### Diagnostics
|
||||
|
||||
The driver surfaces two counters through `DriverHealth.Diagnostics` (the
|
||||
same path the `driver-diagnostics` RPC + Admin UI render for Modbus / S7 /
|
||||
OPC UA Client):
|
||||
|
||||
- `AbCip.WritesSuppressed` — total writes the coalescer skipped.
|
||||
- `AbCip.WritesPassedThrough` — total writes that hit the wire after
|
||||
consulting the coalescer.
|
||||
|
||||
Their ratio is the "wire savings" headline. A deployment with `0`
|
||||
suppressions either has no tags opted in or has the deadband too tight /
|
||||
the equality threshold too loose; revisit the per-tag config.
|
||||
|
||||
### Verification
|
||||
|
||||
- **Unit**: `AbCipWriteDeadbandTests` (`tests/.../AbCip.Tests`). Asserts
|
||||
the deadband math, the equality fallback, the first-write pass-through,
|
||||
reset-on-reconnect, two-device cache independence, suppressed-Good
|
||||
status, NaN bypass, the back-compat fast path, and DTO round-trip.
|
||||
- **Integration**: `AbCipWriteDeadbandTests`
|
||||
(`tests/.../AbCip.IntegrationTests`). Drives a 5-write jittery sequence
|
||||
with `WriteDeadband: 1.0` against a live `ab_server` and asserts the
|
||||
driver's diagnostics counter matches the expected suppression count.
|
||||
- **E2E**: `scripts/e2e/test-abcip.ps1` — see the *WriteCoalesce*
|
||||
assertion.
|
||||
|
||||
### Cross-references
|
||||
|
||||
- `docs/drivers/AbServer-Test-Fixture.md` §7 — capability surfaces beyond
|
||||
read; mentions write-coalesce coverage.
|
||||
- Modbus driver — read-side deadband in `ModbusDriver` predates this
|
||||
write-side equivalent; the config shape is intentionally similar.
|
||||
- Kepware "Deadband (write)" knob — this is the AB CIP equivalent.
|
||||
|
||||
## System tags / `_System` folder
|
||||
|
||||
PR abcip-4.3 surfaces five read-only diagnostic variables under
|
||||
`AbCip/<device>/_System/` so SCADA / Admin clients can pivot from "is the
|
||||
wire up?" to "what's our scan rate / tag count?" without leaving the OPC UA
|
||||
address space. The values come straight from the live
|
||||
`IHostConnectivityProbe` + `DriverHealth` surfaces — reads bypass libplctag
|
||||
and are served from the in-memory snapshot the probe loop / read loop
|
||||
updates. PR abcip-4.4 added `_RefreshTagDb` as a sixth, writeable entry —
|
||||
the Kepware-style refresh trigger.
|
||||
|
||||
### What it ships
|
||||
|
||||
| Variable | Type | Access | Source | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `_ConnectionStatus` | String | ViewOnly | `HostState` | `Running` / `Stopped` / `Unknown` / `Faulted`. Mirrors what the connectivity probe sees. |
|
||||
| `_ScanRate` | Float64 | ViewOnly | `AbCipProbeOptions.Interval` | Configured probe interval in milliseconds — compare against `_LastScanTimeMs` to spot wire stretch. |
|
||||
| `_TagCount` | Int32 | ViewOnly | `_tagsByName` | Discovered tag count for this device, excluding `_System/*`. |
|
||||
| `_DeviceError` | String | ViewOnly | `DriverHealth.LastError` | Most recent error message; empty when the device is healthy. |
|
||||
| `_LastScanTimeMs` | Float64 | ViewOnly | `ReadAsync` wall-clock | Duration of the most-recent `ReadAsync` iteration on this device. |
|
||||
| `_RefreshTagDb` | Boolean | **Operate** | n/a (write-only trigger) | PR abcip-4.4 — Kepware-style refresh trigger. Reads always return `false`. Writing any truthy value (`true`, non-zero number, `"true"` / `"1"` strings, case-insensitive) dispatches to `RebrowseAsync` against the device's cached `IAddressSpaceBuilder`. Falsy / unparseable writes are no-ops that report `Good` so a UI that resets the trigger flag doesn't see a phantom error. The `AbCip.RefreshTriggers` diagnostic counter increments per truthy write. |
|
||||
|
||||
### When the snapshot updates
|
||||
|
||||
- **Probe transitions** — every `Running ↔ Stopped` flip refreshes the
|
||||
device's snapshot inline, so a client subscribed to
|
||||
`_System/_ConnectionStatus` sees the new state on the next OPC UA
|
||||
publish tick.
|
||||
- **Read iterations** — `ReadAsync` recomputes `_LastScanTimeMs` per
|
||||
device that owned at least one reference in the batch + writes a fresh
|
||||
snapshot before returning.
|
||||
- **Driver init** — every device gets a seeded snapshot
|
||||
(`Unknown` / `0` / `""`) before the probe loop spins up so a read that
|
||||
arrives before the first probe iteration returns a stable shape rather
|
||||
than null.
|
||||
|
||||
### Browse + read example
|
||||
|
||||
```powershell
|
||||
# Browse the synthetic folder
|
||||
otopcua-client-cli browse -u opc.tcp://localhost:4840 \
|
||||
-n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System"
|
||||
|
||||
# Read the connection status
|
||||
otopcua-client-cli read -u opc.tcp://localhost:4840 \
|
||||
-n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System/_ConnectionStatus"
|
||||
```
|
||||
|
||||
The driver-side reference embeds the device host address (the
|
||||
`_System/<device>/<name>` form) so the dispatcher can route by device
|
||||
without an additional registry. PR abcip-4.4 turned `_RefreshTagDb` into
|
||||
a writeable refresh trigger; the rest of the surface remains `ViewOnly`.
|
||||
|
||||
### Refreshing the tag DB via OPC UA write
|
||||
|
||||
PR abcip-4.4 wires `_RefreshTagDb` to the same `RebrowseAsync` entry point
|
||||
the CLI's `rebrowse` command exercises (issue #233). Operators have two
|
||||
roughly-equivalent ways to force a controller-side `@tags` re-walk after a
|
||||
program download:
|
||||
|
||||
```powershell
|
||||
# Path A — OPC UA write to the system tag (production / Admin UI path)
|
||||
otopcua-client-cli write -u opc.tcp://localhost:4840 \
|
||||
-n "ns=2;s=AbCip/ab://10.0.0.5/1,0/_System/_RefreshTagDb" \
|
||||
-v true --type Boolean
|
||||
|
||||
# Path B — direct CLI rebrowse against a transient driver (admin / debug path)
|
||||
otopcua-abcip-cli rebrowse -g ab://10.0.0.5/1,0
|
||||
```
|
||||
|
||||
Both paths drop the UDT template cache + re-run the enumerator walk. Path A
|
||||
is the operator-facing surface (the same `IDriverControl.RebrowseAsync`
|
||||
contract, just dispatched from the OPC UA write surface instead of an
|
||||
in-process call). Path B spins up its own driver instance so it doesn't
|
||||
share the live server's cache, which makes it useful for one-off
|
||||
controller-side validation.
|
||||
|
||||
The `AbCip.RefreshTriggers` driver-diagnostics counter increments per
|
||||
successful truthy write, so the Admin UI / driver-diagnostics RPC can show
|
||||
a "Refreshes since boot" tile that pairs naturally with the existing
|
||||
`WritesSuppressed` / `WritesPassedThrough` write-coalescer counters.
|
||||
|
||||
### Verification
|
||||
|
||||
- **Unit**: `AbCipSystemTagSourceTests`
|
||||
(`tests/.../AbCip.Tests`) — covers snapshot round-trip, two-device
|
||||
isolation, recognised-name lookup, default-shape on unseeded devices,
|
||||
discovery emits the six canonical nodes, and `ReadAsync` dispatches
|
||||
through the source instead of libplctag.
|
||||
- **Unit**: `AbCipRefreshTagDbTests`
|
||||
(`tests/.../AbCip.Tests`) — PR abcip-4.4 — covers discovery emits the
|
||||
trigger as Operate, reads always return `false`, truthy/falsy/null write
|
||||
semantics, the `AbCip.RefreshTriggers` counter, two-device counter
|
||||
independence, defends-in-depth `BadNotWritable` for read-only system
|
||||
variables, no-op-Good when no builder is cached yet, and mixed-batch
|
||||
routing alongside ordinary tag writes.
|
||||
- **Integration**: `AbCipSystemTagDiscoveryTests`
|
||||
(`tests/.../AbCip.IntegrationTests`) — `[AbServerFact]` connects to a
|
||||
real `ab_server`, browses `_System/`, reads each variable, asserts
|
||||
every one returns Good with a non-null value.
|
||||
- **Integration**: `AbCipRefreshTagDbTests`
|
||||
(`tests/.../AbCip.IntegrationTests`) — PR abcip-4.4 — `[AbServerFact]`
|
||||
drives a `_RefreshTagDb` write, asserts the template cache drops + the
|
||||
per-device counter advances against a live `ab_server`.
|
||||
- **E2E**: `scripts/e2e/test-abcip.ps1` — see the *SystemTagBrowse* +
|
||||
*RefreshTagDbWrite* assertions.
|
||||
@@ -0,0 +1,405 @@
|
||||
# AB CIP — Performance knobs
|
||||
|
||||
Phase 3 of the AB CIP driver plan introduces a small set of operator-tunable
|
||||
performance knobs that change how the driver talks to the controller without
|
||||
altering the address space or per-tag semantics. They consolidate decisions
|
||||
that Kepware exposes as a slider / advanced page so deployments running into
|
||||
high-latency PLCs, narrow-CPU CompactLogix parts, or legacy ControlLogix
|
||||
firmware have an explicit lever to pull.
|
||||
|
||||
This document is the home for those knobs as PRs land. PR abcip-3.1 ships the
|
||||
first knob: per-device **CIP Connection Size**.
|
||||
|
||||
## Connection Size
|
||||
|
||||
### What it is
|
||||
|
||||
CIP Connection Size — the byte ceiling on a single Forward Open response
|
||||
fragment, set during the EtherNet/IP Forward Open handshake. Larger
|
||||
connection sizes pack more tags into a single CIP RTT (higher request-packing
|
||||
density, fewer round-trips for the same scan list); smaller connection sizes
|
||||
stay compatible with legacy or narrow-buffer firmware that rejects oversized
|
||||
Forward Open requests.
|
||||
|
||||
### Family defaults
|
||||
|
||||
The driver picks a Connection Size from the per-family profile when the
|
||||
device-level override is unset:
|
||||
|
||||
| Family | Default | Rationale |
|
||||
|---|---:|---|
|
||||
| `ControlLogix` | `4002` | Large Forward Open — FW20+ |
|
||||
| `GuardLogix` | `4002` | Same wire protocol as ControlLogix |
|
||||
| `CompactLogix` | `504` | 5069-L1/L2/L3 narrow-buffer parts (5370 family) |
|
||||
| `Micro800` | `488` | Hard cap on Micro800 firmware |
|
||||
|
||||
These map straight to libplctag's `connection_size` attribute and match the
|
||||
defaults Kepware uses out of the box for the same families.
|
||||
|
||||
### Override knob
|
||||
|
||||
`AbCipDeviceOptions.ConnectionSize` (`int?`, default `null`) overrides the
|
||||
family default for one device. Bind it through driver config JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"Devices": [
|
||||
{
|
||||
"HostAddress": "ab://10.0.0.5/1,0",
|
||||
"PlcFamily": "ControlLogix",
|
||||
"ConnectionSize": 504
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The override threads through every libplctag handle the driver creates for
|
||||
that device — read tags, write tags, probe tags, UDT-template reads, the
|
||||
`@tags` walker, and BOOL-in-DINT parent runtimes. There is no per-tag
|
||||
override; one Connection Size applies to the whole controller (matches CIP
|
||||
session semantics).
|
||||
|
||||
### Valid range
|
||||
|
||||
`[500..4002]` bytes. This matches the slider Kepware exposes for the same
|
||||
family. Values outside the range fail driver `InitializeAsync` with an
|
||||
`InvalidOperationException` — there's no silent clamp; misconfigured devices
|
||||
fail loudly so operators see the problem at deploy time.
|
||||
|
||||
| Value | Behaviour |
|
||||
|---|---|
|
||||
| `null` | Use family default (4002 / 504 / 488) |
|
||||
| `499` or below | Driver init fault — out-of-range |
|
||||
| `500..4002` | Threaded through to libplctag |
|
||||
| `4003` or above | Driver init fault — out-of-range |
|
||||
|
||||
### Legacy-firmware caveat
|
||||
|
||||
ControlLogix firmware **v19 and earlier** caps the CIP buffer at **504
|
||||
bytes** — Connection Sizes above that cause the controller to reject the
|
||||
Forward Open with CIP error 0x01/0x113. The 5069-L1/L2/L3 CompactLogix narrow
|
||||
parts are subject to the same cap.
|
||||
|
||||
The driver emits a warning via `AbCipDriverOptions.OnWarning` when the
|
||||
configured Connection Size **exceeds 511** *and* the device's family profile
|
||||
default is also at-or-below the legacy cap (i.e. CompactLogix with default
|
||||
504, or Micro800 with default 488). Production hosting should wire
|
||||
`OnWarning` to the application logger; the unit tests (`AbCipConnectionSizeTests`)
|
||||
collect into a list to assert which warnings fired.
|
||||
|
||||
The warning fires once per device at `InitializeAsync`. It does not block
|
||||
initialisation — operators may need the override anyway when running newer
|
||||
CompactLogix firmware that does support the larger Forward Open. The
|
||||
controller will reject the connection at runtime if it can't honour the size,
|
||||
and that surfaces through the standard `IHostConnectivityProbe` channel.
|
||||
|
||||
### Performance trade-off
|
||||
|
||||
| Larger Connection Size | Smaller Connection Size |
|
||||
|---|---|
|
||||
| More tags per CIP RTT — higher throughput | Compatible with legacy / narrow firmware |
|
||||
| Bigger buffers held by libplctag native (RSS impact) | Lower memory footprint |
|
||||
| Forward Open rejected on FW19- ControlLogix | Always works (assuming ≥500) |
|
||||
| Required for high-density scan lists | Forces more round-trips — higher latency |
|
||||
|
||||
For most FW20+ ControlLogix shops, the default `4002` is correct and the
|
||||
override is unnecessary. The override is mainly useful when:
|
||||
|
||||
1. **Migrating off Kepware** with a controller-specific slider value already
|
||||
tuned in production — set Connection Size to match.
|
||||
2. **Mixed-firmware fleets** where some controllers are still on FW19 — set
|
||||
the legacy controllers explicitly to `504`.
|
||||
3. **CompactLogix L1/L2/L3** running newer firmware that supports a larger
|
||||
Forward Open than the family-default 504 — bump the override up.
|
||||
4. **Micro800** never goes above `488`; the override is for documentation /
|
||||
discoverability rather than capability change.
|
||||
|
||||
### libplctag wrapper limitation
|
||||
|
||||
The libplctag .NET wrapper (1.5.x) does not expose `connection_size` as a
|
||||
public `Tag` property. The driver propagates the value via reflection on the
|
||||
wrapper's internal `NativeTagWrapper.SetIntAttribute("connection_size", N)`
|
||||
after `InitializeAsync` — equivalent to libplctag's
|
||||
`plc_tag_set_int_attribute`. Because libplctag native parses
|
||||
`connection_size` only at create time, this is **best-effort** until either:
|
||||
|
||||
- the libplctag .NET wrapper exposes `ConnectionSize` directly (planned in
|
||||
the upstream backlog), in which case the reflection no-ops cleanly, or
|
||||
- libplctag native gains post-create hot-update for `connection_size`, in
|
||||
which case the call lands as intended.
|
||||
|
||||
In the meantime the value is correctly stored on `DeviceState.ConnectionSize`
|
||||
+ surfaces in every `AbCipTagCreateParams` the driver builds, so the override
|
||||
is observable end-to-end through the public driver surface and unit tests
|
||||
even if the underlying wrapper isn't yet honouring it on the wire.
|
||||
|
||||
Operators who need *guaranteed* Connection Size enforcement against FW19
|
||||
controllers today can pin `libplctag` to a wrapper version that exposes
|
||||
`ConnectionSize` once one is available, or run a libplctag native build
|
||||
patched for runtime updates. Both paths are tracked in the AB CIP plan.
|
||||
|
||||
### See also
|
||||
|
||||
- [`docs/Driver.AbCip.Cli.md`](../Driver.AbCip.Cli.md) — AB CIP CLI uses the
|
||||
family default ConnectionSize on each invocation; per-device overrides only
|
||||
apply through the driver's device-config JSON, not the CLI's command-line.
|
||||
- [`docs/drivers/AbServer-Test-Fixture.md`](AbServer-Test-Fixture.md) §5 —
|
||||
ab_server simulator does not enforce the narrow CompactLogix cap, so
|
||||
Connection Size correctness is verified by unit tests + Emulate-rig live
|
||||
smokes only.
|
||||
- [`PlcFamilies/AbCipPlcFamilyProfile.cs`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs) —
|
||||
per-family default values.
|
||||
- [`AbCipConnectionSize`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipConnectionSize.cs) —
|
||||
range bounds + legacy-firmware threshold constants.
|
||||
|
||||
## Addressing mode
|
||||
|
||||
### What it is
|
||||
|
||||
CIP exposes two equivalent ways to address a Logix tag on the wire:
|
||||
|
||||
1. **Symbolic** — the request carries the tag's ASCII name and the controller
|
||||
parses + resolves the path on every read. This is the libplctag default
|
||||
and what every previous driver build has used.
|
||||
2. **Logical** — the request carries a CIP Symbol Object instance ID (a small
|
||||
integer assigned by the controller when the project was downloaded). The
|
||||
controller skips ASCII parsing entirely; the lookup is a single
|
||||
instance-table dereference.
|
||||
|
||||
Logical addressing is faster on the controller side and produces smaller
|
||||
request frames. The trade-off is that the driver has to learn the
|
||||
name → instance-id mapping once, by reading the `@tags` pseudo-tag at
|
||||
startup, and the resolution step has to repeat after a controller program
|
||||
download (instance IDs are re-assigned).
|
||||
|
||||
### Enum values
|
||||
|
||||
`AbCipDeviceOptions.AddressingMode` (`AddressingMode` enum, default
|
||||
`Auto`) takes one of three values:
|
||||
|
||||
| Value | Behaviour |
|
||||
|---|---|
|
||||
| `Auto` | Driver picks. **Currently resolves to `Symbolic`** — a future PR will plumb a real auto-detection heuristic (firmware version + symbol-table size). |
|
||||
| `Symbolic` | Force ASCII symbolic addressing on the wire. The historical default. |
|
||||
| `Logical` | Use CIP logical-segment / instance-ID addressing. Triggers a one-time `@tags` walk at the first read; subsequent reads consult the cached map. |
|
||||
|
||||
`Auto` is documented as "Symbolic-for-now" so deployments setting `Auto`
|
||||
explicitly today will silently flip to a real heuristic when one ships,
|
||||
matching the spirit of the toggle. Operators who want to pin the wire
|
||||
behaviour should set `Symbolic` or `Logical` directly.
|
||||
|
||||
### Family compatibility
|
||||
|
||||
Logical addressing depends on the controller implementing CIP Symbol Object
|
||||
class 0x6B with stable instance IDs. Older AB families don't:
|
||||
|
||||
| Family | Logical addressing supported? | Why |
|
||||
|---|---|---|
|
||||
| `ControlLogix` | yes | Native class 0x6B support, FW10+ |
|
||||
| `CompactLogix` | yes | Same wire protocol as ControlLogix |
|
||||
| `GuardLogix` | yes | Same wire protocol; safety partition is tag-level, not addressing-level |
|
||||
| `Micro800` | **no** | Firmware does not implement class 0x6B; instance-ID reads trip CIP "Path Segment Error" 0x04 |
|
||||
| `SLC500` / `PLC5` | **no** | Pre-CIP families; PCCC bridging only — no Symbol Object at all |
|
||||
|
||||
When `AddressingMode = Logical` is set on an unsupported family, the driver
|
||||
**falls back to Symbolic with a warning** (via `OnWarning`) instead of
|
||||
faulting. This keeps mixed-firmware deployments working — operators can ship
|
||||
a uniform "Logical" config across the fleet and let the driver downgrade
|
||||
the families that can't honour it.
|
||||
|
||||
The driver-level decision is exposed via
|
||||
`PlcFamilies.AbCipPlcFamilyProfile.SupportsLogicalAddressing` and resolved at
|
||||
`AbCipDriver.InitializeAsync` time; the resolved mode is stored on
|
||||
`DeviceState.AddressingMode` and threaded through every
|
||||
`AbCipTagCreateParams` from then on.
|
||||
|
||||
### One-time symbol-table walk
|
||||
|
||||
The first read on a Logical-mode device triggers a one-time `@tags` walk via
|
||||
`LibplctagTagEnumerator` (the same component used for opt-in controller
|
||||
browse). The driver caches the resulting name → instance-id map on
|
||||
`DeviceState.LogicalInstanceMap`; subsequent reads consult the cache without
|
||||
issuing another walk. The walk is gated by a per-device `SemaphoreSlim` so
|
||||
parallel first-reads serialise on a single dispatch.
|
||||
|
||||
The walk happens in `AbCipDriver.EnsureLogicalMappingsAsync` and runs only
|
||||
for devices that have actually resolved to `Logical`. Symbolic-mode devices
|
||||
skip the walk entirely. Walk failures are non-fatal: the
|
||||
`LogicalWalkComplete` flag still flips to `true` so the driver does not
|
||||
re-attempt indefinitely, and per-tag handles fall back to Symbolic addressing
|
||||
on the wire (libplctag's default).
|
||||
|
||||
A controller program download invalidates the instance IDs. There is no
|
||||
auto-invalidation today — operators trigger a fresh walk by either
|
||||
restarting the driver or calling `RebrowseAsync` (the same surface that
|
||||
clears the UDT template cache) with logic-mode plumbing extended in a
|
||||
future PR. For now, restart-on-download is the recommended workflow.
|
||||
|
||||
### libplctag wrapper limitation
|
||||
|
||||
The libplctag .NET wrapper (1.5.x) does **not** expose a public knob for
|
||||
instance-ID addressing. The driver translates Logical-mode params into
|
||||
libplctag attributes via reflection on
|
||||
`NativeTagWrapper.SetAttributeString("use_connected_msg", "1")` +
|
||||
`SetAttributeString("cip_addr", "0x6B,N")` — same best-effort fallback
|
||||
pattern as the Connection Size knob.
|
||||
|
||||
This means **Logical mode is observable end-to-end through the public
|
||||
driver surface and unit tests today**, but the actual wire behaviour
|
||||
remains Symbolic until either:
|
||||
|
||||
- the upstream libplctag .NET wrapper exposes the
|
||||
`UseConnectedMessaging` + `CipAddr` properties on `Tag` directly
|
||||
(planned in the upstream backlog), in which case the reflection no-ops
|
||||
cleanly, or
|
||||
- libplctag native gains post-create hot-update for `cip_addr`, in which
|
||||
case the call lands as intended.
|
||||
|
||||
The driver-level bookkeeping (resolved mode, instance-id map, family
|
||||
compatibility, fall-back warning) is fully wired so the upgrade path is
|
||||
purely a wrapper-version bump.
|
||||
|
||||
### Performance trade-off
|
||||
|
||||
| Symbolic addressing | Logical addressing |
|
||||
|---|---|
|
||||
| Works everywhere | Requires Symbol Object class 0x6B |
|
||||
| ASCII parse on every read (controller-side cost) | One-time walk; instance-id lookup thereafter |
|
||||
| No first-read latency | First read on a device pays the `@tags` walk |
|
||||
| Smaller code surface | Stale on program download — restart driver to re-walk |
|
||||
| Best for small / sparse tag sets | Best for >500-tag scans with stable controller |
|
||||
|
||||
For scan lists in the **single-digit-tag** range, the per-poll ASCII parse
|
||||
cost is invisible. For **medium** scan lists (~100 tags) the gain is real
|
||||
but small — typically 5–10% per CIP RTT depending on tag-name length. The
|
||||
break-even point is where the ASCII-parse overhead starts dominating,
|
||||
roughly **>500 tags** in a tight scan loop, which is also where libplctag's
|
||||
own request-packing benefits compound. Large MES / batch projects with
|
||||
many UDT instances are the canonical case.
|
||||
|
||||
### Driver config JSON
|
||||
|
||||
Bind the toggle through the driver-config JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"Devices": [
|
||||
{
|
||||
"HostAddress": "ab://10.0.0.5/1,0",
|
||||
"PlcFamily": "ControlLogix",
|
||||
"AddressingMode": "Logical"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`"Auto"`, `"Symbolic"`, and `"Logical"` parse case-insensitively. Omitting
|
||||
the field defaults to `"Auto"`.
|
||||
|
||||
### See also
|
||||
|
||||
- [`AbCipDriverOptions.AddressingMode`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs) —
|
||||
enum definition + per-value docstrings.
|
||||
- [`AbCipPlcFamilyProfile.SupportsLogicalAddressing`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs) —
|
||||
family compatibility table source-of-truth.
|
||||
- [`docs/drivers/AbServer-Test-Fixture.md`](AbServer-Test-Fixture.md) §
|
||||
"What it actually covers" — Logical-mode fixture coverage status.
|
||||
- [`AbCipAddressingModeBenchTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipAddressingModeBenchTests.cs) —
|
||||
scaffold for the wall-clock comparison; gated on `[AbServerFact]`.
|
||||
|
||||
## Read strategy (PR abcip-3.3)
|
||||
|
||||
A per-device toggle that controls how multi-member UDT batches are read.
|
||||
The default `Auto` value matches every previous build's behaviour for dense
|
||||
reads but switches to per-member bundling when only a handful of members of
|
||||
a large UDT are subscribed — the canonical "5 of 50" sparse-subscription
|
||||
case where reading the whole UDT buffer just to extract a few fields wastes
|
||||
wire bandwidth.
|
||||
|
||||
### Three modes
|
||||
|
||||
| Mode | When to use |
|
||||
|---|---|
|
||||
| `WholeUdt` | Most members of every subscribed UDT are read together. One libplctag read per parent UDT, members decoded in-memory at their byte offsets. The task #194 default. |
|
||||
| `MultiPacket` | A few members of a large UDT are subscribed at a time. One read per subscribed member, bundled per parent into one CIP Multi-Service Packet. |
|
||||
| `Auto` (default) | Planner picks per-batch from the subscribed-member fraction (see *Sparsity threshold*). |
|
||||
|
||||
### Sparsity threshold
|
||||
|
||||
Auto mode divides `subscribedMembers / totalMembers` for each parent UDT and
|
||||
picks `MultiPacket` when the fraction is **strictly less than** the
|
||||
threshold, else `WholeUdt`. Default threshold `0.25` — a 1/4 subscription is
|
||||
the rough break-even where the wire-cost of one whole-UDT read still beats
|
||||
N member reads on a ControlLogix 4002-byte connection-size buffer; above
|
||||
1/4, the per-member overhead dominates.
|
||||
|
||||
Tune via `AbCipDeviceOptions.MultiPacketSparsityThreshold` (clamped to
|
||||
`[0..1]`). Threshold `0.0` = "never MultiPacket"; `1.0` = "always MultiPacket
|
||||
when any member is subscribed."
|
||||
|
||||
### Family compatibility
|
||||
|
||||
`MultiPacket` requires CIP service `0x0A` (Multi-Service Packet) on the
|
||||
controller. Source of truth is
|
||||
[`AbCipPlcFamilyProfile.SupportsRequestPacking`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs):
|
||||
|
||||
| Family | `SupportsRequestPacking` |
|
||||
|---|---|
|
||||
| ControlLogix | yes |
|
||||
| CompactLogix | yes |
|
||||
| GuardLogix | yes (wire identical to ControlLogix) |
|
||||
| Micro800 | **no** |
|
||||
| SLC500 / PLC5 (when those profiles ship) | **no** |
|
||||
|
||||
User-forced `MultiPacket` against a non-packing family logs a warning at
|
||||
device init and falls back to `WholeUdt`. `Auto` against a non-packing
|
||||
family stays `Auto` at the device level — the per-batch heuristic caps the
|
||||
strategy to `WholeUdt` so the wire never sees a Multi-Service-Packet against
|
||||
a controller that can't decode it.
|
||||
|
||||
### libplctag wrapper limitation
|
||||
|
||||
The libplctag .NET wrapper (1.5.x) does not expose the `0x0A` service as a
|
||||
public knob — same wrapper-version constraint that gates PR abcip-3.1's
|
||||
`connection_size` and PR abcip-3.2's instance-ID addressing. Today's
|
||||
MultiPacket runtime therefore issues N libplctag reads sequentially when
|
||||
the planner picks the strategy; the wire-level bundling lands cleanly when
|
||||
an upstream wrapper release exposes the primitive.
|
||||
|
||||
The driver-level bookkeeping (resolved strategy, per-batch heuristic,
|
||||
family-compat fall-back, per-device dispatch counters) is fully wired so
|
||||
the upgrade path is a wrapper-version bump only — the planner already
|
||||
produces the right plan, and `AbCipMultiPacketReadPlanner.Build` is
|
||||
covered by unit tests that pin the plan shape rather than wire bytes.
|
||||
|
||||
### Driver config JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"Devices": [
|
||||
{
|
||||
"HostAddress": "ab://10.0.0.5/1,0",
|
||||
"PlcFamily": "ControlLogix",
|
||||
"ReadStrategy": "Auto",
|
||||
"MultiPacketSparsityThreshold": 0.25
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`"Auto"`, `"WholeUdt"`, and `"MultiPacket"` parse case-insensitively.
|
||||
Omitting the field defaults to `"Auto"`. Omitting
|
||||
`MultiPacketSparsityThreshold` defaults to `0.25`.
|
||||
|
||||
### See also
|
||||
|
||||
- [`AbCipDriverOptions.ReadStrategy`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs) —
|
||||
enum definition + per-value docstrings.
|
||||
- [`AbCipMultiPacketReadPlanner`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipMultiPacketReadPlanner.cs) —
|
||||
plan shape + Auto-mode heuristic.
|
||||
- [`AbCipPlcFamilyProfile.SupportsRequestPacking`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs) —
|
||||
family compatibility table source-of-truth.
|
||||
- [`AbCipReadStrategyTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipReadStrategyTests.cs) —
|
||||
device-init resolution, heuristic edges, dispatch counters, DTO round-trip.
|
||||
- [`AbCipEmulateMultiPacketReadTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateMultiPacketReadTests.cs) —
|
||||
golden-box-tier wire-level coverage scaffold; gated on `AB_SERVER_PROFILE=emulate`.
|
||||
@@ -0,0 +1,141 @@
|
||||
# AB Legacy — DH+ via 1756-DHRIO bridging
|
||||
|
||||
PR ablegacy-13 / [#256](https://github.com/dohertj2/lmxopcua/issues/256). The AB
|
||||
Legacy driver can address a PLC-5 sitting on a DH+ link by routing CIP requests
|
||||
through a 1756-DHRIO module installed in a ControlLogix chassis. This is the
|
||||
canonical way to keep an installed-base PLC-5 fleet alive after the chassis-
|
||||
level migration to ControlLogix; the DHRIO module exposes a DH+ "side" that
|
||||
talks to the legacy PLC-5 / SLC-DH+ peers and a backplane "side" that the
|
||||
ControlLogix CPU + Ethernet bridge can route through.
|
||||
|
||||
## Wire layout
|
||||
|
||||
```
|
||||
OtOpcUa server ──EtherNet/IP──► 1756-EN2T (slot 0) ──backplane──► 1756-DHRIO (slot N) ──DH+──► PLC-5
|
||||
```
|
||||
|
||||
Two CIP hops:
|
||||
|
||||
1. **Backplane** — port `1`, slot `<N>` (the slot the DHRIO module lives in).
|
||||
2. **DH+** — port `2`, station `<S>` (the DH+ node address of the target PLC-5,
|
||||
in **octal**).
|
||||
|
||||
Resulting CIP path: `1,<N>,2,<S>`.
|
||||
|
||||
> The first port `1` is always the backplane; port `2` is the DH+ side of the
|
||||
> 1756-DHRIO module. This mirrors the convention Rockwell uses in RSLinx + RSLogix
|
||||
> 5.
|
||||
|
||||
## Octal station number
|
||||
|
||||
The DH+ network was specified with **octal** node addresses. Rockwell tooling
|
||||
displays them in octal too (RSLogix 5 → "DH+ Node Address" field on the
|
||||
controller properties dialog). The driver follows suit — the station segment
|
||||
of the CIP path **must be parsed as octal** (digits 0..7 only; `8`, `9`, and
|
||||
multi-byte garbage are rejected).
|
||||
|
||||
DH+ addresses run `0..77` octal == `0..63` decimal. Quick reference:
|
||||
|
||||
| Octal | Decimal | Octal | Decimal | Octal | Decimal | Octal | Decimal |
|
||||
|------:|--------:|------:|--------:|------:|--------:|------:|--------:|
|
||||
| 00 | 0 | 20 | 16 | 40 | 32 | 60 | 48 |
|
||||
| 01 | 1 | 21 | 17 | 41 | 33 | 61 | 49 |
|
||||
| 02 | 2 | 22 | 18 | 42 | 34 | 62 | 50 |
|
||||
| 03 | 3 | 23 | 19 | 43 | 35 | 63 | 51 |
|
||||
| 04 | 4 | 24 | 20 | 44 | 36 | 64 | 52 |
|
||||
| 05 | 5 | 25 | 21 | 45 | 37 | 65 | 53 |
|
||||
| 06 | 6 | 26 | 22 | 46 | 38 | 66 | 54 |
|
||||
| 07 | 7 | 27 | 23 | 47 | 39 | 67 | 55 |
|
||||
| 10 | 8 | 30 | 24 | 50 | 40 | 70 | 56 |
|
||||
| 11 | 9 | 31 | 25 | 51 | 41 | 71 | 57 |
|
||||
| 12 | 10 | 32 | 26 | 52 | 42 | 72 | 58 |
|
||||
| 13 | 11 | 33 | 27 | 53 | 43 | 73 | 59 |
|
||||
| 14 | 12 | 34 | 28 | 54 | 44 | 74 | 60 |
|
||||
| 15 | 13 | 35 | 29 | 55 | 45 | 75 | 61 |
|
||||
| 16 | 14 | 36 | 30 | 56 | 46 | 76 | 62 |
|
||||
| 17 | 15 | 37 | 31 | 57 | 47 | 77 | 63 |
|
||||
|
||||
Anything past `77` octal (i.e. decimal > 63) is invalid on a real DH+ network
|
||||
and rejected by the parser.
|
||||
|
||||
## PLC-5 only
|
||||
|
||||
DHRIO bridging is **PLC-5-only**. The driver enforces this at
|
||||
`AbLegacyDriver.InitializeAsync` time: a DH+ bridge path combined with
|
||||
`PlcFamily=Slc500 / MicroLogix / LogixPccc` throws
|
||||
`InvalidOperationException("DHRIO bridging is PLC-5-only")` immediately rather
|
||||
than letting reads silently fail with `BadCommunicationError` on the wire.
|
||||
|
||||
Background: the 1756-DHRIO module only speaks DH+ to PLC-5 / SLC-DH+ peers, and
|
||||
libplctag's PCCC stack only exposes the PLC-5 side. SLC 5/04 boxes on DH+
|
||||
**can** be physically reached through a DHRIO module, but the protocol stack
|
||||
needed to drive them isn't exposed by libplctag — out of scope for this driver.
|
||||
|
||||
## CLI worked example
|
||||
|
||||
PLC-5 at DH+ node `07` (octal == 7 decimal), DHRIO module in slot 3, gateway
|
||||
`192.168.1.10`:
|
||||
|
||||
```powershell
|
||||
otopcua-ablegacy-cli probe `
|
||||
-g ab://192.168.1.10/1,3,2,07 `
|
||||
-P Plc5 `
|
||||
-a N7:0
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Read N7:10 from the PLC-5 across the DHRIO bridge
|
||||
otopcua-ablegacy-cli read `
|
||||
-g ab://192.168.1.10/1,3,2,07 `
|
||||
-P Plc5 `
|
||||
-a N7:10 `
|
||||
-t Int
|
||||
```
|
||||
|
||||
The driver surfaces the parsed bridge form on the host-address record:
|
||||
`BackplaneSlot=3`, `DhPlusPort=2`, `DhPlusStation=7` (decimal-translated). Use
|
||||
those values when reading driver-diagnostics output to confirm the bridge was
|
||||
recognised — a non-bridge CIP path leaves all three fields null.
|
||||
|
||||
## Manual smoke procedure
|
||||
|
||||
There is no automated end-to-end coverage for DH+ bridging because the only
|
||||
path to wire-level validation is real hardware (libplctag's `ab_server` Docker
|
||||
image doesn't simulate the DHRIO + DH+ + PLC-5 stack). The unit-test layer
|
||||
covers parser positive / negative cases.
|
||||
|
||||
Hardware smoke checklist:
|
||||
|
||||
1. Confirm the 1756-DHRIO module is present in the target ControlLogix chassis.
|
||||
RSLinx Classic should show `DH+, 1` under the chassis tree with the PLC-5
|
||||
nodes enumerated underneath.
|
||||
2. Note the DHRIO module's slot number (the `<N>` in `1,<N>,2,<S>`).
|
||||
3. Note the target PLC-5's DH+ node address — read it off the front-panel switch
|
||||
bank, or the controller properties in RSLogix 5. **Read it as octal**.
|
||||
4. From an OtOpcUa box that can reach the EtherNet/IP gateway:
|
||||
|
||||
```powershell
|
||||
otopcua-ablegacy-cli probe -g ab://<gateway>/1,<slot>,2,<station-octal> -P Plc5 -a S:0
|
||||
```
|
||||
|
||||
`S:0` (status file word 0) is non-destructive and present on every PLC-5.
|
||||
5. If the probe succeeds, exercise an N file read against a known
|
||||
non-zero address. Compare against the value displayed in RSLogix 5 →
|
||||
Online → Data → N7.
|
||||
|
||||
If the probe fails with `BadCommunicationError`:
|
||||
|
||||
- Wrong slot number — re-check via RSLinx.
|
||||
- Wrong octal node — convert from RSLogix 5's display value (already octal); a
|
||||
decimal-thinking conversion mistake is the most common smoke failure.
|
||||
- DHRIO module's DH+ baud rate doesn't match the PLC-5's switch setting (57.6k
|
||||
/ 115.2k / 230.4k) — driver-side problem this can't paper over.
|
||||
- A scanner on the DHRIO is in scheduled-mode and starving unscheduled
|
||||
PCCC traffic — bump the DHRIO's unscheduled-message slice in RSLogix 5000.
|
||||
|
||||
## See also
|
||||
|
||||
- [`Driver.AbLegacy.Cli.md`](../Driver.AbLegacy.Cli.md) — the family / CIP-path
|
||||
cheat sheet now carries a DHRIO row.
|
||||
- [`drivers/AbLegacy-Test-Fixture.md`](AbLegacy-Test-Fixture.md) — DH+ bridging
|
||||
is unit-only; no Docker fixture supports it.
|
||||
@@ -0,0 +1,188 @@
|
||||
# AB Legacy diagnostic counters
|
||||
|
||||
Per-device diagnostic counters surface as auto-generated read-only OPC UA
|
||||
variables under each device's synthetic `_Diagnostics/` folder. HMIs can bind
|
||||
directly without going through a separate diagnostics RPC. Mirrors the AB CIP
|
||||
`_System/` pattern from PR abcip-4.3.
|
||||
|
||||
Closes #253 (PR ablegacy-10).
|
||||
|
||||
## The nine counters
|
||||
|
||||
Each device managed by the `AbLegacyDriver` exposes nine read-only nodes under
|
||||
`AbLegacy/<host>/_Diagnostics/<name>`. The first seven shipped in PR ablegacy-10;
|
||||
`DemoteCount` + `LastDemotedUtc` arrived with PR ablegacy-12 / #255 (auto-demote
|
||||
on comm failure).
|
||||
|
||||
| Name | Type | Semantics |
|
||||
|---|---|---|
|
||||
| `RequestCount` | Int64 | Total `ReadAsync` requests issued against this device. One increment per non-diagnostic reference per call, success or failure. |
|
||||
| `ResponseCount` | Int64 | Successful read responses. |
|
||||
| `ErrorCount` | Int64 | Failed read responses (any non-Good status). |
|
||||
| `RetryCount` | Int64 | Retry attempts beyond the first per the PR 9 retry loop. A single read with two retries adds two. |
|
||||
| `LastErrorCode` | Int32 | Most recent libplctag status code on a failed read; `0` when no error has been seen since the last reset. |
|
||||
| `LastErrorMessage` | String | Most recent libplctag error message on a failed read; empty when no error has been seen since the last reset. |
|
||||
| `CommFailures` | Int64 | Count of read failures mapped to `BadCommunicationError`. Spans transient libplctag throws + retried-out chains so operators see a single "wire fell off" counter. |
|
||||
| `DemoteCount` | Int64 | **PR ablegacy-12** — cumulative auto-demote events for this device. Bumps every time the driver crosses the consecutive-failure threshold and arms a fresh cool-down window. Cumulative across `ReinitializeAsync` (preserved through redeploys) so a flapping link surfaces as a steadily climbing counter. |
|
||||
| `LastDemotedUtc` | String | **PR ablegacy-12** — ISO-8601 UTC timestamp of the most recent auto-demotion. Empty string when this device has never been demoted. |
|
||||
|
||||
**Address shape**: `_Diagnostics/<deviceHostAddress>/<name>` —
|
||||
e.g. `_Diagnostics/ab://10.0.0.5/1,0/RequestCount`.
|
||||
|
||||
The `<deviceHostAddress>` segment is the canonical `ab://host[:port]/cip-path`
|
||||
string from `AbLegacyDeviceOptions.HostAddress`. The browse path looks like
|
||||
`AbLegacy/<deviceHostAddress>/_Diagnostics/<name>` — the same shape as a
|
||||
user-config tag node, just under a reserved sibling folder.
|
||||
|
||||
## Reset behaviour
|
||||
|
||||
| Trigger | Effect |
|
||||
|---|---|
|
||||
| `ReinitializeAsync` | Every counter for every device resets to zero, plus `LastErrorMessage` clears to empty. **PR ablegacy-12 exception:** `DemoteCount` + `LastDemotedUtc` survive the reinit so an operator redeploying mid-incident doesn't lose the flapping-link history. |
|
||||
| `ShutdownAsync` | All counters drop with the device map (including `DemoteCount`). |
|
||||
| Driver process restart | Counters start at zero. |
|
||||
| Probe transition Stopped→Running | **No automatic reset** — counters are cumulative across reconnect events so operators can spot intermittent links by watching `CommFailures` keep climbing. |
|
||||
| Probe transition Demoted→Running | **PR ablegacy-12** — early-clear of the active demote window, but the cumulative `DemoteCount` stays put. |
|
||||
|
||||
There is no in-process "reset" RPC at the time of writing. If you need to
|
||||
clear counters without a redeploy, kick a `ReinitializeAsync` from the Admin
|
||||
RPC surface — the driver re-EnsureDevice's each host so the freshly registered
|
||||
counters start at zero.
|
||||
|
||||
## What does *not* increment counters
|
||||
|
||||
Reads against `_Diagnostics/<host>/<name>` are **driver-local observability**,
|
||||
not field traffic — they short-circuit before the libplctag dispatch and do
|
||||
NOT increment `RequestCount` or any other counter. Otherwise a 1 Hz HMI poll
|
||||
of `RequestCount` would make the counter chase its own tail.
|
||||
|
||||
Writes against `_Diagnostics/*` are rejected with `BadNotWritable` because
|
||||
every diagnostic node is `SecurityClassification.ViewOnly` — a misbehaving
|
||||
SCADA template can't accidentally clobber the diagnostic surface.
|
||||
|
||||
## Collision with user tags
|
||||
|
||||
User-config tags must not shadow the seven reserved diagnostic names and
|
||||
must not live under the synthetic `_Diagnostics/` folder. Both shapes are
|
||||
rejected at `InitializeAsync` time with a clear `InvalidOperationException`:
|
||||
|
||||
- A tag named `RequestCount` (or any of the other six reserved names) is
|
||||
rejected because it would silently never resolve at read time — the
|
||||
diagnostics short-circuit wins.
|
||||
- A tag whose `Address` starts with `_Diagnostics/` is rejected because the
|
||||
whole prefix is owned by the auto-emitted counters.
|
||||
|
||||
Pick a different name (`SiteRequestCount`, `MachineRequestCount`) or a
|
||||
different address path (real PCCC files like `N7:0`).
|
||||
|
||||
## HMI binding examples
|
||||
|
||||
### OPC UA Client CLI
|
||||
|
||||
```powershell
|
||||
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- read `
|
||||
-u opc.tcp://localhost:4840 `
|
||||
-n "ns=2;s=AbLegacy/ab://10.0.0.5/1,0/_Diagnostics/RequestCount"
|
||||
```
|
||||
|
||||
### AB Legacy CLI (driver-direct, no OPC UA layer)
|
||||
|
||||
```powershell
|
||||
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli -- read `
|
||||
-g "ab://10.0.0.5/1,0" -P Slc500 `
|
||||
--address "_Diagnostics/RequestCount"
|
||||
```
|
||||
|
||||
The driver-direct path lets you sanity-check the counter without standing up
|
||||
an OPC UA server — useful when triaging a wire-level issue on the bench.
|
||||
|
||||
### Subscription pattern
|
||||
|
||||
Subscribe to all seven counters at a slow rate (e.g. 5–10 s) on a long-lived
|
||||
overview dashboard, plus a faster rate (1 s) on `LastErrorMessage` /
|
||||
`LastErrorCode` when actively debugging a flapping link. The diagnostics
|
||||
short-circuit makes every read O(1) — there's no penalty for fast polling
|
||||
of the counter itself, only the OPC UA subscription bookkeeping.
|
||||
|
||||
## Auto-demote on comm failure (PR ablegacy-12 / #255)
|
||||
|
||||
When a device fails N consecutive reads or probes the driver marks it
|
||||
**Demoted** for a configurable cool-down window. Reads against a demoted
|
||||
device short-circuit with `BadCommunicationError` *without invoking
|
||||
libplctag* — that's the whole point of the feature: one slow PLC sharing
|
||||
the driver thread can't starve faster peers reading from healthy hosts on
|
||||
the same `AbLegacyDriver` instance.
|
||||
|
||||
### Configuration
|
||||
|
||||
Per-device, optional. `null` keeps the documented defaults (auto-demote
|
||||
**enabled** with 3 failures / 30 s).
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"Devices": [
|
||||
{
|
||||
"HostAddress": "ab://10.0.0.5/1,0",
|
||||
"PlcFamily": "Slc500",
|
||||
"Demote": {
|
||||
"FailureThreshold": 3, // default 3
|
||||
"DemoteForMs": 30000, // default 30s
|
||||
"Enabled": true // default true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Knob | Default | Notes |
|
||||
|---|---|---|
|
||||
| `FailureThreshold` | `3` | Consecutive comm failures before the device is demoted. A successful read or probe resets the tally. Terminal failures (`BadNodeIdUnknown`, `BadTypeMismatch`, …) **do not count** — they're config / decoder mismatches, not field outages. |
|
||||
| `DemoteForMs` | `30000` (30s) | Cool-down window. Reads while this is active short-circuit; a successful probe clears it early. |
|
||||
| `Enabled` | `true` | Set to `false` to keep the diagnostic counters but skip the auto-throttle. The failure tally still ticks but never arms the cool-down. |
|
||||
|
||||
### Recovery
|
||||
|
||||
Three ways out of Demoted, in order of likelihood:
|
||||
|
||||
1. **Probe success** — the per-device probe loop (`Probe.Enabled = true`,
|
||||
default address `S:0`) is the fast path. The next probe iteration after
|
||||
demotion will exercise the wire; on success it clears
|
||||
`DemotedUntilUtc` immediately and transitions the host to `Running`.
|
||||
2. **Window expiry** — once `DemoteForMs` elapses the demote marker
|
||||
clears on the next read attempt. The read goes through; if it fails,
|
||||
the failure tally keeps counting from where it left off (so a
|
||||
permanently-down device re-arms the window after one more consecutive
|
||||
failure rather than having to repeat the full threshold).
|
||||
3. **`ReinitializeAsync`** — clears `ConsecutiveFailures` +
|
||||
`DemotedUntilUtc` outright. Cumulative `DemoteCount` survives.
|
||||
|
||||
### Observability
|
||||
|
||||
`DemoteCount` is the headline counter — it bumps once per demotion event,
|
||||
not per short-circuited read. A device that flaps every hour for a week
|
||||
shows `DemoteCount = ~168` on Friday afternoon, which is the operator
|
||||
signal you actually want.
|
||||
|
||||
`LastDemotedUtc` is the ISO-8601 UTC timestamp of the most recent
|
||||
demotion. Bind it on a per-device tile alongside `DemoteCount` for
|
||||
"flapping link" alerting.
|
||||
|
||||
### Host-state surface
|
||||
|
||||
A demoted device reports `HostState.Demoted` (new in PR ablegacy-12
|
||||
on `Core.Abstractions/IHostConnectivityProbe.cs`). Consumers that
|
||||
predate the new value (the central `HostStatusPublisher`) safely treat
|
||||
it as `Stopped` — no schema migration needed.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [`AbLegacyDiagnosticTags.cs`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDiagnosticTags.cs)
|
||||
— counter store + read short-circuit
|
||||
- [`AbLegacyDriver.cs`](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs)
|
||||
— increment sites in `ReadAsync`, discovery emission in `DiscoverAsync`,
|
||||
auto-demote bookkeeping in `RecordFailureAndMaybeDemote` + `ProbeLoopAsync`
|
||||
- [`AbLegacy-Test-Fixture.md`](AbLegacy-Test-Fixture.md) — `AbLegacyDiagnosticsTests`
|
||||
+ `AbLegacyAutoDemoteTests` + collision-rejection contract
|
||||
- [AB CIP `_System/` parallel](../../src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipSystemTagSource.cs)
|
||||
— same pattern with the CIP-specific six entries (incl. writeable
|
||||
`_RefreshTagDb` trigger)
|
||||
@@ -0,0 +1,163 @@
|
||||
# AB Legacy — RSLogix symbol & data-table import
|
||||
|
||||
ablegacy-11 / [#254](https://github.com/dohertj2/lmxopcua/issues/254) — bulk-import
|
||||
RSLogix 500 / 5 symbol exports into the AB Legacy driver. Saves operators from
|
||||
hand-typing every `N7:0` / `F8:12` / `B3:0/5` row of a several-hundred-tag PLC
|
||||
into `appsettings.json`.
|
||||
|
||||
## Supported formats — v1
|
||||
|
||||
| Format | Status | Notes |
|
||||
|---|---|---|
|
||||
| `.CSV` "Database Export" | **supported** | Header columns `Symbol,Address,Description,DataType,Scope`; quoted fields, doubled-quote escapes, comment lines (`;` / `#`) all honoured |
|
||||
| `.SLC` text export | **supported** | RSLogix 500's "Save As Text" emits the same column shape — point the importer at the file directly |
|
||||
| `.RSS` (RSLogix 500 binary project) | **out of scope** | Proprietary; no parser ships in libplctag or any community project. Export to CSV first |
|
||||
| `.RSP` (RSLogix 5 binary project) | **out of scope** | Same as `.RSS` |
|
||||
|
||||
The binary `.RSS` / `.RSP` non-goal isn't a "we don't have time" decision —
|
||||
Rockwell's binary format is undocumented + tied to RSLogix's internal page
|
||||
layout, and the only known parsers are commercial IDE plugins. v1 ships with
|
||||
text/CSV only and a clean abstraction (`IRsLogixImporter`) so a binary parser
|
||||
can slot in later without reshaping the call sites.
|
||||
|
||||
## CSV column reference
|
||||
|
||||
| Column | Required | Notes |
|
||||
|---|---|---|
|
||||
| `Symbol` | yes | OPC UA tag name. RSLogix symbols are already stable; the importer uses them verbatim |
|
||||
| `Address` | yes | PCCC address. File letter implies `DataType` (see below); the importer's resolution wins over the CSV's `DataType` column |
|
||||
| `Description` | no | Parsed but currently unused — `AbLegacyTagDefinition` has no `Description` field at the v2 schema layer (see [#248](https://github.com/dohertj2/lmxopcua/issues/248)). Held in the column contract for future schema bumps |
|
||||
| `DataType` | no | RSLogix-supplied (`INT` / `REAL` / `BOOL` / `TIMER` / …). Ignored at import time; the importer derives the type from the file letter |
|
||||
| `Scope` | no | `Global` (default when blank) or `Local:N` for ladder-file-N-scoped tags. Acts as a filter when `--scope` is set on the CLI |
|
||||
|
||||
### File-letter → `AbLegacyDataType` mapping
|
||||
|
||||
| Letter | Example | Maps to | Notes |
|
||||
|---|---|---|---|
|
||||
| `N` | `N7:0` | `Int` (signed 16-bit) | |
|
||||
| `F` | `F8:0` | `Float` (32-bit IEEE-754) | |
|
||||
| `B` | `B3:0/0` | `Bit` | Bit-within-word also forces Bit when `BitIndex` is set |
|
||||
| `L` | `L9:0` | `Long` (signed 32-bit) | SLC 5/05+ only |
|
||||
| `ST` | `ST10:0` | `String` | 82-byte fixed-length + length word |
|
||||
| `T` | `T4:0.ACC` | `TimerElement` | Sub-element implied by `.ACC` / `.PRE` / `.EN` / `.DN` |
|
||||
| `C` | `C5:0.ACC` | `CounterElement` | |
|
||||
| `R` | `R6:0.LEN` | `ControlElement` | |
|
||||
| `A` | `A14:0` | `AnalogInt` | Older hardware |
|
||||
| `I` / `O` / `S` | `I:0/0` | `Int` (or `Bit` with bit suffix) | I/O + status files |
|
||||
| `PD` / `MG` / `PLS` / `BT` | `PD9:0` | `PidElement` etc. | Family-gated; PD/MG common on SLC500 + PLC-5; PLS/BT PLC-5 only |
|
||||
| `RTC` / `HSC` / `DLS` / … | `RTC:0.YR` | `MicroLogixFunctionFile` | MicroLogix 1100 / 1400 only |
|
||||
|
||||
A bit suffix (`/N`) on any file letter forces `Bit`, regardless of the file
|
||||
letter's normal classification — `N7:0/3` parses as Bit, not Int.
|
||||
|
||||
## Scope filter
|
||||
|
||||
The `Scope` column distinguishes program-scoped tags (`Local:1`, `Local:2`, …)
|
||||
from globals. RSLogix exports usually mix both. The CLI's `--scope` flag (and
|
||||
`ImportOptions.ScopeFilter` at the API level) keeps only the rows whose
|
||||
`Scope` value matches case-insensitively; rows with no `Scope` column count as
|
||||
`Global`.
|
||||
|
||||
```powershell
|
||||
# Import only the Global symbols
|
||||
otopcua-ablegacy-cli import-rslogix `
|
||||
--file plc-export.csv `
|
||||
--device ab://192.168.1.20/1,0 `
|
||||
--scope Global
|
||||
|
||||
# Import only the file-2 program-scope tags
|
||||
otopcua-ablegacy-cli import-rslogix `
|
||||
--file plc-export.csv `
|
||||
--device ab://192.168.1.20/1,0 `
|
||||
--scope Local:2
|
||||
```
|
||||
|
||||
## CLI subcommand — `import-rslogix`
|
||||
|
||||
```powershell
|
||||
otopcua-ablegacy-cli import-rslogix --help
|
||||
```
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `-f` / `--file` | **required** | Path to the CSV export |
|
||||
| `-d` / `--device` | **required** | Canonical AB Legacy gateway URI every imported tag binds to |
|
||||
| `--emit` | `appsettings-fragment` | `appsettings-fragment` (JSON) or `summary` (one-line counter) |
|
||||
| `-o` / `--output` | stdout | Optional path; when set the JSON fragment is written there + summary line goes to stdout |
|
||||
| `--scope` | none | Optional Scope filter (case-insensitive) |
|
||||
| `--max-rows` | unlimited | Defensive cap on rows imported |
|
||||
| `--strict` | off | Fail-fast on the first malformed row (default permissive: skip + log) |
|
||||
|
||||
### `appsettings-fragment` output shape
|
||||
|
||||
The default `--emit appsettings-fragment` mode writes a JSON object whose
|
||||
`Tags` array is shaped like the `AbLegacyDriverConfigDto.Tags` array — paste
|
||||
straight into the driver-instance config under
|
||||
`Drivers/<instance>/Config/Tags`.
|
||||
|
||||
```json
|
||||
{
|
||||
"Tags": [
|
||||
{
|
||||
"Name": "MotorSpeed",
|
||||
"DeviceHostAddress": "ab://192.168.1.20/1,0",
|
||||
"Address": "N7:0",
|
||||
"DataType": "Int",
|
||||
"Writable": true
|
||||
},
|
||||
…
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Summary line
|
||||
|
||||
`--emit summary` writes a single line:
|
||||
|
||||
```
|
||||
Imported 142 tag(s), skipped 3, errors 0.
|
||||
```
|
||||
|
||||
`Skipped` covers Scope-filter rejections + missing-required-field rows; `errors`
|
||||
covers rows whose `Address` failed to parse as a PCCC address.
|
||||
|
||||
## API surface — `IRsLogixImporter` + `AddRsLogixImport`
|
||||
|
||||
For server-side / bootstrap use-cases the importer is also reachable via:
|
||||
|
||||
```csharp
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Import;
|
||||
|
||||
var options = new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://192.168.1.20/1,0")],
|
||||
};
|
||||
|
||||
// Append imported tags onto an existing options object.
|
||||
var updated = options.AddRsLogixImport(
|
||||
path: @"C:\plc\plc-export.csv",
|
||||
deviceHostAddress: "ab://192.168.1.20/1,0",
|
||||
out var result);
|
||||
|
||||
// result.ParsedCount / SkippedCount / ErrorCount surface the import telemetry.
|
||||
Console.WriteLine($"Imported {result.ParsedCount} tags");
|
||||
```
|
||||
|
||||
For a hand-managed importer instance (e.g. supplying a custom `ILogger`) call
|
||||
`new RsLogixSymbolImport(logger).Parse(stream, deviceHostAddress, opts)`
|
||||
directly.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- The importer is **additive** — `AddRsLogixImport` concatenates onto the
|
||||
existing `Tags` list rather than replacing it. Hand-rolled tags (system-status
|
||||
variables, computed fields the operator added by hand) survive a re-import.
|
||||
- Re-imports are not idempotent today — calling `AddRsLogixImport` twice will
|
||||
produce duplicate tag rows. Operators are expected to either start from a
|
||||
clean options object or de-duplicate themselves; a future schema rev may add
|
||||
a `replace=true` switch.
|
||||
- Description metadata is dropped on the floor — see the column reference
|
||||
above. When [#248](https://github.com/dohertj2/lmxopcua/issues/248) lands a
|
||||
`Description` field on `AbLegacyTagDefinition` the importer will start
|
||||
populating it without further changes to the CSV contract.
|
||||
@@ -36,6 +36,12 @@ supplies a `FakeAbLegacyTag`.
|
||||
|
||||
- `AbLegacyAddressTests` — PCCC address parsing for SLC / MicroLogix / PLC-5
|
||||
/ LogixPccc-mode (`N7:0`, `F8:12`, `B3:0/5`, etc.)
|
||||
- `AbLegacyArrayTests` — PR 7 array contiguous-block addressing: parser
|
||||
positives + rejects for `,N` / `[N]` suffixes, options-override
|
||||
(`ArrayLength`), driver `IsArray` discovery, and array decoding for N / F /
|
||||
L / B files (Rockwell convention: one BOOL per word for `B3:0,10`). Latency
|
||||
benchmark against the Docker fixture is a perf-flagged integration case in
|
||||
`AbLegacyArrayReadTests` — runs only when ab_server is reachable.
|
||||
- `AbLegacyCapabilityTests` — data type mapping, read-only enforcement
|
||||
- `AbLegacyReadWriteTests` — read + write happy + error paths against the fake
|
||||
- `AbLegacyBitRmwTests` — bit-within-DINT read-modify-write serialization via
|
||||
@@ -43,6 +49,63 @@ supplies a `FakeAbLegacyTag`.
|
||||
- `AbLegacyHostAndStatusTests` — probe + host-status transitions driven by
|
||||
fake-returned statuses
|
||||
- `AbLegacyDriverTests` — `IDriver` lifecycle
|
||||
- `AbLegacyDiagnosticsTests` — PR ablegacy-10 / #253 per-device diagnostic
|
||||
counters: 5 reads (3 ok / 2 fail) → `RequestCount=5`, `ResponseCount=3`,
|
||||
`ErrorCount=2`; `LastErrorCode` reflects the most recent libplctag status;
|
||||
`RetryCount` increments per retry attempt beyond the first; counters reset
|
||||
on `ReinitializeAsync`; discovery emits the canonical diagnostic variables
|
||||
per device under `_Diagnostics/` (now 9 with PR ablegacy-12); collision
|
||||
rejection at `InitializeAsync` for user tags shadowing reserved names or
|
||||
`_Diagnostics/` addresses; the `_Diagnostics/<host>/<name>` short-circuit
|
||||
returns the live snapshot through `ReadAsync` without bumping
|
||||
`RequestCount`; two devices keep counters independent.
|
||||
- `AbLegacyAutoDemoteTests` — **PR ablegacy-12 / #255** auto-demote on comm
|
||||
failure: 3 consecutive failures arm the demote window and surface
|
||||
`HostState.Demoted`; subsequent reads short-circuit with
|
||||
`BadCommunicationError` *without invoking libplctag* (verified via
|
||||
`factory.Tags["N7:0"].ReadCount` not advancing); successful read resets
|
||||
the consecutive-failure counter; failure-success-failure pattern doesn't
|
||||
cross the threshold; `DemoteCount` + `LastDemotedUtc` surface via
|
||||
`_Diagnostics/`; `Enabled=false` opts out (failures still count, demotion
|
||||
never fires); `ReinitializeAsync` clears the active window but preserves
|
||||
cumulative `DemoteCount`; cool-down expiry allows the next read through;
|
||||
two devices in one driver — one faulty, one healthy — proves the faulty
|
||||
side's demotion doesn't starve the healthy side; `BadNodeIdUnknown`
|
||||
(terminal) does not count toward the comm-failure tally; DTO JSON
|
||||
round-trip preserves `FailureThreshold` / `DemoteForMs` / `Enabled` at
|
||||
the per-device level; `HostState.Demoted` enum value is wired through
|
||||
`Core.Abstractions`. Companion integration test in
|
||||
`tests/.../IntegrationTests/AbLegacyAutoDemoteTests.cs` runs the
|
||||
two-device-one-unreachable scenario against a live ab_server fixture
|
||||
using `127.0.0.1:1` as the unreachable peer.
|
||||
- `RsLogixSymbolImportTests` — ablegacy-11 / #254 RSLogix CSV symbol-import parser:
|
||||
canonical 8-row CSV (one row per N/F/B/L/ST/T/C/R) → 8 typed
|
||||
`AbLegacyTagDefinition`s with the right `DataType`; header + comment-line
|
||||
(`;` / `#`) skipping; malformed-row → log warning + skip (`IgnoreInvalid=true`
|
||||
default) vs. `InvalidDataException` (`IgnoreInvalid=false`); empty stream →
|
||||
empty result; UTF-8 BOM survival; embedded comma in quoted Description;
|
||||
doubled-quote escape; `--scope` filter (Global vs. Local:N); `MaxRowsToImport`
|
||||
cap; missing required header column → `InvalidDataException` regardless of
|
||||
`IgnoreInvalid`; `TryResolveDataType` rejects garbage + bit-suffix overrides
|
||||
the file letter (`N7:0/3` → Bit).
|
||||
- `RsLogixSymbolImportGoldenTests` — golden-snapshot integration: loads
|
||||
`Fixtures/rslogix-canonical.csv` (8-row canonical export covering every v1
|
||||
file letter), serialises the resulting tag list, and compares to
|
||||
`Fixtures/rslogix-canonical-expected.json`. On mismatch the actual JSON is
|
||||
dumped to `%TEMP%/rslogix-canonical-actual.json` and the path printed in the
|
||||
failure message so the dev can `cp` the golden after reviewing the diff.
|
||||
- `AbLegacyDriverFactoryAddRsLogixImportTests` — covers the
|
||||
`AbLegacyDriverFactoryExtensions.AddRsLogixImport` extension method:
|
||||
appends imported tags onto an existing options object without dropping the
|
||||
hand-rolled tags or the device list; mutates by-copy (immutability
|
||||
guarantee); `AddRsLogixImportWithResult` tuple overload returns both the
|
||||
modified options and the import counters.
|
||||
- `AbLegacyDeadbandTests` — PR 8 per-tag deadband / change filter:
|
||||
absolute-only suppression sequence `[10.0, 10.5, 11.5, 11.6] -> [10.0, 11.5]`,
|
||||
percent-only suppression with a zero-prev short-circuit, both-set logical-OR
|
||||
semantics (Kepware), Boolean edge-only publish, string change-only publish,
|
||||
status-change always-publish, first-seen always-publish, ReinitializeAsync
|
||||
cache wipe, JSON DTO round-trip.
|
||||
|
||||
Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
|
||||
`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
|
||||
@@ -69,6 +132,17 @@ driver-side correctness depends on libplctag being correct.
|
||||
`IPerCallHostResolver` contract is verified; real PCCC wire routing across
|
||||
multiple gateways is not.
|
||||
|
||||
### 3a. DH+ via 1756-DHRIO bridging (PR ablegacy-13 / #256)
|
||||
|
||||
Unit-only — coverage lives in `AbLegacyDhPlusBridgingTests`. The CIP-path
|
||||
parser positive / negative cases (octal-station validation, slot bounds, port
|
||||
shape) and the PLC-5-only family guard at `InitializeAsync` are exercised
|
||||
against fakes. There is no Docker fixture for DH+ because libplctag's
|
||||
`ab_server` doesn't simulate the DHRIO + DH+ + PLC-5 stack — wire-level
|
||||
validation requires real hardware. See
|
||||
[`AbLegacy-DH-Bridging.md`](AbLegacy-DH-Bridging.md) for the manual smoke
|
||||
procedure.
|
||||
|
||||
### 4. Alarms / history
|
||||
|
||||
PCCC has no alarm object + no history object. Driver doesn't implement
|
||||
@@ -111,6 +185,33 @@ cover the common ones but uncommon ones (`R` counters, `S` status files,
|
||||
network; parts are end-of-life but still available. PLC-5 +
|
||||
LogixPccc-mode behaviour + DF1 serial need specific controllers.
|
||||
|
||||
## Per-device options (`AbLegacyDeviceOptions`)
|
||||
|
||||
Each entry in `AbLegacyDriverOptions.Devices` carries:
|
||||
|
||||
| Field | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `HostAddress` | string | required | `ab://host[:port]/cip-path` |
|
||||
| `PlcFamily` | enum | `Slc500` | Slc500 / MicroLogix / Plc5 / LogixPccc |
|
||||
| `DeviceName` | string | null | Friendly label used in browse + diagnostics |
|
||||
| `Timeout` | TimeSpan? | null → driver-wide default | **PR 9 / #252** — wins over the driver-wide `Timeout`. Mix-and-match: SLC 5/01 ≈ 5 s, SLC 5/05 ≈ 2 s, MicroLogix 1100 ≈ 3 s |
|
||||
| `Retries` | int? | null → driver-wide default → 0 | **PR 9 / #252** — retries on transient `BadCommunicationError`; terminal errors surface on the first attempt |
|
||||
|
||||
JSON shape (mirrored on `AbLegacyDeviceDto`):
|
||||
|
||||
```json
|
||||
{
|
||||
"HostAddress": "ab://192.168.1.10/1,0",
|
||||
"PlcFamily": "Slc500",
|
||||
"DeviceName": "slc-5-01-line-A",
|
||||
"TimeoutMs": 5000,
|
||||
"Retries": 1
|
||||
}
|
||||
```
|
||||
|
||||
Per-device overrides also flow into the probe loop — slow chassis won't be
|
||||
falsely marked Stopped just because the driver-wide probe timeout is tight.
|
||||
|
||||
## Key fixture / config files
|
||||
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyServerFixture.cs`
|
||||
@@ -124,5 +225,10 @@ cover the common ones but uncommon ones (`R` counters, `S` status files,
|
||||
— known-limitations write-up + resolution paths
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs` —
|
||||
in-process fake + factory
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/Fixtures/rslogix-canonical.csv`
|
||||
— ablegacy-11 / #254 8-row canonical RSLogix CSV symbol export, one row per
|
||||
v1 file letter (N/F/B/L/ST/T/C/R)
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/Fixtures/rslogix-canonical-expected.json`
|
||||
— golden snapshot the import tests compare against
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs` — scope remarks
|
||||
at the top of the file
|
||||
|
||||
@@ -38,6 +38,16 @@ quirk. UDT / alarm / quirk behavior is verified only by unit tests with
|
||||
- `--plc controllogix` and `--plc compactlogix` mode dispatch.
|
||||
- The skip-on-missing-binary behavior (`AbServerFactAttribute`) so a fresh
|
||||
clone without the simulator stays green.
|
||||
- **Symbolic vs Logical addressing wall-clock** (PR abcip-3.2,
|
||||
`AbCipAddressingModeBenchTests`) — both modes complete + emit timing.
|
||||
**Emulate-tier only**: `ab_server` does not currently honour the CIP Symbol
|
||||
Object class 0x6B `cip_addr` attribute that Logical mode sets, so on the
|
||||
fixture the two modes measure the same wire path. The bench scaffold
|
||||
asserts both complete + records timing for human inspection; the actual
|
||||
Symbolic-vs-Logical perf comparison requires a real ControlLogix /
|
||||
CompactLogix on the network. See
|
||||
[`docs/drivers/AbCip-Performance.md`](AbCip-Performance.md) §"Addressing
|
||||
mode" for the full caveat.
|
||||
|
||||
## What it does NOT cover
|
||||
|
||||
@@ -60,6 +70,19 @@ Unit coverage: `AbCipFetchUdtShapeTests`, `CipTemplateObjectDecoderTests`,
|
||||
`AbCipDriverWholeUdtReadTests` — all with golden Template-Object byte buffers
|
||||
+ offset-keyed `FakeAbCipTag` values.
|
||||
|
||||
PR abcip-3.3 layers a per-device **`ReadStrategy`** selector on top
|
||||
(`WholeUdt` / `MultiPacket` / `Auto`, see
|
||||
[`AbCip-Performance.md`](AbCip-Performance.md) §"Read strategy"). Strategy
|
||||
switching is planner-side: the dispatcher picks between
|
||||
`AbCipUdtReadPlanner` (whole-UDT) and `AbCipMultiPacketReadPlanner`
|
||||
(per-member, bundled per parent) per batch. The selector + per-batch Auto
|
||||
heuristic + family-compat fall-back + per-device dispatch counters are
|
||||
**unit-tested only** in `AbCipReadStrategyTests` — `ab_server` cannot host
|
||||
a 50-member UDT to exercise the sparse case the strategy is designed for,
|
||||
and the libplctag .NET wrapper (1.5.x) does not expose explicit
|
||||
Multi-Service-Packet bundling, so wire-level coverage stays Emulate-tier
|
||||
in `AbCipEmulateMultiPacketReadTests` (gated on `AB_SERVER_PROFILE=emulate`).
|
||||
|
||||
### 2. ALMD / ALMA alarm projection (#177)
|
||||
|
||||
Depends on the ALMD UDT shape, which `ab_server` cannot emulate. The
|
||||
@@ -96,6 +119,15 @@ value per PR 10, but `ab_server` accepts whatever the client asks for — the
|
||||
cap's correctness is trusted from its unit test, never stressed against a
|
||||
simulator that rejects oversized requests.
|
||||
|
||||
PR abcip-3.1 layers the **per-device `ConnectionSize` override** on top
|
||||
(`AbCipDeviceOptions.ConnectionSize`, range `[500..4002]`, see
|
||||
[`AbCip-Performance.md`](AbCip-Performance.md)). Same gap — `ab_server`
|
||||
happily honours an oversized override against the CompactLogix profile, so
|
||||
the legacy-firmware warning + Forward Open rejection that real 5069-L1/L2/L3
|
||||
parts emit are unit-tested only. Live coverage stays Emulate / rig-only
|
||||
(connect against a real CompactLogix L2 with `ConnectionSize=1500` to
|
||||
confirm the Forward Open fails with CIP error 0x01/0x113).
|
||||
|
||||
### 6. BOOL-within-DINT read-modify-write (#181)
|
||||
|
||||
The `AbCipDriver.WriteBitInDIntAsync` RMW path + its per-parent `SemaphoreSlim`
|
||||
@@ -107,14 +139,48 @@ the RMW path is not exercised end-to-end.
|
||||
|
||||
No smoke test for:
|
||||
|
||||
- `IWritable.WriteAsync`
|
||||
- `IWritable.WriteAsync` — atomic write coverage; PR abcip-4.2 added a
|
||||
multi-write *suppression* smoke (jittery 5-write sequence with
|
||||
`WriteDeadband: 1.0` against `ab_server`, asserting the driver's
|
||||
diagnostics counter matches the expected suppression count) but pure
|
||||
atomic-write coverage end-to-end is still unit-only.
|
||||
- `ITagDiscovery.DiscoverAsync` (`@tags` walker)
|
||||
- `ISubscribable.SubscribeAsync` (poll-group engine)
|
||||
- `IHostConnectivityProbe` state transitions under wire failure
|
||||
- ~~`IHostConnectivityProbe` state transitions under wire failure~~ —
|
||||
covered as of PR abcip-4.3. `AbCipSystemTagDiscoveryTests` connects to
|
||||
`ab_server`, drives the discovery + read path against the synthetic
|
||||
`_System/_ConnectionStatus` variable, and asserts the live snapshot
|
||||
reflects the probe-driven `HostState`. Wire-failure transitions still
|
||||
rely on unit-level `ThrowOnRead` injection rather than a real wire pull,
|
||||
but the end-to-end probe → snapshot → OPC UA address-space link is
|
||||
exercised against `ab_server`.
|
||||
- `IPerCallHostResolver` multi-device routing
|
||||
|
||||
The driver implements all of these + they have unit coverage, but the only
|
||||
end-to-end path `ab_server` validates today is atomic `ReadAsync`.
|
||||
end-to-end paths `ab_server` validates today are atomic `ReadAsync` and
|
||||
write-deadband / write-on-change suppression.
|
||||
|
||||
### 8. ControlLogix HSBY paired-IP role probing (PR abcip-5.1)
|
||||
|
||||
`ab_server` has no second-chassis concept and no `WallClockTime.SyncStatus`
|
||||
tag. The HSBY paired-IP role-prober (PR abcip-5.1) is unit-tested only —
|
||||
`AbCipHsbyTests` drives two fake runtimes (primary + partner), pins each
|
||||
chassis's role-tag value, and asserts the active-resolution rules + DTO
|
||||
round-trip + diagnostics surface.
|
||||
|
||||
The `paired` Docker compose profile spins up two `ab_server` instances +
|
||||
a stub `hsby-mux` sidecar so the topology is documented, but PR 5.2 follow-
|
||||
up needs a patched `ab_server` image (or a Python shim) that actually
|
||||
serves the role tag before the integration test
|
||||
(`AbCipHsbyRoleProberTests`) can flip its `Assert.Skip` into a real wire
|
||||
assertion. Until then the test is gated on `Category=Hsby` + skipped by
|
||||
default.
|
||||
|
||||
Lab-rig coverage is the authoritative path — a real 1756-RM redundant
|
||||
chassis pair is the only place the live `WallClockTime.SyncStatus` matrix
|
||||
+ split-brain handling can be exercised end-to-end. See
|
||||
[`AbCip-HSBY.md`](AbCip-HSBY.md) for the full configuration + role-tag
|
||||
detection matrix.
|
||||
|
||||
## Logix Emulate golden-box tier
|
||||
|
||||
|
||||
@@ -106,6 +106,42 @@ Tier-C pipeline end-to-end without any CNC.
|
||||
| "Does `Fwlib32.dll` crash on concurrent reads?" | no | yes (stress) |
|
||||
| "Do macro variables round-trip across power cycles?" | no | yes (required) |
|
||||
|
||||
## Alarm history (`cnc_rdalmhistry`) — issue #267, plan PR F3-a
|
||||
|
||||
`FocasAlarmProjection` ships two modes:
|
||||
|
||||
- **`ActiveOnly`** (default) — surfaces only currently-active alarms.
|
||||
No history poll. Same back-compat shape every prior FOCAS deployment used.
|
||||
- **`ActivePlusHistory`** — additionally polls `cnc_rdalmhistry` on connect
|
||||
+ on the configured cadence (`HistoryPollInterval`, default 5 min). Each
|
||||
unseen entry fires an `OnAlarmEvent` with `SourceTimestampUtc` set from
|
||||
the CNC's reported timestamp, not Now.
|
||||
|
||||
Unit-test coverage in `FocasAlarmProjectionTests`:
|
||||
|
||||
- mode `ActiveOnly` — no `ReadAlarmHistoryAsync` call ever issued
|
||||
- mode `ActivePlusHistory` — first poll fires on subscribe (== "on connect")
|
||||
- dedup — same `(OccurrenceTime, AlarmNumber, AlarmType)` triple across two
|
||||
polls only emits once
|
||||
- distinct entries with different timestamps each emit separately
|
||||
- same alarm number / different type still emits both (type is part of the
|
||||
dedup key)
|
||||
- `OccurrenceTime` is the wire timestamp (round-trips a year-old stamp
|
||||
without bleeding into Now)
|
||||
- `HistoryDepth` clamp — user-supplied 500 collapses to 250 on the wire;
|
||||
zero / negative falls back to the 100 default
|
||||
- `FocasAlarmHistoryDecoder` — round-trips through `Encode` / `Decode` and
|
||||
pins the simulator command id at `0x0F1A`
|
||||
|
||||
Future integration coverage (not yet shipped — no FOCAS integration test
|
||||
project exists):
|
||||
|
||||
- a focas-mock with a per-profile ring buffer and `mock_patch_alarmhistory`
|
||||
admin endpoint will let `cnc_rdalmhistry` round-trip end-to-end through
|
||||
the wire protocol
|
||||
- `FocasSimFixture.SeedAlarmHistoryAsync` will let series tests prime canned
|
||||
history without per-test JSON
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
1. **Nothing public** — Fanuc's FOCAS Developer Kit ships an emulator DLL
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
# FOCAS driver
|
||||
|
||||
Fanuc CNC driver for the FS 0i / 16i / 18i / 21i / 30i / 31i / 32i / 35i /
|
||||
Power Mate i families. Talks to the controller via the licensed
|
||||
`Fwlib32.dll` (Tier C, process-isolated per
|
||||
[`docs/v2/driver-stability.md`](../v2/driver-stability.md)).
|
||||
|
||||
For range-validation and per-series capability surface see
|
||||
[`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md).
|
||||
|
||||
## Fixed-tree `Production/` projection — issue #258 (F1-b) + issue #272 (F5-a)
|
||||
|
||||
Per-device read-only nodes refreshed from the same `cnc_rdparam` /
|
||||
cycle-timer poll the probe loop already runs. No additional wire calls
|
||||
are issued for any of these — they are all cache-or-derive reads.
|
||||
|
||||
| Node | DataType | Source | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `Production/PartsProduced` | `Int32` | `cnc_rdparam(6711)` | Active parts-count counter. Wraps to 0 on operator reset. |
|
||||
| `Production/PartsRequired` | `Int32` | `cnc_rdparam(6712)` | Operator-set target. |
|
||||
| `Production/PartsTotal` | `Int32` | `cnc_rdparam(6713)` | Lifetime parts counter. |
|
||||
| `Production/CycleTimeSeconds` | `Int32` | `cnc_rdtimer` (channel 0) | Live cycle-time accumulator. Resets to 0 on next cycle start (CNC-side behaviour). |
|
||||
| **`Production/LastCycleSeconds`** | **`Float64`** | **derived** | **Plan PR F5-a — seconds for the most recently completed cycle, computed as `CycleTimeSeconds(now) - CycleTimeSeconds(at previous parts-count increment)`. `null` until the second observed parts-count increment establishes a delta. Pure derivation, no new wire calls. See edge-case rules below.** |
|
||||
| **`Production/LastCycleStartUtc`** | **`DateTime`** *(UTC)* | **derived** | **Plan PR F5-a — UTC wall-clock of the most-recent cycle's start, computed as `nowUtc - LastCycleSeconds`. `null` alongside `LastCycleSeconds` until the second observed increment.** |
|
||||
|
||||
### F5-a derivation edge-case rules
|
||||
|
||||
- **First observation** establishes the baseline; `LastCycleSeconds` /
|
||||
`LastCycleStartUtc` stay `null` until the second observed parts-count
|
||||
increment produces the first delta.
|
||||
- **Parts-count counter reset** (current value goes backwards, e.g.
|
||||
shift-change zero) **preserves the last published values** so an
|
||||
operator reading the tag mid-shift-change sees the last known cycle
|
||||
duration rather than `null` / Bad. The next positive transition
|
||||
produces a fresh delta from the new baseline.
|
||||
- **Cycle-timer rollover** (delta would be negative — e.g. CNC zeroes
|
||||
the cycle timer at part completion) **leaves the previously-published
|
||||
values unchanged for one tick** and re-baselines so the next
|
||||
increment produces a clean delta. The driver does NOT publish a
|
||||
negative `LastCycleSeconds`.
|
||||
- **Parts-count jumps `> 1`** (backfill — e.g. counter increments by
|
||||
3 at once) publish the **timer delta over the window** as
|
||||
`LastCycleSeconds`. The plan's "delta over the window between
|
||||
successive parts-count increments" definition does not divide by the
|
||||
count delta; the value reflects the actual elapsed timer between the
|
||||
two observations.
|
||||
- **Reconnect / reinit** clears the derivation state — the prior CNC
|
||||
session's cycle-timer + parts-count snapshots may be invalidated by
|
||||
the FWLIB session boundary, so the next post-reconnect probe tick
|
||||
re-establishes the baseline before the next delta publishes.
|
||||
|
||||
## Alarm history (`cnc_rdalmhistry`) — issue #267, plan PR F3-a
|
||||
|
||||
`FocasAlarmProjection` exposes two modes via `FocasDriverOptions.AlarmProjection`:
|
||||
|
||||
| Mode | Behaviour |
|
||||
| --- | --- |
|
||||
| `ActiveOnly` *(default)* | Subscribe / unsubscribe / acknowledge wire up so capability negotiation works, but no history poll runs. Back-compat with every pre-F3-a deployment. |
|
||||
| `ActivePlusHistory` | On subscribe (== "on connect") and on every `HistoryPollInterval` tick, the projection issues `cnc_rdalmhistry` for the most recent `HistoryDepth` entries. Each previously-unseen entry fires an `OnAlarmEvent` with `SourceTimestampUtc` set from the CNC's reported timestamp — OPC UA dashboards see the real occurrence time, not the moment the projection polled. |
|
||||
|
||||
### Config knobs
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"AlarmProjection": {
|
||||
"Mode": "ActivePlusHistory", // "ActiveOnly" (default) | "ActivePlusHistory"
|
||||
"HistoryPollInterval": "00:05:00", // default 5 min
|
||||
"HistoryDepth": 100 // default 100, capped at 250
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dedup key
|
||||
|
||||
`(OccurrenceTime, AlarmNumber, AlarmType)`. The same triple across two
|
||||
polls only emits once. The dedup set is in-memory and **resets on
|
||||
reconnect** — first poll after reconnect re-emits everything in the ring
|
||||
buffer. OPC UA clients that need exactly-once semantics dedupe client-side
|
||||
on the same triple (the timestamp + type + number tuple is stable across
|
||||
the boundary).
|
||||
|
||||
### `HistoryDepth` cap
|
||||
|
||||
Capped at `FocasAlarmProjectionOptions.MaxHistoryDepth = 250` so an
|
||||
operator who types `10000` by accident can't blast the wire session with a
|
||||
giant request. Typical FANUC ring buffers cap at ~100 entries; the default
|
||||
`HistoryDepth = 100` matches the most common ring-buffer size.
|
||||
|
||||
### Wire surface
|
||||
|
||||
- Wire-protocol command id: `0x0F1A` (see
|
||||
[`docs/v2/implementation/focas-wire-protocol.md`](../v2/implementation/focas-wire-protocol.md)).
|
||||
- ODBALMHIS struct decoder: `Wire/FocasAlarmHistoryDecoder.cs`.
|
||||
- Tier-C Fwlib32 backend short-circuits the packed-buffer decoder by
|
||||
surfacing the FWLIB struct fields directly into
|
||||
`FocasAlarmHistoryEntry`.
|
||||
|
||||
## Writes (opt-in, off by default) — issue #268 (F4-a) + #269 (F4-b) + #270 (F4-c)
|
||||
|
||||
Writes ship behind multiple independent opt-ins. All default off so a freshly
|
||||
deployed FOCAS driver is read-only until the deployment makes a deliberate
|
||||
choice. Decision record: [`docs/v2/decisions.md`](../v2/decisions.md) →
|
||||
"FOCAS write-path opt-in".
|
||||
|
||||
| Knob | Default | Effect when off |
|
||||
| --- | --- | --- |
|
||||
| `FocasDriverOptions.Writes.Enabled` *(driver-level master switch)* | `false` | Every entry in a `WriteAsync` batch short-circuits to `BadNotWritable` with status text `writes disabled at driver level`. Wire client never gets touched. |
|
||||
| **`FocasDriverOptions.Writes.AllowParameter`** *(F4-b granular kill switch)* | **`false`** | **`PARAM:` writes return `BadNotWritable` with no wire client constructed. Defense in depth — even if `Enabled = true` an operator must explicitly opt into parameter writes per kind because a misdirected `cnc_wrparam` can put the CNC in a bad state.** |
|
||||
| **`FocasDriverOptions.Writes.AllowMacro`** *(F4-b granular kill switch)* | **`false`** | **`MACRO:` writes return `BadNotWritable` with no wire client constructed. Macro writes are the normal HMI-driven recipe / setpoint surface; gating them separately from `AllowParameter` lets a deployment open MACRO without exposing the heavier PARAM write surface.** |
|
||||
| **`FocasDriverOptions.Writes.AllowPmc`** *(F4-c granular kill switch)* | **`false`** | **PMC writes (R/G/F/D/X/Y/K/A/E/T/C letters, both Bit and Byte) return `BadNotWritable` with no wire client constructed. PMC is ladder working memory — a mistargeted bit can move motion, latch a feedhold, or flip a safety interlock, so PMC writes are gated separately from PARAM/MACRO so an operator team can open PARAM (commissioning) without exposing the much higher-blast-radius PMC surface.** |
|
||||
| `FocasTagDefinition.Writable` *(per-tag opt-in)* | `false` | The per-tag check returns `BadNotWritable` for that tag even when the driver-level flags are on. |
|
||||
|
||||
> **PMC SAFETY CALLOUT** — PMC is the FANUC ladder's working memory. A
|
||||
> mistargeted bit can move motion (a Y-coil writing to a servo enable),
|
||||
> latch a feedhold (an internal R-relay the ladder ANDs with cycle-start),
|
||||
> or flip a safety interlock (an X-input shadow). **Treat PMC writes the
|
||||
> same way you'd treat editing a live ladder:** verify e-stop is live and
|
||||
> the machine is in jog mode before issuing the first write of a session.
|
||||
> The driver gates these writes behind THREE independent opt-ins
|
||||
> (`Writes.Enabled` + `Writes.AllowPmc` + per-tag `Writable`) precisely
|
||||
> because the blast radius is higher than parameter writes.
|
||||
|
||||
### PMC bit-write read-modify-write semantics — F4-c
|
||||
|
||||
The FOCAS wire call `pmc_wrpmcrng` is **byte-addressed** — there is no
|
||||
sub-byte write primitive. When the driver receives a write request on a
|
||||
`Bit` tag (e.g. `R100.3`), it:
|
||||
|
||||
1. Reads the parent byte via `pmc_rdpmcrng` (1 byte at `R100`).
|
||||
2. Masks the target bit (set: `current | (1 << bit)`; clear: `current & ~(1 << bit)`).
|
||||
3. Writes the modified byte back via `pmc_wrpmcrng` (1 byte at `R100`).
|
||||
|
||||
A **per-byte semaphore** serialises concurrent bit writes against the same
|
||||
byte so two updates that race never lose one another's bit. RMW means **a
|
||||
PMC bit write reads first, then writes back the whole byte** — if the ladder
|
||||
is also writing to that byte at the same instant, there is a small window
|
||||
where the driver's value can clobber the ladder's. Operators who care about
|
||||
this race must coordinate the write through a ladder-side handshake (e.g.
|
||||
the operator sets a request bit, the ladder reads + clears it).
|
||||
|
||||
### Config shape — F4-c
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"Writes": {
|
||||
"Enabled": true,
|
||||
"AllowParameter": true, // F4-b — opt into cnc_wrparam
|
||||
"AllowMacro": true, // F4-b — opt into cnc_wrmacro
|
||||
"AllowPmc": true // F4-c — opt into pmc_wrpmcrng (incl. RMW bit writes)
|
||||
},
|
||||
"Tags": [
|
||||
{ "Name": "RPM", "Address": "PARAM:1815", "DataType": "Int32",
|
||||
"Writable": true, "WriteIdempotent": false },
|
||||
{ "Name": "Recipe", "Address": "MACRO:500", "DataType": "Int32",
|
||||
"Writable": true, "WriteIdempotent": false },
|
||||
{ "Name": "StartFlag", "Address": "R100.3", "DataType": "Bit",
|
||||
"Writable": true, "WriteIdempotent": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Server-layer ACL (LDAP groups)
|
||||
|
||||
Per the [`docs/v2/acl-design.md`](../v2/acl-design.md) tier model, the FOCAS
|
||||
driver only declares per-tag `SecurityClassification`; `DriverNodeManager`
|
||||
applies the gate. The classification post-F4-b is:
|
||||
|
||||
| Tag kind | Classification | LDAP group required (default mapping) |
|
||||
| --- | --- | --- |
|
||||
| `PARAM:N` writable | `Configure` | **`WriteConfigure`** |
|
||||
| `MACRO:N` writable | `Operate` | `WriteOperate` |
|
||||
| Other writable (PMC R/G/F/...) | `Operate` | `WriteOperate` |
|
||||
| Non-writable | `ViewOnly` | (no write permission) |
|
||||
|
||||
Parameter writes need the heavier `WriteConfigure` group because they're
|
||||
mostly emergency commissioning territory; macro writes use `WriteOperate`
|
||||
because they're the normal HMI recipe surface. The driver-level
|
||||
`AllowParameter` / `AllowMacro` kill switches sit independently of ACL — an
|
||||
operator-team kill switch the deployment can flip without redeploying ACL
|
||||
group memberships. See [`docs/security.md`](../security.md) for the full
|
||||
group/permission map.
|
||||
|
||||
`WriteIdempotent` is plumbed through Polly retry by the server-layer
|
||||
`CapabilityInvoker.ExecuteWriteAsync`. When `false` (default), failed writes
|
||||
are NOT auto-retried per plan decisions #44/#45 — a timeout that fires after
|
||||
the CNC already accepted the write would otherwise risk a duplicate
|
||||
non-idempotent action (alarm acks, M-code pulses, recipe steps). Flip
|
||||
`WriteIdempotent` on per tag for genuinely-idempotent writes (a parameter
|
||||
value that the operator simply wants forced to a target).
|
||||
|
||||
### FOCAS password — issue #271 (F4-d)
|
||||
|
||||
Some controllers — notably 16i and certain 30i firmwares with the
|
||||
parameter-protect switch on — gate `cnc_wrparam` and a handful of reads
|
||||
behind a connection-level password. Without unlocking the session, every
|
||||
gated wire call returns `EW_PASSWD`, which the F4-b mapping surfaces as
|
||||
`BadUserAccessDenied`.
|
||||
|
||||
`FocasDeviceOptions.Password` plumbs the password through the device config:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"Devices": [
|
||||
{
|
||||
"HostAddress": "focas://10.0.0.5:8193",
|
||||
"Password": "1234" // F4-d — optional CNC password
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
When set, the driver:
|
||||
|
||||
1. **On connect**, calls `IFocasClient.UnlockAsync(password, ct)` after
|
||||
the FWLIB handle opens but before any read/write fires. The FWLIB-backed
|
||||
client emits `cnc_wrunlockparam` with the password ASCII-encoded into
|
||||
the 4-byte FOCAS password slot (right-padded with `0x00`, truncated at
|
||||
4 bytes — that's the shape the public Fanuc samples document).
|
||||
2. **On `BadUserAccessDenied` from any gated read or write**, re-issues
|
||||
`UnlockAsync` and retries the call **exactly once**. A second
|
||||
`EW_PASSWD` propagates unchanged so a wrong password doesn't loop
|
||||
forever on the wire.
|
||||
3. **Reset on reconnect** — FWLIB unlock state lives on the handle, so
|
||||
any reconnect path (planned or unplanned) re-runs unlock automatically
|
||||
via `EnsureConnectedAsync`.
|
||||
|
||||
**No-log invariant.** The password is a secret. The driver MUST NOT log
|
||||
it. Specifically:
|
||||
|
||||
- `FocasDeviceOptions` overrides the record's auto-generated `ToString`
|
||||
to print `Password = ***` when the field is non-null. Any Serilog
|
||||
destructure that flows the device options through `{Device}` gets the
|
||||
redaction for free.
|
||||
- `FwlibFocasClient.UnlockAsync` does not include the password in any
|
||||
exception message — only the FWLIB return code (`EW_PASSWD`,
|
||||
`EW_HANDLE`, etc.) makes it into the surface.
|
||||
- `FocasDriver` logs only `"FOCAS unlock applied for {host}"` when the
|
||||
unlock succeeds — no password.
|
||||
- The Driver.FOCAS.Cli `--cnc-password` flag is also redacted at the
|
||||
same `FocasDeviceOptions` choke point.
|
||||
- See [`docs/v2/focas-deployment.md`](../v2/focas-deployment.md)
|
||||
§ "FOCAS password handling" for the storage/rotation runbook + the
|
||||
cross-link to [`docs/Security.md`](../Security.md).
|
||||
|
||||
When the controller does **not** need a password, leave `Password`
|
||||
unset (`null`) and the driver short-circuits the unlock call entirely —
|
||||
no wire-level cost.
|
||||
|
||||
### Status-code semantics post-F4-b
|
||||
|
||||
- `BadNotWritable` — one of: driver-level `Writes.Enabled = false`; per-tag
|
||||
`Writable = false`; **`Writes.AllowParameter = false` for a `PARAM:` tag
|
||||
(F4-b)**; **`Writes.AllowMacro = false` for a `MACRO:` tag (F4-b)**;
|
||||
**`Writes.AllowPmc = false` for a PMC tag (F4-c)**. Same status code,
|
||||
five distinct paths — operators distinguish by checking the knobs.
|
||||
- `BadUserAccessDenied` — **F4-b** — the CNC reported `EW_PASSWD`
|
||||
(parameter-write switch off / unlock required). **F4-d** wires the
|
||||
`cnc_wrunlockparam` retry path on top: when `Password` is configured
|
||||
the driver re-issues unlock + retries the gated call once before
|
||||
surfacing this status. A persistent `BadUserAccessDenied` after F4-d
|
||||
means either (a) the password doesn't match the controller, or (b)
|
||||
the parameter-write switch on the pendant is still off and the
|
||||
controller wants both the switch + the password.
|
||||
- `BadNotSupported` — both opt-ins flipped on, but the wire client doesn't
|
||||
implement the kind being written (e.g. older transport variant). F4-a
|
||||
wired the generic dispatch; F4-b adds typed `WriteParameterAsync` /
|
||||
`WriteMacroAsync` entry points whose default impls return
|
||||
`BadNotSupported` so transports compiled against a stale `IFocasClient`
|
||||
surface still build.
|
||||
- `BadNodeIdUnknown` — full-reference doesn't match any configured
|
||||
`FocasTagDefinition.Name`.
|
||||
- `BadCommunicationError` — wire failure (DLL not loaded, IPC peer dead,
|
||||
etc.).
|
||||
|
||||
### CLI bypass
|
||||
|
||||
`otopcua-focas-cli write` ([`docs/Driver.FOCAS.Cli.md`](../Driver.FOCAS.Cli.md))
|
||||
sets `Writes.Enabled=true` locally for the lifetime of one invocation
|
||||
because the CLI is a per-operator tool — not a long-lived process bound to
|
||||
the central config DB. The server-side flag is untouched; configure-the-
|
||||
server code paths remain safer-by-default.
|
||||
@@ -47,6 +47,13 @@ the tests mock.
|
||||
- `OpcUaClientSmokeTests.Client_subscribe_receives_StepUp_data_changes_from_live_server` —
|
||||
real `MonitoredItem` subscription against `ns=3;s=FastUInt1` (ticks every
|
||||
100 ms); asserts `OnDataChange` fires within 3 s of subscribe
|
||||
- `OpcUaClientReverseConnectSmokeTests.Driver_accepts_reverse_connect_from_opc_plc_rc_simulator` —
|
||||
reverse-connect (server-initiated) coverage. Driver binds
|
||||
`opc.tcp://0.0.0.0:4844`, the `opc-plc-rc` docker service dials in via
|
||||
`--rc opc.tcp://host.docker.internal:4844`, and a Read round-trips over
|
||||
the inbound socket. Gated on `OPCUA_RC_SIM=1` because the simulator
|
||||
requires `host.docker.internal` resolution which not every CI runner
|
||||
exposes.
|
||||
|
||||
Wire-level surfaces verified: `IDriver` + `IReadable` + `ISubscribable` +
|
||||
`IHostConnectivityProbe` (via the Secure Channel exchange).
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# OPC UA Client driver
|
||||
|
||||
Tier-A in-process driver that opens a `Session` against a remote OPC UA server
|
||||
and re-exposes its address space through the local OtOpcUa server. The
|
||||
"gateway / aggregation" direction — opposite to the usual "server exposes PLC
|
||||
data" flow.
|
||||
|
||||
For the test fixture (opc-plc) see [`OpcUaClient-Test-Fixture.md`](OpcUaClient-Test-Fixture.md).
|
||||
For the configuration surface see `OpcUaClientDriverOptions` in
|
||||
[`src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs`](../../src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverOptions.cs).
|
||||
|
||||
## Auto re-import on `ModelChangeEvent`
|
||||
|
||||
The driver subscribes to `BaseModelChangeEventType` (and its subtype
|
||||
`GeneralModelChangeEventType`) on the upstream `Server` node (`i=2253`) at
|
||||
the end of `InitializeAsync`. When the upstream server advertises a
|
||||
topology change, the driver coalesces events over a debounce window and
|
||||
runs a single re-import (equivalent to calling `ReinitializeAsync` —
|
||||
internally `ShutdownAsync` + `InitializeAsync`).
|
||||
|
||||
### Configuration
|
||||
|
||||
| Option | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `WatchModelChanges` | `true` | Disable to skip the watch entirely (no extra subscription, no re-import on topology change). |
|
||||
| `ModelChangeDebounce` | `5s` | Coalescing window. The first event starts the timer; further events extend it; when it elapses with no new events, the driver fires one re-import. |
|
||||
|
||||
### Behaviour
|
||||
|
||||
- One model-change subscription per driver instance, separate from the
|
||||
data + alarm subscriptions. Created best-effort: a server that doesn't
|
||||
advertise the event types or rejects the `EventFilter` falls through to
|
||||
no-watch — `InitializeAsync` still succeeds.
|
||||
- The `EventFilter` selects only the `EventType` field (a `WhereClause`
|
||||
constrains by `OfType BaseModelChangeEventType`). Payload fields like
|
||||
`Changes[]` are intentionally ignored: the driver always re-imports the
|
||||
full upstream root, so per-event delta tracking would just add wire
|
||||
overhead.
|
||||
- Debounce is implemented via a single-shot `Timer`; every event calls
|
||||
`Timer.Change(window, Infinite)` so a burst of N events triggers exactly
|
||||
one re-import after the window elapses with no further events.
|
||||
- The re-import path acquires the same `_gate` semaphore that `ReadAsync`
|
||||
/ `WriteAsync` / `BrowseAsync` / `SubscribeAsync` use. Downstream callers
|
||||
see a brief browse-gap (≈ the upstream `DiscoverAsync` duration) while
|
||||
the gate is held — but no torn reads or split-batch writes.
|
||||
- Failure during the re-import is best-effort: the next `ModelChangeEvent`
|
||||
triggers another attempt, and the keep-alive watchdog covers permanent
|
||||
upstream loss. Operators see failures through `DriverHealth.LastError`
|
||||
+ the diagnostics counters.
|
||||
|
||||
### When to disable
|
||||
|
||||
Flip `WatchModelChanges` to `false` when:
|
||||
|
||||
- The upstream topology is known-static (e.g. firmware-pinned PLC) and
|
||||
the driver should never run a re-import unprompted.
|
||||
- The brief browse-gap during re-import is unacceptable and a manual
|
||||
`ReinitializeAsync` call from the operator is preferred.
|
||||
- The upstream server fires spurious `ModelChangeEvent`s that don't
|
||||
reflect real topology changes, causing wasted re-imports. Tighten or
|
||||
disable rather than chasing the noise downstream.
|
||||
|
||||
## Reverse Connect (server-initiated)
|
||||
|
||||
OPC UA's reverse-connect mode flips the transport direction: instead of the
|
||||
client dialling the server, the **server** dials the client's listener. The
|
||||
upstream sends a `ReverseHello` and the client continues the OPC UA
|
||||
handshake on the inbound socket. Required for OT-DMZ deployments where the
|
||||
plant firewall only permits outbound traffic from the upstream — the
|
||||
gateway opens a listener, the upstream reaches out.
|
||||
|
||||
### Configuration
|
||||
|
||||
| Option | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `ReverseConnect.Enabled` | `false` | Opt-in. When `true`, replaces the failover dial-sweep with a `WaitForConnection` call. |
|
||||
| `ReverseConnect.ListenerUrl` | `null` | Local listener URL the SDK binds. Typically `opc.tcp://0.0.0.0:4844` (any interface) or a specific NIC for multi-homed gateways. **Required when `Enabled` is `true`.** |
|
||||
| `ReverseConnect.ExpectedServerUri` | `null` | Upstream's `ApplicationUri` to filter inbound dials. `null` accepts the first connection (only safe with one upstream targeting the listener). |
|
||||
|
||||
### Shared listener (singleton)
|
||||
|
||||
A single underlying `Opc.Ua.Client.ReverseConnectManager` per process keyed
|
||||
on `ListenerUrl`. Two driver instances that share a listener URL multiplex
|
||||
onto one TCP socket; the SDK demuxes inbound dials by the upstream's
|
||||
reported `ServerUri`. The wrapper (`ReverseConnectListener`) is
|
||||
reference-counted — first `Acquire` binds the port, last `Release` tears it
|
||||
down. Letting drivers come and go independently without races on
|
||||
port-bind / port-unbind.
|
||||
|
||||
When two drivers share a listener:
|
||||
|
||||
- They MUST set `ExpectedServerUri` to disambiguate; otherwise the first
|
||||
upstream to dial in wins regardless of which driver is waiting.
|
||||
- They CAN come and go independently; the listener stays alive while at
|
||||
least one driver references it.
|
||||
|
||||
### Behaviour
|
||||
|
||||
- The dial path is bypassed entirely when `Enabled` is `true`. Failover
|
||||
across multiple `EndpointUrls` doesn't apply — there's no client-side
|
||||
dial to fail over.
|
||||
- `ExpectedServerUri` is the SDK's filter parameter to `WaitForConnectionAsync`.
|
||||
Inbound `ReverseHello`s from a different upstream are ignored and the
|
||||
caller keeps waiting.
|
||||
- The same `EndpointDescription` derivation runs as the dial path — the
|
||||
first `EndpointUrl` in the candidate list seeds `SecurityPolicy` /
|
||||
`SecurityMode` / `EndpointUrl` for the session-create call. The actual
|
||||
endpoint lives on the upstream and the SDK reconciles after the
|
||||
`ReverseHello`.
|
||||
- Cancellation: `Timeout` bounds the wait. A stuck listener with no inbound
|
||||
dial throws after `Timeout` rather than hanging init forever.
|
||||
- Shutdown releases the listener reference. The last release stops the
|
||||
listener so the port can be re-bound by a future driver lifecycle.
|
||||
|
||||
### Wiring it up on the upstream
|
||||
|
||||
The upstream OPC UA server has to be configured to dial out. The `opc-plc`
|
||||
simulator does this with `--rc=opc.tcp://<gateway-host>:4844`; for a real
|
||||
upstream see your server's reverse-connect docs (most major implementations
|
||||
expose a "ReverseConnect.Endpoint" config knob).
|
||||
|
||||
### When NOT to use
|
||||
|
||||
- Standard plant networks where the gateway can dial the upstream — the
|
||||
conventional dial path is simpler and supports failover natively.
|
||||
- Public-internet OPC UA: reverse-connect is a network-policy workaround,
|
||||
not a security primitive. Always pair with `Sign` or `SignAndEncrypt`
|
||||
+ a vetted user-token policy.
|
||||
|
||||
## HistoryRead Events
|
||||
|
||||
The driver passes through OPC UA `HistoryReadEvents` to the upstream server.
|
||||
HistoryRead Raw / Processed / AtTime ship in the same code path
|
||||
(`ExecuteHistoryReadAsync`); event history takes a slightly different shape
|
||||
because the client sends an `EventFilter` (SelectClauses + WhereClause) rather
|
||||
than a plain numeric / time-based detail block.
|
||||
|
||||
### Wire path
|
||||
|
||||
`IHistoryProvider.ReadEventsAsync(fullReference, EventHistoryRequest, ct)`
|
||||
translates to:
|
||||
|
||||
```
|
||||
new ReadEventDetails {
|
||||
StartTime,
|
||||
EndTime,
|
||||
NumValuesPerNode,
|
||||
Filter = EventFilter { SelectClauses, WhereClause }
|
||||
}
|
||||
```
|
||||
|
||||
…and is sent through `Session.HistoryReadAsync` to the upstream server. The
|
||||
returned `HistoryEvent.Events` collection (one `HistoryEventFieldList` per
|
||||
historical event) is unwrapped into `HistoricalEventBatch.Events`, where each
|
||||
`HistoricalEventRow.Fields` dictionary is keyed by the
|
||||
`SimpleAttributeSpec.FieldName` the caller supplied. The server-side history
|
||||
dispatcher uses those keys to align fields with the wire-side SelectClause
|
||||
order — drivers don't have to honour the entire OPC UA `EventFilter` shape
|
||||
verbatim.
|
||||
|
||||
### SelectClauses
|
||||
|
||||
When `EventHistoryRequest.SelectClauses` is `null` the driver falls back to a
|
||||
default set that matches `BuildHistoryEvent` on the server side:
|
||||
|
||||
| Field | Browse path | Notes |
|
||||
| --- | --- | --- |
|
||||
| `EventId` | `EventId` | BaseEventType — stable unique id. |
|
||||
| `SourceName` | `SourceName` | Source-object name. |
|
||||
| `Time` | `Time` | Process-side event timestamp. Used for `OccurrenceTime`. |
|
||||
| `Message` | `Message` | LocalizedText payload. |
|
||||
| `Severity` | `Severity` | OPC UA 1-1000 scale. |
|
||||
| `ReceiveTime` | `ReceiveTime` | Server-side ingest timestamp. |
|
||||
|
||||
Custom SelectClauses are supported — pass any
|
||||
`IReadOnlyList<SimpleAttributeSpec>`. Each entry's `TypeDefinitionId`
|
||||
defaults to `BaseEventType` when `null`; pass an explicit NodeId text (e.g.
|
||||
`"i=2782"` for `ConditionType`) to reach typed-condition fields.
|
||||
|
||||
### WhereClause
|
||||
|
||||
`ContentFilterSpec.EncodedOperands` carries the binary-encoded
|
||||
`ContentFilter` from the wire. The driver decodes it into the SDK
|
||||
`ContentFilter` and attaches it to the outgoing `EventFilter` verbatim — the
|
||||
OPC UA Client driver is a passthrough for filter semantics, it does not
|
||||
evaluate them. A malformed filter is dropped silently; the SelectClause
|
||||
projection still goes out.
|
||||
|
||||
### Continuation points
|
||||
|
||||
Returned in `HistoricalEventBatch.ContinuationPoint`. The server-side
|
||||
HistoryRead facade is responsible for round-tripping these so a paged event
|
||||
read against a chatty upstream completes incrementally. The driver itself
|
||||
doesn't track them — every `ReadEventsAsync` call issues a fresh
|
||||
`HistoryReadAsync`.
|
||||
@@ -0,0 +1,338 @@
|
||||
# S7 — TIA Portal CSV & STEP 7 Classic AWL symbol import
|
||||
|
||||
PR-S7-D1 / [#299](https://github.com/dohertj2/lmxopcua/issues/299) — bulk-import
|
||||
TIA Portal "Show all tags" CSV exports and STEP 7 Classic AWL declaration files
|
||||
into the S7 driver. Saves operators from hand-typing every `%MW0` /
|
||||
`%DB1.DBW0` row of a several-hundred-tag PLC into `appsettings.json`.
|
||||
|
||||
## Supported formats — v1
|
||||
|
||||
| Format | Status | Notes |
|
||||
|---|---|---|
|
||||
| TIA Portal `.CSV` ("Show all tags" export) | **supported** | Header columns `Name,Path,Data type,Logical address,Comment,Hmi accessible,…`; en-US (`,`) and DE-locale (`;` separator + `,` decimal) auto-detected |
|
||||
| STEP 7 Classic `.AWL` (`VAR_GLOBAL` + `DATA_BLOCK`) | **supported, best-effort** | Position-based offset assignment (no exact byte offsets in hand-exported AWL — see below) |
|
||||
| STEP 7 / TIA Portal native binary (`.s7p`, `.zap`) | **out of scope** | Proprietary; no community parser. Use TIA's "Show all tags" CSV export |
|
||||
| TIA Portal Openness API | **out of scope** | Requires a licensed TIA install + OpenAPI license; future PR |
|
||||
|
||||
## TIA Portal CSV column reference
|
||||
|
||||
| Column | Required | Notes |
|
||||
|---|---|---|
|
||||
| `Name` | yes | OPC UA tag name. TIA symbols are stable across deployments; the importer uses them verbatim |
|
||||
| `Logical address` (or `Address`) | yes | TIA-style address with leading `%` (e.g. `%MW0`, `%DB1.DBW10`, `%DB1.DBX2.3`). Stripped on import |
|
||||
| `Data type` | recommended | TIA primitive type (`Int`, `Real`, `Bool`, `String`, …) — drives the imported `S7DataType` |
|
||||
| `Comment` | no | Parsed but currently unused — `S7TagDefinition` has no `Description` field at the v2 schema layer (see [#248](https://github.com/dohertj2/lmxopcua/issues/248)). Held in the column contract for future schema bumps |
|
||||
| `Hmi accessible` | no | Filter — rows with `False` / `FALSCH` / `nein` are skipped (internal symbols TIA shows in the editor but doesn't expose to client interfaces). Missing column defaults to `True` |
|
||||
| `Hmi visible` / `Hmi writeable` | no | Currently unused — held for future Admin-UI-side metadata |
|
||||
| `Length` | no | For `String` rows: max length. Default 254. Drives `StringLength` on the imported tag |
|
||||
| `Path` | no | TIA tag-table path (`Default tag table`, custom names). Currently unused; held in the contract |
|
||||
|
||||
### TIA `Data type` → `S7DataType` mapping
|
||||
|
||||
| TIA type | Maps to | Notes |
|
||||
|---|---|---|
|
||||
| `Bool` | `Bool` | Bit access; address must include a `.bit` suffix |
|
||||
| `Byte`, `SInt`, `USInt` | `Byte` | 1-byte unsigned/signed |
|
||||
| `Int` | `Int16` | Signed 16-bit |
|
||||
| `Word`, `UInt` | `UInt16` | Unsigned 16-bit |
|
||||
| `DInt` | `Int32` | Signed 32-bit |
|
||||
| `DWord`, `UDInt` | `UInt32` | Unsigned 32-bit |
|
||||
| `LInt` | `Int64` | 64-bit signed (S7-1500 only) |
|
||||
| `LWord`, `ULInt` | `UInt64` | 64-bit unsigned (S7-1500 only) |
|
||||
| `Real` | `Float32` | IEEE-754 32-bit |
|
||||
| `LReal` | `Float64` | IEEE-754 64-bit (S7-1500 only) |
|
||||
| `String` | `String` | S7 STRING with 2-byte header; `Length` column drives `StringLength` |
|
||||
| `WString` | `WString` | S7 WSTRING (UTF-16BE) |
|
||||
| `Char` / `WChar` | `Char` / `WChar` | Single-character |
|
||||
| `Date` | `Date` | UInt16 days since 1990-01-01 |
|
||||
| `Time` | `Time` | Int32 ms |
|
||||
| `TOD` / `Time_Of_Day` | `TimeOfDay` | UInt32 ms since midnight |
|
||||
| `DT` / `Date_And_Time` | `DateAndTime` | 8-byte BCD |
|
||||
| `DTL` | `Dtl` | 12-byte structured (S7-1200 / S7-1500) |
|
||||
| `S5Time` | `S5Time` | 16-bit BCD duration |
|
||||
| `Struct` / quoted UDT name | UDT placeholder | See below |
|
||||
|
||||
### UDT placeholders
|
||||
|
||||
UDT-typed symbols (TIA `Data type` = `"MyUdt"` quoted, or the literal `Struct`)
|
||||
import as a **placeholder** — the resulting tag lands in the driver options so
|
||||
it shows up in the Admin UI tag list, but its data type is forced to `Byte`
|
||||
and the row is marked `Writable = false`.
|
||||
|
||||
`S7ImportResult.UdtPlaceholderCount` tracks how many of the imported tags
|
||||
landed in this bucket.
|
||||
|
||||
#### Cooperation with `Udts` declarations (PR-S7-D2 / #300)
|
||||
|
||||
PR-S7-D2 ships UDT fan-out via `S7DriverOptions.Udts` + `S7TagDefinition.UdtName`.
|
||||
The importer and the `Udts` declaration cooperate as follows:
|
||||
|
||||
1. The importer emits a placeholder row for each UDT-typed symbol — same as
|
||||
today (data type forced to `Byte`, `Writable = false`).
|
||||
2. The operator hand-edits the placeholder row in the resulting JSON / options
|
||||
object and:
|
||||
- Sets `UdtName` to the UDT type name from the TIA "Data type" column
|
||||
- Removes the `Writable: false` marker (UDT leaves inherit the parent's
|
||||
writability)
|
||||
3. The operator declares the matching `S7UdtDefinition` in
|
||||
`S7DriverOptions.Udts` (member offsets come from the TIA UDT definition
|
||||
in the project file — TIA's "Show all tags" CSV does not export struct
|
||||
field offsets, hence the manual layout step).
|
||||
4. At driver init, the fan-out replaces the placeholder with one scalar leaf
|
||||
per UDT member.
|
||||
|
||||
The importer does NOT auto-populate `Udts` — UDT layouts live in the project
|
||||
file, not the symbol-table CSV. A future enhancement may parse the SCL UDT
|
||||
declaration alongside the CSV; for now the cooperation is "importer flags it,
|
||||
operator declares the layout, driver fans out at init".
|
||||
|
||||
See [`docs/v2/s7.md` "UDT / STRUCT support"](../v2/s7.md#udt--struct-support)
|
||||
for the full fan-out semantics, the 4-level nesting cap, and the
|
||||
Optimized-block-access prerequisite.
|
||||
|
||||
## Instance DBs / FB parameters
|
||||
|
||||
PR-S7-D3 / [#301](https://github.com/dohertj2/lmxopcua/issues/301) — multi-instance
|
||||
Function-Block (FB) instances are addressed symbolically inside the PLC program
|
||||
(`MyFB_Instance.MyParam`) but the runtime wire access still needs the absolute
|
||||
`DBn.DBW_offset`. TIA Portal's "Show all tags" CSV export distinguishes these
|
||||
rows from regular global DBs via the **`DB type`** column.
|
||||
|
||||
### `DB type` column convention
|
||||
|
||||
| `DB type` value | Meaning | Path |
|
||||
|---|---|---|
|
||||
| (empty) | Legacy export — no column at all (TIA pre-v15 / partial export). Treated as Global. | D1 (existing) |
|
||||
| `Global DB` / `Global` / `Global Data Block` | Standalone DB declared in the project tree. | D1 (existing) |
|
||||
| `Globaler Datenbaustein` | Same as above, DE locale. | D1 (existing) |
|
||||
| `Instance DB` / `Instance` / `Instance Data Block` | Multi-instance FB instance. Member tags are the FB's `IN` / `OUT` / `IN_OUT` / `STAT` parameters. | **D3 (new)** |
|
||||
| `Instance-DB` / `Instanz-DB` / `Instanz-Datenbaustein` | Same as above (locale + dashing variants). | **D3 (new)** |
|
||||
|
||||
The `DB type` column is matched case-insensitively; quoting and surrounding
|
||||
whitespace are tolerated.
|
||||
|
||||
### `MyFB_Instance.MyParam` → `DBn.DBW_offset`
|
||||
|
||||
The TIA Portal export ships the **resolved absolute address** in the
|
||||
`Logical address` column for every instance-DB member — TIA itself walks the FB
|
||||
interface declaration at export time and writes out the byte-offset-anchored
|
||||
address verbatim. The importer accepts these rows the same way as a Global-DB
|
||||
row, with two differences:
|
||||
|
||||
1. The row counts under `S7ImportResult.InstanceDbCount` (a sub-counter of
|
||||
`ParsedCount`) so the operator can see how much of the import depends on the
|
||||
FB-interface layout.
|
||||
2. The row is rejected from the UDT placeholder path even if the data type
|
||||
column happens to match a UDT name pattern — instance-DB members always
|
||||
import as fully-functional scalar tags.
|
||||
|
||||
Example fixture row:
|
||||
|
||||
```csv
|
||||
Name,Path,Data type,Logical address,Comment,Hmi accessible,DB type
|
||||
MotorFB_1.Speed,FB instances,Int,%DB7.DBW0,Speed setpoint,True,Instance DB
|
||||
```
|
||||
|
||||
The imported `S7TagDefinition` ends up with:
|
||||
|
||||
```csharp
|
||||
new S7TagDefinition(
|
||||
Name: "MotorFB_1.Speed",
|
||||
Address: "DB7.DBW0",
|
||||
DataType: S7DataType.Int16,
|
||||
Writable: true);
|
||||
```
|
||||
|
||||
### Empty-`Logical address` fallback
|
||||
|
||||
When TIA exports an instance-DB row with an empty `Logical address` column
|
||||
(rare in practice — happens when the export was generated against a
|
||||
not-yet-compiled project), `InstanceDbResolver` can compute the absolute
|
||||
address from explicit parent-DB / parent-base-offset / member-offset inputs.
|
||||
This fallback is exposed at the resolver-class level for advanced bootstrap
|
||||
scenarios; the CSV path itself does not currently parse interface declarations
|
||||
out of the file (TIA's CSV doesn't carry them).
|
||||
|
||||
For now the operator workflow is: re-export from TIA after compiling the
|
||||
project so every instance-DB row carries a resolved `Logical address`.
|
||||
|
||||
### Re-import on FB-interface edit — caveat
|
||||
|
||||
When the FB interface changes — a member is added, removed, or reordered in
|
||||
TIA — the instance-DB layout shifts on the PLC side. Member byte offsets that
|
||||
worked yesterday point at the wrong word today; absolute-offset addressing has
|
||||
no in-band schema check.
|
||||
|
||||
**The driver does not auto-detect this.** Operators must:
|
||||
|
||||
1. Recompile the FB in TIA Portal.
|
||||
2. Download the updated program to the PLC.
|
||||
3. **Re-export "Show all tags" CSV** from the updated project.
|
||||
4. Re-import the CSV via `AddTiaCsvImport` or the `import-symbols` CLI.
|
||||
5. Restart the driver instance (Admin UI → Drivers → Reload).
|
||||
|
||||
A stale import will silently read / write the wrong byte offsets — the values
|
||||
will look like valid PLC data but reference whichever member used to live at
|
||||
that offset before the interface edit. There is no runtime guard; this is the
|
||||
same caveat that applies to all absolute-offset DB addressing on S7-1200 /
|
||||
1500 (see [`docs/v2/s7.md` "UDT / STRUCT support"](../v2/s7.md#udt--struct-support)
|
||||
for the parallel UDT-edit story).
|
||||
|
||||
A future enhancement may add a project-fingerprint compare at driver init —
|
||||
hashing the interface offsets at import time and re-checking against a known
|
||||
PLC system function. Tracked as a follow-up; not in PR-S7-D3.
|
||||
|
||||
## DE locale handling
|
||||
|
||||
TIA Portal honours the Windows display locale when writing CSV. A DE-locale
|
||||
install emits:
|
||||
|
||||
- Field separator `;` (because `,` is the decimal separator)
|
||||
- Decimal-comma in addresses: `%MW0,5` rather than `%MW0.5` for bit addresses
|
||||
- Boolean column values `WAHR` / `FALSCH` rather than `True` / `False`
|
||||
|
||||
The importer **auto-detects** the locale from the first non-blank line:
|
||||
|
||||
- Field-separator detection: counts `;` vs `,` occurrences in the header
|
||||
- Decimal-comma detection: scans the first data row's address column for a
|
||||
digit-comma-digit pattern
|
||||
- Boolean column values: recognises both languages (`true/false/wahr/falsch/yes/no/ja/nein`,
|
||||
case-insensitive) plus bare `0`/`1`
|
||||
|
||||
The address column is rewritten to en-US shape (`%MW0,5` → `MW0.5`) before the
|
||||
strict `S7AddressParser` runs, so the rest of the driver pipeline sees a
|
||||
single canonical address shape.
|
||||
|
||||
## STEP 7 Classic AWL — `VAR_GLOBAL` + `DATA_BLOCK`
|
||||
|
||||
Best-effort parser for legacy STEP 7 Classic projects:
|
||||
|
||||
- `VAR_GLOBAL … END_VAR` — global memory area declarations. Each entry maps to
|
||||
a sequential `M{B|W|D}{offset}` address based on declaration order.
|
||||
- `DATA_BLOCK DBn … END_DATA_BLOCK` — DB declarations. Each field maps to a
|
||||
`DB{n}.DB{B|W|D}{offset}` address based on declaration order; the DB number
|
||||
is parsed from the `DATA_BLOCK` line's `DBn` keyword.
|
||||
|
||||
### Position-based addressing — heuristic
|
||||
|
||||
Real STEP 7 Classic projects carry exact byte offsets in the symbol table /
|
||||
.gr8 deployment artefact, but a hand-exported AWL file omits them. The
|
||||
importer assumes:
|
||||
|
||||
| Type | Bytes |
|
||||
|---|---|
|
||||
| `BOOL` | 1 (rounded up to byte alignment) |
|
||||
| `BYTE` / `SINT` / `USINT` / `CHAR` | 1 |
|
||||
| `INT` / `WORD` / `UINT` | 2 |
|
||||
| `DINT` / `DWORD` / `UDINT` / `REAL` | 4 |
|
||||
| `LREAL` / `LINT` / `ULINT` / `LWORD` | 8 |
|
||||
| `STRING[N]` | N + 2 (2-byte header) |
|
||||
| `STRING` (no length) | 256 |
|
||||
| `STRUCT` / `Array[…] of …` / quoted UDT name | UDT placeholder (8-bit Byte at next aligned offset) |
|
||||
|
||||
S7 alignment rule: offsets round up to a 2-byte boundary for any 16-bit-or-larger
|
||||
type. Sites needing exact offsets should drive their symbol import from the
|
||||
TIA Portal CSV path instead — the CSV carries the offsets verbatim.
|
||||
|
||||
Comments (`(* ... *)` block, `// ...` line) are stripped before declaration
|
||||
parsing. Initial-value clauses (`:= 0`) are recognised and discarded.
|
||||
|
||||
## CLI subcommand — `import-symbols`
|
||||
|
||||
```powershell
|
||||
otopcua-s7-cli import-symbols --help
|
||||
```
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `-f` / `--file` | **required** | Path to the TIA CSV or `.AWL` file |
|
||||
| `--format` | `tia` | `tia` (CSV) or `awl` (STEP 7 Classic) |
|
||||
| `-d` / `--device` | none | Optional documentation tag (reserved for symmetry with `import-rslogix`) |
|
||||
| `--emit` | `appsettings-fragment` | `appsettings-fragment` (JSON) or `summary` (one-line counter) |
|
||||
| `-o` / `--output` | stdout | Optional path; when set the JSON fragment is written there + summary line goes to stdout |
|
||||
| `--max-rows` | unlimited | Defensive cap on rows imported |
|
||||
| `--strict` | off | Fail-fast on the first malformed row (default permissive: skip + log) |
|
||||
|
||||
### `appsettings-fragment` output shape
|
||||
|
||||
The default `--emit appsettings-fragment` mode writes a JSON object whose
|
||||
`Tags` array is shaped like the `S7DriverConfigDto.Tags` array — paste
|
||||
straight into the driver-instance config under
|
||||
`Drivers/<instance>/Config/Tags`.
|
||||
|
||||
```json
|
||||
{
|
||||
"Tags": [
|
||||
{
|
||||
"Name": "MotorSpeed",
|
||||
"Address": "MW0",
|
||||
"DataType": "Int16",
|
||||
"Writable": true,
|
||||
"StringLength": 254
|
||||
},
|
||||
…
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Summary line
|
||||
|
||||
`--emit summary` writes a single line:
|
||||
|
||||
```
|
||||
Imported 142 tag(s), skipped 3, errors 0, udt-placeholders 5, instance-db 9.
|
||||
```
|
||||
|
||||
`Skipped` covers HMI-accessible-false rows + missing-required-field rows;
|
||||
`errors` covers rows whose `Address` failed to parse as an S7 address;
|
||||
`udt-placeholders` covers UDT-typed rows that imported as placeholders;
|
||||
`instance-db` (PR-S7-D3) covers rows whose `DB type` column tagged them as
|
||||
multi-instance FB-instance members.
|
||||
|
||||
## API surface — `IS7SymbolImporter` + `AddTiaCsvImport` / `AddAwlImport`
|
||||
|
||||
For server-side / bootstrap use-cases the importer is reachable via:
|
||||
|
||||
```csharp
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||
|
||||
var options = new S7DriverOptions { Host = "192.168.1.30", CpuType = CpuType.S71500 };
|
||||
|
||||
// Append imported tags onto an existing options object.
|
||||
var updated = options.AddTiaCsvImport(
|
||||
path: @"C:\plc\tia-export.csv",
|
||||
out var result);
|
||||
|
||||
Console.WriteLine($"Imported {result.ParsedCount} tags ({result.UdtPlaceholderCount} placeholders)");
|
||||
|
||||
// AWL variant — same shape.
|
||||
var withAwl = updated.AddAwlImport(
|
||||
path: @"C:\plc\classic.awl",
|
||||
out var awlResult);
|
||||
```
|
||||
|
||||
For a hand-managed importer instance (e.g. supplying a custom `ILogger`) call
|
||||
`new TiaCsvImporter(logger).Parse(stream, opts)` or
|
||||
`new AwlImporter(logger).Parse(stream, opts)` directly.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- The importers are **additive** — `AddTiaCsvImport` / `AddAwlImport` concatenate
|
||||
onto the existing `Tags` list rather than replacing it. Hand-rolled tags
|
||||
(system-status variables, computed fields the operator added by hand) survive
|
||||
a re-import.
|
||||
- Re-imports are not idempotent — calling `AddTiaCsvImport` twice will produce
|
||||
duplicate tag rows. Operators are expected to start from a clean options
|
||||
object or de-duplicate themselves; a future schema rev may add a
|
||||
`replace=true` switch.
|
||||
- UDT placeholders surface in the Admin UI as non-writable Byte tags. PR-S7-D2
|
||||
added the runtime UDT fan-out (`S7DriverOptions.Udts` + `S7TagDefinition.UdtName`)
|
||||
— operators upgrade a placeholder row by setting `UdtName` and declaring the
|
||||
matching `S7UdtDefinition`; see "Cooperation with `Udts` declarations" above.
|
||||
Placeholder-only rows still work as a Byte view of the first byte but
|
||||
can't browse / read their members until the layout is declared.
|
||||
- Description metadata is dropped on the floor today — see the column
|
||||
reference above. When [#248](https://github.com/dohertj2/lmxopcua/issues/248)
|
||||
lands a `Description` field on `S7TagDefinition` the importer will start
|
||||
populating it without further changes to the CSV contract.
|
||||
@@ -44,6 +44,10 @@ The driver ctor change that made this possible:
|
||||
bool-with-bit in one batch call; proves typed decode per S7DataType
|
||||
- `S7_1500SmokeTests.Driver_write_then_read_round_trip_on_scratch_word` —
|
||||
`DB1.DBW100` write → read-back; proves write path + buffer visibility
|
||||
- `S7_1500DiagnosticsTests.Driver_exposes_negotiated_pdu_size_post_init` —
|
||||
asserts `DriverHealth.Diagnostics["S7.NegotiatedPduSize"]` is non-zero
|
||||
after `InitializeAsync`; proves the negotiated PDU size surfaces in
|
||||
driver health (Snap7 fixture pins this at 240 bytes — see fixture README)
|
||||
|
||||
### Unit
|
||||
|
||||
@@ -86,8 +90,10 @@ not differentiated at test time.
|
||||
|
||||
### 5. Data types beyond the scalars
|
||||
|
||||
UDT fan-out, `STRING` with length-prefix quirks, `DTL` / `DATE_AND_TIME`,
|
||||
arrays of structs — not covered.
|
||||
`STRING` with length-prefix quirks, `DTL` / `DATE_AND_TIME`, arrays of
|
||||
structs — not covered. UDT fan-out IS covered (PR-S7-D2 / #300) via the
|
||||
`udt_layout` meta-seed in `Docker/profiles/s7_1500.json` and the
|
||||
`Driver_fans_out_udt_into_member_tags` integration test.
|
||||
|
||||
## When to trust the S7 tests, when to reach for a rig
|
||||
|
||||
@@ -97,7 +103,7 @@ arrays of structs — not covered.
|
||||
| "Does the driver lifecycle hang / crash?" | yes | yes |
|
||||
| "Does a real read against an S7-1500 return correct bytes?" | no | yes (required) |
|
||||
| "Does mailbox serialization actually prevent PG timeouts?" | no | yes (required) |
|
||||
| "Does a UDT fan-out produce usable member variables?" | no | yes (required) |
|
||||
| "Does a UDT fan-out produce usable member variables?" | yes (Snap7 + `udt_layout` meta-seed) | yes |
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
@@ -109,6 +115,18 @@ arrays of structs — not covered.
|
||||
lab rig but not CI.
|
||||
3. **Real S7 lab rig** — cheapest physical PLC (CPU 1212C) on a dedicated
|
||||
network port, wired via self-hosted runner.
|
||||
4. **PR-S7-C5 — PUT/GET-disabled pre-flight rejection.** Snap7 does *not*
|
||||
model the hardened-CPU PUT/GET response (it accepts every read once the
|
||||
COTP handshake completes), so the **failure** path of the pre-flight
|
||||
probe — `S7PutGetDisabledException` thrown from `InitializeAsync` when
|
||||
the PLC rejects the probe read with `ErrorCode.WrongCPU_Type` /
|
||||
`ErrorCode.ReadData` — needs a real S7-1500 with PUT/GET disabled in TIA
|
||||
Portal. The integration suite covers the *happy* path
|
||||
(`Driver_preflight_passes_when_probe_address_seeded`); the failure path
|
||||
should be added as a `--with-real-plc` opt-in test that the self-hosted
|
||||
runner with the lab rig executes. The classifier branch
|
||||
(`S7PreflightClassifier.IsPutGetDisabled`) is unit-tested without a
|
||||
network in `S7PreflightTests.Classifier_matches_only_PUT_GET_disabled_error_codes`.
|
||||
|
||||
Without any of these, S7 driver correctness against real hardware is trusted
|
||||
from field deployments, not from the test suite.
|
||||
|
||||
@@ -57,6 +57,14 @@ All three gated on `TWINCAT_TARGET_HOST` + `TWINCAT_TARGET_NETID` env
|
||||
vars; skip cleanly via `[TwinCATFact]` when the VM isn't reachable or
|
||||
vars are unset.
|
||||
|
||||
PR 4.1 / #315 adds `TwinCATUdtBrowseTests.Driver_browses_UDT_tree_and_flattens_to_atomic_leaves`
|
||||
which exercises `TwinCATDriver.DiscoverAsync` end-to-end against the
|
||||
`GVL_Plant` UDT fixture. Asserts the discovery surface emits one OPC UA
|
||||
variable per atomic leaf and folds `aAlarmRecords[1..2000]` into a
|
||||
single `IsArrayRoot` placeholder when the element count exceeds the
|
||||
default 1024-element cap (UDT per-member coverage; see
|
||||
`TwinCatProject/README.md §Complex hierarchy` for the supporting DUTs).
|
||||
|
||||
### Unit
|
||||
|
||||
- `TwinCATAmsAddressTests` — `ads://<netId>:<port>` parsing + routing
|
||||
@@ -66,6 +74,14 @@ vars are unset.
|
||||
- `TwinCATSymbolPathTests` — symbol-path routing for nested struct members
|
||||
- `TwinCATSymbolBrowserTests` — `ITagDiscovery.DiscoverAsync` via
|
||||
`ReadSymbolsAsync` (#188) + system-symbol filtering
|
||||
- `TwinCATTypeWalkerTests` — PR 4.1 / #315 nested-UDT decomposition:
|
||||
atomic / single-level struct / nested struct / array-of-atomic
|
||||
(in / over `MaxArrayExpansion`) / array-of-struct / alias chain /
|
||||
pointer skip / self-referencing struct depth-cap / per-leaf
|
||||
`MaxArrayExpansion` honored / ReadOnly propagation. Stub `IDataType`
|
||||
/ `IStructType` / `IArrayType` / `IMember` / `IDimensionCollection`
|
||||
trees built in-test so the walker is exercised without
|
||||
`Beckhoff.TwinCAT.Ads`-internal ctors.
|
||||
- `TwinCATNativeNotificationTests` — `AddDeviceNotification` (#189)
|
||||
registration, callback-delivery-to-`OnDataChange` wiring, unregister on
|
||||
unsubscribe
|
||||
@@ -96,6 +112,16 @@ CPU load or network jitter real notifications can coalesce. The fake fires
|
||||
one callback per test invocation — real callback-coalescing behavior is
|
||||
untested.
|
||||
|
||||
PR 3.1 (#313) makes the per-tag `MaxDelay` configurable via
|
||||
`TwinCATTagDefinition.MaxDelayMs` — the runtime can buffer changes for up to
|
||||
that many milliseconds before dispatch, deliberately coalescing bursty
|
||||
high-frequency signals so the OPC UA queue downstream doesn't flood. Default
|
||||
`null` / `0` preserves the pre-PR-3.1 "fire ASAP" behaviour.
|
||||
`TwinCATMaxDelayTests.Driver_coalesces_notifications_at_max_delay` exercises
|
||||
the wire-side coalescer end-to-end against `GVL_Fixture.nCounter`; the unit
|
||||
suite (`TwinCATNativeNotificationTests`) covers the plumbing contract via
|
||||
the `FakeTwinCATClient.FakeNotification.MaxDelayMs` capture.
|
||||
|
||||
### 4. TC2 vs TC3 variant handling
|
||||
|
||||
TwinCAT 2 (ADS v1) and TwinCAT 3 (ADS v2) have subtly different
|
||||
@@ -125,6 +151,104 @@ back an `IAlarmSource`, but shipping that is a separate feature.
|
||||
| "Do notifications coalesce under load?" | no | yes (required) |
|
||||
| "Does a TC2 PLC work the same as TC3?" | no | yes (required) |
|
||||
|
||||
## Performance
|
||||
|
||||
PR 2.1 (Sum-read / Sum-write, IndexGroup `0xF080..0xF084`) replaced the per-tag
|
||||
`ReadValueAsync` loop in `TwinCATDriver.ReadAsync` / `WriteAsync` with a
|
||||
bucketed bulk dispatch — N tags addressed against the same device flow through a
|
||||
single ADS sum-command round-trip via `SumInstancePathAnyTypeRead` (read) and
|
||||
`SumWriteBySymbolPath` (write). Whole-array tags + bit-extracted BOOL tags
|
||||
remain on the per-tag fallback path because the sum surface only marshals
|
||||
scalars and bit-RMW writes need the per-parent serialisation lock.
|
||||
|
||||
**Baseline → Sum-command delta** (dev box, 1000 × DINT, XAR VM over LAN):
|
||||
|
||||
| Path | Round-trips | Wall-clock |
|
||||
| --- | --- | --- |
|
||||
| Per-tag loop (pre-PR 2.1) | 1000 | ~5–8 s |
|
||||
| Sum-command bulk (PR 2.1) | 1 | ~250–600 ms |
|
||||
| Ratio | — | ≥ 10× typical, ≥ 5× CI floor |
|
||||
|
||||
The perf-tier test
|
||||
`TwinCATSumCommandPerfTests.Driver_sum_read_1000_tags_beats_loop_baseline_by_5x`
|
||||
asserts the ratio with a conservative 5× lower bound that survives noisy CI /
|
||||
VM scheduling. It is gated behind both `TWINCAT_TARGET_NETID` (XAR reachable)
|
||||
and `TWINCAT_PERF=1` (operator opt-in) — perf runs aren't part of the default
|
||||
integration pass because they hit the wire heavily.
|
||||
|
||||
The required fixture state (1000-DINT GVL + churn POU) is documented in
|
||||
`TwinCatProject/README.md §Performance scenarios`; XAE-form sources land at
|
||||
`TwinCatProject/PLC/GVLs/GVL_Perf.TcGVL` + `TwinCatProject/PLC/POUs/FB_PerfChurn.TcPOU`.
|
||||
|
||||
### Handle caching (PR 2.2)
|
||||
|
||||
Per-tag reads / writes route through an in-process ADS variable-handle cache.
|
||||
The first read of a symbol resolves a handle via `CreateVariableHandleAsync`;
|
||||
subsequent reads / writes of the same symbol issue against the cached handle.
|
||||
On the wire this trades a multi-byte symbolic path (`GVL_Perf.aTags[742]` =
|
||||
20+ bytes) for a 4-byte handle, and the device server skips name resolution
|
||||
on every subsequent op. Cache lifetime is process-scoped; entries are evicted
|
||||
on `AdsErrorCode.DeviceSymbolVersionInvalid` (with one retry against a fresh
|
||||
handle), wiped on reconnect (handles are per-AMS-session), and deleted
|
||||
best-effort on driver disposal.
|
||||
|
||||
`TwinCATHandleCachePerfTests.Driver_handle_cache_avoids_repeat_symbol_resolution`
|
||||
asserts the contract on real XAR by reading 50 symbols twice and verifying
|
||||
the second pass issues zero new `CreateVariableHandleAsync` calls. It runs
|
||||
under the standard `[TwinCATFact]` gate (XAR reachable; no `TWINCAT_PERF`
|
||||
opt-in needed because 50 symbols is cheap).
|
||||
|
||||
**Self-invalidation (PR 2.3)**: handle cache is now self-invalidating on
|
||||
TwinCAT online changes. `AdsTwinCATClient` registers an
|
||||
`AdsSymbolVersionChanged` event listener (Beckhoff's high-level wrapper
|
||||
around the SymbolVersion ADS notification, IndexGroup `0xF008`) on connect;
|
||||
when the PLC's symbol-version counter increments — full re-init after a
|
||||
download / activate-config — the listener fires and wipes the handle cache
|
||||
proactively. Three-layered defence in depth: (1) proactive listener
|
||||
preempts the next read entirely on full re-inits, (2) the
|
||||
`DeviceSymbolVersionInvalid` evict-and-retry path from PR 2.2 catches the
|
||||
narrower "symbol survives but its descriptor moved" race, and (3)
|
||||
operators can still call `ITwinCATClient.FlushOptionalCachesAsync` manually
|
||||
for the truly-paranoid case. The bulk Sum-read / Sum-write path remains
|
||||
on symbolic paths in PR 2.2 (the bulk path's per-call symbol resolution
|
||||
is already amortised across N tags; the perf delta vs. handle-batched
|
||||
bulk is marginal — tracked as a follow-up for the Phase-2 perf sweep).
|
||||
|
||||
## Diagnostics
|
||||
|
||||
PR 3.2 (#314) augments the probe loop. On every successful tick (post `ReadStateAsync`)
|
||||
the driver also reads four well-known system symbols off the AMS target and stashes
|
||||
them on `DeviceState.LastDiagnostics` as a `TwinCATDeviceDiagnostics` record. The same
|
||||
snapshot is folded into `DriverHealth.Diagnostics` so the cross-driver
|
||||
`driver-diagnostics` RPC (added for Modbus, task #154) renders TwinCAT cycle-time /
|
||||
jitter / online-change counters next to its peers without a per-driver special-case.
|
||||
|
||||
| Symbol | Type | Diagnostic key | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `TwinCAT_SystemInfoVarList._AppInfo.AppName` | `STRING(80)` | (record only) | Running PLC project name, e.g. `"Plc1"` |
|
||||
| `TwinCAT_SystemInfoVarList._AppInfo.OnlineChangeCnt` | `UDINT` | `TwinCAT.OnlineChangeCnt` | Increments on every accepted online change; informational |
|
||||
| `TwinCAT_SystemInfoVarList._TaskInfo[1].CycleTime` | `UDINT` (100 ns ticks) | `TwinCAT.CycleTimeMs` | Configured task period after `÷10000` ms conversion |
|
||||
| `TwinCAT_SystemInfoVarList._TaskInfo[1].LastExecTime` | `UDINT` (100 ns ticks) | `TwinCAT.LastExecTimeMs` | Wall-clock duration of the last task tick |
|
||||
| (computed) | `double` | `TwinCAT.JitterMs` | `LastExecTimeMs - CycleTimeMs`; positive = overrun |
|
||||
| (computed) | `long` | `TwinCAT.OnlineChangeIncrements` | Cumulative deltas observed since the driver started; only emitted once non-zero |
|
||||
|
||||
Each individual read is wrapped in best-effort try/catch. A runtime that doesn't
|
||||
expose `_TaskInfo[1]` (older TwinCAT 2 builds, some soft-PLC implementations) still
|
||||
produces a partial snapshot; the missing fields fall back to the previous tick's value
|
||||
or the type default for the first probe tick. Wholesale failure of all four reads
|
||||
leaves the previous snapshot in place and the next tick retries.
|
||||
|
||||
Single-device deployments produce flat keys (`TwinCAT.CycleTimeMs`); multi-device
|
||||
deployments prefix with the AMS host address (`TwinCAT.<hostAddress>.CycleTimeMs`)
|
||||
so the readout is unambiguous when one driver instance owns multiple AMS targets.
|
||||
|
||||
Wire-level coverage lives in
|
||||
`TwinCATDiagnosticsIntegrationTests.Probe_loop_surfaces_cycle_time_and_online_change_count`
|
||||
(asserts `CycleTimeMs > 0` + `OnlineChangeCnt >= 0` within one probe interval against a
|
||||
reachable XAR runtime). Unit-level coverage of the dictionary shape, the per-symbol
|
||||
try/catch, and the multi-device prefixing lives in `TwinCATDeviceDiagnosticsTests` —
|
||||
the `FakeTwinCATClient.SetSystemSymbolValue` helper drives the surface deterministically.
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
1. **XAR VM live-population** — scaffolding is in place (this PR); the
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Decisions
|
||||
|
||||
Architecture-level decisions taken during the v2 implementation, captured
|
||||
once and referenced from feature docs / PR descriptions / ADR-style
|
||||
follow-ups. Each entry lists the decision, the alternatives we considered,
|
||||
and the rationale that tipped the call.
|
||||
|
||||
## FOCAS write-path opt-in
|
||||
|
||||
**Issue:** [#268](https://github.com/dohertj2/lmxopcua/issues/268). **Plan PR:** F4-a.
|
||||
|
||||
### Decision
|
||||
|
||||
The FOCAS driver ships writes behind two independent opt-ins, both default
|
||||
off:
|
||||
|
||||
1. **Driver-level master switch** — `FocasDriverOptions.Writes.Enabled`,
|
||||
default `false`. When off, every entry in a `WriteAsync` batch short-
|
||||
circuits to `BadNotWritable` with status text `writes disabled at
|
||||
driver level`. The wire client is never touched.
|
||||
2. **Per-tag opt-in** — `FocasTagDefinition.Writable`, default `false`
|
||||
(flipped from `true` in F4-a). A `Writable = false` tag returns
|
||||
`BadNotWritable` even when the driver-level flag is on.
|
||||
|
||||
`BadNotSupported` is reserved for kinds the wire client hasn't yet
|
||||
implemented; F4-b/c land actual macro / parameter / PMC writes that
|
||||
currently dispatch to `BadNotSupported` (or to `Good` against the F4-a
|
||||
fake) for unimplemented branches.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
- **Always-on writes (the pre-F4-a default).** Rejected: a single
|
||||
misconfigured tag flipping `Writable = true` by accident would let an
|
||||
operator overwrite a CNC parameter from any OPC UA client. The two-
|
||||
opt-in posture means an accidental tag flip alone isn't enough.
|
||||
- **Driver-level switch only.** Rejected: doesn't protect against an
|
||||
operator with admin rights flipping the master switch to do bulk diag
|
||||
reads but inheriting write capability for tags that were intended
|
||||
read-only.
|
||||
- **Per-tag opt-in only.** Rejected: doesn't give the deployment an "all
|
||||
writes off" emergency lever — useful during a CNC commissioning where
|
||||
writes are unsafe across the board for a period.
|
||||
|
||||
### Rationale
|
||||
|
||||
CNC writes are non-idempotent in the field's worst-case shape: feed
|
||||
overrides, M-code pulses, alarm acks, recipe-step advances. Two opt-ins
|
||||
is the cheapest defence-in-depth posture that still lets writes ship.
|
||||
Both default off so a fresh deployment is read-only — the explicit choice
|
||||
to enable writes lands at config time where it's reviewable, not at
|
||||
runtime where it's invisible.
|
||||
|
||||
`WriteIdempotent` plumbs through `CapabilityInvoker.ExecuteWriteAsync`
|
||||
into the Polly retry pipeline; default `false` means failed writes are
|
||||
not auto-retried (plan decisions #44 / #45). Per-tag flip required for
|
||||
genuinely-idempotent writes.
|
||||
|
||||
### CLI carve-out
|
||||
|
||||
`otopcua-focas-cli write` sets `Writes.Enabled = true` locally for the
|
||||
lifetime of one process and synthesises a `Writable = true` tag. The CLI
|
||||
is a per-operator direct-to-CNC tool — not a long-lived process bound to
|
||||
the central config DB. Configuring the server still requires both opt-ins
|
||||
to be set explicitly in the DriverInstance JSON. The bypass is documented
|
||||
in `docs/Driver.FOCAS.Cli.md` so operators understand the asymmetry.
|
||||
|
||||
### Migration
|
||||
|
||||
Pre-F4-a deployments that relied on the `Writable = true` default need to
|
||||
add `"Writable": true` to every tag they intend to write + an enclosing
|
||||
`"Writes": { "Enabled": true }` block in their DriverInstance JSON.
|
||||
Bootstrap rows seeded before F4-a get `Writable = false` after upgrade —
|
||||
this is intentional; review-then-flip is the safer migration path.
|
||||
@@ -0,0 +1,321 @@
|
||||
# FOCAS deployment guide
|
||||
|
||||
Per-driver runbook for deploying the FANUC FOCAS driver. See
|
||||
[`docs/drivers/FOCAS.md`](../drivers/FOCAS.md) for the per-feature
|
||||
reference and [`focas-version-matrix.md`](./focas-version-matrix.md) for
|
||||
the per-CNC-series capability surface.
|
||||
|
||||
## Operator config-knob cheat sheet
|
||||
|
||||
| Knob | Where | Default | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `Devices[].HostAddress` | `FocasDriverOptions.Devices` | — | `focas://{ip}[:{port}]` |
|
||||
| `Devices[].Series` | `FocasDriverOptions.Devices` | `Unknown` | Drives per-series range validation in `FocasCapabilityMatrix`. |
|
||||
| `Devices[].OverrideParameters` | `FocasDriverOptions.Devices` | `null` | MTB-specific parameter numbers for Feed/Rapid/Spindle/Jog overrides. `null` suppresses the `Override/` subtree. |
|
||||
| `Probe.Enabled` | `FocasDriverOptions.Probe` | `true` | Background reachability probe. |
|
||||
| `Probe.Interval` | `FocasDriverOptions.Probe` | `00:00:05` | Probe cadence. |
|
||||
| `FixedTree.ApplyFigureScaling` | `FocasDriverOptions.FixedTree` | `true` | Divide position values by 10^decimal-places (issue #262). |
|
||||
| **`AlarmProjection.Mode`** | **`FocasDriverOptions.AlarmProjection`** | **`ActiveOnly`** | **`ActiveOnly` keeps today's behaviour. `ActivePlusHistory` polls `cnc_rdalmhistry` on connect + on `HistoryPollInterval` ticks (issue #267, plan PR F3-a).** |
|
||||
| **`AlarmProjection.HistoryPollInterval`** | **`FocasDriverOptions.AlarmProjection`** | **`00:05:00`** | **Cadence of the history poll. Operator dashboards run the default; high-frequency rigs can drop to 30 s.** |
|
||||
| **`AlarmProjection.HistoryDepth`** | **`FocasDriverOptions.AlarmProjection`** | **`100`** | **Most-recent-N ring-buffer entries pulled per poll. Hard-capped at `250` so misconfigured values can't blast the wire session.** |
|
||||
|
||||
## Sample `appsettings.json` snippet for `ActivePlusHistory`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"Drivers": {
|
||||
"FOCAS": {
|
||||
"Devices": [
|
||||
{ "HostAddress": "focas://10.0.0.5:8193", "Series": "Series30i" }
|
||||
],
|
||||
"AlarmProjection": {
|
||||
"Mode": "ActivePlusHistory",
|
||||
"HistoryPollInterval": "00:05:00",
|
||||
"HistoryDepth": 100
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The history projection emits each unseen entry through
|
||||
`IAlarmSource.OnAlarmEvent` with `SourceTimestampUtc` set from the CNC's
|
||||
reported wall-clock — keep CNC clocks on UTC so the dedup key
|
||||
`(OccurrenceTime, AlarmNumber, AlarmType)` stays stable across DST
|
||||
transitions.
|
||||
|
||||
## Derived telemetry — issue #272 (plan PR F5-a)
|
||||
|
||||
The `Production/` subtree gains two **derived** nodes alongside the four
|
||||
F1-b wire-sourced fields:
|
||||
|
||||
- `Production/LastCycleSeconds` (`Float64`)
|
||||
- `Production/LastCycleStartUtc` (`DateTime` UTC)
|
||||
|
||||
**No new wire calls.** Both nodes are computed client-visible from the
|
||||
same `cnc_rdparam(6711)` + `cnc_rdtimer` poll the F1-b projection
|
||||
already runs on every probe tick. There is no per-device knob — the
|
||||
nodes are present for every CNC the driver connects to and surface
|
||||
`null` until the second observed parts-count increment produces the
|
||||
first delta.
|
||||
|
||||
This means:
|
||||
|
||||
- **No additional CNC load.** Probe-tick wire traffic is unchanged.
|
||||
- **No new opt-in.** The nodes ship enabled by default and are
|
||||
read-only (`SecurityClassification.ViewOnly`); no LDAP group needs
|
||||
the new permission.
|
||||
- **Reconnect re-baselines.** Per the FWLIB session boundary the
|
||||
derivation state resets on reconnect / reinit, so the first cycle
|
||||
observed after a reconnect re-establishes the baseline before
|
||||
publishing the first post-reconnect delta.
|
||||
|
||||
See [`docs/drivers/FOCAS.md`](../drivers/FOCAS.md) § "Fixed-tree
|
||||
`Production/` projection" for the full edge-case behaviour matrix
|
||||
(parts-count counter reset, cycle-timer rollover, parts-count jumps
|
||||
> 1).
|
||||
|
||||
## Write safety — issue #269 (PARAM/MACRO, F4-b) + issue #270 (PMC, F4-c)
|
||||
|
||||
The FOCAS driver supports `cnc_wrparam`, `cnc_wrmacro`, and `pmc_wrpmcrng`
|
||||
writes behind multiple independent opt-ins. A misdirected parameter write
|
||||
can put the CNC in a bad state; a misdirected PMC write can move motion or
|
||||
latch a feedhold. The runbook below MUST be followed before flipping any
|
||||
of the granular kill switches on.
|
||||
|
||||
### Operator pre-checks (every deployment, every change)
|
||||
|
||||
1. **CNC must be in MDI mode.** Most parameter writes fail with `EW_PASSWD`
|
||||
(surfaces as `BadUserAccessDenied`) unless the CNC is in MDI. The
|
||||
server-side write returns immediately with the access-denied status; no
|
||||
value reaches the wire.
|
||||
2. **Parameter-write switch enabled on the CNC pendant.** Even in MDI mode
|
||||
protected parameters require the operator to physically enable the
|
||||
parameter-write switch. Without it `cnc_wrparam` returns `EW_PASSWD`.
|
||||
Plan PR F4-d will land an OPC UA-side unlock workflow; today the only
|
||||
path is the pendant.
|
||||
3. **Verify each tag's address against the FANUC manual.** Ranges vary per
|
||||
CNC series; the
|
||||
[`focas-version-matrix`](./focas-version-matrix.md) capability matrix
|
||||
rejects out-of-range numbers at startup, but address-vs-meaning is the
|
||||
operator's job.
|
||||
4. **Dry run with `Writable = true` but `Writes.AllowParameter = false`.**
|
||||
Staged opt-in catches mis-mapped tags: every PARAM write returns
|
||||
`BadNotWritable` until you flip the granular flag, so you can confirm
|
||||
the tag list before any wire write fires.
|
||||
|
||||
### PMC pre-checks (in addition to the above) — F4-c
|
||||
|
||||
PMC writes have a higher blast radius than PARAM/MACRO writes because PMC
|
||||
is the ladder's working memory — bits in R/G/F/D directly drive servo
|
||||
enables, feedhold latches, and safety interlocks. Before flipping
|
||||
`Writes.AllowPmc` on:
|
||||
|
||||
1. **E-stop verified live + reachable.** The first PMC write of a session
|
||||
should be issued with the operator's hand on the e-stop. PMC writes
|
||||
bypass the ladder's normal MDI-mode protections; a misdirected bit can
|
||||
move motion the moment it lands on the wire.
|
||||
2. **Machine in JOG mode (or equivalent low-energy mode).** Auto / MEM
|
||||
modes interpret PMC state immediately; JOG / MDI surface symptoms
|
||||
slowly enough that the e-stop is the recovery path. **Never issue the
|
||||
first PMC write of a deployment in Auto.**
|
||||
3. **Audit the PMC tag list against the ladder print-out.** `R100.3` on
|
||||
one machine is "homing complete"; on another it's "feedhold released".
|
||||
The driver has no way to distinguish — the ladder source is the only
|
||||
ground truth.
|
||||
4. **Bit writes are read-modify-write — see
|
||||
[`docs/drivers/FOCAS.md`](../drivers/FOCAS.md) "PMC bit-write read-modify-write semantics".**
|
||||
`pmc_wrpmcrng` is byte-addressed; the driver reads the parent byte
|
||||
first, masks the target bit, and writes the byte back. Concurrent
|
||||
ladder writes to the same byte create a small race window. Coordinate
|
||||
through a ladder-side handshake when this matters.
|
||||
5. **Dry run with `Writable = true` but `Writes.AllowPmc = false`.** Same
|
||||
staged-opt-in pattern as PARAM/MACRO — confirm tag mapping before any
|
||||
PMC byte hits the wire.
|
||||
|
||||
### LDAP group requirements
|
||||
|
||||
Per [`docs/security.md`](../security.md) the server-layer ACL maps
|
||||
`SecurityClassification` to LDAP groups. Post-F4-b:
|
||||
|
||||
| Tag kind | LDAP group required |
|
||||
| --- | --- |
|
||||
| `PARAM:N` (writable) | **`WriteConfigure`** — heaviest write tier; matches commissioning roles |
|
||||
| `MACRO:N` (writable) | `WriteOperate` — standard HMI recipe / setpoint group |
|
||||
| PMC R/G/F (writable) | `WriteOperate` |
|
||||
| Read-only | `ReadOnly` |
|
||||
|
||||
Per the `feedback_acl_at_server_layer` design note, the FOCAS driver
|
||||
declares the classification but does NOT enforce it; `DriverNodeManager`
|
||||
applies the gate before the driver's `WriteAsync` ever runs. A user
|
||||
without `WriteConfigure` who attempts a `PARAM:` write gets
|
||||
`BadUserAccessDenied` from the server with no driver-level audit entry —
|
||||
the OPC UA layer's audit log catches it.
|
||||
|
||||
### Audit-log expectations
|
||||
|
||||
Every successful write produces:
|
||||
|
||||
- An OPC UA AuditWriteEvent (server layer — see
|
||||
[`docs/security.md`](../security.md) "Audit logging").
|
||||
- A FOCAS driver-level Serilog entry tagged `Driver=FOCAS DriverInstanceId=...
|
||||
TagName=... Address=... ResultStatus=...`.
|
||||
- A `Writes/LastWriteAt` and `Writes/LastWriteStatus` diagnostic counter
|
||||
refresh on the device's `Diagnostics/` fixed-tree node (planned;
|
||||
populated as F4-c lands).
|
||||
|
||||
Failures to write (`BadUserAccessDenied`, `BadCommunicationError`, etc.)
|
||||
produce the same audit entries with the failure status code so a
|
||||
post-incident reviewer sees the same shape regardless of whether the write
|
||||
succeeded.
|
||||
|
||||
**Audit PMC writes specifically.** Because PMC writes have the highest blast
|
||||
radius of the three write kinds, ops should set up a saved-search /
|
||||
dashboard query for `Driver=FOCAS` + `Address` matching the PMC letter
|
||||
prefixes (`R*`, `G*`, `F*`, `D*`, `Y*`, etc.) and review on the same
|
||||
cadence as ladder change reviews. A spike in PMC write rate or a write
|
||||
to an address outside the audited tag list is the leading indicator of a
|
||||
misconfigured client or compromised credential.
|
||||
|
||||
### Granular config example
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"Drivers": {
|
||||
"FOCAS": {
|
||||
"Devices": [
|
||||
{ "HostAddress": "focas://10.0.0.5:8193", "Series": "Series30i" }
|
||||
],
|
||||
"Writes": {
|
||||
"Enabled": true,
|
||||
"AllowMacro": true, // recipe / setpoint writes — operator role
|
||||
"AllowParameter": false, // commissioning only — keep locked except during planned work
|
||||
"AllowPmc": false // PMC writes — keep locked unless the deployment specifically needs them
|
||||
},
|
||||
"Tags": [
|
||||
{ "Name": "Recipe.PartCount", "DeviceHostAddress": "focas://10.0.0.5:8193",
|
||||
"Address": "MACRO:500", "DataType": "Int32",
|
||||
"Writable": true, "WriteIdempotent": true },
|
||||
{ "Name": "MaxFeedrate", "DeviceHostAddress": "focas://10.0.0.5:8193",
|
||||
"Address": "PARAM:1815", "DataType": "Int32",
|
||||
"Writable": false /* keep read-only until commissioning window */ },
|
||||
{ "Name": "OperatorRequest", "DeviceHostAddress": "focas://10.0.0.5:8193",
|
||||
"Address": "R100.3", "DataType": "Bit",
|
||||
"Writable": false /* keep PMC read-only until ladder handshake reviewed */ }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Flipping `AllowParameter` / `AllowPmc` on for the commissioning window
|
||||
(and back off afterward) is the recommended deployment cadence — the
|
||||
granular kill switches are lightweight runtime toggles, not config-DB
|
||||
redeploys. PMC in particular should default OFF in production and only
|
||||
flip on for windows where the ladder team has signed off on the write
|
||||
path.
|
||||
|
||||
## FOCAS password handling — issue #271 (F4-d)
|
||||
|
||||
Some controllers (16i + certain 30i firmwares with parameter-protect on)
|
||||
gate `cnc_wrparam` and selected reads behind a connection-level password.
|
||||
The driver supports this via the `Password` field on `FocasDeviceOptions`
|
||||
which is emitted via `cnc_wrunlockparam` on connect and re-emitted on any
|
||||
`EW_PASSWD` read/write retry path. See
|
||||
[`docs/drivers/FOCAS.md`](../drivers/FOCAS.md) § "FOCAS password" for the
|
||||
driver-side behaviour; this section covers the deployment side.
|
||||
|
||||
### Storage in `appsettings.json`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"Drivers": {
|
||||
"Focas01": {
|
||||
"DriverConfigJson": {
|
||||
"Backend": "fwlib",
|
||||
"Series": "Sixteen_i",
|
||||
"Devices": [
|
||||
{
|
||||
"HostAddress": "focas://10.0.0.5:8193",
|
||||
"Password": "1234"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For dev environments, the password is materialised under
|
||||
`.local/focas-passwords.txt` (or whichever .local subkey the deployment
|
||||
team prefers); production deployments use the same secrets-store /
|
||||
KeyVault pattern the LDAP `Authentication.Ldap.Password` field follows.
|
||||
**The `.local/` directory is .gitignore'd** — this is the same posture
|
||||
as `.local/galaxy-host-secret.txt` and other dev secrets in this repo.
|
||||
|
||||
### No-log invariant
|
||||
|
||||
The driver guarantees the password is **never logged**:
|
||||
|
||||
1. **`FocasDeviceOptions` ToString redaction.** The record overrides
|
||||
`PrintMembers` so any Serilog destructure of the device options renders
|
||||
`Password = ***` when the field is non-null. This catches the most
|
||||
common leak path — a structured-log statement that included
|
||||
`{@Device}` for diagnostic context.
|
||||
2. **No password in exception messages.** `FwlibFocasClient.UnlockAsync`
|
||||
omits the password from its `InvalidOperationException` text — only
|
||||
the FWLIB error code (`EW_PASSWD`, `EW_HANDLE`, etc.) makes it through.
|
||||
3. **Driver log line uses host only.** When unlock succeeds the driver
|
||||
updates `DriverHealth.StatusText` to `"FOCAS unlock applied for
|
||||
{host}"` — no password.
|
||||
4. **CLI flag covered by the same choke point.** The
|
||||
`Driver.FOCAS.Cli --cnc-password` flag flows through
|
||||
`FocasDeviceOptions.Password`, so its redaction is identical to the
|
||||
server's. The PowerShell e2e harness (`scripts/e2e/test-focas.ps1
|
||||
-CncPassword`) follows the same path.
|
||||
|
||||
Any new logging surface that touches `FocasDeviceOptions` MUST continue
|
||||
to use the record's `ToString` (or otherwise omit `Password`). A code
|
||||
review checklist item: "no log statement contains `device.Options.Password`
|
||||
or `device.Password` directly."
|
||||
|
||||
### Password-rotation runbook
|
||||
|
||||
When the CNC password rotates (operator team flipped a parameter-protect
|
||||
gate, or your security policy requires periodic rotation):
|
||||
|
||||
1. **Update the password on the controller** (CNC pendant or vendor's
|
||||
admin tool). The exact path varies by series — Fanuc service manual
|
||||
page reference depends on the MTB.
|
||||
2. **Update `appsettings.json`** in place with the new value.
|
||||
- Production: bump the secrets-store entry that backs the
|
||||
`Devices[*].Password` config-DB column. Same workflow as rotating
|
||||
the LDAP service-account password.
|
||||
- Dev: update `.local/focas-passwords.txt` (or wherever the dev
|
||||
deployment sources the secret).
|
||||
3. **Restart the OtOpcUa server** (or trigger a config-DB bump that
|
||||
forces driver reinitialise). The driver picks up the new password
|
||||
on the next `EnsureConnectedAsync` call. **No need to manually
|
||||
reconnect each device** — `cnc_wrunlockparam` emits on the next
|
||||
wire-call boundary.
|
||||
4. **Verify**. The first wire call after restart logs
|
||||
`"FOCAS unlock applied for focas://{host}:{port}"` at info. A wrong
|
||||
password surfaces as `BadUserAccessDenied` on the next gated read or
|
||||
write.
|
||||
5. **Audit.** OPC UA wrote-event entries (per
|
||||
[`audit-log-rules.md`](audit-log-rules.md)) cover the
|
||||
parameter/macro write paths. Password rotation itself is NOT logged
|
||||
beyond "unlock applied" — same posture as LDAP service-account
|
||||
rotation, where the password change is logged out-of-band by the IAM
|
||||
system.
|
||||
|
||||
### Cross-references
|
||||
|
||||
- [`docs/Security.md`](../Security.md) — server-wide secrets handling +
|
||||
the same `.local/` pattern used for LDAP and the Galaxy.Host pipe
|
||||
secret. The FOCAS password follows the same posture.
|
||||
- [`docs/drivers/FOCAS.md`](../drivers/FOCAS.md) § "FOCAS password" —
|
||||
driver-side behaviour, EW_PASSWD retry semantics, status-code
|
||||
surface.
|
||||
- [`docs/v2/implementation/focas-wire-protocol.md`](implementation/focas-wire-protocol.md)
|
||||
§ "cnc_wrunlockparam" — wire-frame layout for the password buffer.
|
||||
@@ -0,0 +1,394 @@
|
||||
# FOCAS simulator (focas-mock) plan
|
||||
|
||||
Notes on the focas-mock simulator that the FOCAS driver's integration
|
||||
tests will eventually talk to. Today there is no FOCAS integration-test
|
||||
project; this doc is the contract the future fixture will be built
|
||||
against. Keeping the contract tracked in repo means the wire-protocol
|
||||
command ids (and their request/response payloads) don't drift between the
|
||||
.NET wire client and a future Python implementation.
|
||||
|
||||
## Ground rules
|
||||
|
||||
- Append-only command ids. Mirror
|
||||
[`focas-wire-protocol.md`](./focas-wire-protocol.md) verbatim.
|
||||
- Per-profile state. The simulator hosts N CNC profiles concurrently
|
||||
(`Series0i`, `Series30i`, `PowerMotion`, ...). Each profile has its own
|
||||
alarm-history ring buffer + its own override map.
|
||||
- Admin endpoints under `POST /admin/...` mutate state without going
|
||||
through the wire protocol; integration tests use these to seed canned
|
||||
inputs.
|
||||
|
||||
## Protocol surface (current scope)
|
||||
|
||||
| Cmd | API | State impact |
|
||||
| --- | --- | --- |
|
||||
| `0x0001` | `cnc_rdcncstat` | reads cached ODBST per profile |
|
||||
| `0x0002` | `cnc_rdparam` | reads parameter map per profile |
|
||||
| `0x0003` | `cnc_rdmacro` | reads macro variables per profile |
|
||||
| `0x0004` | `cnc_rddiag` | reads diagnostic map per profile |
|
||||
| `0x0010` | `pmc_rdpmcrng` | reads PMC byte ranges |
|
||||
| `0x0020` | `cnc_modal` | reads cached modal MSTB per profile |
|
||||
| ... | ... | ... |
|
||||
| **`0x0102`** | **`cnc_wrparam`** | **mutates per-profile parameter map; returns `EW_PASSWD` (`11`) when the profile's `unlock_state` is off (sets up F4-d's unlock workflow) — issue #269, plan PR F4-b** |
|
||||
| **`0x0103`** | **`cnc_wrmacro`** | **mutates per-profile macro map; integer-only writes for now (decimalPointCount=0) — issue #269, plan PR F4-b** |
|
||||
| **`0x0104`** | **`pmc_wrpmcrng`** | **mutates per-profile PMC byte tables; byte-aligned writes preserve untouched bytes; bit-level writes never reach the simulator (driver wraps with RMW) — issue #270, plan PR F4-c** |
|
||||
| **`0x0105`** | **`cnc_wrunlockparam`** | **flips the per-profile `unlock_state` to true when the supplied 4-byte password buffer matches the profile's `unlock_password`; otherwise returns `EW_PASSWD`. State persists for the connection lifetime (per-session). — issue #271, plan PR F4-d** |
|
||||
| **`0x0F1A`** | **`cnc_rdalmhistry`** | **dumps the per-profile alarm-history ring buffer (issue #267, plan PR F3-a)** |
|
||||
|
||||
## `cnc_rdalmhistry` mock behaviour
|
||||
|
||||
The simulator keeps a per-profile ring buffer of alarm-history entries.
|
||||
Default fixture seeds 5 profiles with 10 canned entries each (per the F3-a
|
||||
plan).
|
||||
|
||||
### Request decode
|
||||
|
||||
```
|
||||
[int16 LE depth]
|
||||
```
|
||||
|
||||
### Response encode
|
||||
|
||||
Use `FocasAlarmHistoryDecoder.Encode` semantics in reverse: emit the
|
||||
count followed by `ALMHIS_data` blocks padded to 4-byte boundaries. The
|
||||
.NET-side decoder consumes the same format verbatim, so a Python encoder
|
||||
written against the table in
|
||||
[`focas-wire-protocol.md`](./focas-wire-protocol.md) interoperates without
|
||||
extra glue.
|
||||
|
||||
### Admin endpoint — `POST /admin/mock_patch_alarmhistory`
|
||||
|
||||
Replaces the alarm-history ring buffer for a profile.
|
||||
|
||||
```
|
||||
POST /admin/mock_patch_alarmhistory
|
||||
{
|
||||
"profile": "Series30i",
|
||||
"entries": [
|
||||
{
|
||||
"occurrenceTime": "2025-04-01T09:30:00Z",
|
||||
"axisNo": 1,
|
||||
"alarmType": 2,
|
||||
"alarmNumber": 100,
|
||||
"message": "Spindle overload"
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`entries` order is interpreted as ring-buffer order (most-recent first to
|
||||
match FANUC's natural surface).
|
||||
|
||||
### `FocasSimFixture.SeedAlarmHistoryAsync`
|
||||
|
||||
The future test-support helper wraps the admin endpoint:
|
||||
|
||||
```csharp
|
||||
await fixture.SeedAlarmHistoryAsync(
|
||||
profile: "Series30i",
|
||||
entries: new []
|
||||
{
|
||||
new FocasAlarmHistoryEntry(
|
||||
new DateTimeOffset(2025, 4, 1, 9, 30, 0, TimeSpan.Zero),
|
||||
AxisNo: 1, AlarmType: 2, AlarmNumber: 100, Message: "Spindle overload"),
|
||||
});
|
||||
```
|
||||
|
||||
Integration test `Series/AlarmHistoryProjectionTests.cs` will assert:
|
||||
|
||||
- historic events fire once with the seeded timestamps
|
||||
- second poll yields zero new events (dedup honoured end-to-end)
|
||||
- active-alarm raise/clear still works alongside the history poll
|
||||
|
||||
These tests are blocked on the focas-mock + integration-test project
|
||||
landing; the unit-test coverage in `FocasAlarmProjectionTests` already
|
||||
exercises every same-process invariant.
|
||||
|
||||
## `cnc_wrparam` / `cnc_wrmacro` mock behaviour — issue #269, plan PR F4-b
|
||||
|
||||
When the focas-mock fixture lands, it MUST implement the contract below.
|
||||
The .NET side already ships against this contract (`FwlibFocasClient.cs`
|
||||
write helpers, `FakeFocasClient` round-trip support); writing the simulator
|
||||
to the same shape lets the existing integration-test scaffolds at
|
||||
`tests/.../IntegrationTests/Series/ParameterWriteTests.cs` and
|
||||
`MacroWriteTests.cs` (when they materialise) light up without driver
|
||||
changes.
|
||||
|
||||
### Per-profile state
|
||||
|
||||
Each profile owns:
|
||||
|
||||
- `parameters: Dict[int, int]` — map from parameter number to current value.
|
||||
- `macros: Dict[int, int]` — map from macro number to current scaled-int
|
||||
value (decimal-point count fixed at 0 for F4-b).
|
||||
- `unlock_state: bool` — defaults `False`. When `False`, every
|
||||
`cnc_wrparam` returns `EW_PASSWD` (numeric `11`) regardless of
|
||||
parameter. Macro writes are NOT gated by `unlock_state`.
|
||||
- `unlock_password: bytes` (4-byte buffer) — defaults to the profile's
|
||||
fixture default (e.g. `b"1234"` for Series30i). Compared byte-for-byte
|
||||
by the `cnc_wrunlockparam` handler; flips `unlock_state = True` on
|
||||
match, leaves it untouched on mismatch (and returns `EW_PASSWD`).
|
||||
Mutable via `POST /admin/mock_set_password` for tests that exercise
|
||||
rotation. Issue #271, plan PR F4-d.
|
||||
- `last_write: Optional[LastWrite]` — most-recent successful
|
||||
`(kind, number, value, ts)` tuple, surfaced via the admin endpoint
|
||||
below for audit-log assertions.
|
||||
|
||||
### `cnc_wrparam` request decode
|
||||
|
||||
```
|
||||
[int16 LE datano][int16 LE axis][int8|int16|int32 LE value]
|
||||
```
|
||||
|
||||
Width of the value field is determined by the request frame trailer
|
||||
length per the table in
|
||||
[`focas-wire-protocol.md`](./focas-wire-protocol.md). On
|
||||
`unlock_state == False` short-circuit to `[int16 LE 11]` (`EW_PASSWD`).
|
||||
Otherwise mutate `parameters[datano] = value`, set `last_write`, return
|
||||
`[int16 LE 0]`.
|
||||
|
||||
### `cnc_wrmacro` request decode
|
||||
|
||||
```
|
||||
[int16 LE number][int16 LE length=8][int32 LE mcr_val][int16 LE dec_val]
|
||||
```
|
||||
|
||||
Always accept (no `unlock_state` gate). Mutate
|
||||
`macros[number] = mcr_val` (we ignore `dec_val` for F4-b — integer-only).
|
||||
Return `[int16 LE 0]`. Round-trip: a subsequent `cnc_rdmacro(number)`
|
||||
returns `(mcr_val, 0)`.
|
||||
|
||||
### Admin endpoint — `POST /admin/mock_set_unlock_state`
|
||||
|
||||
Toggles `unlock_state` for the F4-d unlock workflow tests. Without this,
|
||||
F4-b parameter-write integration tests can't reproduce the
|
||||
`EW_PASSWD` → `BadUserAccessDenied` mapping.
|
||||
|
||||
```
|
||||
POST /admin/mock_set_unlock_state
|
||||
{ "profile": "Series30i", "unlocked": true }
|
||||
```
|
||||
|
||||
### `cnc_wrunlockparam` request decode — issue #271, plan PR F4-d
|
||||
|
||||
```
|
||||
[byte[4] password]
|
||||
```
|
||||
|
||||
Match `password == profile.unlock_password` byte-for-byte. On match:
|
||||
flip `unlock_state = True`, return `[int16 LE 0]`. On mismatch: leave
|
||||
`unlock_state` untouched, return `[int16 LE 11]` (`EW_PASSWD`).
|
||||
|
||||
The simulator deliberately keeps unlock state per-session (per OpenSession
|
||||
handle) so a reconnect drops back to `unlock_state = False` — matching the
|
||||
FWLIB lifetime semantics described in
|
||||
[`focas-wire-protocol.md`](./focas-wire-protocol.md) § "cnc_wrunlockparam".
|
||||
|
||||
### Admin endpoint — `POST /admin/mock_set_password`
|
||||
|
||||
Rotates the per-profile `unlock_password` for tests that exercise the
|
||||
F4-d password-rotation runbook (`docs/v2/focas-deployment.md`
|
||||
§ "FOCAS password handling"). Idempotent — call again to revert.
|
||||
|
||||
```
|
||||
POST /admin/mock_set_password
|
||||
{ "profile": "Series30i", "password": "5678" }
|
||||
```
|
||||
|
||||
The endpoint accepts the password as a UTF-8/ASCII string and applies
|
||||
the same right-pad-to-4-bytes / truncate-to-4-bytes normalisation the
|
||||
driver does, so simulator-side matching is byte-symmetric with the
|
||||
production wire encoder.
|
||||
|
||||
### Admin endpoint — `GET /admin/mock_get_last_write`
|
||||
|
||||
Returns the simulator's view of the most-recent successful write, used by
|
||||
F4-b audit-log integration assertions ("did the write actually reach the
|
||||
fixture, and is the audit log capturing the right kind/number/value?").
|
||||
|
||||
```
|
||||
GET /admin/mock_get_last_write?profile=Series30i
|
||||
->
|
||||
{
|
||||
"kind": "param", // "param" | "macro"
|
||||
"number": 1815,
|
||||
"value": 100,
|
||||
"writtenAt": "2026-04-25T13:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
When no write has happened the endpoint returns `null` rather than 404 so
|
||||
the test helper can assert "no writes since fixture reset" without
|
||||
exception handling.
|
||||
|
||||
## `pmc_wrpmcrng` mock behaviour — issue #270, plan PR F4-c
|
||||
|
||||
The simulator keeps a per-profile PMC byte table keyed by `(addr_type,
|
||||
byte_address)` — the same map the existing `pmc_rdpmcrng` handler reads
|
||||
from. The write handler mutates the same map so a subsequent read sees
|
||||
the written bytes.
|
||||
|
||||
### Per-profile state
|
||||
|
||||
Each profile carries:
|
||||
|
||||
```python
|
||||
pmc: Dict[int, bytearray] # addr_type -> bytearray (one per PMC letter, default 256 bytes each)
|
||||
```
|
||||
|
||||
`addr_type` is the PMC area code (R=5, G=4, F=3, D=8, X=1, Y=2, K=10,
|
||||
A=11, E=12, T=6, C=7); the existing `pmc_rdpmcrng` fixture seeds the
|
||||
defaults (zeros + a few canned bits per the dl205-style profile fixtures).
|
||||
|
||||
### `pmc_wrpmcrng` request decode
|
||||
|
||||
| Offset | Width | Field |
|
||||
| --- | --- | --- |
|
||||
| 0 | int16 LE | `addr_type` |
|
||||
| 2 | int16 LE | `data_type` (must be `0` = byte; the driver only emits byte writes) |
|
||||
| 4 | uint16 LE | `datano_s` |
|
||||
| 6 | uint16 LE | `datano_e` |
|
||||
| 8 | bytes | `data[]` — `(datano_e - datano_s + 1)` bytes |
|
||||
|
||||
Handler steps:
|
||||
|
||||
1. Look up the per-profile bytearray for `addr_type` (allocate on first
|
||||
write, default 256 zeros).
|
||||
2. **Validate** `0 <= datano_s <= datano_e < len(bytearray)` — otherwise
|
||||
return `EW_NUMBER` (`4`).
|
||||
3. **Validate** `len(data) == datano_e - datano_s + 1` — otherwise
|
||||
return `EW_LENGTH` (`14`).
|
||||
4. **Validate** `data_type == 0` — otherwise return `EW_DATA` (`9`)
|
||||
because the driver only ever emits byte writes (bit writes wrap with
|
||||
driver-side RMW so they reach the simulator as 1-byte writes).
|
||||
5. Copy `data[]` into `bytearray[datano_s:datano_e+1]`. Other bytes
|
||||
in the array are untouched.
|
||||
6. Update `last_write` admin-endpoint state (kind=`pmc`, address-type,
|
||||
start byte, length, bytes).
|
||||
7. Return `ew_status = 0`.
|
||||
|
||||
### Round-trip invariant
|
||||
|
||||
The simulator MUST satisfy:
|
||||
|
||||
```
|
||||
write(R, [10..12], [0xAA, 0xBB, 0xCC]); read(R, [10..12]) == [0xAA, 0xBB, 0xCC]
|
||||
```
|
||||
|
||||
and the **byte-isolation invariant**:
|
||||
|
||||
```
|
||||
write(R, [11], [0xFF]); bytes[10] == prior bytes[10] && bytes[12] == prior bytes[12]
|
||||
```
|
||||
|
||||
The integration tests `Series/PmcRangeWriteTests.cs` and
|
||||
`Series/PmcBitRmwIntegrationTests.cs` assert both shapes.
|
||||
|
||||
### Admin endpoint — `GET /admin/mock_get_last_write` extension
|
||||
|
||||
The `last_write` payload gains a `kind: "pmc"` variant:
|
||||
|
||||
```
|
||||
{
|
||||
"kind": "pmc",
|
||||
"addr_type": 5, // R
|
||||
"datano_s": 100,
|
||||
"datano_e": 100,
|
||||
"bytes": "0x08", // hex-encoded
|
||||
"writtenAt": "2026-04-25T13:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Bit-level writes never appear here as a separate kind — they reach the
|
||||
simulator as 1-byte writes after the driver's RMW wrapper, so the audit
|
||||
shape is identical to a byte write at the same address.
|
||||
|
||||
## Cycle-time per part / last cycle delta — F5-a (issue #272)
|
||||
|
||||
Plan PR F5-a derives `Production/LastCycleSeconds` +
|
||||
`Production/LastCycleStartUtc` from the existing `cnc_rdparam(6711)` +
|
||||
`cnc_rdtimer` snapshot stream — **pure derivation, no new wire calls**.
|
||||
The simulator does NOT need new wire commands; the existing
|
||||
`cnc_rdparam` + `cnc_rdtimer` handlers already cover the read surface.
|
||||
|
||||
What focas-mock DOES need is an admin endpoint + test-fixture helper
|
||||
that lets integration tests atomically increment the parts-count
|
||||
counter alongside the cycle-time timer so the driver sees a clean
|
||||
"cycle completed" transition on the next probe tick.
|
||||
|
||||
### Per-profile state
|
||||
|
||||
Already covered by the existing F1-b state map:
|
||||
|
||||
- `parameters: Dict[int, int]` (entry `6711` is the parts-count counter).
|
||||
- `timers: Dict[int, int]` (entry `0` is the live cycle-time counter,
|
||||
in seconds).
|
||||
|
||||
### Admin endpoint — `POST /admin/mock_simulate_cycle_completion`
|
||||
|
||||
Atomically advances both values to model "the CNC just finished a
|
||||
cycle". Atomicity matters: the F5-a derivation samples both fields on
|
||||
every probe tick, so if the simulator updated parts-count and the
|
||||
timer in two separate writes the test could observe an intermediate
|
||||
state where parts-count incremented but the timer hasn't updated yet
|
||||
(producing a misleading `LastCycleSeconds`).
|
||||
|
||||
```
|
||||
POST /admin/mock_simulate_cycle_completion
|
||||
{
|
||||
"profile": "Series30i",
|
||||
"partsDelta": 1, // default 1; tests asserting backfill use 3+
|
||||
"newCycleTimerSeconds": 18 // absolute value, NOT a delta
|
||||
}
|
||||
```
|
||||
|
||||
Handler steps:
|
||||
|
||||
1. `parameters[6711] += partsDelta` (under the per-profile lock).
|
||||
2. `timers[0] = newCycleTimerSeconds`.
|
||||
3. Return `200 OK` with the new values for verification.
|
||||
|
||||
The endpoint MUST hold the profile's update lock for the full
|
||||
read-modify-write so a concurrent `cnc_rdparam` + `cnc_rdtimer` poll
|
||||
sees both fields in their pre-update OR post-update state — never
|
||||
half-applied.
|
||||
|
||||
### `FocasSimFixture.SimulateCycleCompletionAsync`
|
||||
|
||||
The future test-support helper wraps the admin endpoint:
|
||||
|
||||
```csharp
|
||||
await fixture.SimulateCycleCompletionAsync(
|
||||
profile: "Series30i",
|
||||
partsDelta: 1,
|
||||
newCycleTimerSeconds: 18);
|
||||
```
|
||||
|
||||
Integration test `Series/CycleDeltaTests.cs` will assert:
|
||||
|
||||
- After a 5 -> 6 transition with `newCycleTimerSeconds=18`, the
|
||||
driver's `Production/LastCycleSeconds` settles to `currentTimer -
|
||||
prevTimer`.
|
||||
- `Production/LastCycleStartUtc` is within driver-tolerance of
|
||||
`nowUtc - LastCycleSeconds` (allow a small window for probe-tick
|
||||
jitter).
|
||||
- Counter reset (parts -> 0) preserves the last published values.
|
||||
- Cycle-timer rollover does not publish a negative delta.
|
||||
|
||||
These tests are blocked on the focas-mock + integration-test project
|
||||
landing; the unit-test coverage in `FocasCycleDeltaTests` already
|
||||
exercises every same-process invariant of the derivation.
|
||||
|
||||
### Status
|
||||
|
||||
focas-mock simulator has not landed yet (tracked separately from F4-b /
|
||||
F4-c). F4-b + F4-c land the .NET-side wire encoders + dispatch + status
|
||||
mapping unconditionally; the integration-test scaffolds at
|
||||
`tests/.../IntegrationTests/Series/ParameterWriteTests.cs`,
|
||||
`MacroWriteTests.cs`, `PmcRangeWriteTests.cs`, and
|
||||
`PmcBitRmwIntegrationTests.cs` are deferred until the simulator +
|
||||
integration-test project land. Until then unit-test coverage in
|
||||
`FocasWriteParameterTests` / `FocasWriteMacroTests` /
|
||||
`FocasWritePmcTests` exercises every same-process invariant against the
|
||||
in-memory `FakeFocasClient`.
|
||||
@@ -0,0 +1,267 @@
|
||||
# FOCAS wire protocol — packed-buffer surface
|
||||
|
||||
Notes on the language-neutral packed-buffer encoding the FOCAS driver +
|
||||
focas-mock simulator share. This format is **not** the FWLIB native struct
|
||||
layout — Tier-C Fwlib32 backends marshal directly from the FANUC C struct.
|
||||
The packed surface exists so the simulator (Python / FastAPI) and the .NET
|
||||
wire client can speak a common format over IPC without piping a Win32 DLL
|
||||
through both ends.
|
||||
|
||||
## Command id table
|
||||
|
||||
Each FOCAS-equivalent call gets a stable wire-protocol command id. Ids are
|
||||
**append-only** — never renumber, never reuse.
|
||||
|
||||
| Id | FOCAS API | Surface |
|
||||
| --- | --- | --- |
|
||||
| `0x0001` | `cnc_rdcncstat` | ODBST 9-field status struct |
|
||||
| `0x0002` | `cnc_rdparam` | parameter value (one number) |
|
||||
| `0x0003` | `cnc_rdmacro` | macro variable value |
|
||||
| `0x0004` | `cnc_rddiag` | diagnostic value |
|
||||
| ... | ... | ... |
|
||||
| **`0x0102`** | **`cnc_wrparam`** | **IODBPSD parameter-write packet (issue #269, plan PR F4-b)** |
|
||||
| **`0x0103`** | **`cnc_wrmacro`** | **ODBM macro-write packet (issue #269, plan PR F4-b)** |
|
||||
| **`0x0104`** | **`pmc_wrpmcrng`** | **IODBPMC PMC range-write packet (issue #270, plan PR F4-c)** |
|
||||
| **`0x0105`** | **`cnc_wrunlockparam`** | **4-byte password buffer for the parameter-protect / read-protect unlock (issue #271, plan PR F4-d)** |
|
||||
| `0x0F1A` | **`cnc_rdalmhistry`** | **ODBALMHIS alarm-history ring-buffer dump (issue #267, plan PR F3-a)** |
|
||||
|
||||
## ODBALMHIS — alarm history (`cnc_rdalmhistry`, command `0x0F1A`)
|
||||
|
||||
Issued by `FocasAlarmProjection` when
|
||||
`FocasDriverOptions.AlarmProjection.Mode == ActivePlusHistory`. Returns up
|
||||
to `depth` most-recent ring-buffer entries.
|
||||
|
||||
### Request
|
||||
|
||||
| Offset | Width | Field | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 | `int16 LE` | `depth` | clamped client-side to `[1..250]` (`FocasAlarmProjectionOptions.MaxHistoryDepth`) |
|
||||
|
||||
### Response (packed buffer, little-endian)
|
||||
|
||||
| Offset | Width | Field |
|
||||
| --- | --- | --- |
|
||||
| 0 | `int16 LE` | `num_alm` — number of entries that follow. `< 0` indicates CNC error. |
|
||||
| 2 | repeated | `ALMHIS_data alm[num_alm]` (see below) |
|
||||
|
||||
Each entry block:
|
||||
|
||||
| Offset (rel.) | Width | Field |
|
||||
| --- | --- | --- |
|
||||
| 0 | `int16 LE` | `year` |
|
||||
| 2 | `int16 LE` | `month` |
|
||||
| 4 | `int16 LE` | `day` |
|
||||
| 6 | `int16 LE` | `hour` |
|
||||
| 8 | `int16 LE` | `minute` |
|
||||
| 10 | `int16 LE` | `second` |
|
||||
| 12 | `int16 LE` | `axis_no` (1-based; 0 = whole-CNC) |
|
||||
| 14 | `int16 LE` | `alm_type` (P/S/OT/SV/SR/MC/SP/PW/IO encoded numerically) |
|
||||
| 16 | `int16 LE` | `alm_no` |
|
||||
| 18 | `int16 LE` | `msg_len` (0..32 typical) |
|
||||
| 20 | `msg_len` | ASCII message (no null terminator) |
|
||||
| `20 + msg_len` | 0..3 | pad to 4-byte boundary so per-entry blocks stay self-delimiting |
|
||||
|
||||
The CNC stamps `year..second` in **its own local time**. The deployment
|
||||
guide instructs operators to keep CNC clocks on UTC so the projection's
|
||||
dedup key `(OccurrenceTime, AlarmNumber, AlarmType)` stays stable across
|
||||
DST transitions. The .NET decoder
|
||||
(`Wire/FocasAlarmHistoryDecoder.Decode`) constructs each
|
||||
`DateTimeOffset` with `TimeSpan.Zero` (UTC) on that assumption.
|
||||
|
||||
### Error handling
|
||||
|
||||
- A negative `num_alm` short-circuits decode to an empty list — the
|
||||
projection treats it as "no history this tick" and the next poll
|
||||
retries.
|
||||
- Malformed timestamps (e.g. month=0) are skipped per-entry instead of
|
||||
faulting the whole decode; the dedup key for malformed entries would be
|
||||
unstable anyway.
|
||||
- `msg_len` overrunning the payload truncates the entry list at the
|
||||
malformed entry rather than throwing.
|
||||
|
||||
## IODBPSD — parameter write (`cnc_wrparam`, command `0x0102`)
|
||||
|
||||
Issue #269, plan PR F4-b. The write-side payload is the **byte-symmetric
|
||||
inverse of the `cnc_rdparam` read** — the same `IODBPSD` struct shape, and
|
||||
the .NET wire client uses the read-side decoder reversed (`EncodeParamValue`
|
||||
in `FwlibFocasClient.cs`) so the encoder/decoder are guaranteed to stay in
|
||||
lock-step.
|
||||
|
||||
### Request
|
||||
|
||||
| Offset | Width | Field |
|
||||
| --- | --- | --- |
|
||||
| 0 | `int16 LE` | `datano` — parameter number (e.g. `1815`) |
|
||||
| 2 | `int16 LE` | `type` — axis index (1-based; `0` = whole-CNC parameter) |
|
||||
| 4 | `length` | `data` payload — width depends on parameter type |
|
||||
|
||||
`length` (request frame trailer, drives `data` width):
|
||||
|
||||
| FocasDataType | `length` | Payload encoding |
|
||||
| --- | --- | --- |
|
||||
| `Byte` | `4 + 1` | one signed byte at offset 4 |
|
||||
| `Int16` | `4 + 2` | int16 LE at offset 4 |
|
||||
| `Int32` | `4 + 4` | int32 LE at offset 4 |
|
||||
|
||||
Bit-addressed parameters (`PARAM:1815/0` form) are not supported by F4-b
|
||||
and surface as `BadNotSupported`; F4-c will land the read-modify-write
|
||||
helper alongside the PMC bit RMW path.
|
||||
|
||||
### Response
|
||||
|
||||
Single `int16 LE` return code per the standard FWLIB convention:
|
||||
|
||||
- `0` → `Good`
|
||||
- `11` (`EW_PASSWD`) → **`BadUserAccessDenied`** (was `BadNotWritable`
|
||||
pre-F4-b — see `FocasStatusMapper`). Means the parameter-write switch is
|
||||
off or the CNC isn't in MDI mode; the F4-d unlock workflow will close
|
||||
the loop on this from the OPC UA side.
|
||||
- Other `EW_*` codes map per
|
||||
[`FocasStatusMapper.MapFocasReturn`](../../src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasStatusMapper.cs).
|
||||
|
||||
## ODBM — macro write (`cnc_wrmacro`, command `0x0103`)
|
||||
|
||||
Issue #269, plan PR F4-b. The write-side payload mirrors the
|
||||
`cnc_rdmacro` read shape: the same `(mcr_val, dec_val)` (integer +
|
||||
decimal-point count) split, but emitted from the .NET side rather than
|
||||
decoded.
|
||||
|
||||
### Request
|
||||
|
||||
| Offset | Width | Field |
|
||||
| --- | --- | --- |
|
||||
| 0 | `int16 LE` | `number` — macro variable number (e.g. `500`) |
|
||||
| 2 | `int16 LE` | `length` — fixed at `8` for ODBM |
|
||||
| 4 | `int32 LE` | `mcr_val` — scaled integer value |
|
||||
| 8 | `int16 LE` | `dec_val` — decimal-point count |
|
||||
|
||||
F4-b ships **integer-only writes** (`dec_val = 0`) to match the most
|
||||
common HMI pattern; a future `WriteMacroScaled` overload will land if the
|
||||
field calls for fractional macro setpoints. Read-side decoders apply
|
||||
`mcr_val / 10^dec_val`, so a `dec_val = 0` write surfaces back as the
|
||||
integer it was emitted as.
|
||||
|
||||
### Response
|
||||
|
||||
Same single-int16 envelope as `cnc_wrparam`. `EW_PASSWD` is rare on macro
|
||||
writes (the gate-switch protection is parameter-specific) but the mapper
|
||||
treats both kinds identically.
|
||||
|
||||
### Symmetry note
|
||||
|
||||
The plan carries a "byte layout symmetry" requirement — the encoder for
|
||||
each kind is the read-side decoder reversed. Adding a new parameter type
|
||||
(e.g. `Int64` parameters, when they ship) means extending both sides in
|
||||
the same PR; the unit test
|
||||
`FocasWriteParameterTests.ParameterWrite_round_trip_stores_value_visible_to_subsequent_read`
|
||||
exercises encode → store → decode with the fake wire client and is the
|
||||
canary for symmetry regressions.
|
||||
|
||||
## IODBPMC — PMC range write (`pmc_wrpmcrng`, command `0x0104`)
|
||||
|
||||
Issue #270, plan PR F4-c. The write-side payload is the read-side
|
||||
`pmc_rdpmcrng` IODBPMC packet with the data direction inverted: the
|
||||
caller fills the `data[]` byte run and the simulator / Fwlib32 stores
|
||||
it; the response is the small status envelope rather than the populated
|
||||
data buffer the read side returns.
|
||||
|
||||
### Request
|
||||
|
||||
| Offset | Width | Field |
|
||||
| --- | --- | --- |
|
||||
| 0 | `int16 LE` | `type_a` — PMC address-type code (R=5, G=4, F=3, D=8, X=1, Y=2, K=10, A=11, E=12, T=6, C=7) |
|
||||
| 2 | `int16 LE` | `type_d` — data type (`0` = byte; only byte writes are issued — bit writes wrap the byte path with a read-modify-write helper) |
|
||||
| 4 | `uint16 LE` | `datano_s` — first byte address (inclusive) |
|
||||
| 6 | `uint16 LE` | `datano_e` — last byte address (inclusive) — `(datano_e - datano_s + 1)` is the byte count |
|
||||
| 8 | `bytes` | `data[]` — payload, exactly `(datano_e - datano_s + 1)` bytes |
|
||||
|
||||
The header is 8 bytes; the FWLIB `IODBPMC.data` field caps at 32 bytes
|
||||
(40-byte total per call), so larger ranges are chunked into 32-byte
|
||||
sub-calls by the wire client. The simulator MUST honour the same chunk
|
||||
ceiling so chunked-vs-single round-trips produce the same final bytes.
|
||||
|
||||
### Response
|
||||
|
||||
Same single-int16 envelope as `cnc_wrparam` / `cnc_wrmacro`:
|
||||
|
||||
| Offset | Width | Field |
|
||||
| --- | --- | --- |
|
||||
| 0 | `int16 LE` | `ew_status` — `0` = success, non-zero = FANUC `EW_*` |
|
||||
|
||||
`EW_NOOPT` (option not installed), `EW_NUMBER` (out-of-range address),
|
||||
`EW_LENGTH` (chunk size mismatch) are the typical failures the simulator
|
||||
reproduces; the mapper translates them to OPC UA status codes the same
|
||||
way the read-side does.
|
||||
|
||||
### Bit-level RMW (driver-side, no extra wire op)
|
||||
|
||||
`pmc_wrpmcrng` is **byte-addressed** — there is no sub-byte write op on
|
||||
the wire. Bit writes go through `IFocasClient.WritePmcBitAsync` which:
|
||||
|
||||
1. Issues a 1-byte `pmc_rdpmcrng` to fetch the parent byte.
|
||||
2. Masks the target bit (set: OR; clear: AND-NOT).
|
||||
3. Issues a 1-byte `pmc_wrpmcrng` with the modified byte.
|
||||
|
||||
A per-byte semaphore in `FwlibFocasClient` serialises concurrent bit
|
||||
writes against the same byte so two updates that race never lose one
|
||||
another's bit. The simulator's handler implements the same byte-aligned
|
||||
semantics — bit writes never reach it as a separate frame.
|
||||
|
||||
### Symmetry note
|
||||
|
||||
The encoder is the `pmc_rdpmcrng` decoder reversed: the read side parses
|
||||
`(type_a, type_d, datano_s, datano_e)` from the request and emits the
|
||||
data buffer in the response; the write side parses all five fields plus
|
||||
the data buffer from the request and emits a status int16 in the
|
||||
response. Tests `FocasWritePmcTests.PMC_*` exercise the round-trip on
|
||||
the fake wire client.
|
||||
|
||||
## cnc_wrunlockparam — connection-level password unlock (command `0x0105`)
|
||||
|
||||
Issue #271, plan PR F4-d. Some controllers (notably 16i + certain 30i
|
||||
firmwares with parameter-protect on) gate `cnc_wrparam` and selected
|
||||
reads behind a connection-level password switch. The driver emits this
|
||||
frame on connect when `FocasDeviceOptions.Password` is configured, and
|
||||
re-emits it on any read/write that returns `EW_PASSWD` (then retries the
|
||||
gated call once).
|
||||
|
||||
### Request
|
||||
|
||||
| Offset | Width | Field |
|
||||
| --- | --- | --- |
|
||||
| 0 | `byte[4]` | `password[4]` — 4-byte password buffer. ASCII-encoded from `FocasDeviceOptions.Password`, right-padded with `0x00`, truncated at 4 bytes. |
|
||||
|
||||
The 4-byte fixed slot matches the FANUC published shape — the controller
|
||||
compares byte-for-byte. Longer / shorter source strings are normalised at
|
||||
the driver layer before they hit this frame so the wire surface stays
|
||||
canonical.
|
||||
|
||||
### Response
|
||||
|
||||
Same single-int16 envelope as the write frames:
|
||||
|
||||
| Offset | Width | Field |
|
||||
| --- | --- | --- |
|
||||
| 0 | `int16 LE` | `ew_status` — `0` = success (gate now lifted for the lifetime of this FWLIB handle), `EW_PASSWD` = supplied password did not match the controller's slot, `EW_HANDLE` = handle invalid. |
|
||||
|
||||
### Lifetime
|
||||
|
||||
Unlock is bound to the FWLIB handle: it persists until the handle closes
|
||||
(disconnect / reconnect). The driver reinvokes unlock on every
|
||||
`EnsureConnectedAsync` reconnect path so a planned or unplanned wire
|
||||
restart self-heals without operator intervention. A `BadUserAccessDenied`
|
||||
on a read/write triggers a single-shot retry: re-emit unlock + redispatch
|
||||
the gated call once. A second `EW_PASSWD` propagates unchanged so a
|
||||
mismatched password doesn't loop forever on the wire.
|
||||
|
||||
### No-log invariant
|
||||
|
||||
The password is a secret. Wire-client implementations MUST NOT log the
|
||||
password on either request or response. The current
|
||||
`FwlibFocasClient.UnlockAsync` constructs an exception that includes
|
||||
only the `EW_*` return code; the `FocasDeviceOptions` record overrides
|
||||
its auto-generated `ToString` so any Serilog destructure renders
|
||||
`Password = ***`. See
|
||||
[`docs/v2/focas-deployment.md`](../focas-deployment.md)
|
||||
§ "FOCAS password handling" for the deployment-side guarantees +
|
||||
rotation runbook.
|
||||
+618
@@ -450,6 +450,624 @@ 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.
|
||||
|
||||
### Diagnostics surfacing
|
||||
|
||||
Beyond the coalescing counters above, the S7 driver also surfaces the
|
||||
**negotiated PDU size** captured during the COTP/S7comm handshake under the
|
||||
same `<DriverType>.<Counter>` naming convention:
|
||||
|
||||
- `S7.NegotiatedPduSize` — the PDU envelope size advertised by the CPU
|
||||
during `Plc.OpenAsync`. Default S7-1500 CPUs negotiate **240 bytes**;
|
||||
CPUs running the extended PDU advertise **480 or 960 bytes**. The value
|
||||
is `0` before the first successful connect and is reset to `0` on
|
||||
driver shutdown so an operator inspecting the Admin UI dashboard can
|
||||
immediately tell whether the driver is currently online.
|
||||
|
||||
Together these counters answer the most common operator questions about
|
||||
S7 driver health without reaching for a Wireshark capture:
|
||||
|
||||
- "Is the driver actually connected?" → `S7.NegotiatedPduSize > 0`
|
||||
- "Is coalescing working?" → `S7.TotalBlockReads` climbing while
|
||||
`S7.TotalMultiVarBatches` stays flat
|
||||
- "Why is throughput poor?" → `S7.NegotiatedPduSize` is 240 instead of 960
|
||||
(operator can switch the CPU to extended PDU if the project allows)
|
||||
|
||||
The values render alongside Modbus / OPC UA Client metrics in the Admin
|
||||
UI driver-diagnostics panel — same RPC, same dashboard row layout.
|
||||
|
||||
### Per-tag scan groups
|
||||
|
||||
Before PR-S7-C3, `ISubscribable.SubscribeAsync` took **one** publishing
|
||||
interval and applied it to every tag in the input list. A site that wanted
|
||||
mixed cadences — say a 100 ms HMI pulse, a 1 s dashboard tile, and a 10 s
|
||||
slow-poll for trend data — had to issue **three separate subscribe calls**,
|
||||
each with its own list of tags. That works, but it pushes the partitioning
|
||||
problem up to the caller (the OPC UA address space layer) and means an
|
||||
operator can't express "this tag is slow-poll" purely in driver config.
|
||||
|
||||
PR-S7-C3 adds **per-tag scan groups** so a single `SubscribeAsync` call
|
||||
naturally splits into N independent poll loops:
|
||||
|
||||
- `S7TagDefinition.ScanGroup` (string, optional) — the group identifier the
|
||||
tag belongs to. Tags with no group (or with a group not declared in the
|
||||
rate map below) keep the legacy behaviour and inherit the
|
||||
subscription-default publishing interval.
|
||||
- `S7DriverOptions.ScanGroupIntervals` (`IReadOnlyDictionary<string, TimeSpan>`,
|
||||
optional) — the rate map. Group names are matched case-insensitively. Any
|
||||
group with a non-positive interval (≤ 0 ms) is silently dropped at config
|
||||
load and tags falling back to that group land in the default partition.
|
||||
|
||||
At subscribe time the driver buckets the input tag list by **resolved
|
||||
publishing interval** (per-tag group → map lookup → fallback to the
|
||||
subscription default), then spins up one background poll loop per distinct
|
||||
interval. Each loop owns its own `CancellationTokenSource` and its own
|
||||
`LastValues` cache; `UnsubscribeAsync` cancels and disposes every per-group
|
||||
loop together so a multi-rate subscription can't leak background tasks.
|
||||
|
||||
#### JSON config example
|
||||
|
||||
```json
|
||||
{
|
||||
"Host": "10.0.0.50",
|
||||
"ScanGroupIntervalsMs": {
|
||||
"Fast": 100,
|
||||
"Medium": 1000,
|
||||
"Slow": 10000
|
||||
},
|
||||
"Tags": [
|
||||
{ "Name": "PressureSetpoint", "Address": "DB1.DBW0", "DataType": "Int16", "ScanGroup": "Fast" },
|
||||
{ "Name": "BatchTotal", "Address": "DB1.DBD10", "DataType": "Int32", "ScanGroup": "Medium" },
|
||||
{ "Name": "TrendBucket", "Address": "DB1.DBD20", "DataType": "Float32", "ScanGroup": "Slow" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
A single `SubscribeAsync(["PressureSetpoint","BatchTotal","TrendBucket"], 1s)`
|
||||
call against this driver produces **three independent poll loops** —
|
||||
the fast HMI tag ticks at 100 ms, the dashboard tile at 1 s, the trend
|
||||
bucket at 10 s. The caller-supplied 1 s default is unused because every
|
||||
tag carries an explicit group.
|
||||
|
||||
#### 100 ms floor applies per partition
|
||||
|
||||
The `100 ms` floor that protects the S7 mailbox from sub-scan polling
|
||||
applies to **both** the subscription default **and** every per-group rate.
|
||||
A typo'd entry like `{"TooFast": 25}` is silently floored to 100 ms at
|
||||
partition-build time — the driver never schedules a sub-100 ms `Task.Delay`
|
||||
even if the operator tries.
|
||||
|
||||
#### `_gate` contention caveat — "1 connection / 1 mailbox"
|
||||
|
||||
Partitioning into N poll loops does **not** parallelise wire-level reads.
|
||||
S7netplus's documented pattern is one `Plc` instance per CPU, and the
|
||||
driver enforces that with a per-instance `SemaphoreSlim` (`_gate`) that
|
||||
every read takes before touching the socket. All N partitions share the
|
||||
same gate, so the **mailbox is still strictly serial** — what the multi-rate
|
||||
split actually buys you is **cadence decoupling**:
|
||||
|
||||
- Before PR-S7-C3: every tag ticked at the slowest configured interval (or
|
||||
required three separate subscribe calls and three separate logical
|
||||
subscription handles, complicating the address-space layer).
|
||||
- After PR-S7-C3: a 100 ms HMI tag isn't blocked behind a 10 s slow-poll
|
||||
batch's `Task.Delay`. While Slow is sleeping, the gate is free and Fast
|
||||
acquires it, polls, releases. The CPU sees more frequent small requests
|
||||
rather than infrequent large ones — which is what you want for a
|
||||
responsive HMI surface.
|
||||
|
||||
The caveat to be aware of: if Fast's per-tick read takes longer than its
|
||||
tick interval (e.g. 100 ms tick but 200 ms gate-held read because Medium
|
||||
or Slow happens to be mid-read on the gate), Fast's effective cadence
|
||||
slows to "as fast as the gate lets me." That's a property of S7netplus's
|
||||
single-connection design, not of partitioning — three separate driver
|
||||
instances against the same CPU would just waste the CPU's
|
||||
8-64-connection-resource budget without speeding anything up.
|
||||
|
||||
#### Diagnostics
|
||||
|
||||
Partition counts aren't yet surfaced under
|
||||
`DriverHealth.Diagnostics` (planned for a follow-up alongside per-partition
|
||||
tick rate). Tests can call the internal helpers `S7Driver.GetPartitionCount`
|
||||
and `S7Driver.GetPartitionSummary` to inspect the resolved partitioning of
|
||||
a live subscription handle.
|
||||
|
||||
### Deadband / on-change
|
||||
|
||||
Before PR-S7-C4 the subscription poll loop emitted `OnDataChange` whenever
|
||||
the freshly-read value differed from the last cached one — a strict
|
||||
`!Equals(prev, current)` test. That's correct for booleans and discrete
|
||||
state, but for analog tags (Float32 / Float64 / scaled integer set-points)
|
||||
it floods the OPC UA subscription queue with insignificant noise: the last
|
||||
counts of an ADC's least-significant-bit jitter, sub-percent setpoint drift,
|
||||
sensor-grade flutter on a flow rate. PR-S7-C4 lets the operator configure
|
||||
**per-tag deadband thresholds** so the driver suppresses uninteresting
|
||||
publishes at source, before they cross the OPC UA boundary.
|
||||
|
||||
Two knobs, both optional, both per-tag:
|
||||
|
||||
- `DeadbandAbsolute` (`double?`) — minimum value change in raw units.
|
||||
Suppress when `|new - prev| < DeadbandAbsolute`.
|
||||
- `DeadbandPercent` (`double?`, 0..100) — minimum value change as a
|
||||
percentage of the previous published value. Suppress when
|
||||
`|new - prev| < |prev| * DeadbandPercent / 100`.
|
||||
|
||||
When both knobs are set the filters are **OR'd** — the value publishes if
|
||||
**either** threshold says publish. This matches Kepware's documented
|
||||
"either threshold triggers" semantics and mirrors the AbLegacy driver's
|
||||
shipped behaviour for cross-driver consistency.
|
||||
|
||||
#### JSON config example
|
||||
|
||||
```json
|
||||
{
|
||||
"Host": "10.0.0.50",
|
||||
"Tags": [
|
||||
{ "Name": "BoilerPressure", "Address": "DB1.DBD0", "DataType": "Float32",
|
||||
"DeadbandAbsolute": 0.5 },
|
||||
|
||||
{ "Name": "FlowRate", "Address": "DB1.DBD4", "DataType": "Float32",
|
||||
"DeadbandPercent": 1.0 },
|
||||
|
||||
{ "Name": "Temperature", "Address": "DB1.DBD8", "DataType": "Float32",
|
||||
"DeadbandAbsolute": 0.1, "DeadbandPercent": 0.5 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`BoilerPressure` only republishes after a 0.5-bar change; `FlowRate` only
|
||||
when the rate moves by more than 1% of its last published value;
|
||||
`Temperature` whenever **either** `0.1 °C absolute` **or** `0.5% of last`
|
||||
is satisfied.
|
||||
|
||||
#### Edge cases
|
||||
|
||||
- **First sample.** `PollOnceAsync` gates `forceRaise` and the
|
||||
no-prior-value case ahead of the deadband filter — the first sample for
|
||||
a tag always publishes (otherwise an OPC UA subscription would never see
|
||||
an initial-data push).
|
||||
- **Status-code change.** Any transition in the OPC UA `StatusCode` channel
|
||||
(`Bad → Good`, `Good → Bad`, etc.) bypasses deadband and publishes,
|
||||
because quality is a semantically different signal from value.
|
||||
- **Non-numeric types.** `String` / `WString` / `Char` / `WChar` /
|
||||
`DateTime` / byte-array tags ignore deadband entirely and keep the
|
||||
legacy `!Equals` semantics. Configuring `DeadbandAbsolute` on a
|
||||
`String` tag is harmless — the filter just doesn't engage.
|
||||
- **`NaN` samples.** If either `prev` or `current` is `NaN`, the filter
|
||||
publishes. NaN never equals NaN; treating it as "changed" surfaces the
|
||||
degenerate float to the client rather than hiding it.
|
||||
- **`±Infinity` samples.** Same rationale as NaN — degenerate values are
|
||||
always published, never deadbanded.
|
||||
- **Sign flip.** A tag swinging `+10 → -10` produces `|delta|=20`; the
|
||||
deadband math operates on the **absolute** delta so a sign flip with
|
||||
`DeadbandAbsolute=1` always publishes. This is the right answer for
|
||||
bidirectional set-points (positive / negative torque, valve-direction
|
||||
flags encoded as signed scalars).
|
||||
- **Near-zero baseline (`|prev| < 1e-6`).** A percent threshold against a
|
||||
zero or near-zero baseline diverges (any tiny change is "infinity
|
||||
percent"), so the driver falls back to absolute when `|prev| < 1e-6`:
|
||||
- If `DeadbandAbsolute` is also configured, that threshold takes over.
|
||||
- If only `DeadbandPercent` is set (no absolute fallback), the sample
|
||||
publishes — there's no usable threshold and silently dropping changes
|
||||
against a near-zero baseline would mask a genuine signal.
|
||||
|
||||
The `1e-6` cutoff is a deliberately conservative floor: floats below
|
||||
`~1e-7` are already in denormal-precision territory; anything above
|
||||
`~1e-6` carries enough magnitude that `|prev| * pct / 100` produces a
|
||||
meaningful threshold.
|
||||
|
||||
#### Implementation notes
|
||||
|
||||
- The filter is the pure-function helper `S7Driver.ShouldPublish(tag,
|
||||
prev, current)`. It's exposed at `internal` scope so unit tests can
|
||||
drive every decision branch (NaN, ±Inf, sign flip, near-zero baseline,
|
||||
both-set OR semantics) without spinning up a partition or poll loop.
|
||||
- `LastValues` continues to cache the **last published** snapshot, not
|
||||
the last polled one. After a deadband suppression the next sample
|
||||
compares against the cached (previously published) value, so a slow
|
||||
drift that never crosses the threshold in any single tick still gets
|
||||
caught the moment cumulative drift exceeds the threshold.
|
||||
- Deadband is a **publish-time** filter, not a wire-level one — every
|
||||
configured tag is still read every tick, the filter only decides
|
||||
whether to invoke `OnDataChange`. The mailbox / PDU / coalescing path
|
||||
is untouched.
|
||||
|
||||
## Pre-flight PUT/GET enablement
|
||||
|
||||
S7-1200 / S7-1500 CPUs ship with **PUT/GET communication disabled by
|
||||
default**. The COTP / S7comm handshake itself succeeds against these
|
||||
locked-down CPUs (you can `OpenAsync` / negotiate PDU size cleanly), so
|
||||
the failure surfaces only on the *first* `Plc.ReadAsync` — at which
|
||||
point the driver is already past `InitializeAsync`, has flipped to
|
||||
`DriverState.Healthy`, and dependent code (subscriptions, Admin UI) is
|
||||
binding against a connection it can't actually use. Operators see
|
||||
`BadDeviceFailure` per tag instead of a single, actionable
|
||||
configuration error.
|
||||
|
||||
PR-S7-C5 adds a **post-`OpenAsync` pre-flight probe**: a tiny 2-byte
|
||||
read against `Probe.ProbeAddress` (default `MW0`). If the PLC rejects
|
||||
that read with the wire-level "function not allowed in current
|
||||
operating state" response (S7 error family `D6 05` / `85 00`),
|
||||
S7netplus surfaces the rejection as `PlcException` with one of
|
||||
`ErrorCode.WrongCPU_Type` (CPU drops the connection mid-response) or
|
||||
`ErrorCode.ReadData` (CPU sends an S7-level error byte). The driver
|
||||
classifies that pair as "PUT/GET disabled" and throws a typed
|
||||
`S7PutGetDisabledException` from `InitializeAsync` so the operator sees
|
||||
the TIA-Portal fix path immediately:
|
||||
|
||||
> PUT/GET communication is disabled on the PLC. Enable it in TIA Portal:
|
||||
> *Device → Properties → Protection & Security → Connection mechanisms →
|
||||
> "Permit access with PUT/GET communication from remote partner"*.
|
||||
> Re-deploy the hardware config and restart the S7 driver.
|
||||
|
||||
`S7PreflightClassifier.IsPutGetDisabled(PlcException)` is the pure
|
||||
function that decides whether a given `PlcException` qualifies; it
|
||||
matches **only** `WrongCPU_Type` and `ReadData`. Other error codes
|
||||
(`ConnectionError`, `IPAddressNotAvailable`, `WrongVarFormat`, …)
|
||||
indicate transport / framing faults rather than PUT/GET gating, so the
|
||||
driver re-throws the original `PlcException` unchanged and the existing
|
||||
`DriverState.Faulted` path takes over with the original message.
|
||||
|
||||
### Knobs
|
||||
|
||||
Two opt-out knobs on `S7ProbeOptions`:
|
||||
|
||||
- `ProbeAddress` (`string?`, default `"MW0"`) — address probed by both
|
||||
the background liveness loop and the pre-flight read. Set to `null`
|
||||
(or empty string in JSON) to skip the pre-flight entirely. Useful
|
||||
for sites where no fingerprint address has been wired and an arbitrary
|
||||
read at `MW0` would itself be misleading.
|
||||
- `SkipPreflight` (`bool`, default `false`) — opt out of the pre-flight
|
||||
read while keeping the background probe. Init succeeds against a
|
||||
PUT/GET-disabled CPU; per-tag reads still surface `BadDeviceFailure`
|
||||
at runtime. Useful for staged deployments where the operator hasn't
|
||||
enabled PUT/GET yet but wants the driver visible in the Admin UI.
|
||||
|
||||
### Why `MW0`?
|
||||
|
||||
The convention from `Driver.S7.Cli.md`'s `probe` command. `MW0` exists
|
||||
on every S7 CPU regardless of project — Merker memory is universal —
|
||||
so it's a safe default that doesn't require a per-site DB to be wired.
|
||||
Sites with a dedicated fingerprint DB can override to e.g.
|
||||
`DB1.DBW0`.
|
||||
|
||||
### JSON config example
|
||||
|
||||
```json
|
||||
{
|
||||
"Host": "10.0.0.50",
|
||||
"Probe": {
|
||||
"Enabled": true,
|
||||
"IntervalMs": 5000,
|
||||
"TimeoutMs": 2000,
|
||||
"ProbeAddress": "DB1.DBW0",
|
||||
"SkipPreflight": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To skip the pre-flight (defer the check to first read):
|
||||
|
||||
```json
|
||||
{
|
||||
"Host": "10.0.0.50",
|
||||
"Probe": { "SkipPreflight": true }
|
||||
}
|
||||
```
|
||||
|
||||
To skip the probe entirely (no pre-flight, no liveness loop):
|
||||
|
||||
```json
|
||||
{
|
||||
"Host": "10.0.0.50",
|
||||
"Probe": { "Enabled": false, "ProbeAddress": "" }
|
||||
}
|
||||
```
|
||||
|
||||
## TSAP / Connection Type
|
||||
|
||||
S7comm runs on top of ISO-on-TCP (RFC 1006), and the COTP connection-request
|
||||
PDU carries a 16-bit **TSAP pair** (local + remote) that the CPU validates
|
||||
before any S7comm payload flows. S7netplus's default `Plc(CpuType, host, port,
|
||||
rack, slot)` constructor picks a **PG-class** TSAP pair via
|
||||
`TsapPair.GetDefaultTsapPair`. That choice works against most lab S7-1200 /
|
||||
S7-1500 CPUs and against TIA Portal itself, but **hardened deployments**
|
||||
(security-config'd S7-1500, ET 200SP, locked-down PROFINET projects) reject
|
||||
PG class outright at COTP-handshake time, returning the same connection-refused
|
||||
shape as a wrong slot byte.
|
||||
|
||||
PR-S7-C2 surfaces a `TsapMode` enum on `S7DriverOptions` so an operator can
|
||||
force a specific class without re-flashing the PLC project. It applies equally
|
||||
to the Admin-UI-driven config DB row and to the `otopcua-s7-cli` test client.
|
||||
|
||||
### Raw-TSAP byte table
|
||||
|
||||
The high byte is the connection class. The local low byte is conventionally
|
||||
`0x00` (caller / unprivileged), and the remote low byte is
|
||||
`(rack << 5) | slot` per the S7 spec — the same convention S7netplus's
|
||||
`TsapPair.GetDefaultTsapPair(CpuType, rack, slot)` uses for the remote endpoint.
|
||||
|
||||
| Class | High byte | Local TSAP (rack=0/slot=0) | Remote TSAP (rack=0/slot=0) | Remote TSAP (rack=0/slot=2) | Typical use |
|
||||
|----------|-----------|----------------------------|------------------------------|------------------------------|----------------------------------------------|
|
||||
| PG | `0x01` | `0x0100` | `0x0100` | `0x0102` | TIA Portal, dev laptops, lab S7-1200/1500 |
|
||||
| OP | `0x02` | `0x0200` | `0x0200` | `0x0202` | Operator panels, hardened-CPU S7-1500 |
|
||||
| S7-Basic | `0x03` | `0x0300` | `0x0300` | `0x0302` | WinCC BasicPanel SDK, S7-Basic clients |
|
||||
| Other | caller | caller-supplied | caller-supplied | caller-supplied | escape hatch — unusual fixed-TSAP firmware |
|
||||
|
||||
### `TsapMode` enum
|
||||
|
||||
| Mode | Behaviour |
|
||||
|-----------|----------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `Auto` | Existing behaviour — S7netplus picks the TSAP pair from `CpuType`. Explicit `LocalTsap` / `RemoteTsap` are ignored under `Auto`. |
|
||||
| `Pg` | Force PG class (high byte `0x01`). Local / remote computed from rack + slot. |
|
||||
| `Op` | Force OP class (high byte `0x02`). |
|
||||
| `S7Basic` | Force S7-Basic class (high byte `0x03`). |
|
||||
| `Other` | Caller-supplied `LocalTsap` + `RemoteTsap`. Both must be set or driver init throws `InvalidOperationException`. |
|
||||
|
||||
Explicit `LocalTsap` / `RemoteTsap` overrides win over the class-derived
|
||||
defaults under any non-`Auto` mode — a site that needs a fixed source-TSAP for
|
||||
firewall reasons can pin `LocalTsap` while keeping `TsapMode = Pg` for the
|
||||
remote computation.
|
||||
|
||||
### Worked example: hardened S7-1500 requiring OP class
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"Host": "10.50.12.30",
|
||||
"CpuType": "S71500",
|
||||
"Rack": 0,
|
||||
"Slot": 0,
|
||||
"TsapMode": "Op",
|
||||
"Tags": [ /* … */ ]
|
||||
}
|
||||
```
|
||||
|
||||
This produces local = `0x0200`, remote = `0x0200` (rack=0, slot=0). The same
|
||||
PLC under `TsapMode = "Auto"` (PG class) returns COTP rejection — same packet
|
||||
capture shape as a wrong-slot misconfig, which is the failure-mode footnote
|
||||
under §5 of `driver-specs.md`.
|
||||
|
||||
### Why not just expose `LocalTsap` / `RemoteTsap` directly?
|
||||
|
||||
Most operators don't know the byte format off-hand and reach for `Pg` /
|
||||
`Op` / `S7Basic` based on Siemens-doc terminology. Keeping the enum lets the
|
||||
Admin UI render a dropdown with sensible labels, while the `ushort?` fields
|
||||
stay available as the manual escape hatch when a site has truly unusual
|
||||
firmware (e.g. third-party S7-protocol gateways with fixed proprietary
|
||||
TSAPs). Both paths are exercised in the unit-test mapping table.
|
||||
|
||||
### Live-firmware verification
|
||||
|
||||
The PG/OP/S7-Basic byte table above is the documented Siemens convention; the
|
||||
actual handshake is verified against the dev-box S7-1500 lab rig (a hardened
|
||||
project that rejects PG and accepts OP). That test is documented in
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests` but only runs against
|
||||
real firmware — the pymodbus-style "TSAP simulator" doesn't exist for S7.
|
||||
|
||||
## Symbol import
|
||||
|
||||
PR-S7-D1 / [#299](https://github.com/dohertj2/lmxopcua/issues/299) — bulk-import
|
||||
TIA Portal "Show all tags" CSV exports and STEP 7 Classic AWL declaration files
|
||||
into the S7 driver's tag list. Operators no longer hand-edit the
|
||||
`Drivers/<instance>/Config/Tags` JSON for hundred-tag projects.
|
||||
|
||||
Two formats supported v1:
|
||||
|
||||
- **TIA Portal CSV** — `Name,Path,Data type,Logical address,Comment,Hmi accessible,…`.
|
||||
en-US (`,`) and DE-locale (`;` separator + `,` decimal) auto-detected.
|
||||
HMI-hidden symbols filter out automatically; UDT-typed rows import as
|
||||
placeholders until PR-S7-D2 ships proper UDT layout.
|
||||
- **STEP 7 Classic AWL** — `VAR_GLOBAL` + `DATA_BLOCK` declarations parsed
|
||||
best-effort with position-based offset assignment.
|
||||
|
||||
Two surface options:
|
||||
|
||||
- **CLI**: `otopcua-s7-cli import-symbols --file foo.csv --format tia` emits
|
||||
an `appsettings.json` JSON fragment for hand-merge.
|
||||
- **API**: `S7DriverOptions.AddTiaCsvImport(path, out result)` /
|
||||
`AddAwlImport(path, out result)` for server-side bootstrap paths.
|
||||
|
||||
Full reference: [`docs/drivers/S7-TIA-Import.md`](../drivers/S7-TIA-Import.md).
|
||||
CLI flag table: [`docs/Driver.S7.Cli.md` "import-symbols"](../Driver.S7.Cli.md#import-symbols).
|
||||
|
||||
## UDT / STRUCT support
|
||||
|
||||
PR-S7-D2 / #300 — UDT-typed DBs are exposed via per-member fan-out at driver
|
||||
init time. The driver reads / writes / subscribes only ever target scalar
|
||||
leaves; the parent UDT pointer never reaches the wire. This keeps the rest of
|
||||
the driver pipeline (address parser, block-coalescing planner, scan-group
|
||||
partitioner, deadband filter) UDT-unaware.
|
||||
|
||||
### `S7UdtDefinition`
|
||||
|
||||
A UDT is declared once in `S7DriverOptions.Udts` and referenced by tags whose
|
||||
`UdtName` is set:
|
||||
|
||||
```csharp
|
||||
new S7UdtDefinition(
|
||||
Name: "Pump",
|
||||
Members: [
|
||||
new S7UdtMember("Pressure", Offset: 0, S7DataType.Float32),
|
||||
new S7UdtMember("Status", Offset: 4, S7DataType.Int16),
|
||||
new S7UdtMember("Enabled", Offset: 6, S7DataType.Bool),
|
||||
],
|
||||
SizeBytes: 7);
|
||||
```
|
||||
|
||||
Tags adopt the UDT layout via `UdtName`:
|
||||
|
||||
```csharp
|
||||
new S7TagDefinition("Pump1", "DB1.DBX0.0", S7DataType.Byte, UdtName: "Pump");
|
||||
```
|
||||
|
||||
### Fan-out semantics
|
||||
|
||||
At `InitializeAsync` time the driver:
|
||||
|
||||
1. Walks `_options.Tags`. For each tag with `UdtName`, looks up the UDT in
|
||||
`_options.Udts` (case-insensitive).
|
||||
2. For each UDT member, computes `parent.Address.ByteOffset + member.Offset`
|
||||
and emits one scalar `S7TagDefinition` per leaf with name
|
||||
`Parent.Member` (dot-separated).
|
||||
3. Array members emit `Member[0]`, `Member[1]`, ... at stride `elementBytes`.
|
||||
4. Nested UDT members recurse — array-of-UDT walks at stride `inner.SizeBytes`.
|
||||
5. The fanned-out leaves replace the parent UDT tag in the driver's tag map.
|
||||
|
||||
Reads / writes / subscribes that target the parent name surface
|
||||
`BadNodeIdUnknown` — clients must address the leaves directly.
|
||||
|
||||
### 4-level nesting cap
|
||||
|
||||
UDT-of-UDT is supported up to 4 levels deep. Anything deeper throws
|
||||
`InvalidOperationException("UDT nesting depth exceeds 4 levels…")` at Init.
|
||||
This catches accidentally-recursive declarations early; real industrial UDTs
|
||||
rarely go beyond 2 layers.
|
||||
|
||||
### Optimized block access — must be off
|
||||
|
||||
The static-offset model assumes member byte offsets in the declaration match
|
||||
the runtime layout exactly. TIA Portal's "Optimized block access" flag lets
|
||||
the runtime reorder members for memory alignment, breaking that assumption.
|
||||
Same prerequisite as general absolute-offset DB addressing on S7-1200 / 1500:
|
||||
**Optimized block access must be disabled** on any DB that the driver
|
||||
addresses by absolute offset, including UDT-typed DBs.
|
||||
|
||||
If a customer can't disable Optimized access (e.g., shared-DB constraints),
|
||||
the workaround is to expose the UDT through the symbolic-tag path once that
|
||||
ships — not in PR-S7-D2.
|
||||
|
||||
### Validation
|
||||
|
||||
The fan-out rejects, with clear errors:
|
||||
|
||||
- UDT name not found in `Udts` collection
|
||||
- Member offsets not in ascending order
|
||||
- Member offsets that overlap (a primitive's `[offset, offset+width)` range
|
||||
intersects the next member's offset)
|
||||
- Total members extending past `SizeBytes`
|
||||
- Tag with `UdtName` AND `ElementCount > 1` (array-of-UDT belongs in the UDT
|
||||
layout, not at the parent-tag level)
|
||||
|
||||
### Re-import on UDT / FB-interface edit — caveat
|
||||
|
||||
The static-offset model assumes the declared layout matches the runtime
|
||||
layout exactly. When the underlying UDT or FB interface changes in TIA Portal
|
||||
— a member added, removed, or reordered — the byte offsets shift on the PLC
|
||||
side and the cached `S7UdtDefinition` / instance-DB addresses point at the
|
||||
wrong member.
|
||||
|
||||
**The driver does not auto-detect interface drift.** After any UDT edit or
|
||||
multi-instance-FB interface edit on the PLC side, the operator must:
|
||||
|
||||
1. Recompile + download the updated program in TIA Portal.
|
||||
2. Re-export "Show all tags" CSV from the updated project.
|
||||
3. Re-import via `AddTiaCsvImport` (or `import-symbols` CLI) and update the
|
||||
matching `S7UdtDefinition` declarations to mirror the new offsets.
|
||||
4. Restart the driver instance (Admin UI → Drivers → Reload).
|
||||
|
||||
A stale UDT layout will silently read / write the wrong byte offsets — the
|
||||
values will look like valid PLC data but reference whichever member used to
|
||||
live at that offset before the edit. The same caveat applies to multi-instance
|
||||
FB-instance DBs imported via PR-S7-D3 / [#301](https://github.com/dohertj2/lmxopcua/issues/301);
|
||||
see [`docs/drivers/S7-TIA-Import.md` "Re-import on FB-interface edit"](../drivers/S7-TIA-Import.md#re-import-on-fb-interface-edit--caveat)
|
||||
for the FB-instance-specific workflow.
|
||||
|
||||
## References
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
#Requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
End-to-end CLI test for AB CIP HSBY failover routing (PR abcip-5.2). Subscribes to
|
||||
a tag through the OtOpcUa OPC UA server, flips the active chassis mid-stream via
|
||||
the paired-fixture's hsby-mux sidecar HTTP endpoint, and asserts the subscribe
|
||||
stream survives the failover (no permanent loss of notifications + the post-flip
|
||||
data carries the partner-side update).
|
||||
|
||||
.DESCRIPTION
|
||||
Paired-fixture variant of test-abcip.ps1. Where test-abcip.ps1 runs against a
|
||||
single ab_server instance, this script assumes a paired fixture with two
|
||||
ab_server instances (primary + partner) and an hsby-mux sidecar exposing
|
||||
/flip {"active": "primary" | "partner"} over HTTP.
|
||||
|
||||
Five assertions:
|
||||
- HsbyInitialActive — primary is Active at start (hsby-mux primes it)
|
||||
- HsbyResolveActive — driver-diagnostics surfaces AbCip.HsbyActive == 1
|
||||
- HsbyFailoverFlip — POST {"active": "partner"} → AbCip.HsbyActive == 2
|
||||
- HsbySubscribeSurvives — subscribe stream stays open across the flip + sees
|
||||
an updated value from the partner side
|
||||
- HsbyFailoverCount — AbCip.HsbyFailoverCount increments by ≥ 1
|
||||
|
||||
.PARAMETER PrimaryGateway
|
||||
ab://host[:port]/cip-path of the primary chassis. Default ab://127.0.0.1/1,0.
|
||||
|
||||
.PARAMETER PartnerGateway
|
||||
ab://host[:port]/cip-path of the partner chassis. Default ab://127.0.0.2/1,0.
|
||||
|
||||
.PARAMETER HsbyMuxUrl
|
||||
Base URL of the paired-fixture's hsby-mux sidecar. Default http://localhost:7080.
|
||||
Endpoints used:
|
||||
GET /role → returns {"primary":"Active","partner":"Standby"}
|
||||
POST /flip {"active":"primary"|"partner"} → flips role tag values on each chassis
|
||||
|
||||
.PARAMETER OpcUaUrl
|
||||
OtOpcUa server endpoint. Default opc.tcp://localhost:4840.
|
||||
|
||||
.PARAMETER BridgeNodeId
|
||||
NodeId at which the server publishes the tag exercised by the subscribe assertion.
|
||||
Required.
|
||||
|
||||
.PARAMETER TagPath
|
||||
Logix symbolic path the bridge tag points at. Default 'TestDINT'.
|
||||
|
||||
.PARAMETER DriverInstanceId
|
||||
DriverInstance ID for the AB CIP driver under test. Used to scope the
|
||||
driver-diagnostics RPC. Default 'abcip-hsby'.
|
||||
|
||||
.EXAMPLE
|
||||
./test-abcip-hsby.ps1 -BridgeNodeId 'ns=2;s=AbCip/Bridge/TestDINT'
|
||||
#>
|
||||
|
||||
param(
|
||||
[string]$PrimaryGateway = "ab://127.0.0.1/1,0",
|
||||
[string]$PartnerGateway = "ab://127.0.0.2/1,0",
|
||||
[string]$HsbyMuxUrl = "http://localhost:7080",
|
||||
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||
[Parameter(Mandatory)] [string]$BridgeNodeId,
|
||||
[string]$TagPath = "TestDINT",
|
||||
[string]$DriverInstanceId = "abcip-hsby"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. "$PSScriptRoot/_common.ps1"
|
||||
|
||||
$abcipCli = Get-CliInvocation `
|
||||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli" `
|
||||
-ExeName "otopcua-abcip-cli"
|
||||
$opcUaCli = Get-CliInvocation `
|
||||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||
-ExeName "otopcua-cli"
|
||||
|
||||
$results = @()
|
||||
|
||||
function Invoke-HsbyFlip {
|
||||
param([string]$Active)
|
||||
$body = @{ active = $Active } | ConvertTo-Json -Compress
|
||||
try {
|
||||
Invoke-RestMethod -Uri "$HsbyMuxUrl/flip" -Method Post -Body $body -ContentType 'application/json'
|
||||
} catch {
|
||||
throw "hsby-mux at $HsbyMuxUrl/flip rejected the request: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-HsbyDiagnosticValue {
|
||||
param([string]$Counter)
|
||||
# Pull driver-diagnostics through the OPC UA Admin RPC surface. The CLI returns
|
||||
# a raw JSON blob; we grep out the named counter so the assertion is robust to
|
||||
# other counters the driver surfaces.
|
||||
$diagArgs = @($opcUaCli.PrefixArgs) + @(
|
||||
"driver-diagnostics", "-u", $OpcUaUrl, "-d", $DriverInstanceId)
|
||||
$diagOut = & $opcUaCli.File @diagArgs 2>&1
|
||||
$joined = ($diagOut -join "`n")
|
||||
if ($joined -match "${Counter}.*?:\s*([\d\.]+)") {
|
||||
return [double]$matches[1]
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# ---- HsbyInitialActive — hsby-mux primes primary as Active ----
|
||||
Write-Header "HsbyInitialActive (POST $HsbyMuxUrl/flip {active=primary})"
|
||||
try {
|
||||
Invoke-HsbyFlip -Active "primary" | Out-Null
|
||||
Start-Sleep -Seconds 3 # role-probe loop default tick is 2s
|
||||
$active = Get-HsbyDiagnosticValue -Counter "AbCip.HsbyActive"
|
||||
$passed = ($active -eq 1.0)
|
||||
$results += [PSCustomObject]@{
|
||||
Name = "HsbyInitialActive"
|
||||
Passed = $passed
|
||||
Detail = if ($passed) { "AbCip.HsbyActive=1 after priming primary" } else { "AbCip.HsbyActive=$active (expected 1)" }
|
||||
}
|
||||
} catch {
|
||||
$results += [PSCustomObject]@{
|
||||
Name = "HsbyInitialActive"; Passed = $false; Detail = $_.Exception.Message
|
||||
}
|
||||
}
|
||||
|
||||
# ---- HsbyResolveActive — driver routing reads through the primary ----
|
||||
Write-Header "HsbyResolveActive (read $TagPath via primary)"
|
||||
$readArgs = @("read") + @("-g", $PrimaryGateway, "-f", "ControlLogix") + @("-t", $TagPath, "--type", "DInt")
|
||||
$readOut = & $abcipCli.Exe @($abcipCli.Args + $readArgs) 2>&1
|
||||
$readOk = ($readOut -join "`n") -notmatch "(error|fail)"
|
||||
$results += [PSCustomObject]@{
|
||||
Name = "HsbyResolveActive"
|
||||
Passed = $readOk
|
||||
Detail = if ($readOk) { "primary read completed without error" } else { "read failed: $($readOut -join ' ')" }
|
||||
}
|
||||
|
||||
# ---- HsbySubscribeSurvives + HsbyFailoverFlip + HsbyFailoverCount ----
|
||||
Write-Header "HsbyFailoverFlip + HsbySubscribeSurvives (subscribe across flip)"
|
||||
$failoverBaseline = Get-HsbyDiagnosticValue -Counter "AbCip.HsbyFailoverCount"
|
||||
if ($null -eq $failoverBaseline) { $failoverBaseline = 0 }
|
||||
|
||||
$duration = 12
|
||||
$subOut = New-TemporaryFile
|
||||
$subErr = New-TemporaryFile
|
||||
$subArgs = @($opcUaCli.PrefixArgs) + @(
|
||||
"subscribe", "-u", $OpcUaUrl, "-n", $BridgeNodeId, "-i", "200", "--duration", "$duration")
|
||||
$subProc = Start-Process -FilePath $opcUaCli.File -ArgumentList $subArgs `
|
||||
-NoNewWindow -PassThru `
|
||||
-RedirectStandardOutput $subOut.FullName `
|
||||
-RedirectStandardError $subErr.FullName
|
||||
|
||||
# Let the subscribe settle + accumulate primary-side notifications.
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
# Mid-stream flip — primary→Standby, partner→Active.
|
||||
try {
|
||||
Invoke-HsbyFlip -Active "partner" | Out-Null
|
||||
} catch {
|
||||
Stop-Process -Id $subProc.Id -Force -ErrorAction SilentlyContinue
|
||||
$results += [PSCustomObject]@{
|
||||
Name = "HsbyFailoverFlip"; Passed = $false; Detail = "hsby-mux flip rejected: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Wait for the role-probe loop to catch up (default tick 2s + ProbeIntervalMs slack).
|
||||
Start-Sleep -Seconds 4
|
||||
|
||||
# Drive a write through the partner so the subscribe sees a fresh value.
|
||||
$flipValue = Get-Random -Minimum 70000 -Maximum 79999
|
||||
$writeArgs = @("write") + @("-g", $PartnerGateway, "-f", "ControlLogix") + @("-t", $TagPath, "--type", "DInt", "-v", $flipValue)
|
||||
& $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null
|
||||
|
||||
$activeAfter = Get-HsbyDiagnosticValue -Counter "AbCip.HsbyActive"
|
||||
$flipPassed = ($activeAfter -eq 2.0)
|
||||
$results += [PSCustomObject]@{
|
||||
Name = "HsbyFailoverFlip"
|
||||
Passed = $flipPassed
|
||||
Detail = if ($flipPassed) { "AbCip.HsbyActive=2 after flip" } else { "AbCip.HsbyActive=$activeAfter (expected 2)" }
|
||||
}
|
||||
|
||||
# Stop the subscribe + harvest the stream.
|
||||
$subProc.WaitForExit(($duration + 5) * 1000) | Out-Null
|
||||
if (-not $subProc.HasExited) { Stop-Process -Id $subProc.Id -Force }
|
||||
|
||||
$subText = (Get-Content $subOut.FullName -Raw) + (Get-Content $subErr.FullName -Raw)
|
||||
Remove-Item $subOut.FullName, $subErr.FullName -ErrorAction SilentlyContinue
|
||||
|
||||
# Stream survival = at least one notification *after* the flip carries the new
|
||||
# partner-side value. The post-flip write of $flipValue is the canary.
|
||||
$saw = $subText -match "$flipValue"
|
||||
$results += [PSCustomObject]@{
|
||||
Name = "HsbySubscribeSurvives"
|
||||
Passed = $saw
|
||||
Detail = if ($saw) {
|
||||
"subscribe stream surfaced post-flip value $flipValue from partner chassis"
|
||||
} else {
|
||||
"subscribe stream did not see the post-flip canary $flipValue — output: $subText"
|
||||
}
|
||||
}
|
||||
|
||||
# ---- HsbyFailoverCount — counter incremented by ≥ 1 ----
|
||||
Write-Header "HsbyFailoverCount"
|
||||
$failoverAfter = Get-HsbyDiagnosticValue -Counter "AbCip.HsbyFailoverCount"
|
||||
if ($null -eq $failoverAfter) { $failoverAfter = 0 }
|
||||
$counterOk = ($failoverAfter - $failoverBaseline) -ge 1
|
||||
$results += [PSCustomObject]@{
|
||||
Name = "HsbyFailoverCount"
|
||||
Passed = $counterOk
|
||||
Detail = if ($counterOk) {
|
||||
"AbCip.HsbyFailoverCount went from $failoverBaseline → $failoverAfter"
|
||||
} else {
|
||||
"AbCip.HsbyFailoverCount unchanged ($failoverBaseline → $failoverAfter); expected at least 1 increment"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Summary -Title "AB CIP HSBY failover e2e" -Results $results
|
||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||
+200
-1
@@ -30,6 +30,30 @@
|
||||
|
||||
.PARAMETER BridgeNodeId
|
||||
NodeId at which the server publishes the TagPath.
|
||||
|
||||
.PARAMETER FastBridgeNodeId
|
||||
Optional NodeId for a Tag declared with ScanRateMs <= 100. When supplied
|
||||
alongside SlowBridgeNodeId the script runs the per-tag scan-rate assertion
|
||||
(PR abcip-4.1).
|
||||
|
||||
.PARAMETER SlowBridgeNodeId
|
||||
Optional NodeId for a Tag declared with ScanRateMs >= 1000. Pair with
|
||||
FastBridgeNodeId to enable the scan-rate assertion.
|
||||
|
||||
.PARAMETER SystemConnectionStatusNodeId
|
||||
Optional NodeId for the synthetic _System/_ConnectionStatus variable
|
||||
emitted by AB CIP discovery (PR abcip-4.3). When supplied, the script
|
||||
runs the SystemTagBrowse assertion — reads the value through the OPC UA
|
||||
server + asserts it surfaces one of the canonical HostState strings.
|
||||
NodeId form: ns=<n>;s=AbCip/<gateway>/_System/_ConnectionStatus.
|
||||
|
||||
.PARAMETER RefreshTagDbNodeId
|
||||
Optional NodeId for the writeable _System/_RefreshTagDb trigger added in
|
||||
PR abcip-4.4. When supplied, the script runs the RefreshTagDbWrite
|
||||
assertion — writes True through the OPC UA server + reads back, asserting
|
||||
the trigger latches to False (Kepware-style "always idle" semantics) and
|
||||
the write itself surfaces Good. NodeId form:
|
||||
ns=<n>;s=AbCip/<gateway>/_System/_RefreshTagDb.
|
||||
#>
|
||||
|
||||
param(
|
||||
@@ -37,7 +61,19 @@ param(
|
||||
[string]$Family = "ControlLogix",
|
||||
[string]$TagPath = "TestDINT",
|
||||
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||
[Parameter(Mandatory)] [string]$BridgeNodeId
|
||||
[Parameter(Mandatory)] [string]$BridgeNodeId,
|
||||
[string]$FastBridgeNodeId,
|
||||
[string]$SlowBridgeNodeId,
|
||||
# PR abcip-4.3 — NodeId for the synthetic _System/_ConnectionStatus variable that
|
||||
# discovery emits under each device. Optional — when wired, runs the
|
||||
# SystemTagBrowse assertion that browses + reads the system folder through the OPC UA
|
||||
# server. NodeId form: ns=<n>;s=AbCip/<gateway>/_System/_ConnectionStatus.
|
||||
[string]$SystemConnectionStatusNodeId,
|
||||
# PR abcip-4.4 — NodeId for the writeable _System/_RefreshTagDb refresh-trigger.
|
||||
# Mirrors the SystemConnectionStatusNodeId knob: optional, only runs the
|
||||
# RefreshTagDbWrite assertion when supplied. NodeId form:
|
||||
# ns=<n>;s=AbCip/<gateway>/_System/_RefreshTagDb.
|
||||
[string]$RefreshTagDbNodeId
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -94,5 +130,168 @@ $results += Test-SubscribeSeesChange `
|
||||
-DriverWriteArgs (@("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $subValue)) `
|
||||
-ExpectedValue "$subValue"
|
||||
|
||||
# PR abcip-3.2 — Symbolic-vs-Logical sanity assertion. Reads the same tag with both
|
||||
# addressing modes through the CLI's --addressing-mode flag. Logical-mode against ab_server
|
||||
# falls back to Symbolic on the wire (libplctag wrapper limitation; see AbCip-Performance.md
|
||||
# §Addressing mode), so the assertion is "both modes complete + return the same value" — not
|
||||
# a perf comparison. Skipped on Micro800 (driver downgrades Logical → Symbolic with warning,
|
||||
# making both reads identical-by-design + uninteresting to compare here).
|
||||
if ($Family -ne "Micro800") {
|
||||
$symValue = Get-Random -Minimum 40000 -Maximum 49999
|
||||
Write-Host "AB CIP e2e: priming gateway with $symValue then reading via Symbolic + Logical"
|
||||
$writeArgs = @("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $symValue)
|
||||
& $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null
|
||||
|
||||
$symRead = & $abcipCli.Exe @($abcipCli.Args + @("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "--addressing-mode", "Symbolic"))
|
||||
$logRead = & $abcipCli.Exe @($abcipCli.Args + @("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "--addressing-mode", "Logical"))
|
||||
|
||||
$symMatched = ($symRead -join "`n") -match "$symValue"
|
||||
$logMatched = ($logRead -join "`n") -match "$symValue"
|
||||
$passed = $symMatched -and $logMatched
|
||||
$results += [PSCustomObject]@{
|
||||
Name = "AddressingModeSanity"
|
||||
Passed = $passed
|
||||
Detail = if ($passed) { "Symbolic + Logical both returned $symValue" } else { "Sym=$symMatched Log=$logMatched" }
|
||||
}
|
||||
}
|
||||
|
||||
# PR abcip-4.1 — per-tag scan-rate divergence assertion. Runs only when both fast + slow
|
||||
# NodeIds are wired; otherwise this knob is skipped on the existing single-NodeId fixture.
|
||||
# The assertion is "fast bucket sees > 5x as many notifications as slow bucket" — the
|
||||
# unit + integration tests cover the bucketing math, this just proves the multi-rate split
|
||||
# survives end-to-end through the OPC UA server's Subscription / MonitoredItem path.
|
||||
if ($FastBridgeNodeId -and $SlowBridgeNodeId) {
|
||||
Write-Header "Per-tag scan rate (FastBridge=$FastBridgeNodeId, SlowBridge=$SlowBridgeNodeId)"
|
||||
$duration = 8
|
||||
$fastOut = New-TemporaryFile
|
||||
$slowOut = New-TemporaryFile
|
||||
$fastErr = New-TemporaryFile
|
||||
$slowErr = New-TemporaryFile
|
||||
$fastArgs = @($opcUaCli.PrefixArgs) + @("subscribe", "-u", $OpcUaUrl, "-n", $FastBridgeNodeId, "-i", "100", "--duration", "$duration")
|
||||
$slowArgs = @($opcUaCli.PrefixArgs) + @("subscribe", "-u", $OpcUaUrl, "-n", $SlowBridgeNodeId, "-i", "1000", "--duration", "$duration")
|
||||
$fastProc = Start-Process -FilePath $opcUaCli.File -ArgumentList $fastArgs `
|
||||
-NoNewWindow -PassThru `
|
||||
-RedirectStandardOutput $fastOut.FullName `
|
||||
-RedirectStandardError $fastErr.FullName
|
||||
$slowProc = Start-Process -FilePath $opcUaCli.File -ArgumentList $slowArgs `
|
||||
-NoNewWindow -PassThru `
|
||||
-RedirectStandardOutput $slowOut.FullName `
|
||||
-RedirectStandardError $slowErr.FullName
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
# Drive a single PLC change so even stable tags get *one* notification during the window
|
||||
# (initial-data push + 1 change). The cadence assertion below relies on the fast tag
|
||||
# accumulating sampling-interval-driven events even between explicit changes.
|
||||
$tickValue = Get-Random -Minimum 50000 -Maximum 59999
|
||||
$writeArgs = @("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $tickValue)
|
||||
& $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null
|
||||
|
||||
$fastProc.WaitForExit(($duration + 5) * 1000) | Out-Null
|
||||
$slowProc.WaitForExit(($duration + 5) * 1000) | Out-Null
|
||||
if (-not $fastProc.HasExited) { Stop-Process -Id $fastProc.Id -Force }
|
||||
if (-not $slowProc.HasExited) { Stop-Process -Id $slowProc.Id -Force }
|
||||
|
||||
$fastText = (Get-Content $fastOut.FullName -Raw) + (Get-Content $fastErr.FullName -Raw)
|
||||
$slowText = (Get-Content $slowOut.FullName -Raw) + (Get-Content $slowErr.FullName -Raw)
|
||||
Remove-Item $fastOut.FullName, $slowOut.FullName, $fastErr.FullName, $slowErr.FullName -ErrorAction SilentlyContinue
|
||||
|
||||
# Each data-change line matches `=\s*<value>\s*(<status>)` per Test-SubscribeSeesChange.
|
||||
$fastMatches = ([regex]::Matches($fastText, "=\s*\S+\s*\(")).Count
|
||||
$slowMatches = ([regex]::Matches($slowText, "=\s*\S+\s*\(")).Count
|
||||
$passed = ($fastMatches -ge 5) -and ($fastMatches -gt ($slowMatches * 5))
|
||||
$detail = if ($passed) {
|
||||
"fast=$fastMatches notifications vs slow=$slowMatches (>5x ratio achieved)"
|
||||
} else {
|
||||
"fast=$fastMatches slow=$slowMatches — expected fast > slow*5"
|
||||
}
|
||||
$results += [PSCustomObject]@{ Name = "PerTagScanRate"; Passed = $passed; Detail = $detail }
|
||||
}
|
||||
|
||||
# PR abcip-4.2 — write-coalesce assertion. Writes the same value twice through the OPC UA
|
||||
# server and verifies the PLC-side state reflects only one wire write. The driver-side
|
||||
# diagnostics counter (AbCip.WritesSuppressed) is the authoritative signal, but ab_server
|
||||
# itself doesn't expose a "writes received" counter so this script-level check is intentionally
|
||||
# observational — it primes the tag with a baseline, writes the same value twice, and reads
|
||||
# back to confirm the value matches without surfacing additional state changes. The unit + integration
|
||||
# tests do the strict "exactly N suppressions" math; this is the e2e shape proof.
|
||||
$coalesceValue = Get-Random -Minimum 60000 -Maximum 69999
|
||||
Write-Header "WriteCoalesce (baseline=$coalesceValue, two redundant writes)"
|
||||
$writeArgs = @("write") + $commonAbCip + @("-t", $TagPath, "--type", "DInt", "-v", $coalesceValue)
|
||||
& $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null
|
||||
& $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null
|
||||
& $abcipCli.Exe @($abcipCli.Args + $writeArgs) | Out-Null
|
||||
$readArgs = @("read") + $commonAbCip + @("-t", $TagPath, "--type", "DInt")
|
||||
$readOut = & $abcipCli.Exe @($abcipCli.Args + $readArgs)
|
||||
$coalesceMatch = ($readOut -join "`n") -match "$coalesceValue"
|
||||
$results += [PSCustomObject]@{
|
||||
Name = "WriteCoalesce"
|
||||
Passed = $coalesceMatch
|
||||
Detail = if ($coalesceMatch) {
|
||||
"three identical writes of $coalesceValue produced the expected readback (driver-side WritesSuppressed counter exposed via driver-diagnostics RPC)"
|
||||
} else {
|
||||
"three identical writes did not converge on $coalesceValue — got '$readOut'"
|
||||
}
|
||||
}
|
||||
|
||||
# PR abcip-4.3 — _System/_ConnectionStatus browse-and-read assertion. Reads the live
|
||||
# diagnostic snapshot via the OPC UA Client CLI; the value comes straight from the
|
||||
# AbCipSystemTagSource (no libplctag round-trip). When the probe loop is healthy + the
|
||||
# gateway is reachable, the value should be "Running"; on a stopped fixture it would be
|
||||
# "Stopped". The assertion accepts any of the four canonical states, plus the "Unknown"
|
||||
# transient that surfaces before the first probe iteration completes.
|
||||
if ($SystemConnectionStatusNodeId) {
|
||||
Write-Header "SystemTagBrowse (_System/_ConnectionStatus from $SystemConnectionStatusNodeId)"
|
||||
$sysReadArgs = @($opcUaCli.PrefixArgs) + @("read", "-u", $OpcUaUrl, "-n", $SystemConnectionStatusNodeId)
|
||||
$sysOut = & $opcUaCli.File @sysReadArgs 2>&1
|
||||
$sysJoined = ($sysOut -join "`n")
|
||||
$sysMatched = $sysJoined -match "Running|Stopped|Unknown|Faulted"
|
||||
$results += [PSCustomObject]@{
|
||||
Name = "SystemTagBrowse"
|
||||
Passed = $sysMatched
|
||||
Detail = if ($sysMatched) {
|
||||
"_ConnectionStatus surfaced one of Running / Stopped / Unknown / Faulted via OPC UA"
|
||||
} else {
|
||||
"_ConnectionStatus did not surface a recognised HostState — got '$sysJoined'"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# PR abcip-4.4 — _RefreshTagDb write-then-verify assertion. Writes True through the
|
||||
# OPC UA server (the live driver intercepts the write + dispatches to RebrowseAsync
|
||||
# against the cached IAddressSpaceBuilder) + reads back, asserting Kepware-style
|
||||
# latch semantics: the trigger always reads False the moment the dispatch returns.
|
||||
# Pairs with the existing rebrowse step driven by the AbCip CLI (issue #233) — both
|
||||
# surfaces hit the same RebrowseAsync entry point, just from different sides of the
|
||||
# OPC UA wire.
|
||||
if ($RefreshTagDbNodeId) {
|
||||
Write-Header "RefreshTagDbWrite (_System/_RefreshTagDb from $RefreshTagDbNodeId)"
|
||||
$writeArgs = @($opcUaCli.PrefixArgs) + @(
|
||||
"write", "-u", $OpcUaUrl, "-n", $RefreshTagDbNodeId, "-v", "true", "--type", "Boolean")
|
||||
$writeOut = & $opcUaCli.File @writeArgs 2>&1
|
||||
$writeJoined = ($writeOut -join "`n")
|
||||
# The OPC UA Client CLI surfaces "Good" on success; a non-Good result still
|
||||
# round-trips the literal status code so we can match generously.
|
||||
$writeOk = $writeJoined -match "Good"
|
||||
|
||||
$readArgs = @($opcUaCli.PrefixArgs) + @("read", "-u", $OpcUaUrl, "-n", $RefreshTagDbNodeId)
|
||||
$readOut = & $opcUaCli.File @readArgs 2>&1
|
||||
$readJoined = ($readOut -join "`n")
|
||||
# Kepware-style trigger reads always return false — assert the trigger isn't
|
||||
# latched to true after the write. Match case-insensitively because the OPC UA
|
||||
# Client CLI may render the value as "False" or "false".
|
||||
$readFalse = $readJoined -imatch "false"
|
||||
|
||||
$passed = $writeOk -and $readFalse
|
||||
$results += [PSCustomObject]@{
|
||||
Name = "RefreshTagDbWrite"
|
||||
Passed = $passed
|
||||
Detail = if ($passed) {
|
||||
"_RefreshTagDb write returned Good and read-back surfaced false — Kepware-style latch held"
|
||||
} else {
|
||||
"RefreshTagDb write/verify failed — write='$writeJoined' read='$readJoined'"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Summary -Title "AB CIP e2e" -Results $results
|
||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||
|
||||
@@ -29,6 +29,41 @@
|
||||
|
||||
.PARAMETER BridgeNodeId
|
||||
NodeId at which the server publishes the Address.
|
||||
|
||||
.PARAMETER DiagnosticsRequestCountNodeId
|
||||
Optional NodeId for the synthetic _Diagnostics/<host>/RequestCount variable
|
||||
emitted by AB Legacy discovery (PR ablegacy-10 / #253). When supplied, the
|
||||
script runs the DiagnosticsRequestCount assertion: reads the user-tag
|
||||
BridgeNodeId N times through the OPC UA server, then reads the diagnostic
|
||||
counter and asserts the value is at least N (a probe loop or a parallel
|
||||
client may have bumped it by more, so the comparison is `>=`). NodeId form:
|
||||
ns=<n>;s=AbLegacy/<gateway>/_Diagnostics/RequestCount. Mirrors the
|
||||
-SystemConnectionStatusNodeId knob on test-abcip.ps1.
|
||||
|
||||
.PARAMETER DiagnosticsDemoteCountNodeId
|
||||
Optional NodeId for the synthetic _Diagnostics/<host>/DemoteCount variable
|
||||
emitted by AB Legacy discovery (PR ablegacy-12 / #255). When supplied, the
|
||||
script runs the auto-demote assertion: kills the simulator container so
|
||||
reads start failing, hammers the user-tag BridgeNodeId at least
|
||||
FailureThreshold times to trip the demotion, then reads the diagnostic
|
||||
counter and asserts the value increased by >= 1. NodeId form:
|
||||
ns=<n>;s=AbLegacy/<gateway>/_Diagnostics/DemoteCount. The simulator
|
||||
must support `docker stop otopcua-ab-server-slc500` for the kill stage.
|
||||
|
||||
.PARAMETER FailureThresholdForDemote
|
||||
Failure threshold the server is configured with (default 3). The
|
||||
demote assertion writes/reads N+1 times against the killed simulator
|
||||
to guarantee the threshold trips even if some reads beat the kill.
|
||||
|
||||
.PARAMETER DhPlusStation
|
||||
PR ablegacy-13 / #256 — DH+ node address (octal 0..77 == decimal 0..63)
|
||||
of a PLC-5 reachable through a 1756-DHRIO module. **Documentation
|
||||
parameter only — there is no automated assertion**: libplctag's ab_server
|
||||
does not simulate the DHRIO + DH+ + PLC-5 stack, so wire-level coverage
|
||||
requires real hardware. When supplied alongside a `-Gateway` of the form
|
||||
`ab://<host>/1,<slot>,2,<station-octal>` and `-PlcType Plc5`, the value
|
||||
here is recorded in the run log so reproducibility is auditable. See
|
||||
docs/drivers/AbLegacy-DH-Bridging.md for the manual smoke procedure.
|
||||
#>
|
||||
|
||||
param(
|
||||
@@ -36,7 +71,14 @@ param(
|
||||
[string]$PlcType = "Slc500",
|
||||
[string]$Address = "N7:5",
|
||||
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||
[Parameter(Mandatory)] [string]$BridgeNodeId
|
||||
[Parameter(Mandatory)] [string]$BridgeNodeId,
|
||||
[string]$DiagnosticsRequestCountNodeId,
|
||||
[string]$DiagnosticsDemoteCountNodeId,
|
||||
[int]$FailureThresholdForDemote = 3,
|
||||
# PR ablegacy-13 / #256 — DH+ station via 1756-DHRIO bridging. Doc-only:
|
||||
# no automated assertion (no Docker fixture covers DH+). See script header
|
||||
# comment + docs/drivers/AbLegacy-DH-Bridging.md.
|
||||
[string]$DhPlusStation
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -95,5 +137,206 @@ $results += Test-SubscribeSeesChange `
|
||||
-DriverWriteArgs (@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $subValue)) `
|
||||
-ExpectedValue "$subValue"
|
||||
|
||||
# PR 7 — contiguous array read smoke. The default `--tag=N7[120]` in the Docker
|
||||
# fixture's docker-compose.yml has plenty of room for `,10`; against real hardware
|
||||
# the seeded N7 file just needs at least 10 words. Asserts the CLI exits 0 (the
|
||||
# driver issued one PCCC frame for the whole block) — the per-element values are
|
||||
# whatever the device currently holds.
|
||||
Write-Header "Array contiguous read"
|
||||
$arrayResult = Invoke-Cli -Cli $abLegacyCli `
|
||||
-Args (@("read") + $commonAbLegacy + @("-a", "N7:0,10", "-t", "Int"))
|
||||
if ($arrayResult.ExitCode -eq 0) {
|
||||
Write-Pass "array read N7:0,10 succeeded"
|
||||
$results += @{ Passed = $true }
|
||||
} else {
|
||||
Write-Fail "array read N7:0,10 exit=$($arrayResult.ExitCode)"
|
||||
Write-Host $arrayResult.Output
|
||||
$results += @{ Passed = $false; Reason = "array read exit $($arrayResult.ExitCode)" }
|
||||
}
|
||||
|
||||
# PR 8 — deadband subscribe assertion. Subscribe with --deadband-absolute 5,
|
||||
# write three small deltas (each within the 5-unit deadband), assert exactly
|
||||
# one notification fires (the first-seen sample). The fourth write breaks
|
||||
# above the threshold and the subscription should fire again.
|
||||
Write-Header "Deadband subscribe (--deadband-absolute 5)"
|
||||
$baseValue = Get-Random -Minimum 100 -Maximum 200
|
||||
& $abLegacyCli.File @($abLegacyCli.PrefixArgs) `
|
||||
@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", $baseValue) | Out-Null
|
||||
$subscribeProc = Start-Process -FilePath $abLegacyCli.File `
|
||||
-ArgumentList ($abLegacyCli.PrefixArgs + @("subscribe") + $commonAbLegacy `
|
||||
+ @("-a", $Address, "-t", "Int", "-i", "200", "--deadband-absolute", "5")) `
|
||||
-PassThru -RedirectStandardOutput "$env:TEMP/ablegacy-deadband.out" `
|
||||
-RedirectStandardError "$env:TEMP/ablegacy-deadband.err"
|
||||
Start-Sleep -Seconds 2
|
||||
# Three small deltas within deadband.
|
||||
& $abLegacyCli.File @($abLegacyCli.PrefixArgs) `
|
||||
@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", ($baseValue + 1)) | Out-Null
|
||||
Start-Sleep -Milliseconds 500
|
||||
& $abLegacyCli.File @($abLegacyCli.PrefixArgs) `
|
||||
@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", ($baseValue + 2)) | Out-Null
|
||||
Start-Sleep -Milliseconds 500
|
||||
& $abLegacyCli.File @($abLegacyCli.PrefixArgs) `
|
||||
@("write") + $commonAbLegacy + @("-a", $Address, "-t", "Int", "-v", ($baseValue + 3)) | Out-Null
|
||||
Start-Sleep -Milliseconds 500
|
||||
Stop-Process -Id $subscribeProc.Id -Force -ErrorAction SilentlyContinue
|
||||
$subscribeOutput = Get-Content "$env:TEMP/ablegacy-deadband.out" -ErrorAction SilentlyContinue
|
||||
# Count `=` lines (the SubscribeCommand format prints one per OnDataChange). Expect exactly 1
|
||||
# (the first-seen sample at $baseValue) — none of the +1/+2/+3 deltas crosses the 5 absolute.
|
||||
$notifyLines = @($subscribeOutput | Where-Object { $_ -match " = " })
|
||||
if ($notifyLines.Count -eq 1) {
|
||||
Write-Pass "deadband subscribe emitted 1 notification (initial only); 3 sub-threshold writes suppressed"
|
||||
$results += @{ Passed = $true }
|
||||
} else {
|
||||
Write-Fail "deadband subscribe expected 1 notification; got $($notifyLines.Count)"
|
||||
Write-Host ($subscribeOutput -join "`n")
|
||||
$results += @{ Passed = $false; Reason = "deadband notify count $($notifyLines.Count)" }
|
||||
}
|
||||
|
||||
# PR ablegacy-10 / #253 — diagnostic-counter round-trip assertion. After N reads
|
||||
# against the user-tag BridgeNodeId the auto-emitted _Diagnostics/<host>/RequestCount
|
||||
# counter must be >= N. The exact equality isn't asserted because a probe loop /
|
||||
# parallel client may have bumped the counter — the spec is "every read counts".
|
||||
if ($DiagnosticsRequestCountNodeId) {
|
||||
Write-Header "DiagnosticsRequestCount (_Diagnostics/RequestCount from $DiagnosticsRequestCountNodeId)"
|
||||
$diagN = 5
|
||||
# Read the first counter snapshot to baseline; the assertion compares delta against
|
||||
# the N OPC UA reads we issue between snapshots so a noisy probe loop doesn't
|
||||
# invalidate the test.
|
||||
$baselineOut = & $opcUaCli.File @($opcUaCli.PrefixArgs) `
|
||||
@("read", "-u", $OpcUaUrl, "-n", $DiagnosticsRequestCountNodeId) 2>&1
|
||||
$baseline = 0
|
||||
if (($baselineOut -join "`n") -match '(\d+)') { $baseline = [int64]$Matches[1] }
|
||||
|
||||
for ($i = 0; $i -lt $diagN; $i++) {
|
||||
& $opcUaCli.File @($opcUaCli.PrefixArgs) `
|
||||
@("read", "-u", $OpcUaUrl, "-n", $BridgeNodeId) | Out-Null
|
||||
}
|
||||
|
||||
$afterOut = & $opcUaCli.File @($opcUaCli.PrefixArgs) `
|
||||
@("read", "-u", $OpcUaUrl, "-n", $DiagnosticsRequestCountNodeId) 2>&1
|
||||
$after = 0
|
||||
if (($afterOut -join "`n") -match '(\d+)') { $after = [int64]$Matches[1] }
|
||||
|
||||
$delta = $after - $baseline
|
||||
if ($delta -ge $diagN) {
|
||||
Write-Pass "DiagnosticsRequestCount delta $delta >= $diagN OPC UA reads"
|
||||
$results += @{ Passed = $true }
|
||||
} else {
|
||||
Write-Fail "DiagnosticsRequestCount delta $delta < $diagN OPC UA reads (baseline=$baseline after=$after)"
|
||||
$results += @{ Passed = $false; Reason = "diag delta $delta < $diagN" }
|
||||
}
|
||||
}
|
||||
|
||||
# ablegacy-11 / #254 — RSLogix CSV import smoke. Builds an in-memory canonical CSV
|
||||
# (one row per N/F/B/L/ST/T/C/R file letter), invokes `import-rslogix --emit
|
||||
# appsettings-fragment` against it, parses the resulting JSON, and asserts the Tags
|
||||
# array carries exactly 8 entries. Doesn't talk to the PLC — purely offline parser
|
||||
# coverage.
|
||||
Write-Header "RSLogix CSV import"
|
||||
$importCsvPath = Join-Path $env:TEMP "ablegacy-rslogix-canonical-$([guid]::NewGuid()).csv"
|
||||
$importJsonPath = Join-Path $env:TEMP "ablegacy-rslogix-fragment-$([guid]::NewGuid()).json"
|
||||
@"
|
||||
Symbol,Address,Description,DataType,Scope
|
||||
MotorSpeed,N7:0,Motor speed setpoint,INT,Global
|
||||
TankLevel,F8:0,Tank level (gallons),REAL,Global
|
||||
RunFlag,B3:0/0,Run command flag,BOOL,Global
|
||||
TotalCount,L9:0,Total piece count,LINT,Global
|
||||
RecipeName,ST10:0,"Recipe name, free-form text",STRING,Global
|
||||
DwellTimer,T4:0.ACC,Dwell timer accumulator,TIMER,Global
|
||||
PieceCounter,C5:0.ACC,Piece counter accumulator,COUNTER,Global
|
||||
StateMachine,R6:0.LEN,State-machine control length,CONTROL,Global
|
||||
"@ | Set-Content -Path $importCsvPath -Encoding UTF8
|
||||
|
||||
try {
|
||||
$importResult = Invoke-Cli -Cli $abLegacyCli `
|
||||
-Args @("import-rslogix", "--file", $importCsvPath, "--device", $Gateway,
|
||||
"--emit", "appsettings-fragment", "--output", $importJsonPath)
|
||||
if ($importResult.ExitCode -ne 0) {
|
||||
Write-Fail "import-rslogix exit=$($importResult.ExitCode): $($importResult.Output)"
|
||||
$results += @{ Passed = $false; Reason = "import-rslogix exit $($importResult.ExitCode)" }
|
||||
}
|
||||
elseif (-not (Test-Path $importJsonPath)) {
|
||||
Write-Fail "import-rslogix produced no output file at $importJsonPath"
|
||||
$results += @{ Passed = $false; Reason = "no output file" }
|
||||
}
|
||||
else {
|
||||
$fragment = Get-Content $importJsonPath -Raw | ConvertFrom-Json
|
||||
$tagCount = @($fragment.Tags).Count
|
||||
if ($tagCount -eq 8) {
|
||||
Write-Pass "import-rslogix emitted $tagCount tag(s) — matches CSV row count"
|
||||
$results += @{ Passed = $true }
|
||||
} else {
|
||||
Write-Fail "import-rslogix emitted $tagCount tag(s); expected 8"
|
||||
$results += @{ Passed = $false; Reason = "tag count $tagCount" }
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-Item -Path $importCsvPath -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path $importJsonPath -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# PR ablegacy-12 / #255 — auto-demote round-trip. Kill the simulator container,
|
||||
# hammer the bridge NodeId past the failure threshold, then assert the
|
||||
# DemoteCount diagnostic incremented. Restart the simulator at the end so the
|
||||
# next run gets a clean baseline. Gated on -DiagnosticsDemoteCountNodeId so
|
||||
# environments without docker-side control of the simulator can opt out.
|
||||
if ($DiagnosticsDemoteCountNodeId) {
|
||||
Write-Header "AutoDemote (kill simulator + observe DemoteCount from $DiagnosticsDemoteCountNodeId)"
|
||||
$baselineDemoteOut = & $opcUaCli.File @($opcUaCli.PrefixArgs) `
|
||||
@("read", "-u", $OpcUaUrl, "-n", $DiagnosticsDemoteCountNodeId) 2>&1
|
||||
$baselineDemote = 0
|
||||
if (($baselineDemoteOut -join "`n") -match '(\d+)') { $baselineDemote = [int64]$Matches[1] }
|
||||
|
||||
# Best-effort container kill — prefer the slc500 profile name; fall back to
|
||||
# micrologix / plc5 in case the operator pointed the e2e at a different family.
|
||||
$simContainers = @("otopcua-ab-server-slc500", "otopcua-ab-server-micrologix", "otopcua-ab-server-plc5")
|
||||
$killed = $false
|
||||
foreach ($c in $simContainers) {
|
||||
$stop = docker stop $c 2>$null
|
||||
if ($LASTEXITCODE -eq 0 -and $stop) {
|
||||
Write-Host "Stopped $c"
|
||||
$killed = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (-not $killed) {
|
||||
Write-Fail "AutoDemote: no ab_server container found via 'docker stop' — skipping demote assertion"
|
||||
$results += @{ Passed = $false; Reason = "no simulator container to kill" }
|
||||
}
|
||||
else {
|
||||
# Hammer past the threshold. Each read against a now-unreachable simulator
|
||||
# surfaces BadCommunicationError; FailureThreshold consecutive ones trip
|
||||
# the demotion. We add 2 extra to absorb timing slack (one read may be
|
||||
# in-flight when the kill lands).
|
||||
$hammerCount = $FailureThresholdForDemote + 2
|
||||
for ($i = 0; $i -lt $hammerCount; $i++) {
|
||||
& $opcUaCli.File @($opcUaCli.PrefixArgs) `
|
||||
@("read", "-u", $OpcUaUrl, "-n", $BridgeNodeId) 2>&1 | Out-Null
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds 1
|
||||
|
||||
$afterDemoteOut = & $opcUaCli.File @($opcUaCli.PrefixArgs) `
|
||||
@("read", "-u", $OpcUaUrl, "-n", $DiagnosticsDemoteCountNodeId) 2>&1
|
||||
$afterDemote = 0
|
||||
if (($afterDemoteOut -join "`n") -match '(\d+)') { $afterDemote = [int64]$Matches[1] }
|
||||
|
||||
$deltaDemote = $afterDemote - $baselineDemote
|
||||
if ($deltaDemote -ge 1) {
|
||||
Write-Pass "AutoDemote DemoteCount delta $deltaDemote >= 1 after $hammerCount failed reads"
|
||||
$results += @{ Passed = $true }
|
||||
} else {
|
||||
Write-Fail "AutoDemote DemoteCount delta $deltaDemote < 1 (baseline=$baselineDemote after=$afterDemote)"
|
||||
$results += @{ Passed = $false; Reason = "demote delta $deltaDemote" }
|
||||
}
|
||||
|
||||
# Restart the simulator so subsequent test runs have a clean baseline.
|
||||
# Best-effort — if docker-compose isn't on the path the operator can
|
||||
# bring it back manually via the Docker/docker-compose.yml profile.
|
||||
try { docker start (docker ps -aq -f "name=otopcua-ab-server-") | Out-Null } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Summary -Title "AB Legacy e2e" -Results $results
|
||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||
|
||||
+108
-1
@@ -26,6 +26,46 @@
|
||||
|
||||
.PARAMETER BridgeNodeId
|
||||
NodeId at which the server publishes the Address.
|
||||
|
||||
.PARAMETER Write
|
||||
Issue #268 (F4-a) + #269 (F4-b) — opts the script into write stages.
|
||||
Without -Write the script runs read-only probe / loopback / bridge
|
||||
coverage. With -Write the script additionally exercises the F4-b
|
||||
cnc_wrparam + cnc_wrmacro round-trip stages against the configured
|
||||
-ParamAddress / -MacroAddress (default safe values). The wire writes
|
||||
fire only when FOCAS_TRUST_WIRE=1 (already gated above) AND the
|
||||
operator explicitly requests the write path.
|
||||
|
||||
.PARAMETER ParamAddress
|
||||
Parameter address for the F4-b write stage (default PARAM:1815).
|
||||
Only used when -Write is supplied. Pick a parameter that's safe to
|
||||
scribble on for your CNC setup — the default is benign for a stock
|
||||
Fanuc 30i but every site differs.
|
||||
|
||||
.PARAMETER MacroAddress
|
||||
Macro variable for the F4-b write stage (default MACRO:500). Macro
|
||||
writes are the lowest-risk write surface (no parameter-write switch
|
||||
needed, no MDI mode required) so this stage runs whenever -Write is
|
||||
supplied.
|
||||
|
||||
.PARAMETER PmcBitAddress
|
||||
PMC bit address for the F4-c bit-write round-trip stage (default
|
||||
R100.3). Only fires when -Write is supplied AND the operator
|
||||
double-opts in via FOCAS_PMC_WRITE=1, mirroring the FOCAS_PARAM_WRITE
|
||||
gate. PMC writes have a higher blast radius than PARAM/MACRO (a
|
||||
mistargeted bit can move motion or latch a feedhold) so the gate is
|
||||
off by default — see docs/v2/focas-deployment.md "Write safety / PMC
|
||||
pre-checks".
|
||||
|
||||
.PARAMETER CncPassword
|
||||
Issue #271 (F4-d) — optional CNC connection-level password emitted via
|
||||
cnc_wrunlockparam on connect. Required only when the controller gates
|
||||
parameter writes behind a password switch (16i + some 30i firmwares
|
||||
with parameter-protect on). Threaded through to every CLI invocation
|
||||
in the -Write stage as --cnc-password. PASSWORD INVARIANT: never
|
||||
logged — the CLI's Serilog config does not destructure this flag.
|
||||
See docs/v2/focas-deployment.md § "FOCAS password handling" for the
|
||||
no-log invariant + rotation runbook.
|
||||
#>
|
||||
|
||||
param(
|
||||
@@ -33,7 +73,12 @@ param(
|
||||
[int]$CncPort = 8193,
|
||||
[string]$Address = "R100",
|
||||
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||
[Parameter(Mandatory)] [string]$BridgeNodeId
|
||||
[Parameter(Mandatory)] [string]$BridgeNodeId,
|
||||
[switch]$Write,
|
||||
[string]$ParamAddress = "PARAM:1815",
|
||||
[string]$MacroAddress = "MACRO:500",
|
||||
[string]$PmcBitAddress = "R100.3",
|
||||
[string]$CncPassword = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -52,6 +97,15 @@ $opcUaCli = Get-CliInvocation `
|
||||
-ExeName "otopcua-cli"
|
||||
|
||||
$commonFocas = @("-h", $CncHost, "-p", $CncPort)
|
||||
# F4-d (issue #271) — thread the CNC connection password through to every CLI
|
||||
# invocation. The CLI's --cnc-password flag emits cnc_wrunlockparam on connect
|
||||
# and the driver's per-call retry path re-issues unlock + retries once on
|
||||
# EW_PASSWD. PASSWORD INVARIANT: the password is NOT logged here. Write-Host
|
||||
# and Test-* helpers never destructure $commonFocas, but we still avoid
|
||||
# Write-Host'ing the array directly; the CLI's Serilog config also redacts.
|
||||
if (-not [string]::IsNullOrWhiteSpace($CncPassword)) {
|
||||
$commonFocas += @("--cnc-password", $CncPassword)
|
||||
}
|
||||
$results = @()
|
||||
|
||||
$results += Test-Probe `
|
||||
@@ -92,5 +146,58 @@ $results += Test-SubscribeSeesChange `
|
||||
-DriverWriteArgs (@("write") + $commonFocas + @("-a", $Address, "-t", "Int16", "-v", $subValue)) `
|
||||
-ExpectedValue "$subValue"
|
||||
|
||||
if ($Write) {
|
||||
# F4-b — macro + parameter round-trip writes. Both stages use the same
|
||||
# write-then-read shape the existing PMC stages use; the per-tag value
|
||||
# comes back through Test-DriverLoopback's read step.
|
||||
#
|
||||
# Macro writes run unconditionally when -Write is supplied — no MDI / no
|
||||
# parameter-write switch dependency, lowest-risk write surface on a CNC.
|
||||
$macroValue = Get-Random -Minimum 100 -Maximum 9999
|
||||
$results += Test-DriverLoopback `
|
||||
-Cli $focasCli `
|
||||
-WriteArgs (@("write") + $commonFocas + @("-a", $MacroAddress, "-t", "Int32", "-v", $macroValue)) `
|
||||
-ReadArgs (@("read") + $commonFocas + @("-a", $MacroAddress, "-t", "Int32")) `
|
||||
-ExpectedValue "$macroValue"
|
||||
|
||||
# Parameter writes only fire when the operator double-opts in via
|
||||
# FOCAS_PARAM_WRITE=1. The CNC must be in MDI mode + parameter-write
|
||||
# switch enabled or every write returns EW_PASSWD (BadUserAccessDenied);
|
||||
# without an opt-in the script won't even attempt the write. F4-d will
|
||||
# land an OPC UA-side unlock workflow that lets this stage run without
|
||||
# the pendant.
|
||||
if ($env:FOCAS_PARAM_WRITE -eq "1" -or $env:FOCAS_PARAM_WRITE -eq "true") {
|
||||
$paramValue = Get-Random -Minimum 100 -Maximum 9999
|
||||
$results += Test-DriverLoopback `
|
||||
-Cli $focasCli `
|
||||
-WriteArgs (@("write") + $commonFocas + @("-a", $ParamAddress, "-t", "Int32", "-v", $paramValue)) `
|
||||
-ReadArgs (@("read") + $commonFocas + @("-a", $ParamAddress, "-t", "Int32")) `
|
||||
-ExpectedValue "$paramValue"
|
||||
} else {
|
||||
Write-Host "[skip] FOCAS_PARAM_WRITE not set — parameter-write stage requires the CNC to be in MDI mode + parameter-write switch enabled (see docs/v2/focas-deployment.md 'Write safety')."
|
||||
}
|
||||
|
||||
# F4-c — PMC bit round-trip. PMC writes have a higher blast radius
|
||||
# than PARAM/MACRO (a mistargeted bit can move motion or latch a
|
||||
# feedhold) so the stage is gated on a separate FOCAS_PMC_WRITE=1
|
||||
# opt-in. The bit write exercises the driver's read-modify-write
|
||||
# path: write 'on' -> read returns 'on'; write 'off' -> read returns
|
||||
# 'off'. Both halves run so a regression in either branch is caught.
|
||||
if ($env:FOCAS_PMC_WRITE -eq "1" -or $env:FOCAS_PMC_WRITE -eq "true") {
|
||||
$results += Test-DriverLoopback `
|
||||
-Cli $focasCli `
|
||||
-WriteArgs (@("write") + $commonFocas + @("-a", $PmcBitAddress, "-t", "Bit", "-v", "on")) `
|
||||
-ReadArgs (@("read") + $commonFocas + @("-a", $PmcBitAddress, "-t", "Bit")) `
|
||||
-ExpectedValue "True"
|
||||
$results += Test-DriverLoopback `
|
||||
-Cli $focasCli `
|
||||
-WriteArgs (@("write") + $commonFocas + @("-a", $PmcBitAddress, "-t", "Bit", "-v", "off")) `
|
||||
-ReadArgs (@("read") + $commonFocas + @("-a", $PmcBitAddress, "-t", "Bit")) `
|
||||
-ExpectedValue "False"
|
||||
} else {
|
||||
Write-Host "[skip] FOCAS_PMC_WRITE not set — PMC bit-write round-trip is off by default because a mistargeted PMC bit can move motion or latch a feedhold (see docs/v2/focas-deployment.md 'PMC pre-checks')."
|
||||
}
|
||||
}
|
||||
|
||||
Write-Summary -Title "FOCAS e2e" -Results $results
|
||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
#Requires -Version 7.0
|
||||
<#
|
||||
.SYNOPSIS
|
||||
End-to-end CLI test for the OPC UA Client (gateway) driver bridged through
|
||||
the OtOpcUa server. Stages: probe, read, subscribe, topology-change.
|
||||
|
||||
.DESCRIPTION
|
||||
The OPC UA Client driver reads from an upstream OPC UA server (default:
|
||||
Microsoft's opc-plc simulator on opc.tcp://localhost:50000) and re-exposes
|
||||
its address space through the local OtOpcUa server. This script drives
|
||||
the bridged path end-to-end via `otopcua-cli`.
|
||||
|
||||
Four stages:
|
||||
|
||||
1. Probe — otopcua-cli connect succeeds against the OtOpcUa
|
||||
server; confirms the gateway is up.
|
||||
2. Bridged read — otopcua-cli read on the bridged NodeId returns a
|
||||
Good value with a non-null payload; proves the
|
||||
IReadable.ReadAsync path round-trips through the
|
||||
driver to the upstream simulator.
|
||||
3. Subscribe — otopcua-cli subscribe observes a data change within
|
||||
N seconds (opc-plc's StepUp ticks once per second by
|
||||
default, so this should always see a change).
|
||||
4. Topology change — assert the auto-reimport-on-ModelChangeEvent path
|
||||
is wired up. We can't easily fire a real upstream
|
||||
model change without elevated opc-plc access, so
|
||||
this stage prints the option settings + asserts the
|
||||
driver's diagnostic surface reflects WatchModelChanges
|
||||
is enabled (or skips with INFO when the upstream
|
||||
doesn't expose ModelChangeEventType).
|
||||
|
||||
Requires:
|
||||
- a running OtOpcUa server whose config DB has an OpcUaClient
|
||||
DriverInstance bound to opc-plc (or another upstream server)
|
||||
- the upstream OPC UA simulator reachable at $UpstreamUrl
|
||||
- a Tag bridged from upstream NodeId $UpstreamNodeId to local
|
||||
$BridgedNodeId
|
||||
|
||||
.PARAMETER OpcUaUrl
|
||||
Endpoint URL of the OtOpcUa server. Default opc.tcp://localhost:4840.
|
||||
|
||||
.PARAMETER UpstreamUrl
|
||||
Endpoint URL of the upstream OPC UA server (for documentation; the bridge
|
||||
itself is wired in the OtOpcUa server config). Default opc.tcp://localhost:50000.
|
||||
|
||||
.PARAMETER BridgedNodeId
|
||||
Local NodeId the OtOpcUa server exposes for the upstream tag. Required —
|
||||
set per your server config (e.g. 'ns=2;s=/warsaw/opc-plc/StepUp').
|
||||
|
||||
.PARAMETER UpstreamNodeId
|
||||
The upstream NodeId being bridged (informational only; default
|
||||
'ns=3;s=StepUp' which is opc-plc's monotonically-increasing UInt32).
|
||||
|
||||
.PARAMETER ChangeWaitSec
|
||||
How long the subscribe stage waits for a data-change. Default 10s.
|
||||
|
||||
.PARAMETER ReverseConnect
|
||||
When set, the script asserts the gateway is configured for reverse-connect
|
||||
(server-initiated) mode. The OtOpcUa server's DriverConfig for the OpcUaClient
|
||||
instance must already have ReverseConnect.Enabled=true + ListenerUrl set; this
|
||||
script doesn't reconfigure the driver, only verifies the bridged path still
|
||||
reads end-to-end with the listener up. The reverse-connect topology is opaque
|
||||
to the downstream OPC UA client (us), so the read assertion is identical to
|
||||
the dial-mode path — the value of running the script in this mode is to catch
|
||||
regressions where reverse-connect breaks the post-init capability surface.
|
||||
|
||||
.PARAMETER ReverseListenerUrl
|
||||
Documentation-only. The listener URL the gateway is expected to be bound to
|
||||
when -ReverseConnect is set; printed in the run banner so operators can
|
||||
cross-check their server config. Default opc.tcp://0.0.0.0:4844.
|
||||
|
||||
.EXAMPLE
|
||||
.\test-opcuaclient.ps1 -BridgedNodeId "ns=2;s=/warsaw/opc-plc/StepUp"
|
||||
|
||||
.EXAMPLE
|
||||
# OT-DMZ deployment: the upstream dials the gateway. The script flow is the
|
||||
# same — we still drive the bridged read through the OtOpcUa server — but the
|
||||
# banner reflects the reverse-connect topology.
|
||||
.\test-opcuaclient.ps1 -BridgedNodeId "ns=2;s=/warsaw/opc-plc/StepUp" -ReverseConnect
|
||||
#>
|
||||
|
||||
param(
|
||||
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||
[string]$UpstreamUrl = "opc.tcp://localhost:50000",
|
||||
[Parameter(Mandatory)] [string]$BridgedNodeId,
|
||||
[string]$UpstreamNodeId = "ns=3;s=StepUp",
|
||||
[int]$ChangeWaitSec = 10,
|
||||
[switch]$ReverseConnect,
|
||||
[string]$ReverseListenerUrl = "opc.tcp://0.0.0.0:4844",
|
||||
# PR-12: HistoryReadEvents passthrough check. Requires the upstream to be running
|
||||
# in alarm-history mode (opc-plc --alm) AND the OtOpcUa server to expose a notifier
|
||||
# node bridged to the upstream's events source. The CLI doesn't have a dedicated
|
||||
# event-history command yet; this stage runs a regular historyread against the
|
||||
# bridged notifier and confirms the gateway round-trips the request without
|
||||
# surfacing BadHistoryOperationUnsupported, which would indicate the filter-aware
|
||||
# ReadEventsAsync path lost wiring.
|
||||
[switch]$HistoryEvents,
|
||||
[string]$EventsNotifierNodeId = "i=2253"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. "$PSScriptRoot/_common.ps1"
|
||||
|
||||
$opcUaCli = Get-CliInvocation `
|
||||
-ProjectFolder "src/ZB.MOM.WW.OtOpcUa.Client.CLI" `
|
||||
-ExeName "otopcua-cli"
|
||||
|
||||
if ($ReverseConnect) {
|
||||
Write-Host "[INFO] -ReverseConnect set: gateway is expected to be bound to listener $ReverseListenerUrl"
|
||||
Write-Host "[INFO] Upstream OPC UA server should be configured with --rc=$ReverseListenerUrl (or equivalent on a real server)"
|
||||
}
|
||||
|
||||
$results = @()
|
||||
|
||||
# Stage 1: probe
|
||||
$results += Test-Probe `
|
||||
-Name "OpcUaClient probe" `
|
||||
-Cmd $opcUaCli `
|
||||
-Args @("connect", "-u", $OpcUaUrl)
|
||||
|
||||
# Stage 2: bridged read
|
||||
$results += Test-Probe `
|
||||
-Name "OpcUaClient bridged read" `
|
||||
-Cmd $opcUaCli `
|
||||
-Args @("read", "-u", $OpcUaUrl, "-n", $BridgedNodeId)
|
||||
|
||||
# Stage 3: subscribe-sees-change
|
||||
Write-Host "[INFO] Subscribing to $BridgedNodeId for ${ChangeWaitSec}s..."
|
||||
$subResults = & $opcUaCli.Cmd @($opcUaCli.Args + @(
|
||||
"subscribe", "-u", $OpcUaUrl, "-n", $BridgedNodeId,
|
||||
"-i", "500", "--duration", "$ChangeWaitSec"))
|
||||
if ($LASTEXITCODE -eq 0 -and $subResults -match "DataChange|StepUp|value=") {
|
||||
$results += [pscustomobject]@{ Stage = "Subscribe-sees-change"; Status = "PASS" }
|
||||
} else {
|
||||
$results += [pscustomobject]@{ Stage = "Subscribe-sees-change"; Status = "FAIL" }
|
||||
}
|
||||
|
||||
# Stage 4: topology change (auto-reimport on ModelChangeEvent)
|
||||
#
|
||||
# The OPC UA Client driver subscribes to BaseModelChangeEventType on the
|
||||
# upstream Server node (i=2253) at the end of InitializeAsync, then debounces
|
||||
# events over OpcUaClientDriverOptions.ModelChangeDebounce (default 5s) and
|
||||
# triggers ReinitializeAsync.
|
||||
#
|
||||
# Driving a real upstream ModelChangeEvent from outside the simulator is
|
||||
# upstream-specific:
|
||||
# - opc-plc: invoke OpcPlc.AddSlowNode via OPC UA Call (requires a session
|
||||
# directly to opc-plc, not via the gateway, since the gateway exposes
|
||||
# mirrored read/write paths only for variables — methods are mirrored
|
||||
# under PR-9 but call permissions on the simulator's namespace may
|
||||
# not allow downstream invocation).
|
||||
# - production server: deploy a topology-change to the upstream server +
|
||||
# observe the local re-import.
|
||||
#
|
||||
# This stage is therefore documentation-only by default. Set
|
||||
# $env:OPCUACLIENT_TOPOLOGY_TRIGGER_CMD to a command that drives a real
|
||||
# topology change on the upstream and we'll execute it + wait for the
|
||||
# debounced re-import.
|
||||
$triggerCmd = $env:OPCUACLIENT_TOPOLOGY_TRIGGER_CMD
|
||||
if ($triggerCmd) {
|
||||
Write-Host "[INFO] Driving topology change via: $triggerCmd"
|
||||
& cmd.exe /c $triggerCmd
|
||||
Start-Sleep -Seconds 8 # debounce window + re-import duration
|
||||
# After re-import the bridged node should still be readable (or, if
|
||||
# the upstream removed the node, the read should return BadNodeIdUnknown).
|
||||
# Either way the gateway must remain healthy.
|
||||
$results += Test-Probe `
|
||||
-Name "Topology-change re-read" `
|
||||
-Cmd $opcUaCli `
|
||||
-Args @("read", "-u", $OpcUaUrl, "-n", $BridgedNodeId)
|
||||
} else {
|
||||
Write-Host "[INFO] Topology-change stage skipped (set OPCUACLIENT_TOPOLOGY_TRIGGER_CMD to drive a real upstream model change)."
|
||||
$results += [pscustomobject]@{ Stage = "Topology-change"; Status = "SKIP" }
|
||||
}
|
||||
|
||||
# Stage 5 (gated): HistoryReadEvents passthrough
|
||||
#
|
||||
# PR-12 lands the filter-aware IHistoryProvider.ReadEventsAsync overload on the
|
||||
# OPC UA Client driver. End-to-end coverage requires:
|
||||
# (a) the upstream in alarm-history mode (opc-plc --alm or a real server);
|
||||
# (b) the OtOpcUa server forwarding HistoryReadEvents to the gateway driver.
|
||||
# Gated behind -HistoryEvents because the default opc-plc fixture image isn't
|
||||
# launched with --alm. When set, the stage issues a historyread against the
|
||||
# bridged notifier ($EventsNotifierNodeId) and confirms the gateway returns
|
||||
# the request without BadHistoryOperationUnsupported.
|
||||
if ($HistoryEvents) {
|
||||
Write-Host "[INFO] HistoryEvents stage: issuing historyread against $EventsNotifierNodeId"
|
||||
$start = (Get-Date).ToUniversalTime().AddMinutes(-30).ToString("o")
|
||||
$end = (Get-Date).ToUniversalTime().AddMinutes(1).ToString("o")
|
||||
$eventOut = & $opcUaCli.Cmd @($opcUaCli.Args + @(
|
||||
"historyread", "-u", $OpcUaUrl, "-n", $EventsNotifierNodeId,
|
||||
"--start", $start, "--end", $end))
|
||||
if ($LASTEXITCODE -eq 0 -and $eventOut -notmatch "BadHistoryOperationUnsupported") {
|
||||
$results += [pscustomobject]@{ Stage = "HistoryReadEvents"; Status = "PASS" }
|
||||
} elseif ($eventOut -match "BadHistoryOperationUnsupported") {
|
||||
Write-Host "[INFO] Upstream returned BadHistoryOperationUnsupported — re-run with --alm + a notifier that has event history."
|
||||
$results += [pscustomobject]@{ Stage = "HistoryReadEvents"; Status = "SKIP" }
|
||||
} else {
|
||||
$results += [pscustomobject]@{ Stage = "HistoryReadEvents"; Status = "FAIL" }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== test-opcuaclient.ps1 results ==="
|
||||
$results | Format-Table -AutoSize
|
||||
$failed = $results | Where-Object { $_.Status -eq "FAIL" }
|
||||
if ($failed) {
|
||||
exit 1
|
||||
}
|
||||
exit 0
|
||||
@@ -96,5 +96,28 @@ $results += Test-SubscribeSeesChange `
|
||||
-DriverWriteArgs (@("write") + $commonS7 + @("-a", $Address, "-t", "Int16", "-v", $subValue)) `
|
||||
-ExpectedValue "$subValue"
|
||||
|
||||
# PR-S7-D2 / #300 — UDT-member round-trip. Exercises the byte offsets the
|
||||
# driver's UDT fan-out uses when expanding a UDT-typed parent tag into per-
|
||||
# member scalar leaves: Real at DB1.DBD400 and Int16 at DB1.DBW404 match the
|
||||
# `MyUdt` layout seeded by Docker/profiles/s7_1500.json's udt_layout meta-seed
|
||||
# and declared by S7_1500UdtFanOutTests. The CLI itself is UDT-unaware so the
|
||||
# e2e step writes / reads at the explicit member byte offsets — proves the
|
||||
# wire-level path the fan-out emits is sound end-to-end.
|
||||
$udtPressureAddress = $Address.Substring(0, $Address.IndexOf('.')) + ".DBD400"
|
||||
$udtPressureValue = "27.5"
|
||||
$results += Test-DriverLoopback `
|
||||
-Cli $s7Cli `
|
||||
-WriteArgs (@("write") + $commonS7 + @("-a", $udtPressureAddress, "-t", "Float32", "-v", $udtPressureValue)) `
|
||||
-ReadArgs (@("read") + $commonS7 + @("-a", $udtPressureAddress, "-t", "Float32")) `
|
||||
-ExpectedValue $udtPressureValue
|
||||
|
||||
$udtStatusAddress = $Address.Substring(0, $Address.IndexOf('.')) + ".DBW404"
|
||||
$udtStatusValue = Get-Random -Minimum 100 -Maximum 999
|
||||
$results += Test-DriverLoopback `
|
||||
-Cli $s7Cli `
|
||||
-WriteArgs (@("write") + $commonS7 + @("-a", $udtStatusAddress, "-t", "Int16", "-v", $udtStatusValue)) `
|
||||
-ReadArgs (@("read") + $commonS7 + @("-a", $udtStatusAddress, "-t", "Int16")) `
|
||||
-ExpectedValue "$udtStatusValue"
|
||||
|
||||
Write-Summary -Title "S7 e2e" -Results $results
|
||||
if ($results | Where-Object { -not $_.Passed }) { exit 1 }
|
||||
|
||||
@@ -80,6 +80,11 @@ VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'ab-sim', 'abcip-001', 1);
|
||||
|
||||
-- AB CIP DriverInstance — single ControlLogix device at the ab_server fixture
|
||||
-- gateway. DriverConfig shape mirrors AbCipDriverConfigDto.
|
||||
--
|
||||
-- The second device entry (CompactLogix L2 example, commented out) demonstrates
|
||||
-- the PR abcip-3.1 ConnectionSize override knob. Uncomment + point at a real
|
||||
-- 5069-L2 to verify the narrow-buffer Forward Open path; ab_server itself
|
||||
-- doesn't enforce the narrow cap (see docs/drivers/AbServer-Test-Fixture.md §5).
|
||||
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
|
||||
Name, DriverType, DriverConfig, Enabled)
|
||||
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ab-server-smoke', 'AbCip', N'{
|
||||
@@ -90,6 +95,14 @@ VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ab-server-smoke', 'AbCip', N'{
|
||||
"PlcFamily": "ControlLogix",
|
||||
"DeviceName": "ab-server"
|
||||
}
|
||||
/*
|
||||
, {
|
||||
"HostAddress": "ab://10.0.0.7/1,0",
|
||||
"PlcFamily": "CompactLogix",
|
||||
"DeviceName": "compactlogix-l2-narrow",
|
||||
"ConnectionSize": 504
|
||||
}
|
||||
*/
|
||||
],
|
||||
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000 },
|
||||
"Tags": [
|
||||
|
||||
@@ -31,10 +31,11 @@ DECLARE @LineId nvarchar(64) = 'ablegacy-smoke-line';
|
||||
DECLARE @EqId nvarchar(64) = 'ablegacy-smoke-eq';
|
||||
DECLARE @EqUuid uniqueidentifier = '5A1D2030-5A1D-4203-A5A1-D20305A1D203';
|
||||
DECLARE @TagId nvarchar(64) = 'ablegacy-smoke-tag-n7_5';
|
||||
DECLARE @ArrTagId nvarchar(64) = 'ablegacy-smoke-tag-n7_block';
|
||||
|
||||
BEGIN TRAN;
|
||||
|
||||
DELETE FROM dbo.Tag WHERE TagId IN (@TagId);
|
||||
DELETE FROM dbo.Tag WHERE TagId IN (@TagId, @ArrTagId);
|
||||
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
|
||||
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
|
||||
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
|
||||
@@ -79,15 +80,28 @@ VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'slc-sim', 'ablegacy-001', 1);
|
||||
|
||||
-- AB Legacy DriverInstance — SLC 500 target. Replace the placeholder gateway
|
||||
-- `192.168.1.10` with the real PLC / RSEmulate host before running.
|
||||
--
|
||||
-- PR 9 / #252 demo: the device row carries `"TimeoutMs": 500` + `"Retries": 1`,
|
||||
-- both overriding the driver-wide `TimeoutMs: 2000` / `Retries: 0` defaults.
|
||||
-- For real chassis tune per family (SLC 5/01 ≈ 5000, SLC 5/05 ≈ 2000,
|
||||
-- MicroLogix 1100 ≈ 3000); see docs/Driver.AbLegacy.Cli.md for the cheat sheet.
|
||||
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
|
||||
Name, DriverType, DriverConfig, Enabled)
|
||||
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ablegacy-smoke', 'AbLegacy', N'{
|
||||
"TimeoutMs": 2000,
|
||||
"Retries": 0,
|
||||
"Devices": [
|
||||
{
|
||||
"HostAddress": "ab://127.0.0.1:44818/1,0",
|
||||
"PlcFamily": "Slc500",
|
||||
"DeviceName": "slc-500"
|
||||
"DeviceName": "slc-500",
|
||||
"TimeoutMs": 500,
|
||||
"Retries": 1,
|
||||
"Demote": {
|
||||
"FailureThreshold": 3,
|
||||
"DemoteForMs": 30000,
|
||||
"Enabled": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": "S:0" },
|
||||
@@ -98,7 +112,16 @@ VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ablegacy-smoke', 'AbLegacy', N'{
|
||||
"Address": "N7:5",
|
||||
"DataType": "Int",
|
||||
"Writable": true,
|
||||
"WriteIdempotent": true
|
||||
"WriteIdempotent": true,
|
||||
"AbsoluteDeadband": 5
|
||||
},
|
||||
{
|
||||
"Name": "N7_Block",
|
||||
"DeviceHostAddress": "ab://127.0.0.1:44818/1,0",
|
||||
"Address": "N7:0,10",
|
||||
"DataType": "Int",
|
||||
"Writable": false,
|
||||
"ArrayLength": 10
|
||||
}
|
||||
]
|
||||
}', 1);
|
||||
@@ -108,6 +131,17 @@ INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataTyp
|
||||
VALUES (@Gen, @TagId, @DrvId, @EqId, 'N7_5', 'Int16', 'ReadWrite',
|
||||
N'{"FullName":"N7_5","Address":"N7:5","DataType":"Int"}', 1);
|
||||
|
||||
-- PR 7 — array contiguous-block tag. The TagConfig JSON carries the address suffix
|
||||
-- + ArrayLength override; the driver picks both up at discovery time and emits the
|
||||
-- DriverAttributeInfo with IsArray=true + ArrayDim=10 so the generic node manager
|
||||
-- materialises a 1-D Int16 array variable. The dbo.Tag schema doesn't carry
|
||||
-- IsArray/ArrayDim columns — the array shape is fully driver-side metadata.
|
||||
-- Read-only because the smoke harness only exercises array reads.
|
||||
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
|
||||
AccessLevel, TagConfig, WriteIdempotent)
|
||||
VALUES (@Gen, @ArrTagId, @DrvId, @EqId, 'N7_Block', 'Int16', 'Read',
|
||||
N'{"FullName":"N7_Block","Address":"N7:0,10","DataType":"Int","ArrayLength":10}', 0);
|
||||
|
||||
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
|
||||
@Notes = N'AB Legacy smoke — task #213';
|
||||
|
||||
@@ -123,3 +157,18 @@ PRINT 'NOTE: default points at the ab_server slc500 Docker fixture with a /1,0';
|
||||
PRINT ' cip-path (required by ab_server). For real SLC/MicroLogix/PLC-5';
|
||||
PRINT ' hardware, edit the DriverConfig HostAddress to end with /<empty>';
|
||||
PRINT ' e.g. "ab://<plc-ip>:44818/" and re-run this seed.';
|
||||
PRINT '';
|
||||
PRINT 'PR ablegacy-10 / #253 — diagnostic counters auto-emit per device under';
|
||||
PRINT ' AbLegacy/<host>/_Diagnostics/<name>. No dbo.Tag rows needed — the';
|
||||
PRINT ' driver registers them at DiscoverAsync time. Nine counters per device:';
|
||||
PRINT ' RequestCount, ResponseCount, ErrorCount, RetryCount, LastErrorCode,';
|
||||
PRINT ' LastErrorMessage, CommFailures, DemoteCount, LastDemotedUtc. See';
|
||||
PRINT ' docs/drivers/AbLegacy-Diagnostics.md for the full surface + reset';
|
||||
PRINT ' semantics.';
|
||||
PRINT '';
|
||||
PRINT 'PR ablegacy-12 / #255 — auto-demote on comm failure: 3 consecutive';
|
||||
PRINT ' failed reads / probes mark the device Demoted for DemoteFor=PT30S';
|
||||
PRINT ' (30 s); reads against a demoted device short-circuit with';
|
||||
PRINT ' BadCommunicationError so one slow PLC can''t starve the driver.';
|
||||
PRINT ' Tune via the Demote block on each Devices[] row. DemoteCount +';
|
||||
PRINT ' LastDemotedUtc on the _Diagnostics folder surface flapping links.';
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Optional control-plane capability — drivers whose backend exposes a way to refresh
|
||||
/// the symbol table on-demand (without tearing the driver down) implement this so the
|
||||
/// Admin UI / CLI can trigger a re-walk in response to an operator action.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Distinct from <see cref="IRediscoverable"/>: that interface is the driver telling Core
|
||||
/// a refresh is needed; this one is Core asking the driver to refresh now. For drivers that
|
||||
/// implement both, the typical wiring is "operator clicks Rebrowse → Core calls
|
||||
/// <see cref="RebrowseAsync"/> → driver re-walks → driver fires
|
||||
/// <c>OnRediscoveryNeeded</c> so the address space is rebuilt".
|
||||
///
|
||||
/// For AB CIP this is the "force re-walk of @tags" hook — useful after a controller
|
||||
/// program download added new tags but the static config still drives the address space.
|
||||
/// </remarks>
|
||||
public interface IDriverControl
|
||||
{
|
||||
/// <summary>
|
||||
/// Re-run the driver's discovery pass against live backend state and stream the
|
||||
/// resulting nodes through the supplied builder. Implementations must be safe to call
|
||||
/// concurrently with reads / writes; they typically serialize internally so a second
|
||||
/// concurrent rebrowse waits for the first to complete rather than racing it.
|
||||
/// </summary>
|
||||
Task RebrowseAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -76,8 +76,107 @@ public interface IHistoryProvider
|
||||
=> throw new NotSupportedException(
|
||||
$"{GetType().Name} does not implement ReadEventsAsync. " +
|
||||
"Drivers whose backends have an event historian override this method.");
|
||||
|
||||
/// <summary>
|
||||
/// Filter-aware historical event read — OPC UA HistoryReadEvents service with full
|
||||
/// <c>EventFilter</c> support (SelectClauses + WhereClause). Distinct from the simpler
|
||||
/// <see cref="ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/>
|
||||
/// overload which is sufficient for "give me the standard BaseEventType fields"
|
||||
/// queries; this overload is for clients that send a custom <c>EventFilter</c> on the
|
||||
/// wire (per-select-clause Variant population, where-filter evaluation).
|
||||
/// </summary>
|
||||
/// <param name="fullReference">
|
||||
/// Driver-specific node identifier. May be a notifier object (e.g. the driver-root
|
||||
/// folder) — drivers that support cluster-wide queries treat it as
|
||||
/// "all sources in the namespace".
|
||||
/// </param>
|
||||
/// <param name="request">Filter spec — time range + select clauses + optional where clause.</param>
|
||||
/// <param name="cancellationToken">Request cancellation.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Default implementation throws — drivers opt in by overriding. Existing drivers
|
||||
/// that only handle the parameterless overload stay green; new drivers that need
|
||||
/// filter-aware event history (OPC UA Client passthrough, future event-historian
|
||||
/// backends) override this method.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The OPC UA Client driver implements this by translating <see cref="EventHistoryRequest"/>
|
||||
/// into <c>ReadEventDetails</c> and calling <c>Session.HistoryReadAsync</c> against
|
||||
/// the upstream server.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
Task<HistoricalEventBatch> ReadEventsAsync(
|
||||
string fullReference,
|
||||
EventHistoryRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException(
|
||||
$"{GetType().Name} does not implement filter-aware ReadEventsAsync(EventHistoryRequest). " +
|
||||
"Drivers whose backends carry historical events with EventFilter support override this method.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter spec for the filter-aware <see cref="IHistoryProvider.ReadEventsAsync(string, EventHistoryRequest, CancellationToken)"/>
|
||||
/// overload. Mirrors the OPC UA <c>ReadEventDetails</c> wire shape (StartTime, EndTime,
|
||||
/// NumValuesPerNode, EventFilter) but transport-neutral so non-UA drivers can implement it
|
||||
/// without taking a dependency on the UA SDK type.
|
||||
/// </summary>
|
||||
/// <param name="StartTime">Inclusive lower bound on event time.</param>
|
||||
/// <param name="EndTime">Exclusive upper bound on event time.</param>
|
||||
/// <param name="NumValuesPerNode">Maximum events per node (0 = no driver-side cap, server may still apply one).</param>
|
||||
/// <param name="SelectClauses">
|
||||
/// Per-field projection. Each entry names a BaseEventType-rooted field (or a
|
||||
/// typed-path field via <see cref="SimpleAttributeSpec.TypeDefinitionId"/>) the caller
|
||||
/// wants returned. <c>null</c> means "use the driver's default field set" — typically
|
||||
/// EventId, SourceName, Time, Message, Severity, ReceiveTime.
|
||||
/// </param>
|
||||
/// <param name="WhereClause">
|
||||
/// Optional content-filter restriction (e.g. <c>EventType OfType AlarmConditionType</c>).
|
||||
/// Drivers may ignore the where clause if their backend doesn't support it; that's a
|
||||
/// best-effort projection rather than a hard error.
|
||||
/// </param>
|
||||
public sealed record EventHistoryRequest(
|
||||
DateTime StartTime,
|
||||
DateTime EndTime,
|
||||
uint NumValuesPerNode,
|
||||
IReadOnlyList<SimpleAttributeSpec>? SelectClauses,
|
||||
ContentFilterSpec? WhereClause);
|
||||
|
||||
/// <summary>
|
||||
/// Transport-neutral mirror of OPC UA's <c>SimpleAttributeOperand</c> — picks one field
|
||||
/// from a node by typed browse path. <see cref="TypeDefinitionId"/> is the OPC UA NodeId
|
||||
/// of the type that the path is rooted at (e.g. <c>BaseEventType</c>); <see cref="BrowsePath"/>
|
||||
/// is a sequence of QualifiedName-style segments (<c>"ns:Name"</c> or just <c>"Name"</c>
|
||||
/// when ns=0). An empty <see cref="BrowsePath"/> means "the node itself".
|
||||
/// </summary>
|
||||
/// <param name="TypeDefinitionId">
|
||||
/// Type the path is rooted at. <c>null</c> defaults to the OPC UA <c>BaseEventType</c>
|
||||
/// when the driver has a UA mapping. Format is driver-specific NodeId text (e.g.
|
||||
/// <c>"i=2041"</c> for BaseEventType).
|
||||
/// </param>
|
||||
/// <param name="BrowsePath">Browse-path segments. Empty list = the typed node itself.</param>
|
||||
/// <param name="FieldName">
|
||||
/// Stable key the driver uses when populating <see cref="HistoricalEventRow.Fields"/>. The
|
||||
/// server-side dispatcher uses this to align the returned values with the wire-side
|
||||
/// SelectClause order, even when a driver doesn't honour the BrowsePath verbatim.
|
||||
/// </param>
|
||||
public sealed record SimpleAttributeSpec(
|
||||
string? TypeDefinitionId,
|
||||
IReadOnlyList<string> BrowsePath,
|
||||
string FieldName);
|
||||
|
||||
/// <summary>
|
||||
/// Transport-neutral mirror of OPC UA's <c>ContentFilter</c>. The current shape carries the
|
||||
/// raw filter operands as opaque OPC UA <c>ExtensionObject</c> bytes — drivers that need to
|
||||
/// evaluate the filter (Galaxy historian) parse it themselves; the OPC UA Client driver
|
||||
/// forwards it untouched. A future PR may replace this with a structured AST when more
|
||||
/// than one driver needs to evaluate where-clauses locally.
|
||||
/// </summary>
|
||||
/// <param name="EncodedOperands">
|
||||
/// Optional binary-encoded <c>ContentFilter</c> from the wire. <c>null</c> when no
|
||||
/// where-clause was supplied.
|
||||
/// </param>
|
||||
public sealed record ContentFilterSpec(byte[]? EncodedOperands);
|
||||
|
||||
/// <summary>Result of a HistoryRead call.</summary>
|
||||
/// <param name="Samples">Returned samples in chronological order.</param>
|
||||
/// <param name="ContinuationPoint">Opaque token for the next call when more samples are available; null when complete.</param>
|
||||
@@ -96,9 +195,11 @@ public enum HistoryAggregateType
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One row returned by <see cref="IHistoryProvider.ReadEventsAsync"/> — a historical
|
||||
/// alarm/event record, not the OPC UA live-event stream. Fields match the minimum set the
|
||||
/// Server needs to populate a <c>HistoryEventFieldList</c> for HistoryReadEvents responses.
|
||||
/// One row returned by the fixed-field
|
||||
/// <see cref="IHistoryProvider.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/>
|
||||
/// overload — a historical alarm/event record, not the OPC UA live-event stream. Fields
|
||||
/// match the minimum set the Server needs to populate a <c>HistoryEventFieldList</c>
|
||||
/// for HistoryReadEvents responses.
|
||||
/// </summary>
|
||||
/// <param name="EventId">Stable unique id for the event — driver-specific format.</param>
|
||||
/// <param name="SourceName">Source object that emitted the event. May differ from the <c>sourceName</c> filter the caller passed (fuzzy matches).</param>
|
||||
@@ -114,9 +215,46 @@ public sealed record HistoricalEvent(
|
||||
string? Message,
|
||||
ushort Severity);
|
||||
|
||||
/// <summary>Result of a <see cref="IHistoryProvider.ReadEventsAsync"/> call.</summary>
|
||||
/// <summary>Result of a <see cref="IHistoryProvider.ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/> call.</summary>
|
||||
/// <param name="Events">Events in chronological order by <c>EventTimeUtc</c>.</param>
|
||||
/// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
|
||||
public sealed record HistoricalEventsResult(
|
||||
IReadOnlyList<HistoricalEvent> Events,
|
||||
byte[]? ContinuationPoint);
|
||||
|
||||
/// <summary>
|
||||
/// One row returned by the filter-aware
|
||||
/// <see cref="IHistoryProvider.ReadEventsAsync(string, EventHistoryRequest, CancellationToken)"/>
|
||||
/// overload. Carries an open-ended <see cref="Fields"/> bag keyed by
|
||||
/// <see cref="SimpleAttributeSpec.FieldName"/> (or a stable default name when no
|
||||
/// SelectClauses were supplied) so the server-side dispatcher can re-align fields with
|
||||
/// the client's requested order — without forcing every driver to honour the entire
|
||||
/// OPC UA EventFilter shape verbatim.
|
||||
/// </summary>
|
||||
/// <param name="Fields">
|
||||
/// SelectClause results. Keys match the <c>FieldName</c> on the corresponding
|
||||
/// <see cref="SimpleAttributeSpec"/>; values are the raw .NET payload (string,
|
||||
/// <c>DateTime</c>, severity int, etc.). <c>null</c> values are legitimate (the
|
||||
/// upstream had a missing field).
|
||||
/// </param>
|
||||
/// <param name="OccurrenceTime">
|
||||
/// Wall-clock event time — convenience for ordering / windowing without picking a key
|
||||
/// out of <see cref="Fields"/>. Drivers populate this from the underlying event row.
|
||||
/// </param>
|
||||
public sealed record HistoricalEventRow(
|
||||
IReadOnlyDictionary<string, object?> Fields,
|
||||
DateTimeOffset OccurrenceTime);
|
||||
|
||||
/// <summary>
|
||||
/// Result of the filter-aware
|
||||
/// <see cref="IHistoryProvider.ReadEventsAsync(string, EventHistoryRequest, CancellationToken)"/>
|
||||
/// overload. Mirrors <see cref="HistoricalEventsResult"/> but carries
|
||||
/// <see cref="HistoricalEventRow"/> instead of the fixed-shape
|
||||
/// <see cref="HistoricalEvent"/> — the server-side dispatcher unpacks the keyed fields
|
||||
/// into a <c>HistoryEventFieldList</c> aligned with the client's SelectClauses.
|
||||
/// </summary>
|
||||
/// <param name="Events">Events in chronological order by <see cref="HistoricalEventRow.OccurrenceTime"/>.</param>
|
||||
/// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
|
||||
public sealed record HistoricalEventBatch(
|
||||
IReadOnlyList<HistoricalEventRow> Events,
|
||||
byte[]? ContinuationPoint);
|
||||
|
||||
@@ -38,4 +38,16 @@ public sealed record HostStatusChangedEventArgs(
|
||||
HostState NewState);
|
||||
|
||||
/// <summary>Host lifecycle state. Generalization of Galaxy's Platform/Engine ScanState.</summary>
|
||||
public enum HostState { Unknown, Running, Stopped, Faulted }
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="Demoted"/> (PR ablegacy-12 / #255) is a soft-stopped state used by drivers
|
||||
/// that auto-throttle a host after N consecutive comm failures. Reads are short-circuited
|
||||
/// with <c>BadCommunicationError</c> for a configurable cool-down window so one slow PLC
|
||||
/// doesn't starve faster peers sharing the same driver. Demoted is *not* the same as
|
||||
/// <see cref="Stopped"/> (which means "probe says it's down") nor <see cref="Faulted"/>
|
||||
/// (which means "the driver itself is broken"); it's a deliberate driver-side back-off.
|
||||
/// Consumers that don't recognize <c>Demoted</c> can safely treat it as <c>Stopped</c>
|
||||
/// (see <c>HostStatusPublisher.MapState</c>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public enum HostState { Unknown, Running, Stopped, Faulted, Demoted }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -26,6 +26,32 @@ public abstract class AbCipCommandBase : DriverCommandBase
|
||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||
public int TimeoutMs { get; init; } = 5000;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-3.2 — pin the device's CIP addressing mode for this CLI invocation.
|
||||
/// Auto / Symbolic / Logical. Defaults to <see cref="AddressingMode.Auto"/> (resolves
|
||||
/// to Symbolic until a future PR plumbs auto-detection). Logical against an
|
||||
/// unsupported family (Micro800) silently falls back to Symbolic with a logged
|
||||
/// warning, so passing <c>--addressing-mode Logical</c> across a mixed-family
|
||||
/// fleet is safe.
|
||||
/// </summary>
|
||||
[CommandOption("addressing-mode", Description =
|
||||
"CIP addressing mode: Auto / Symbolic / Logical (default Auto, resolves to " +
|
||||
"Symbolic). Logical uses CIP Symbol Object instance IDs after a one-time @tags " +
|
||||
"walk; unsupported on Micro800 (silent fallback to Symbolic with warning).")]
|
||||
public AddressingMode AddressingMode { get; init; } = AddressingMode.Auto;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.1 — partner gateway URI for HSBY (Hot-Standby) paired chassis. When
|
||||
/// supplied, every CLI command auto-enables HSBY role probing on the device options
|
||||
/// so subcommands like <c>hsby-status</c> + diagnostics surface the active chassis
|
||||
/// without extra flags. Unset for non-redundant deployments.
|
||||
/// </summary>
|
||||
[CommandOption("partner", Description =
|
||||
"Partner gateway URI for ControlLogix HSBY pair (e.g. ab://10.0.0.6/1,0). When " +
|
||||
"set, the driver runs a second role-probe loop and the hsby-status command can " +
|
||||
"surface which chassis is currently Active. Optional.")]
|
||||
public string? Partner { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan Timeout
|
||||
{
|
||||
@@ -43,7 +69,18 @@ public abstract class AbCipCommandBase : DriverCommandBase
|
||||
Devices = [new AbCipDeviceOptions(
|
||||
HostAddress: Gateway,
|
||||
PlcFamily: Family,
|
||||
DeviceName: $"cli-{Family}")],
|
||||
DeviceName: $"cli-{Family}",
|
||||
AddressingMode: AddressingMode,
|
||||
// PR abcip-5.1 — surface --partner through the device options so commands that
|
||||
// use BuildOptions can take advantage of HSBY role probing without subclassing.
|
||||
// Hsby auto-enables only when a partner was actually supplied; pre-5.1 invocations
|
||||
// (no --partner) see exactly the legacy options shape.
|
||||
PartnerHostAddress: Partner,
|
||||
Hsby: string.IsNullOrWhiteSpace(Partner) ? null : new AbCipHsbyOptions
|
||||
{
|
||||
Enabled = true,
|
||||
ProbeInterval = TimeSpan.FromSeconds(2),
|
||||
})],
|
||||
Tags = tags,
|
||||
Timeout = Timeout,
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.1 — print the current HSBY role on each chassis of a paired ControlLogix
|
||||
/// ControlLogix Hot-Standby setup. Requires <c>--partner</c> on the base command +
|
||||
/// reads <c>WallClockTime.SyncStatus</c> on both gateways once before printing.
|
||||
/// </summary>
|
||||
[Command("hsby-status", Description =
|
||||
"Read the WallClockTime.SyncStatus role tag on a ControlLogix HSBY pair and print " +
|
||||
"which chassis is currently Active. Requires --partner.")]
|
||||
public sealed class HsbyStatusCommand : AbCipCommandBase
|
||||
{
|
||||
[CommandOption("role-tag", Description =
|
||||
"Role-tag address. Default WallClockTime.SyncStatus matches v20+ ControlLogix HSBY; " +
|
||||
"use S:34 for legacy SLC500 / PLC-5 status-byte fronts.")]
|
||||
public string RoleTagAddress { get; init; } = "WallClockTime.SyncStatus";
|
||||
|
||||
[CommandOption("samples", Description =
|
||||
"Number of role-probe ticks to wait for before printing (default 3). Larger values " +
|
||||
"give the role-prober loop more chances to sample both chassis through transient " +
|
||||
"transport hiccups.")]
|
||||
public int Samples { get; init; } = 3;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Partner))
|
||||
{
|
||||
await console.Error.WriteLineAsync(
|
||||
"hsby-status requires --partner <ab://gateway/cip-path>. Without a partner the " +
|
||||
"command has no second chassis to compare roles against.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Override the base BuildOptions so we can pin the role-tag address + a tight probe
|
||||
// interval — the default 2 s would mean Samples * 2 s before the print fires, too slow
|
||||
// for an interactive CLI. Tag list stays empty; only the role probe runs.
|
||||
var options = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(
|
||||
HostAddress: Gateway,
|
||||
PlcFamily: Family,
|
||||
DeviceName: $"cli-{Family}",
|
||||
AddressingMode: AddressingMode,
|
||||
PartnerHostAddress: Partner,
|
||||
Hsby: new AbCipHsbyOptions
|
||||
{
|
||||
Enabled = true,
|
||||
RoleTagAddress = RoleTagAddress,
|
||||
ProbeInterval = TimeSpan.FromMilliseconds(500),
|
||||
})],
|
||||
Tags = [],
|
||||
Timeout = Timeout,
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
EnableControllerBrowse = false,
|
||||
EnableAlarmProjection = false,
|
||||
};
|
||||
|
||||
await using var driver = new AbCipDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
// Wait Samples * ProbeInterval so the role probe has had time to sample each
|
||||
// chassis at least <Samples> times. The role probe loop spins inside the driver;
|
||||
// we just sleep + read GetDeviceState's ActiveAddress.
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(500 * Math.Max(1, Samples)), ct);
|
||||
|
||||
// Pull HSBY state out via DriverHealth.Diagnostics. Single-pair config emits
|
||||
// the flat AbCip.HsbyActive / AbCip.HsbyPrimaryRole / AbCip.HsbyPartnerRole keys.
|
||||
var diag = driver.GetHealth().Diagnostics
|
||||
?? new Dictionary<string, double>();
|
||||
var primaryRole = diag.TryGetValue("AbCip.HsbyPrimaryRole", out var pr)
|
||||
? (HsbyRole)(int)pr : HsbyRole.Unknown;
|
||||
var partnerRole = diag.TryGetValue("AbCip.HsbyPartnerRole", out var qr)
|
||||
? (HsbyRole)(int)qr : HsbyRole.Unknown;
|
||||
var activeCode = diag.TryGetValue("AbCip.HsbyActive", out var ac) ? (int)ac : 0;
|
||||
var activeAddress = activeCode switch
|
||||
{
|
||||
1 => Gateway,
|
||||
2 => Partner,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
await console.Output.WriteLineAsync($"Primary: {Gateway}");
|
||||
await console.Output.WriteLineAsync($"Partner: {Partner}");
|
||||
await console.Output.WriteLineAsync($"Role tag: {RoleTagAddress}");
|
||||
await console.Output.WriteLineAsync();
|
||||
await console.Output.WriteLineAsync($"Primary role: {primaryRole}");
|
||||
await console.Output.WriteLineAsync($"Partner role: {partnerRole}");
|
||||
await console.Output.WriteLineAsync($"Active chassis: {activeAddress ?? "<none>"}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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() },
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-3.1 — bounds + magic numbers for the per-device CIP <c>ConnectionSize</c>
|
||||
/// override. Pulled into a single place so config validation, the legacy-firmware warning,
|
||||
/// and the docs stay in sync.
|
||||
/// </summary>
|
||||
public static class AbCipConnectionSize
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum supported CIP Forward Open buffer size, in bytes. Matches the lower bound of
|
||||
/// Kepware's connection-size slider for ControlLogix drivers + the libplctag native
|
||||
/// floor that still leaves headroom for the CIP MR header.
|
||||
/// </summary>
|
||||
public const int Min = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum supported CIP Forward Open buffer size, in bytes. Matches the upper bound of
|
||||
/// Kepware's slider + the Large Forward Open ceiling on FW20+ ControlLogix.
|
||||
/// </summary>
|
||||
public const int Max = 4002;
|
||||
|
||||
/// <summary>
|
||||
/// Soft cap above which legacy ControlLogix firmware (v19 and earlier) rejects the
|
||||
/// Forward Open. CompactLogix L1/L2/L3 narrow-cap parts (5069-L1/L2/L3) and Micro800
|
||||
/// hard-cap below this too. Used as the threshold for the legacy-firmware warning.
|
||||
/// </summary>
|
||||
public const int LegacyFirmwareCap = 511;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,21 @@ public static class AbCipDriverFactoryExtensions
|
||||
$"AB CIP config for '{driverInstanceId}' has a device missing HostAddress"),
|
||||
PlcFamily: ParseEnum<AbCipPlcFamily>(d.PlcFamily, "device", driverInstanceId, "PlcFamily",
|
||||
fallback: AbCipPlcFamily.ControlLogix),
|
||||
DeviceName: d.DeviceName))]
|
||||
DeviceName: d.DeviceName,
|
||||
ConnectionSize: d.ConnectionSize,
|
||||
AddressingMode: ParseEnum<AddressingMode>(d.AddressingMode, "device", driverInstanceId,
|
||||
"AddressingMode", fallback: AddressingMode.Auto),
|
||||
ReadStrategy: ParseEnum<ReadStrategy>(d.ReadStrategy, "device", driverInstanceId,
|
||||
"ReadStrategy", fallback: ReadStrategy.Auto),
|
||||
MultiPacketSparsityThreshold: d.MultiPacketSparsityThreshold ?? 0.25,
|
||||
// PR abcip-5.1 — HSBY paired-IP knobs. Both null / absent = no HSBY.
|
||||
PartnerHostAddress: d.PartnerHostAddress,
|
||||
Hsby: d.Hsby is null ? null : new AbCipHsbyOptions
|
||||
{
|
||||
Enabled = d.Hsby.Enabled ?? false,
|
||||
RoleTagAddress = d.Hsby.RoleTagAddress ?? "WallClockTime.SyncStatus",
|
||||
ProbeInterval = TimeSpan.FromMilliseconds(d.Hsby.ProbeIntervalMs ?? 2_000),
|
||||
}))]
|
||||
: [],
|
||||
Tags = dto.Tags is { Count: > 0 }
|
||||
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
|
||||
@@ -78,7 +92,13 @@ public static class AbCipDriverFactoryExtensions
|
||||
Writable: m.Writable ?? true,
|
||||
WriteIdempotent: m.WriteIdempotent ?? false))]
|
||||
: null,
|
||||
SafetyTag: t.SafetyTag ?? false);
|
||||
SafetyTag: t.SafetyTag ?? false,
|
||||
// PR abcip-4.1 — per-tag scan rate override; null means "use subscription default".
|
||||
ScanRateMs: t.ScanRateMs,
|
||||
// PR abcip-4.2 — per-tag write-deadband + write-on-change. Both default to "off"
|
||||
// when absent so back-compat deployments behave exactly as before.
|
||||
WriteDeadband: t.WriteDeadband,
|
||||
WriteOnChange: t.WriteOnChange ?? false);
|
||||
|
||||
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field,
|
||||
T? fallback = null) where T : struct, Enum
|
||||
@@ -119,6 +139,64 @@ public static class AbCipDriverFactoryExtensions
|
||||
public string? HostAddress { get; init; }
|
||||
public string? PlcFamily { get; init; }
|
||||
public string? DeviceName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-3.1 — optional per-device CIP <c>ConnectionSize</c> override. Validated
|
||||
/// against <c>[500..4002]</c> at <see cref="AbCipDriver.InitializeAsync"/>.
|
||||
/// </summary>
|
||||
public int? ConnectionSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-3.2 — optional per-device addressing-mode override. <c>"Auto"</c>,
|
||||
/// <c>"Symbolic"</c>, or <c>"Logical"</c>. Defaults to <c>Auto</c> (resolves to
|
||||
/// Symbolic until a future PR adds real auto-detection). Family compatibility is
|
||||
/// enforced at <see cref="AbCipDriver.InitializeAsync"/>: Logical against
|
||||
/// Micro800 / SLC500 / PLC5 falls back to Symbolic with a warning.
|
||||
/// </summary>
|
||||
public string? AddressingMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-3.3 — optional per-device read-strategy override. <c>"Auto"</c>,
|
||||
/// <c>"WholeUdt"</c>, or <c>"MultiPacket"</c>. Defaults to <c>Auto</c> (the planner
|
||||
/// picks per-batch using <see cref="MultiPacketSparsityThreshold"/>). Family
|
||||
/// compatibility is enforced at <see cref="AbCipDriver.InitializeAsync"/>: explicit
|
||||
/// <c>MultiPacket</c> against Micro800 (no
|
||||
/// <see cref="PlcFamilies.AbCipPlcFamilyProfile.SupportsRequestPacking"/>) falls
|
||||
/// back to <c>WholeUdt</c> with a warning.
|
||||
/// </summary>
|
||||
public string? ReadStrategy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-3.3 — sparsity-threshold knob applied when <see cref="ReadStrategy"/>
|
||||
/// resolves to <c>Auto</c>. Default <c>0.25</c>; clamped to <c>[0..1]</c>.
|
||||
/// </summary>
|
||||
public double? MultiPacketSparsityThreshold { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.1 — canonical AB CIP gateway URI of the partner chassis in a
|
||||
/// ControlLogix HSBY pair. <c>null</c> = no HSBY partner; the driver behaves
|
||||
/// exactly like every pre-5.1 build. When set together with
|
||||
/// <see cref="Hsby"/> <c>.Enabled = true</c>, the driver runs a second probe loop
|
||||
/// against the partner + reports the active chassis through driver diagnostics.
|
||||
/// </summary>
|
||||
public string? PartnerHostAddress { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.1 — HSBY (Hot-Standby) sub-options. Defaults to
|
||||
/// <c>Enabled = false</c> when omitted; pre-5.1 deployments are unaffected.
|
||||
/// </summary>
|
||||
public AbCipHsbyDto? Hsby { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.1 — JSON-mirror of <see cref="AbCipHsbyOptions"/>. Off by default; enabled
|
||||
/// by setting <c>Enabled = true</c> + the parent device's <c>PartnerHostAddress</c>.
|
||||
/// </summary>
|
||||
internal sealed class AbCipHsbyDto
|
||||
{
|
||||
public bool? Enabled { get; init; }
|
||||
public string? RoleTagAddress { get; init; }
|
||||
public int? ProbeIntervalMs { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AbCipTagDto
|
||||
@@ -131,6 +209,31 @@ public static class AbCipDriverFactoryExtensions
|
||||
public bool? WriteIdempotent { get; init; }
|
||||
public List<AbCipMemberDto>? Members { get; init; }
|
||||
public bool? SafetyTag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-4.1 — optional per-tag publish-rate override (in milliseconds). When
|
||||
/// present, the driver places this tag in its own <see cref="Core.Abstractions.PollGroupEngine"/>
|
||||
/// bucket so it ticks at <c>ScanRateMs</c> regardless of the subscription's default
|
||||
/// publishing interval. <c>null</c> uses the default — back-compat with deployments
|
||||
/// that don't set the knob. Mirrors Kepware's "scan classes" model.
|
||||
/// </summary>
|
||||
public int? ScanRateMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-4.2 — optional numeric write deadband. When set, the driver skips a
|
||||
/// wire write whose absolute difference from the previous successfully-written
|
||||
/// value falls below this threshold. Suppressed writes still return <c>Good</c>.
|
||||
/// <c>null</c> = no numeric suppression (back-compat default).
|
||||
/// </summary>
|
||||
public double? WriteDeadband { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-4.2 — optional write-on-change gate. When <c>true</c>, the driver
|
||||
/// skips a wire write whose value equals the previous successfully-written value.
|
||||
/// Combines with <see cref="WriteDeadband"/> on numeric tags (deadband path takes
|
||||
/// priority for numerics). Default <c>false</c> — every write reaches the wire.
|
||||
/// </summary>
|
||||
public bool? WriteOnChange { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AbCipMemberDto
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -56,6 +87,14 @@ public sealed class AbCipDriverOptions
|
||||
/// 1 second — matches typical SCADA alarm-refresh conventions.
|
||||
/// </summary>
|
||||
public TimeSpan AlarmPollInterval { get; init; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-3.1 — optional sink for non-fatal driver warnings (legacy-firmware
|
||||
/// <c>ConnectionSize</c> mis-match, etc.). Production hosting wires this to Serilog;
|
||||
/// unit tests pin a list-collecting lambda to assert which warnings fired. <c>null</c>
|
||||
/// swallows warnings — convenient for back-compat deployments that don't care.
|
||||
/// </summary>
|
||||
public Action<string>? OnWarning { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -67,10 +106,199 @@ public sealed class AbCipDriverOptions
|
||||
/// <param name="PlcFamily">Which per-family profile to apply. Determines ConnectionSize,
|
||||
/// request-packing support, unconnected-only hint, and other quirks.</param>
|
||||
/// <param name="DeviceName">Optional display label for Admin UI. Falls back to <see cref="HostAddress"/>.</param>
|
||||
/// <param name="ConnectionSize">PR abcip-3.1 — optional override for the family-default
|
||||
/// <see cref="PlcFamilies.AbCipPlcFamilyProfile.DefaultConnectionSize"/>. Threads through to
|
||||
/// libplctag's <c>connection_size</c> attribute on the underlying tag handle so operators can
|
||||
/// dial the CIP Forward Open buffer down for legacy firmware (v19-and-earlier ControlLogix
|
||||
/// caps at 504) or up for high-throughput shops on FW20+. Validated against the Kepware
|
||||
/// supported range [500..4002] at <c>InitializeAsync</c>; out-of-range values fault the
|
||||
/// driver. <c>null</c> uses the family default — back-compat with deployments that haven't
|
||||
/// touched the knob.</param>
|
||||
/// <param name="AddressingMode">PR abcip-3.2 — controls whether the driver addresses tags by
|
||||
/// ASCII symbolic path (the default), by CIP logical-segment instance ID, or asks the driver
|
||||
/// to pick. Logical addressing skips per-poll ASCII parsing on every read and unlocks
|
||||
/// symbol-table-cached scans for 500+-tag projects, but requires a one-time symbol-table
|
||||
/// walk at first read + is unsupported on Micro800 / SLC500 / PLC5 (their CIP firmware does
|
||||
/// not honour Symbol Object instance IDs). When the user picks <see cref="AbCip.AddressingMode.Logical"/>
|
||||
/// against an unsupported family the driver logs a warning + falls back to symbolic so
|
||||
/// misconfiguration does not fault the driver. <see cref="AbCip.AddressingMode.Auto"/> currently
|
||||
/// resolves to symbolic — a future PR will plumb a real auto-detection heuristic; the docs
|
||||
/// in <c>docs/drivers/AbCip-Performance.md</c> §"Addressing mode" call this out.</param>
|
||||
/// <param name="ReadStrategy">PR abcip-3.3 — picks how a multi-member UDT batch is read on this
|
||||
/// device. <see cref="AbCip.ReadStrategy.WholeUdt"/> issues one read per parent UDT and decodes
|
||||
/// each subscribed member from the buffer in-memory (the historical behaviour that ships in
|
||||
/// task #194 — best when a large fraction of a UDT's members are subscribed).
|
||||
/// <see cref="AbCip.ReadStrategy.MultiPacket"/> bundles per-member reads into one CIP
|
||||
/// Multi-Service Packet — best for sparse UDT subscriptions where reading the whole UDT
|
||||
/// buffer just to extract one or two fields wastes wire bandwidth. <see cref="AbCip.ReadStrategy.Auto"/>
|
||||
/// (the default) lets the planner pick per-batch using
|
||||
/// <paramref name="MultiPacketSparsityThreshold"/>: if the subscribed-member fraction is below
|
||||
/// the threshold MultiPacket wins, otherwise WholeUdt wins. Family compatibility — Micro800 /
|
||||
/// SLC500 / PLC5 lack Multi-Service-Packet support per
|
||||
/// <see cref="PlcFamilies.AbCipPlcFamilyProfile.SupportsRequestPacking"/>; user-forced
|
||||
/// <see cref="AbCip.ReadStrategy.MultiPacket"/> against those families logs a warning + falls
|
||||
/// back to <see cref="AbCip.ReadStrategy.WholeUdt"/> at device-init time. The libplctag .NET
|
||||
/// wrapper (1.5.x) does not expose a public knob for explicit Multi-Service-Packet bundling,
|
||||
/// so today's MultiPacket runtime issues one libplctag read per member; the planner's grouping
|
||||
/// is still load-bearing because it gives the runtime the right plan to execute when an
|
||||
/// upstream wrapper release exposes wire-level bundling.</param>
|
||||
/// <param name="MultiPacketSparsityThreshold">PR abcip-3.3 — sparsity-threshold knob the planner
|
||||
/// uses when <paramref name="ReadStrategy"/> is <see cref="AbCip.ReadStrategy.Auto"/>. The
|
||||
/// planner divides <c>subscribedMembers / totalMembers</c> for each parent UDT in a batch;
|
||||
/// a fraction strictly less than the threshold picks
|
||||
/// <see cref="AbCip.ReadStrategy.MultiPacket"/>, else <see cref="AbCip.ReadStrategy.WholeUdt"/>.
|
||||
/// Default <c>0.25</c> — picked because reading 1/4 of a UDT's members is the rough break-even
|
||||
/// where the wire-cost of one whole-UDT read still beats N member reads on ControlLogix's
|
||||
/// 4002-byte connection size; see <c>docs/drivers/AbCip-Performance.md</c> §"Read strategy".
|
||||
/// Clamped to <c>[0..1]</c> at planner time; values outside the range silently saturate.</param>
|
||||
/// <param name="PartnerHostAddress">PR abcip-5.1 — optional canonical AB CIP gateway URI of the
|
||||
/// partner chassis in a ControlLogix HSBY (Hot-Standby) pair. When set together with
|
||||
/// <paramref name="Hsby"/><c>.Enabled = true</c>, the driver runs a second probe loop against
|
||||
/// this partner address + uses the configured role tag (default
|
||||
/// <c>WallClockTime.SyncStatus</c>, fall-back <c>S:34</c> for PLC-5 / SLC-style fronts) to
|
||||
/// determine which chassis is currently Active. PR abcip-5.1 only **discovers + reports**
|
||||
/// the active chassis through driver diagnostics; PR abcip-5.2 is the follow-up that wires
|
||||
/// the resolved active address into <see cref="AbCipDriver.ResolveHost"/> for live read /
|
||||
/// write routing. <c>null</c> = no HSBY partner; the driver behaves exactly like every
|
||||
/// pre-5.1 build.</param>
|
||||
/// <param name="Hsby">PR abcip-5.1 — HSBY (Hot-Standby) sub-options. Defaults to
|
||||
/// <c>Enabled = false</c> so back-compat deployments that don't set
|
||||
/// <see cref="PartnerHostAddress"/> see no behaviour change. <see cref="AbCipHsbyOptions.Enabled"/>
|
||||
/// gates the second probe loop + role-tag read; <see cref="AbCipHsbyOptions.RoleTagAddress"/>
|
||||
/// picks <c>WallClockTime.SyncStatus</c> (v20+ ControlLogix) vs <c>S:34</c> (legacy
|
||||
/// SLC500 / PLC-5 status byte fallback); <see cref="AbCipHsbyOptions.ProbeInterval"/>
|
||||
/// controls the role-tag poll cadence.</param>
|
||||
public sealed record AbCipDeviceOptions(
|
||||
string HostAddress,
|
||||
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
|
||||
string? DeviceName = null);
|
||||
string? DeviceName = null,
|
||||
int? ConnectionSize = null,
|
||||
AddressingMode AddressingMode = AddressingMode.Auto,
|
||||
ReadStrategy ReadStrategy = ReadStrategy.Auto,
|
||||
double MultiPacketSparsityThreshold = 0.25,
|
||||
string? PartnerHostAddress = null,
|
||||
AbCipHsbyOptions? Hsby = null);
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.1 — HSBY (Hot-Standby) per-device options. Off by default. When
|
||||
/// <see cref="Enabled"/> = <c>true</c> + the device sets
|
||||
/// <see cref="AbCipDeviceOptions.PartnerHostAddress"/>, the driver runs two probe loops
|
||||
/// concurrently — primary <see cref="AbCipDeviceOptions.HostAddress"/> + the partner —
|
||||
/// reads the configured role tag on each, and reports which chassis is Active through
|
||||
/// driver diagnostics (<c>AbCip.HsbyActive</c>, <c>AbCip.HsbyPrimaryRole</c>,
|
||||
/// <c>AbCip.HsbyPartnerRole</c>). PR abcip-5.2 is the follow-up that wires the resolved
|
||||
/// active address back into <see cref="AbCipDriver.ResolveHost"/> for live read / write
|
||||
/// routing — 5.1 just gathers the role.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Role-tag detection matrix:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>v20 / v24 / v32+ ControlLogix HSBY</b> — <c>WallClockTime.SyncStatus</c>
|
||||
/// (DINT). Values: <c>0</c> = Standby (Synchronized but not Active),
|
||||
/// <c>1</c> = Synchronized / Active (active chassis), <c>2</c> = Disqualified.</item>
|
||||
/// <item><b>PLC-5 / SLC500 fallback</b> — <c>S:34</c> Module Status word (PLC-5 has a
|
||||
/// role bit in word 34 of the status file). Bit 0 = "this chassis is Active". This
|
||||
/// is the legacy fallback for sites that haven't migrated to ControlLogix HSBY.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed record AbCipHsbyOptions
|
||||
{
|
||||
/// <summary>Master switch. Default <c>false</c> — no role probing, no second probe loop.</summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Address of the role tag the driver reads on each probe tick. Default
|
||||
/// <c>WallClockTime.SyncStatus</c> matches v20+ ControlLogix HSBY firmware. Legacy
|
||||
/// PLC-5 / SLC500 fronts that expose a status-file role bit pass <c>S:34</c> here +
|
||||
/// the role prober applies the bit-mask interpretation automatically.
|
||||
/// </summary>
|
||||
public string RoleTagAddress { get; init; } = "WallClockTime.SyncStatus";
|
||||
|
||||
/// <summary>
|
||||
/// Cadence the HSBY role probe ticks at. Default 2 seconds — tight enough to detect
|
||||
/// a manual switch-over within one Admin-UI refresh, loose enough to leave headroom
|
||||
/// for the regular probe loop on the same gateway.
|
||||
/// </summary>
|
||||
public TimeSpan ProbeInterval { get; init; } = TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-3.3 — per-device strategy for reading multi-member UDT batches. <see cref="WholeUdt"/>
|
||||
/// mirrors the task #194 behaviour: one libplctag read on the parent tag, each subscribed member
|
||||
/// decoded from the buffer at its computed offset. <see cref="MultiPacket"/> bundles per-member
|
||||
/// reads into one CIP Multi-Service Packet so sparse UDT subscriptions don't pay for the whole
|
||||
/// UDT buffer. <see cref="Auto"/> lets the planner pick per-batch using
|
||||
/// <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Strategy resolution lives at two layers:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Device init</b> — user-forced <see cref="MultiPacket"/> against a family whose
|
||||
/// profile sets <see cref="PlcFamilies.AbCipPlcFamilyProfile.SupportsRequestPacking"/>
|
||||
/// = <c>false</c> (Micro800, SLC500, PLC5) falls back to <see cref="WholeUdt"/> with a
|
||||
/// warning. <see cref="Auto"/> stays as-is (the planner re-evaluates per batch).</item>
|
||||
/// <item><b>Per-batch (Auto only)</b> — for each parent UDT in the request set, the planner
|
||||
/// computes <c>subscribedMembers / totalMembers</c> and routes the group through
|
||||
/// <see cref="MultiPacket"/> when the fraction is below the threshold, else
|
||||
/// <see cref="WholeUdt"/>.</item>
|
||||
/// </list>
|
||||
/// <para>libplctag .NET wrapper (1.5.x) does not expose explicit Multi-Service-Packet bundling,
|
||||
/// so today's runtime issues one libplctag read per member when the planner picks MultiPacket —
|
||||
/// the same wrapper limitation called out in PR abcip-3.1 (ConnectionSize) and PR abcip-3.2
|
||||
/// (instance-ID addressing). The planner's grouping is still observable from tests + future-proofs
|
||||
/// the driver for when an upstream wrapper release exposes wire-level bundling.</para>
|
||||
/// </remarks>
|
||||
public enum ReadStrategy
|
||||
{
|
||||
/// <summary>Driver picks per-batch based on
|
||||
/// <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/>. Default.</summary>
|
||||
Auto = 0,
|
||||
|
||||
/// <summary>One read per parent UDT; members decoded from the buffer in-memory. Best when a
|
||||
/// large fraction of the UDT's members are subscribed (dense reads).</summary>
|
||||
WholeUdt = 1,
|
||||
|
||||
/// <summary>Bundle per-member reads into one CIP Multi-Service Packet. Best when only a few
|
||||
/// members of a large UDT are subscribed (sparse reads). Unsupported on Micro800 / SLC500 /
|
||||
/// PLC5; the driver warns + falls back to <see cref="WholeUdt"/> at device init.</summary>
|
||||
MultiPacket = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-3.2 — how the AB CIP driver addresses tags on a given device. <see cref="Symbolic"/>
|
||||
/// is the historical default + matches every previous driver build: each read carries the tag
|
||||
/// name as ASCII bytes + the controller parses the path on every request. <see cref="Logical"/>
|
||||
/// uses CIP logical-segment instance IDs (Symbol Object class 0x6B) — the controller looks the
|
||||
/// tag up in its own symbol table once + the driver caches the resolved instance ID for
|
||||
/// subsequent reads, eliminating the per-poll ASCII parse step. <see cref="Auto"/> lets the
|
||||
/// driver pick (today: always Symbolic; a future PR fingerprints the controller and switches
|
||||
/// to Logical when supported).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Logical addressing requires a one-time symbol-table walk at the first read on the device
|
||||
/// (the driver issues an <c>@tags</c> read via <see cref="LibplctagTagEnumerator"/> and stores
|
||||
/// the name → instance-id map on the per-device <c>DeviceState</c>). It is unsupported on
|
||||
/// Micro800 / SLC500 / PLC5 — see <see cref="PlcFamilies.AbCipPlcFamilyProfile.SupportsLogicalAddressing"/>.
|
||||
/// The libplctag .NET wrapper (1.5.x) does not expose a public knob for instance-ID
|
||||
/// addressing, so the driver translates Logical → libplctag attribute via reflection on
|
||||
/// <c>NativeTagWrapper.SetAttributeString</c> — same best-effort fallback pattern as
|
||||
/// PR abcip-3.1's ConnectionSize plumbing.
|
||||
/// </remarks>
|
||||
public enum AddressingMode
|
||||
{
|
||||
/// <summary>Driver picks. Currently resolves to <see cref="Symbolic"/>; future PR may
|
||||
/// auto-detect based on family + firmware + symbol-table size.</summary>
|
||||
Auto = 0,
|
||||
|
||||
/// <summary>ASCII symbolic-path addressing — the libplctag default. Per-poll ASCII parse on
|
||||
/// the controller; works on every CIP family.</summary>
|
||||
Symbolic = 1,
|
||||
|
||||
/// <summary>CIP logical-segment / instance-ID addressing. Requires a one-time
|
||||
/// symbol-table walk at first read; subsequent reads skip ASCII parsing on the
|
||||
/// controller. Unsupported on Micro800 / SLC500 / PLC5.</summary>
|
||||
Logical = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One AB-backed OPC UA variable. Mirrors the <c>ModbusTagDefinition</c> shape.
|
||||
@@ -92,6 +320,41 @@ 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>
|
||||
/// <param name="ScanRateMs">PR abcip-4.1 — optional per-tag publish rate (in milliseconds) that
|
||||
/// overrides the subscription's default <c>publishingInterval</c> for this tag. Mirrors
|
||||
/// Kepware's "scan classes" + Siemens / Mitsubishi per-tag scan groups; the driver buckets
|
||||
/// tags by resolved interval at <see cref="AbCipDriver.SubscribeAsync"/> time + runs one
|
||||
/// <see cref="Core.Abstractions.PollGroupEngine"/> loop per distinct interval so a fast HMI
|
||||
/// tag is not delayed behind a slow batch tag's 10 s tick. <c>null</c> = use the subscription
|
||||
/// default (legacy behaviour). The 100 ms floor enforced by the engine still applies — a
|
||||
/// <c>ScanRateMs < 100</c> is clamped up. UDT member tags inherit the parent tag's
|
||||
/// <c>ScanRateMs</c> at member-fan-out time. See
|
||||
/// <c>docs/drivers/AbCip-Operability.md</c> §"Per-tag scan rate".</param>
|
||||
/// <param name="WriteDeadband">PR abcip-4.2 — optional numeric write deadband. When set and both
|
||||
/// the previous successfully-written value and the new write are numeric, the driver suppresses
|
||||
/// the next write if <c>|new - last| < WriteDeadband</c>. Suppressed writes still return
|
||||
/// <c>Good</c> so the OPC UA write semantics observed by clients are unchanged — the driver
|
||||
/// simply skips the wire round-trip. Mirrors Kepware's "Deadband (write)" knob and is the
|
||||
/// write-side companion to the read-side deadband already shipped at the OPC UA monitored-item
|
||||
/// layer. NaN / Infinity values bypass suppression (let the wire decide). See
|
||||
/// <c>docs/drivers/AbCip-Operability.md</c> §"Write deadband / write-on-change".</param>
|
||||
/// <param name="WriteOnChange">PR abcip-4.2 — optional write-on-change gate. When <c>true</c> and
|
||||
/// the new write equals the previous successfully-written value, the driver suppresses the
|
||||
/// write (returns <c>Good</c> without hitting the wire). Combines with <see cref="WriteDeadband"/>
|
||||
/// for numeric tags — the deadband test takes priority for numerics, equality is the fallback
|
||||
/// for non-numeric types (BOOL setpoints, STRING constants, etc.). Default <c>false</c> —
|
||||
/// legacy behaviour where every write goes to the wire.</param>
|
||||
public sealed record AbCipTagDefinition(
|
||||
string Name,
|
||||
string DeviceHostAddress,
|
||||
@@ -100,7 +363,12 @@ 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,
|
||||
int? ScanRateMs = null,
|
||||
double? WriteDeadband = null,
|
||||
bool WriteOnChange = false);
|
||||
|
||||
/// <summary>
|
||||
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>,
|
||||
@@ -108,11 +376,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
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.1 — resolved HSBY role for one chassis in a ControlLogix Hot-Standby pair.
|
||||
/// <see cref="Unknown"/> covers "couldn't read the role tag" (transport failure, tag not
|
||||
/// found, decode failure); the driver treats it as "no information yet, don't change
|
||||
/// ActiveAddress" rather than as a vote for Standby.
|
||||
/// </summary>
|
||||
public enum HsbyRole
|
||||
{
|
||||
/// <summary>Read failed or value was not decodable. Surface as "no information".</summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>Chassis is the active member of the HSBY pair (Synchronized + serving I/O).</summary>
|
||||
Active = 1,
|
||||
|
||||
/// <summary>Chassis is the standby member — Synchronized but not driving I/O.</summary>
|
||||
Standby = 2,
|
||||
|
||||
/// <summary>Chassis has been disqualified by the HSBY module (e.g. firmware mismatch).</summary>
|
||||
Disqualified = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.1 — reads a ControlLogix HSBY role tag from one chassis and maps the value
|
||||
/// to <see cref="HsbyRole"/>. Two address formats are supported:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>v20 / v24 / v32+ ControlLogix HSBY</b> — <c>WallClockTime.SyncStatus</c>
|
||||
/// (DINT-typed). Values: <c>0 = Standby</c>, <c>1 = Synchronized / Active</c>,
|
||||
/// <c>2 = Disqualified</c>. Other values map to <see cref="HsbyRole.Unknown"/>.</item>
|
||||
/// <item><b>PLC-5 / SLC500 fallback</b> — <c>S:34</c> Module Status word. Bit 0 of the
|
||||
/// integer value indicates "this chassis is Active"; the prober applies the
|
||||
/// bit-mask interpretation when the address starts with <c>"S:"</c> + maps
|
||||
/// <c>(value & 1) == 1 → Active</c>, otherwise → Standby.</item>
|
||||
/// </list>
|
||||
/// Read failure (initialise / read throw, non-zero libplctag status, undecodable buffer)
|
||||
/// returns <see cref="HsbyRole.Unknown"/> — callers (the driver's HSBY probe loop)
|
||||
/// interpret Unknown as "leave ActiveAddress alone for this tick".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The prober is stateless / static — the per-chassis runtime is provided by
|
||||
/// <see cref="AbCipDriver.ProbeLoopAsync"/> + drives initialise / read on the runtime
|
||||
/// before delegating to <see cref="ProbeAsync"/>. Keeping the value-mapping logic isolated
|
||||
/// here lets unit tests assert the matrix (0 / 1 / 2 / S:34 bit 0 / unknown values) without
|
||||
/// standing up a probe loop.
|
||||
/// </remarks>
|
||||
public static class AbCipHsbyRoleProber
|
||||
{
|
||||
/// <summary>
|
||||
/// Read <paramref name="roleTagAddress"/> on <paramref name="runtime"/> + map the
|
||||
/// decoded value to a <see cref="HsbyRole"/>. The runtime is already initialised by
|
||||
/// the caller (<see cref="AbCipDriver.ProbeLoopAsync"/> shares the same lazy-init
|
||||
/// pattern with the regular probe loop); this method only issues the read + decodes.
|
||||
/// </summary>
|
||||
public static async Task<HsbyRole> ProbeAsync(
|
||||
IAbCipTagRuntime runtime, string roleTagAddress, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(roleTagAddress);
|
||||
try
|
||||
{
|
||||
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (runtime.GetStatus() != 0) return HsbyRole.Unknown;
|
||||
var raw = runtime.DecodeValue(AbCipDataType.DInt, bitIndex: null);
|
||||
return MapValueToRole(raw, roleTagAddress);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Wire / init / decode failure — surface as Unknown so the caller doesn't
|
||||
// misinterpret a transient transport hiccup as "this chassis went Standby".
|
||||
return HsbyRole.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure value-to-role mapper. Exposed for unit tests so the matrix assertions can run
|
||||
/// without a runtime in scope. <see cref="ProbeAsync"/> is the production entry point.
|
||||
/// </summary>
|
||||
public static HsbyRole MapValueToRole(object? raw, string roleTagAddress)
|
||||
{
|
||||
if (raw is null) return HsbyRole.Unknown;
|
||||
if (!TryToInt64(raw, out var value)) return HsbyRole.Unknown;
|
||||
|
||||
// PLC-5 / SLC500 status-file fallback — bit 0 of S:34 is the role bit. Pattern-match
|
||||
// on the "S:" prefix because operators do put the file number after it (S:34, S:2,
|
||||
// etc) + the role bit lives in S:34 specifically on PLC-5 fronts but the bit-mask
|
||||
// semantics apply to any S:NN address an integration plumbs in.
|
||||
if (roleTagAddress.StartsWith("S:", StringComparison.OrdinalIgnoreCase))
|
||||
return (value & 1) == 1 ? HsbyRole.Active : HsbyRole.Standby;
|
||||
|
||||
// Default — WallClockTime.SyncStatus matrix (v20 / v24 / v32+ ControlLogix HSBY).
|
||||
return value switch
|
||||
{
|
||||
0 => HsbyRole.Standby,
|
||||
1 => HsbyRole.Active,
|
||||
2 => HsbyRole.Disqualified,
|
||||
_ => HsbyRole.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryToInt64(object raw, out long value)
|
||||
{
|
||||
switch (raw)
|
||||
{
|
||||
case long l: value = l; return true;
|
||||
case int i: value = i; return true;
|
||||
case short s: value = s; return true;
|
||||
case sbyte sb: value = sb; return true;
|
||||
case byte b: value = b; return true;
|
||||
case ushort us: value = us; return true;
|
||||
case uint ui: value = ui; return true;
|
||||
case ulong ul when ul <= long.MaxValue: value = (long)ul; return true;
|
||||
case bool boolean: value = boolean ? 1 : 0; return true;
|
||||
case string str when long.TryParse(str, System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var parsed):
|
||||
value = parsed; return true;
|
||||
default: value = 0; return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-3.3 — sparse-UDT read planner. Where <see cref="AbCipUdtReadPlanner"/> reads each
|
||||
/// parent UDT once and decodes every subscribed member from the buffer in-memory, this planner
|
||||
/// keeps the per-member read shape and bundles the reads into one CIP Multi-Service Packet
|
||||
/// per parent so a 5-of-50-member subscription doesn't pay for the whole UDT buffer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Pure function — like its sibling planner, this one never touches the runtime + never
|
||||
/// reads the PLC. It produces the plan; <see cref="AbCipDriver"/> executes it.</para>
|
||||
///
|
||||
/// <para>The planner is intentionally <c>libplctag</c>-agnostic: the output is just a list of
|
||||
/// <see cref="AbCipMultiPacketReadBatch"/> records that name the parent UDT, the per-member
|
||||
/// read targets, and their byte offsets. The runtime layer decides whether to issue one
|
||||
/// libplctag read per member (today's wrapper-limited fallback) or to flush the batch onto
|
||||
/// one Multi-Service Packet (a future wrapper release). Either way the planner-tier logic
|
||||
/// stays correct, which is why the unit tests in
|
||||
/// <c>AbCipMultiPacketReadPlannerTests</c> assert plan shape rather than wire bytes.</para>
|
||||
///
|
||||
/// <para>Auto-mode dispatch (the heuristic): callers run <see cref="ChooseStrategyForGroup"/>
|
||||
/// for each parent UDT to pick between the WholeUdt and MultiPacket paths per-group. The
|
||||
/// heuristic divides <c>subscribedMembers / totalMembers</c> and picks MultiPacket when the
|
||||
/// fraction is strictly less than the device's
|
||||
/// <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/>.</para>
|
||||
/// </remarks>
|
||||
public static class AbCipMultiPacketReadPlanner
|
||||
{
|
||||
/// <summary>
|
||||
/// Build a multi-packet read plan from <paramref name="requests"/>. Members of the same
|
||||
/// parent UDT collapse into one <see cref="AbCipMultiPacketReadBatch"/>; references that
|
||||
/// don't resolve to a UDT member fall back to <see cref="AbCipUdtReadFallback"/> for the
|
||||
/// existing per-tag read path.
|
||||
/// </summary>
|
||||
public static AbCipMultiPacketReadPlan Build(
|
||||
IReadOnlyList<string> requests,
|
||||
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(requests);
|
||||
ArgumentNullException.ThrowIfNull(tagsByName);
|
||||
|
||||
var fallback = new List<AbCipUdtReadFallback>(requests.Count);
|
||||
var byParent = new Dictionary<string, List<AbCipUdtReadMember>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (var i = 0; i < requests.Count; i++)
|
||||
{
|
||||
var name = requests[i];
|
||||
if (!tagsByName.TryGetValue(name, out var def))
|
||||
{
|
||||
fallback.Add(new AbCipUdtReadFallback(i, name));
|
||||
continue;
|
||||
}
|
||||
|
||||
var (parentName, memberName) = SplitParentMember(name);
|
||||
if (parentName is null || memberName is null
|
||||
|| !tagsByName.TryGetValue(parentName, out var parent)
|
||||
|| parent.DataType != AbCipDataType.Structure
|
||||
|| parent.Members is not { Count: > 0 })
|
||||
{
|
||||
fallback.Add(new AbCipUdtReadFallback(i, name));
|
||||
continue;
|
||||
}
|
||||
|
||||
var offsets = AbCipUdtMemberLayout.TryBuild(parent.Members);
|
||||
if (offsets is null || !offsets.TryGetValue(memberName, out var offset))
|
||||
{
|
||||
fallback.Add(new AbCipUdtReadFallback(i, name));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!byParent.TryGetValue(parentName, out var members))
|
||||
{
|
||||
members = new List<AbCipUdtReadMember>();
|
||||
byParent[parentName] = members;
|
||||
}
|
||||
members.Add(new AbCipUdtReadMember(i, def, offset));
|
||||
}
|
||||
|
||||
var batches = new List<AbCipMultiPacketReadBatch>(byParent.Count);
|
||||
foreach (var (parentName, members) in byParent)
|
||||
{
|
||||
batches.Add(new AbCipMultiPacketReadBatch(parentName, tagsByName[parentName], members));
|
||||
}
|
||||
|
||||
return new AbCipMultiPacketReadPlan(batches, fallback);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-3.3 — Auto-mode heuristic. For a single parent UDT group with
|
||||
/// <paramref name="subscribedMembers"/> of <paramref name="totalMembers"/> declared
|
||||
/// members, pick <see cref="ReadStrategy.MultiPacket"/> when sparsity is strictly below
|
||||
/// <paramref name="threshold"/>, else <see cref="ReadStrategy.WholeUdt"/>. Threshold is
|
||||
/// clamped to <c>[0..1]</c>; out-of-range values saturate. Edge cases:
|
||||
/// <c>totalMembers == 0</c> defaults to <see cref="ReadStrategy.WholeUdt"/> (the
|
||||
/// historical behaviour) so a misconfigured tag map doesn't fault the read.
|
||||
/// </summary>
|
||||
public static ReadStrategy ChooseStrategyForGroup(int subscribedMembers, int totalMembers, double threshold)
|
||||
{
|
||||
if (totalMembers <= 0) return ReadStrategy.WholeUdt;
|
||||
|
||||
// Saturate the threshold to a sane range. 0.0 → never MultiPacket; 1.0 → always
|
||||
// MultiPacket whenever any member is subscribed (deterministic boundary behaviour).
|
||||
var t = threshold;
|
||||
if (t < 0.0) t = 0.0;
|
||||
if (t > 1.0) t = 1.0;
|
||||
|
||||
var fraction = (double)subscribedMembers / totalMembers;
|
||||
return fraction < t ? ReadStrategy.MultiPacket : ReadStrategy.WholeUdt;
|
||||
}
|
||||
|
||||
private static (string? Parent, string? Member) SplitParentMember(string reference)
|
||||
{
|
||||
var dot = reference.IndexOf('.');
|
||||
if (dot <= 0 || dot == reference.Length - 1) return (null, null);
|
||||
return (reference[..dot], reference[(dot + 1)..]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A planner output: per-parent multi-packet batches + per-tag fallbacks.</summary>
|
||||
public sealed record AbCipMultiPacketReadPlan(
|
||||
IReadOnlyList<AbCipMultiPacketReadBatch> Batches,
|
||||
IReadOnlyList<AbCipUdtReadFallback> Fallbacks);
|
||||
|
||||
/// <summary>
|
||||
/// One UDT parent whose subscribed members are bundled into a Multi-Service Packet read.
|
||||
/// Reuses <see cref="AbCipUdtReadMember"/> from the WholeUdt planner so callers can decode
|
||||
/// the member offsets uniformly across both planners.
|
||||
/// </summary>
|
||||
public sealed record AbCipMultiPacketReadBatch(
|
||||
string ParentName,
|
||||
AbCipTagDefinition ParentDefinition,
|
||||
IReadOnlyList<AbCipUdtReadMember> Members);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-4.3 — diagnostic / system-tag source. Holds the latest health snapshot for
|
||||
/// each device, served back through <see cref="AbCipDriver.ReadAsync"/> when the
|
||||
/// incoming reference points at the synthetic <c>_System/<name></c> address. The
|
||||
/// driver bypasses libplctag for these reads — values come straight from the
|
||||
/// <see cref="IHostConnectivityProbe"/> + <see cref="DriverHealth"/> surfaces.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Design parity with Modbus' <c>ModbusSystemTags</c> — the same six canonical
|
||||
/// names are exposed under each device's <c>_System</c> folder so the Admin UI / SCADA
|
||||
/// clients can pivot from "is the wire up?" to "what's our scan rate / tag count?"
|
||||
/// without leaving the OPC UA address space. PR 4.4 turns <c>_RefreshTagDb</c> into a
|
||||
/// writeable Kepware-style trigger — reads always return <c>false</c>, writes of any
|
||||
/// truthy value dispatch to <see cref="AbCipDriver.RebrowseAsync"/>.</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>_ConnectionStatus</c> — string, mirrors the device's <see cref="HostState"/>.</item>
|
||||
/// <item><c>_ScanRate</c> — double, the configured probe interval in milliseconds
|
||||
/// (operators can compare against <c>_LastScanTimeMs</c> to spot wire stretch).</item>
|
||||
/// <item><c>_TagCount</c> — int, count of discovered tags excluding the
|
||||
/// <c>_System</c> folder itself.</item>
|
||||
/// <item><c>_DeviceError</c> — string, the most recent driver-error message or empty.</item>
|
||||
/// <item><c>_LastScanTimeMs</c> — double, wall-clock ms of the last poll-loop
|
||||
/// iteration on this device.</item>
|
||||
/// <item><c>_RefreshTagDb</c> — boolean, writeable Kepware-style trigger. Reads
|
||||
/// always return <c>false</c>; writing any truthy value (true / non-zero / "true"
|
||||
/// / "1") forces a controller-side re-walk via
|
||||
/// <see cref="AbCipDriver.RebrowseAsync"/>.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class AbCipSystemTagSource
|
||||
{
|
||||
/// <summary>
|
||||
/// PR abcip-4.4 — the writeable Kepware-style refresh-trigger system tag. Reads
|
||||
/// always return <c>false</c>; writes of any truthy value cause the driver to
|
||||
/// re-run discovery against the live controller symbol table.
|
||||
/// </summary>
|
||||
public const string RefreshTagDbName = "_RefreshTagDb";
|
||||
|
||||
/// <summary>Canonical names the system folder exposes — keep in lockstep with discovery.</summary>
|
||||
public static readonly IReadOnlyList<string> SystemTagNames =
|
||||
[
|
||||
"_ConnectionStatus",
|
||||
"_ScanRate",
|
||||
"_TagCount",
|
||||
"_DeviceError",
|
||||
"_LastScanTimeMs",
|
||||
RefreshTagDbName,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Address-space prefix the driver stamps on each system variable's
|
||||
/// <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverAttributeInfo.FullName"/> so
|
||||
/// <see cref="AbCipDriver.ReadAsync"/> can dispatch to <see cref="TryRead"/> instead
|
||||
/// of materialising a libplctag runtime.
|
||||
/// </summary>
|
||||
public const string SystemFolderPrefix = "_System/";
|
||||
|
||||
private readonly Dictionary<string, SystemTagSnapshot> _snapshots =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, long> _refreshTriggers =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private long _totalRefreshTriggers;
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-4.4 — total <c>_RefreshTagDb</c> writes across every device managed by
|
||||
/// the driver. Surfaced through <see cref="AbCipDriver.GetHealth"/> as the
|
||||
/// <c>AbCip.RefreshTriggers</c> diagnostic counter.
|
||||
/// </summary>
|
||||
public long TotalRefreshTriggers => Interlocked.Read(ref _totalRefreshTriggers);
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-4.4 — number of times <c>_RefreshTagDb</c> has been written for this
|
||||
/// specific device. Returns <c>0</c> when the device has never seen a refresh
|
||||
/// write (or isn't known to the source).
|
||||
/// </summary>
|
||||
public long GetRefreshTriggerCount(string deviceHostAddress)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deviceHostAddress);
|
||||
lock (_lock)
|
||||
{
|
||||
return _refreshTriggers.TryGetValue(deviceHostAddress, out var n) ? n : 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-4.4 — bump the per-device + global refresh counters for a successful
|
||||
/// <c>_RefreshTagDb</c> write. Called from <see cref="AbCipDriver.WriteAsync"/>
|
||||
/// after the rebrowse dispatch lands so a failed dispatch doesn't pollute the
|
||||
/// counter.
|
||||
/// </summary>
|
||||
public void RecordRefreshTrigger(string deviceHostAddress)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deviceHostAddress);
|
||||
Interlocked.Increment(ref _totalRefreshTriggers);
|
||||
lock (_lock)
|
||||
{
|
||||
_refreshTriggers[deviceHostAddress] =
|
||||
(_refreshTriggers.TryGetValue(deviceHostAddress, out var n) ? n : 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace the snapshot for one device. Called on every health transition + every
|
||||
/// successful read iteration so the surfaced values track the live driver loop
|
||||
/// without piling up extra timers.
|
||||
/// </summary>
|
||||
public void Update(string deviceHostAddress, SystemTagSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deviceHostAddress);
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
lock (_lock)
|
||||
{
|
||||
_snapshots[deviceHostAddress] = snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Look up the current snapshot for a device. Returns <c>null</c> when no snapshot
|
||||
/// has been recorded yet (the driver is still in <see cref="DriverState.Initializing"/>
|
||||
/// or no probe / read iteration has fired).
|
||||
/// </summary>
|
||||
public SystemTagSnapshot? TryGet(string deviceHostAddress)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _snapshots.TryGetValue(deviceHostAddress, out var s) ? s : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a <c>_System/<name></c> address against the current snapshot for
|
||||
/// <paramref name="deviceHostAddress"/>. <paramref name="addressUnderSystem"/> may be
|
||||
/// either the bare name (<c>_ConnectionStatus</c>) or the prefixed form
|
||||
/// (<c>_System/_ConnectionStatus</c>) — both shapes the driver might pass in.
|
||||
/// Returns <c>true</c> when the name is recognised; <paramref name="value"/> is
|
||||
/// <c>null</c> when no snapshot has been recorded yet so the caller can stamp the
|
||||
/// read with <c>UncertainNoCommunicationLastUsableValue</c> if it cares to.
|
||||
/// </summary>
|
||||
public bool TryRead(string addressUnderSystem, string deviceHostAddress, out object? value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(addressUnderSystem);
|
||||
ArgumentNullException.ThrowIfNull(deviceHostAddress);
|
||||
|
||||
var name = addressUnderSystem.StartsWith(SystemFolderPrefix, StringComparison.Ordinal)
|
||||
? addressUnderSystem[SystemFolderPrefix.Length..]
|
||||
: addressUnderSystem;
|
||||
|
||||
// Recognised name?
|
||||
var matched = false;
|
||||
for (var i = 0; i < SystemTagNames.Count; i++)
|
||||
{
|
||||
if (string.Equals(SystemTagNames[i], name, StringComparison.Ordinal))
|
||||
{
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched)
|
||||
{
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
// PR abcip-4.4 — _RefreshTagDb is a Kepware-style writeable trigger: reads always
|
||||
// return false (the trigger latches back to "idle" the moment the dispatch returns)
|
||||
// so subscribed clients see a stable shape regardless of how many refreshes fired.
|
||||
if (string.Equals(name, RefreshTagDbName, StringComparison.Ordinal))
|
||||
{
|
||||
value = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
var snapshot = TryGet(deviceHostAddress);
|
||||
if (snapshot is null)
|
||||
{
|
||||
// Recognised name but no data yet — surface a sensible default per the type so
|
||||
// clients see a stable shape instead of nulls flickering across the address space.
|
||||
value = name switch
|
||||
{
|
||||
"_ConnectionStatus" => "Unknown",
|
||||
"_DeviceError" => string.Empty,
|
||||
"_TagCount" => 0,
|
||||
_ => 0.0,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
value = name switch
|
||||
{
|
||||
"_ConnectionStatus" => snapshot.ConnectionStatus,
|
||||
"_ScanRate" => snapshot.ScanRateMs,
|
||||
"_TagCount" => snapshot.TagCount,
|
||||
"_DeviceError" => snapshot.DeviceError,
|
||||
"_LastScanTimeMs" => snapshot.LastScanTimeMs,
|
||||
_ => null,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-4.4 — recognise <c>_RefreshTagDb</c> writes. Accepts both bare
|
||||
/// (<c>_RefreshTagDb</c>) + prefixed (<c>_System/_RefreshTagDb</c>) shapes so
|
||||
/// callers can pass whichever form the address came in as.
|
||||
/// </summary>
|
||||
public static bool IsRefreshTagDb(string addressUnderSystem)
|
||||
{
|
||||
if (string.IsNullOrEmpty(addressUnderSystem)) return false;
|
||||
var name = addressUnderSystem.StartsWith(SystemFolderPrefix, StringComparison.Ordinal)
|
||||
? addressUnderSystem[SystemFolderPrefix.Length..]
|
||||
: addressUnderSystem;
|
||||
return string.Equals(name, RefreshTagDbName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-4.4 — Kepware-style truthy coercion for the <c>_RefreshTagDb</c> trigger.
|
||||
/// Mirrors the same wire-format the OPC UA stack delivers: booleans pass through;
|
||||
/// integers / doubles trigger when non-zero; strings parse as <c>"true"</c> /
|
||||
/// <c>"1"</c> (case-insensitive). Anything else (null, empty, an unparseable string)
|
||||
/// is treated as <c>false</c> + the write becomes a no-op.
|
||||
/// </summary>
|
||||
public static bool IsTruthyRefresh(object? value)
|
||||
{
|
||||
if (value is null) return false;
|
||||
return value switch
|
||||
{
|
||||
bool b => b,
|
||||
sbyte s => s != 0,
|
||||
byte b => b != 0,
|
||||
short s => s != 0,
|
||||
ushort u => u != 0,
|
||||
int i => i != 0,
|
||||
uint u => u != 0,
|
||||
long l => l != 0,
|
||||
ulong u => u != 0,
|
||||
float f => f != 0f && !float.IsNaN(f),
|
||||
double d => d != 0.0 && !double.IsNaN(d),
|
||||
decimal m => m != 0m,
|
||||
string s => bool.TryParse(s, out var parsed)
|
||||
? parsed
|
||||
: (int.TryParse(s, out var n) && n != 0),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> when <paramref name="reference"/> targets a node under the synthetic
|
||||
/// <c>_System/</c> folder. The driver's read path uses this to bypass the libplctag
|
||||
/// runtime + dispatch to <see cref="TryRead"/> directly.
|
||||
/// </summary>
|
||||
public static bool IsSystemReference(string reference) =>
|
||||
!string.IsNullOrEmpty(reference)
|
||||
&& reference.StartsWith(SystemFolderPrefix, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-4.3 — immutable snapshot of one device's diagnostic surface. Five fields
|
||||
/// match the five system-tag variables the discovery emits.
|
||||
/// </summary>
|
||||
/// <param name="ConnectionStatus">Stringified <c>HostState</c> (Running / Stopped / Unknown / Faulted).</param>
|
||||
/// <param name="ScanRateMs">Configured probe / poll interval in milliseconds.</param>
|
||||
/// <param name="TagCount">Count of discovered tags on this device, excluding <c>_System</c>.</param>
|
||||
/// <param name="DeviceError">Most recent error message; empty when the device is healthy.</param>
|
||||
/// <param name="LastScanTimeMs">Wall-clock ms the last poll iteration took on this device.</param>
|
||||
public sealed record SystemTagSnapshot(
|
||||
string ConnectionStatus,
|
||||
double ScanRateMs,
|
||||
int TagCount,
|
||||
string DeviceError,
|
||||
double LastScanTimeMs);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-4.2 — per-tag last-successfully-written-value cache supporting
|
||||
/// <see cref="AbCipTagDefinition.WriteDeadband"/> + <see cref="AbCipTagDefinition.WriteOnChange"/>
|
||||
/// suppression in <see cref="AbCipDriver.WriteAsync"/>. Keys are
|
||||
/// <c>(deviceHostAddress, tagAddress)</c>: the same Logix tag served from two devices
|
||||
/// keeps independent caches because the underlying PLC state is independent. Counters
|
||||
/// (<see cref="TotalWritesSuppressed"/>, <see cref="TotalWritesPassedThrough"/>) feed
|
||||
/// <c>AbCip.WritesSuppressed</c> / <c>AbCip.WritesPassedThrough</c> in the driver
|
||||
/// diagnostics surface.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The coalescer is consulted *before* the wire write; only successful writes call
|
||||
/// <see cref="Record"/> so a failed write does not poison the cache (next attempt with the
|
||||
/// same value still hits the wire because no last-value was ever recorded for it).
|
||||
/// <see cref="Reset"/> wipes the per-device entries on reconnect / shutdown — the PLC may
|
||||
/// have been restarted and our cached "we already wrote 42" is no longer valid PLC state.</para>
|
||||
///
|
||||
/// <para>Suppression rules:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>No prior recorded value → not suppressed (first write always passes through).</item>
|
||||
/// <item><see cref="AbCipTagDefinition.WriteDeadband"/> + both values numeric →
|
||||
/// <c>|new - last| < deadband</c> suppresses. NaN / Infinity in either side bypass
|
||||
/// suppression; the wire decides.</item>
|
||||
/// <item><see cref="AbCipTagDefinition.WriteOnChange"/> set →
|
||||
/// <see cref="object.Equals(object?, object?)"/> equality suppresses. For numeric tags
|
||||
/// with a deadband configured, this still applies as the equality fallback when the
|
||||
/// deadband path doesn't trigger (e.g. exact equality with a 0 deadband).</item>
|
||||
/// <item>Neither knob set → never suppress (back-compat default).</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
internal sealed class AbCipWriteCoalescer
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string Device, string Tag), object?> _lastValues =
|
||||
new(LastKeyComparer.Instance);
|
||||
|
||||
private long _totalWritesSuppressed;
|
||||
private long _totalWritesPassedThrough;
|
||||
|
||||
/// <summary>Diagnostics counter — number of writes the coalescer told the driver to skip.</summary>
|
||||
public long TotalWritesSuppressed => Interlocked.Read(ref _totalWritesSuppressed);
|
||||
|
||||
/// <summary>Diagnostics counter — number of writes that hit the wire after consulting the coalescer.</summary>
|
||||
public long TotalWritesPassedThrough => Interlocked.Read(ref _totalWritesPassedThrough);
|
||||
|
||||
/// <summary>
|
||||
/// Decide whether <paramref name="newValue"/> should suppress the wire write for
|
||||
/// <paramref name="tag"/> on <paramref name="deviceHostAddress"/>. Increments the
|
||||
/// internal <see cref="TotalWritesSuppressed"/> / <see cref="TotalWritesPassedThrough"/>
|
||||
/// counter as a side effect so callers don't have to maintain a parallel tally.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <c>true</c> when the write can be skipped (last value recorded + suppression rule
|
||||
/// fired). <c>false</c> when the write must hit the wire (no prior value, no rule
|
||||
/// active, or values differ enough).
|
||||
/// </returns>
|
||||
public bool ShouldSuppress(string deviceHostAddress, AbCipTagDefinition tag, object? newValue)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deviceHostAddress);
|
||||
ArgumentNullException.ThrowIfNull(tag);
|
||||
|
||||
// Fast path — neither knob active. Skip the dictionary lookup entirely; this is the
|
||||
// overwhelming common case in deployments that don't opt in.
|
||||
if (!tag.WriteOnChange && !tag.WriteDeadband.HasValue)
|
||||
{
|
||||
Interlocked.Increment(ref _totalWritesPassedThrough);
|
||||
return false;
|
||||
}
|
||||
|
||||
var key = (deviceHostAddress, tag.TagPath);
|
||||
if (!_lastValues.TryGetValue(key, out var lastValue))
|
||||
{
|
||||
// No prior recorded write — first write must always pass through so the PLC sees a
|
||||
// baseline. The Record call after a successful write seeds the cache from this point.
|
||||
Interlocked.Increment(ref _totalWritesPassedThrough);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TrySuppress(tag, lastValue, newValue))
|
||||
{
|
||||
Interlocked.Increment(ref _totalWritesSuppressed);
|
||||
return true;
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _totalWritesPassedThrough);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record the value just successfully written so the next call to
|
||||
/// <see cref="ShouldSuppress"/> can compare against it. Called only from the
|
||||
/// <see cref="AbCipDriver"/> success branch — failed writes do not seed the cache.
|
||||
/// </summary>
|
||||
public void Record(string deviceHostAddress, AbCipTagDefinition tag, object? writtenValue)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deviceHostAddress);
|
||||
ArgumentNullException.ThrowIfNull(tag);
|
||||
|
||||
// Only care about tags that opted in to either knob — pure-passthrough tags don't need
|
||||
// a cache entry at all and the dictionary stays small for the common case.
|
||||
if (!tag.WriteOnChange && !tag.WriteDeadband.HasValue) return;
|
||||
|
||||
_lastValues[(deviceHostAddress, tag.TagPath)] = writtenValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop every cached last-value for one device. Called on reconnect or driver shutdown
|
||||
/// so the next write after a wire-state change pays the full round-trip — the PLC may
|
||||
/// have been restarted and our cached "we already wrote 42" is stale.
|
||||
/// </summary>
|
||||
public void Reset(string deviceHostAddress)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deviceHostAddress);
|
||||
|
||||
// ConcurrentDictionary doesn't have a "remove where" overload, so iterate keys + remove.
|
||||
// Suppression races are tolerated — losing one suppression decision after a reconnect
|
||||
// costs at most one extra wire write, never correctness.
|
||||
foreach (var key in _lastValues.Keys)
|
||||
{
|
||||
if (string.Equals(key.Device, deviceHostAddress, StringComparison.OrdinalIgnoreCase))
|
||||
_lastValues.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drop every cached last-value across all devices — invoked on full driver shutdown.</summary>
|
||||
public void ResetAll() => _lastValues.Clear();
|
||||
|
||||
private static bool TrySuppress(AbCipTagDefinition tag, object? lastValue, object? newValue)
|
||||
{
|
||||
// Numeric deadband — only fires when both sides convert cleanly to double. NaN / Infinity
|
||||
// bypass: the wire decides because IEEE-754 comparisons against NaN are undefined and
|
||||
// we don't want a stale +Inf in the cache to silently swallow a real reset.
|
||||
if (tag.WriteDeadband.HasValue
|
||||
&& TryToDouble(lastValue, out var lastNum)
|
||||
&& TryToDouble(newValue, out var newNum))
|
||||
{
|
||||
if (double.IsNaN(lastNum) || double.IsNaN(newNum)
|
||||
|| double.IsInfinity(lastNum) || double.IsInfinity(newNum))
|
||||
{
|
||||
// Fall through to the WriteOnChange equality check below — NaN / Infinity skip
|
||||
// the deadband path but a legacy WriteOnChange tag should still benefit from
|
||||
// exact-equality suppression on the same packet.
|
||||
}
|
||||
else if (Math.Abs(newNum - lastNum) < tag.WriteDeadband.Value)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// WriteOnChange — equality fallback. Always evaluated when the flag is set so a
|
||||
// non-numeric tag (BOOL, STRING) still benefits even when WriteDeadband is set on the
|
||||
// same tag (the deadband path simply doesn't apply to it).
|
||||
if (tag.WriteOnChange && Equals(lastValue, newValue))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryToDouble(object? value, out double result)
|
||||
{
|
||||
// IConvertible covers every Logix atomic type the AB CIP driver decodes (sbyte, short,
|
||||
// int, long + their unsigned siblings + float / double). DateTime and string are
|
||||
// excluded — neither has a meaningful "deadband" interpretation.
|
||||
switch (value)
|
||||
{
|
||||
case null:
|
||||
result = 0;
|
||||
return false;
|
||||
case bool:
|
||||
result = 0;
|
||||
return false;
|
||||
case string:
|
||||
result = 0;
|
||||
return false;
|
||||
case DateTime:
|
||||
result = 0;
|
||||
return false;
|
||||
case IConvertible conv:
|
||||
try
|
||||
{
|
||||
result = conv.ToDouble(System.Globalization.CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
result = 0;
|
||||
return false;
|
||||
}
|
||||
default:
|
||||
result = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class LastKeyComparer : IEqualityComparer<(string Device, string Tag)>
|
||||
{
|
||||
public static readonly LastKeyComparer Instance = new();
|
||||
|
||||
public bool Equals((string Device, string Tag) x, (string Device, string Tag) y) =>
|
||||
string.Equals(x.Device, y.Device, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(x.Tag, y.Tag, StringComparison.Ordinal);
|
||||
|
||||
public int GetHashCode((string Device, string Tag) obj) =>
|
||||
HashCode.Combine(
|
||||
StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Device),
|
||||
StringComparer.Ordinal.GetHashCode(obj.Tag));
|
||||
}
|
||||
}
|
||||
@@ -65,10 +65,43 @@ 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>
|
||||
/// <param name="ConnectionSize">PR abcip-3.1 — CIP Forward Open buffer size in bytes. Threads
|
||||
/// through to libplctag's <c>connection_size</c> attribute. The driver always supplies a
|
||||
/// value here — either the per-device <see cref="AbCipDeviceOptions.ConnectionSize"/>
|
||||
/// override or the family profile's <see cref="PlcFamilies.AbCipPlcFamilyProfile.DefaultConnectionSize"/>.
|
||||
/// Bigger packets fit more tags per RTT (higher throughput); smaller packets stay compatible
|
||||
/// with legacy firmware (v19-and-earlier ControlLogix caps at 504, Micro800 hard-caps at
|
||||
/// 488).</param>
|
||||
/// <param name="AddressingMode">PR abcip-3.2 — concrete addressing mode the runtime should
|
||||
/// activate for this tag handle. Always either <see cref="AddressingMode.Symbolic"/> or
|
||||
/// <see cref="AddressingMode.Logical"/> at this layer (the driver resolves <c>Auto</c> +
|
||||
/// family-incompatibility before building the create-params). Symbolic is the libplctag
|
||||
/// default and needs no extra attribute. Logical adds the libplctag <c>use_connected_msg=1</c>
|
||||
/// attribute + (when an instance ID is known via <see cref="LogicalInstanceId"/>) reaches
|
||||
/// into <c>NativeTagWrapper.SetAttributeString</c> by reflection because the .NET wrapper
|
||||
/// does not expose a public knob for instance-ID addressing.</param>
|
||||
/// <param name="LogicalInstanceId">PR abcip-3.2 — Symbol Object instance ID the controller
|
||||
/// assigned to this tag, populated by the driver after a one-time <c>@tags</c> walk for
|
||||
/// Logical-mode devices. <c>null</c> for Symbolic mode + for the very first read on a
|
||||
/// Logical device when the symbol-table walk has not yet completed; the runtime falls back
|
||||
/// to Symbolic addressing in either case so the read still completes.</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,
|
||||
int ConnectionSize = 4002,
|
||||
AddressingMode AddressingMode = AddressingMode.Symbolic,
|
||||
uint? LogicalInstanceId = null);
|
||||
|
||||
@@ -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("\"", "\"\"") + "\"";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Reflection;
|
||||
using libplctag;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
@@ -12,6 +13,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
{
|
||||
private readonly Tag _tag;
|
||||
private readonly int _connectionSize;
|
||||
private readonly AddressingMode _addressingMode;
|
||||
private readonly uint? _logicalInstanceId;
|
||||
|
||||
public LibplctagTagRuntime(AbCipTagCreateParams p)
|
||||
{
|
||||
@@ -24,12 +28,119 @@ 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;
|
||||
_connectionSize = p.ConnectionSize;
|
||||
_addressingMode = p.AddressingMode;
|
||||
_logicalInstanceId = p.LogicalInstanceId;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||
// PR abcip-3.1 — propagate the configured CIP connection size to the native libplctag
|
||||
// handle. The 1.5.x C# wrapper does not expose <c>connection_size</c> as a public Tag
|
||||
// property, so we reach into the internal <c>NativeTagWrapper</c>'s
|
||||
// <c>SetIntAttribute</c> (mirroring libplctag's <c>plc_tag_set_int_attribute</c>).
|
||||
// libplctag native parses <c>connection_size</c> at create time, so this best-effort
|
||||
// call lights up automatically when a future wrapper release exposes the attribute or
|
||||
// when libplctag native gains post-create hot-update support — until then it falls back
|
||||
// to the wrapper default. Failures (older / patched wrappers without the internal API)
|
||||
// are intentionally swallowed so the driver keeps initialising.
|
||||
TrySetConnectionSize(_tag, _connectionSize);
|
||||
|
||||
// PR abcip-3.2 — propagate the addressing mode + (when known) the resolved Symbol
|
||||
// Object instance ID. Same reflection-fallback shape as ConnectionSize: the libplctag
|
||||
// .NET wrapper (1.5.x) doesn't expose a public knob for instance-ID addressing, so
|
||||
// we forward the relevant attribute string through NativeTagWrapper.SetAttributeString.
|
||||
// Logical mode lights up only when the driver has populated LogicalInstanceId via the
|
||||
// one-time @tags walk; first reads on a Logical device + every Symbolic-mode read take
|
||||
// the libplctag default ASCII-symbolic path.
|
||||
if (_addressingMode == AddressingMode.Logical)
|
||||
TrySetLogicalAddressing(_tag, _logicalInstanceId);
|
||||
}
|
||||
|
||||
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
|
||||
public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken);
|
||||
public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort propagation of <c>connection_size</c> to libplctag native. Reflects into
|
||||
/// the wrapper's internal <c>NativeTagWrapper.SetIntAttribute(string, int)</c>; isolated
|
||||
/// in a static helper so the lookup costs run once + the failure path is one line.
|
||||
/// </summary>
|
||||
private static void TrySetConnectionSize(Tag tag, int connectionSize)
|
||||
{
|
||||
try
|
||||
{
|
||||
var wrapperField = typeof(Tag).GetField("_tag", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
var wrapper = wrapperField?.GetValue(tag);
|
||||
if (wrapper is null) return;
|
||||
var setInt = wrapper.GetType().GetMethod(
|
||||
"SetIntAttribute",
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
|
||||
binder: null,
|
||||
types: [typeof(string), typeof(int)],
|
||||
modifiers: null);
|
||||
setInt?.Invoke(wrapper, ["connection_size", connectionSize]);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Wrapper internals shifted (newer libplctag.NET) — drop quietly. Either the new
|
||||
// wrapper exposes ConnectionSize directly (our reflection no-ops) or operators must
|
||||
// upgrade to a known-good version per docs/drivers/AbCip-Performance.md.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-3.2 — best-effort propagation of CIP logical-segment / instance-ID
|
||||
/// addressing to libplctag native. Two attributes are forwarded:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>use_connected_msg=1</c> — instance-ID addressing only works over a
|
||||
/// connected CIP session; switch the tag to use Forward Open + Class3 messaging.</item>
|
||||
/// <item><c>cip_addr=0x6B,N</c> — replace the ASCII Symbol Object lookup with a
|
||||
/// direct logical segment reference, where <c>N</c> is the resolved instance ID
|
||||
/// from the driver's one-time <c>@tags</c> walk.</item>
|
||||
/// </list>
|
||||
/// Same reflection-via-<c>NativeTagWrapper.SetAttributeString</c> shape as
|
||||
/// <see cref="TrySetConnectionSize"/> — the 1.5.x .NET wrapper does not expose a
|
||||
/// public knob, so we degrade gracefully when the internal API is not present.
|
||||
/// </summary>
|
||||
private static void TrySetLogicalAddressing(Tag tag, uint? logicalInstanceId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var wrapperField = typeof(Tag).GetField("_tag", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
var wrapper = wrapperField?.GetValue(tag);
|
||||
if (wrapper is null) return;
|
||||
var setStr = wrapper.GetType().GetMethod(
|
||||
"SetAttributeString",
|
||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance,
|
||||
binder: null,
|
||||
types: [typeof(string), typeof(string)],
|
||||
modifiers: null);
|
||||
if (setStr is null) return;
|
||||
setStr.Invoke(wrapper, ["use_connected_msg", "1"]);
|
||||
if (logicalInstanceId is uint id)
|
||||
setStr.Invoke(wrapper, ["cip_addr", $"0x6B,{id}"]);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Wrapper internals not present / shifted — fall back to symbolic addressing on
|
||||
// the wire. Driver-level logical-mode bookkeeping (the @tags map) is still useful
|
||||
// because future wrapper releases may expose this attribute publicly + the
|
||||
// reflection lights up cleanly then.
|
||||
}
|
||||
}
|
||||
|
||||
public int GetStatus() => (int)_tag.GetStatus();
|
||||
|
||||
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
|
||||
@@ -50,7 +161,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 +216,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.");
|
||||
|
||||
@@ -16,7 +16,8 @@ public sealed record AbCipPlcFamilyProfile(
|
||||
string DefaultCipPath,
|
||||
bool SupportsRequestPacking,
|
||||
bool SupportsConnectedMessaging,
|
||||
int MaxFragmentBytes)
|
||||
int MaxFragmentBytes,
|
||||
bool SupportsLogicalAddressing = true)
|
||||
{
|
||||
/// <summary>Look up the profile for a configured family.</summary>
|
||||
public static AbCipPlcFamilyProfile ForFamily(AbCipPlcFamily family) => family switch
|
||||
@@ -34,7 +35,8 @@ public sealed record AbCipPlcFamilyProfile(
|
||||
DefaultCipPath: "1,0",
|
||||
SupportsRequestPacking: true,
|
||||
SupportsConnectedMessaging: true,
|
||||
MaxFragmentBytes: 4000);
|
||||
MaxFragmentBytes: 4000,
|
||||
SupportsLogicalAddressing: true);
|
||||
|
||||
public static readonly AbCipPlcFamilyProfile CompactLogix = new(
|
||||
LibplctagPlcAttribute: "compactlogix",
|
||||
@@ -42,15 +44,21 @@ public sealed record AbCipPlcFamilyProfile(
|
||||
DefaultCipPath: "1,0",
|
||||
SupportsRequestPacking: true,
|
||||
SupportsConnectedMessaging: true,
|
||||
MaxFragmentBytes: 500);
|
||||
MaxFragmentBytes: 500,
|
||||
SupportsLogicalAddressing: true);
|
||||
|
||||
// PR abcip-3.2 — Micro800 firmware does not implement the Symbol Object class 0x6B
|
||||
// instance-ID addressing path; @tags returns the symbol set but reads keyed on instance
|
||||
// IDs trip a CIP "Path Segment Error" (0x04). Logical mode is therefore disabled here
|
||||
// + the driver silently falls back to Symbolic with a warning per AbCipDriverOptions.OnWarning.
|
||||
public static readonly AbCipPlcFamilyProfile Micro800 = new(
|
||||
LibplctagPlcAttribute: "micro800",
|
||||
DefaultConnectionSize: 488, // Micro800 hard cap
|
||||
DefaultCipPath: "", // no backplane routing
|
||||
SupportsRequestPacking: false,
|
||||
SupportsConnectedMessaging: false, // unconnected-only on most models
|
||||
MaxFragmentBytes: 484);
|
||||
MaxFragmentBytes: 484,
|
||||
SupportsLogicalAddressing: false);
|
||||
|
||||
public static readonly AbCipPlcFamilyProfile GuardLogix = new(
|
||||
LibplctagPlcAttribute: "controllogix", // wire protocol identical; safety partition is tag-level
|
||||
@@ -58,5 +66,6 @@ public sealed record AbCipPlcFamilyProfile(
|
||||
DefaultCipPath: "1,0",
|
||||
SupportsRequestPacking: true,
|
||||
SupportsConnectedMessaging: true,
|
||||
MaxFragmentBytes: 4000);
|
||||
MaxFragmentBytes: 4000,
|
||||
SupportsLogicalAddressing: true);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -25,6 +25,34 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
|
||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||
public int TimeoutMs { get; init; } = 5000;
|
||||
|
||||
/// <summary>
|
||||
/// PR ablegacy-12 / #255 — consecutive comm failures before this device is
|
||||
/// auto-demoted. Reads against a demoted device short-circuit with
|
||||
/// <c>BadCommunicationError</c> for <see cref="DemoteForMs"/> ms so one
|
||||
/// unreachable PLC can't starve faster peers sharing the driver thread.
|
||||
/// </summary>
|
||||
[CommandOption("demote-failure-threshold", Description =
|
||||
"Consecutive comm failures before the device is auto-demoted (PR ablegacy-12). Default 3.")]
|
||||
public int DemoteFailureThreshold { get; init; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// PR ablegacy-12 / #255 — auto-demote cool-down window in ms. Reads while
|
||||
/// this window is active short-circuit with <c>BadCommunicationError</c>;
|
||||
/// a successful probe clears it early.
|
||||
/// </summary>
|
||||
[CommandOption("demote-for-ms", Description =
|
||||
"Auto-demote cool-down window in ms (PR ablegacy-12). Default 30000 (30s).")]
|
||||
public int DemoteForMs { get; init; } = 30_000;
|
||||
|
||||
/// <summary>
|
||||
/// PR ablegacy-12 / #255 — opt out of the auto-demote behaviour. The
|
||||
/// consecutive-failure tally still ticks (so DemoteCount/LastDemotedUtc
|
||||
/// stay zero) but reads never short-circuit.
|
||||
/// </summary>
|
||||
[CommandOption("no-demote", Description =
|
||||
"Disable auto-demote on consecutive comm failures (PR ablegacy-12). Default off (auto-demote enabled).")]
|
||||
public bool NoDemote { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan Timeout
|
||||
{
|
||||
@@ -41,7 +69,11 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
|
||||
Devices = [new AbLegacyDeviceOptions(
|
||||
HostAddress: Gateway,
|
||||
PlcFamily: PlcType,
|
||||
DeviceName: $"cli-{PlcType}")],
|
||||
DeviceName: $"cli-{PlcType}",
|
||||
Demote: new AbLegacyDemoteOptions(
|
||||
FailureThreshold: DemoteFailureThreshold,
|
||||
DemoteFor: TimeSpan.FromMilliseconds(DemoteForMs),
|
||||
Enabled: !NoDemote))],
|
||||
Tags = tags,
|
||||
Timeout = Timeout,
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Import;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// ablegacy-11 / #254 — read an RSLogix 500 / 5 "Database Export" CSV and emit either an
|
||||
/// <c>appsettings.json</c> tag fragment or a summary line. Avoids the AbLegacyCommandBase
|
||||
/// hierarchy because import is a purely-offline operation: no gateway, no driver, no
|
||||
/// timeout. Mirrors the
|
||||
/// <a href="https://github.com/dohertj2/lmxopcua/issues/254">#254 plan section</a>'s CLI
|
||||
/// specification verbatim.
|
||||
/// </summary>
|
||||
[Command("import-rslogix", Description =
|
||||
"Read an RSLogix 500/5 CSV symbol export and emit a JSON tag fragment for appsettings.json. " +
|
||||
"Binary .RSS / .RSP project files are out of scope (see docs/drivers/AbLegacy-RSLogix-Import.md).")]
|
||||
public sealed class ImportRslogixCommand : ICommand
|
||||
{
|
||||
[CommandOption("file", 'f', Description =
|
||||
"Path to the RSLogix CSV export. RFC 4180-ish format with header columns " +
|
||||
"Symbol,Address,Description,DataType,Scope; quoted fields + doubled-quote escapes " +
|
||||
"are honoured; comment lines starting with ; or # are skipped.",
|
||||
IsRequired = true)]
|
||||
public string File { get; init; } = default!;
|
||||
|
||||
[CommandOption("device", 'd', Description =
|
||||
"Canonical AB Legacy gateway URI (ab://host[:port]/cip-path) every imported tag " +
|
||||
"binds to. Required even though import is offline — the resulting tag definitions " +
|
||||
"carry the gateway address as their DeviceHostAddress.",
|
||||
IsRequired = true)]
|
||||
public string Device { get; init; } = default!;
|
||||
|
||||
[CommandOption("emit", Description =
|
||||
"Output shape: 'appsettings-fragment' (default) emits a JSON object with a Tags array " +
|
||||
"ready to paste into appsettings.json; 'summary' emits one human-readable counter line.")]
|
||||
public string Emit { get; init; } = "appsettings-fragment";
|
||||
|
||||
[CommandOption("output", 'o', Description =
|
||||
"Optional output file. When omitted, the result goes to stdout.")]
|
||||
public string? Output { get; init; }
|
||||
|
||||
[CommandOption("scope", Description =
|
||||
"Optional Scope filter — match the row's Scope column against this value " +
|
||||
"case-insensitively. Common values: 'Global', 'Local:1', 'Local:2'. Rows with " +
|
||||
"no Scope column count as Global.")]
|
||||
public string? Scope { get; init; }
|
||||
|
||||
[CommandOption("max-rows", Description =
|
||||
"Defensive cap on the number of rows imported. Beyond the cap the parser stops " +
|
||||
"and emits a warning; useful for dry-running a large export against the CLI.")]
|
||||
public int? MaxRows { get; init; }
|
||||
|
||||
[CommandOption("strict", Description =
|
||||
"When set, the first malformed row throws and the CLI exits non-zero. Default is " +
|
||||
"permissive (skip + log).")]
|
||||
public bool Strict { get; init; }
|
||||
|
||||
public async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
if (!System.IO.File.Exists(File))
|
||||
{
|
||||
// Surface a clean exit-code-1 with a one-line error rather than letting
|
||||
// FileNotFoundException bubble up through CliFx's default exception path —
|
||||
// the CLI tests and operators both prefer `import-rslogix --file missing.csv`
|
||||
// to print "file not found" rather than a stack trace.
|
||||
throw new CommandException($"RSLogix CSV not found: {File}", exitCode: 1);
|
||||
}
|
||||
|
||||
var opts = new ImportOptions(
|
||||
ScopeFilter: Scope,
|
||||
MaxRowsToImport: MaxRows,
|
||||
IgnoreInvalid: !Strict);
|
||||
|
||||
RsLogixImportResult result;
|
||||
using (var stream = System.IO.File.OpenRead(File))
|
||||
{
|
||||
var importer = new RsLogixSymbolImport();
|
||||
result = importer.Parse(stream, Device, opts);
|
||||
}
|
||||
|
||||
var emit = Emit?.Trim().ToLowerInvariant();
|
||||
var payload = emit switch
|
||||
{
|
||||
"summary" => FormatSummary(result),
|
||||
"appsettings-fragment" or null or "" => FormatFragment(result),
|
||||
_ => throw new CommandException(
|
||||
$"Unknown --emit value '{Emit}'. Use 'appsettings-fragment' or 'summary'.",
|
||||
exitCode: 2),
|
||||
};
|
||||
|
||||
if (Output is { Length: > 0 })
|
||||
{
|
||||
await System.IO.File.WriteAllTextAsync(Output, payload);
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Wrote {result.ParsedCount} tag(s) to {Output} (skipped={result.SkippedCount}, errors={result.ErrorCount}).");
|
||||
}
|
||||
else
|
||||
{
|
||||
await console.Output.WriteLineAsync(payload);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialise the imported tag list as a JSON fragment shaped like the
|
||||
/// <c>AbLegacyDriverConfigDto</c>'s <c>Tags</c> array — drop straight into the
|
||||
/// <c>appsettings.json</c> driver config under
|
||||
/// <c>Drivers/<instance>/Config/Tags</c>.
|
||||
/// </summary>
|
||||
internal static string FormatFragment(RsLogixImportResult result)
|
||||
{
|
||||
var tags = result.Tags.Select(t => new
|
||||
{
|
||||
Name = t.Name,
|
||||
DeviceHostAddress = t.DeviceHostAddress,
|
||||
Address = t.Address,
|
||||
DataType = t.DataType.ToString(),
|
||||
Writable = t.Writable,
|
||||
}).ToArray();
|
||||
var doc = new { Tags = tags };
|
||||
return JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
|
||||
private static string FormatSummary(RsLogixImportResult result) =>
|
||||
$"Imported {result.ParsedCount} tag(s), skipped {result.SkippedCount}, errors {result.ErrorCount}.";
|
||||
}
|
||||
@@ -40,10 +40,19 @@ public sealed class ProbeCommand : AbLegacyCommandBase
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||
var health = driver.GetHealth();
|
||||
// PR ablegacy-12 / #255 — surface Demoted alongside the probe-driven
|
||||
// HostState. After a one-shot probe the host hasn't been observed
|
||||
// (no probe loop runs in CLI mode), so HostState is typically Unknown
|
||||
// unless the read above tripped the demote threshold.
|
||||
var hostStatus = driver.GetHostStatuses().FirstOrDefault();
|
||||
|
||||
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
|
||||
await console.Output.WriteLineAsync($"PLC type: {PlcType}");
|
||||
await console.Output.WriteLineAsync($"Health: {health.State}");
|
||||
if (hostStatus is not null)
|
||||
{
|
||||
await console.Output.WriteLineAsync($"Host state: {hostStatus.State}");
|
||||
}
|
||||
if (health.LastError is { } err)
|
||||
await console.Output.WriteLineAsync($"Last error: {err}");
|
||||
await console.Output.WriteLineAsync();
|
||||
|
||||
@@ -24,6 +24,16 @@ public sealed class SubscribeCommand : AbLegacyCommandBase
|
||||
"Publishing interval in milliseconds (default 1000).")]
|
||||
public int IntervalMs { get; init; } = 1000;
|
||||
|
||||
[CommandOption("deadband-absolute", Description =
|
||||
"PR 8 — absolute change filter. Suppress notifications until |new - prev| >= this value. " +
|
||||
"Booleans bypass; strings + status changes always publish.")]
|
||||
public double? DeadbandAbsolute { get; init; }
|
||||
|
||||
[CommandOption("deadband-percent", Description =
|
||||
"PR 8 — percent-of-previous change filter. Suppress notifications until " +
|
||||
"|new - prev| >= |prev * pct / 100|. prev=0 always publishes.")]
|
||||
public double? DeadbandPercent { get; init; }
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
@@ -35,7 +45,9 @@ public sealed class SubscribeCommand : AbLegacyCommandBase
|
||||
DeviceHostAddress: Gateway,
|
||||
Address: Address,
|
||||
DataType: DataType,
|
||||
Writable: false);
|
||||
Writable: false,
|
||||
AbsoluteDeadband: DeadbandAbsolute,
|
||||
PercentDeadband: DeadbandPercent);
|
||||
var options = BuildOptions([tag]);
|
||||
|
||||
await using var driver = new AbLegacyDriver(options, DriverInstanceId);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
@@ -30,35 +32,102 @@ public sealed record AbLegacyAddress(
|
||||
int? FileNumber,
|
||||
int WordNumber,
|
||||
int? BitIndex,
|
||||
string? SubElement)
|
||||
string? SubElement,
|
||||
AbLegacyAddress? IndirectFileSource = null,
|
||||
AbLegacyAddress? IndirectWordSource = null,
|
||||
int? ArrayCount = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// PR 7 — PCCC frame ceiling. A single SLC/PLC-5 PCCC read can return up to about 240
|
||||
/// bytes (~120 INT words / 60 DINTs / 60 floats). The parser caps <see cref="ArrayCount"/>
|
||||
/// at 120 so a misconfigured tag fails fast instead of bouncing off the wire as a fragmented
|
||||
/// multi-frame read.
|
||||
/// </summary>
|
||||
public const int MaxArrayCount = 120;
|
||||
|
||||
/// <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}";
|
||||
// PR 7 — emit libplctag's `[N]` array suffix when the parsed address carries an
|
||||
// ArrayCount. libplctag's PCCC text decoder treats `N7:0[10]` as "10 consecutive
|
||||
// words starting at N7:0"; the comma form (`N7:0,10`) is Rockwell-native and gets
|
||||
// canonicalised to bracket form here so the driver always hands libplctag a single
|
||||
// recognisable shape.
|
||||
if (ArrayCount is int n) wordPart += $"[{n}]";
|
||||
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 +138,220 @@ 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");
|
||||
|
||||
// PR 7 — strip an optional array suffix from the trailing edge of the word part.
|
||||
// Two accepted forms: Rockwell-native `,N` (e.g. `N7:0,10`) and libplctag-native
|
||||
// `[N]` (e.g. `N7:0[10]`). Both resolve to the same ArrayCount. The bracket form
|
||||
// collides syntactically with the indirect-word form (`N7:[N7:0]`) — the
|
||||
// disambiguation is "leading bracket = indirect; trailing bracket after the
|
||||
// numeric word literal = array". A trailing `[N]` may also follow an indirect
|
||||
// word (`N7:[N7:0][10]`) — supported.
|
||||
int? arrayCount = null;
|
||||
|
||||
// Try comma form first — only meaningful when no leading-bracket indirect form is
|
||||
// present. Comma never appears in indirect-word source addresses (those use ':').
|
||||
var commaIdx = wordPart.LastIndexOf(',');
|
||||
if (commaIdx > 0 && wordPart[0] != '[')
|
||||
{
|
||||
var arrayText = wordPart[(commaIdx + 1)..];
|
||||
if (!int.TryParse(arrayText, out var ac) || ac < 1 || ac > MaxArrayCount) return null;
|
||||
arrayCount = ac;
|
||||
wordPart = wordPart[..commaIdx];
|
||||
}
|
||||
else if (wordPart.Length > 0 && wordPart[^1] == ']')
|
||||
{
|
||||
// Trailing `[N]` — only valid when there's already a primary word/indirect
|
||||
// segment in front of it. Walk back to the matching `[`.
|
||||
// Use top-level-aware index so a nested indirect like `[N7:0]` doesn't trip us.
|
||||
// We want the LAST top-level `[` whose body is a pure integer.
|
||||
var openIdx = MatchingOpenBracket(wordPart, wordPart.Length - 1);
|
||||
if (openIdx > 0)
|
||||
{
|
||||
var arrayText = wordPart[(openIdx + 1)..^1];
|
||||
if (int.TryParse(arrayText, out var ac))
|
||||
{
|
||||
if (ac < 1 || ac > MaxArrayCount) return null;
|
||||
arrayCount = ac;
|
||||
wordPart = wordPart[..openIdx];
|
||||
}
|
||||
// If the bracket body isn't a pure integer, leave wordPart alone — likely
|
||||
// an indirect-word source address (handled below) or malformed input.
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// PR 7 — array tags can't combine with a bit suffix (`N7:0,10/3` is meaningless —
|
||||
// "the third bit of ten different words"?) or with a sub-element pull (`T4:0,5.ACC`
|
||||
// is also meaningless — the sub-element targets one timer's accumulator). The
|
||||
// libplctag PCCC layer would silently accept the combination; reject up-front so
|
||||
// the OPC UA client sees a clean parse failure rather than a wire-level surprise.
|
||||
if (arrayCount is not null)
|
||||
{
|
||||
if (bitIndex is not null) return null;
|
||||
if (subElement is not null) return null;
|
||||
}
|
||||
|
||||
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement, indirectFile, indirectWord, arrayCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find the index of the `[` that matches the `]` at <paramref name="closeIdx"/> in
|
||||
/// <paramref name="s"/>, accounting for nested brackets. Returns -1 if no match.
|
||||
/// </summary>
|
||||
private static int MatchingOpenBracket(string s, int closeIdx)
|
||||
{
|
||||
if (closeIdx < 0 || closeIdx >= s.Length || s[closeIdx] != ']') return -1;
|
||||
var depth = 1;
|
||||
for (var i = closeIdx - 1; i >= 0; i--)
|
||||
{
|
||||
if (s[i] == ']') depth++;
|
||||
else if (s[i] == '[')
|
||||
{
|
||||
depth--;
|
||||
if (depth == 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <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 +359,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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// PR ablegacy-10 / #253 — diagnostic-counter tag source. Holds per-device live
|
||||
/// counters (request / response / error / retry / last-error / comm-failures) that
|
||||
/// the driver surfaces under each device's synthetic <c>_Diagnostics</c> folder. The
|
||||
/// read path short-circuits before the libplctag dispatch when the incoming
|
||||
/// reference targets a <c>_Diagnostics/<host>/<name></c> address — the
|
||||
/// values come straight from the driver-local counters.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Mirrors AbCip's <c>AbCipSystemTagSource</c> pattern (the abcip-4.3 PR that
|
||||
/// just merged) — same per-device folder, same read-only semantics, but the seven
|
||||
/// names + their counter shape match the AB-Legacy plan: numerical counters that
|
||||
/// HMIs can bind directly without a separate diagnostics RPC. Counters are
|
||||
/// <c>long</c> (Int64) so a long-running deployment can't roll an
|
||||
/// <c>RequestCount</c> over inside a maintenance window.</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>RequestCount</c> — total <see cref="AbLegacyDriver.ReadAsync"/>
|
||||
/// requests issued against this device (each non-diagnostic reference counts
|
||||
/// once per call, success or fail).</item>
|
||||
/// <item><c>ResponseCount</c> — successful read responses.</item>
|
||||
/// <item><c>ErrorCount</c> — failed read responses (any non-Good status).</item>
|
||||
/// <item><c>RetryCount</c> — retry attempts beyond the first per the PR 9 retry
|
||||
/// loop. Incremented once per extra attempt, not per successful retry.</item>
|
||||
/// <item><c>LastErrorCode</c> — most recent libplctag status code on a failed
|
||||
/// read (0 when no error has been seen since reset).</item>
|
||||
/// <item><c>LastErrorMessage</c> — most recent libplctag error message on a
|
||||
/// failed read (empty when no error has been seen).</item>
|
||||
/// <item><c>CommFailures</c> — count of read failures mapped to
|
||||
/// <see cref="AbLegacyStatusMapper.BadCommunicationError"/>. Spans transient
|
||||
/// exceptions + retried-out chains so operators see a single "wire fell off"
|
||||
/// counter without having to sum across error-code subtotals.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class AbLegacyDiagnosticTags
|
||||
{
|
||||
/// <summary>Address-space prefix the driver stamps on every diagnostic variable's
|
||||
/// <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverAttributeInfo.FullName"/>.</summary>
|
||||
public const string DiagnosticsFolderPrefix = "_Diagnostics/";
|
||||
|
||||
/// <summary>Canonical names the diagnostics folder exposes. Keep in lockstep with discovery.</summary>
|
||||
/// <remarks>
|
||||
/// PR ablegacy-12 / #255 — <c>DemoteCount</c> + <c>LastDemotedUtc</c> ride
|
||||
/// alongside the original seven so HMIs can spot a flapping device by
|
||||
/// watching <c>DemoteCount</c> climb without scraping logs.
|
||||
/// </remarks>
|
||||
public static readonly IReadOnlyList<string> DiagnosticTagNames =
|
||||
[
|
||||
"RequestCount",
|
||||
"ResponseCount",
|
||||
"ErrorCount",
|
||||
"RetryCount",
|
||||
"LastErrorCode",
|
||||
"LastErrorMessage",
|
||||
"CommFailures",
|
||||
// PR ablegacy-12 / #255 — auto-demote on comm failure surface.
|
||||
"DemoteCount",
|
||||
"LastDemotedUtc",
|
||||
];
|
||||
|
||||
private static readonly HashSet<string> DiagnosticTagNameSet =
|
||||
new(DiagnosticTagNames, StringComparer.Ordinal);
|
||||
|
||||
private readonly Dictionary<string, DiagnosticsCounters> _counters =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Make sure a slot exists for <paramref name="deviceHostAddress"/>. Called from
|
||||
/// <see cref="AbLegacyDriver.InitializeAsync"/> so the counters are zero-initialised
|
||||
/// by the time the first read or probe iteration fires.
|
||||
/// </summary>
|
||||
public void EnsureDevice(string deviceHostAddress)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deviceHostAddress);
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_counters.ContainsKey(deviceHostAddress))
|
||||
_counters[deviceHostAddress] = new DiagnosticsCounters();
|
||||
}
|
||||
}
|
||||
|
||||
private DiagnosticsCounters GetOrCreate(string deviceHostAddress)
|
||||
{
|
||||
// Fast path: already-tracked device. Slow path: lazy add when a caller hits an
|
||||
// unregistered host (defensive — production callers all go through EnsureDevice).
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_counters.TryGetValue(deviceHostAddress, out var c))
|
||||
{
|
||||
c = new DiagnosticsCounters();
|
||||
_counters[deviceHostAddress] = c;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Increment <c>RequestCount</c> for <paramref name="deviceHostAddress"/>.</summary>
|
||||
public void RecordRequest(string deviceHostAddress)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deviceHostAddress);
|
||||
var c = GetOrCreate(deviceHostAddress);
|
||||
Interlocked.Increment(ref c.Request);
|
||||
}
|
||||
|
||||
/// <summary>Increment <c>ResponseCount</c> for a successful read.</summary>
|
||||
public void RecordResponse(string deviceHostAddress)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deviceHostAddress);
|
||||
var c = GetOrCreate(deviceHostAddress);
|
||||
Interlocked.Increment(ref c.Response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Increment <c>ErrorCount</c> + record the latest libplctag status code +
|
||||
/// message for a failed read. <paramref name="commFailure"/> = true also bumps
|
||||
/// <c>CommFailures</c> when the failure mapped to <c>BadCommunicationError</c>.
|
||||
/// </summary>
|
||||
public void RecordError(
|
||||
string deviceHostAddress, int libplctagStatus, string? errorMessage, bool commFailure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deviceHostAddress);
|
||||
var c = GetOrCreate(deviceHostAddress);
|
||||
Interlocked.Increment(ref c.Error);
|
||||
if (commFailure) Interlocked.Increment(ref c.CommFailures);
|
||||
// Atomic int32 store on a 32-bit-aligned field; .NET reference-write atomicity
|
||||
// covers the message swap. Last-write-wins matches the spec.
|
||||
Interlocked.Exchange(ref c.LastErrorCode, libplctagStatus);
|
||||
c.LastErrorMessage = errorMessage ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Increment <c>RetryCount</c> per retry attempt beyond the first.</summary>
|
||||
public void RecordRetry(string deviceHostAddress)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deviceHostAddress);
|
||||
var c = GetOrCreate(deviceHostAddress);
|
||||
Interlocked.Increment(ref c.Retry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR ablegacy-12 / #255 — record an auto-demotion event: bumps cumulative
|
||||
/// <c>DemoteCount</c> and stamps <c>LastDemotedUtc</c>. Fires every time the
|
||||
/// driver crosses the failure threshold and arms a fresh cool-down window —
|
||||
/// a single flapping link that demotes hourly will surface as a steadily
|
||||
/// climbing counter, which is the operator-facing signal we want.
|
||||
/// </summary>
|
||||
public void RecordDemote(string deviceHostAddress, DateTime nowUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deviceHostAddress);
|
||||
var c = GetOrCreate(deviceHostAddress);
|
||||
Interlocked.Increment(ref c.DemoteCount);
|
||||
// DateTime is 64 bits — use Interlocked.Exchange on the Ticks field so a
|
||||
// concurrent reader sees a torn-free snapshot. On x86 a 64-bit non-aligned
|
||||
// write isn't atomic; on x64 it is, but routing through Interlocked is
|
||||
// platform-independent + costs almost nothing.
|
||||
Interlocked.Exchange(ref c.LastDemotedUtcTicks, nowUtc.Ticks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR ablegacy-12 / #255 — restore cumulative demote bookkeeping after a
|
||||
/// <see cref="AbLegacyDriver.ReinitializeAsync"/> cycle so an operator
|
||||
/// redeploying config mid-incident doesn't lose flapping-link history.
|
||||
/// Sets the counters to absolute values rather than incrementing.
|
||||
/// </summary>
|
||||
public void RestoreDemote(string deviceHostAddress, long demoteCount, DateTime? lastDemotedUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deviceHostAddress);
|
||||
var c = GetOrCreate(deviceHostAddress);
|
||||
Interlocked.Exchange(ref c.DemoteCount, demoteCount);
|
||||
Interlocked.Exchange(ref c.LastDemotedUtcTicks, lastDemotedUtc?.Ticks ?? 0);
|
||||
}
|
||||
|
||||
/// <summary>Snapshot the current counters for a device. Returns zeros for unknown hosts.</summary>
|
||||
public DiagnosticsSnapshot Snapshot(string deviceHostAddress)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deviceHostAddress);
|
||||
DiagnosticsCounters? c;
|
||||
lock (_lock)
|
||||
{
|
||||
_counters.TryGetValue(deviceHostAddress, out c);
|
||||
}
|
||||
if (c is null) return new DiagnosticsSnapshot(0, 0, 0, 0, 0, string.Empty, 0, 0, null);
|
||||
var ticks = Interlocked.Read(ref c.LastDemotedUtcTicks);
|
||||
return new DiagnosticsSnapshot(
|
||||
Request: Interlocked.Read(ref c.Request),
|
||||
Response: Interlocked.Read(ref c.Response),
|
||||
Error: Interlocked.Read(ref c.Error),
|
||||
Retry: Interlocked.Read(ref c.Retry),
|
||||
LastErrorCode: Volatile.Read(ref c.LastErrorCode),
|
||||
LastErrorMessage: c.LastErrorMessage ?? string.Empty,
|
||||
CommFailures: Interlocked.Read(ref c.CommFailures),
|
||||
// PR ablegacy-12 / #255 — auto-demote surface.
|
||||
DemoteCount: Interlocked.Read(ref c.DemoteCount),
|
||||
LastDemotedUtc: ticks == 0 ? null : new DateTime(ticks, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reset every counter for <paramref name="deviceHostAddress"/> back to zero. Called
|
||||
/// from <see cref="AbLegacyDriver.ReinitializeAsync"/> so a config redeploy starts
|
||||
/// with a clean diagnostic surface.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// PR ablegacy-12 / #255 — when <paramref name="preserveDemote"/> is <c>true</c> the
|
||||
/// cumulative <c>DemoteCount</c> + <c>LastDemotedUtc</c> survive the reset.
|
||||
/// <see cref="AbLegacyDriver.ReinitializeAsync"/> uses that mode so an operator
|
||||
/// redeploying a config doesn't lose their flapping-link history; a fresh process
|
||||
/// start clears them naturally because the dictionary is rebuilt from scratch.
|
||||
/// </remarks>
|
||||
public void Reset(string deviceHostAddress, bool preserveDemote = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deviceHostAddress);
|
||||
var c = GetOrCreate(deviceHostAddress);
|
||||
Interlocked.Exchange(ref c.Request, 0);
|
||||
Interlocked.Exchange(ref c.Response, 0);
|
||||
Interlocked.Exchange(ref c.Error, 0);
|
||||
Interlocked.Exchange(ref c.Retry, 0);
|
||||
Interlocked.Exchange(ref c.LastErrorCode, 0);
|
||||
c.LastErrorMessage = string.Empty;
|
||||
Interlocked.Exchange(ref c.CommFailures, 0);
|
||||
if (!preserveDemote)
|
||||
{
|
||||
Interlocked.Exchange(ref c.DemoteCount, 0);
|
||||
Interlocked.Exchange(ref c.LastDemotedUtcTicks, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reset every tracked device. Called on full <c>ShutdownAsync</c>.</summary>
|
||||
/// <remarks>
|
||||
/// PR ablegacy-12 / #255 — when <paramref name="preserveDemote"/> is <c>true</c> the
|
||||
/// cumulative demote counters survive a per-device reset of every other field.
|
||||
/// The default (<c>false</c>) clears the dictionary outright, which is what
|
||||
/// <see cref="AbLegacyDriver.ShutdownAsync"/> wants.
|
||||
/// </remarks>
|
||||
public void ResetAll(bool preserveDemote = false)
|
||||
{
|
||||
if (!preserveDemote)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_counters.Clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Preserve mode: keep the dictionary keys + cumulative demote fields, but
|
||||
// zero everything else. Used by Reinitialize to span a config redeploy
|
||||
// without losing flapping-link history.
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var key in _counters.Keys.ToList())
|
||||
{
|
||||
Reset(key, preserveDemote: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a <c>_Diagnostics/<host>/<name></c> reference into a counter
|
||||
/// value. Returns <c>true</c> when the reference shape matches; <paramref name="value"/>
|
||||
/// carries the counter (or empty string for <c>LastErrorMessage</c>) on success.
|
||||
/// </summary>
|
||||
public bool TryRead(string fullReference, out object? value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fullReference);
|
||||
if (!IsDiagnosticAddress(fullReference)) { value = null; return false; }
|
||||
|
||||
var withoutPrefix = fullReference[DiagnosticsFolderPrefix.Length..];
|
||||
var slashIdx = withoutPrefix.LastIndexOf('/');
|
||||
if (slashIdx <= 0 || slashIdx >= withoutPrefix.Length - 1) { value = null; return false; }
|
||||
|
||||
var host = withoutPrefix[..slashIdx];
|
||||
var name = withoutPrefix[(slashIdx + 1)..];
|
||||
if (!IsReservedName(name)) { value = null; return false; }
|
||||
|
||||
var snapshot = Snapshot(host);
|
||||
value = name switch
|
||||
{
|
||||
"RequestCount" => snapshot.Request,
|
||||
"ResponseCount" => snapshot.Response,
|
||||
"ErrorCount" => snapshot.Error,
|
||||
"RetryCount" => snapshot.Retry,
|
||||
"LastErrorCode" => snapshot.LastErrorCode,
|
||||
"LastErrorMessage" => snapshot.LastErrorMessage,
|
||||
"CommFailures" => snapshot.CommFailures,
|
||||
// PR ablegacy-12 / #255 — auto-demote surface. LastDemotedUtc returns
|
||||
// the empty string when no demotion has happened yet, mirroring the
|
||||
// LastErrorMessage convention so HMIs can bind directly to a string.
|
||||
"DemoteCount" => snapshot.DemoteCount,
|
||||
"LastDemotedUtc" => snapshot.LastDemotedUtc?.ToString("o") ?? string.Empty,
|
||||
_ => null,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> when <paramref name="reference"/> targets a node under the synthetic
|
||||
/// <c>_Diagnostics/</c> folder. The driver's read path uses this to bypass the
|
||||
/// libplctag runtime and dispatch to <see cref="TryRead"/> directly.
|
||||
/// </summary>
|
||||
public static bool IsDiagnosticAddress(string? reference) =>
|
||||
!string.IsNullOrEmpty(reference)
|
||||
&& reference.StartsWith(DiagnosticsFolderPrefix, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> when <paramref name="name"/> matches one of the seven reserved
|
||||
/// diagnostic names. Used by <see cref="AbLegacyDriver.InitializeAsync"/> to reject
|
||||
/// user-config tags that would shadow the driver-emitted counters.
|
||||
/// </summary>
|
||||
public static bool IsReservedName(string? name) =>
|
||||
!string.IsNullOrEmpty(name) && DiagnosticTagNameSet.Contains(name);
|
||||
|
||||
private sealed class DiagnosticsCounters
|
||||
{
|
||||
public long Request;
|
||||
public long Response;
|
||||
public long Error;
|
||||
public long Retry;
|
||||
public int LastErrorCode;
|
||||
public string? LastErrorMessage = string.Empty;
|
||||
public long CommFailures;
|
||||
// PR ablegacy-12 / #255 — cumulative across config redeploys. Cleared only
|
||||
// on full driver process restart (the dictionary is rebuilt from scratch);
|
||||
// ReinitializeAsync uses preserveDemote: true.
|
||||
public long DemoteCount;
|
||||
public long LastDemotedUtcTicks;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR ablegacy-10 / #253 — immutable snapshot of one device's diagnostic counters.
|
||||
/// Returned through <see cref="AbLegacyDriver.ReadAsync"/> when an OPC UA client
|
||||
/// reads any of the seven <c>_Diagnostics/<host>/<name></c> variables.
|
||||
/// </summary>
|
||||
/// <param name="Request">Total <c>ReadAsync</c> requests issued against this device.</param>
|
||||
/// <param name="Response">Successful read responses.</param>
|
||||
/// <param name="Error">Failed read responses (any non-Good status).</param>
|
||||
/// <param name="Retry">Retry attempts beyond the first per the PR 9 retry loop.</param>
|
||||
/// <param name="LastErrorCode">Most recent libplctag status code on a failed read.</param>
|
||||
/// <param name="LastErrorMessage">Most recent libplctag error message on a failed read.</param>
|
||||
/// <param name="CommFailures">Count of read failures mapped to <c>BadCommunicationError</c>.</param>
|
||||
/// <param name="DemoteCount">PR ablegacy-12 / #255 — cumulative auto-demote events.</param>
|
||||
/// <param name="LastDemotedUtc">PR ablegacy-12 / #255 — UTC timestamp of the most
|
||||
/// recent demotion, or <c>null</c> if the device has never been demoted.</param>
|
||||
public sealed record DiagnosticsSnapshot(
|
||||
long Request,
|
||||
long Response,
|
||||
long Error,
|
||||
long Retry,
|
||||
int LastErrorCode,
|
||||
string LastErrorMessage,
|
||||
long CommFailures,
|
||||
long DemoteCount,
|
||||
DateTime? LastDemotedUtc);
|
||||
@@ -17,6 +17,30 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, AbLegacyTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// PR 8 — per-tag last published <c>(value, status)</c> cache for the deadband filter.
|
||||
/// Layered on top of <see cref="PollGroupEngine"/> because the engine's change-detection
|
||||
/// is binary (publish on any value/status diff). Cleared on <see cref="ShutdownAsync"/>
|
||||
/// so a reconnect doesn't suppress legitimate post-reconnect updates against stale state.
|
||||
/// Keyed by full reference (== tag name) — matches the engine's own <c>LastValues</c> key
|
||||
/// space.
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, (object? Value, uint StatusCode)> _lastPublished =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _lastPublishedLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// PR ablegacy-10 / #253 — per-device diagnostic counters surfaced as
|
||||
/// <c>_Diagnostics/<host>/<name></c> read-only variables. Updated on
|
||||
/// every <see cref="ReadAsync"/> call (success, failure, retry) so HMIs can bind
|
||||
/// directly without a separate diagnostics RPC.
|
||||
/// </summary>
|
||||
private readonly AbLegacyDiagnosticTags _diagnosticTags = new();
|
||||
|
||||
/// <summary>Test seam — exposes the live diagnostic-tag source so unit tests can poke counters.</summary>
|
||||
internal AbLegacyDiagnosticTags DiagnosticTags => _diagnosticTags;
|
||||
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
@@ -31,8 +55,99 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
_tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
|
||||
_poll = new PollGroupEngine(
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||||
onChange: DispatchPollChange);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR 8 — wraps the <see cref="PollGroupEngine"/> change callback with a per-tag
|
||||
/// deadband filter. Booleans bypass (publish on every edge); strings + status changes
|
||||
/// always publish; numerics pass only when <c>|new - prev|</c> meets the configured
|
||||
/// absolute and / or percent deadband. First-seen always publishes.
|
||||
/// </summary>
|
||||
private void DispatchPollChange(ISubscriptionHandle handle, string tagRef, DataValueSnapshot snapshot)
|
||||
{
|
||||
if (!ShouldPublish(tagRef, snapshot)) return;
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR 8 — deadband decision for one new sample. Updates the per-tag last-published
|
||||
/// cache when the publish goes through so the next sample compares against the actual
|
||||
/// emitted value (not every polled value).
|
||||
/// </summary>
|
||||
internal bool ShouldPublish(string tagRef, DataValueSnapshot snapshot)
|
||||
{
|
||||
// Tags absent from config (impossible via the engine path, defensive against callers
|
||||
// that exercise the dispatch logic in isolation) bypass the filter.
|
||||
var hasTag = _tagsByName.TryGetValue(tagRef, out var def);
|
||||
|
||||
lock (_lastPublishedLock)
|
||||
{
|
||||
var firstSeen = !_lastPublished.TryGetValue(tagRef, out var prev);
|
||||
|
||||
// First-seen, status change, or no tag config: always publish.
|
||||
if (firstSeen || prev.StatusCode != snapshot.StatusCode || !hasTag)
|
||||
{
|
||||
_lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode);
|
||||
return true;
|
||||
}
|
||||
|
||||
// No deadband configured -> defer to PollGroupEngine's value-equality decision
|
||||
// (the engine already filtered to "different from last engine snapshot" before we
|
||||
// got here, so any sample reaching this point is a legitimate change).
|
||||
if (def!.AbsoluteDeadband is null && def.PercentDeadband is null)
|
||||
{
|
||||
_lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Booleans + strings + non-numerics: deadband is meaningless; publish whenever the
|
||||
// value differs from the last published one.
|
||||
if (!TryAsDouble(snapshot.Value, out var newD) || !TryAsDouble(prev.Value, out var prevD))
|
||||
{
|
||||
if (Equals(prev.Value, snapshot.Value)) return false;
|
||||
_lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode);
|
||||
return true;
|
||||
}
|
||||
|
||||
var delta = Math.Abs(newD - prevD);
|
||||
var absPass = def.AbsoluteDeadband is double abs && delta >= abs;
|
||||
|
||||
// Percent: |prev| == 0 short-circuits to "always publish on any change" — avoids
|
||||
// div-by-zero and matches Kepware's documented behaviour.
|
||||
bool percentPass;
|
||||
if (def.PercentDeadband is double pct)
|
||||
{
|
||||
if (prevD == 0) percentPass = delta > 0;
|
||||
else percentPass = delta >= Math.Abs(prevD * pct / 100.0);
|
||||
}
|
||||
else percentPass = false;
|
||||
|
||||
// Logical OR — either filter triggering is enough. Matches the spec note in the
|
||||
// PR plan ("Both deadbands set -> either triggers, Kepware semantics").
|
||||
var pass = (def.AbsoluteDeadband is not null && absPass)
|
||||
|| (def.PercentDeadband is not null && percentPass);
|
||||
|
||||
if (!pass) return false;
|
||||
|
||||
_lastPublished[tagRef] = (snapshot.Value, snapshot.StatusCode);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryAsDouble(object? value, out double result)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case null: result = 0; return false;
|
||||
case bool: result = 0; return false; // booleans use the equality fast path
|
||||
case string: result = 0; return false;
|
||||
case Array: result = 0; return false;
|
||||
case IConvertible conv:
|
||||
try { result = conv.ToDouble(System.Globalization.CultureInfo.InvariantCulture); return true; }
|
||||
catch { result = 0; return false; }
|
||||
default: result = 0; return false;
|
||||
}
|
||||
}
|
||||
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
@@ -48,10 +163,50 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
var addr = AbLegacyHostAddress.TryParse(device.HostAddress)
|
||||
?? throw new InvalidOperationException(
|
||||
$"AbLegacy device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'.");
|
||||
// PR ablegacy-13 / #256 — DHRIO DH+ bridging is PLC-5-only. SLC500 /
|
||||
// MicroLogix / LogixPccc cannot be addressed through a 1756-DHRIO module
|
||||
// (the module only speaks DH+ to PLC-5 and SLC-DH+ peers — and the SLC-DH+
|
||||
// path uses a different protocol stack libplctag's PCCC layer doesn't
|
||||
// expose). Catch the misconfiguration up front rather than waiting for
|
||||
// reads to return BadCommunicationError on the wire.
|
||||
if (addr.IsDhPlusBridge && device.PlcFamily != AbLegacyPlcFamily.Plc5)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"AbLegacy device '{device.HostAddress}' uses the 1756-DHRIO DH+ bridge " +
|
||||
$"path '1,{addr.BackplaneSlot},2,…' but PlcFamily='{device.PlcFamily}'. " +
|
||||
"DHRIO bridging is PLC-5-only.");
|
||||
}
|
||||
var profile = AbLegacyPlcFamilyProfile.ForFamily(device.PlcFamily);
|
||||
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
|
||||
// PR ablegacy-10 / #253 — pre-allocate the diagnostic-counter slot so the
|
||||
// first read against this device sees zero-initialised counters instead of
|
||||
// having to lazy-add on the request path.
|
||||
_diagnosticTags.EnsureDevice(device.HostAddress);
|
||||
}
|
||||
foreach (var tag in _options.Tags)
|
||||
{
|
||||
// PR ablegacy-10 / #253 — collision rejection. User-config tags must not
|
||||
// shadow the seven driver-emitted diagnostic names, and they must not live
|
||||
// under the synthetic _Diagnostics/ folder. Both shapes would silently
|
||||
// never resolve at read time (the diagnostics short-circuit wins) so we
|
||||
// reject up front with a clear error rather than letting the operator wonder
|
||||
// why their tag returns BadNodeIdUnknown.
|
||||
if (AbLegacyDiagnosticTags.IsDiagnosticAddress(tag.Address))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"AbLegacy tag '{tag.Name}' has Address '{tag.Address}' under the reserved " +
|
||||
$"'_Diagnostics/' namespace; that prefix is owned by the auto-emitted " +
|
||||
$"diagnostic counters. Choose a different address.");
|
||||
}
|
||||
if (AbLegacyDiagnosticTags.IsReservedName(tag.Name))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"AbLegacy tag name '{tag.Name}' collides with a reserved diagnostic " +
|
||||
$"counter ({string.Join(", ", AbLegacyDiagnosticTags.DiagnosticTagNames)}). " +
|
||||
$"Rename the tag.");
|
||||
}
|
||||
_tagsByName[tag.Name] = tag;
|
||||
}
|
||||
foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag;
|
||||
|
||||
// Probe loops — one per device when enabled + probe address configured.
|
||||
if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeAddress))
|
||||
@@ -75,8 +230,37 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
|
||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
// PR ablegacy-12 / #255 — capture the cumulative DemoteCount + LastDemotedUtc
|
||||
// for every currently-tracked device before we tear down. The Shutdown below
|
||||
// calls ResetAll() which clears the dictionary; the per-host InitializeAsync
|
||||
// below re-EnsureDevice's the slots; we restore the cumulative demote
|
||||
// history so an operator who redeploys mid-incident doesn't lose the trail
|
||||
// of how often this device was flapping.
|
||||
var preservedDemote = new Dictionary<string, (long DemoteCount, DateTime? LastDemotedUtc)>(
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (host, _) in _devices)
|
||||
{
|
||||
var snap = _diagnosticTags.Snapshot(host);
|
||||
preservedDemote[host] = (snap.DemoteCount, snap.LastDemotedUtc);
|
||||
}
|
||||
|
||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||
// PR ablegacy-10 / #253 — counters were dropped along with the device map when
|
||||
// ShutdownAsync called ResetAll; the InitializeAsync below re-EnsureDevice's each
|
||||
// host so the freshly registered counters start at zero. Belt-and-braces clear
|
||||
// here in case a downstream override of either method skips the cycle.
|
||||
_diagnosticTags.ResetAll();
|
||||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// PR ablegacy-12 / #255 — restore the cumulative demote history. Only hosts
|
||||
// that survive the redeploy get their counters back; a device removed from
|
||||
// config legitimately drops its history (it isn't being tracked any more).
|
||||
foreach (var (host, (count, lastUtc)) in preservedDemote)
|
||||
{
|
||||
if (count == 0 && lastUtc is null) continue;
|
||||
if (!_devices.ContainsKey(host)) continue;
|
||||
_diagnosticTags.RestoreDemote(host, count, lastUtc);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
@@ -91,6 +275,14 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
}
|
||||
_devices.Clear();
|
||||
_tagsByName.Clear();
|
||||
// PR 8 — clear the deadband last-published cache so a ReinitializeAsync (or a
|
||||
// reconnect-driven shutdown) doesn't suppress the very first post-reconnect sample
|
||||
// by comparing it against pre-disconnect state.
|
||||
lock (_lastPublishedLock) { _lastPublished.Clear(); }
|
||||
// PR ablegacy-10 / #253 — drop every per-device counter so a reinit / redeploy
|
||||
// starts with a clean diagnostic surface. Reset (per-host) is also exposed so a
|
||||
// future "clear counters" admin RPC can reach in without a full shutdown.
|
||||
_diagnosticTags.ResetAll();
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
@@ -102,6 +294,56 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
internal DeviceState? GetDeviceState(string hostAddress) =>
|
||||
_devices.TryGetValue(hostAddress, out var s) ? s : null;
|
||||
|
||||
/// <summary>
|
||||
/// PR 9 — per-device timeout precedence: device-level override wins, otherwise the
|
||||
/// driver-wide default. Probe loop has its own timeout knob via
|
||||
/// <see cref="AbLegacyProbeOptions.Timeout"/> but still falls back to the per-device
|
||||
/// value when the probe override is absent (handled at the call site).
|
||||
/// </summary>
|
||||
internal TimeSpan ResolveTimeout(DeviceState device) =>
|
||||
device.Options.Timeout ?? _options.Timeout;
|
||||
|
||||
/// <summary>
|
||||
/// PR 9 — per-device retry count: device-level override wins, otherwise the driver-wide
|
||||
/// default, otherwise zero (single attempt). The driver-wide default itself is
|
||||
/// <c>null</c> by default so a vanilla AbLegacy config still issues exactly one read per
|
||||
/// reference, matching pre-PR-9 behaviour.
|
||||
/// </summary>
|
||||
internal int ResolveRetries(DeviceState device) =>
|
||||
device.Options.Retries ?? _options.Retries ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// PR ablegacy-12 / #255 — resolve the active <see cref="AbLegacyDemoteOptions"/> for
|
||||
/// a device. Per-device options win; otherwise the documented defaults (3 failures /
|
||||
/// 30 s / enabled). Returns a non-null record so callers can assume a usable value.
|
||||
/// </summary>
|
||||
internal AbLegacyDemoteOptions ResolveDemote(DeviceState device) =>
|
||||
device.Options.Demote ?? new AbLegacyDemoteOptions();
|
||||
|
||||
/// <summary>
|
||||
/// PR ablegacy-12 / #255 — common bookkeeping for one comm failure: bump the
|
||||
/// consecutive-failure counter and arm the demote window once the threshold is
|
||||
/// crossed. Returns <c>true</c> when this call tipped the device into Demoted (so
|
||||
/// the caller can fire <see cref="OnHostStatusChanged"/>); <c>false</c> when the
|
||||
/// device was already demoted or stayed below the threshold.
|
||||
/// </summary>
|
||||
private bool RecordFailureAndMaybeDemote(DeviceState state, DateTime nowUtc)
|
||||
{
|
||||
var demote = ResolveDemote(state);
|
||||
var consecutive = Interlocked.Increment(ref state.ConsecutiveFailures);
|
||||
|
||||
if (!demote.Enabled || consecutive < demote.FailureThreshold) return false;
|
||||
// Already demoted? Don't re-arm — the original window's expiry is the
|
||||
// operator-facing recovery clock and re-arming on every subsequent failed
|
||||
// read would suppress reads forever on a fully-down device. The probe
|
||||
// loop is what eventually clears the demotion (or the window expiring).
|
||||
if (state.DemotedUntilUtc is { } until && until > nowUtc) return false;
|
||||
|
||||
state.DemotedUntilUtc = nowUtc + demote.EffectiveDemoteFor;
|
||||
_diagnosticTags.RecordDemote(state.Options.HostAddress, nowUtc);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- IReadable ----
|
||||
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
@@ -114,6 +356,25 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var reference = fullReferences[i];
|
||||
|
||||
// PR ablegacy-10 / #253 — synthetic _Diagnostics/<host>/<name> reference;
|
||||
// serve from the in-process counter store and skip the libplctag dispatch
|
||||
// entirely. Diagnostic reads do NOT bump RequestCount — they're driver-local
|
||||
// observability, not field traffic, and counting them would make the
|
||||
// counter chase its own tail when a subscription polls at 1 Hz.
|
||||
if (AbLegacyDiagnosticTags.IsDiagnosticAddress(reference))
|
||||
{
|
||||
if (_diagnosticTags.TryRead(reference, out var diagValue))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(diagValue, AbLegacyStatusMapper.Good, now, now);
|
||||
}
|
||||
else
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_tagsByName.TryGetValue(reference, out var def))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now);
|
||||
@@ -125,33 +386,175 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
// PR ablegacy-10 / #253 — bump RequestCount once per non-diagnostic reference,
|
||||
// success or fail. The retry loop below counts retries through RecordRetry so
|
||||
// operators can spot a flapping link via the RetryCount counter without us
|
||||
// double-counting the original attempt as a retry.
|
||||
_diagnosticTags.RecordRequest(def.DeviceHostAddress);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status != 0)
|
||||
// PR ablegacy-12 / #255 — auto-demote short-circuit. When the device's demote
|
||||
// window is still active we return BadCommunicationError immediately, without
|
||||
// touching libplctag or its retry loop. That's the whole point of the feature:
|
||||
// one slow PLC sharing the driver thread can't drag down healthy peers. We
|
||||
// don't bump ErrorCount/CommFailures here because this isn't a fresh field
|
||||
// failure — it's the cool-down on a previously-counted one.
|
||||
if (device.DemotedUntilUtc is { } demotedUntil)
|
||||
{
|
||||
if (demotedUntil > now)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null,
|
||||
AbLegacyStatusMapper.MapLibplctagStatus(status), null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"libplctag status {status} reading {reference}");
|
||||
AbLegacyStatusMapper.BadCommunicationError, null, now);
|
||||
continue;
|
||||
}
|
||||
// Window expired without an early-clear from a probe success — drop the
|
||||
// marker but don't reset ConsecutiveFailures yet. If this read also
|
||||
// fails the failure tally keeps counting from where it left off, so a
|
||||
// permanently-down device re-arms the window after one more
|
||||
// consecutive failure (vs. having to repeat the full threshold).
|
||||
lock (device.ProbeLock)
|
||||
{
|
||||
if (device.DemotedUntilUtc is { } stillUntil && stillUntil <= now)
|
||||
{
|
||||
device.DemotedUntilUtc = null;
|
||||
// Mirror Stopped→Running on a probe-driven recovery: leave the
|
||||
// HostState transition to the probe loop (or the upcoming success
|
||||
// below); we just clear the cool-down marker so the next read
|
||||
// dispatches normally.
|
||||
if (device.HostState == HostState.Demoted)
|
||||
{
|
||||
// Surface a transition out of Demoted. The probe loop will
|
||||
// bring it Running once a probe succeeds; until then leave
|
||||
// it in Stopped to reflect "we don't actually know it's up".
|
||||
TransitionDeviceState(device, HostState.Stopped);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
||||
var value = runtime.DecodeValue(def.DataType, parsed?.BitIndex);
|
||||
results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
// PR 9 — per-device retry loop: on transient BadCommunicationError (libplctag throw
|
||||
// OR a non-zero status that maps to BadCommunicationError) retry up to N times. A
|
||||
// terminal mapped status (e.g. BadNodeIdUnknown for a missing PLC tag, BadTypeMismatch
|
||||
// for a decoder mismatch) is surfaced as-is — retrying won't fix it. Cancellation
|
||||
// always rethrows.
|
||||
var retries = ResolveRetries(device);
|
||||
DataValueSnapshot? snapshot = null;
|
||||
for (var attempt = 0; attempt <= retries; attempt++)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null,
|
||||
AbLegacyStatusMapper.BadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
// PR ablegacy-10 / #253 — second + later attempts count as retries for the
|
||||
// diagnostic counter. Increment BEFORE the work so a thrown exception still
|
||||
// shows up in the retry tally.
|
||||
if (attempt > 0) _diagnosticTags.RecordRetry(def.DeviceHostAddress);
|
||||
|
||||
try
|
||||
{
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status != 0)
|
||||
{
|
||||
var mappedStatus = AbLegacyStatusMapper.MapLibplctagStatus(status);
|
||||
// Transient: BadCommunicationError → eligible for retry.
|
||||
if (mappedStatus == AbLegacyStatusMapper.BadCommunicationError && attempt < retries)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// PR ablegacy-10 / #253 — terminal failure: bump the error counter
|
||||
// + record the libplctag status. CommFailure tally rolls only when
|
||||
// the mapped status is BadCommunicationError so operators see a
|
||||
// single "wire fell off" counter independent of other error codes.
|
||||
_diagnosticTags.RecordError(
|
||||
def.DeviceHostAddress,
|
||||
status,
|
||||
$"libplctag status {status} reading {reference}",
|
||||
commFailure: mappedStatus == AbLegacyStatusMapper.BadCommunicationError);
|
||||
// PR ablegacy-12 / #255 — only comm failures count toward the
|
||||
// demote tally. A BadNodeIdUnknown / BadTypeMismatch is a config
|
||||
// / decoder mismatch, not a sign the host is unreachable, so
|
||||
// demoting on it would punish the operator for a typo.
|
||||
if (mappedStatus == AbLegacyStatusMapper.BadCommunicationError
|
||||
&& RecordFailureAndMaybeDemote(device, now))
|
||||
{
|
||||
TransitionDeviceState(device, HostState.Demoted);
|
||||
}
|
||||
snapshot = new DataValueSnapshot(null, mappedStatus, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"libplctag status {status} reading {reference}");
|
||||
break;
|
||||
}
|
||||
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
||||
// PR 7 — array contiguous block. Decode N consecutive elements via the runtime's
|
||||
// per-index accessor and box the result as a typed .NET array. The parser has
|
||||
// already rejected array+bit and array+sub-element combinations, so the array
|
||||
// path can ignore the bit/sub-element decoders entirely.
|
||||
int arrayCount;
|
||||
if (parsed is not null && (def.ArrayLength is not null || (parsed.ArrayCount ?? 1) > 1))
|
||||
{
|
||||
arrayCount = ResolveElementCount(def, parsed);
|
||||
}
|
||||
else arrayCount = 1;
|
||||
|
||||
if (arrayCount > 1)
|
||||
{
|
||||
var arr = DecodeArrayAs(runtime, def.DataType, arrayCount);
|
||||
snapshot = new DataValueSnapshot(arr, AbLegacyStatusMapper.Good, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
// PR ablegacy-10 / #253 — successful array read.
|
||||
_diagnosticTags.RecordResponse(def.DeviceHostAddress);
|
||||
// PR ablegacy-12 / #255 — successful read clears the
|
||||
// consecutive-failure tally. We do NOT auto-clear DemotedUntilUtc
|
||||
// here — the demote window is honoured to its full duration so an
|
||||
// intermittent link that just happened to answer once doesn't
|
||||
// immediately re-flood the channel. Probe success is the early
|
||||
// recovery path.
|
||||
Interlocked.Exchange(ref device.ConsecutiveFailures, 0);
|
||||
break;
|
||||
}
|
||||
|
||||
// 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);
|
||||
snapshot = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
// PR ablegacy-10 / #253 — successful scalar / sub-element / bit read.
|
||||
_diagnosticTags.RecordResponse(def.DeviceHostAddress);
|
||||
// PR ablegacy-12 / #255 — successful read clears the
|
||||
// consecutive-failure tally; demote window keeps running
|
||||
// until a probe success or natural expiry.
|
||||
Interlocked.Exchange(ref device.ConsecutiveFailures, 0);
|
||||
break;
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Transient — exhaust retries before reporting BadCommunicationError.
|
||||
if (attempt < retries) continue;
|
||||
// PR ablegacy-10 / #253 — exhausted retries surface as a comm
|
||||
// failure. Pass libplctag status 0 because the throw means we never
|
||||
// got a status code back, but record the exception message so the
|
||||
// LastErrorMessage diagnostic still has actionable text.
|
||||
_diagnosticTags.RecordError(
|
||||
def.DeviceHostAddress,
|
||||
libplctagStatus: 0,
|
||||
errorMessage: ex.Message,
|
||||
commFailure: true);
|
||||
// PR ablegacy-12 / #255 — exception-driven comm failure counts
|
||||
// toward the demote tally just like a status-mapped one.
|
||||
if (RecordFailureAndMaybeDemote(device, now))
|
||||
{
|
||||
TransitionDeviceState(device, HostState.Demoted);
|
||||
}
|
||||
snapshot = new DataValueSnapshot(null,
|
||||
AbLegacyStatusMapper.BadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
results[i] = snapshot ?? new DataValueSnapshot(null,
|
||||
AbLegacyStatusMapper.BadCommunicationError, null, now);
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -186,7 +589,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 +635,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,22 +666,93 @@ 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);
|
||||
// PR 7 — array contiguous-block tags advertise IsArray + ArrayDim so the OPC UA
|
||||
// generic node-manager builds a 1-D array variable. ArrayLength on the tag
|
||||
// definition wins over the parsed `,N` / `[N]` suffix; both null = scalar.
|
||||
var arrayLen = tag.ArrayLength
|
||||
?? (parsed?.ArrayCount is int n && n > 1 ? n : (int?)null);
|
||||
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
||||
FullName: tag.Name,
|
||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: tag.Writable
|
||||
DriverDataType: effectiveType,
|
||||
IsArray: arrayLen is int al && al > 1,
|
||||
ArrayDim: arrayLen is int al2 && al2 > 1 ? (uint)al2 : null,
|
||||
SecurityClass: tag.Writable && !plcSetBit
|
||||
? SecurityClassification.Operate
|
||||
: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: tag.WriteIdempotent));
|
||||
}
|
||||
|
||||
// PR ablegacy-10 / #253 — auto-emit the per-device _Diagnostics folder + its
|
||||
// seven read-only counter variables. FullName carries the synthetic
|
||||
// _Diagnostics/<host>/<name> reference so ReadAsync can short-circuit before
|
||||
// EnsureTagRuntimeAsync. Mirrors AbCip's _System/ pattern from abcip-4.3.
|
||||
EmitDiagnosticsFolder(deviceFolder, device.HostAddress);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR ablegacy-10 / #253 — emit the per-device <c>_Diagnostics</c> folder + its
|
||||
/// seven read-only diagnostic-counter variables. The <c>FullName</c> on each
|
||||
/// variable encodes the owning device's host address
|
||||
/// (<c>_Diagnostics/<host>/<name></c>) so the read path can route to
|
||||
/// <see cref="AbLegacyDiagnosticTags.TryRead"/> without a separate registry. Names
|
||||
/// + types stay in lockstep with <see cref="AbLegacyDiagnosticTags.DiagnosticTagNames"/>.
|
||||
/// </summary>
|
||||
private static void EmitDiagnosticsFolder(IAddressSpaceBuilder deviceFolder, string deviceHostAddress)
|
||||
{
|
||||
var diag = deviceFolder.Folder("_Diagnostics", "_Diagnostics");
|
||||
EmitDiagnosticVariable(diag, deviceHostAddress, "RequestCount", DriverDataType.Int64,
|
||||
"Total ReadAsync requests issued against this device (one per non-diagnostic reference per call, success or fail).");
|
||||
EmitDiagnosticVariable(diag, deviceHostAddress, "ResponseCount", DriverDataType.Int64,
|
||||
"Successful read responses for this device.");
|
||||
EmitDiagnosticVariable(diag, deviceHostAddress, "ErrorCount", DriverDataType.Int64,
|
||||
"Failed read responses for this device (any non-Good status).");
|
||||
EmitDiagnosticVariable(diag, deviceHostAddress, "RetryCount", DriverDataType.Int64,
|
||||
"Retry attempts beyond the first per the AbLegacy retry loop. Bumps once per extra attempt — a single read with two retries adds two.");
|
||||
EmitDiagnosticVariable(diag, deviceHostAddress, "LastErrorCode", DriverDataType.Int32,
|
||||
"Most recent libplctag status code on a failed read; 0 when no error has been seen since the last reset.");
|
||||
EmitDiagnosticVariable(diag, deviceHostAddress, "LastErrorMessage", DriverDataType.String,
|
||||
"Most recent libplctag error message on a failed read; empty when no error has been seen since the last reset.");
|
||||
EmitDiagnosticVariable(diag, deviceHostAddress, "CommFailures", DriverDataType.Int64,
|
||||
"Count of read failures mapped to BadCommunicationError. Spans transient libplctag throws + retried-out chains so operators see a single 'wire fell off' counter.");
|
||||
// PR ablegacy-12 / #255 — auto-demote surface. DemoteCount is cumulative
|
||||
// across reinit (preserved in ReinitializeAsync); LastDemotedUtc is a
|
||||
// string (ISO-8601 UTC) so HMIs can bind directly without a separate
|
||||
// DateTime decoder. Empty string means "never demoted".
|
||||
EmitDiagnosticVariable(diag, deviceHostAddress, "DemoteCount", DriverDataType.Int64,
|
||||
"Cumulative auto-demote events for this device — bumps every time the driver crosses the consecutive-failure threshold and arms a fresh cool-down window. Survives ReinitializeAsync.");
|
||||
EmitDiagnosticVariable(diag, deviceHostAddress, "LastDemotedUtc", DriverDataType.String,
|
||||
"ISO-8601 UTC timestamp of the most recent auto-demotion; empty when this device has never been demoted.");
|
||||
}
|
||||
|
||||
private static void EmitDiagnosticVariable(
|
||||
IAddressSpaceBuilder folder, string deviceHostAddress, string name,
|
||||
DriverDataType type, string description)
|
||||
{
|
||||
var fullName = $"{AbLegacyDiagnosticTags.DiagnosticsFolderPrefix}{deviceHostAddress}/{name}";
|
||||
folder.Variable(name, name, new DriverAttributeInfo(
|
||||
FullName: fullName,
|
||||
DriverDataType: type,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
// Read-only — operators can't write the diagnostic surface from a SCADA template.
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false,
|
||||
Description: description));
|
||||
}
|
||||
|
||||
// ---- ISubscribable (polling overlay via shared engine) ----
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
@@ -282,13 +772,17 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
|
||||
private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
|
||||
{
|
||||
// PR 9 — per-device timeout wins over the probe's own timeout. Slow chassis (SLC 5/01
|
||||
// RS-232 ~5 s round-trip) need their per-device override to flow into the probe too,
|
||||
// otherwise the probe times out before the device ever has a chance to respond.
|
||||
var probeTimeout = state.Options.Timeout ?? _options.Probe.Timeout;
|
||||
var probeParams = new AbLegacyTagCreateParams(
|
||||
Gateway: state.ParsedAddress.Gateway,
|
||||
Port: state.ParsedAddress.Port,
|
||||
CipPath: state.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
|
||||
TagName: _options.Probe.ProbeAddress!,
|
||||
Timeout: _options.Probe.Timeout);
|
||||
Timeout: probeTimeout);
|
||||
|
||||
IAbLegacyTagRuntime? probeRuntime = null;
|
||||
while (!ct.IsCancellationRequested)
|
||||
@@ -313,7 +807,39 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
state.ProbeInitialized = false;
|
||||
}
|
||||
|
||||
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
|
||||
// PR ablegacy-12 / #255 — probe success is the early-recovery path: clear
|
||||
// any active demote window + reset the failure tally so the next read
|
||||
// dispatches normally. Probe failure participates in the same shared
|
||||
// failure-tally as ReadAsync so a device with no live read traffic still
|
||||
// demotes on a sustained outage.
|
||||
if (success)
|
||||
{
|
||||
bool wasDemoted;
|
||||
lock (state.ProbeLock)
|
||||
{
|
||||
wasDemoted = state.DemotedUntilUtc is not null;
|
||||
state.DemotedUntilUtc = null;
|
||||
}
|
||||
Interlocked.Exchange(ref state.ConsecutiveFailures, 0);
|
||||
TransitionDeviceState(state, HostState.Running);
|
||||
_ = wasDemoted; // intentionally observed for future telemetry hooks
|
||||
}
|
||||
else
|
||||
{
|
||||
if (RecordFailureAndMaybeDemote(state, DateTime.UtcNow))
|
||||
{
|
||||
TransitionDeviceState(state, HostState.Demoted);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Mid-tally probe failure: surface as Stopped if not already
|
||||
// Demoted. This preserves pre-PR-12 behaviour for the common
|
||||
// case (FailureThreshold=3 + a single hiccup ends up Stopped,
|
||||
// not Demoted).
|
||||
if (state.HostState != HostState.Demoted)
|
||||
TransitionDeviceState(state, HostState.Stopped);
|
||||
}
|
||||
}
|
||||
|
||||
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
@@ -394,7 +920,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
CipPath: device.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: parentName,
|
||||
Timeout: _options.Timeout));
|
||||
Timeout: ResolveTimeout(device)));
|
||||
try
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
@@ -413,17 +939,37 @@ 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.");
|
||||
|
||||
// PR 7 — resolve the effective array length: explicit ArrayLength override on the tag
|
||||
// definition wins over the parsed `,N` / `[N]` suffix. ElementCount of 1 means
|
||||
// single-element scalar (libplctag's default); >1 triggers the contiguous-block path.
|
||||
var elementCount = ResolveElementCount(def, parsed);
|
||||
// Drop the parsed array suffix from the libplctag tag name when ArrayLength overrides
|
||||
// it — libplctag would otherwise read the parsed length, not the override.
|
||||
var tagName = (def.ArrayLength is int && parsed.ArrayCount is not null)
|
||||
? (parsed with { ArrayCount = null }).ToLibplctagName()
|
||||
: parsed.ToLibplctagName();
|
||||
|
||||
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
CipPath: device.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: parsed.ToLibplctagName(),
|
||||
Timeout: _options.Timeout));
|
||||
TagName: tagName,
|
||||
Timeout: ResolveTimeout(device),
|
||||
ElementCount: elementCount));
|
||||
try
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
@@ -437,6 +983,54 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR 7 — pull <paramref name="elementCount"/> consecutive elements from a runtime that
|
||||
/// just completed a single contiguous-block read. Element type drives both the .NET
|
||||
/// array shape (Int32[] / Single[] / Boolean[]) and the per-index decoder routing.
|
||||
/// </summary>
|
||||
private static object DecodeArrayAs(IAbLegacyTagRuntime runtime, AbLegacyDataType type, int elementCount)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
AbLegacyDataType.Bit => BuildArray<bool>(runtime, type, elementCount),
|
||||
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => BuildArray<int>(runtime, type, elementCount),
|
||||
AbLegacyDataType.Long => BuildArray<int>(runtime, type, elementCount),
|
||||
AbLegacyDataType.Float => BuildArray<float>(runtime, type, elementCount),
|
||||
_ => throw new NotSupportedException(
|
||||
$"AbLegacyDataType {type} is not supported in array contiguous-block reads."),
|
||||
};
|
||||
}
|
||||
|
||||
private static T[] BuildArray<T>(IAbLegacyTagRuntime runtime, AbLegacyDataType type, int n)
|
||||
{
|
||||
var arr = new T[n];
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var element = runtime.DecodeArrayElement(type, i);
|
||||
arr[i] = (T)Convert.ChangeType(element!, typeof(T))!;
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR 7 — resolve the effective array element count for a tag. Explicit
|
||||
/// <see cref="AbLegacyTagDefinition.ArrayLength"/> on the tag definition wins; otherwise
|
||||
/// the parsed <see cref="AbLegacyAddress.ArrayCount"/> from the address suffix is used;
|
||||
/// otherwise 1 (scalar). Validates the override against the same PCCC frame ceiling
|
||||
/// enforced by the parser so config-overrides can't bypass the limit.
|
||||
/// </summary>
|
||||
internal static int ResolveElementCount(AbLegacyTagDefinition def, AbLegacyAddress parsed)
|
||||
{
|
||||
if (def.ArrayLength is int n)
|
||||
{
|
||||
if (n < 1 || n > AbLegacyAddress.MaxArrayCount)
|
||||
throw new InvalidOperationException(
|
||||
$"AbLegacy tag '{def.Name}' has ArrayLength {n}; expected 1..{AbLegacyAddress.MaxArrayCount}.");
|
||||
return n;
|
||||
}
|
||||
return parsed.ArrayCount ?? 1;
|
||||
}
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
@@ -470,6 +1064,25 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
public CancellationTokenSource? ProbeCts { get; set; }
|
||||
public bool ProbeInitialized { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// PR ablegacy-12 / #255 — running tally of consecutive read / probe failures.
|
||||
/// Reset on every successful read or probe; tripping
|
||||
/// <see cref="AbLegacyDemoteOptions.FailureThreshold"/> arms the demote window.
|
||||
/// Read + written via <see cref="Interlocked"/> because read + probe loops can
|
||||
/// touch it concurrently.
|
||||
/// </summary>
|
||||
public int ConsecutiveFailures;
|
||||
|
||||
/// <summary>
|
||||
/// PR ablegacy-12 / #255 — when set, reads against this device short-circuit
|
||||
/// with <c>BadCommunicationError</c> until the timestamp passes; cleared early
|
||||
/// by a successful probe. Guarded by <see cref="ProbeLock"/> for the mutator
|
||||
/// paths (TransitionDeviceState + RecordFailureAndMaybeDemote); reads grab
|
||||
/// the property without locking — a torn DateTime? read is harmless here
|
||||
/// because the worst case is one extra dispatched read on an x86 boundary.
|
||||
/// </summary>
|
||||
public DateTimeOffset? DemotedUntilUtc { get; set; }
|
||||
|
||||
public void DisposeRuntimes()
|
||||
{
|
||||
foreach (var r in Runtimes.Values) r.Dispose();
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Import;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
@@ -38,7 +42,17 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
$"AB Legacy config for '{driverInstanceId}' has a device missing HostAddress"),
|
||||
PlcFamily: ParseEnum<AbLegacyPlcFamily>(d.PlcFamily, driverInstanceId, "PlcFamily",
|
||||
fallback: AbLegacyPlcFamily.Slc500),
|
||||
DeviceName: d.DeviceName))]
|
||||
DeviceName: d.DeviceName,
|
||||
// PR 9 — per-device timeout / retry overrides. Device-level wins over driver-wide.
|
||||
Timeout: d.TimeoutMs is int devMs ? TimeSpan.FromMilliseconds(devMs) : null,
|
||||
Retries: d.Retries,
|
||||
// PR ablegacy-12 / #255 — auto-demote knobs.
|
||||
Demote: d.Demote is null ? null : new AbLegacyDemoteOptions(
|
||||
FailureThreshold: d.Demote.FailureThreshold ?? 3,
|
||||
DemoteFor: d.Demote.DemoteForMs is int demMs
|
||||
? TimeSpan.FromMilliseconds(demMs)
|
||||
: null,
|
||||
Enabled: d.Demote.Enabled ?? true)))]
|
||||
: [],
|
||||
Tags = dto.Tags is { Count: > 0 }
|
||||
? [.. dto.Tags.Select(t => new AbLegacyTagDefinition(
|
||||
@@ -51,7 +65,10 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
DataType: ParseEnum<AbLegacyDataType>(t.DataType, driverInstanceId, "DataType",
|
||||
tagName: t.Name),
|
||||
Writable: t.Writable ?? true,
|
||||
WriteIdempotent: t.WriteIdempotent ?? false))]
|
||||
WriteIdempotent: t.WriteIdempotent ?? false,
|
||||
ArrayLength: t.ArrayLength,
|
||||
AbsoluteDeadband: t.AbsoluteDeadband,
|
||||
PercentDeadband: t.PercentDeadband))]
|
||||
: [],
|
||||
Probe = new AbLegacyProbeOptions
|
||||
{
|
||||
@@ -61,11 +78,88 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
|
||||
},
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
|
||||
// PR 9 — driver-wide retry default. null ≡ 0 retries (single attempt). Per-device
|
||||
// Retries on AbLegacyDeviceOptions still wins.
|
||||
Retries = dto.Retries,
|
||||
};
|
||||
|
||||
return new AbLegacyDriver(options, driverInstanceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ablegacy-11 / #254 — append RSLogix CSV symbol-export rows to
|
||||
/// <paramref name="options"/> as <see cref="AbLegacyTagDefinition"/> entries bound to
|
||||
/// <paramref name="deviceHostAddress"/>. Returns a new <see cref="AbLegacyDriverOptions"/>
|
||||
/// with the imported tags concatenated onto the existing <c>Tags</c> list — useful both
|
||||
/// at startup-time (server-side bootstrap that wants to seed a device's address space
|
||||
/// from a customer-supplied CSV) and from the CLI (<c>import-rslogix</c> emits the
|
||||
/// resulting JSON fragment for hand-merging into an appsettings file).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The importer is permissive by default — malformed rows are logged and skipped;
|
||||
/// the resulting <see cref="RsLogixImportResult"/> counts surface on
|
||||
/// <paramref name="result"/> for callers that want to assert "we got the row count
|
||||
/// we expected".
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// RSLogix 500's <c>.RSS</c> + RSLogix 5's <c>.RSP</c> binary project files are
|
||||
/// out of scope for v1 — the binary format is proprietary and undocumented; no
|
||||
/// libplctag or community parser exists. Customers must export to text/CSV via
|
||||
/// RSLogix's "Tools → Database → Save" or "Database Export" before pointing the
|
||||
/// importer at the file. See <c>docs/drivers/AbLegacy-RSLogix-Import.md</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static AbLegacyDriverOptions AddRsLogixImport(
|
||||
this AbLegacyDriverOptions options,
|
||||
string path,
|
||||
string deviceHostAddress,
|
||||
out RsLogixImportResult result,
|
||||
ImportOptions? importOptions = null,
|
||||
ILogger<RsLogixSymbolImport>? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(deviceHostAddress);
|
||||
|
||||
using var stream = File.OpenRead(path);
|
||||
var importer = new RsLogixSymbolImport(logger ?? NullLogger<RsLogixSymbolImport>.Instance);
|
||||
result = importer.Parse(stream, deviceHostAddress, importOptions);
|
||||
|
||||
// Concat onto whatever's already on the options — the importer is additive so
|
||||
// hand-edited Tags rows (e.g., system-status fields not surfaced by RSLogix) keep
|
||||
// sitting alongside the bulk-imported symbol rows. Use init-syntax with-expression
|
||||
// so the returned options keeps every other field (Devices, Probe, Timeout, …)
|
||||
// untouched.
|
||||
var merged = new List<AbLegacyTagDefinition>(options.Tags.Count + result.Tags.Count);
|
||||
merged.AddRange(options.Tags);
|
||||
merged.AddRange(result.Tags);
|
||||
return new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = options.Devices,
|
||||
Tags = merged,
|
||||
Probe = options.Probe,
|
||||
Timeout = options.Timeout,
|
||||
Retries = options.Retries,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-friendly overload that returns the <see cref="RsLogixImportResult"/> alongside
|
||||
/// the modified options as a tuple. Mirrors <see cref="AddRsLogixImport"/> but avoids
|
||||
/// the <c>out</c> parameter for call sites that prefer pattern-matched destructuring.
|
||||
/// </summary>
|
||||
public static (AbLegacyDriverOptions Options, RsLogixImportResult Result) AddRsLogixImportWithResult(
|
||||
this AbLegacyDriverOptions options,
|
||||
string path,
|
||||
string deviceHostAddress,
|
||||
ImportOptions? importOptions = null,
|
||||
ILogger<RsLogixSymbolImport>? logger = null)
|
||||
{
|
||||
var updated = options.AddRsLogixImport(path, deviceHostAddress, out var result, importOptions, logger);
|
||||
return (updated, result);
|
||||
}
|
||||
|
||||
private static T ParseEnum<T>(string? raw, string driverInstanceId, string field,
|
||||
string? tagName = null, T? fallback = null) where T : struct, Enum
|
||||
{
|
||||
@@ -92,6 +186,12 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
internal sealed class AbLegacyDriverConfigDto
|
||||
{
|
||||
public int? TimeoutMs { get; init; }
|
||||
/// <summary>
|
||||
/// PR 9 — driver-wide retry count for transient <c>BadCommunicationError</c> reads.
|
||||
/// <c>null</c> ≡ <c>0</c> (single attempt). A per-device override on
|
||||
/// <see cref="AbLegacyDeviceDto.Retries"/> wins.
|
||||
/// </summary>
|
||||
public int? Retries { get; init; }
|
||||
public List<AbLegacyDeviceDto>? Devices { get; init; }
|
||||
public List<AbLegacyTagDto>? Tags { get; init; }
|
||||
public AbLegacyProbeDto? Probe { get; init; }
|
||||
@@ -102,6 +202,40 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
public string? HostAddress { get; init; }
|
||||
public string? PlcFamily { get; init; }
|
||||
public string? DeviceName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR 9 — optional per-device timeout in ms. Wins over the driver-wide
|
||||
/// <see cref="AbLegacyDriverConfigDto.TimeoutMs"/>. Tune this per chassis: SLC 5/01
|
||||
/// RS-232 ≈ 5000, SLC 5/05 ≈ 2000, MicroLogix 1100 ≈ 3000.
|
||||
/// </summary>
|
||||
public int? TimeoutMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR 9 — optional per-device retry count for transient <c>BadCommunicationError</c>
|
||||
/// reads. Wins over the driver-wide <see cref="AbLegacyDriverConfigDto.Retries"/>.
|
||||
/// <c>null</c> at both levels = single attempt.
|
||||
/// </summary>
|
||||
public int? Retries { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR ablegacy-12 / #255 — optional per-device auto-demote knobs. <c>null</c>
|
||||
/// means "use the documented defaults" (<c>FailureThreshold=3</c>,
|
||||
/// <c>DemoteFor=30s</c>, <c>Enabled=true</c>) — the driver still demotes by
|
||||
/// default. Set <c>Enabled=false</c> in the JSON to opt out entirely.
|
||||
/// </summary>
|
||||
public AbLegacyDemoteDto? Demote { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR ablegacy-12 / #255 — JSON DTO for the auto-demote knobs. Times are
|
||||
/// ms-suffixed for consistency with the rest of the driver config (TimeoutMs,
|
||||
/// IntervalMs).
|
||||
/// </summary>
|
||||
internal sealed class AbLegacyDemoteDto
|
||||
{
|
||||
public int? FailureThreshold { get; init; }
|
||||
public int? DemoteForMs { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AbLegacyTagDto
|
||||
@@ -112,6 +246,25 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
public string? DataType { get; init; }
|
||||
public bool? Writable { get; init; }
|
||||
public bool? WriteIdempotent { get; init; }
|
||||
/// <summary>
|
||||
/// PR 7 — optional override for the parsed array suffix. When set and > 1 the
|
||||
/// driver issues a single contiguous PCCC block read for N elements.
|
||||
/// </summary>
|
||||
public int? ArrayLength { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR 8 — optional absolute change filter for numeric tags. <c>OnDataChange</c> is
|
||||
/// suppressed unless <c>|new - prev| >= AbsoluteDeadband</c>. Booleans bypass;
|
||||
/// strings + status changes always publish.
|
||||
/// </summary>
|
||||
public double? AbsoluteDeadband { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR 8 — optional percent-of-previous change filter for numeric tags.
|
||||
/// <c>OnDataChange</c> is suppressed unless <c>|new - prev| >= |prev * Percent / 100|</c>.
|
||||
/// <c>prev == 0</c> always publishes (avoids division-by-zero).
|
||||
/// </summary>
|
||||
public double? PercentDeadband { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AbLegacyProbeDto
|
||||
|
||||
@@ -13,25 +13,94 @@ public sealed class AbLegacyDriverOptions
|
||||
public IReadOnlyList<AbLegacyDeviceOptions> Devices { get; init; } = [];
|
||||
public IReadOnlyList<AbLegacyTagDefinition> Tags { get; init; } = [];
|
||||
public AbLegacyProbeOptions Probe { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Driver-wide default per-operation timeout. Applies to every device unless that device
|
||||
/// overrides it via <see cref="AbLegacyDeviceOptions.Timeout"/> (PR 9).
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// PR 9 — driver-wide default retry count for transient
|
||||
/// <c>BadCommunicationError</c> reads. <c>null</c> ≡ <c>0</c> (single attempt). Applies
|
||||
/// to every device unless that device overrides it via
|
||||
/// <see cref="AbLegacyDeviceOptions.Retries"/>.
|
||||
/// </summary>
|
||||
public int? Retries { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-device options for the AB Legacy driver. PR 9 added optional <see cref="Timeout"/>
|
||||
/// and <see cref="Retries"/> overrides — chassis families have very different per-operation
|
||||
/// latency floors (SLC 5/01 RS-232 ~5 s; SLC 5/05 ~2 s; ML1100 ~3 s) so a single driver-wide
|
||||
/// timeout always misfires on at least one device. Both fields are optional and fall back
|
||||
/// to the driver-wide default on <see cref="AbLegacyDriverOptions"/>.
|
||||
/// </summary>
|
||||
public sealed record AbLegacyDeviceOptions(
|
||||
string HostAddress,
|
||||
AbLegacyPlcFamily PlcFamily = AbLegacyPlcFamily.Slc500,
|
||||
string? DeviceName = null);
|
||||
string? DeviceName = null,
|
||||
TimeSpan? Timeout = null,
|
||||
int? Retries = null,
|
||||
AbLegacyDemoteOptions? Demote = null);
|
||||
|
||||
/// <summary>
|
||||
/// One PCCC-backed OPC UA variable. <paramref name="Address"/> is the canonical PCCC
|
||||
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse"/>.
|
||||
/// PR ablegacy-12 / #255 — auto-demote knobs. After
|
||||
/// <see cref="FailureThreshold"/> consecutive read / probe failures the driver
|
||||
/// marks the device <c>Demoted</c> for <see cref="DemoteFor"/>; reads against
|
||||
/// a demoted device short-circuit with <c>BadCommunicationError</c> instead
|
||||
/// of dispatching through libplctag, so one slow PLC can't starve faster
|
||||
/// peers sharing the same driver. A successful probe clears the demotion
|
||||
/// early; a successful read just resets the consecutive-failure counter
|
||||
/// without leaving the demoted window.
|
||||
/// </summary>
|
||||
/// <param name="FailureThreshold">Consecutive read or probe failures that trip
|
||||
/// the demotion. Default <c>3</c>.</param>
|
||||
/// <param name="DemoteFor">Cool-down window before reads are dispatched again
|
||||
/// without a successful probe in between. Default <c>30s</c>.</param>
|
||||
/// <param name="Enabled">When <c>false</c> the failure tally still ticks but the
|
||||
/// driver never sets the demoted window — useful when an operator wants the
|
||||
/// diagnostic counters without the throttling behaviour.</param>
|
||||
public sealed record AbLegacyDemoteOptions(
|
||||
int FailureThreshold = 3,
|
||||
TimeSpan? DemoteFor = null,
|
||||
bool Enabled = true)
|
||||
{
|
||||
/// <summary>
|
||||
/// Effective demote window. Records can't have <c>TimeSpan</c> defaults
|
||||
/// because <c>TimeSpan.FromSeconds(30)</c> isn't a compile-time constant;
|
||||
/// callers that pass <c>null</c> get the documented 30-second default
|
||||
/// here.
|
||||
/// </summary>
|
||||
public TimeSpan EffectiveDemoteFor => DemoteFor ?? TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One PCCC-backed OPC UA variable. <c>Address</c> is the canonical PCCC file-address
|
||||
/// string that parses via <see cref="AbLegacyAddress.TryParse(string?)"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// PR 8 deadband fields:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>AbsoluteDeadband</c> — when set, suppresses <c>OnDataChange</c> for numeric
|
||||
/// tags unless <c>|new - prev| >= AbsoluteDeadband</c>.</item>
|
||||
/// <item><c>PercentDeadband</c> — when set, suppresses unless
|
||||
/// <c>|new - prev| >= |prev * Percent / 100|</c>; <c>prev == 0</c> always publishes.</item>
|
||||
/// </list>
|
||||
/// Booleans bypass deadband entirely (every transition publishes); strings + status
|
||||
/// changes always publish; first-seen always publishes; both set → logical-OR (Kepware
|
||||
/// semantics).
|
||||
/// </remarks>
|
||||
public sealed record AbLegacyTagDefinition(
|
||||
string Name,
|
||||
string DeviceHostAddress,
|
||||
string Address,
|
||||
AbLegacyDataType DataType,
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false);
|
||||
bool WriteIdempotent = false,
|
||||
int? ArrayLength = null,
|
||||
double? AbsoluteDeadband = null,
|
||||
double? PercentDeadband = null);
|
||||
|
||||
public sealed class AbLegacyProbeOptions
|
||||
{
|
||||
|
||||
@@ -7,14 +7,38 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
/// a direct-wired SLC 500 uses an empty path).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Parser duplicated from AbCipHostAddress rather than shared because the two drivers ship
|
||||
/// independently + a shared helper would force a reference between them. If a third AB
|
||||
/// driver appears, extract into Core.Abstractions.
|
||||
/// <para>Parser duplicated from AbCipHostAddress rather than shared because the two drivers
|
||||
/// ship independently + a shared helper would force a reference between them. If a third AB
|
||||
/// driver appears, extract into Core.Abstractions.</para>
|
||||
/// <para>PR ablegacy-13 / #256 — the optional <see cref="BackplaneSlot"/>,
|
||||
/// <see cref="DhPlusPort"/> and <see cref="DhPlusStation"/> fields are populated when the
|
||||
/// CIP path matches the canonical 1756-DHRIO bridge form <c>1,<slot>,2,<station></c>:
|
||||
/// port 1 (backplane) → DHRIO module slot → port 2 (DH+ side of the module) → DH+ node
|
||||
/// address. The DH+ station number is octal in PLC-5 firmware (0..77 = decimal 0..63);
|
||||
/// we parse it as octal and surface the decimal value for diagnostics. DHRIO bridging is
|
||||
/// PLC-5-only — the family-validation guard lives on the driver.</para>
|
||||
/// </remarks>
|
||||
public sealed record AbLegacyHostAddress(string Gateway, int Port, string CipPath)
|
||||
public sealed record AbLegacyHostAddress(
|
||||
string Gateway,
|
||||
int Port,
|
||||
string CipPath,
|
||||
int? BackplaneSlot = null,
|
||||
int? DhPlusPort = null,
|
||||
int? DhPlusStation = null)
|
||||
{
|
||||
public const int DefaultEipPort = 44818;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum chassis slot index accepted for the DHRIO bridge form. Real ControlLogix
|
||||
/// chassis are 4 / 7 / 10 / 13 / 17 slots; capping at 16 covers the largest standard
|
||||
/// 17-slot frame (slots 0..16) without rejecting anything an operator might legitimately
|
||||
/// configure. Tighter / family-specific bounds are enforced elsewhere.
|
||||
/// </summary>
|
||||
public const int MaxBackplaneSlot = 16;
|
||||
|
||||
/// <summary>True iff the CIP path was the canonical 1756-DHRIO DH+ bridge form.</summary>
|
||||
public bool IsDhPlusBridge => DhPlusStation is not null;
|
||||
|
||||
public override string ToString() => Port == DefaultEipPort
|
||||
? $"ab://{Gateway}/{CipPath}"
|
||||
: $"ab://{Gateway}:{Port}/{CipPath}";
|
||||
@@ -48,6 +72,73 @@ public sealed record AbLegacyHostAddress(string Gateway, int Port, string CipPat
|
||||
}
|
||||
if (string.IsNullOrEmpty(gateway)) return null;
|
||||
|
||||
// PR ablegacy-13 / #256 — optional DHRIO DH+ bridge path detection.
|
||||
// Shape: exactly four comma-separated decimal segments `port,slot,port,station` with
|
||||
// port[0]=1 (backplane), slot in [0..16], port[2]=2 (DH+), station octal 0..77.
|
||||
// Anything else is left as an opaque CIP path — direct-wired PLCs, MicroLogix empty
|
||||
// paths, longer multi-hop bridges all flow through unchanged.
|
||||
if (TryParseDhPlusBridge(cipPath, out var slot, out var dhPort, out var station))
|
||||
{
|
||||
return new AbLegacyHostAddress(gateway, port, cipPath,
|
||||
BackplaneSlot: slot,
|
||||
DhPlusPort: dhPort,
|
||||
DhPlusStation: station);
|
||||
}
|
||||
|
||||
return new AbLegacyHostAddress(gateway, port, cipPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> iff <paramref name="cipPath"/> is exactly the four-segment DHRIO
|
||||
/// bridge form <c>1,<slot>,2,<station></c>. Returns <c>false</c> for every
|
||||
/// other shape — including malformed near-misses (slot out of range, station out of
|
||||
/// octal range, port-1 ≠ backplane). A near-miss returns false rather than throwing so
|
||||
/// the caller can keep treating the CIP path as opaque.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method is the single source of truth for the DHRIO octal-station range check.
|
||||
/// Reuses the same "octal accepts only 0..7" rule as <c>AbLegacyAddress</c>'s PLC-5
|
||||
/// I/O parser — a leading <c>8</c> or <c>9</c> in any digit is rejected.
|
||||
/// </remarks>
|
||||
private static bool TryParseDhPlusBridge(
|
||||
string cipPath, out int slot, out int dhPort, out int station)
|
||||
{
|
||||
slot = 0;
|
||||
dhPort = 0;
|
||||
station = 0;
|
||||
|
||||
if (string.IsNullOrEmpty(cipPath)) return false;
|
||||
|
||||
// Reject leading / trailing whitespace inside segments — `1, 3, 2, 07` (with spaces)
|
||||
// is plausibly user typo but we keep the parser strict to avoid false positives.
|
||||
var parts = cipPath.Split(',');
|
||||
if (parts.Length != 4) return false;
|
||||
|
||||
if (!int.TryParse(parts[0], out var firstPort) || firstPort != 1) return false;
|
||||
if (!int.TryParse(parts[1], out slot) || slot < 0 || slot > MaxBackplaneSlot) return false;
|
||||
if (!int.TryParse(parts[2], out dhPort) || dhPort != 2) return false;
|
||||
if (!TryParseOctal(parts[3], out station) || station < 0 || station > 63) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a DH+ station number written in octal (0..77 octal = 0..63 decimal). Mirrors
|
||||
/// the octal-rule in <c>AbLegacyAddress.TryParseIndex</c> — digits 0..7 only, no sign,
|
||||
/// no prefix. Returns <c>false</c> for empty input, illegal digits (8/9), or non-digit
|
||||
/// characters.
|
||||
/// </summary>
|
||||
private static bool TryParseOctal(string text, out int value)
|
||||
{
|
||||
value = 0;
|
||||
if (string.IsNullOrEmpty(text)) return false;
|
||||
var acc = 0;
|
||||
foreach (var c in text)
|
||||
{
|
||||
if (c < '0' || c > '7') return false;
|
||||
acc = (acc * 8) + (c - '0');
|
||||
}
|
||||
value = acc;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,16 @@ public interface IAbLegacyTagRuntime : IDisposable
|
||||
int GetStatus();
|
||||
object? DecodeValue(AbLegacyDataType type, int? bitIndex);
|
||||
void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value);
|
||||
|
||||
/// <summary>
|
||||
/// PR 7 — decode element <paramref name="elementIndex"/> of an N-element contiguous
|
||||
/// block read. Implementations call the same per-element accessors used by
|
||||
/// <see cref="DecodeValue"/> at offset <c>elementIndex × elementBytes</c>. Default
|
||||
/// implementation throws so existing fakes that don't override remain explicit.
|
||||
/// </summary>
|
||||
object? DecodeArrayElement(AbLegacyDataType type, int elementIndex)
|
||||
=> throw new NotSupportedException(
|
||||
"Array decoding requires an IAbLegacyTagRuntime that overrides DecodeArrayElement.");
|
||||
}
|
||||
|
||||
public interface IAbLegacyTagFactory
|
||||
@@ -26,4 +36,5 @@ public sealed record AbLegacyTagCreateParams(
|
||||
string CipPath,
|
||||
string LibplctagPlcAttribute,
|
||||
string TagName,
|
||||
TimeSpan Timeout);
|
||||
TimeSpan Timeout,
|
||||
int ElementCount = 1);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Materialises <see cref="AbLegacyTagDefinition"/> entries from a RSLogix export. v1 ships
|
||||
/// a single implementation (<see cref="RsLogixSymbolImport"/>) for text/CSV "Database
|
||||
/// Export" — RSLogix 500's <c>.RSS</c> and RSLogix 5's <c>.RSP</c> binary project files are
|
||||
/// proprietary and out of scope (no parser ships with libplctag or any community library at
|
||||
/// the time of writing). The interface exists so a binary parser can slot in later without
|
||||
/// reshaping the call sites.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The <c>deviceHostAddress</c> parameter on <see cref="Parse"/> is required because RSLogix
|
||||
/// exports list addresses scoped to a single PLC; the importer needs to stamp every
|
||||
/// resulting tag with the gateway address that the runtime layer will use to reach
|
||||
/// it. Multi-device deployments call the importer once per device, then concatenate.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Parse"/> never throws on parse errors when
|
||||
/// <see cref="ImportOptions.IgnoreInvalid"/> is <c>true</c> (default) — malformed rows
|
||||
/// are skipped with a structured warning logged via the importer's <c>ILogger</c>, and
|
||||
/// the counts surface on <see cref="RsLogixImportResult"/>. With
|
||||
/// <see cref="ImportOptions.IgnoreInvalid"/> set to <c>false</c> the first malformed row
|
||||
/// throws <see cref="System.IO.InvalidDataException"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IRsLogixImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Read the entire <paramref name="stream"/> and emit one
|
||||
/// <see cref="AbLegacyTagDefinition"/> per recognised symbol row.
|
||||
/// </summary>
|
||||
/// <param name="stream">Open, readable stream over the RSLogix export. Caller owns it.</param>
|
||||
/// <param name="deviceHostAddress">
|
||||
/// Canonical AB Legacy gateway URI (<c>ab://host[:port]/cip-path</c>) the resulting
|
||||
/// tags should bind to.
|
||||
/// </param>
|
||||
/// <param name="options">Filter + safety knobs; <c>null</c> ≡ default options.</param>
|
||||
RsLogixImportResult Parse(Stream stream, string deviceHostAddress, ImportOptions? options = null);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Options that drive an <see cref="IRsLogixImporter"/> run. Captures the few knobs that
|
||||
/// reasonably differ between projects without forcing a dedicated subclass per import shape:
|
||||
/// scope filter (Global vs. Local:N), maximum rows to keep (defensive cap on suspicious
|
||||
/// exports), and whether to silently drop malformed rows or surface a parse exception.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="ScopeFilter"/> matches the optional <c>Scope</c> column on RSLogix CSV
|
||||
/// exports — "Global" tags live at the project root; "Local:1" / "Local:2" / etc. are
|
||||
/// scoped to ladder file 1, ladder file 2, etc. When non-null, only rows whose
|
||||
/// <c>Scope</c> value matches case-insensitively are emitted; rows with no <c>Scope</c>
|
||||
/// column are treated as Global.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="IgnoreInvalid"/> defaults to <c>true</c> — RSLogix exports tend to carry
|
||||
/// the occasional cosmetic row (single-letter alias, comment-only rows, blank lines)
|
||||
/// and the v1 contract is "import what we can, log a warning for everything else".
|
||||
/// Set to <c>false</c> to fail-fast on the first malformed row (useful for CI lint).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record ImportOptions(
|
||||
string? ScopeFilter = null,
|
||||
int? MaxRowsToImport = null,
|
||||
bool IgnoreInvalid = true);
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of a single <see cref="IRsLogixImporter"/> run. <see cref="Tags"/> carries the
|
||||
/// imported tag definitions ready to drop into <c>AbLegacyDriverOptions.Tags</c>;
|
||||
/// <see cref="ParsedCount"/>, <see cref="SkippedCount"/>, and <see cref="ErrorCount"/>
|
||||
/// give the operator a single line of telemetry ("imported 142 / skipped 3 / errored 0")
|
||||
/// suitable for either a CLI summary or a startup-time log line.
|
||||
/// </summary>
|
||||
public sealed record RsLogixImportResult(
|
||||
IReadOnlyList<AbLegacyTagDefinition> Tags,
|
||||
int ParsedCount,
|
||||
int SkippedCount,
|
||||
int ErrorCount);
|
||||
@@ -0,0 +1,325 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Materialises <see cref="AbLegacyTagDefinition"/> entries from RSLogix 500 / 5
|
||||
/// "Database Export" CSV. The expected column shape is
|
||||
/// <c>Symbol,Address,Description,DataType,Scope</c> — a slight superset of what RSLogix
|
||||
/// itself emits ("DataType" is RSLogix-supplied for symbol exports but ignored here in
|
||||
/// favour of the file-letter prefix on <c>Address</c>; it is left in the schema for
|
||||
/// forward-compatibility with editor tools that prefer to drive the type explicitly).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The parser is deliberately tolerant: header row + comment lines (starting with
|
||||
/// <c>;</c> or <c>#</c>) are skipped silently, headers are matched case-insensitively,
|
||||
/// and quoted fields handle embedded commas the way RFC 4180 prescribes ("foo,bar"
|
||||
/// → <c>foo,bar</c>; doubled quotes inside a quoted field collapse to a single
|
||||
/// literal quote).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Type resolution defers to <see cref="AbLegacyAddress.TryParse(string?)"/> +
|
||||
/// <see cref="TryResolveDataType"/> so the whole "what kind of file is N7?" knowledge
|
||||
/// lives in one place. Function-file (<c>RTC</c>, <c>HSC</c>, …) and structure-file
|
||||
/// (<c>PD</c>, <c>MG</c>, <c>PLS</c>, <c>BT</c>) prefixes are accepted but parsed
|
||||
/// conditionally on <see cref="PlcFamilies.AbLegacyPlcFamily"/>; for the import path
|
||||
/// we don't yet know the family so we use Slc500 as the parser context — that family
|
||||
/// covers every common letter <see cref="RsLogixSymbolImport"/> needs to classify.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="System.IO.InvalidDataException"/> surfaces only when
|
||||
/// <see cref="ImportOptions.IgnoreInvalid"/> is <c>false</c> — the default permissive
|
||||
/// path logs a warning per malformed row and bumps the <c>SkippedCount</c> /
|
||||
/// <c>ErrorCount</c> totals on <see cref="RsLogixImportResult"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RsLogixSymbolImport : IRsLogixImporter
|
||||
{
|
||||
private readonly ILogger<RsLogixSymbolImport> _logger;
|
||||
|
||||
public RsLogixSymbolImport() : this(NullLogger<RsLogixSymbolImport>.Instance) { }
|
||||
|
||||
public RsLogixSymbolImport(ILogger<RsLogixSymbolImport> logger)
|
||||
{
|
||||
_logger = logger ?? NullLogger<RsLogixSymbolImport>.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public RsLogixImportResult Parse(Stream stream, string deviceHostAddress, ImportOptions? options = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(deviceHostAddress);
|
||||
var opts = options ?? new ImportOptions();
|
||||
|
||||
var tags = new List<AbLegacyTagDefinition>();
|
||||
var parsed = 0;
|
||||
var skipped = 0;
|
||||
var errors = 0;
|
||||
|
||||
// detectEncodingFromByteOrderMarks=true honours UTF-8 BOMs (RSLogix tools on Windows
|
||||
// emit them often) without making the caller reach for a pre-decoded TextReader.
|
||||
// leaveOpen=true lets the caller manage the stream's lifecycle.
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
|
||||
|
||||
int? symbolIdx = null;
|
||||
int? addressIdx = null;
|
||||
int? descriptionIdx = null;
|
||||
int? dataTypeIdx = null;
|
||||
int? scopeIdx = null;
|
||||
var headerSeen = false;
|
||||
var lineNumber = 0;
|
||||
|
||||
string? line;
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
lineNumber++;
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
var trimmed = line.TrimStart();
|
||||
if (trimmed.StartsWith(';') || trimmed.StartsWith('#')) continue;
|
||||
|
||||
var fields = SplitCsv(line);
|
||||
if (fields.Count == 0) continue;
|
||||
|
||||
if (!headerSeen)
|
||||
{
|
||||
// First non-blank, non-comment row — treat as header. Map every column we
|
||||
// recognise; missing required columns short-circuit the whole run with a
|
||||
// single InvalidDataException because the failure is structural, not
|
||||
// per-row.
|
||||
for (var i = 0; i < fields.Count; i++)
|
||||
{
|
||||
var header = fields[i].Trim().ToLowerInvariant();
|
||||
switch (header)
|
||||
{
|
||||
case "symbol": symbolIdx = i; break;
|
||||
case "address": addressIdx = i; break;
|
||||
case "description": descriptionIdx = i; break;
|
||||
case "datatype":
|
||||
case "data type":
|
||||
case "type": dataTypeIdx = i; break;
|
||||
case "scope": scopeIdx = i; break;
|
||||
}
|
||||
}
|
||||
|
||||
if (symbolIdx is null || addressIdx is null)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"RSLogix import header at line {lineNumber} is missing required Symbol or Address column. " +
|
||||
$"Got: {string.Join(",", fields)}");
|
||||
}
|
||||
headerSeen = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opts.MaxRowsToImport is int cap && parsed >= cap)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"RSLogix import hit MaxRowsToImport={Cap} at line {LineNumber}; remaining rows skipped.",
|
||||
cap, lineNumber);
|
||||
break;
|
||||
}
|
||||
|
||||
// Per-row error scoping — we want a single bad row to skip cleanly without
|
||||
// dropping the rest of the file. The else branch in IgnoreInvalid=false mode
|
||||
// re-throws to surface the failure to the caller.
|
||||
try
|
||||
{
|
||||
// symbolIdx + addressIdx are guaranteed non-null past the header gate above.
|
||||
var symbol = SafeField(fields, symbolIdx!.Value);
|
||||
var address = SafeField(fields, addressIdx!.Value);
|
||||
var description = descriptionIdx.HasValue ? SafeField(fields, descriptionIdx.Value) : null;
|
||||
var scope = scopeIdx.HasValue ? SafeField(fields, scopeIdx.Value) : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(symbol) || string.IsNullOrWhiteSpace(address))
|
||||
{
|
||||
skipped++;
|
||||
_logger.LogWarning(
|
||||
"RSLogix CSV row at line {LineNumber} skipped — missing Symbol or Address (symbol='{Symbol}', address='{Address}').",
|
||||
lineNumber, symbol, address);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scope filter: row's Scope (or "Global" when blank) must match the filter
|
||||
// case-insensitively. RSLogix CSV scope values look like "Global" or
|
||||
// "Local:N" / "LOCAL:1" depending on the tool that emitted them.
|
||||
if (opts.ScopeFilter is { } wanted)
|
||||
{
|
||||
var actual = string.IsNullOrWhiteSpace(scope) ? "Global" : scope.Trim();
|
||||
if (!string.Equals(actual, wanted.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!TryResolveDataType(address.Trim(), out var dataType))
|
||||
{
|
||||
if (!opts.IgnoreInvalid)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"RSLogix CSV row at line {lineNumber} has unrecognised PCCC address '{address}'.");
|
||||
}
|
||||
errors++;
|
||||
_logger.LogWarning(
|
||||
"RSLogix CSV row at line {LineNumber} skipped — unrecognised PCCC address '{Address}'.",
|
||||
lineNumber, address);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Description column is parsed but currently unused — AbLegacyTagDefinition
|
||||
// doesn't carry a Description field today (the v2 schema ledger lives on the
|
||||
// server's metadata side of the bridge per #248). We retain the column in the
|
||||
// CSV header contract so a future schema bump can pick it up without breaking
|
||||
// existing exports. _ discard suppresses the unused-local warning.
|
||||
_ = description;
|
||||
tags.Add(new AbLegacyTagDefinition(
|
||||
Name: symbol.Trim(),
|
||||
DeviceHostAddress: deviceHostAddress,
|
||||
Address: address.Trim(),
|
||||
DataType: dataType,
|
||||
Writable: true));
|
||||
parsed++;
|
||||
}
|
||||
catch (InvalidDataException) when (opts.IgnoreInvalid)
|
||||
{
|
||||
errors++;
|
||||
_logger.LogWarning("RSLogix CSV row at line {LineNumber} skipped — invalid data.", lineNumber);
|
||||
}
|
||||
catch (Exception ex) when (opts.IgnoreInvalid)
|
||||
{
|
||||
errors++;
|
||||
_logger.LogWarning(ex, "RSLogix CSV row at line {LineNumber} skipped — parser threw.", lineNumber);
|
||||
}
|
||||
}
|
||||
|
||||
if (!headerSeen)
|
||||
{
|
||||
// Empty CSV (only blanks / comments) — return an empty result rather than
|
||||
// surface a "no header found" error. The CLI will report parsed=0 which is the
|
||||
// honest answer.
|
||||
return new RsLogixImportResult([], 0, skipped, errors);
|
||||
}
|
||||
|
||||
return new RsLogixImportResult(tags, parsed, skipped, errors);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a PCCC <paramref name="address"/> to the matching
|
||||
/// <see cref="AbLegacyDataType"/>. Returns <c>false</c> for unparsable addresses.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The mapping follows the file-letter table on
|
||||
/// <see cref="AbLegacyAddress"/> doc comments:
|
||||
/// N→Int, F→Float, B→Bit, L→Long, ST→String, T→TimerElement, C→CounterElement,
|
||||
/// R→ControlElement, A→AnalogInt, S/I/O→Int (status / I/O bits resolve as Bit when
|
||||
/// the address carries a <c>/N</c> bit suffix), PD→PidElement, MG→MessageElement,
|
||||
/// PLS→PlsElement, BT→BlockTransferElement, function-file letters (RTC/HSC/etc.) →
|
||||
/// MicroLogixFunctionFile.
|
||||
/// </remarks>
|
||||
public static bool TryResolveDataType(string address, out AbLegacyDataType dataType)
|
||||
{
|
||||
dataType = AbLegacyDataType.Int;
|
||||
// Use Slc500 as the parser family — it accepts every common letter the importer
|
||||
// sees in the wild. Family-specific gating (PLC-5 octal I:/O:, PD/MG/PLS/BT) only
|
||||
// matters for runtime addressing, not for shape classification at import time.
|
||||
var parsed = AbLegacyAddress.TryParse(address, PlcFamilies.AbLegacyPlcFamily.Plc5)
|
||||
?? AbLegacyAddress.TryParse(address, PlcFamilies.AbLegacyPlcFamily.Slc500)
|
||||
?? AbLegacyAddress.TryParse(address, PlcFamilies.AbLegacyPlcFamily.MicroLogix);
|
||||
if (parsed is null) return false;
|
||||
|
||||
var letter = parsed.FileLetter;
|
||||
// Bit-within-word references on N/L/I/O/S files surface as Bit regardless of the
|
||||
// base file type. B-file references with no bit suffix are rare in real exports
|
||||
// but still classify as Bit (the wire-level element is a single word — Rockwell
|
||||
// convention is one bool per word).
|
||||
if (parsed.BitIndex is not null)
|
||||
{
|
||||
dataType = AbLegacyDataType.Bit;
|
||||
return true;
|
||||
}
|
||||
|
||||
dataType = letter switch
|
||||
{
|
||||
"N" => AbLegacyDataType.Int,
|
||||
"F" => AbLegacyDataType.Float,
|
||||
"B" => AbLegacyDataType.Bit,
|
||||
"L" => AbLegacyDataType.Long,
|
||||
"ST" => AbLegacyDataType.String,
|
||||
"T" => AbLegacyDataType.TimerElement,
|
||||
"C" => AbLegacyDataType.CounterElement,
|
||||
"R" => AbLegacyDataType.ControlElement,
|
||||
"A" => AbLegacyDataType.AnalogInt,
|
||||
"I" or "O" or "S" => AbLegacyDataType.Int,
|
||||
"PD" => AbLegacyDataType.PidElement,
|
||||
"MG" => AbLegacyDataType.MessageElement,
|
||||
"PLS" => AbLegacyDataType.PlsElement,
|
||||
"BT" => AbLegacyDataType.BlockTransferElement,
|
||||
_ when AbLegacyAddress.IsFunctionFileLetter(letter) => AbLegacyDataType.MicroLogixFunctionFile,
|
||||
_ => AbLegacyDataType.Int,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string SafeField(IReadOnlyList<string> fields, int idx) =>
|
||||
idx >= 0 && idx < fields.Count ? fields[idx] : string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// RFC 4180-ish CSV splitter — quoted fields, doubled-quote escape, embedded comma
|
||||
/// inside quoted fields. Avoids a third-party CSV dependency for a five-column
|
||||
/// parser.
|
||||
/// </summary>
|
||||
internal static List<string> SplitCsv(string line)
|
||||
{
|
||||
var fields = new List<string>();
|
||||
var sb = new StringBuilder(line.Length);
|
||||
var inQuotes = false;
|
||||
for (var i = 0; i < line.Length; i++)
|
||||
{
|
||||
var c = line[i];
|
||||
if (inQuotes)
|
||||
{
|
||||
if (c == '"')
|
||||
{
|
||||
// Doubled quote inside a quoted field is a literal `"`; otherwise the
|
||||
// quote terminates the quoted segment.
|
||||
if (i + 1 < line.Length && line[i + 1] == '"')
|
||||
{
|
||||
sb.Append('"');
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
inQuotes = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(c);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
switch (c)
|
||||
{
|
||||
case '"':
|
||||
inQuotes = true;
|
||||
break;
|
||||
case ',':
|
||||
fields.Add(sb.ToString());
|
||||
sb.Clear();
|
||||
break;
|
||||
default:
|
||||
sb.Append(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
fields.Add(sb.ToString());
|
||||
return fields;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -23,6 +32,11 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
Name = p.TagName,
|
||||
Timeout = p.Timeout,
|
||||
};
|
||||
// PR 7 — array contiguous-block reads. Setting ElementCount tells libplctag to allocate
|
||||
// a buffer covering N consecutive PCCC words (one frame, up to ~120 elements). The
|
||||
// driver decodes element-by-element through DecodeArrayElement after a single ReadAsync.
|
||||
if (p.ElementCount > 1)
|
||||
_tag.ElementCount = p.ElementCount;
|
||||
}
|
||||
|
||||
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
|
||||
@@ -40,8 +54,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,18 +101,63 @@ 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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR 7 — decode element <paramref name="elementIndex"/> of an N-element contiguous
|
||||
/// PCCC block read. Element width is fixed per data type: Int / AnalogInt / Bit-as-word
|
||||
/// are 16-bit (2 bytes/element), Long / Float are 32-bit (4 bytes/element). Mirrors the
|
||||
/// non-array decoder shape but at byte offset <c>elementIndex × elementBytes</c>.
|
||||
/// </summary>
|
||||
public object? DecodeArrayElement(AbLegacyDataType type, int elementIndex)
|
||||
{
|
||||
if (elementIndex < 0) throw new ArgumentOutOfRangeException(nameof(elementIndex));
|
||||
return type switch
|
||||
{
|
||||
// Bit / N-array reads — Rockwell convention is one BOOL per word (e.g. `B3:0,10`
|
||||
// returns 10 BOOLs, not 160 individual bits). Each word is non-zero → true.
|
||||
AbLegacyDataType.Bit => _tag.GetInt16(elementIndex * 2) != 0,
|
||||
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => (int)_tag.GetInt16(elementIndex * 2),
|
||||
AbLegacyDataType.Long => _tag.GetInt32(elementIndex * 4),
|
||||
AbLegacyDataType.Float => _tag.GetFloat32(elementIndex * 4),
|
||||
// String + element types are out-of-scope for PR 7 array reads — the PCCC layer's
|
||||
// 240-byte frame ceiling means an ST array would only fit a couple of strings, and
|
||||
// sub-element arrays (`T4:0,5.ACC`) are rejected at parse time. Surface a clear
|
||||
// error if the driver mis-routes us here.
|
||||
_ => throw new NotSupportedException(
|
||||
$"AbLegacyDataType {type} cannot be decoded as a contiguous array element."),
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose() => _tag.Dispose();
|
||||
|
||||
private static PlcType MapPlcType(string attribute) => attribute switch
|
||||
|
||||
@@ -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,47 @@ 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);
|
||||
|
||||
/// <remarks>
|
||||
/// PR ablegacy-13 / #256 — PLC-5 is the only family that supports the 1756-DHRIO DH+
|
||||
/// bridging form (CIP path <c>1,<slot>,2,<station-octal></c>). The DHRIO
|
||||
/// module only speaks DH+ to PLC-5 / SLC-DH+ peers, and libplctag's PCCC stack only
|
||||
/// exposes the PLC-5 side. SLC500 / MicroLogix / LogixPccc devices addressed through a
|
||||
/// DHRIO path are rejected at <c>AbLegacyDriver.InitializeAsync</c> time. See
|
||||
/// <c>docs/drivers/AbLegacy-DH-Bridging.md</c> for the full DH+ syntax + smoke procedure.
|
||||
/// </remarks>
|
||||
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 +83,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>
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
Decision #41 — AbLegacy split from AbCip since PCCC addressing (file-based N7:0) and
|
||||
Logix addressing (symbolic Motor1.Speed) pull the abstraction in incompatible directions. -->
|
||||
<PackageReference Include="libplctag" Version="1.5.2"/>
|
||||
<!-- ablegacy-11 / #254 — RsLogixSymbolImport logs warnings for malformed CSV rows
|
||||
via ILogger so import-time issues surface in Serilog without making the importer
|
||||
throw. Abstractions only — runtime sink is the host's responsibility. -->
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -38,7 +38,11 @@ public sealed class WriteCommand : FocasCommandBase
|
||||
Address: Address,
|
||||
DataType: DataType,
|
||||
Writable: true);
|
||||
var options = BuildOptions([tag]);
|
||||
// The CLI is a per-invocation operator tool; it bypasses the server-side
|
||||
// FocasDriverOptions.Writes.Enabled gate by enabling writes locally for this single
|
||||
// process. Configure-the-server code paths still respect the safer-by-default flag —
|
||||
// see docs/Driver.FOCAS.Cli.md "Writes" subsection (issue #268, plan PR F4-a).
|
||||
var options = BuildOptions([tag], writesEnabled: true);
|
||||
|
||||
var parsed = ParseValue(Value, DataType);
|
||||
|
||||
|
||||
@@ -26,6 +26,20 @@ public abstract class FocasCommandBase : DriverCommandBase
|
||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 2000).")]
|
||||
public int TimeoutMs { get; init; } = 2000;
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-d (issue #271) — optional CNC connection-level password emitted
|
||||
/// via <c>cnc_wrunlockparam</c> on connect. Required only by controllers that
|
||||
/// gate <c>cnc_wrparam</c> + selected reads behind a password switch.
|
||||
/// PASSWORD INVARIANT: never logged. The CLI's Serilog config does not
|
||||
/// include this option in any console / file destructure; the redaction is
|
||||
/// enforced at the <see cref="FocasDeviceOptions"/> layer (record's
|
||||
/// overridden <c>ToString</c>).
|
||||
/// </summary>
|
||||
[CommandOption("cnc-password", Description =
|
||||
"Optional CNC connection password emitted via cnc_wrunlockparam on connect. " +
|
||||
"Required by controllers that gate parameter writes behind a password switch.")]
|
||||
public string? CncPassword { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan Timeout
|
||||
{
|
||||
@@ -41,17 +55,26 @@ public abstract class FocasCommandBase : DriverCommandBase
|
||||
/// + the tag list a subclass supplies. Probe disabled; the default
|
||||
/// <see cref="FwlibFocasClientFactory"/> attempts <c>Fwlib32.dll</c> P/Invoke, which
|
||||
/// throws <see cref="DllNotFoundException"/> at first call when the DLL is absent —
|
||||
/// surfaced through the driver as <c>BadCommunicationError</c>.
|
||||
/// surfaced through the driver as <c>BadCommunicationError</c>. Pass
|
||||
/// <paramref name="writesEnabled"/> = <c>true</c> to bypass the F4-a driver-level
|
||||
/// write gate for the lifetime of this CLI invocation (issue #268).
|
||||
/// </summary>
|
||||
protected FocasDriverOptions BuildOptions(IReadOnlyList<FocasTagDefinition> tags) => new()
|
||||
protected FocasDriverOptions BuildOptions(
|
||||
IReadOnlyList<FocasTagDefinition> tags, bool writesEnabled = false) => new()
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(
|
||||
HostAddress: HostAddress,
|
||||
DeviceName: $"cli-{CncHost}:{CncPort}",
|
||||
Series: Series)],
|
||||
Series: Series,
|
||||
OverrideParameters: null,
|
||||
// PR F4-d (issue #271) — thread the CLI's --cnc-password through to
|
||||
// the driver. Null when the operator didn't supply the flag — the
|
||||
// driver short-circuits the unlock call in that case.
|
||||
Password: CncPassword)],
|
||||
Tags = tags,
|
||||
Timeout = Timeout,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
Writes = new FocasWritesOptions { Enabled = writesEnabled },
|
||||
};
|
||||
|
||||
protected string DriverInstanceId => $"focas-cli-{CncHost}:{CncPort}";
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #267 (plan PR F3-a) — projects FANUC CNC alarms onto the OPC UA alarm surface
|
||||
/// via <see cref="IAlarmSource"/>. Two modes:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="FocasAlarmProjectionMode.ActiveOnly"/> (default) — only
|
||||
/// currently-active alarms surface. Subscribe / unsubscribe / acknowledge wire up,
|
||||
/// but no history poll runs. This is the conservative mode operators get when
|
||||
/// they don't explicitly opt into history.</item>
|
||||
/// <item><see cref="FocasAlarmProjectionMode.ActivePlusHistory"/> — additionally
|
||||
/// polls <c>cnc_rdalmhistry</c> on connect and on every
|
||||
/// <see cref="FocasAlarmProjectionOptions.HistoryPollInterval"/> tick. Each
|
||||
/// previously-unseen entry fires an <c>OnAlarmEvent</c> with
|
||||
/// <c>SourceTimestampUtc</c> set from the CNC's reported timestamp (not Now)
|
||||
/// so OPC UA dashboards see the real occurrence time.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Dedup</b> — an in-memory <see cref="HashSet{T}"/> keyed on
|
||||
/// <c>(OccurrenceTime, AlarmNumber, AlarmType)</c> tracks every entry the projection has
|
||||
/// emitted. The same triple across two polls only emits once. The set resets on reconnect
|
||||
/// — first poll after reconnect re-emits everything in the ring buffer; OPC UA clients
|
||||
/// that care about exactly-once semantics dedupe on their side via the
|
||||
/// timestamp + number + type tuple.</para>
|
||||
///
|
||||
/// <para><b>HistoryDepth clamp</b> — user-supplied depth is bounded to
|
||||
/// <c>[1..<see cref="FocasAlarmProjectionOptions.MaxHistoryDepth"/>]</c> so an operator
|
||||
/// who types <c>10000</c> by accident doesn't blow up the wire session. The clamp lives
|
||||
/// in <see cref="ResolveDepth"/>.</para>
|
||||
///
|
||||
/// <para><b>Active alarms</b> — first cut surfaces history only. Active alarms (raise +
|
||||
/// clear via <c>cnc_rdalmmsg</c>/<c>cnc_rdalmmsg2</c>) are a follow-up; this projection's
|
||||
/// subscribe path returns a handle but does not poll for active alarms today. The
|
||||
/// ActiveOnly mode therefore is functionally a no-op subscribe — the IAlarmSource
|
||||
/// contract still wires up so capability negotiation works + a future PR can add the
|
||||
/// active-alarm poll without reshaping the projection. The plan deliberately scopes F3-a
|
||||
/// to the history extension; the active poll lands as F3-b.</para>
|
||||
/// </remarks>
|
||||
internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||
{
|
||||
private readonly Func<CancellationToken, Task<IFocasClient?>> _connectAsync;
|
||||
private readonly Action<AlarmEventArgs> _emit;
|
||||
private readonly FocasAlarmProjectionOptions _options;
|
||||
private readonly string _diagnosticPrefix;
|
||||
|
||||
private readonly Dictionary<long, Subscription> _subs = new();
|
||||
private readonly Lock _subsLock = new();
|
||||
private long _nextId;
|
||||
|
||||
/// <summary>
|
||||
/// Dedup set across the entire projection — alarm history is per-CNC, not
|
||||
/// per-subscription, so a single set across all subscriptions matches operator
|
||||
/// intent (one CNC, one ring buffer, one set of history events even if multiple
|
||||
/// OPC UA clients have subscribed).
|
||||
/// </summary>
|
||||
private readonly HashSet<DedupKey> _seen = new();
|
||||
private readonly Lock _seenLock = new();
|
||||
|
||||
public FocasAlarmProjection(
|
||||
FocasAlarmProjectionOptions options,
|
||||
Func<CancellationToken, Task<IFocasClient?>> connectAsync,
|
||||
Action<AlarmEventArgs> emit,
|
||||
string diagnosticPrefix = "focas-alarm-sub")
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(connectAsync);
|
||||
ArgumentNullException.ThrowIfNull(emit);
|
||||
_options = options;
|
||||
_connectAsync = connectAsync;
|
||||
_emit = emit;
|
||||
_diagnosticPrefix = diagnosticPrefix;
|
||||
}
|
||||
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextId);
|
||||
var handle = new FocasAlarmSubscriptionHandle(id, _diagnosticPrefix);
|
||||
|
||||
if (_options.Mode != FocasAlarmProjectionMode.ActivePlusHistory)
|
||||
{
|
||||
// ActiveOnly — return the handle so capability negotiation works, but skip the
|
||||
// history poll entirely. The active-alarm poll lands as a follow-up PR.
|
||||
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
|
||||
}
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var sub = new Subscription(handle, [..sourceNodeIds], cts);
|
||||
lock (_subsLock) _subs[id] = sub;
|
||||
sub.Loop = Task.Run(() => RunHistoryPollAsync(sub, cts.Token), cts.Token);
|
||||
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
|
||||
}
|
||||
|
||||
public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is not FocasAlarmSubscriptionHandle h) return;
|
||||
Subscription? sub;
|
||||
lock (_subsLock)
|
||||
{
|
||||
if (!_subs.Remove(h.Id, out sub)) return;
|
||||
}
|
||||
try { await sub.Cts.CancelAsync().ConfigureAwait(false); } catch { }
|
||||
try { await sub.Loop.ConfigureAwait(false); } catch { }
|
||||
sub.Cts.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledge stub — FANUC's history surface is read-only (the ring buffer only
|
||||
/// records what the CNC has cleared internally), so per-history-entry ack is a no-op.
|
||||
/// A future PR may extend the active-alarm flow with a per-CNC reset call.
|
||||
/// </summary>
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
List<Subscription> snap;
|
||||
lock (_subsLock) { snap = _subs.Values.ToList(); _subs.Clear(); }
|
||||
foreach (var sub in snap)
|
||||
{
|
||||
try { await sub.Cts.CancelAsync().ConfigureAwait(false); } catch { }
|
||||
try { await sub.Loop.ConfigureAwait(false); } catch { }
|
||||
sub.Cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reset the dedup set — used after reconnect so the next history poll re-emits
|
||||
/// everything in the ring buffer. Public for tests + the driver's reconnect hook.
|
||||
/// </summary>
|
||||
public void ResetDedup()
|
||||
{
|
||||
lock (_seenLock) _seen.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pull one history snapshot + emit unseen entries. Extracted from the timer loop so
|
||||
/// unit tests can drive a single tick without standing up Task.Run.
|
||||
/// </summary>
|
||||
internal async Task<int> PollOnceAsync(Subscription sub, CancellationToken ct)
|
||||
{
|
||||
var client = await _connectAsync(ct).ConfigureAwait(false);
|
||||
if (client is null) return 0;
|
||||
|
||||
var depth = ResolveDepth(_options.HistoryDepth);
|
||||
IReadOnlyList<FocasAlarmHistoryEntry> entries;
|
||||
try
|
||||
{
|
||||
entries = await client.ReadAlarmHistoryAsync(depth, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Per-tick failure — leave dedup intact, next tick retries. Matches the
|
||||
// AbCip alarm projection's "non-fatal per-tick" pattern (#177).
|
||||
return 0;
|
||||
}
|
||||
|
||||
var emitted = 0;
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var key = new DedupKey(entry.OccurrenceTime, entry.AlarmNumber, entry.AlarmType);
|
||||
bool added;
|
||||
lock (_seenLock) added = _seen.Add(key);
|
||||
if (!added) continue;
|
||||
|
||||
// Each subscription gets its own copy of the event — multiple OPC UA clients
|
||||
// can subscribe + each sees the historic events through their own subscription
|
||||
// handle. Source node id is the first declared id (sub.SourceNodeIds[0]) when
|
||||
// present; empty subscriptions get a synthetic "alarm-history" id so the
|
||||
// event still threads through the IAlarmSource contract cleanly.
|
||||
var sourceNodeId = sub.SourceNodeIds.Count > 0 ? sub.SourceNodeIds[0] : "alarm-history";
|
||||
|
||||
_emit(new AlarmEventArgs(
|
||||
SubscriptionHandle: sub.Handle,
|
||||
SourceNodeId: sourceNodeId,
|
||||
ConditionId: $"focas-history#{entry.AlarmType}-{entry.AlarmNumber}-{entry.OccurrenceTime:O}",
|
||||
AlarmType: $"FOCAS_T{entry.AlarmType}",
|
||||
Message: BuildMessage(entry),
|
||||
Severity: AlarmSeverity.High,
|
||||
SourceTimestampUtc: entry.OccurrenceTime.UtcDateTime));
|
||||
emitted++;
|
||||
}
|
||||
return emitted;
|
||||
}
|
||||
|
||||
private async Task RunHistoryPollAsync(Subscription sub, CancellationToken ct)
|
||||
{
|
||||
// First poll fires immediately on subscribe (== "on connect" per F3-a) so operators
|
||||
// get history dashboard data without waiting for the cadence to elapse.
|
||||
try { await PollOnceAsync(sub, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
||||
catch { /* swallowed in PollOnceAsync; defensive double-catch */ }
|
||||
|
||||
var interval = _options.HistoryPollInterval > TimeSpan.Zero
|
||||
? _options.HistoryPollInterval
|
||||
: FocasAlarmProjectionOptions.DefaultHistoryPollInterval;
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
|
||||
try { await PollOnceAsync(sub, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
|
||||
catch { /* per-tick failures are non-fatal */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bound user-requested depth to <c>[1..MaxHistoryDepth]</c>. <c>0</c>/negative
|
||||
/// values fall back to <see cref="FocasAlarmProjectionOptions.DefaultHistoryDepth"/>
|
||||
/// so misconfigured options still pull a reasonable batch.
|
||||
/// </summary>
|
||||
internal static int ResolveDepth(int requested)
|
||||
{
|
||||
if (requested <= 0) return FocasAlarmProjectionOptions.DefaultHistoryDepth;
|
||||
return Math.Min(requested, FocasAlarmProjectionOptions.MaxHistoryDepth);
|
||||
}
|
||||
|
||||
private static string BuildMessage(FocasAlarmHistoryEntry entry)
|
||||
{
|
||||
if (string.IsNullOrEmpty(entry.Message))
|
||||
return $"FOCAS alarm T{entry.AlarmType} #{entry.AlarmNumber}";
|
||||
return $"FOCAS T{entry.AlarmType} #{entry.AlarmNumber}: {entry.Message}";
|
||||
}
|
||||
|
||||
/// <summary>Composite dedup key — see class-level remarks.</summary>
|
||||
private readonly record struct DedupKey(DateTimeOffset OccurrenceTime, int AlarmNumber, int AlarmType);
|
||||
|
||||
internal sealed class Subscription
|
||||
{
|
||||
public Subscription(FocasAlarmSubscriptionHandle handle, IReadOnlyList<string> sourceNodeIds, CancellationTokenSource cts)
|
||||
{
|
||||
Handle = handle; SourceNodeIds = sourceNodeIds; Cts = cts;
|
||||
}
|
||||
public FocasAlarmSubscriptionHandle Handle { get; }
|
||||
public IReadOnlyList<string> SourceNodeIds { get; }
|
||||
public CancellationTokenSource Cts { get; }
|
||||
public Task Loop { get; set; } = Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Handle returned by <see cref="FocasAlarmProjection.SubscribeAsync"/>.</summary>
|
||||
public sealed record FocasAlarmSubscriptionHandle(long Id, string DiagnosticPrefix) : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => $"{DiagnosticPrefix}-{Id}";
|
||||
}
|
||||
@@ -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
@@ -62,7 +62,13 @@ public static class FocasDriverFactoryExtensions
|
||||
HostAddress: d.HostAddress ?? throw new InvalidOperationException(
|
||||
$"FOCAS config for '{driverInstanceId}' has a device missing HostAddress"),
|
||||
DeviceName: d.DeviceName,
|
||||
Series: ParseSeries(d.Series ?? dto.Series)))]
|
||||
Series: ParseSeries(d.Series ?? dto.Series),
|
||||
OverrideParameters: null,
|
||||
// Plan PR F4-d (issue #271) — optional CNC password for cnc_wrunlockparam.
|
||||
// The DTO carries it through JSON config round-trip; the driver-layer
|
||||
// record overrides ToString to redact (no-log invariant). See
|
||||
// docs/v2/focas-deployment.md § "FOCAS password handling".
|
||||
Password: d.Password))]
|
||||
: [],
|
||||
Tags = dto.Tags is { Count: > 0 }
|
||||
? [.. dto.Tags.Select(t => new FocasTagDefinition(
|
||||
@@ -73,7 +79,10 @@ public static class FocasDriverFactoryExtensions
|
||||
Address: t.Address ?? throw new InvalidOperationException(
|
||||
$"FOCAS tag '{t.Name}' in '{driverInstanceId}' missing Address"),
|
||||
DataType: ParseDataType(t.DataType, t.Name!, driverInstanceId),
|
||||
Writable: t.Writable ?? true,
|
||||
// Per-tag Writable defaults to false post-F4-a (issue #268). A config-DB row
|
||||
// with Writable null means "not opted in" — operators must explicitly flip
|
||||
// the flag per tag before writes flow.
|
||||
Writable: t.Writable ?? false,
|
||||
WriteIdempotent: t.WriteIdempotent ?? false))]
|
||||
: [],
|
||||
Probe = new FocasProbeOptions
|
||||
@@ -83,6 +92,24 @@ public static class FocasDriverFactoryExtensions
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
|
||||
},
|
||||
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 2_000),
|
||||
// Driver-level write opt-in (issue #268, plan PR F4-a). Default false — config rows
|
||||
// that omit the section keep the safer-by-default read-only posture; flipping it on
|
||||
// requires an explicit deployment-time choice.
|
||||
Writes = new FocasWritesOptions
|
||||
{
|
||||
Enabled = dto.Writes?.Enabled ?? false,
|
||||
// Plan PR F4-b (issue #269) — granular kill-switches on top of Enabled.
|
||||
// Default false: even with Enabled=true the operator must explicitly opt
|
||||
// into parameter and macro writes per kind. A bare Writes section with
|
||||
// just { Enabled: true } keeps PARAM/MACRO writes locked.
|
||||
AllowParameter = dto.Writes?.AllowParameter ?? false,
|
||||
AllowMacro = dto.Writes?.AllowMacro ?? false,
|
||||
// Plan PR F4-c (issue #270) — granular kill-switch for pmc_wrpmcrng.
|
||||
// Default false: PMC is ladder working memory; a mistargeted bit can
|
||||
// move motion or latch a feedhold so the operator team must explicitly
|
||||
// opt in even with Enabled=true.
|
||||
AllowPmc = dto.Writes?.AllowPmc ?? false,
|
||||
},
|
||||
};
|
||||
|
||||
var clientFactory = BuildClientFactory(dto, driverInstanceId);
|
||||
@@ -170,6 +197,30 @@ public static class FocasDriverFactoryExtensions
|
||||
public List<FocasDeviceDto>? Devices { get; init; }
|
||||
public List<FocasTagDto>? Tags { get; init; }
|
||||
public FocasProbeDto? Probe { get; init; }
|
||||
public FocasWritesDto? Writes { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class FocasWritesDto
|
||||
{
|
||||
public bool? Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-b (issue #269). Default false — see
|
||||
/// <see cref="FocasWritesOptions.AllowParameter"/>.
|
||||
/// </summary>
|
||||
public bool? AllowParameter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-b (issue #269). Default false — see
|
||||
/// <see cref="FocasWritesOptions.AllowMacro"/>.
|
||||
/// </summary>
|
||||
public bool? AllowMacro { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-c (issue #270). Default false — see
|
||||
/// <see cref="FocasWritesOptions.AllowPmc"/>.
|
||||
/// </summary>
|
||||
public bool? AllowPmc { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class FocasDeviceDto
|
||||
@@ -177,6 +228,20 @@ public static class FocasDriverFactoryExtensions
|
||||
public string? HostAddress { get; init; }
|
||||
public string? DeviceName { get; init; }
|
||||
public string? Series { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-d (issue #271) — optional CNC connection-level password emitted
|
||||
/// via <c>cnc_wrunlockparam</c> on connect. Required only by controllers that
|
||||
/// gate <c>cnc_wrparam</c> + selected reads behind a password switch. The
|
||||
/// driver maps <c>EW_PASSWD</c> -> <c>BadUserAccessDenied</c> and re-issues
|
||||
/// unlock + retries the gated call once on that mapping.
|
||||
/// <para><b>No-log invariant:</b> never logged through the driver. The host
|
||||
/// <c>FocasDeviceOptions</c> record overrides <c>ToString</c> to redact this
|
||||
/// field. Stored in <c>appsettings.json</c> alongside the rest of the device
|
||||
/// config; treat as a secret per <c>docs/v2/focas-deployment.md</c>
|
||||
/// § "FOCAS password handling" + cross-link to <c>docs/Security.md</c>.</para>
|
||||
/// </summary>
|
||||
public string? Password { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class FocasTagDto
|
||||
|
||||
@@ -11,29 +11,253 @@ 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>
|
||||
/// Alarm projection knobs (issue #267, plan PR F3-a). Default mode is
|
||||
/// <see cref="FocasAlarmProjectionMode.ActiveOnly"/> — the projection only surfaces
|
||||
/// currently-active alarms. Operators who want the on-CNC ring-buffer history
|
||||
/// replayed as historic OPC UA events (so dashboards see the real CNC timestamp,
|
||||
/// not the moment the projection polled) flip this to
|
||||
/// <see cref="FocasAlarmProjectionMode.ActivePlusHistory"/>.
|
||||
/// </summary>
|
||||
public FocasAlarmProjectionOptions AlarmProjection { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Driver-level write opt-in (issue #268, plan PR F4-a). Defaults to
|
||||
/// <c>Enabled = false</c> — the driver short-circuits every <c>IWritable.WriteAsync</c>
|
||||
/// call to <see cref="FocasStatusMapper.BadNotWritable"/> until the deployment explicitly
|
||||
/// flips this on. Combined with the per-tag <see cref="FocasTagDefinition.Writable"/>
|
||||
/// gate (also default-off), every CNC write requires two opt-ins.
|
||||
/// </summary>
|
||||
public FocasWritesOptions Writes { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Driver-level write controls (issue #268, plan PR F4-a). Per the F4-a decision record
|
||||
/// writes ship behind a flag with a safe default: an operator who pulls the FOCAS driver
|
||||
/// into production without touching <c>Writes.Enabled</c> gets read-only behaviour, and
|
||||
/// even with the flag flipped on each individual tag must still set
|
||||
/// <see cref="FocasTagDefinition.Writable"/> = <c>true</c>.
|
||||
/// </summary>
|
||||
public sealed record FocasWritesOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Driver-level master switch. Default <c>false</c> — every write returns
|
||||
/// <see cref="FocasStatusMapper.BadNotWritable"/> with the status text
|
||||
/// <c>"writes disabled at driver level"</c>.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #269, plan PR F4-b — granular kill-switch for <c>cnc_wrparam</c>
|
||||
/// parameter writes (defense in depth on top of <see cref="Enabled"/> and the
|
||||
/// per-tag <see cref="FocasTagDefinition.Writable"/>). Default <c>false</c>: an
|
||||
/// operator who flips <see cref="Enabled"/> on without explicitly opting into
|
||||
/// parameter writes still gets <see cref="FocasStatusMapper.BadNotWritable"/>
|
||||
/// for every <c>PARAM:</c> tag. A misdirected parameter write can put the CNC
|
||||
/// in a bad state, so the third opt-in keeps the blast radius bounded.
|
||||
/// <para>Server-layer ACL: <c>PARAM:</c> tags additionally surface a
|
||||
/// <see cref="Core.Abstractions.SecurityClassification.Configure"/> classification
|
||||
/// so the OPC UA gate requires <c>WriteConfigure</c> group membership; this
|
||||
/// flag is the driver-level kill switch the operator team can flip without a
|
||||
/// redeploy.</para>
|
||||
/// </summary>
|
||||
public bool AllowParameter { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #269, plan PR F4-b — granular kill-switch for <c>cnc_wrmacro</c> macro
|
||||
/// variable writes (defense in depth on top of <see cref="Enabled"/> and the
|
||||
/// per-tag <see cref="FocasTagDefinition.Writable"/>). Default <c>false</c>:
|
||||
/// macro writes are gated separately from parameter writes because they're a
|
||||
/// normal HMI-driven recipe / setpoint surface where parameter writes are
|
||||
/// mostly emergency commissioning territory.
|
||||
/// <para>Server-layer ACL: <c>MACRO:</c> tags surface
|
||||
/// <see cref="Core.Abstractions.SecurityClassification.Operate"/> so the OPC UA
|
||||
/// gate requires <c>WriteOperate</c> group membership.</para>
|
||||
/// </summary>
|
||||
public bool AllowMacro { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #270, plan PR F4-c — granular kill-switch for <c>pmc_wrpmcrng</c> PMC
|
||||
/// range writes (and the bit-level read-modify-write that wraps it). Default
|
||||
/// <c>false</c>: PMC is ladder working memory — a mistargeted bit can move
|
||||
/// motion, latch a feedhold, or flip a safety interlock. Even with
|
||||
/// <see cref="Enabled"/> on and a tag's <see cref="FocasTagDefinition.Writable"/>
|
||||
/// flag flipped on, PMC writes stay locked until this third opt-in fires.
|
||||
/// <para>Server-layer ACL: PMC tags surface
|
||||
/// <see cref="Core.Abstractions.SecurityClassification.Operate"/> so the OPC UA
|
||||
/// gate requires <c>WriteOperate</c> group membership; this flag is the driver-
|
||||
/// level kill switch the operator team can flip without a redeploy.</para>
|
||||
/// </summary>
|
||||
public bool AllowPmc { get; init; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mode for the FOCAS alarm projection (issue #267, plan PR F3-a). Default
|
||||
/// <see cref="ActiveOnly"/> matches today's behaviour — only currently-active
|
||||
/// alarms surface as OPC UA events. <see cref="ActivePlusHistory"/> additionally
|
||||
/// polls <c>cnc_rdalmhistry</c> on connect + on a configurable cadence and emits the
|
||||
/// ring-buffer entries as historic events, deduped by <c>(OccurrenceTime, AlarmNumber,
|
||||
/// AlarmType)</c> so a polled entry never re-fires.
|
||||
/// </summary>
|
||||
public enum FocasAlarmProjectionMode
|
||||
{
|
||||
/// <summary>Surface only currently-active CNC alarms. No history poll. Default.</summary>
|
||||
ActiveOnly = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Surface active alarms plus the on-CNC ring-buffer history. The projection
|
||||
/// polls <c>cnc_rdalmhistry</c> on connect and on
|
||||
/// <see cref="FocasAlarmProjectionOptions.HistoryPollInterval"/> ticks afterward.
|
||||
/// Each new entry (keyed by <c>(OccurrenceTime, AlarmNumber, AlarmType)</c>)
|
||||
/// fires an <see cref="Core.Abstractions.IAlarmSource.OnAlarmEvent"/> with
|
||||
/// <c>SourceTimestampUtc</c> set from the CNC's reported timestamp, not Now.
|
||||
/// </summary>
|
||||
ActivePlusHistory = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FOCAS alarm-projection knobs (issue #267, plan PR F3-a). Carries the mode switch +
|
||||
/// the cadence / depth tuning for the <c>cnc_rdalmhistry</c> poll loop. Defaults match
|
||||
/// "operator dashboard with five-minute refresh" — the single most common deployment
|
||||
/// shape per the F3-a deployment doc.
|
||||
/// </summary>
|
||||
public sealed record FocasAlarmProjectionOptions
|
||||
{
|
||||
/// <summary>Default poll interval — 5 minutes. Matches dashboard-class cadences.</summary>
|
||||
public static readonly TimeSpan DefaultHistoryPollInterval = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Default ring-buffer depth requested per poll — <c>100</c>. Most FANUC controllers
|
||||
/// keep ~100 entries by default; pulling the full depth on every poll keeps the
|
||||
/// dedup set authoritative across reconnects without burning extra wire bandwidth on
|
||||
/// entries the dedup key would discard anyway.
|
||||
/// </summary>
|
||||
public const int DefaultHistoryDepth = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Hard ceiling on <see cref="HistoryDepth"/>. The projection clamps user-requested
|
||||
/// depths above this value down — typical CNC ring buffers cap well below this and
|
||||
/// letting an operator type <c>10000</c> by accident shouldn't take down the wire
|
||||
/// session with a giant <c>cnc_rdalmhistry</c> request.
|
||||
/// </summary>
|
||||
public const int MaxHistoryDepth = 250;
|
||||
|
||||
/// <summary>Active-only (default) vs Active-plus-history. See <see cref="FocasAlarmProjectionMode"/>.</summary>
|
||||
public FocasAlarmProjectionMode Mode { get; init; } = FocasAlarmProjectionMode.ActiveOnly;
|
||||
|
||||
/// <summary>
|
||||
/// Cadence at which the projection re-polls <c>cnc_rdalmhistry</c> when
|
||||
/// <see cref="Mode"/> is <see cref="FocasAlarmProjectionMode.ActivePlusHistory"/>.
|
||||
/// Default <see cref="DefaultHistoryPollInterval"/> = 5 minutes. Only applies after
|
||||
/// the on-connect poll fires.
|
||||
/// </summary>
|
||||
public TimeSpan HistoryPollInterval { get; init; } = DefaultHistoryPollInterval;
|
||||
|
||||
/// <summary>
|
||||
/// Number of most-recent ring-buffer entries to request per poll. Clamped to
|
||||
/// <c>[1..<see cref="MaxHistoryDepth"/>]</c> at projection startup so misconfigured
|
||||
/// values can't hammer the CNC. Default <see cref="DefaultHistoryDepth"/> = 100.
|
||||
/// </summary>
|
||||
public int HistoryDepth { get; init; } = DefaultHistoryDepth;
|
||||
}
|
||||
|
||||
/// <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).
|
||||
/// <paramref name="Password"/> (issue #271, plan PR F4-d) is the CNC connection-level
|
||||
/// password emitted via <c>cnc_wrunlockparam</c> on connect when the controller
|
||||
/// gates parameter writes / certain reads behind a password switch (16i + some
|
||||
/// 30i firmwares with parameter-protect on).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>No-log invariant:</b> <see cref="Password"/> is a secret. The driver MUST NOT
|
||||
/// log it. <c>FocasDeviceOptions.ToString()</c> would include the field by default
|
||||
/// because it's a positional record member, so the record's auto-generated
|
||||
/// <c>ToString</c> is overridden via <see cref="PrintMembers"/> below to redact
|
||||
/// the password. Any new logging surface that touches <see cref="FocasDeviceOptions"/>
|
||||
/// must continue to redact. See <c>docs/v2/focas-deployment.md</c> § "FOCAS password
|
||||
/// handling" for the no-log invariant and rotation runbook.</para>
|
||||
/// </remarks>
|
||||
public sealed record FocasDeviceOptions(
|
||||
string HostAddress,
|
||||
string? DeviceName = null,
|
||||
FocasCncSeries Series = FocasCncSeries.Unknown);
|
||||
FocasCncSeries Series = FocasCncSeries.Unknown,
|
||||
FocasOverrideParameters? OverrideParameters = null,
|
||||
string? Password = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Issue #271 (plan PR F4-d) — record auto-generated <c>ToString</c> would print
|
||||
/// <see cref="Password"/> verbatim. Override the printer so the secret is replaced
|
||||
/// with <c>"***"</c> when the field is non-null. The no-log invariant relies on
|
||||
/// this — every Serilog destructure that flows a <see cref="FocasDeviceOptions"/>
|
||||
/// value through <c>{Device}</c> gets redaction for free.
|
||||
/// </summary>
|
||||
private bool PrintMembers(System.Text.StringBuilder builder)
|
||||
{
|
||||
builder.Append("HostAddress = ").Append(HostAddress);
|
||||
builder.Append(", DeviceName = ").Append(DeviceName);
|
||||
builder.Append(", Series = ").Append(Series);
|
||||
builder.Append(", OverrideParameters = ").Append(OverrideParameters);
|
||||
builder.Append(", Password = ").Append(Password is null ? "<null>" : "***");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <remarks>
|
||||
/// <paramref name="Writable"/> defaults to <c>false</c> per issue #268 / plan PR F4-a — a
|
||||
/// newly-onboarded tag is read-only until the deployment explicitly opts it in, matching
|
||||
/// the driver-level <see cref="FocasWritesOptions.Enabled"/> safer-by-default posture.
|
||||
/// <paramref name="WriteIdempotent"/> is plumbed through the
|
||||
/// <see cref="Core.Resilience.CapabilityInvoker.ExecuteWriteAsync"/> retry path at the
|
||||
/// server layer (see <see cref="Core.Abstractions.WriteIdempotentAttribute"/>); a
|
||||
/// <c>true</c> value lets the Polly pipeline retry on transient failures while
|
||||
/// <c>false</c> (the default) disables retry per decisions #44/#45.
|
||||
/// </remarks>
|
||||
public sealed record FocasTagDefinition(
|
||||
string Name,
|
||||
string DeviceHostAddress,
|
||||
string Address,
|
||||
FocasDataType DataType,
|
||||
bool Writable = true,
|
||||
bool Writable = false,
|
||||
bool WriteIdempotent = false);
|
||||
|
||||
public sealed class FocasProbeOptions
|
||||
|
||||
@@ -19,6 +19,16 @@ public static class FocasStatusMapper
|
||||
public const uint BadTimeout = 0x800A0000u;
|
||||
public const uint BadTypeMismatch = 0x80730000u;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA <c>BadUserAccessDenied</c>. Surfaced when the CNC reports
|
||||
/// <c>EW_PASSWD</c> (parameter-write switch off, MDI mode required, etc.) — the
|
||||
/// deployment must escalate the operator's session to satisfy the write gate.
|
||||
/// Plan PR F4-d will land the unlock workflow that lets operators flip the gate
|
||||
/// from the OPC UA side; F4-b just maps the status code so clients can branch
|
||||
/// on it. (Plan PR F4-b, issue #269.)
|
||||
/// </summary>
|
||||
public const uint BadUserAccessDenied = 0x801F0000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map common FWLIB <c>EW_*</c> return codes. The values below match Fanuc's published
|
||||
/// numeric conventions (EW_OK=0, EW_FUNC=1, EW_NUMBER=3, EW_LENGTH=4, EW_ATTRIB=7,
|
||||
@@ -37,7 +47,7 @@ public static class FocasStatusMapper
|
||||
7 => BadTypeMismatch, // EW_ATTRIB
|
||||
8 => BadNodeIdUnknown, // EW_DATA — invalid data address
|
||||
9 => BadCommunicationError, // EW_PARITY
|
||||
11 => BadNotWritable, // EW_PASSWD
|
||||
11 => BadUserAccessDenied, // EW_PASSWD — parameter-write switch off / unlock required (F4-d)
|
||||
-1 => BadDeviceFailure, // EW_BUSY
|
||||
-8 => BadInternalError, // EW_HANDLE — CNC handle not available
|
||||
-9 => BadNotSupported, // EW_VERSION — FWLIB vs CNC version mismatch
|
||||
|
||||
@@ -48,6 +48,47 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-d (issue #271) — emit <c>cnc_wrunlockparam</c> to lift the
|
||||
/// CNC's parameter-protect / read-protect gate. The password is ASCII-encoded
|
||||
/// into a 4-byte buffer (right-padded with <c>0x00</c>; truncated when the
|
||||
/// supplied string exceeds 4 chars — Fanuc's published password buffer is a
|
||||
/// fixed 4-byte slot). Mismatch surfaces as <c>EW_PASSWD</c> mapped to
|
||||
/// <see cref="FocasStatusMapper.BadUserAccessDenied"/>; the F4-d retry loop
|
||||
/// in <see cref="FocasDriver"/> re-issues unlock + retries the call once on
|
||||
/// that mapping.
|
||||
/// <para><b>No-log invariant:</b> the password is NOT logged from this method
|
||||
/// and never appears in any exception message. The caller logs only "FOCAS
|
||||
/// unlock applied for {host}" (no password). See <c>FocasDeviceOptions.Password</c>.</para>
|
||||
/// </summary>
|
||||
public Task UnlockAsync(string password, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected)
|
||||
throw new InvalidOperationException(
|
||||
"FOCAS UnlockAsync called before Connect — handle is not yet open.");
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Fixed 4-byte buffer (FOCAS password slot). Right-pad with 0x00; truncate
|
||||
// longer inputs. Truncation is a deployment-error not a runtime concern —
|
||||
// the password the operator put in appsettings.json must already match the
|
||||
// controller's slot exactly. We don't surface a different error code for
|
||||
// length mismatch because the controller will reject with EW_PASSWD anyway.
|
||||
var buf = new byte[4];
|
||||
var pwd = password ?? string.Empty;
|
||||
var bytes = System.Text.Encoding.ASCII.GetBytes(pwd);
|
||||
Array.Copy(bytes, 0, buf, 0, Math.Min(bytes.Length, buf.Length));
|
||||
|
||||
var ret = FwlibNative.WrUnlockParam(_handle, buf);
|
||||
if (ret != 0)
|
||||
{
|
||||
// Note: deliberately do NOT include `password` in the exception message —
|
||||
// exceptions get logged. The error code is enough for diagnosis.
|
||||
throw new InvalidOperationException(
|
||||
$"FWLIB cnc_wrunlockparam failed with EW_{ret}.");
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -59,10 +100,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)
|
||||
{
|
||||
@@ -74,6 +125,10 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
FocasAreaKind.Pmc when type == FocasDataType.Bit && address.BitIndex is int =>
|
||||
await WritePmcBitAsync(address, Convert.ToBoolean(value), cancellationToken).ConfigureAwait(false),
|
||||
FocasAreaKind.Pmc => WritePmc(address, type, value),
|
||||
// PR F4-b (issue #269) — route through the typed WriteParameterAsync /
|
||||
// WriteMacroAsync entry points so the driver-level dispatch can apply the
|
||||
// granular Writes.AllowParameter / Writes.AllowMacro gates without re-parsing
|
||||
// the address kind.
|
||||
FocasAreaKind.Parameter => WriteParameter(address, type, value),
|
||||
FocasAreaKind.Macro => WriteMacro(address, value),
|
||||
_ => FocasStatusMapper.BadNotSupported,
|
||||
@@ -81,8 +136,38 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-modify-write one bit within a PMC byte. Acquires a per-byte semaphore so
|
||||
/// concurrent bit writes against the same byte serialise and neither loses its update.
|
||||
/// Plan PR F4-b (issue #269) — typed parameter-write entry point. Backed by the
|
||||
/// same <see cref="WriteParameter"/> helper as the kind-dispatched
|
||||
/// <see cref="WriteAsync"/> path, so unit tests that go through the typed entry
|
||||
/// point exercise the same wire encoding the production read/write loop hits.
|
||||
/// </summary>
|
||||
public Task<uint> WriteParameterAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult(FocasStatusMapper.BadCommunicationError);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(WriteParameter(address, type, value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-b (issue #269) — typed macro-write entry point. Today
|
||||
/// <see cref="WriteMacro"/> writes integer-only with
|
||||
/// <c>decimalPointCount = 0</c>; a follow-up <c>WriteMacroScaled</c> overload
|
||||
/// can land if fractional macro setpoints become a field requirement.
|
||||
/// </summary>
|
||||
public Task<uint> WriteMacroAsync(
|
||||
FocasAddress address, object? value, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult(FocasStatusMapper.BadCommunicationError);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(WriteMacro(address, value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-modify-write one bit within a PMC byte (Plan PR F4-c, issue #270).
|
||||
/// Acquires a per-byte semaphore so concurrent bit writes against the same
|
||||
/// byte serialise and neither loses its update. The wire call is byte-addressed
|
||||
/// so we read the parent byte, mask the target bit, then write the byte back.
|
||||
/// </summary>
|
||||
private async Task<uint> WritePmcBitAsync(
|
||||
FocasAddress address, bool newValue, CancellationToken cancellationToken)
|
||||
@@ -109,19 +194,8 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
? (byte)(current | (1 << bit))
|
||||
: (byte)(current & ~(1 << bit));
|
||||
|
||||
// Write the updated byte.
|
||||
var writeBuf = new FwlibNative.IODBPMC
|
||||
{
|
||||
TypeA = addrType,
|
||||
TypeD = FocasPmcDataType.Byte,
|
||||
DatanoS = (ushort)address.Number,
|
||||
DatanoE = (ushort)address.Number,
|
||||
Data = new byte[40],
|
||||
};
|
||||
writeBuf.Data[0] = updated;
|
||||
|
||||
var writeRet = FwlibNative.PmcWrPmcRng(_handle, 8 + 1, ref writeBuf);
|
||||
return writeRet == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(writeRet);
|
||||
// Write the updated byte via pmc_wrpmcrng (1-byte range).
|
||||
return WritePmcRange(addrType, address.Number, new[] { updated });
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -129,6 +203,72 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-c (issue #270) — typed PMC-range write entry point. Writes a
|
||||
/// contiguous run of bytes via <c>pmc_wrpmcrng</c>. The FWLIB <c>IODBPMC.Data</c>
|
||||
/// payload caps at ~40 bytes so larger ranges are chunked into 32-byte
|
||||
/// sub-calls, mirroring the read-side <see cref="ReadPmcRangeAsync"/> shape.
|
||||
/// </summary>
|
||||
public Task<uint> WritePmcRangeAsync(
|
||||
string letter, int pathId, int startByte, byte[] bytes, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult(FocasStatusMapper.BadCommunicationError);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (bytes is null || bytes.Length == 0) return Task.FromResult(FocasStatusMapper.Good);
|
||||
var addrType = FocasPmcAddrType.FromLetter(letter)
|
||||
?? throw new InvalidOperationException($"Unknown PMC letter '{letter}'.");
|
||||
return Task.FromResult(WritePmcRange(addrType, startByte, bytes));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synchronous PMC range write helper — chunked at 32 bytes so each
|
||||
/// <c>pmc_wrpmcrng</c> call fits inside the FWLIB <c>IODBPMC.Data</c> 40-byte
|
||||
/// window (8-byte header + 32-byte payload). Stops on the first non-zero
|
||||
/// EW_* return so a partial write doesn't claim Good.
|
||||
/// </summary>
|
||||
private uint WritePmcRange(short addrType, int startByte, byte[] bytes)
|
||||
{
|
||||
const int chunkBytes = 32;
|
||||
var offset = 0;
|
||||
while (offset < bytes.Length)
|
||||
{
|
||||
var thisChunk = Math.Min(chunkBytes, bytes.Length - offset);
|
||||
var writeBuf = new FwlibNative.IODBPMC
|
||||
{
|
||||
TypeA = addrType,
|
||||
TypeD = FocasPmcDataType.Byte,
|
||||
DatanoS = (ushort)(startByte + offset),
|
||||
DatanoE = (ushort)(startByte + offset + thisChunk - 1),
|
||||
Data = new byte[40],
|
||||
};
|
||||
Array.Copy(bytes, offset, writeBuf.Data, 0, thisChunk);
|
||||
var ret = FwlibNative.PmcWrPmcRng(_handle, (ushort)(8 + thisChunk), ref writeBuf);
|
||||
if (ret != 0) return FocasStatusMapper.MapFocasReturn(ret);
|
||||
offset += thisChunk;
|
||||
}
|
||||
return FocasStatusMapper.Good;
|
||||
}
|
||||
|
||||
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 +277,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 +555,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 +643,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();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user