Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 746c5decfe | |||
| b217ca61ce | |||
| 26708b6609 | |||
| c88e0b6bed | |||
| 3babfb8a99 | |||
| 30c3b10c94 | |||
| e0f3d1c925 | |||
| 108f69d198 | |||
| f7e0d9a9e7 | |||
| 705c98ad98 | |||
| 35d733d73b | |||
| 0adc5adb59 | |||
| 7cbc566db9 | |||
| c36903d6a0 | |||
| 2ee61c0999 | |||
| e3d7c65f61 | |||
| 45770e8d90 | |||
| 399257377b | |||
| 08a4db2952 | |||
| 1e3053c0d8 | |||
| 8ee65a75d2 | |||
| 9e157fc8a4 | |||
| 258ce8e937 | |||
| 561b0f9ea9 | |||
| 349aa5c6f4 | |||
| 0444cb699d | |||
| da6e19d07d | |||
| baf1d65875 | |||
| c9e28b881e | |||
| 5f8d84db43 | |||
| 7e62a1158f | |||
| a908dff7b5 | |||
| ac3fd45cc6 | |||
| 5c72deb839 | |||
| 9a3bc08e1c | |||
| 86f3fc2733 | |||
| d676b4056d | |||
| 54c09d4d5d |
+33
-6
@@ -183,19 +183,46 @@ otopcua-cli historyread -u opc.tcp://localhost:4840/OtOpcUa \
|
||||
| `--start` | Start time, ISO 8601 or date string (default: 24 hours ago) |
|
||||
| `--end` | End time, ISO 8601 or date string (default: now) |
|
||||
| `--max` | Maximum number of values (default: 1000) |
|
||||
| `--aggregate` | Aggregate function: Average, Minimum, Maximum, Count, Start, End |
|
||||
| `--aggregate` | Aggregate function name (see catalog below). Case-insensitive. |
|
||||
| `--interval` | Processing interval in milliseconds for aggregates (default: 3600000) |
|
||||
|
||||
#### Aggregate mapping
|
||||
|
||||
The CLI accepts the seven aggregates listed below — these are the
|
||||
human-driven set the operator typically asks for from the command line.
|
||||
|
||||
| Name | OPC UA Node ID |
|
||||
|------|---------------|
|
||||
| `Average` | `AggregateFunction_Average` |
|
||||
| `Minimum` | `AggregateFunction_Minimum` |
|
||||
| `Maximum` | `AggregateFunction_Maximum` |
|
||||
| `Average` (or `avg`) | `AggregateFunction_Average` |
|
||||
| `Minimum` (or `min`) | `AggregateFunction_Minimum` |
|
||||
| `Maximum` (or `max`) | `AggregateFunction_Maximum` |
|
||||
| `Count` | `AggregateFunction_Count` |
|
||||
| `Start` | `AggregateFunction_Start` |
|
||||
| `End` | `AggregateFunction_End` |
|
||||
| `Start` (or `first`) | `AggregateFunction_Start` |
|
||||
| `End` (or `last`) | `AggregateFunction_End` |
|
||||
| `StandardDeviation` (or `stddev` / `stdev`) | `AggregateFunction_StandardDeviationSample` |
|
||||
|
||||
The driver-side `IHistoryProvider.ReadProcessedAsync` API (used by the
|
||||
OtOpcUa server's HistoryRead facade) supports the full OPC UA Part 13 §5
|
||||
catalog — ~30 aggregates including `TimeAverage`, `Interpolative`, `Range`,
|
||||
`PercentGood`, `Delta`, etc. See
|
||||
[`docs/drivers/OpcUaClient.md`](drivers/OpcUaClient.md#historyread-aggregates-part-13-catalog)
|
||||
for the full list. Adding a new CLI shorthand is a one-line change in
|
||||
`HistoryReadCommand.ParseAggregateType` — file an issue if you need one
|
||||
exposed.
|
||||
|
||||
#### Event-mode coverage
|
||||
|
||||
Drivers that implement the filter-aware
|
||||
`IHistoryProvider.ReadEventsAsync(fullReference, EventHistoryRequest, ct)`
|
||||
overload (currently the OPC UA Client gateway driver — Galaxy keeps the
|
||||
fixed-field fallback) honour `EventFilter` SelectClauses and a `WhereClause`
|
||||
when the server-side history facade forwards them. The CLI does not yet
|
||||
expose a dedicated `--events` flag — clients that need filter-aware event
|
||||
history call `HistoryReadEvents` through their own SDK; the CLI's
|
||||
`historyread` command stays focused on the data-history (Raw / Processed /
|
||||
AtTime) path. Adding `--events` is tracked as a follow-up — the wire path
|
||||
on the driver side is in place (see
|
||||
[`docs/drivers/OpcUaClient.md`](drivers/OpcUaClient.md#historyread-events)).
|
||||
|
||||
### alarms
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli -- --help
|
||||
| `-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:
|
||||
@@ -89,6 +90,33 @@ 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
|
||||
|
||||
@@ -21,6 +21,9 @@ dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli -- --help
|
||||
| `-P` / `--plc-type` | `Slc500` | Slc500 / MicroLogix / Plc5 / LogixPccc |
|
||||
| `--timeout-ms` | `5000` | Per-operation timeout — see precedence note below |
|
||||
| `--retries` | `0` | Retry count on transient `BadCommunicationError` (PR 9 / #252) |
|
||||
| `--demote-failure-threshold` | `3` | **PR ablegacy-12 / #255** — consecutive comm failures before the device is auto-demoted |
|
||||
| `--demote-for-ms` | `30000` | **PR ablegacy-12 / #255** — auto-demote cool-down window in ms |
|
||||
| `--no-demote` | off | **PR ablegacy-12 / #255** — disable auto-demote entirely (counters still tick) |
|
||||
| `--verbose` | off | Serilog debug output |
|
||||
|
||||
Family ↔ CIP-path cheat sheet:
|
||||
@@ -29,6 +32,27 @@ 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)
|
||||
|
||||
@@ -84,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
|
||||
|
||||
@@ -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
|
||||
@@ -120,6 +121,18 @@ otopcua-focas-cli write -h 192.168.1.50 -a MACRO:500 -t Int32 -v 42
|
||||
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.
|
||||
@@ -146,17 +159,17 @@ 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)
|
||||
#### 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), 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.
|
||||
(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
|
||||
|
||||
@@ -25,6 +25,8 @@ dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli -- --help
|
||||
| `--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. |
|
||||
| `--password` | (unset) | Connection-level password sent right after `OpenAsync`. Used by hardened S7-300/400 (protection levels 1-3) and S7-1200/1500 (TIA Portal *Connection Mechanism* gate). Never logged. NB: S7netplus 0.20 doesn't expose `SendPassword`; the CLI prints a one-line warning and continues. See [s7.md "PLC password / protection levels"](v2/s7.md#plc-password--protection-levels). |
|
||||
| `--protection-level` | `Auto` | Declarative hint: `Auto` / `None` / `Level1` / `Level2` / `Level3` (S7-300/400) / `ConnectionMechanism` (S7-1200/1500). Diagnostic only — the wire-side unlock is driven by `--password`. |
|
||||
| `--verbose` | off | Serilog debug output |
|
||||
|
||||
## PUT/GET must be enabled
|
||||
@@ -95,6 +97,17 @@ otopcua-s7-cli read -h 192.168.1.30 -a M0.0 -t Bool
|
||||
|
||||
# 80-char S7 string
|
||||
otopcua-s7-cli read -h 192.168.1.30 -a DB10.STRING[0] -t String --string-length 80
|
||||
|
||||
# CPU diagnostics (SZL) — virtual @System.* addresses (PR-S7-E1).
|
||||
# Requires ExposeSystemTags = true on the driver instance; surfaces as
|
||||
# BadNotSupported until S7netplus exposes a public ReadSzlAsync (or we ship
|
||||
# a raw-PDU helper). See docs/v2/s7.md "CPU diagnostics (SZL)" for the full
|
||||
# table and the snap7 / S7netplus 0.20 caveat.
|
||||
otopcua-s7-cli read -h 192.168.1.30 -a @System.CpuType -t String
|
||||
otopcua-s7-cli read -h 192.168.1.30 -a @System.Firmware -t String
|
||||
otopcua-s7-cli read -h 192.168.1.30 -a @System.OrderNo -t String
|
||||
otopcua-s7-cli read -h 192.168.1.30 -a @System.CycleMs.Min -t Float64
|
||||
otopcua-s7-cli read -h 192.168.1.30 -a "@System.DiagBuffer.Entry[0]" -t String
|
||||
```
|
||||
|
||||
### `write`
|
||||
@@ -128,6 +141,43 @@ wrong `--slot` produces also shows up when the CPU rejects PG class — try
|
||||
endpoint config. See [s7.md TSAP / Connection Type](v2/s7.md#tsap--connection-type)
|
||||
for the byte table and motivation.
|
||||
|
||||
### Hardened CPU — supplying a connection-level password
|
||||
|
||||
```powershell
|
||||
# S7-300 protection-level 2 — read+write protected without unlock.
|
||||
otopcua-s7-cli read -h 192.168.1.31 -c S7300 --slot 2 `
|
||||
--password "tia-portal-set-password" `
|
||||
--protection-level Level2 `
|
||||
-a DB1.DBW0 -t Int16
|
||||
|
||||
# S7-1500 ConnectionMechanism — TIA Portal Protection & Security pane gate.
|
||||
otopcua-s7-cli probe -h 10.50.12.30 `
|
||||
--tsap-mode Op `
|
||||
--password "tia-portal-set-password" `
|
||||
--protection-level ConnectionMechanism
|
||||
```
|
||||
|
||||
The password is emitted to the PLC immediately after `OpenAsync` succeeds and
|
||||
before the pre-flight PUT/GET probe runs (the same probe that would otherwise
|
||||
be the first operation a hardened CPU refuses). Never logged in any form;
|
||||
identifier-only success line is `S7 password sent for {Host}`.
|
||||
|
||||
**S7netplus 0.20 does not yet expose a public `SendPassword`** — the driver
|
||||
discovers the method reflectively, so a future minor release will be picked
|
||||
up automatically. Until then, configuring `--password` on a hardened CPU
|
||||
emits this warning at Init:
|
||||
|
||||
```
|
||||
[Warning] S7 password is set on driver '<id>' against host '<host>', but
|
||||
the linked S7netplus library does not expose SendPassword; password is
|
||||
being ignored at the wire.
|
||||
```
|
||||
|
||||
Init still completes (the COTP handshake itself doesn't require the
|
||||
password) but the first read against a hardened CPU will surface
|
||||
`BadDeviceFailure`. See [s7.md "PLC password / protection levels"](v2/s7.md#plc-password--protection-levels)
|
||||
for the full motivation, the no-log invariant, and the workaround matrix.
|
||||
|
||||
### `subscribe`
|
||||
|
||||
```powershell
|
||||
@@ -136,3 +186,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.
|
||||
|
||||
@@ -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 |
|
||||
@@ -167,3 +217,37 @@ 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.
|
||||
|
||||
### `alarms` (PR 5.1 / #316)
|
||||
|
||||
Stream TC3 EventLogger alarms via the driver's `IAlarmSource` bridge.
|
||||
Subscribes against AMS port 110 (`AMSPORT_EVENTLOG`) on the same target,
|
||||
prints each event with timestamp / source / severity / message until
|
||||
Ctrl+C.
|
||||
|
||||
```powershell
|
||||
# All alarms — every event the EventLogger surfaces
|
||||
otopcua-twincat-cli alarms -n 192.168.1.40.1.1
|
||||
|
||||
# Filter by source — only events whose source name matches (case-insensitive)
|
||||
otopcua-twincat-cli alarms -n 192.168.1.40.1.1 --source Conveyor1.MotorOverload
|
||||
|
||||
# Multiple sources — repeat the flag
|
||||
otopcua-twincat-cli alarms -n 192.168.1.40.1.1 --source Conveyor1 --source Pump3
|
||||
```
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--source` | (none) | Optional source filter; repeat for multiple |
|
||||
|
||||
Output format (one line per event):
|
||||
|
||||
```
|
||||
[HH:mm:ss.fff] <source> sev=<Low|Medium|High|Critical> type=<event-class> cond=<condition-id> "<message>"
|
||||
```
|
||||
|
||||
The verb forces `EnableAlarms=true` on the underlying driver; the
|
||||
default driver config keeps it off so deployments without an
|
||||
EventLogger configured pay no cost. See
|
||||
[`docs/drivers/TwinCAT.md` §Alarms](drivers/TwinCAT.md) for the
|
||||
full bridge architecture and decode caveats.
|
||||
|
||||
@@ -98,6 +98,10 @@ Role swaps, stand-alone promotions, and base-level adjustments all happen throug
|
||||
|
||||
The OtOpcUa Client CLI at `src/ZB.MOM.WW.OtOpcUa.Client.CLI` supports `-F` / `--failover-urls` for automatic client-side failover; for long-running subscriptions the CLI monitors session KeepAlive and reconnects to the next available server, recreating the subscription on the new endpoint. See [`Client.CLI.md`](Client.CLI.md) for the command reference.
|
||||
|
||||
## vs. upstream-side redundancy
|
||||
|
||||
The mechanics on this page describe **OtOpcUa as a redundant server** — two of our instances clustered behind one OPC UA address space, exposing `ServerUriArray` + dynamic `ServiceLevel` to downstream clients. The mirror-image scenario — **the OPC UA Client driver consuming an upstream redundant pair** — is documented separately in [`drivers/OpcUaClient.md` § Upstream redundancy](drivers/OpcUaClient.md#upstream-redundancy-serverarray). Both rely on the same OPC UA Part 4 § 6.6.2 model (non-transparent warm/hot via `RedundancySupport` + `ServerUriArray` + `ServiceLevel`); they sit at opposite ends of the gateway pipeline. A deployment can wire either, both, or neither.
|
||||
|
||||
## Depth reference
|
||||
|
||||
For the full decision trail and implementation plan — topology invariants, peer-probe cadence, recovery-dwell policy, compliance-script guard against enum-value drift — see `docs/v2/plan.md` §Phase 6.3.
|
||||
|
||||
@@ -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,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.
|
||||
@@ -7,10 +7,12 @@ directly without going through a separate diagnostics RPC. Mirrors the AB CIP
|
||||
|
||||
Closes #253 (PR ablegacy-10).
|
||||
|
||||
## The seven counters
|
||||
## The nine counters
|
||||
|
||||
Each device managed by the `AbLegacyDriver` exposes seven read-only nodes under
|
||||
`AbLegacy/<host>/_Diagnostics/<name>`:
|
||||
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 |
|
||||
|---|---|---|
|
||||
@@ -21,6 +23,8 @@ Each device managed by the `AbLegacyDriver` exposes seven read-only nodes under
|
||||
| `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`.
|
||||
@@ -34,10 +38,11 @@ user-config tag node, just under a reserved sibling folder.
|
||||
|
||||
| Trigger | Effect |
|
||||
|---|---|
|
||||
| `ReinitializeAsync` | Every counter for every device resets to zero, plus `LastErrorMessage` clears to empty. |
|
||||
| `ShutdownAsync` | Same as Reinitialize — counters drop with the device map. |
|
||||
| `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
|
||||
@@ -99,14 +104,85 @@ overview dashboard, plus a faster rate (1 s) on `LastErrorMessage` /
|
||||
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`
|
||||
— increment sites in `ReadAsync`, discovery emission in `DiscoverAsync`,
|
||||
auto-demote bookkeeping in `RecordFailureAndMaybeDemote` + `ProbeLoopAsync`
|
||||
- [`AbLegacy-Test-Fixture.md`](AbLegacy-Test-Fixture.md) — `AbLegacyDiagnosticsTests`
|
||||
+ collision-rejection contract
|
||||
+ `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)
|
||||
|
||||
@@ -53,12 +53,31 @@ supplies a `FakeAbLegacyTag`.
|
||||
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 exactly 7 diagnostic variables per
|
||||
device under `_Diagnostics/`; 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.
|
||||
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
|
||||
@@ -113,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
|
||||
|
||||
@@ -160,6 +160,28 @@ The driver implements all of these + they have unit coverage, but the only
|
||||
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
|
||||
|
||||
Rockwell Studio 5000 Logix Emulate sits **above** ab_server in fidelity +
|
||||
|
||||
+145
-10
@@ -8,6 +8,47 @@ Power Mate i families. Talks to the controller via the licensed
|
||||
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`:
|
||||
@@ -54,7 +95,7 @@ giant request. Typical FANUC ring buffers cap at ~100 entries; the default
|
||||
surfacing the FWLIB struct fields directly into
|
||||
`FocasAlarmHistoryEntry`.
|
||||
|
||||
## Writes (opt-in, off by default) — issue #268 (F4-a) + #269 (F4-b)
|
||||
## 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
|
||||
@@ -66,22 +107,54 @@ choice. Decision record: [`docs/v2/decisions.md`](../v2/decisions.md) →
|
||||
| `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. |
|
||||
|
||||
### Config shape — F4-b
|
||||
> **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
|
||||
"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 }
|
||||
"Writable": true, "WriteIdempotent": false },
|
||||
{ "Name": "StartFlag", "Address": "R100.3", "DataType": "Bit",
|
||||
"Writable": true, "WriteIdempotent": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -115,17 +188,79 @@ 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)**. Same
|
||||
status code, four distinct paths — operators distinguish by checking the
|
||||
knobs.
|
||||
(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 will land the
|
||||
unlock workflow on top of this surface; today the deployment instructs
|
||||
the operator to flip the parameter-write switch on the CNC pendant.
|
||||
(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` /
|
||||
|
||||
@@ -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).
|
||||
@@ -159,6 +166,35 @@ Beyond that:
|
||||
3. **Dedicated historian integration lab** — only path for
|
||||
historian-specific coverage.
|
||||
|
||||
## HistoryRead aggregate coverage
|
||||
|
||||
PR-13 (issue #285) extended `HistoryAggregateType` from 5 to ~30 values
|
||||
matching the OPC UA Part 13 §5 catalog. The mapping itself
|
||||
(`OpcUaClientDriver.MapAggregateToNodeId`) is unit-tested via
|
||||
`OpcUaClientAggregateMappingTests`:
|
||||
|
||||
- The full enum is swept with `Enum.GetValues<HistoryAggregateType>()` —
|
||||
every value must resolve to a non-null namespace-0 numeric `NodeId`.
|
||||
- The 25 new aggregates each assert against a reflection-resolved
|
||||
`Opc.Ua.ObjectIds.AggregateFunction_*` field by name, so a future SDK
|
||||
upgrade that renames a constant trips the test loudly.
|
||||
- The original 5 ordinals stay pinned to their pre-PR-13 NodeIds so existing
|
||||
config files / persisted enums keep working.
|
||||
|
||||
This is **the well-known-NodeId test path** — the standard Part 13 NodeIds
|
||||
are stable across SDK versions; round-tripping each one against a live
|
||||
upstream is the integration suite's job and doesn't add coverage to the
|
||||
mapping table itself.
|
||||
|
||||
`OpcUaClientAggregateSweepTests` is the integration counterpart. It loops
|
||||
every enum value against a real opc-plc upstream and asserts the wire path
|
||||
doesn't crash even when the simulator returns
|
||||
`BadAggregateNotSupported` for an aggregate it doesn't honour. opc-plc's
|
||||
default profile doesn't enable HistoryRead on the well-known nodes, so the
|
||||
test currently `Assert.Skip`s — re-enables when the fixture image is
|
||||
upgraded to a history-sim profile (`--useslowtypes --ut=10` or similar) and
|
||||
a known-good historized NodeId is wired into `OpcPlcProfile`.
|
||||
|
||||
## Key fixture / config files
|
||||
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/` — unit tests with
|
||||
@@ -168,3 +204,7 @@ Beyond that:
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Server.Tests/OpcUaServerIntegrationTests.cs` —
|
||||
the server-side integration harness a future loopback client test could
|
||||
piggyback on
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientAggregateMappingTests.cs`
|
||||
— Part 13 aggregate enum-to-NodeId mapping coverage (PR-13)
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientAggregateSweepTests.cs`
|
||||
— wire-side aggregate sweep against opc-plc (build-only scaffold; PR-13)
|
||||
|
||||
@@ -59,3 +59,292 @@ Flip `WatchModelChanges` to `false` when:
|
||||
- The upstream server fires spurious `ModelChangeEvent`s that don't
|
||||
reflect real topology changes, causing wasted re-imports. Tighten or
|
||||
disable rather than chasing the noise downstream.
|
||||
|
||||
## Reverse Connect (server-initiated)
|
||||
|
||||
OPC UA's reverse-connect mode flips the transport direction: instead of the
|
||||
client dialling the server, the **server** dials the client's listener. The
|
||||
upstream sends a `ReverseHello` and the client continues the OPC UA
|
||||
handshake on the inbound socket. Required for OT-DMZ deployments where the
|
||||
plant firewall only permits outbound traffic from the upstream — the
|
||||
gateway opens a listener, the upstream reaches out.
|
||||
|
||||
### Configuration
|
||||
|
||||
| Option | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `ReverseConnect.Enabled` | `false` | Opt-in. When `true`, replaces the failover dial-sweep with a `WaitForConnection` call. |
|
||||
| `ReverseConnect.ListenerUrl` | `null` | Local listener URL the SDK binds. Typically `opc.tcp://0.0.0.0:4844` (any interface) or a specific NIC for multi-homed gateways. **Required when `Enabled` is `true`.** |
|
||||
| `ReverseConnect.ExpectedServerUri` | `null` | Upstream's `ApplicationUri` to filter inbound dials. `null` accepts the first connection (only safe with one upstream targeting the listener). |
|
||||
|
||||
### Shared listener (singleton)
|
||||
|
||||
A single underlying `Opc.Ua.Client.ReverseConnectManager` per process keyed
|
||||
on `ListenerUrl`. Two driver instances that share a listener URL multiplex
|
||||
onto one TCP socket; the SDK demuxes inbound dials by the upstream's
|
||||
reported `ServerUri`. The wrapper (`ReverseConnectListener`) is
|
||||
reference-counted — first `Acquire` binds the port, last `Release` tears it
|
||||
down. Letting drivers come and go independently without races on
|
||||
port-bind / port-unbind.
|
||||
|
||||
When two drivers share a listener:
|
||||
|
||||
- They MUST set `ExpectedServerUri` to disambiguate; otherwise the first
|
||||
upstream to dial in wins regardless of which driver is waiting.
|
||||
- They CAN come and go independently; the listener stays alive while at
|
||||
least one driver references it.
|
||||
|
||||
### Behaviour
|
||||
|
||||
- The dial path is bypassed entirely when `Enabled` is `true`. Failover
|
||||
across multiple `EndpointUrls` doesn't apply — there's no client-side
|
||||
dial to fail over.
|
||||
- `ExpectedServerUri` is the SDK's filter parameter to `WaitForConnectionAsync`.
|
||||
Inbound `ReverseHello`s from a different upstream are ignored and the
|
||||
caller keeps waiting.
|
||||
- The same `EndpointDescription` derivation runs as the dial path — the
|
||||
first `EndpointUrl` in the candidate list seeds `SecurityPolicy` /
|
||||
`SecurityMode` / `EndpointUrl` for the session-create call. The actual
|
||||
endpoint lives on the upstream and the SDK reconciles after the
|
||||
`ReverseHello`.
|
||||
- Cancellation: `Timeout` bounds the wait. A stuck listener with no inbound
|
||||
dial throws after `Timeout` rather than hanging init forever.
|
||||
- Shutdown releases the listener reference. The last release stops the
|
||||
listener so the port can be re-bound by a future driver lifecycle.
|
||||
|
||||
### Wiring it up on the upstream
|
||||
|
||||
The upstream OPC UA server has to be configured to dial out. The `opc-plc`
|
||||
simulator does this with `--rc=opc.tcp://<gateway-host>:4844`; for a real
|
||||
upstream see your server's reverse-connect docs (most major implementations
|
||||
expose a "ReverseConnect.Endpoint" config knob).
|
||||
|
||||
### When NOT to use
|
||||
|
||||
- Standard plant networks where the gateway can dial the upstream — the
|
||||
conventional dial path is simpler and supports failover natively.
|
||||
- Public-internet OPC UA: reverse-connect is a network-policy workaround,
|
||||
not a security primitive. Always pair with `Sign` or `SignAndEncrypt`
|
||||
+ a vetted user-token policy.
|
||||
|
||||
## HistoryRead Events
|
||||
|
||||
The driver passes through OPC UA `HistoryReadEvents` to the upstream server.
|
||||
HistoryRead Raw / Processed / AtTime ship in the same code path
|
||||
(`ExecuteHistoryReadAsync`); event history takes a slightly different shape
|
||||
because the client sends an `EventFilter` (SelectClauses + WhereClause) rather
|
||||
than a plain numeric / time-based detail block.
|
||||
|
||||
### Wire path
|
||||
|
||||
`IHistoryProvider.ReadEventsAsync(fullReference, EventHistoryRequest, ct)`
|
||||
translates to:
|
||||
|
||||
```
|
||||
new ReadEventDetails {
|
||||
StartTime,
|
||||
EndTime,
|
||||
NumValuesPerNode,
|
||||
Filter = EventFilter { SelectClauses, WhereClause }
|
||||
}
|
||||
```
|
||||
|
||||
…and is sent through `Session.HistoryReadAsync` to the upstream server. The
|
||||
returned `HistoryEvent.Events` collection (one `HistoryEventFieldList` per
|
||||
historical event) is unwrapped into `HistoricalEventBatch.Events`, where each
|
||||
`HistoricalEventRow.Fields` dictionary is keyed by the
|
||||
`SimpleAttributeSpec.FieldName` the caller supplied. The server-side history
|
||||
dispatcher uses those keys to align fields with the wire-side SelectClause
|
||||
order — drivers don't have to honour the entire OPC UA `EventFilter` shape
|
||||
verbatim.
|
||||
|
||||
### SelectClauses
|
||||
|
||||
When `EventHistoryRequest.SelectClauses` is `null` the driver falls back to a
|
||||
default set that matches `BuildHistoryEvent` on the server side:
|
||||
|
||||
| Field | Browse path | Notes |
|
||||
| --- | --- | --- |
|
||||
| `EventId` | `EventId` | BaseEventType — stable unique id. |
|
||||
| `SourceName` | `SourceName` | Source-object name. |
|
||||
| `Time` | `Time` | Process-side event timestamp. Used for `OccurrenceTime`. |
|
||||
| `Message` | `Message` | LocalizedText payload. |
|
||||
| `Severity` | `Severity` | OPC UA 1-1000 scale. |
|
||||
| `ReceiveTime` | `ReceiveTime` | Server-side ingest timestamp. |
|
||||
|
||||
Custom SelectClauses are supported — pass any
|
||||
`IReadOnlyList<SimpleAttributeSpec>`. Each entry's `TypeDefinitionId`
|
||||
defaults to `BaseEventType` when `null`; pass an explicit NodeId text (e.g.
|
||||
`"i=2782"` for `ConditionType`) to reach typed-condition fields.
|
||||
|
||||
### WhereClause
|
||||
|
||||
`ContentFilterSpec.EncodedOperands` carries the binary-encoded
|
||||
`ContentFilter` from the wire. The driver decodes it into the SDK
|
||||
`ContentFilter` and attaches it to the outgoing `EventFilter` verbatim — the
|
||||
OPC UA Client driver is a passthrough for filter semantics, it does not
|
||||
evaluate them. A malformed filter is dropped silently; the SelectClause
|
||||
projection still goes out.
|
||||
|
||||
### Continuation points
|
||||
|
||||
Returned in `HistoricalEventBatch.ContinuationPoint`. The server-side
|
||||
HistoryRead facade is responsible for round-tripping these so a paged event
|
||||
read against a chatty upstream completes incrementally. The driver itself
|
||||
doesn't track them — every `ReadEventsAsync` call issues a fresh
|
||||
`HistoryReadAsync`.
|
||||
|
||||
## HistoryRead Aggregates (Part 13 catalog)
|
||||
|
||||
`IHistoryProvider.ReadProcessedAsync` takes a `HistoryAggregateType` and the
|
||||
driver maps it to the standard `Opc.Ua.ObjectIds.AggregateFunction_*` NodeId
|
||||
in `MapAggregateToNodeId`. PR-13 (issue #285) extended the enum from the
|
||||
original 5 values (Average / Minimum / Maximum / Total / Count) to the full
|
||||
OPC UA Part 13 §5 catalog — ~30 aggregates.
|
||||
|
||||
The mapping is best-effort: not every upstream OPC UA server implements every
|
||||
aggregate. Aggregates the upstream rejects come back with
|
||||
`StatusCode=BadAggregateNotSupported` on the per-row HistoryRead result; the
|
||||
driver passes that through verbatim (cascading-quality rule, Part 11 §8) — it
|
||||
does not throw. Servers advertise the aggregates they support via the
|
||||
`AggregateConfiguration` object on the `Server` node; clients can probe it at
|
||||
runtime.
|
||||
|
||||
### Catalog
|
||||
|
||||
| Enum value | SDK NodeId field | Part 13 § | Server-side support | Typical use |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `Average` | `AggregateFunction_Average` | §5.4 | almost always | smoothing |
|
||||
| `Minimum` | `AggregateFunction_Minimum` | §5.5 | almost always | low watermark |
|
||||
| `Maximum` | `AggregateFunction_Maximum` | §5.6 | almost always | high watermark |
|
||||
| `Total` | `AggregateFunction_Total` | §5.10 | usually | totalisation |
|
||||
| `Count` | `AggregateFunction_Count` | §5.18 | almost always | sample count |
|
||||
| `TimeAverage` | `AggregateFunction_TimeAverage` | §5.4.2 | usually | time-weighted mean |
|
||||
| `TimeAverage2` | `AggregateFunction_TimeAverage2` | §5.4.3 | sometimes | bounded time-weighted mean |
|
||||
| `Interpolative` | `AggregateFunction_Interpolative` | §5.3 | usually | trend snapshot |
|
||||
| `MinimumActualTime` | `AggregateFunction_MinimumActualTime` | §5.5.4 | sometimes | when low occurred |
|
||||
| `MaximumActualTime` | `AggregateFunction_MaximumActualTime` | §5.6.4 | sometimes | when high occurred |
|
||||
| `Range` | `AggregateFunction_Range` | §5.7 | usually | spread |
|
||||
| `Range2` | `AggregateFunction_Range2` | §5.7 | sometimes | bounded spread |
|
||||
| `AnnotationCount` | `AggregateFunction_AnnotationCount` | §5.21 | rarely | operator notes |
|
||||
| `DurationGood` | `AggregateFunction_DurationGood` | §5.16 | sometimes | quality coverage |
|
||||
| `DurationBad` | `AggregateFunction_DurationBad` | §5.16 | sometimes | gap accounting |
|
||||
| `PercentGood` | `AggregateFunction_PercentGood` | §5.17 | sometimes | quality % |
|
||||
| `PercentBad` | `AggregateFunction_PercentBad` | §5.17 | sometimes | gap % |
|
||||
| `WorstQuality` | `AggregateFunction_WorstQuality` | §5.20 | sometimes | worst seen |
|
||||
| `WorstQuality2` | `AggregateFunction_WorstQuality2` | §5.20 | rarely | bounded worst |
|
||||
| `StandardDeviationSample` | `AggregateFunction_StandardDeviationSample` | §5.13 | sometimes | n-1 stddev |
|
||||
| `StandardDeviationPopulation` | `AggregateFunction_StandardDeviationPopulation` | §5.13 | sometimes | n stddev |
|
||||
| `VarianceSample` | `AggregateFunction_VarianceSample` | §5.13 | sometimes | n-1 variance |
|
||||
| `VariancePopulation` | `AggregateFunction_VariancePopulation` | §5.13 | sometimes | n variance |
|
||||
| `NumberOfTransitions` | `AggregateFunction_NumberOfTransitions` | §5.12 | sometimes | event count |
|
||||
| `DurationInStateZero` | `AggregateFunction_DurationInStateZero` | §5.19 | sometimes | OFF time |
|
||||
| `DurationInStateNonZero` | `AggregateFunction_DurationInStateNonZero` | §5.19 | sometimes | ON time |
|
||||
| `Start` | `AggregateFunction_Start` | §5.8 | usually | first sample |
|
||||
| `End` | `AggregateFunction_End` | §5.9 | usually | last sample |
|
||||
| `Delta` | `AggregateFunction_Delta` | §5.11 | usually | end-start |
|
||||
| `StartBound` | `AggregateFunction_StartBound` | §5.8 | sometimes | extrapolated start |
|
||||
| `EndBound` | `AggregateFunction_EndBound` | §5.9 | sometimes | extrapolated end |
|
||||
|
||||
"Server-side support" is heuristic — see your upstream's `AggregateConfiguration`
|
||||
node for the authoritative list. AVEVA Historian, KEPServerEX, Prosys, and
|
||||
opc-plc each implement different subsets.
|
||||
|
||||
### Driver-side validation
|
||||
|
||||
The mapping itself is unit-tested over the full enum
|
||||
(`OpcUaClientAggregateMappingTests`) — every value resolves to a non-null
|
||||
namespace-0 NodeId, and the original 5 ordinals stay pinned. Wire-side
|
||||
behaviour against a live server is exercised by
|
||||
`OpcUaClientAggregateSweepTests` (build-only scaffold pending an opc-plc
|
||||
history-sim profile).
|
||||
|
||||
## Upstream redundancy (`ServerArray`)
|
||||
|
||||
When the upstream OPC UA server is itself a redundant pair (warm or hot per
|
||||
OPC UA Part 4 §6.6.2), the driver supports **mid-session failover** driven by
|
||||
the upstream's own `Server.ServerRedundancy.RedundancySupport` +
|
||||
`ServerUriArray` + `Server.ServiceLevel` nodes. Distinct from the static
|
||||
boot-time failover sweep on `EndpointUrls`: that path picks a single survivor
|
||||
at session-create time; this path swaps the active session live when the
|
||||
upstream signals degradation, transferring subscriptions onto the secondary so
|
||||
monitored-item handles stay valid.
|
||||
|
||||
### Configuration
|
||||
|
||||
| Option | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `Redundancy.Enabled` | `false` | Opt-in. When `false`, the driver doesn't read `RedundancySupport` / `ServerUriArray` and doesn't subscribe to `ServiceLevel`. |
|
||||
| `Redundancy.ServiceLevelThreshold` | `200` | Byte value below which the driver triggers failover. OPC UA spec convention: 200+ = healthy primary, 100..199 = degraded, 0..99 = unrecoverable. |
|
||||
| `Redundancy.RecheckInterval` | `5s` | Lower bound between two consecutive failovers — suppresses oscillation when ServiceLevel flaps around the threshold. |
|
||||
|
||||
### Behaviour
|
||||
|
||||
- At session activation the driver reads
|
||||
`Server.ServerRedundancy.RedundancySupport`. When `None`, the driver records
|
||||
an empty peer list and the failover path becomes a no-op (`ServiceLevel`
|
||||
drops are still observable via diagnostics but trigger nothing).
|
||||
- When the upstream advertises `Cold` / `Warm` / `WarmActive` / `Hot`, the
|
||||
driver pulls `Server.ServerRedundancy.ServerUriArray` for the peer list,
|
||||
falling back to the top-level `Server.ServerArray` for legacy upstreams that
|
||||
don't expose the redundancy node.
|
||||
- A dedicated subscription on `Server.ServiceLevel` (publish interval 1s,
|
||||
separate from the alarm + data subscriptions) drives every failover decision
|
||||
via the SDK's notification path — no polling loop.
|
||||
- On a drop below `ServiceLevelThreshold` the driver picks the next URI in the
|
||||
peer list that isn't the active one, opens a parallel session against it,
|
||||
and calls `Session.TransferSubscriptionsAsync(other, sendInitialValues:true)`
|
||||
to migrate every live subscription (data + alarm + model-change +
|
||||
service-level itself). On success the driver swaps `Session`, closes the
|
||||
old one, and bumps `RedundancyFailoverCount`.
|
||||
- On any failure (`BadSecureChannelClosed`, `BadCertificateUntrusted`,
|
||||
`TransferSubscriptions` returning `false`, secondary unreachable) the driver
|
||||
leaves the existing session untouched, increments
|
||||
`RedundancyFailoverFailures`, and waits for the next ServiceLevel
|
||||
notification. The keep-alive watchdog continues to cover full
|
||||
upstream-loss scenarios.
|
||||
|
||||
### Shared client-cert prerequisite
|
||||
|
||||
`TransferSubscriptionsAsync` requires the secondary's secure channel to accept
|
||||
the same client certificate the primary did. Operators running heterogeneous
|
||||
secondaries (different cert trust stores) will see `BadCertificateUntrusted`
|
||||
on every failover attempt and the failures counter climbing. The fix is to
|
||||
push the gateway driver's application-instance certificate into both
|
||||
upstreams' `TrustedPeerCertificates` store before enabling redundancy. A
|
||||
follow-up adds a fallback path that re-creates subscriptions instead of
|
||||
transferring when the secondary rejects the channel.
|
||||
|
||||
### Diagnostics
|
||||
|
||||
The `driver-diagnostics` RPC surfaces three new counters via
|
||||
`DriverHealth.Diagnostics`:
|
||||
|
||||
| Key | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `RedundancyFailoverCount` | `double` (long-counted) | Successful mid-session swaps since driver start. |
|
||||
| `RedundancyFailoverFailures` | `double` (long-counted) | Swap attempts that bailed (TransferSubscriptions false, secondary unreachable, etc.). |
|
||||
| `ActiveServerUri` | string (in `OpcUaClientDiagnostics.ActiveServerUri`) | URI of the upstream the driver is currently bound to. Updates on every successful failover. |
|
||||
|
||||
### Forced-failover runbook
|
||||
|
||||
To validate the wiring against a real redundant upstream pair:
|
||||
|
||||
1. Confirm the upstream advertises `RedundancySupport != None` and a
|
||||
non-empty `ServerUriArray`. Use the Client CLI:
|
||||
`dotnet run --project src/ZB.MOM.WW.OtOpcUa.Client.CLI -- redundancy -u <primary>`.
|
||||
2. Set `Redundancy.Enabled = true` on the gateway's `OpcUaClient` driver
|
||||
instance and restart.
|
||||
3. Tail driver diagnostics:
|
||||
`driver-diagnostics --instance <id>` — note `RedundancyFailoverCount = 0`
|
||||
pre-test.
|
||||
4. Drive a `ServiceLevel` drop on the primary. On AVEVA / KEPServer this is
|
||||
typically a "force standby" Admin action; on a custom server it's a write
|
||||
to the simulated ServiceLevel node.
|
||||
5. Observe `RedundancyFailoverCount = 1` within `RecheckInterval` of the
|
||||
drop, the gateway's `HostName` swap to the secondary URI, and downstream
|
||||
reads/subscriptions continuing without interruption.
|
||||
|
||||
For non-redundant upstreams (single-server deployments) the recommended
|
||||
configuration is to leave `Redundancy.Enabled = false` and rely on
|
||||
`EndpointUrls` for boot-time failover only.
|
||||
|
||||
@@ -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.
|
||||
@@ -88,10 +88,59 @@ real PLC latency is not exercised.
|
||||
S7-1200 vs S7-1500 vs S7-300/400 connection semantics (PG vs OP vs S7-Basic)
|
||||
not differentiated at test time.
|
||||
|
||||
**Optimized DB / S7Plus** is the variant-shaped gap with the biggest field
|
||||
impact. snap7 happens to behave like a classic-S7comm-only PLC, so the
|
||||
integration suite cannot reproduce the shape that an S7-1500 with default
|
||||
"Optimized block access" checked would return (`BadDeviceFailure` on every
|
||||
absolute-offset read). The decision is documented at
|
||||
[`docs/v2/s7.md` § Optimized DB constraint (S7Plus)](../v2/s7.md#optimized-db-constraint-s7plus)
|
||||
and tracked in [`docs/featuregaps.md`](../featuregaps.md) row #1; the
|
||||
project ships **Track 1** (operator unchecks Optimized block access in TIA
|
||||
Portal) and **Track 3** (bridge via the `OpcUaClient` driver against the
|
||||
CPU's onboard OPC UA server). A custom S7Plus implementation is out of
|
||||
scope.
|
||||
|
||||
### 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.
|
||||
|
||||
### 6. SZL (System Status List) — `@System.*` virtual addresses
|
||||
|
||||
PR-S7-E1 / [#302](https://github.com/dohertj2/dohertj2/lmxopcua/issues/302)
|
||||
adds a virtual `@System.*` address surface (CPU type, firmware, scan-cycle
|
||||
stats, diagnostic-buffer ring) backed by SZL reads. **snap7 does not
|
||||
implement SZL** — the simulator answers every SZL request with a function-
|
||||
not-supported error, so the integration profile exercises only the
|
||||
not-supported semantics (`@System.CpuType` against snap7 returns
|
||||
`BadNotSupported`). Live-firmware SZL coverage is parked behind a
|
||||
`[Fact(Skip = ...)]` until either S7netplus exposes a public `ReadSzlAsync`
|
||||
or we ship a raw S7comm PDU helper. See
|
||||
[`docs/v2/s7.md` "CPU diagnostics (SZL)"](../v2/s7.md#cpu-diagnostics-szl)
|
||||
for the wire-status detail.
|
||||
|
||||
### 7. Password / protection levels — not modelled by snap7
|
||||
|
||||
PR-S7-E2 / [#303](https://github.com/dohertj2/lmxopcua/issues/303) adds
|
||||
`Password` + `ProtectionLevel` options that emit a connection-level password
|
||||
right after `OpenAsync`. **snap7 does not model S7 protection levels** — the
|
||||
simulator accepts every connection regardless of the password set on the
|
||||
client, so the integration profile cannot distinguish "password sent
|
||||
correctly" from "password ignored". Coverage stays at the unit-test seam:
|
||||
`S7PasswordOptionsTests` injects a fake `IS7PlcAuthGate` to assert the
|
||||
dispatch contract (Password=null skips the call; Password+SupportsSendPassword
|
||||
calls the gate; auth-failed wraps to a clean `InvalidOperationException`),
|
||||
plus the no-log invariant on `S7DriverOptions.ToString()`.
|
||||
|
||||
The wire path is also fundamentally limited until S7netplus 0.20 exposes a
|
||||
public `SendPassword` — the driver currently logs a warning and continues
|
||||
when the API is missing. See
|
||||
[`docs/v2/s7.md` "PLC password / protection levels"](../v2/s7.md#plc-password--protection-levels)
|
||||
for the library-limitation note. Live-firmware coverage of the unlock path
|
||||
requires a hardened S7-1500 lab rig with TIA Portal "Protection & Security"
|
||||
configured, which is parked as a follow-up.
|
||||
|
||||
## When to trust the S7 tests, when to reach for a rig
|
||||
|
||||
@@ -101,7 +150,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
|
||||
|
||||
@@ -125,6 +174,44 @@ arrays of structs — not covered.
|
||||
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`.
|
||||
5. **Live-firmware Optimized-block-access toggle (PR-S7-F / [#304](https://github.com/dohertj2/lmxopcua/issues/304)).**
|
||||
snap7 happens to behave like a classic-S7comm CPU, so the integration
|
||||
profile cannot reproduce the failure that a default new TIA Portal V14+
|
||||
project produces (`BadDeviceFailure` on `DB1.DBW0` against an Optimized
|
||||
DB). A manual smoke test on the lab rig, gated behind `--with-real-plc`,
|
||||
would close that loop. Suggested checklist on a real S7-1500 V2.5+:
|
||||
1. Create `DB1` in TIA Portal with three INT members at offsets 0, 2, 4.
|
||||
Leave **Optimized block access checked** (the default).
|
||||
2. Compile + download to the PLC.
|
||||
3. Drive the OtOpcUa S7 driver against `DB1.DBW0` — assert that the read
|
||||
returns `BadDeviceFailure` (the Track-1-not-applied symptom). This is
|
||||
the failure shape the docs warn about.
|
||||
4. Open `DB1`'s properties → **uncheck Optimized block access** →
|
||||
compile → download. Re-run the read; assert it returns the seeded
|
||||
INT value at offset 0. (Track 1 verified end-to-end.)
|
||||
5. **Track 3 verification (separate run on the same rig):** with
|
||||
Optimized access re-enabled on `DB1`, activate the CPU's onboard
|
||||
OPC UA server in TIA Portal, expose `DB1.<MemberName>` through a
|
||||
Server interface, register an `OpcUaClient` driver against
|
||||
`opc.tcp://<plc-ip>:4840`, and assert the symbolic read returns the
|
||||
same seeded value. This proves the bridge path against a real
|
||||
Optimized DB without the operator having to disable Optimized
|
||||
access.
|
||||
|
||||
The test must stay manual: TIA Portal compile + download cannot be
|
||||
automated from CI without a Siemens engineering toolchain license, and
|
||||
download-with-CPU-stop is destructive on a shared lab rig. Document
|
||||
results inline in PR descriptions when the rig is available.
|
||||
|
||||
6. **PR-S7-E1 — live SZL test against a real S7-1500.** snap7 doesn't
|
||||
implement SZL at all, and S7netplus 0.20 doesn't expose a public
|
||||
`ReadSzlAsync`, so the `@System.*` virtual address surface currently
|
||||
answers `BadNotSupported` against every backend. The parser
|
||||
(`S7SzlParser`) is unit-tested against golden bytes; flipping the wire
|
||||
path on requires either an S7netplus PR or a raw-PDU helper. Once that's
|
||||
in, [`S7_1500SzlTests.System_CpuType_against_live_S7_1500_returns_non_empty_string`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SzlTests.cs)
|
||||
should be flipped from `[Fact(Skip = ...)]` to env-var-gated against the
|
||||
self-hosted runner with the lab rig.
|
||||
|
||||
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
|
||||
@@ -73,7 +89,22 @@ vars are unset.
|
||||
|
||||
Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
|
||||
`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
|
||||
`IPerCallHostResolver`.
|
||||
`IPerCallHostResolver`, `IAlarmSource` (PR 5.1 / #316, gated behind
|
||||
`EnableAlarms=true` — see capability matrix below).
|
||||
|
||||
## Capability matrix
|
||||
|
||||
| Capability | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| `IDriver` | yes | Lifecycle + health |
|
||||
| `IReadable` | yes | Sum-read for scalars; per-tag for bit / array |
|
||||
| `IWritable` | yes | Sum-write for scalars; per-tag for bit-RMW / array |
|
||||
| `ITagDiscovery` | yes | Pre-declared + opt-in symbol-table walk |
|
||||
| `ISubscribable` | yes | Native ADS notifications by default; poll fallback |
|
||||
| `IHostConnectivityProbe` | yes | `ReadStateAsync` + system-symbol diagnostics |
|
||||
| `IPerCallHostResolver` | yes | Tag → device hostAddress |
|
||||
| `IAlarmSource` (PR 5.1 / #316) | partial | Scaffold + unit-tested; live wire decode is best-effort against AMS port 110, see `docs/v3/twincat-eventlogger-spike.md` |
|
||||
| `IHistoryProvider` | no | Not in scope for this driver family |
|
||||
|
||||
## What it does NOT cover
|
||||
|
||||
@@ -118,11 +149,11 @@ Native ADS notifications fire on the PLC cycle boundary. The fake test
|
||||
harness assumes notifications fire on a timer the test controls;
|
||||
cycle-aligned firing under real PLC control is not verified.
|
||||
|
||||
### 6. Alarms / history
|
||||
### 6. History
|
||||
|
||||
Driver doesn't implement `IAlarmSource` or `IHistoryProvider` — not in
|
||||
scope for this driver family. TwinCAT 3's TcEventLogger could theoretically
|
||||
back an `IAlarmSource`, but shipping that is a separate feature.
|
||||
Driver doesn't implement `IHistoryProvider` — not in scope for this
|
||||
driver family. (Alarms now have a dedicated `IAlarmSource` bridge — see
|
||||
the capability matrix below + `docs/drivers/TwinCAT.md`.)
|
||||
|
||||
## When to trust TwinCAT tests, when to reach for a rig
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# TwinCAT driver — operator guide
|
||||
|
||||
Beckhoff TwinCAT 2 / TwinCAT 3 ADS driver. Talks to the runtime via
|
||||
`Beckhoff.TwinCAT.Ads` v6 (managed); requires a reachable AMS router on
|
||||
the host (local TwinCAT XAR, the standalone `Beckhoff.TwinCAT.Ads.TcpRouter`
|
||||
NuGet, or any Windows box with TwinCAT installed and an authorised AMS
|
||||
route).
|
||||
|
||||
## Configuration surface
|
||||
|
||||
`TwinCATDriverOptions` (one instance supports N AMS targets, each a
|
||||
`TwinCATDeviceOptions`). Wire format mirrors the C# class on the JSON
|
||||
side — every `init`-only property round-trips through
|
||||
`System.Text.Json` with the default options.
|
||||
|
||||
| Option | Type | Default | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `Devices` | `TwinCATDeviceOptions[]` | `[]` | One entry per AMS target. |
|
||||
| `Tags` | `TwinCATTagDefinition[]` | `[]` | Pre-declared symbol set. |
|
||||
| `Probe.Enabled` | `bool` | `true` | Per-tick `ReadStateAsync` against the runtime. |
|
||||
| `Probe.Interval` | `TimeSpan` | `5 s` | |
|
||||
| `Timeout` | `TimeSpan` | `2 s` | Per-operation timeout. |
|
||||
| `UseNativeNotifications` | `bool` | `true` | False = fall through to PollGroupEngine. |
|
||||
| `EnableControllerBrowse` | `bool` | `false` | Walk symbol table on `DiscoverAsync`. |
|
||||
| `MaxArrayExpansion` | `int` | `1024` | Per-element cutoff during nested-UDT browse. |
|
||||
| `EnableAlarms` (PR 5.1) | `bool` | `false` | Opt-in TC3 EventLogger bridge — see "Alarms" below. |
|
||||
|
||||
## Alarms (TC3 EventLogger bridge, PR 5.1 / #316)
|
||||
|
||||
When `EnableAlarms=true`, the driver implements `IAlarmSource` by
|
||||
opening a second `AdsClient` against AMS port **110**
|
||||
(`AMSPORT_EVENTLOG`) and adding a device notification on
|
||||
`ADSIGRP_TCEVENTLOG_ALARMS`. Subscribers receive `OnAlarmEvent`
|
||||
notifications for every transition the EventLogger surfaces (raise /
|
||||
clear / acknowledge).
|
||||
|
||||
### Decode caveat
|
||||
|
||||
Beckhoff doesn't ship a managed wrapper for `TcEventLogger` in the
|
||||
regular `Beckhoff.TwinCAT.Ads` v6 NuGet — only the C++ TcCOM headers
|
||||
exist. The driver therefore decodes the AMS-port-110 binary payload
|
||||
manually. The current implementation is best-effort: event class GUIDs
|
||||
and source names usually decode cleanly; some less-common fields may
|
||||
surface as `"Unknown"` until a follow-up PR lands a complete decoder.
|
||||
Spike output captured at
|
||||
[`docs/v3/twincat-eventlogger-spike.md`](../v3/twincat-eventlogger-spike.md).
|
||||
|
||||
### Wire path
|
||||
|
||||
| Layer | What it does |
|
||||
| --- | --- |
|
||||
| Primary `AdsClient` | The existing per-device session against the PLC runtime port (default `851`) — handles reads / writes / native subscriptions. |
|
||||
| Secondary `AdsClient` (alarms) | Opens against AMS port `110` on the same target NetId. Adds one device notification on `ADSIGRP_TCEVENTLOG_ALARMS` with a `length=...` payload covering the full alarm-list shape. |
|
||||
| `ITwinCATAlarmGate` (driver-internal) | Decodes incoming notifications into `TwinCATAlarmEvent` records (`EventClass`, `Source`, `Severity`, `Message`, `OccurrenceUtc`, `Acked`). |
|
||||
| `TwinCATAlarmSource` | Projects `TwinCATAlarmEvent` onto the driver-agnostic `IAlarmSource.OnAlarmEvent`. |
|
||||
|
||||
### Severity mapping (TC3 → OPC UA AC)
|
||||
|
||||
TC3 EventLogger severity is a 0–255 `USINT`. The driver maps it onto
|
||||
the four-bucket `AlarmSeverity` enum the OPC UA AC layer consumes:
|
||||
|
||||
| TC3 severity | `AlarmSeverity` |
|
||||
| --- | --- |
|
||||
| 0–64 | `Low` |
|
||||
| 65–128 | `Medium` |
|
||||
| 129–192 | `High` |
|
||||
| 193–255 | `Critical` |
|
||||
|
||||
### Acknowledge
|
||||
|
||||
`AcknowledgeAsync` round-trips through `ITwinCATAlarmGate.AcknowledgeAsync`,
|
||||
which writes to the EventLogger ack index group. Best-effort — the wire
|
||||
format isn't documented in managed code, so individual ack failures don't
|
||||
poison the batch and the gate returns silently when the EventLogger isn't
|
||||
configured.
|
||||
|
||||
### Disabling
|
||||
|
||||
`EnableAlarms=false` (default) returns a sentinel handle from
|
||||
`SubscribeAlarmsAsync` and never opens the secondary `AdsClient`.
|
||||
`OnAlarmEvent` simply never fires. Capability negotiation still works,
|
||||
which is why the driver advertises `IAlarmSource` unconditionally.
|
||||
|
||||
## CLI
|
||||
|
||||
The `otopcua-twincat-cli` test client exposes an `alarms` subcommand
|
||||
that wraps the bridge end-to-end:
|
||||
|
||||
```powershell
|
||||
dotnet run --project src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli -- alarms `
|
||||
--ams-net-id 5.23.91.23.1.1 --ams-port 851 `
|
||||
--source Conveyor1.MotorOverload
|
||||
```
|
||||
|
||||
See [`docs/Driver.TwinCAT.Cli.md`](../Driver.TwinCAT.Cli.md) for the
|
||||
full CLI surface.
|
||||
|
||||
## Test coverage
|
||||
|
||||
- **Unit**: `TwinCATAlarmSourceTests` covers (a) feature-gating off vs.
|
||||
on, (b) gate-event projection shape, (c) multi-event ordering, (d)
|
||||
source-filter matching, (e) acknowledge round-trip, (f) JSON DTO
|
||||
round-trip.
|
||||
- **Integration**: `TwinCATAlarmIntegrationTests.Driver_raises_alarm_event_when_PLC_logs_event`
|
||||
ships build-only in PR 5.1; the GVL + FB_AlarmHarness ship as XAE
|
||||
stubs at `tests/.../TwinCatProject/PLC/`. Once the XAR project
|
||||
imports them the test transitions skip → pass.
|
||||
|
||||
## See also
|
||||
|
||||
- [`docs/v3/twincat-eventlogger-spike.md`](../v3/twincat-eventlogger-spike.md)
|
||||
— spike output for the managed-wrapper question
|
||||
- [`docs/drivers/TwinCAT-Test-Fixture.md`](TwinCAT-Test-Fixture.md)
|
||||
— coverage map + capability matrix
|
||||
- [`docs/Driver.TwinCAT.Cli.md`](../Driver.TwinCAT.Cli.md) — CLI guide
|
||||
@@ -0,0 +1,20 @@
|
||||
# Feature gaps — driver-side limitations and decisions
|
||||
|
||||
Cross-driver registry of known capability gaps, the workaround we ship, and
|
||||
whether the gap is on the roadmap. Each row links to the driver-specific
|
||||
deep-dive document. Closed entries stay in the table for traceability — they
|
||||
are not deleted, only marked.
|
||||
|
||||
| # | Driver | Gap | Status | Workaround / decision | Roadmap | Reference |
|
||||
|---|--------|-----|--------|-----------------------|---------|-----------|
|
||||
| 1 | S7 | **Optimized DB / S7Plus** — S7netplus speaks classic S7comm only and cannot read S7-1200 / S7-1500 DBs that have "Optimized block access" checked (the TIA Portal V14+ default). Absolute-offset reads against an Optimized DB return `BadDeviceFailure`. | **Decided — Track 1 + Track 3 (closed by [#304](https://github.com/dohertj2/lmxopcua/issues/304))** | **Track 1 (docs):** operators uncheck "Optimized block access" in TIA Portal on every DB the driver reads, recompile, and download. **Track 3 (bridge):** for shops that won't or can't disable Optimized access, run an `OpcUaClient` driver instance against the S7-1500 V2.5+ CPU's onboard OPC UA server (Siemens runtime OPC UA license required). | **Track 2 (custom S7Plus library) is out of scope** unless a customer funds the ≥4-week initial implementation plus ongoing protocol-revision maintenance. Sharp7 / Snap7Net don't help — they are also classic-S7comm-only. | [`docs/v2/s7.md` § Optimized DB constraint](v2/s7.md#optimized-db-constraint-s7plus) · [`docs/drivers/OpcUaClient.md`](drivers/OpcUaClient.md) |
|
||||
|
||||
## How to read this table
|
||||
|
||||
- **Status** is one of `Open` (work pending), `Decided` (architectural
|
||||
decision made; docs reflect it; no code change planned), or
|
||||
`Closed` (delivered).
|
||||
- **Roadmap** captures whether the gap is funded for the next phase. A blank
|
||||
cell means "no roadmap; doc-only outcome."
|
||||
- The numeric **#** is stable — new rows append at the bottom and keep their
|
||||
number across deletions/edits so cross-references survive.
|
||||
+191
-10
@@ -44,12 +44,44 @@ reported wall-clock — keep CNC clocks on UTC so the dedup key
|
||||
`(OccurrenceTime, AlarmNumber, AlarmType)` stays stable across DST
|
||||
transitions.
|
||||
|
||||
## Write safety — issue #269, plan PR F4-b
|
||||
## Derived telemetry — issue #272 (plan PR F5-a)
|
||||
|
||||
The FOCAS driver supports `cnc_wrparam` and `cnc_wrmacro` writes behind
|
||||
multiple independent opt-ins. A misdirected parameter write can put the
|
||||
CNC in a bad state, so the runbook below MUST be followed before flipping
|
||||
the granular kill switches on.
|
||||
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)
|
||||
|
||||
@@ -72,6 +104,35 @@ the granular kill switches on.
|
||||
`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
|
||||
@@ -108,6 +169,14 @@ 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
|
||||
@@ -120,7 +189,8 @@ succeeded.
|
||||
"Writes": {
|
||||
"Enabled": true,
|
||||
"AllowMacro": true, // recipe / setpoint writes — operator role
|
||||
"AllowParameter": false // commissioning only — keep locked except during planned work
|
||||
"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",
|
||||
@@ -128,13 +198,124 @@ succeeded.
|
||||
"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 */ }
|
||||
"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` on for the commissioning window (and back off
|
||||
afterward) is the recommended deployment cadence — the granular kill
|
||||
switch is a lightweight runtime toggle, not a config-DB redeploy.
|
||||
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.
|
||||
|
||||
@@ -31,6 +31,8 @@ command ids (and their request/response payloads) don't drift between the
|
||||
| ... | ... | ... |
|
||||
| **`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
|
||||
@@ -123,6 +125,12 @@ Each profile owns:
|
||||
- `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.
|
||||
@@ -162,6 +170,37 @@ 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
|
||||
@@ -183,13 +222,173 @@ 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-b lands the .NET-side wire encoders + dispatch + status mapping
|
||||
unconditionally; the integration-test scaffolds at
|
||||
`tests/.../IntegrationTests/Series/ParameterWriteTests.cs` and
|
||||
`MacroWriteTests.cs` are deferred until the simulator + integration-test
|
||||
project land. Until then unit-test coverage in
|
||||
`FocasWriteParameterTests` / `FocasWriteMacroTests` exercises every
|
||||
same-process invariant against the in-memory `FakeFocasClient`.
|
||||
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`.
|
||||
|
||||
@@ -21,6 +21,8 @@ Each FOCAS-equivalent call gets a stable wire-protocol command id. Ids are
|
||||
| ... | ... | ... |
|
||||
| **`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`)
|
||||
@@ -154,3 +156,112 @@ 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.
|
||||
|
||||
+522
@@ -1,5 +1,190 @@
|
||||
# Siemens SIMATIC S7 (S7-1200 / S7-1500 / S7-300 / S7-400 / ET 200SP) — Modbus TCP quirks
|
||||
|
||||
> **Read first: [Optimized DB constraint (S7Plus)](#optimized-db-constraint-s7plus).**
|
||||
> S7netplus, the wire library this driver is built on, speaks classic S7comm
|
||||
> only — it cannot read Optimized-block-access DBs on S7-1200 / S7-1500. That
|
||||
> is the default in TIA Portal V14+ for new projects. If you skip the section
|
||||
> below, every absolute-offset read against a freshly-created S7-1500 project
|
||||
> will return `BadDeviceFailure`.
|
||||
|
||||
## Optimized DB constraint (S7Plus)
|
||||
|
||||
### Symptom
|
||||
|
||||
Against a default new S7-1500 TIA Portal project, an absolute-offset read like
|
||||
`DB1.DBW0` issued by the OtOpcUa S7 driver returns `BadDeviceFailure` (the
|
||||
S7netplus `PlcException` surfaces as `ErrorCode.WrongVarFormat` /
|
||||
`ErrorCode.ReadData` depending on firmware revision). No bytes are returned;
|
||||
the read never reaches the user data; the failure is identical whether
|
||||
PUT/GET is enabled or not.
|
||||
|
||||
### Why
|
||||
|
||||
The OtOpcUa S7 driver is built on
|
||||
[**S7netplus**](https://github.com/S7NetPlus/s7netplus), which implements
|
||||
**classic S7comm** only — the protocol historically used by S7-300 / S7-400
|
||||
and the legacy "compatibility" path on S7-1200 / S7-1500. Classic S7comm
|
||||
addresses DB contents by **absolute byte offset**: `DB1.DBW0` literally means
|
||||
"give me 2 bytes starting at byte 0 of DB number 1". This works as long as
|
||||
the byte offsets in the program match the byte offsets on the wire.
|
||||
|
||||
S7-1200 V4 and S7-1500 introduced **Optimized block access**. When checked,
|
||||
the TIA Portal compiler is free to **reorder DB members**, insert padding for
|
||||
alignment, and store members in CPU-internal memory that the absolute-offset
|
||||
read protocol cannot reach. There are no fixed byte offsets to address — the
|
||||
only way to read an Optimized DB is by **symbolic name**, which requires
|
||||
**S7Plus** (the post-2014 protocol Siemens uses for TIA-Portal-aware tooling
|
||||
and OPC UA gateways).
|
||||
|
||||
S7Plus is undocumented by Siemens. A community Wireshark dissector exists
|
||||
(`s7comm-plus`), but no production-ready open-source library implements the
|
||||
write/subscribe surface end-to-end. **S7netplus does not, and is not on a
|
||||
roadmap to, support S7Plus.** Snap7 v2 / Snap7Net and the various Sharp7
|
||||
forks are also classic-S7comm-only.
|
||||
|
||||
### Default to know about
|
||||
|
||||
In **TIA Portal V14 and newer, "Optimized block access" is checked by default
|
||||
on every newly-created DB**. A customer who clicks "Add new block → Data
|
||||
block → OK" on a fresh S7-1500 project gets an Optimized DB. The driver
|
||||
cannot read it.
|
||||
|
||||
### Supported workarounds
|
||||
|
||||
The OtOpcUa project supports two workarounds. Pick one per deployment.
|
||||
|
||||
#### Track 1 — Disable Optimized block access in TIA Portal
|
||||
|
||||
Per DB the driver reads:
|
||||
|
||||
1. In TIA Portal, open the project tree → `<PLC>` → **Program blocks** →
|
||||
right-click the DB → **Properties**.
|
||||
2. In the **Attributes** tab, **uncheck "Optimized block access"**.
|
||||
3. **Compile** the program.
|
||||
4. **Download** to the PLC (download the changed block; the CPU will go into
|
||||
STOP if the DB layout changed and download-without-reinitialize is
|
||||
refused — schedule a maintenance window).
|
||||
|
||||
After this, `DB1.DBW0` and friends address absolute byte offsets again and
|
||||
the OtOpcUa S7 driver reads through unmodified.
|
||||
|
||||
**Trade-off:** Optimized DBs are slightly faster for *the PLC program
|
||||
itself* to access (better alignment, sometimes better cache behaviour) and
|
||||
let the compiler add/remove DB members without renumbering offsets in user
|
||||
code. Disabling Optimized access trades a tiny amount of CPU-side
|
||||
performance and a layout-stability guarantee for absolute-offset wire
|
||||
addressability. For DBs that exist only as a Modbus / S7comm gateway buffer
|
||||
(common pattern), there is no real downside.
|
||||
|
||||
This is the same prerequisite called out in
|
||||
["Optimized block access — must be off"](#optimized-block-access--must-be-off)
|
||||
and ["Address / DB Mapping → MB_HOLD_REG"](#address--db-mapping) for the
|
||||
Modbus-TCP path; the constraint is the same and stems from the same
|
||||
absolute-offset-only assumption.
|
||||
|
||||
#### Track 3 — Bridge via the OpcUaClient driver against the CPU's onboard OPC UA server
|
||||
|
||||
S7-1500 firmware **V2.5 and later** ship with an **integrated OPC UA
|
||||
server** running on the CPU's PROFINET port (default port 4840). Once
|
||||
enabled in TIA Portal it exposes the entire symbol table — including
|
||||
Optimized DBs — through standard OPC UA, by symbolic name. There is no S7
|
||||
protocol involved at all from the OtOpcUa side.
|
||||
|
||||
Configure the bridge once:
|
||||
|
||||
1. **TIA Portal side**:
|
||||
- Open the CPU's properties → **OPC UA** → **General** → check
|
||||
**Activate OPC UA server**.
|
||||
- Set the server port (default 4840) and security policy. For a quick
|
||||
bring-up, allow `None` + `UserName` and create a server certificate;
|
||||
for production, use Basic256Sha256 with a CA-issued cert.
|
||||
- Under **OPC UA** → **Server interfaces**, expose the symbols/tags the
|
||||
OtOpcUa side should see. (Whole-symbol-table exposure works; a
|
||||
curated server interface is more secure and faster.)
|
||||
- Compile and download.
|
||||
- Note: this requires a **runtime OPC UA license on the CPU**
|
||||
(Siemens SIMATIC NET OPC UA server license, typically activated via
|
||||
SIMATIC SUM). The license is per CPU, not per client.
|
||||
|
||||
2. **OtOpcUa side** — register an `OpcUaClient` driver instance pointing
|
||||
at the CPU. Minimal `DriverConfig` JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"Driver": "OpcUaClient",
|
||||
"Name": "PLC1500_Onboard",
|
||||
"Options": {
|
||||
"EndpointUrl": "opc.tcp://10.0.0.42:4840",
|
||||
"SecurityMode": "SignAndEncrypt",
|
||||
"SecurityPolicy": "Basic256Sha256",
|
||||
"UserName": "OtOpcUa",
|
||||
"Password": "<from secret store>",
|
||||
"WatchModelChanges": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The driver handles browse, read, write, and subscriptions through the
|
||||
CPU's symbolic name space. Optimized DBs Just Work — the CPU resolves
|
||||
names internally, so the wire never sees a byte offset.
|
||||
|
||||
See [`docs/drivers/OpcUaClient.md`](../drivers/OpcUaClient.md) for the full
|
||||
configuration surface (reverse connect, model-change re-import, failover,
|
||||
aggregate functions, redundancy via `ServerUriArray`, etc.).
|
||||
|
||||
**When to use Track 3 over Track 1**:
|
||||
|
||||
- The DB layout is owned by an upstream Siemens engineering team that won't
|
||||
disable Optimized access (legitimate concern: shared-DB constraints,
|
||||
compile-time member-renumbering, application notes that mandate optimized
|
||||
blocks).
|
||||
- The customer already licenses OPC UA on the CPU.
|
||||
- Symbolic addressing is preferred end-to-end (no byte-offset bookkeeping
|
||||
in the OtOpcUa tag list; tags survive DB-member additions).
|
||||
- S7-300 / S7-400 are out of scope on this CPU (the onboard OPC UA server
|
||||
is S7-1500 V2.5+ only — see V2.5 firmware change list).
|
||||
|
||||
**When to use Track 1 over Track 3**:
|
||||
|
||||
- The CPU is S7-1200 (no onboard OPC UA server even on the V4 firmware
|
||||
line) or older S7-1500 firmware (< V2.5).
|
||||
- The customer won't pay for the SIMATIC NET OPC UA server license on the
|
||||
CPU.
|
||||
- The DBs in question exist purely as gateway buffers and have no
|
||||
significant CPU-program access pattern that would benefit from
|
||||
Optimized access.
|
||||
|
||||
### Track 2 — out of scope
|
||||
|
||||
For completeness, the **Track 2** option that was evaluated and rejected:
|
||||
migrate the OtOpcUa S7 driver off S7netplus to a library that speaks
|
||||
S7Plus. The candidates were:
|
||||
|
||||
- **Snap7 v2 / Snap7Net** — classic S7comm only. Same Optimized-DB
|
||||
limitation. Not a step forward.
|
||||
- **Sharp7 community forks** — partial S7-1200 / S7-1500 PUT/GET semantics
|
||||
but still classic-S7comm wire format. Not a step forward.
|
||||
- **Custom S7Plus implementation** — possible in principle (the Wireshark
|
||||
`s7comm-plus` dissector covers the wire format), but estimated **≥4
|
||||
weeks** of engineering for a minimal read/write/subscribe surface, plus
|
||||
ongoing maintenance every time Siemens revs the protocol version
|
||||
(which they do silently with each TIA Portal release).
|
||||
|
||||
**Track 2 is not on the OtOpcUa roadmap** unless a specific customer funds
|
||||
the engineering and ongoing maintenance. Track 1 + Track 3 together cover
|
||||
every shipping S7 deployment we have visibility into.
|
||||
|
||||
### Pre-flight diagnostics
|
||||
|
||||
The driver does not currently auto-detect Optimized DBs from the
|
||||
`BadDeviceFailure` shape (the same error code is returned for "DB doesn't
|
||||
exist", "DB exists but is too short", etc.). On first encounter of a
|
||||
device-failure error, check the suspect DB's properties in TIA Portal
|
||||
**before** chasing wire-level theories. The auto-detect would require an
|
||||
SZL probe or a symbolic round-trip; tracked but not a v2 deliverable.
|
||||
|
||||
---
|
||||
|
||||
Siemens S7 PLCs do *not* speak Modbus TCP natively at the OS/firmware level. Every
|
||||
S7 Modbus-TCP-server deployment is either (a) the **`MB_SERVER`** library block
|
||||
running on the CPU's PROFINET port (S7-1200 / S7-1500 / CPU 1510SP-series
|
||||
@@ -940,6 +1125,343 @@ 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. See
|
||||
[Optimized DB constraint (S7Plus)](#optimized-db-constraint-s7plus) at the
|
||||
top of this document for the project-wide decision (Track 1 disable in TIA
|
||||
Portal, or Track 3 bridge via the OpcUaClient driver against the CPU's
|
||||
onboard OPC UA server).
|
||||
|
||||
### 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.
|
||||
|
||||
## CPU diagnostics (SZL)
|
||||
|
||||
PR-S7-E1 / [#302](https://github.com/dohertj2/lmxopcua/issues/302) — every S7
|
||||
CPU answers SZL (System Status List) queries with metadata about itself: CPU
|
||||
type, firmware, order number, scan-cycle min/avg/max, and the diagnostic
|
||||
buffer ring. The driver surfaces those through a virtual `@System.*` address
|
||||
space dispatched against the SZL sub-protocol — no DB / merker tag declarations
|
||||
required.
|
||||
|
||||
### Opt-in: `ExposeSystemTags`
|
||||
|
||||
Off by default. Set `ExposeSystemTags = true` in `S7DriverOptions` and
|
||||
`DiscoverAsync` adds a `Diagnostics/` sub-folder under the driver root with
|
||||
the variables listed below. Knobs:
|
||||
|
||||
| Option | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `ExposeSystemTags` | `false` | Master switch. When `false` the SZL surface is invisible — no extra browse nodes, no extra wire traffic. |
|
||||
| `DiagBufferDepth` | `10` | Number of diagnostic-buffer entries to discover under `DiagBuffer/Entry[N]`. Capped at 50. |
|
||||
| `SzlCacheTtl` | `5 s` | TTL for the per-driver SZL cache. A burst of `@System.*` reads inside this window reuses one wire response per SZL ID. Set to `TimeSpan.Zero` to disable caching (every read hits the wire). |
|
||||
|
||||
### `@System.*` address table
|
||||
|
||||
| Address | OPC UA type | SZL ID | Index | What it is |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `@System.CpuType` | `String` | `0x0011` | `0x0000` | CPU friendly name (SZL index 0x0007) or MLFB fallback. |
|
||||
| `@System.Firmware` | `String` | `0x0011` | `0x0000` | Firmware version, formatted `Vmaj.min.patch`. |
|
||||
| `@System.OrderNo` | `String` | `0x0011` | `0x0000` | MLFB / order number, e.g. `6ES7 516-3AN01-0AB0`. |
|
||||
| `@System.CycleMs.Min` | `Float64` | `0x0132` | `0x0005` | Shortest scan cycle observed since last reset, in milliseconds. |
|
||||
| `@System.CycleMs.Max` | `Float64` | `0x0132` | `0x0005` | Longest scan cycle observed since last reset, in milliseconds. |
|
||||
| `@System.CycleMs.Avg` | `Float64` | `0x0132` | `0x0005` | Rolling average scan-cycle time, in milliseconds. |
|
||||
| `@System.DiagBuffer.Entry[N]` | `String` | `0x00A0` | `0x0000` | Diagnostic-buffer entry rendered as `<UTC ISO-8601> \| 0x<event id> \| prio=<N> \| <event text>`. `N` ranges from `0` (most recent) through `DiagBufferDepth-1`. |
|
||||
|
||||
The diagnostic-buffer entries surface as flat strings rather than a structured
|
||||
DataType so dashboards / log scrapers can split / grep them without a custom
|
||||
schema.
|
||||
|
||||
### What's wired today vs not-supported
|
||||
|
||||
S7netplus 0.20 builds SZL request packages internally
|
||||
(`SzlReadRequestPackage` / `WriteSzlReadRequest`) but does **not** expose a
|
||||
public `ReadSzlAsync` API. Until S7netplus catches up (or we ship a raw S7comm
|
||||
PDU helper that side-steps the library), the production
|
||||
[`S7NetSzlReader`](../../src/ZB.MOM.WW.OtOpcUa.Driver.S7/Szl/S7NetSzlReader.cs)
|
||||
returns `null` on every call and every `@System.*` read surfaces as
|
||||
`BadNotSupported`. The browse tree still lights up — operators can wire
|
||||
clients against it — only the values come back not-supported.
|
||||
|
||||
The parser code (`S7SzlParser`) is fully tested against golden bytes
|
||||
regardless. Flipping the wire path on is a one-method change in
|
||||
`S7NetSzlReader` once the upstream surface is available; no parser / dispatch
|
||||
/ cache changes needed.
|
||||
|
||||
snap7 (the simulator backing the integration profile) also doesn't implement
|
||||
SZL — the integration test
|
||||
[`S7_1500SzlTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SzlTests.cs)
|
||||
asserts the not-supported semantics against snap7 + parks the live-firmware
|
||||
test behind `[Fact(Skip = ...)]` until the wire path lights up.
|
||||
|
||||
### Caching
|
||||
|
||||
Diagnostics shouldn't poll faster than `SzlCacheTtl` — a 100 ms HMI
|
||||
subscription on every `@System.*` tag would otherwise hammer the comms
|
||||
mailbox for data that doesn't change between scans. The per-driver
|
||||
[`S7SzlCache`](../../src/ZB.MOM.WW.OtOpcUa.Driver.S7/Szl/S7SzlCache.cs)
|
||||
de-dups concurrent reads by `(SzlId, SzlIndex)`; one SZL 0x0011 round-trip
|
||||
backs `CpuType` + `Firmware` + `OrderNo` for the whole TTL window. Negative
|
||||
results (SZL not supported) are cached just as aggressively — repeatedly
|
||||
hammering a CPU that already said "not supported" wouldn't help.
|
||||
|
||||
`SzlCacheTtl = TimeSpan.Zero` disables caching entirely; useful for
|
||||
diagnostics tests where you want every read to hit the wire.
|
||||
|
||||
### JSON config example
|
||||
|
||||
```json
|
||||
{
|
||||
"DriverConfig": {
|
||||
"Host": "192.168.10.50",
|
||||
"Port": 102,
|
||||
"CpuType": "S71500",
|
||||
"ExposeSystemTags": true,
|
||||
"DiagBufferDepth": 20,
|
||||
"SzlCacheTtl": "00:00:05"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## PLC password / protection levels
|
||||
|
||||
PR-S7-E2 (issue #303) adds a connection-level password option for hardened
|
||||
deployments. The driver emits the password to the PLC immediately after
|
||||
`OpenAsync` succeeds and before the pre-flight PUT/GET probe runs (the same
|
||||
pre-flight read that would otherwise be the first operation a hardened CPU
|
||||
refuses).
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Default | Purpose |
|
||||
| ----------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Password` | `null` | Connection-level password. Secret — never logged. `null` or empty = no password is sent. |
|
||||
| `ProtectionLevel` | `Auto` | Declarative hint about the PLC's protection scheme. One of `Auto`, `None`, `Level1`, `Level2`, `Level3` (S7-300/400 SFC 109/110 levels), or `ConnectionMechanism` (S7-1200/1500 TIA Portal "Protection & Security" pane). |
|
||||
|
||||
### S7-300 / S7-400 protection levels (1, 2, 3)
|
||||
|
||||
S7-300/400 firmware exposes three CPU-side protection levels:
|
||||
|
||||
* **Level 1** — write protection. Reads work without a password; writes
|
||||
(parameter, DB, M/Q changes) require an unlock.
|
||||
* **Level 2** — read and write protection. Both kinds of operation require
|
||||
the password.
|
||||
* **Level 3** — full protection. Even online presence detection / status
|
||||
list reads require the password.
|
||||
|
||||
Set `ProtectionLevel = Level1` / `Level2` / `Level3` and supply
|
||||
`Password` to match the level configured in the CPU's HW Config dialog.
|
||||
The level value is descriptive — the driver doesn't switch behaviour
|
||||
between Level1/2/3, since the wire-side `SendPassword` is the same call
|
||||
in all three cases. The hint surfaces in the driver-diagnostics RPC so a
|
||||
"PLC said Level 3 but config says Level 1" mismatch is spottable from the
|
||||
Admin UI.
|
||||
|
||||
### S7-1200 / S7-1500 connection mechanism
|
||||
|
||||
S7-1200/1500 firmware uses a different gate: TIA Portal's "Protection &
|
||||
Security" pane has a single **Connection Mechanism** dropdown that, when
|
||||
set to anything stricter than "No access", requires every PG/HMI/SCADA
|
||||
connection to authenticate after the COTP handshake. The wire-level
|
||||
exchange is the same `SendPassword` call but the diagnostic flag is
|
||||
distinct, so set `ProtectionLevel = ConnectionMechanism` for these
|
||||
families.
|
||||
|
||||
### No-log invariant
|
||||
|
||||
`Password` is a secret. The driver MUST NOT include the password value in
|
||||
log lines, exception messages, or diagnostic surfaces. Specifically:
|
||||
|
||||
* `S7DriverOptions.ToString()` redacts the field as `***`.
|
||||
* `S7Driver`'s success log line is `S7 password sent for {Host}` —
|
||||
identifier-only, no value.
|
||||
* The "S7netplus does not expose SendPassword" warning logs the host name
|
||||
and driver instance ID only, never the password.
|
||||
* Authentication-failure exceptions wrap the inner `S7.Net.PlcException`
|
||||
but their own message says only "S7 password authentication failed for
|
||||
host '{Host}'" — no password value.
|
||||
|
||||
Any new logging surface that flows an `S7DriverOptions` value MUST
|
||||
continue to redact. See the FOCAS-F4-d
|
||||
`docs/v2/focas-deployment.md` § "FOCAS password handling" entry for the
|
||||
sister no-log discipline on the FOCAS driver.
|
||||
|
||||
### Library limitation — S7netplus 0.20
|
||||
|
||||
**S7netplus 0.20.0 (the pinned dependency) does not expose a public
|
||||
`SendPassword` method.** The driver discovers the method reflectively
|
||||
(checking for `SendPasswordAsync(string, CancellationToken)` first, then
|
||||
`SendPassword(string)`) so a future minor release that ships the API
|
||||
will be picked up automatically without a code change here.
|
||||
|
||||
Until the upstream lands, configuring `Password` on a hardened CPU
|
||||
produces this Init-time warning:
|
||||
|
||||
```
|
||||
[Warning] S7 password is set on driver '<DriverInstanceId>' against
|
||||
host '<Host>', but the linked S7netplus library does not expose
|
||||
SendPassword; password is being ignored at the wire. Hardened-CPU
|
||||
connect may fail at first read.
|
||||
```
|
||||
|
||||
Init still completes — the COTP/S7comm handshake itself doesn't require
|
||||
the password — but the first read against a hardened CPU will surface
|
||||
`BadDeviceFailure` because PUT/GET-disabled and "level-3 protection"
|
||||
return identical "function not allowed" PDUs at the wire layer.
|
||||
|
||||
If your S7-1200/1500 deployment requires `ConnectionMechanism`, the
|
||||
near-term workarounds are:
|
||||
|
||||
1. **Lower the protection setting** in TIA Portal's Protection & Security
|
||||
pane to "Full access (no protection)" for the duration of the
|
||||
evaluation.
|
||||
2. **Configure a separate non-hardened connection** on a CP module that
|
||||
the driver can target while keeping the production endpoint hardened.
|
||||
3. **Track upstream S7netplus** for a `SendPassword` PR (the package owner
|
||||
has discussed adding it; see issue
|
||||
<https://github.com/S7NetPlus/s7netplus/issues>).
|
||||
|
||||
For S7-300/400 CPUs, levels 1 and 2 leave at least *read* access open
|
||||
without a password, so most monitoring use cases work without
|
||||
`SendPassword` until the library catches up — only Level 3 and the
|
||||
S7-1200/1500 ConnectionMechanism require the wire-level unlock.
|
||||
|
||||
### JSON config example
|
||||
|
||||
```json
|
||||
{
|
||||
"DriverConfig": {
|
||||
"Host": "192.168.10.50",
|
||||
"Port": 102,
|
||||
"CpuType": "S71500",
|
||||
"Password": "tia-portal-set-password",
|
||||
"ProtectionLevel": "ConnectionMechanism"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 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,101 @@
|
||||
# TC3 EventLogger spike — managed-wrapper investigation
|
||||
|
||||
**Question (b) from the PR 5.1 / #316 plan**: Does Beckhoff publish a
|
||||
managed `TcEventLogger` wrapper that lets the driver subscribe to
|
||||
alarms via `EventLogger.AlarmRaised` instead of decoding AMS port 110
|
||||
notifications by hand?
|
||||
|
||||
## TL;DR
|
||||
|
||||
**No managed wrapper.** The `Beckhoff.TwinCAT.Ads` v6 NuGet (the regular
|
||||
managed SDK the driver already takes a dependency on) ships only the
|
||||
ADS read/write/notification surface — it does not surface
|
||||
`TcEventLogger` on the .NET side. The C++ TcCOM headers
|
||||
(`TcEventLogger.h` etc.) exist in the on-box TwinCAT install
|
||||
(`%TC_INSTALLPATH%\Components\TcEventLogger\`) but there is no managed
|
||||
projection of those COM interfaces in any official Beckhoff NuGet as
|
||||
of TC3 build 4024.x.
|
||||
|
||||
Decision: **ship a binary-protocol decode against AMS port 110**
|
||||
(`AMSPORT_EVENTLOG`) with index group `ADSIGRP_TCEVENTLOG_ALARMS`. The
|
||||
decoder lands in `AdsTwinCATAlarmGate` (production) and `NullTwinCATAlarmGate`
|
||||
(default / no-op). Best-effort field decoding — fields the protocol
|
||||
analyzer hasn't yet identified surface as `"Unknown"`.
|
||||
|
||||
## What was checked
|
||||
|
||||
| Source | Result |
|
||||
| --- | --- |
|
||||
| `Beckhoff.TwinCAT.Ads` v6.x NuGet, namespace inventory | `TwinCAT.Ads`, `TwinCAT.Ads.SumCommand`, `TwinCAT.Ads.TypeSystem`, `TwinCAT.TypeSystem`. **No** `TcEventLogger` namespace. |
|
||||
| `Beckhoff.TwinCAT.Ads.TcpRouter` v6.x NuGet | Router only; no EventLogger surface. |
|
||||
| Beckhoff Information System (Infosys) → TwinCAT 3 → EventLogger → API reference | Documents only the C++ TcCOM API + the PLC-side `Tc3_EventLogger` library. No managed-language section. |
|
||||
| TwinCAT install on dev box → `Components\TcEventLogger\` | C++ headers + DLL only; the `.tlb` could be COM-imported via `tlbimp` but that creates a brittle install-path-coupled binding. |
|
||||
| Public Beckhoff GitHub orgs | `Beckhoff/TwinCAT-Tools-Library` etc. — no managed EventLogger wrapper. |
|
||||
|
||||
## Why decode at the wire?
|
||||
|
||||
A `tlbimp` projection of the on-box TcCOM `.tlb` would technically work
|
||||
but introduces three problems:
|
||||
|
||||
1. **Install-path coupling** — the `.tlb` lives under
|
||||
`%TC_INSTALLPATH%`; the driver would need to find / load it at
|
||||
runtime + ship a per-build interop assembly.
|
||||
2. **Bitness lock-in** — TcCOM is x86; the driver builds AnyCPU.
|
||||
3. **No upgrade path** — Beckhoff makes no API-stability guarantees
|
||||
on the TcCOM surface across TC3 builds.
|
||||
|
||||
Direct AMS-port-110 notifications keep the driver coupled to **only**
|
||||
the `Beckhoff.TwinCAT.Ads` v6 NuGet's stable wire surface. Trade-off:
|
||||
the binary protocol is undocumented in managed-code form; we work
|
||||
around that by:
|
||||
|
||||
- Writing a permissive decoder that surfaces unrecognised fields as
|
||||
`"Unknown"` rather than throwing.
|
||||
- Gating the entire bridge behind `EnableAlarms=false` so deployments
|
||||
that don't run TcEventLogger pay no cost.
|
||||
- Logging the raw payload at TRACE level when a decode partially
|
||||
succeeds, so operators can hand the bytes to the integration team
|
||||
for follow-up decoding.
|
||||
|
||||
## What ships in PR 5.1
|
||||
|
||||
- `ITwinCATAlarmGate` interface — driver-internal seam.
|
||||
- `NullTwinCATAlarmGate` — default no-op implementation, used when
|
||||
`EnableAlarms=false` and as the unit-test substitute base.
|
||||
- `TwinCATAlarmSource` — projects `TwinCATAlarmEvent` onto the
|
||||
driver's `IAlarmSource` surface; handles subscription bookkeeping
|
||||
+ source-id filtering.
|
||||
- `TwinCATDriver` declares `IAlarmSource`; methods short-circuit when
|
||||
the gate is null (default).
|
||||
- Production `AdsTwinCATAlarmGate` (with the binary decoder) is
|
||||
scaffolded — the wire path is best-effort and can be tightened in
|
||||
a follow-up PR without touching the driver's public surface.
|
||||
|
||||
## Open questions for the follow-up PR
|
||||
|
||||
1. **Exact byte layout** of the alarm-list notification payload —
|
||||
needs a wire trace from a known-good TC3 EventLogger configuration
|
||||
compared against the C++ `TcEventLogger.h` struct definitions.
|
||||
2. **Acknowledge wire format** — the `AcknowledgeAsync` path writes
|
||||
to the EventLogger ack index group; the operand layout (event-id
|
||||
vs. condition-id mapping) is best-effort in PR 5.1.
|
||||
3. **Multi-language alarm text** — TC3 EventLogger supports localized
|
||||
message texts. The decoder should pick the runtime's configured
|
||||
language; PR 5.1 falls back to the first text it finds.
|
||||
4. **Active-alarm refresh on subscribe** — TC3's `RefreshActive`
|
||||
semantic is documented in C++ but not exposed through AMS port 110
|
||||
notifications directly. The follow-up PR should investigate
|
||||
whether a separate `Read` against the active-alarm-list index
|
||||
group can backfill the snapshot at subscribe time.
|
||||
|
||||
## Why land PR 5.1 anyway
|
||||
|
||||
The driver's public `IAlarmSource` surface, the options knob, the unit
|
||||
tests, the CLI verb, and the integration-test scaffold are all
|
||||
independent of the wire decoder's completeness. Deferring the entire
|
||||
PR until decode coverage is 100 % blocks every consumer that just
|
||||
needs the capability negotiation contract (the OPC UA server's
|
||||
`DriverNodeManager` checks `driver is IAlarmSource` to decide whether
|
||||
to expose the alarm subtree). Shipping the gated scaffold now lets
|
||||
those consumers light up without committing to a specific decoder
|
||||
quality bar.
|
||||
@@ -60,6 +60,12 @@
|
||||
"historyLookbackSec": 3600
|
||||
},
|
||||
|
||||
"opcuaclient": {
|
||||
"$comment": "Optional upstream-redundancy probe (PR-14). When both primaryUrl and secondaryUrl are set, test-opcuaclient.ps1 runs an extra bridged read while both upstreams are reachable. Leave keys absent to skip the redundancy stage. The OtOpcUa server's DriverConfig for the OpcUaClient instance must already have Redundancy.Enabled=true + the same EndpointUrls list; this script doesn't reconfigure the driver.",
|
||||
"primaryUrl": "opc.tcp://localhost:50000",
|
||||
"secondaryUrl": "opc.tcp://localhost:50002"
|
||||
},
|
||||
|
||||
"phase7": {
|
||||
"$comment": "Virtual tags + scripted alarms. The VirtualNodeId must resolve to a server-side virtual tag whose script reads the modbus InputNodeId and writes VT = input * 2. The AlarmNodeId is the ConditionId of a scripted alarm that fires when VT > 100.",
|
||||
"modbusEndpoint": "127.0.0.1:5502",
|
||||
|
||||
@@ -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 }
|
||||
@@ -39,6 +39,31 @@
|
||||
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(
|
||||
@@ -47,7 +72,13 @@ param(
|
||||
[string]$Address = "N7:5",
|
||||
[string]$OpcUaUrl = "opc.tcp://localhost:4840",
|
||||
[Parameter(Mandatory)] [string]$BridgeNodeId,
|
||||
[string]$DiagnosticsRequestCountNodeId
|
||||
[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"
|
||||
@@ -245,5 +276,67 @@ finally {
|
||||
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 }
|
||||
|
||||
@@ -47,6 +47,25 @@
|
||||
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(
|
||||
@@ -57,7 +76,9 @@ param(
|
||||
[Parameter(Mandatory)] [string]$BridgeNodeId,
|
||||
[switch]$Write,
|
||||
[string]$ParamAddress = "PARAM:1815",
|
||||
[string]$MacroAddress = "MACRO:500"
|
||||
[string]$MacroAddress = "MACRO:500",
|
||||
[string]$PmcBitAddress = "R100.3",
|
||||
[string]$CncPassword = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -76,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 `
|
||||
@@ -146,6 +176,27 @@ if ($Write) {
|
||||
} 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
|
||||
|
||||
@@ -54,8 +54,29 @@
|
||||
.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(
|
||||
@@ -63,7 +84,26 @@ param(
|
||||
[string]$UpstreamUrl = "opc.tcp://localhost:50000",
|
||||
[Parameter(Mandatory)] [string]$BridgedNodeId,
|
||||
[string]$UpstreamNodeId = "ns=3;s=StepUp",
|
||||
[int]$ChangeWaitSec = 10
|
||||
[int]$ChangeWaitSec = 10,
|
||||
[switch]$ReverseConnect,
|
||||
[string]$ReverseListenerUrl = "opc.tcp://0.0.0.0:4844",
|
||||
# PR-12: HistoryReadEvents passthrough check. Requires the upstream to be running
|
||||
# in alarm-history mode (opc-plc --alm) AND the OtOpcUa server to expose a notifier
|
||||
# node bridged to the upstream's events source. The CLI doesn't have a dedicated
|
||||
# event-history command yet; this stage runs a regular historyread against the
|
||||
# bridged notifier and confirms the gateway round-trips the request without
|
||||
# surfacing BadHistoryOperationUnsupported, which would indicate the filter-aware
|
||||
# ReadEventsAsync path lost wiring.
|
||||
[switch]$HistoryEvents,
|
||||
[string]$EventsNotifierNodeId = "i=2253",
|
||||
# PR-14: upstream-redundancy probe. Passes the primary + secondary URLs
|
||||
# straight through to the gateway driver via DriverConfig (operator must have
|
||||
# already wired Redundancy.Enabled=true on the OpcUaClient instance — this
|
||||
# script doesn't reconfigure the driver, only verifies the bridged read still
|
||||
# works while both upstreams are reachable, and that the driver's redundancy
|
||||
# diagnostics are non-null). Stage is no-op when neither URL is provided.
|
||||
[string]$PrimaryUrl,
|
||||
[string]$SecondaryUrl
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -73,6 +113,11 @@ $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
|
||||
@@ -136,6 +181,55 @@ if ($triggerCmd) {
|
||||
$results += [pscustomobject]@{ Stage = "Topology-change"; Status = "SKIP" }
|
||||
}
|
||||
|
||||
# Stage 5 (gated): HistoryReadEvents passthrough
|
||||
#
|
||||
# PR-12 lands the filter-aware IHistoryProvider.ReadEventsAsync overload on the
|
||||
# OPC UA Client driver. End-to-end coverage requires:
|
||||
# (a) the upstream in alarm-history mode (opc-plc --alm or a real server);
|
||||
# (b) the OtOpcUa server forwarding HistoryReadEvents to the gateway driver.
|
||||
# Gated behind -HistoryEvents because the default opc-plc fixture image isn't
|
||||
# launched with --alm. When set, the stage issues a historyread against the
|
||||
# bridged notifier ($EventsNotifierNodeId) and confirms the gateway returns
|
||||
# the request without BadHistoryOperationUnsupported.
|
||||
# Stage 6 (gated): upstream-redundancy probe (PR-14)
|
||||
#
|
||||
# When -PrimaryUrl + -SecondaryUrl are both supplied, the script runs an extra
|
||||
# read against the bridged NodeId and reports whether the gateway is still
|
||||
# answering. The actual ServiceLevel-driven failover is observable only on the
|
||||
# server side (driver-diagnostics RPC reports RedundancyFailoverCount); this
|
||||
# stage is a smoke check that the bridged path keeps round-tripping while
|
||||
# both upstreams are reachable. Drive a real failover by writing to the
|
||||
# primary's ServiceLevel node from outside this script.
|
||||
if ($PrimaryUrl -and $SecondaryUrl) {
|
||||
Write-Host "[INFO] Upstream redundancy probe: primary=$PrimaryUrl secondary=$SecondaryUrl"
|
||||
$results += Test-Probe `
|
||||
-Name "OpcUaClient redundancy bridged-read" `
|
||||
-Cmd $opcUaCli `
|
||||
-Args @("read", "-u", $OpcUaUrl, "-n", $BridgedNodeId)
|
||||
} else {
|
||||
if (-not $PrimaryUrl -and -not $SecondaryUrl) {
|
||||
Write-Host "[INFO] Upstream redundancy stage skipped (set -PrimaryUrl and -SecondaryUrl to enable)."
|
||||
$results += [pscustomobject]@{ Stage = "Upstream-redundancy"; Status = "SKIP" }
|
||||
}
|
||||
}
|
||||
|
||||
if ($HistoryEvents) {
|
||||
Write-Host "[INFO] HistoryEvents stage: issuing historyread against $EventsNotifierNodeId"
|
||||
$start = (Get-Date).ToUniversalTime().AddMinutes(-30).ToString("o")
|
||||
$end = (Get-Date).ToUniversalTime().AddMinutes(1).ToString("o")
|
||||
$eventOut = & $opcUaCli.Cmd @($opcUaCli.Args + @(
|
||||
"historyread", "-u", $OpcUaUrl, "-n", $EventsNotifierNodeId,
|
||||
"--start", $start, "--end", $end))
|
||||
if ($LASTEXITCODE -eq 0 -and $eventOut -notmatch "BadHistoryOperationUnsupported") {
|
||||
$results += [pscustomobject]@{ Stage = "HistoryReadEvents"; Status = "PASS" }
|
||||
} elseif ($eventOut -match "BadHistoryOperationUnsupported") {
|
||||
Write-Host "[INFO] Upstream returned BadHistoryOperationUnsupported — re-run with --alm + a notifier that has event history."
|
||||
$results += [pscustomobject]@{ Stage = "HistoryReadEvents"; Status = "SKIP" }
|
||||
} else {
|
||||
$results += [pscustomobject]@{ Stage = "HistoryReadEvents"; Status = "FAIL" }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== test-opcuaclient.ps1 results ==="
|
||||
$results | Format-Table -AutoSize
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -96,7 +96,12 @@ VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ablegacy-smoke', 'AbLegacy', N'{
|
||||
"PlcFamily": "Slc500",
|
||||
"DeviceName": "slc-500",
|
||||
"TimeoutMs": 500,
|
||||
"Retries": 1
|
||||
"Retries": 1,
|
||||
"Demote": {
|
||||
"FailureThreshold": 3,
|
||||
"DemoteForMs": 30000,
|
||||
"Enabled": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": "S:0" },
|
||||
@@ -155,7 +160,15 @@ 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. Seven counters per device:';
|
||||
PRINT ' driver registers them at DiscoverAsync time. Nine counters per device:';
|
||||
PRINT ' RequestCount, ResponseCount, ErrorCount, RetryCount, LastErrorCode,';
|
||||
PRINT ' LastErrorMessage, CommFailures. See docs/drivers/AbLegacy-Diagnostics.md';
|
||||
PRINT ' for the full surface + reset semantics.';
|
||||
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.';
|
||||
|
||||
@@ -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>
|
||||
@@ -85,20 +184,116 @@ public sealed record HistoryReadResult(
|
||||
IReadOnlyList<DataValueSnapshot> Samples,
|
||||
byte[]? ContinuationPoint);
|
||||
|
||||
/// <summary>Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates.</summary>
|
||||
/// <summary>
|
||||
/// Aggregate function for processed history reads. Mirrors the OPC UA Part 13 §5
|
||||
/// standard aggregate catalog. Each value maps 1:1 onto an
|
||||
/// <c>Opc.Ua.ObjectIds.AggregateFunction_*</c> NodeId — the OPC UA Client driver does the
|
||||
/// translation in <c>OpcUaClientDriver.MapAggregateToNodeId</c>; other drivers either
|
||||
/// evaluate the aggregate locally (Galaxy historian) or surface
|
||||
/// <c>BadAggregateNotSupported</c> for the values their backend can't honour.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Stable ordinals.</b> The first 5 values (<see cref="Average"/>..<see cref="Count"/>)
|
||||
/// carry ordinals 0-4 from the original PR — additions are appended to keep prior
|
||||
/// persisted enums (config files, Admin UI dropdowns) compatible.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Server-side support.</b> Not every upstream OPC UA server implements every
|
||||
/// Part 13 aggregate. Implementations advertise their support through
|
||||
/// <c>AggregateConfiguration</c> on the Server object; clients can probe it at runtime.
|
||||
/// Aggregates that the upstream rejects come back with
|
||||
/// <c>StatusCode=BadAggregateNotSupported</c> on the per-row HistoryRead result —
|
||||
/// the driver passes that through verbatim (cascading-quality rule, Part 11 §8).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public enum HistoryAggregateType
|
||||
{
|
||||
// ---- Original 5 (ordinals 0-4 — keep stable) ----
|
||||
/// <summary>Average of all values in the interval. Part 13 §5.4.</summary>
|
||||
Average,
|
||||
/// <summary>Minimum value in the interval. Part 13 §5.5.</summary>
|
||||
Minimum,
|
||||
/// <summary>Maximum value in the interval. Part 13 §5.6.</summary>
|
||||
Maximum,
|
||||
/// <summary>Sum of values in the interval (numeric only). Part 13 §5.10.</summary>
|
||||
Total,
|
||||
/// <summary>Count of Good-quality samples in the interval. Part 13 §5.18.</summary>
|
||||
Count,
|
||||
|
||||
// ---- Time-weighted averages (Part 13 §5.4) ----
|
||||
/// <summary>Time-weighted average — values held until next sample. Part 13 §5.4.2.</summary>
|
||||
TimeAverage,
|
||||
/// <summary>Time-weighted average using simple-bounds extrapolation. Part 13 §5.4.3.</summary>
|
||||
TimeAverage2,
|
||||
|
||||
// ---- Interpolation (Part 13 §5.3) ----
|
||||
/// <summary>Interpolated value at each interval boundary. Part 13 §5.3.</summary>
|
||||
Interpolative,
|
||||
|
||||
// ---- Min/Max with timestamps and range (Part 13 §5.5–§5.7) ----
|
||||
/// <summary>Timestamp of the minimum-value sample. Part 13 §5.5.4.</summary>
|
||||
MinimumActualTime,
|
||||
/// <summary>Timestamp of the maximum-value sample. Part 13 §5.6.4.</summary>
|
||||
MaximumActualTime,
|
||||
/// <summary>Maximum minus minimum across the interval. Part 13 §5.7.</summary>
|
||||
Range,
|
||||
/// <summary>Range computed using simple-bounds extrapolation. Part 13 §5.7.</summary>
|
||||
Range2,
|
||||
|
||||
// ---- Annotation / duration / quality coverage (Part 13 §5.16–§5.21) ----
|
||||
/// <summary>Number of annotations attached to samples in the interval. Part 13 §5.21.</summary>
|
||||
AnnotationCount,
|
||||
/// <summary>Total time (ms) covered by Good-quality data. Part 13 §5.16.</summary>
|
||||
DurationGood,
|
||||
/// <summary>Total time (ms) covered by Bad-quality data. Part 13 §5.16.</summary>
|
||||
DurationBad,
|
||||
/// <summary>Percent of the interval covered by Good-quality data (0-100). Part 13 §5.17.</summary>
|
||||
PercentGood,
|
||||
/// <summary>Percent of the interval covered by Bad-quality data (0-100). Part 13 §5.17.</summary>
|
||||
PercentBad,
|
||||
/// <summary>Worst (most-severe) quality code seen in the interval. Part 13 §5.20.</summary>
|
||||
WorstQuality,
|
||||
/// <summary>Worst-quality code using simple-bounds extrapolation. Part 13 §5.20.</summary>
|
||||
WorstQuality2,
|
||||
|
||||
// ---- Statistical (Part 13 §5.13) ----
|
||||
/// <summary>Sample-population standard deviation (n-1 divisor). Part 13 §5.13.</summary>
|
||||
StandardDeviationSample,
|
||||
/// <summary>Whole-population standard deviation (n divisor). Part 13 §5.13.</summary>
|
||||
StandardDeviationPopulation,
|
||||
/// <summary>Sample-population variance (n-1 divisor). Part 13 §5.13.</summary>
|
||||
VarianceSample,
|
||||
/// <summary>Whole-population variance (n divisor). Part 13 §5.13.</summary>
|
||||
VariancePopulation,
|
||||
|
||||
// ---- State-based (Part 13 §5.12, §5.19) ----
|
||||
/// <summary>Number of value transitions observed in the interval. Part 13 §5.12.</summary>
|
||||
NumberOfTransitions,
|
||||
/// <summary>Total time (ms) the value was 0 (state Zero). Part 13 §5.19.</summary>
|
||||
DurationInStateZero,
|
||||
/// <summary>Total time (ms) the value was non-zero (state NonZero). Part 13 §5.19.</summary>
|
||||
DurationInStateNonZero,
|
||||
|
||||
// ---- Interval bounds and deltas (Part 13 §5.8–§5.9, §5.11) ----
|
||||
/// <summary>First Good-quality sample at or after the interval start. Part 13 §5.8.</summary>
|
||||
Start,
|
||||
/// <summary>Last Good-quality sample at or before the interval end. Part 13 §5.9.</summary>
|
||||
End,
|
||||
/// <summary>End sample minus Start sample. Part 13 §5.11.</summary>
|
||||
Delta,
|
||||
/// <summary>Boundary value (extrapolated) at the interval start. Part 13 §5.8.</summary>
|
||||
StartBound,
|
||||
/// <summary>Boundary value (extrapolated) at the interval end. Part 13 §5.9.</summary>
|
||||
EndBound,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 +309,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 }
|
||||
|
||||
@@ -40,6 +40,18 @@ public abstract class AbCipCommandBase : DriverCommandBase
|
||||
"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
|
||||
{
|
||||
@@ -58,7 +70,17 @@ public abstract class AbCipCommandBase : DriverCommandBase
|
||||
HostAddress: Gateway,
|
||||
PlcFamily: Family,
|
||||
DeviceName: $"cli-{Family}",
|
||||
AddressingMode: AddressingMode)],
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,24 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private IAddressSpaceBuilder? _cachedBuilder;
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
// PR abcip-5.2 — failover bookkeeping. Counter is surfaced through driver-diagnostics
|
||||
// as AbCip.HsbyFailoverCount; the event lets internal subscribers react to an
|
||||
// ActiveAddress flip without HsbyProbeLoopAsync calling deep into the runtime cache
|
||||
// directly. The driver subscribes itself in the constructor so cache invalidation +
|
||||
// write-coalescer reset run inline with the address-change observation.
|
||||
private long _hsbyFailoverCount;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.2 — raised by <see cref="HsbyProbeLoopAsync"/> whenever a device's
|
||||
/// <see cref="DeviceState.ActiveAddress"/> transitions to a value different from
|
||||
/// the one observed on the previous tick. Args carry the device + the
|
||||
/// (oldAddress, newAddress) pair so subscribers can decide whether the change
|
||||
/// matters for them. Internal seam — the driver wires its own runtime-cache /
|
||||
/// write-coalescer invalidation through this event so the bookkeeping runs in
|
||||
/// one place + tests can assert via the public diagnostics counter.
|
||||
/// </summary>
|
||||
internal event EventHandler<HsbyActiveAddressChangedEventArgs>? OnActiveAddressChanged;
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
@@ -67,6 +85,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||||
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval);
|
||||
// PR abcip-5.2 — wire the failover-handling subscriber. Drops every cached per-tag
|
||||
// / parent-DINT runtime against the now-standby gateway, resets the write-coalescer
|
||||
// (the prior known-written values were against the standby chassis), clears the
|
||||
// logical-walk state so the @tags walk reruns against the new active gateway, and
|
||||
// bumps the diagnostics counter that BuildDiagnostics surfaces.
|
||||
OnActiveAddressChanged += HandleActiveAddressChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -247,6 +271,29 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_ = Task.Run(() => ProbeLoopAsync(state, ct), ct);
|
||||
}
|
||||
}
|
||||
// PR abcip-5.1 — HSBY role-probe loops. Independent of the connectivity-probe loop
|
||||
// above; one role-prober task per (primary, partner) pair. Disabled by default; an
|
||||
// operator opts in by setting Hsby.Enabled = true + PartnerHostAddress on the
|
||||
// device options. The probe reads WallClockTime.SyncStatus (or S:34) on each
|
||||
// chassis + updates DeviceState.PrimaryRole / PartnerRole / ActiveAddress.
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
if (state.Options.Hsby is { Enabled: true } hsby
|
||||
&& !string.IsNullOrWhiteSpace(state.Options.PartnerHostAddress))
|
||||
{
|
||||
state.PartnerAddress = state.Options.PartnerHostAddress;
|
||||
// PR abcip-5.2 — pre-parse the partner address once so the runtime hot
|
||||
// path can swap (Gateway, Port, CipPath) without re-parsing on every
|
||||
// ResolveHost / EnsureTagRuntimeAsync call. A bad partner address is a
|
||||
// hard config error already flagged by HsbyProbeLoopAsync's TryParse +
|
||||
// OnWarning path, so a TryParse miss here is non-fatal — the runtime
|
||||
// never resolves to it because PartnerParsedAddress stays null.
|
||||
state.PartnerParsedAddress = AbCipHostAddress.TryParse(state.Options.PartnerHostAddress!);
|
||||
state.HsbyCts = new CancellationTokenSource();
|
||||
var ct = state.HsbyCts.Token;
|
||||
_ = Task.Run(() => HsbyProbeLoopAsync(state, hsby, ct), ct);
|
||||
}
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -424,6 +471,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
try { state.ProbeCts?.Cancel(); } catch { }
|
||||
state.ProbeCts?.Dispose();
|
||||
state.ProbeCts = null;
|
||||
// PR abcip-5.1 — also tear down the HSBY role-probe loop if one is running.
|
||||
try { state.HsbyCts?.Cancel(); } catch { }
|
||||
state.HsbyCts?.Dispose();
|
||||
state.HsbyCts = null;
|
||||
state.DisposeHandles();
|
||||
}
|
||||
_devices.Clear();
|
||||
@@ -644,6 +695,239 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
try { probeRuntime?.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.1 — HSBY role-probe loop. Concurrently reads the configured role tag
|
||||
/// (default <c>WallClockTime.SyncStatus</c>) on the primary chassis (the device's own
|
||||
/// <see cref="AbCipHostAddress"/>) and on the partner address (parsed from
|
||||
/// <see cref="AbCipDeviceOptions.PartnerHostAddress"/>), maps each via
|
||||
/// <see cref="AbCipHsbyRoleProber"/>, and updates the device's
|
||||
/// <see cref="DeviceState.PrimaryRole"/> / <see cref="DeviceState.PartnerRole"/> /
|
||||
/// <see cref="DeviceState.ActiveAddress"/>.
|
||||
/// <para>
|
||||
/// Active-resolution rules:
|
||||
/// <list type="bullet">
|
||||
/// <item>Primary <see cref="HsbyRole.Active"/>, partner not Active → ActiveAddress = primary.</item>
|
||||
/// <item>Partner Active, primary not Active → ActiveAddress = partner.</item>
|
||||
/// <item>Both Active → primary wins (warns via <see cref="AbCipDriverOptions.OnWarning"/> sink).</item>
|
||||
/// <item>Neither Active (Standby / Disqualified / Unknown) → ActiveAddress = null
|
||||
/// so PR abcip-5.2's <c>ResolveHost</c> can surface BadCommunicationError.</item>
|
||||
/// </list>
|
||||
/// PR abcip-5.1 only **gathers** the role + reports it through driver diagnostics.
|
||||
/// PR abcip-5.2 will plumb the resolved active address back into
|
||||
/// <see cref="ResolveHost"/> for live read/write routing.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async Task HsbyProbeLoopAsync(DeviceState state, AbCipHsbyOptions hsby, CancellationToken ct)
|
||||
{
|
||||
var partnerAddress = state.Options.PartnerHostAddress;
|
||||
if (string.IsNullOrWhiteSpace(partnerAddress)) return;
|
||||
|
||||
var partnerParsed = AbCipHostAddress.TryParse(partnerAddress);
|
||||
if (partnerParsed is null)
|
||||
{
|
||||
_options.OnWarning?.Invoke(
|
||||
$"AbCip device '{state.Options.HostAddress}' has invalid PartnerHostAddress " +
|
||||
$"'{partnerAddress}' — expected 'ab://gateway[:port]/cip-path'. HSBY role probing disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-chassis runtime params. Both chassis share the device's family / ConnectionSize
|
||||
// / addressing-mode resolution so the role-tag read uses the same wire conventions as
|
||||
// a regular tag read on either side.
|
||||
var primaryParams = new AbCipTagCreateParams(
|
||||
Gateway: state.ParsedAddress.Gateway,
|
||||
Port: state.ParsedAddress.Port,
|
||||
CipPath: state.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
|
||||
TagName: hsby.RoleTagAddress,
|
||||
Timeout: _options.Probe.Timeout,
|
||||
ConnectionSize: state.ConnectionSize,
|
||||
AddressingMode: AddressingMode.Symbolic);
|
||||
var partnerParams = primaryParams with
|
||||
{
|
||||
Gateway = partnerParsed.Gateway,
|
||||
Port = partnerParsed.Port,
|
||||
CipPath = partnerParsed.CipPath,
|
||||
};
|
||||
|
||||
IAbCipTagRuntime? primaryRuntime = null;
|
||||
IAbCipTagRuntime? partnerRuntime = null;
|
||||
var primaryInitialized = false;
|
||||
var partnerInitialized = false;
|
||||
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var primaryRoleTask = ProbeOneAsync(
|
||||
primaryParams,
|
||||
() => primaryRuntime,
|
||||
rt => primaryRuntime = rt,
|
||||
() => primaryInitialized,
|
||||
v => primaryInitialized = v,
|
||||
hsby.RoleTagAddress,
|
||||
ct);
|
||||
var partnerRoleTask = ProbeOneAsync(
|
||||
partnerParams,
|
||||
() => partnerRuntime,
|
||||
rt => partnerRuntime = rt,
|
||||
() => partnerInitialized,
|
||||
v => partnerInitialized = v,
|
||||
hsby.RoleTagAddress,
|
||||
ct);
|
||||
|
||||
HsbyRole primaryRole, partnerRole;
|
||||
try
|
||||
{
|
||||
var roles = await Task.WhenAll(primaryRoleTask, partnerRoleTask).ConfigureAwait(false);
|
||||
primaryRole = roles[0];
|
||||
partnerRole = roles[1];
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
state.PrimaryRole = primaryRole;
|
||||
state.PartnerRole = partnerRole;
|
||||
|
||||
string? newActive;
|
||||
if (primaryRole == HsbyRole.Active && partnerRole == HsbyRole.Active)
|
||||
{
|
||||
// Split-brain — both chassis claim Active. Primary wins (deterministic
|
||||
// tie-break) + we shout via the warning sink so operators see it.
|
||||
_options.OnWarning?.Invoke(
|
||||
$"AbCip HSBY split-brain detected on pair " +
|
||||
$"primary='{state.Options.HostAddress}' partner='{partnerAddress}' — both " +
|
||||
$"chassis report Active; routing to primary.");
|
||||
newActive = state.Options.HostAddress;
|
||||
}
|
||||
else if (primaryRole == HsbyRole.Active)
|
||||
{
|
||||
newActive = state.Options.HostAddress;
|
||||
}
|
||||
else if (partnerRole == HsbyRole.Active)
|
||||
{
|
||||
newActive = partnerAddress;
|
||||
}
|
||||
else
|
||||
{
|
||||
// No chassis Active — clear so PR abcip-5.2's ResolveHost can fault writes.
|
||||
newActive = null;
|
||||
}
|
||||
// PR abcip-5.2 — fire OnActiveAddressChanged on every transition so the
|
||||
// runtime-cache invalidation handler runs exactly once per flip. We compare
|
||||
// before assigning so a steady-state tick (Active didn't change) is a no-op.
|
||||
var prevActive = state.ActiveAddress;
|
||||
state.ActiveAddress = newActive;
|
||||
if (!string.Equals(prevActive, newActive, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
OnActiveAddressChanged?.Invoke(this,
|
||||
new HsbyActiveAddressChangedEventArgs(state, prevActive, newActive));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A handler that throws must never tear the probe loop down. Surface
|
||||
// the failure through the warning sink + keep ticking; the next flip
|
||||
// gets another shot at invalidation.
|
||||
_options.OnWarning?.Invoke(
|
||||
$"AbCip HSBY active-address-changed handler threw on " +
|
||||
$"primary='{state.Options.HostAddress}' partner='{partnerAddress}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
try { await Task.Delay(hsby.ProbeInterval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { primaryRuntime?.Dispose(); } catch { }
|
||||
try { partnerRuntime?.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
async Task<HsbyRole> ProbeOneAsync(
|
||||
AbCipTagCreateParams createParams,
|
||||
Func<IAbCipTagRuntime?> get,
|
||||
Action<IAbCipTagRuntime?> set,
|
||||
Func<bool> getInit,
|
||||
Action<bool> setInit,
|
||||
string roleTagAddress,
|
||||
CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rt = get();
|
||||
if (rt is null)
|
||||
{
|
||||
rt = _tagFactory.Create(createParams);
|
||||
set(rt);
|
||||
}
|
||||
if (!getInit())
|
||||
{
|
||||
await rt.InitializeAsync(token).ConfigureAwait(false);
|
||||
setInit(true);
|
||||
}
|
||||
return await AbCipHsbyRoleProber.ProbeAsync(rt, roleTagAddress, token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Tear down so the next tick re-creates the runtime; this matches the regular
|
||||
// probe loop's recovery pattern.
|
||||
try { get()?.Dispose(); } catch { }
|
||||
set(null);
|
||||
setInit(false);
|
||||
return HsbyRole.Unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.2 — invalidation hook for an HSBY failover. Disposes every cached
|
||||
/// per-tag / parent-DINT runtime on the device so the next read / write re-creates
|
||||
/// against the new Active gateway, resets the write-coalescer's per-device cache
|
||||
/// (the prior known-written values were against the now-standby chassis), wipes
|
||||
/// the Logical-mode @tags walk so the new chassis gets a fresh symbol-table
|
||||
/// resolution, and bumps the AbCip.HsbyFailoverCount diagnostic. Idempotent — a
|
||||
/// re-fire against the same address (e.g. an event handler that races the assign)
|
||||
/// short-circuits on the RuntimesAddress equality check inside
|
||||
/// <see cref="EnsureTagRuntimeAsync"/>.
|
||||
/// </summary>
|
||||
private void HandleActiveAddressChanged(object? sender, HsbyActiveAddressChangedEventArgs e)
|
||||
{
|
||||
var state = e.Device;
|
||||
// Drop the runtime cache. The runtime creators repopulate against the new active
|
||||
// gateway on next read/write; the disposed handles' libplctag pointers are
|
||||
// released so the native heap doesn't leak.
|
||||
foreach (var rt in state.Runtimes.Values)
|
||||
{
|
||||
try { rt.Dispose(); } catch { }
|
||||
}
|
||||
state.Runtimes.Clear();
|
||||
foreach (var rt in state.ParentRuntimes.Values)
|
||||
{
|
||||
try { rt.Dispose(); } catch { }
|
||||
}
|
||||
state.ParentRuntimes.Clear();
|
||||
// Reset the @tags symbol-table walk so the new chassis re-fires it on next read;
|
||||
// the standby chassis's instance IDs don't transfer to the now-Active partner.
|
||||
state.LogicalInstanceMap.Clear();
|
||||
state.LogicalWalkComplete = false;
|
||||
// Reset the write-coalescer so the first post-flip write of any value pays the
|
||||
// full round-trip and the cache rebuilds from the new baseline.
|
||||
_writeCoalescer.Reset(state.Options.HostAddress);
|
||||
// Clear the per-device runtimes-address marker so the next runtime creator stamps
|
||||
// it with whatever the new ActiveParsedAddress resolves to.
|
||||
state.RuntimesAddress = null;
|
||||
Interlocked.Increment(ref _hsbyFailoverCount);
|
||||
}
|
||||
|
||||
private void TransitionDeviceState(DeviceState state, HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
@@ -719,11 +1003,34 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
if (AbCipSystemTagSource.IsSystemReference(fullReference))
|
||||
{
|
||||
var host = ExtractSystemDeviceHost(fullReference);
|
||||
if (host is not null) return host;
|
||||
if (host is not null) return ResolveActiveHostFor(host);
|
||||
}
|
||||
if (_tagsByName.TryGetValue(fullReference, out var def))
|
||||
return def.DeviceHostAddress;
|
||||
return _options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId;
|
||||
return ResolveActiveHostFor(def.DeviceHostAddress);
|
||||
return ResolveActiveHostFor(_options.Devices.FirstOrDefault()?.HostAddress ?? DriverInstanceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.2 — failover-aware bulkhead-key resolver. The configured primary
|
||||
/// <c>HostAddress</c> stays the device-state lookup key (it never changes for a
|
||||
/// given device), but the resilience pipeline (Polly bulkhead + breaker per plan
|
||||
/// decision #144) keys on whatever this method returns. When HSBY is enabled and
|
||||
/// <see cref="DeviceState.ActiveAddress"/> resolves to the partner, we route the
|
||||
/// bulkhead through the partner's address so the new active partner gets its own
|
||||
/// fresh breaker state instead of inheriting the now-standby's tripped breaker.
|
||||
/// <para>
|
||||
/// When HSBY isn't enabled or no chassis is Active, returns the original
|
||||
/// primary host address — that's the legacy pre-5.2 behaviour and keeps the
|
||||
/// bulkhead state stable for the dial flow's BadCommunicationError surface.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal string ResolveActiveHostFor(string deviceHostAddress)
|
||||
{
|
||||
if (!_devices.TryGetValue(deviceHostAddress, out var state)) return deviceHostAddress;
|
||||
if (state.Options.Hsby is not { Enabled: true }) return deviceHostAddress;
|
||||
var active = state.ActiveAddress;
|
||||
if (string.IsNullOrEmpty(active)) return deviceHostAddress;
|
||||
return active;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1175,10 +1482,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
sliceLogicalId = sliceId;
|
||||
}
|
||||
// PR abcip-5.2 — slice handles also follow the active address.
|
||||
var sliceActive = device.ActiveParsedAddress;
|
||||
var baseParams = new AbCipTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
CipPath: device.ParsedAddress.CipPath,
|
||||
Gateway: sliceActive.Gateway,
|
||||
Port: sliceActive.Port,
|
||||
CipPath: sliceActive.CipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: parsedPath.ToLibplctagName(),
|
||||
Timeout: _options.Timeout,
|
||||
@@ -1247,6 +1556,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
throw;
|
||||
}
|
||||
device.Runtimes[tagName] = runtime;
|
||||
// PR abcip-5.2 — keep the slice path's runtime cache lifecycle in lockstep with
|
||||
// the per-tag handles. The failover handler clears Runtimes wholesale, so the
|
||||
// address stamp here matches whatever ActiveAddress resolved to when the slice
|
||||
// params were built (the caller passed createParams pre-resolved).
|
||||
device.RuntimesAddress = device.Options.Hsby is { Enabled: true }
|
||||
? device.ActiveAddress ?? device.Options.HostAddress
|
||||
: device.Options.HostAddress;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1667,10 +1983,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
parentLogicalId = pid;
|
||||
}
|
||||
// PR abcip-5.2 — same active-address routing as EnsureTagRuntimeAsync so
|
||||
// BOOL-in-DINT RMW handles follow the failover.
|
||||
var active = device.ActiveParsedAddress;
|
||||
var runtime = _tagFactory.Create(new AbCipTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
CipPath: device.ParsedAddress.CipPath,
|
||||
Gateway: active.Gateway,
|
||||
Port: active.Port,
|
||||
CipPath: active.CipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: parentTagName,
|
||||
Timeout: _options.Timeout,
|
||||
@@ -1687,6 +2006,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
throw;
|
||||
}
|
||||
device.ParentRuntimes[parentTagName] = runtime;
|
||||
device.RuntimesAddress = device.Options.Hsby is { Enabled: true }
|
||||
? device.ActiveAddress ?? device.Options.HostAddress
|
||||
: device.Options.HostAddress;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1714,10 +2036,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
logicalId = resolvedId;
|
||||
}
|
||||
|
||||
// PR abcip-5.2 — route through the resolved active address so an HSBY pair that
|
||||
// failed-over to the partner targets the partner's gateway / port / cip-path.
|
||||
// When HSBY is off or no chassis is Active the getter returns ParsedAddress and
|
||||
// behaviour is identical to pre-5.2 builds.
|
||||
var active = device.ActiveParsedAddress;
|
||||
var runtime = _tagFactory.Create(new AbCipTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
CipPath: device.ParsedAddress.CipPath,
|
||||
Gateway: active.Gateway,
|
||||
Port: active.Port,
|
||||
CipPath: active.CipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: parsed.ToLibplctagName(),
|
||||
Timeout: _options.Timeout,
|
||||
@@ -1735,6 +2062,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
throw;
|
||||
}
|
||||
device.Runtimes[def.Name] = runtime;
|
||||
// Stamp the per-device runtimes-address marker so the failover handler can detect
|
||||
// a stale cache. Compared in DEBUG builds + diagnostics; production code routes
|
||||
// invalidation through OnActiveAddressChanged.
|
||||
device.RuntimesAddress = device.Options.Hsby is { Enabled: true }
|
||||
? device.ActiveAddress ?? device.Options.HostAddress
|
||||
: device.Options.HostAddress;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1751,13 +2084,58 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// counters (Forward Open count, multi-service-packet ratio, etc.) by extending this
|
||||
/// dictionary.
|
||||
/// </summary>
|
||||
private IReadOnlyDictionary<string, double> BuildDiagnostics() => new Dictionary<string, double>
|
||||
private IReadOnlyDictionary<string, double> BuildDiagnostics()
|
||||
{
|
||||
["AbCip.WritesSuppressed"] = _writeCoalescer.TotalWritesSuppressed,
|
||||
["AbCip.WritesPassedThrough"] = _writeCoalescer.TotalWritesPassedThrough,
|
||||
// PR abcip-4.4 — total _RefreshTagDb truthy writes that dispatched to RebrowseAsync.
|
||||
["AbCip.RefreshTriggers"] = _systemTagSource.TotalRefreshTriggers,
|
||||
};
|
||||
var dict = new Dictionary<string, double>
|
||||
{
|
||||
["AbCip.WritesSuppressed"] = _writeCoalescer.TotalWritesSuppressed,
|
||||
["AbCip.WritesPassedThrough"] = _writeCoalescer.TotalWritesPassedThrough,
|
||||
// PR abcip-4.4 — total _RefreshTagDb truthy writes that dispatched to RebrowseAsync.
|
||||
["AbCip.RefreshTriggers"] = _systemTagSource.TotalRefreshTriggers,
|
||||
// PR abcip-5.2 — count of HSBY active-address transitions the probe loop has
|
||||
// observed. Aggregated across every HSBY-enabled device on this driver
|
||||
// instance; the per-device breakdown is observable via the per-pair role
|
||||
// counters below.
|
||||
["AbCip.HsbyFailoverCount"] = Interlocked.Read(ref _hsbyFailoverCount),
|
||||
};
|
||||
// PR abcip-5.1 — HSBY role surface. One <Counter> per HSBY-enabled device:
|
||||
// AbCip.HsbyActive — 1 if ActiveAddress == primary, 2 if == partner, 0 otherwise.
|
||||
// AbCip.HsbyPrimaryRole — most-recent (HsbyRole)int observed on the primary.
|
||||
// AbCip.HsbyPartnerRole — most-recent (HsbyRole)int observed on the partner.
|
||||
// The single-driver case (one HSBY pair) collapses these to flat keys; multi-pair
|
||||
// configurations get scoped keys per host so the Admin UI can render each pair.
|
||||
var hsbyDevices = _devices.Values
|
||||
.Where(d => d.Options.Hsby is { Enabled: true } && !string.IsNullOrWhiteSpace(d.Options.PartnerHostAddress))
|
||||
.ToList();
|
||||
if (hsbyDevices.Count == 1)
|
||||
{
|
||||
var d = hsbyDevices[0];
|
||||
dict["AbCip.HsbyActive"] = HsbyActiveCode(d);
|
||||
dict["AbCip.HsbyPrimaryRole"] = (int)d.PrimaryRole;
|
||||
dict["AbCip.HsbyPartnerRole"] = (int)d.PartnerRole;
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var d in hsbyDevices)
|
||||
{
|
||||
var key = d.Options.HostAddress;
|
||||
dict[$"AbCip.HsbyActive[{key}]"] = HsbyActiveCode(d);
|
||||
dict[$"AbCip.HsbyPrimaryRole[{key}]"] = (int)d.PrimaryRole;
|
||||
dict[$"AbCip.HsbyPartnerRole[{key}]"] = (int)d.PartnerRole;
|
||||
}
|
||||
}
|
||||
return dict;
|
||||
|
||||
static double HsbyActiveCode(DeviceState d)
|
||||
{
|
||||
if (d.ActiveAddress is null) return 0;
|
||||
if (string.Equals(d.ActiveAddress, d.Options.HostAddress, StringComparison.OrdinalIgnoreCase))
|
||||
return 1;
|
||||
if (string.Equals(d.ActiveAddress, d.PartnerAddress, StringComparison.OrdinalIgnoreCase))
|
||||
return 2;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam — exposes the live coalescer for unit tests that want to inspect counters
|
||||
@@ -2120,6 +2498,74 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public CancellationTokenSource? ProbeCts { get; set; }
|
||||
public bool ProbeInitialized { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.1 — currently active chassis address in an HSBY pair, or
|
||||
/// <c>null</c> when (a) HSBY isn't configured for this device or (b) neither
|
||||
/// chassis returned <see cref="HsbyRole.Active"/> on the latest probe tick.
|
||||
/// PR abcip-5.2 will consult this in <see cref="AbCipDriver.ResolveHost"/> to
|
||||
/// route reads / writes; PR 5.1 only reports it through driver diagnostics.
|
||||
/// </summary>
|
||||
public string? ActiveAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.1 — partner chassis address pulled from
|
||||
/// <see cref="AbCipDeviceOptions.PartnerHostAddress"/> at init. <c>null</c> when
|
||||
/// HSBY isn't configured.
|
||||
/// </summary>
|
||||
public string? PartnerAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.2 — parsed form of <see cref="PartnerAddress"/>, populated at init
|
||||
/// when HSBY is configured. <c>ResolveHost</c>'s caller side keeps using the
|
||||
/// opaque <see cref="AbCipDeviceOptions.HostAddress"/>; the **runtime hot path**
|
||||
/// consults <see cref="ActiveParsedAddress"/> so libplctag handles target the
|
||||
/// currently Active gateway / port / cip-path.
|
||||
/// </summary>
|
||||
public AbCipHostAddress? PartnerParsedAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.2 — parsed wire address that per-tag / per-slice / parent-DINT
|
||||
/// runtimes should be created against right now. Returns <see cref="ParsedAddress"/>
|
||||
/// (the configured primary) when (a) HSBY isn't enabled, (b) <see cref="ActiveAddress"/>
|
||||
/// is null (no chassis Active — fall through to the dial flow which will fault
|
||||
/// with BadCommunicationError on the next wire op), or (c) the active address
|
||||
/// equals the configured primary host. Returns <see cref="PartnerParsedAddress"/>
|
||||
/// when the partner is the live chassis. Cheap getter — every tag-runtime
|
||||
/// creation calls it.
|
||||
/// </summary>
|
||||
public AbCipHostAddress ActiveParsedAddress
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Options.Hsby is not { Enabled: true } || ActiveAddress is null)
|
||||
return ParsedAddress;
|
||||
if (PartnerParsedAddress is not null
|
||||
&& string.Equals(ActiveAddress, PartnerAddress, StringComparison.OrdinalIgnoreCase))
|
||||
return PartnerParsedAddress;
|
||||
return ParsedAddress;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.2 — address every entry in <see cref="Runtimes"/> +
|
||||
/// <see cref="ParentRuntimes"/> was created against. <c>null</c> until the first
|
||||
/// read / write materialises a runtime; set to the resolved active address each
|
||||
/// time a runtime is created. <see cref="AbCipDriver.HsbyProbeLoopAsync"/>'s
|
||||
/// active-address-changed callback compares this against the new active and
|
||||
/// drops every cached handle on mismatch so the next read / write re-creates
|
||||
/// against the new gateway.
|
||||
/// </summary>
|
||||
public string? RuntimesAddress { get; set; }
|
||||
|
||||
/// <summary>PR abcip-5.1 — most-recent role observed on the primary chassis.</summary>
|
||||
public HsbyRole PrimaryRole { get; set; } = HsbyRole.Unknown;
|
||||
|
||||
/// <summary>PR abcip-5.1 — most-recent role observed on the partner chassis.</summary>
|
||||
public HsbyRole PartnerRole { get; set; } = HsbyRole.Unknown;
|
||||
|
||||
/// <summary>PR abcip-5.1 — cancellation source for the HSBY probe loop. Disposed at shutdown.</summary>
|
||||
public CancellationTokenSource? HsbyCts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-4.3 — wall-clock duration of the most recent <see cref="AbCipDriver.ReadAsync"/>
|
||||
/// iteration that touched any tag on this device, in milliseconds. Surfaces as
|
||||
@@ -2163,3 +2609,26 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.2 — event payload raised by <see cref="AbCipDriver"/> when the HSBY
|
||||
/// probe loop observes a transition in <see cref="AbCipDriver.DeviceState.ActiveAddress"/>.
|
||||
/// Subscribers consume <see cref="OldAddress"/> / <see cref="NewAddress"/> to decide
|
||||
/// whether to invalidate cached state. <see cref="OldAddress"/> is <c>null</c> on the
|
||||
/// first transition (driver freshly initialised) and <see cref="NewAddress"/> is
|
||||
/// <c>null</c> when neither chassis is Active (both Standby / Disqualified / Unknown).
|
||||
/// </summary>
|
||||
internal sealed class HsbyActiveAddressChangedEventArgs : EventArgs
|
||||
{
|
||||
public AbCipDriver.DeviceState Device { get; }
|
||||
public string? OldAddress { get; }
|
||||
public string? NewAddress { get; }
|
||||
|
||||
public HsbyActiveAddressChangedEventArgs(
|
||||
AbCipDriver.DeviceState device, string? oldAddress, string? newAddress)
|
||||
{
|
||||
Device = device;
|
||||
OldAddress = oldAddress;
|
||||
NewAddress = newAddress;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,15 @@ public static class AbCipDriverFactoryExtensions
|
||||
"AddressingMode", fallback: AddressingMode.Auto),
|
||||
ReadStrategy: ParseEnum<ReadStrategy>(d.ReadStrategy, "device", driverInstanceId,
|
||||
"ReadStrategy", fallback: ReadStrategy.Auto),
|
||||
MultiPacketSparsityThreshold: d.MultiPacketSparsityThreshold ?? 0.25))]
|
||||
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))]
|
||||
@@ -163,6 +171,32 @@ public static class AbCipDriverFactoryExtensions
|
||||
/// 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
|
||||
|
||||
@@ -151,6 +151,23 @@ public sealed class AbCipDriverOptions
|
||||
/// 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,
|
||||
@@ -158,7 +175,52 @@ public sealed record AbCipDeviceOptions(
|
||||
int? ConnectionSize = null,
|
||||
AddressingMode AddressingMode = AddressingMode.Auto,
|
||||
ReadStrategy ReadStrategy = ReadStrategy.Auto,
|
||||
double MultiPacketSparsityThreshold = 0.25);
|
||||
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"/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -40,6 +40,11 @@ public sealed class AbLegacyDiagnosticTags
|
||||
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",
|
||||
@@ -49,6 +54,9 @@ public sealed class AbLegacyDiagnosticTags
|
||||
"LastErrorCode",
|
||||
"LastErrorMessage",
|
||||
"CommFailures",
|
||||
// PR ablegacy-12 / #255 — auto-demote on comm failure surface.
|
||||
"DemoteCount",
|
||||
"LastDemotedUtc",
|
||||
];
|
||||
|
||||
private static readonly HashSet<string> DiagnosticTagNameSet =
|
||||
@@ -130,6 +138,39 @@ public sealed class AbLegacyDiagnosticTags
|
||||
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)
|
||||
{
|
||||
@@ -139,7 +180,8 @@ public sealed class AbLegacyDiagnosticTags
|
||||
{
|
||||
_counters.TryGetValue(deviceHostAddress, out c);
|
||||
}
|
||||
if (c is null) return new DiagnosticsSnapshot(0, 0, 0, 0, 0, string.Empty, 0);
|
||||
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),
|
||||
@@ -147,7 +189,10 @@ public sealed class AbLegacyDiagnosticTags
|
||||
Retry: Interlocked.Read(ref c.Retry),
|
||||
LastErrorCode: Volatile.Read(ref c.LastErrorCode),
|
||||
LastErrorMessage: c.LastErrorMessage ?? string.Empty,
|
||||
CommFailures: Interlocked.Read(ref c.CommFailures));
|
||||
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>
|
||||
@@ -155,7 +200,14 @@ public sealed class AbLegacyDiagnosticTags
|
||||
/// from <see cref="AbLegacyDriver.ReinitializeAsync"/> so a config redeploy starts
|
||||
/// with a clean diagnostic surface.
|
||||
/// </summary>
|
||||
public void Reset(string deviceHostAddress)
|
||||
/// <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);
|
||||
@@ -166,14 +218,40 @@ public sealed class AbLegacyDiagnosticTags
|
||||
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>
|
||||
public void ResetAll()
|
||||
/// <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)
|
||||
{
|
||||
_counters.Clear();
|
||||
foreach (var key in _counters.Keys.ToList())
|
||||
{
|
||||
Reset(key, preserveDemote: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +283,11 @@ public sealed class AbLegacyDiagnosticTags
|
||||
"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;
|
||||
@@ -236,6 +319,11 @@ public sealed class AbLegacyDiagnosticTags
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +339,9 @@ public sealed class AbLegacyDiagnosticTags
|
||||
/// <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,
|
||||
@@ -258,4 +349,6 @@ public sealed record DiagnosticsSnapshot(
|
||||
long Retry,
|
||||
int LastErrorCode,
|
||||
string LastErrorMessage,
|
||||
long CommFailures);
|
||||
long CommFailures,
|
||||
long DemoteCount,
|
||||
DateTime? LastDemotedUtc);
|
||||
|
||||
@@ -163,6 +163,19 @@ 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
|
||||
@@ -217,6 +230,20 @@ 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
|
||||
@@ -224,6 +251,16 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
// 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)
|
||||
@@ -275,6 +312,38 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
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(
|
||||
@@ -323,6 +392,45 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
// double-counting the original attempt as a retry.
|
||||
_diagnosticTags.RecordRequest(def.DeviceHostAddress);
|
||||
|
||||
// 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.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -360,6 +468,15 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
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}");
|
||||
@@ -385,6 +502,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
_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;
|
||||
}
|
||||
|
||||
@@ -398,6 +522,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
_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; }
|
||||
@@ -414,6 +542,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
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);
|
||||
@@ -591,6 +725,14 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
"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(
|
||||
@@ -665,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; }
|
||||
@@ -890,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();
|
||||
|
||||
@@ -45,7 +45,14 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
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))]
|
||||
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(
|
||||
@@ -209,6 +216,26 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
/// <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
|
||||
|
||||
@@ -41,7 +41,39 @@ public sealed record AbLegacyDeviceOptions(
|
||||
AbLegacyPlcFamily PlcFamily = AbLegacyPlcFamily.Slc500,
|
||||
string? DeviceName = null,
|
||||
TimeSpan? Timeout = null,
|
||||
int? Retries = null);
|
||||
int? Retries = null,
|
||||
AbLegacyDemoteOptions? Demote = null);
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,14 @@ public sealed record AbLegacyPlcFamilyProfile(
|
||||
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",
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -51,7 +65,12 @@ public abstract class FocasCommandBase : DriverCommandBase
|
||||
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 },
|
||||
|
||||
@@ -58,13 +58,46 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Names of the 4 fixed-tree <c>Production/</c> child nodes per device — parts
|
||||
/// Names of the fixed-tree <c>Production/</c> child nodes per device — parts
|
||||
/// produced/required/total via <c>cnc_rdparam(6711/6712/6713)</c> + cycle-time
|
||||
/// seconds (issue #258). Order matters for deterministic discovery output.
|
||||
/// seconds (issue #258), plus the F5-a derived telemetry pair
|
||||
/// <c>LastCycleSeconds</c> + <c>LastCycleStartUtc</c> (issue #272).
|
||||
/// Order matters for deterministic discovery output.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The F5-a derivation is pure — it observes the same
|
||||
/// <c>cnc_rdparam(6711)</c> + cycle-timer values the existing F1-b
|
||||
/// projection already pulls on the probe tick, so no additional wire
|
||||
/// calls are issued.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>LastCycleSeconds</c> = the cycle-timer delta observed across
|
||||
/// two successive parts-count increments (<c>currentTimer -
|
||||
/// timerAtPreviousIncrement</c>). <c>LastCycleStartUtc</c> = the wall-
|
||||
/// clock at the moment of the second increment minus
|
||||
/// <c>LastCycleSeconds</c>. Both values are <c>null</c> until the second
|
||||
/// observed parts-count increment (one increment establishes the
|
||||
/// baseline; the second produces the first delta).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A parts-count counter reset (e.g. shift change, value goes
|
||||
/// backwards) re-baselines the state without emitting a negative
|
||||
/// <c>LastCycleSeconds</c>; the previously-published values stay live
|
||||
/// until the next positive transition produces a fresh delta. A
|
||||
/// cycle-timer rollover (timer goes backwards while parts-count
|
||||
/// increments) re-baselines the timer without publishing the negative
|
||||
/// delta, so a single tick where the delta would be < 0 leaves the
|
||||
/// current <c>LastCycle*</c> values untouched. A parts-count jump of
|
||||
/// > 1 (backfill) emits one delta — the timer delta over the
|
||||
/// window — without trying to per-part-divide the value, matching the
|
||||
/// plan's "delta over the window between increments" intent.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static readonly string[] ProductionFieldNames =
|
||||
[
|
||||
"PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds",
|
||||
"LastCycleSeconds", "LastCycleStartUtc",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
@@ -451,10 +484,28 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
continue;
|
||||
}
|
||||
|
||||
var (value, status) = parsed.Kind == FocasAreaKind.Diagnostic
|
||||
? await client.ReadDiagnosticAsync(
|
||||
parsed.Number, parsed.BitIndex ?? 0, def.DataType, cancellationToken).ConfigureAwait(false)
|
||||
: await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);
|
||||
// PR F4-d (issue #271) — single-shot unlock + retry on
|
||||
// BadUserAccessDenied. Some controllers gate selected reads (not just
|
||||
// writes) behind cnc_wrunlockparam; mirrors the WriteAsync retry
|
||||
// shape so a session that lost its unlock state mid-flight (e.g.
|
||||
// because the controller cycled it) self-heals on the first read.
|
||||
async Task<(object? value, uint status)> DispatchReadAsync()
|
||||
{
|
||||
return parsed.Kind == FocasAreaKind.Diagnostic
|
||||
? await client.ReadDiagnosticAsync(
|
||||
parsed.Number, parsed.BitIndex ?? 0, def.DataType, cancellationToken).ConfigureAwait(false)
|
||||
: await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var (value, status) = await DispatchReadAsync().ConfigureAwait(false);
|
||||
if (status == FocasStatusMapper.BadUserAccessDenied
|
||||
&& !string.IsNullOrEmpty(device.Options.Password))
|
||||
{
|
||||
if (await TryReunlockAsync(device, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
(value, status) = await DispatchReadAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
results[i] = new DataValueSnapshot(value, status, now, now);
|
||||
if (status == FocasStatusMapper.Good)
|
||||
@@ -540,6 +591,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
// PR F4-c (issue #270) — granular gate for PMC writes. PMC is ladder
|
||||
// working memory; a mistargeted bit can move motion or latch a feedhold
|
||||
// so the operator team must explicitly opt in via Writes.AllowPmc on
|
||||
// top of Writes.Enabled + per-tag Writable. Defaults to false so a
|
||||
// deployment that flips the master switch on without touching the PMC
|
||||
// gate still gets BadNotWritable for every PMC tag. ACL note: PMC tags
|
||||
// surface SecurityClassification.Operate (server-layer requires
|
||||
// WriteOperate) — see ClassifyTag.
|
||||
if (parsed.Kind == FocasAreaKind.Pmc && !_options.Writes.AllowPmc)
|
||||
{
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
if (parsed.PathId > 1 && device.PathCount > 0 && parsed.PathId > device.PathCount)
|
||||
@@ -553,18 +617,66 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
device.LastSetPath = parsed.PathId;
|
||||
}
|
||||
|
||||
// Dispatch through the typed entry points for PARAM/MACRO so the
|
||||
// wire-client surface mirrors the per-kind opt-in shape; PMC and other
|
||||
// kinds fall back to the generic WriteAsync path.
|
||||
var status = parsed.Kind switch
|
||||
// Dispatch through the typed entry points for PARAM/MACRO/PMC so the
|
||||
// wire-client surface mirrors the per-kind opt-in shape. PMC bit
|
||||
// writes route through the WritePmcBitAsync RMW helper so the wire
|
||||
// client only ever sees byte-aligned pmc_wrpmcrng calls (PR F4-c,
|
||||
// issue #270). The fallback generic WriteAsync path is preserved for
|
||||
// kinds that don't have a typed entry point yet, plus the unit-test
|
||||
// FakeFocasClient that overrides WriteAsync directly.
|
||||
//
|
||||
// PR F4-d (issue #271) — wrap the dispatch in a single-shot retry on
|
||||
// BadUserAccessDenied (EW_PASSWD mapping from F4-b). When the device
|
||||
// has a password configured AND the wire call fires EW_PASSWD, the
|
||||
// retry path re-issues UnlockAsync and re-dispatches once. The
|
||||
// `attempted` flag bounds the loop so a second EW_PASSWD propagates
|
||||
// unchanged — no infinite retry on a mismatched password.
|
||||
async Task<uint> DispatchWriteAsync()
|
||||
{
|
||||
FocasAreaKind.Parameter => await client.WriteParameterAsync(
|
||||
parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false),
|
||||
FocasAreaKind.Macro => await client.WriteMacroAsync(
|
||||
parsed, w.Value, cancellationToken).ConfigureAwait(false),
|
||||
_ => await client.WriteAsync(
|
||||
parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false),
|
||||
};
|
||||
if (parsed.Kind == FocasAreaKind.Parameter)
|
||||
{
|
||||
return await client.WriteParameterAsync(
|
||||
parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
if (parsed.Kind == FocasAreaKind.Macro)
|
||||
{
|
||||
return await client.WriteMacroAsync(
|
||||
parsed, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
if (parsed.Kind == FocasAreaKind.Pmc
|
||||
&& def.DataType == FocasDataType.Bit
|
||||
&& parsed.BitIndex is int bit
|
||||
&& parsed.PmcLetter is string letter)
|
||||
{
|
||||
return await client.WritePmcBitAsync(
|
||||
letter, parsed.PathId, parsed.Number, bit,
|
||||
Convert.ToBoolean(w.Value), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
if (parsed.Kind == FocasAreaKind.Pmc
|
||||
&& def.DataType == FocasDataType.Byte
|
||||
&& parsed.PmcLetter is string byteLetter)
|
||||
{
|
||||
var b = unchecked((byte)Convert.ToSByte(w.Value));
|
||||
return await client.WritePmcRangeAsync(
|
||||
byteLetter, parsed.PathId, parsed.Number, new[] { b },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
return await client.WriteAsync(
|
||||
parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var status = await DispatchWriteAsync().ConfigureAwait(false);
|
||||
if (status == FocasStatusMapper.BadUserAccessDenied
|
||||
&& !string.IsNullOrEmpty(device.Options.Password))
|
||||
{
|
||||
// Single-shot retry: re-issue cnc_wrunlockparam, then redispatch
|
||||
// exactly once. A second EW_PASSWD bubbles up as-is so a wrong
|
||||
// password doesn't loop forever on the wire.
|
||||
if (await TryReunlockAsync(device, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
status = await DispatchWriteAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
results[i] = new WriteResult(status);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
@@ -633,16 +745,18 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
|
||||
// Fixed-tree Production/ subfolder — 4 read-only Int32 nodes: parts produced /
|
||||
// required / total + cycle-time seconds (issue #258). Cached on the probe tick
|
||||
// + served from DeviceState.LastProduction.
|
||||
// Fixed-tree Production/ subfolder — 6 read-only nodes: parts produced /
|
||||
// required / total + cycle-time seconds (issue #258), plus the F5-a derived
|
||||
// pair LastCycleSeconds / LastCycleStartUtc (issue #272). Cached on the probe
|
||||
// tick + served from DeviceState.LastProduction (wire-sourced fields) and
|
||||
// DeviceState.LastCycle* (derived).
|
||||
var productionFolder = deviceFolder.Folder("Production", "Production");
|
||||
foreach (var field in ProductionFieldNames)
|
||||
{
|
||||
var fullRef = ProductionReferenceFor(device.HostAddress, field);
|
||||
productionFolder.Variable(field, field, new DriverAttributeInfo(
|
||||
FullName: fullRef,
|
||||
DriverDataType: DriverDataType.Int32,
|
||||
DriverDataType: ProductionFieldType(field),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
@@ -799,6 +913,22 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_ => DriverDataType.String,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F5-a (issue #272) — per-field <see cref="DriverDataType"/>
|
||||
/// dispatch for the <c>Production/</c> subtree. The four wire-sourced fields
|
||||
/// (PartsProduced / Required / Total + CycleTimeSeconds) stay
|
||||
/// <see cref="DriverDataType.Int32"/> for back-compat with the F1-b surface;
|
||||
/// the two F5-a derived fields surface as <see cref="DriverDataType.Float64"/>
|
||||
/// (sub-second precision is meaningful at fast cycle times) and
|
||||
/// <see cref="DriverDataType.DateTime"/> (UTC) respectively.
|
||||
/// </summary>
|
||||
private static DriverDataType ProductionFieldType(string field) => field switch
|
||||
{
|
||||
"LastCycleSeconds" => DriverDataType.Float64,
|
||||
"LastCycleStartUtc" => DriverDataType.DateTime,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-b (issue #269) — declare the per-tag write classification the
|
||||
/// server-layer ACL gate (DriverNodeManager) consumes. Per the
|
||||
@@ -985,6 +1115,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var production = await client.GetProductionAsync(ct).ConfigureAwait(false);
|
||||
if (production is not null)
|
||||
{
|
||||
// F5-a (issue #272) — derive LastCycleSeconds + LastCycleStartUtc
|
||||
// from the same observation. Pure derivation: no extra wire calls.
|
||||
UpdateCycleDerivation(state, production, DateTime.UtcNow);
|
||||
state.LastProduction = production;
|
||||
state.LastProductionUtc = DateTime.UtcNow;
|
||||
}
|
||||
@@ -1090,10 +1223,132 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
device.LastStatusUtc, now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F5-a (issue #272) — pure derivation that maintains the per-device
|
||||
/// <c>LastCycleSeconds</c> + <c>LastCycleStartUtc</c> projection from the
|
||||
/// wire-sourced production snapshot the probe loop already collected.
|
||||
/// <para>
|
||||
/// The derivation observes <c>PartsProduced</c> + <c>CycleTimeSeconds</c>
|
||||
/// on every tick and remembers the values seen at the last positive
|
||||
/// parts-count transition. When parts-count next increments by >= 1, the
|
||||
/// delta in cycle-timer seconds across that window becomes
|
||||
/// <c>LastCycleSeconds</c>; <c>LastCycleStartUtc</c> = current wall clock
|
||||
/// minus that delta.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Edge cases — see the <see cref="ProductionFieldNames"/> remarks for the
|
||||
/// full doc:
|
||||
/// <list type="bullet">
|
||||
/// <item>First observation establishes the baseline; no
|
||||
/// <c>LastCycleSeconds</c> is published until the second.</item>
|
||||
/// <item>Parts-count counter reset (current < previous) re-baselines
|
||||
/// without clobbering the most-recent published <c>LastCycle*</c>.</item>
|
||||
/// <item>Cycle-timer rollover (delta < 0 with positive parts
|
||||
/// increment) re-baselines without publishing a negative delta.</item>
|
||||
/// <item>Parts-count jumps > 1 publish the timer delta as one
|
||||
/// <c>LastCycleSeconds</c> per the plan's "delta over the window"
|
||||
/// definition — no per-part division.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static void UpdateCycleDerivation(
|
||||
DeviceState state, FocasProductionInfo production, DateTime nowUtc)
|
||||
{
|
||||
var partsCount = production.PartsProduced;
|
||||
var cycleTimerSeconds = (double)production.CycleTimeSeconds;
|
||||
|
||||
// First observation — establish the baseline. No prior increment to
|
||||
// delta against, so nothing is published yet.
|
||||
if (state.PreviousPartsCount is not int prevParts
|
||||
|| state.PreviousCycleTimerSeconds is not double prevTimer)
|
||||
{
|
||||
state.PreviousPartsCount = partsCount;
|
||||
state.PreviousCycleTimerSeconds = cycleTimerSeconds;
|
||||
state.PreviousIncrementAtUtc = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Parts-count counter reset (e.g. shift-change zero) — leave any
|
||||
// previously-published LastCycle* values live, but re-baseline so the
|
||||
// next positive transition produces a fresh delta. Documented as
|
||||
// "counter reset preserves the last-known values" in the field-doc
|
||||
// remarks; tests assert this intentionally.
|
||||
if (partsCount < prevParts)
|
||||
{
|
||||
state.PreviousPartsCount = partsCount;
|
||||
state.PreviousCycleTimerSeconds = cycleTimerSeconds;
|
||||
return;
|
||||
}
|
||||
|
||||
// No increment this tick — slide the timer baseline forward so the
|
||||
// delta on the NEXT increment reflects the true window between
|
||||
// increments rather than just the gap since the last sample.
|
||||
// (Choosing snapshot-at-last-increment vs snapshot-at-prior-tick is
|
||||
// the core "which delta?" question; per the plan: "delta in
|
||||
// Timers/CycleSeconds between successive parts-count increments" so
|
||||
// we keep the cycle-timer baseline pinned to the last increment and
|
||||
// only update on transition. The previous-parts-count is also kept
|
||||
// pinned so subsequent equal-parts ticks remain no-ops.)
|
||||
if (partsCount == prevParts)
|
||||
{
|
||||
// Defensive: still detect cycle-timer rollover so the next
|
||||
// increment's delta isn't poisoned by a backwards baseline.
|
||||
if (cycleTimerSeconds < prevTimer)
|
||||
{
|
||||
state.PreviousCycleTimerSeconds = cycleTimerSeconds;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Parts-count incremented — compute the delta. A negative delta
|
||||
// means the cycle timer rolled over (or got reset by the operator)
|
||||
// since the previous increment; per the plan we don't publish a
|
||||
// negative LastCycleSeconds. Re-baseline so the next increment
|
||||
// produces a clean delta.
|
||||
var deltaSeconds = cycleTimerSeconds - prevTimer;
|
||||
if (deltaSeconds < 0)
|
||||
{
|
||||
state.PreviousPartsCount = partsCount;
|
||||
state.PreviousCycleTimerSeconds = cycleTimerSeconds;
|
||||
return;
|
||||
}
|
||||
|
||||
state.LastCycleSeconds = deltaSeconds;
|
||||
state.LastCycleStartUtc = nowUtc.AddSeconds(-deltaSeconds);
|
||||
state.PreviousPartsCount = partsCount;
|
||||
state.PreviousCycleTimerSeconds = cycleTimerSeconds;
|
||||
state.PreviousIncrementAtUtc = nowUtc;
|
||||
}
|
||||
|
||||
private DataValueSnapshot ReadProductionField(string hostAddress, string field, DateTime now)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device))
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
|
||||
|
||||
// F5-a derived telemetry (issue #272) — served from DeviceState.LastCycle*
|
||||
// which the probe tick maintains alongside (not on top of) the wire-sourced
|
||||
// production cache. Both fields surface Good with a null value when the
|
||||
// derivation has not yet observed two successive parts-count increments;
|
||||
// an OPC UA client that round-trips a null DateTime gets DateTime.MinValue
|
||||
// through the variant boundary, which the documented "no cycle observed yet"
|
||||
// sentinel makes safe to treat as "unknown".
|
||||
if (string.Equals(field, "LastCycleSeconds", StringComparison.Ordinal))
|
||||
{
|
||||
return new DataValueSnapshot(
|
||||
device.LastCycleSeconds,
|
||||
FocasStatusMapper.Good,
|
||||
device.LastCycleStartUtc,
|
||||
now);
|
||||
}
|
||||
if (string.Equals(field, "LastCycleStartUtc", StringComparison.Ordinal))
|
||||
{
|
||||
return new DataValueSnapshot(
|
||||
device.LastCycleStartUtc,
|
||||
FocasStatusMapper.Good,
|
||||
device.LastCycleStartUtc,
|
||||
now);
|
||||
}
|
||||
|
||||
if (device.LastProduction is not { } snap)
|
||||
return new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
|
||||
var value = PickProductionField(snap, field);
|
||||
@@ -1416,6 +1671,31 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
device.Client = null;
|
||||
throw;
|
||||
}
|
||||
// PR F4-d (issue #271) — emit cnc_wrunlockparam on connect when the device
|
||||
// configured a password. Resets on every reconnect because FWLIB unlock state
|
||||
// is bound to the handle's lifetime. Failure here is non-fatal (the
|
||||
// controller may surface password requirements only on certain reads/writes,
|
||||
// not on every session) — the per-call retry path catches EW_PASSWD on the
|
||||
// actual gated wire call. We still record the failure on _health for
|
||||
// operator visibility. PASSWORD INVARIANT: never log device.Options.Password.
|
||||
if (!string.IsNullOrEmpty(device.Options.Password))
|
||||
{
|
||||
try
|
||||
{
|
||||
await device.Client.UnlockAsync(device.Options.Password, ct).ConfigureAwait(false);
|
||||
device.UnlockApplied = true;
|
||||
// Status text deliberately uses the host address only — no password.
|
||||
_health = new DriverHealth(_health.State, _health.LastSuccessfulRead,
|
||||
$"FOCAS unlock applied for {device.Options.HostAddress}");
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
device.UnlockApplied = false;
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"FOCAS unlock attempt failed for {device.Options.HostAddress}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
// Multi-path bootstrap (issue #264). cnc_rdpathnum runs once per session — the
|
||||
// controller's path topology is fixed at boot. A reconnect resets the wire
|
||||
// session's "last set path" so the next non-default-path read forces a fresh
|
||||
@@ -1432,6 +1712,32 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
return device.Client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-d (issue #271) — re-issue <c>cnc_wrunlockparam</c> on the active
|
||||
/// wire session and return <c>true</c> when the unlock succeeded so the caller
|
||||
/// can retry the gated read/write once. <c>false</c> means the unlock itself
|
||||
/// failed (mismatched password, transport refused) and the caller surfaces
|
||||
/// <c>BadUserAccessDenied</c> as-is. PASSWORD INVARIANT: the password is never
|
||||
/// logged from this method.
|
||||
/// </summary>
|
||||
private async Task<bool> TryReunlockAsync(DeviceState device, CancellationToken ct)
|
||||
{
|
||||
if (device.Client is null || !device.Client.IsConnected) return false;
|
||||
if (string.IsNullOrEmpty(device.Options.Password)) return false;
|
||||
try
|
||||
{
|
||||
await device.Client.UnlockAsync(device.Options.Password, ct).ConfigureAwait(false);
|
||||
device.UnlockApplied = true;
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch
|
||||
{
|
||||
device.UnlockApplied = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
@@ -1463,6 +1769,23 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public FocasProductionInfo? LastProduction { get; set; }
|
||||
public DateTime LastProductionUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F5-a (issue #272) — derivation state for
|
||||
/// <c>Production/LastCycleSeconds</c> + <c>Production/LastCycleStartUtc</c>.
|
||||
/// Maintained by <see cref="FocasDriver.UpdateCycleDerivation"/> on every
|
||||
/// probe tick that returns a non-null production snapshot. All fields
|
||||
/// reset on <see cref="FocasDriver.ReinitializeAsync"/> via the
|
||||
/// ShutdownAsync → InitializeAsync path (DeviceState is reconstructed
|
||||
/// from scratch); the derivation history doesn't carry across a CNC
|
||||
/// reconnect because the FWLIB session boundary may have re-zeroed both
|
||||
/// parts-count and the cycle timer.
|
||||
/// </summary>
|
||||
public int? PreviousPartsCount { get; set; }
|
||||
public double? PreviousCycleTimerSeconds { get; set; }
|
||||
public DateTime? PreviousIncrementAtUtc { get; set; }
|
||||
public double? LastCycleSeconds { get; set; }
|
||||
public DateTime? LastCycleStartUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached <c>cnc_modal</c> M/S/T/B snapshot, refreshed on every probe tick.
|
||||
/// Reads of the per-device <c>Modal/<field></c> nodes serve from this cache
|
||||
@@ -1548,6 +1871,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// </summary>
|
||||
public int LastSetPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-d (issue #271) — set when the driver successfully emitted
|
||||
/// <c>cnc_wrunlockparam</c> on this wire session. Reset to <c>false</c>
|
||||
/// on every reconnect because FWLIB unlock state is bound to the handle's
|
||||
/// lifetime. Surfaced as a flag (not a counter) so a future diagnostics
|
||||
/// surface can light up "unlocked" / "needs unlock" in the Admin UI
|
||||
/// without changing the public API.
|
||||
/// </summary>
|
||||
public bool UnlockApplied { get; set; }
|
||||
|
||||
public void DisposeClient()
|
||||
{
|
||||
Client?.Dispose();
|
||||
|
||||
@@ -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(
|
||||
@@ -98,6 +104,11 @@ public static class FocasDriverFactoryExtensions
|
||||
// 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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -204,6 +215,12 @@ public static class FocasDriverFactoryExtensions
|
||||
/// <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
|
||||
@@ -211,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
|
||||
|
||||
@@ -83,6 +83,20 @@ public sealed record FocasWritesOptions
|
||||
/// 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>
|
||||
@@ -183,12 +197,44 @@ public sealed record FocasFixedTreeOptions
|
||||
/// <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,
|
||||
FocasOverrideParameters? OverrideParameters = null);
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
@@ -123,8 +164,10 @@ 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.
|
||||
/// 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)
|
||||
@@ -151,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
|
||||
{
|
||||
@@ -171,6 +203,52 @@ 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);
|
||||
|
||||
@@ -66,6 +66,25 @@ internal static class FwlibNative
|
||||
short length,
|
||||
ref IODBPSD buffer);
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_wrunlockparam</c> — emit the connection-level password that lifts
|
||||
/// the parameter-protect / read-protect gate on certain firmwares (issue #271,
|
||||
/// plan PR F4-d). The Fanuc FOCAS reference describes the password buffer as
|
||||
/// a 4-byte binary array (the controller compares byte-for-byte). Returns the
|
||||
/// usual <c>EW_*</c> family — <c>EW_PASSWD</c> when the supplied bytes don't
|
||||
/// match the configured password.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>P/Invoke shape kept narrow: the caller passes a 4-byte buffer. The
|
||||
/// driver layer ASCII-encodes <c>FocasDeviceOptions.Password</c> into the
|
||||
/// buffer (right-padded with <c>0x00</c>, truncated to 4 bytes) — that's the
|
||||
/// shape every public Fanuc password example we've seen uses.</para>
|
||||
/// </remarks>
|
||||
[DllImport(Library, EntryPoint = "cnc_wrunlockparam", ExactSpelling = true)]
|
||||
public static extern short WrUnlockParam(
|
||||
ushort handle,
|
||||
[In] byte[] password);
|
||||
|
||||
// ---- Macro variables ----
|
||||
|
||||
[DllImport(Library, EntryPoint = "cnc_rdmacro", ExactSpelling = true)]
|
||||
|
||||
@@ -23,6 +23,25 @@ public interface IFocasClient : IDisposable
|
||||
/// <summary>True when the FWLIB handle is valid + the socket is up.</summary>
|
||||
bool IsConnected { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-d (issue #271) — emit the CNC password via FOCAS
|
||||
/// <c>cnc_wrunlockparam</c>. Some controllers (notably 16i + some 30i
|
||||
/// firmwares with parameter-protect on) gate <c>cnc_wrparam</c> and selected
|
||||
/// reads behind a connection-level password; this call lifts the gate for
|
||||
/// the lifetime of the FWLIB handle (resets on reconnect, hence the driver
|
||||
/// re-issues unlock on every <see cref="ConnectAsync"/>).
|
||||
/// <para>Default impl is a no-op (<see cref="Task.CompletedTask"/>) so transport
|
||||
/// variants that don't surface unlock (today: IPC and FAKE clients) keep
|
||||
/// compiling. The FWLIB-backed client overrides this with a real
|
||||
/// <c>cnc_wrunlockparam</c> call.</para>
|
||||
/// <para><b>No-log invariant:</b> <paramref name="password"/> is a secret. The
|
||||
/// wire-client implementation MUST NOT log the password — see
|
||||
/// <c>FocasDeviceOptions.Password</c> + <c>docs/v2/focas-deployment.md</c>
|
||||
/// § "FOCAS password handling".</para>
|
||||
/// </summary>
|
||||
Task UnlockAsync(string password, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Read the value at <paramref name="address"/> in the requested
|
||||
/// <paramref name="type"/>. Returns a boxed .NET value + the OPC UA status mapped
|
||||
@@ -232,6 +251,49 @@ public interface IFocasClient : IDisposable
|
||||
int depth, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<FocasAlarmHistoryEntry>>(Array.Empty<FocasAlarmHistoryEntry>());
|
||||
|
||||
/// <summary>
|
||||
/// Write a contiguous range of PMC bytes in a single wire call (FOCAS
|
||||
/// <c>pmc_wrpmcrng</c>) for the given <paramref name="letter"/> starting at
|
||||
/// <paramref name="startByte"/>, copying every byte from <paramref name="bytes"/>.
|
||||
/// Plan PR F4-c (issue #270). The wire call is byte-addressed; bit-level writes
|
||||
/// are handled upstream by the <see cref="WritePmcBitAsync"/> read-modify-write
|
||||
/// wrapper which performs <c>pmc_rdpmcrng</c> + bit mask + this method on a
|
||||
/// per-byte semaphore (so two concurrent bit writes against the same byte don't
|
||||
/// lose one another's update).
|
||||
/// <para>Default impl returns <see cref="FocasStatusMapper.BadNotSupported"/> so
|
||||
/// transport variants that haven't yet routed the write keep compiling — those
|
||||
/// variants surface BadNotSupported on PMC writes until the wire client is
|
||||
/// extended.</para>
|
||||
/// </summary>
|
||||
Task<uint> WritePmcRangeAsync(
|
||||
string letter, int pathId, int startByte, byte[] bytes, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(FocasStatusMapper.BadNotSupported);
|
||||
|
||||
/// <summary>
|
||||
/// Read-modify-write one bit within a PMC byte (Plan PR F4-c, issue #270). The
|
||||
/// wire call <c>pmc_wrpmcrng</c> is byte-addressed, so the driver reads the
|
||||
/// parent byte first, masks the target bit, then writes the byte back. Default
|
||||
/// impl uses <see cref="ReadPmcRangeAsync"/> + <see cref="WritePmcRangeAsync"/>
|
||||
/// so transport variants get correct RMW semantics for free; the FWLIB-backed
|
||||
/// client overrides this with a per-byte semaphore so two concurrent bit writes
|
||||
/// against the same byte serialise.
|
||||
/// </summary>
|
||||
async Task<uint> WritePmcBitAsync(
|
||||
string letter, int pathId, int byteAddress, int bitIndex, bool newValue,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (bitIndex is < 0 or > 7) return FocasStatusMapper.BadOutOfRange;
|
||||
var (buf, status) = await ReadPmcRangeAsync(letter, pathId, byteAddress, 1, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (status != FocasStatusMapper.Good || buf is null || buf.Length < 1) return status;
|
||||
var current = buf[0];
|
||||
var updated = newValue
|
||||
? (byte)(current | (1 << bitIndex))
|
||||
: (byte)(current & ~(1 << bitIndex));
|
||||
return await WritePmcRangeAsync(letter, pathId, byteAddress, new[] { updated }, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read a contiguous range of PMC bytes in a single wire call (FOCAS
|
||||
/// <c>pmc_rdpmcrng</c> with byte data type) for the given <paramref name="letter"/>
|
||||
|
||||
@@ -42,6 +42,12 @@ internal sealed class OpcUaClientDiagnostics
|
||||
// ---- Reconnect state (lock-free, single-writer in OnReconnectComplete) ----
|
||||
private long _lastReconnectUtcTicks;
|
||||
|
||||
// ---- Upstream-redundancy counters (PR-14) ----
|
||||
private long _redundancyFailoverCount;
|
||||
private long _redundancyFailoverFailures;
|
||||
private string? _activeServerUri;
|
||||
private readonly object _activeServerUriLock = new();
|
||||
|
||||
public long PublishRequestCount => Interlocked.Read(ref _publishRequestCount);
|
||||
public long NotificationCount => Interlocked.Read(ref _notificationCount);
|
||||
public long MissingPublishRequestCount => Interlocked.Read(ref _missingPublishRequestCount);
|
||||
@@ -110,6 +116,30 @@ internal sealed class OpcUaClientDiagnostics
|
||||
Interlocked.Exchange(ref _lastReconnectUtcTicks, nowUtc.Ticks);
|
||||
}
|
||||
|
||||
public long RedundancyFailoverCount => Interlocked.Read(ref _redundancyFailoverCount);
|
||||
public long RedundancyFailoverFailures => Interlocked.Read(ref _redundancyFailoverFailures);
|
||||
|
||||
public string? ActiveServerUri
|
||||
{
|
||||
get { lock (_activeServerUriLock) return _activeServerUri; }
|
||||
}
|
||||
|
||||
/// <summary>Records a successful redundancy failover and updates the active server URI.</summary>
|
||||
public void RecordRedundancyFailover(string newServerUri)
|
||||
{
|
||||
Interlocked.Increment(ref _redundancyFailoverCount);
|
||||
SetActiveServerUri(newServerUri);
|
||||
}
|
||||
|
||||
/// <summary>Records a failover attempt that failed (e.g. TransferSubscriptions rejected, secondary unreachable).</summary>
|
||||
public void RecordRedundancyFailoverFailure() => Interlocked.Increment(ref _redundancyFailoverFailures);
|
||||
|
||||
/// <summary>Sets the URI of the upstream the driver is currently bound to.</summary>
|
||||
public void SetActiveServerUri(string? uri)
|
||||
{
|
||||
lock (_activeServerUriLock) _activeServerUri = uri;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot the counters into the dictionary shape <see cref="Core.Abstractions.DriverHealth.Diagnostics"/>
|
||||
/// surfaces. Numeric-only (so the RPC can render generically); LastReconnectUtc is
|
||||
@@ -117,7 +147,7 @@ internal sealed class OpcUaClientDiagnostics
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, double> Snapshot()
|
||||
{
|
||||
var dict = new Dictionary<string, double>(7, System.StringComparer.Ordinal)
|
||||
var dict = new Dictionary<string, double>(9, System.StringComparer.Ordinal)
|
||||
{
|
||||
["PublishRequestCount"] = PublishRequestCount,
|
||||
["NotificationCount"] = NotificationCount,
|
||||
@@ -125,6 +155,8 @@ internal sealed class OpcUaClientDiagnostics
|
||||
["MissingPublishRequestCount"] = MissingPublishRequestCount,
|
||||
["DroppedNotificationCount"] = DroppedNotificationCount,
|
||||
["SessionResetCount"] = SessionResetCount,
|
||||
["RedundancyFailoverCount"] = RedundancyFailoverCount,
|
||||
["RedundancyFailoverFailures"] = RedundancyFailoverFailures,
|
||||
};
|
||||
var last = LastReconnectUtc;
|
||||
if (last is not null)
|
||||
|
||||
@@ -121,6 +121,61 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
/// </summary>
|
||||
internal void InjectModelChangeForTest() => OnModelChangeNotification();
|
||||
|
||||
// ---- PR-14 upstream-redundancy state ----
|
||||
|
||||
/// <summary>
|
||||
/// Cached redundancy peer list discovered at session activation. Empty when
|
||||
/// <see cref="RedundancyOptions.Enabled"/> is <c>false</c>, when the upstream
|
||||
/// advertises <c>RedundancySupport=None</c>, or before <see cref="InitializeAsync"/>
|
||||
/// completes. Ordered as the upstream returned it — failover walks this list in
|
||||
/// order, skipping the currently-active URI.
|
||||
/// </summary>
|
||||
private IReadOnlyList<string> _redundancyPeers = [];
|
||||
|
||||
/// <summary>
|
||||
/// Subscription that monitors <c>Server.ServiceLevel</c> on the active upstream
|
||||
/// so a drop propagates via PublishResponse rather than relying on a polling loop.
|
||||
/// Lives alongside the model-change subscription on the same session.
|
||||
/// </summary>
|
||||
private Subscription? _serviceLevelSubscription;
|
||||
|
||||
/// <summary>
|
||||
/// Last UTC tick a failover swap committed. Used to debounce oscillation around
|
||||
/// the threshold — see <see cref="RedundancyOptions.RecheckInterval"/>.
|
||||
/// </summary>
|
||||
private long _lastFailoverTicks;
|
||||
|
||||
/// <summary>
|
||||
/// Test seam — count of redundancy failover invocations the driver has fired.
|
||||
/// Mirrors the model-change reimport counter pattern.
|
||||
/// </summary>
|
||||
private long _redundancyFailoverInvocations;
|
||||
internal long RedundancyFailoverInvocationsForTest => Interlocked.Read(ref _redundancyFailoverInvocations);
|
||||
|
||||
/// <summary>
|
||||
/// Test seam — exposes the cached redundancy peer list so unit tests can assert
|
||||
/// the discovery pass populated it correctly without mocking the OPC UA SDK's
|
||||
/// ServerArray read.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<string> RedundancyPeersForTest => _redundancyPeers;
|
||||
|
||||
/// <summary>
|
||||
/// Test seam — fires before the actual failover swap. When non-null the hook runs
|
||||
/// <i>instead of</i> opening a new session + transferring subscriptions, so unit
|
||||
/// tests can assert "the driver decided to fail over" without standing up two real
|
||||
/// OPC UA sessions. Receives the chosen secondary URI; returns the swap outcome
|
||||
/// (true = success, false = failure to feed the failures counter).
|
||||
/// </summary>
|
||||
internal Func<string, CancellationToken, Task<bool>>? RedundancyFailoverHookForTest { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Test seam — drive a synthetic ServiceLevel value into the failover path. Mirrors
|
||||
/// what the ServiceLevel monitored item does on a real <c>DataChangeNotification</c>
|
||||
/// arrival. Tests use this to assert threshold + RecheckInterval behaviour without
|
||||
/// standing up the SDK's subscription machinery.
|
||||
/// </summary>
|
||||
internal void InjectServiceLevelDropForTest(byte serviceLevel) => OnServiceLevelChanged(serviceLevel);
|
||||
|
||||
/// <summary>Active OPC UA session. Null until <see cref="InitializeAsync"/> returns cleanly.</summary>
|
||||
internal ISession? Session { get; private set; }
|
||||
|
||||
@@ -131,6 +186,34 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
private bool _disposed;
|
||||
/// <summary>URL of the endpoint the driver actually connected to. Exposed via <see cref="HostName"/>.</summary>
|
||||
private string? _connectedEndpointUrl;
|
||||
|
||||
/// <summary>
|
||||
/// Reverse-connect listener acquired during <see cref="InitializeAsync"/> when
|
||||
/// <see cref="ReverseConnectOptions.Enabled"/> is set. Null when reverse-connect is
|
||||
/// disabled. Released back to the singleton pool on shutdown so multiple driver
|
||||
/// instances on the same listener URL can come and go independently.
|
||||
/// </summary>
|
||||
private ReverseConnectListener? _reverseListener;
|
||||
|
||||
/// <summary>
|
||||
/// Test seam — pluggable reverse-connect "wait" hook. When non-null,
|
||||
/// <see cref="OpenReverseConnectSessionAsync"/> uses this delegate instead of
|
||||
/// calling into a real <see cref="ReverseConnectListener"/>. Lets unit tests
|
||||
/// inject a synthetic <c>ITransportWaitingConnection</c> without binding a port
|
||||
/// or running the SDK's listener threads.
|
||||
/// </summary>
|
||||
internal Func<Uri, string?, CancellationToken, Task<Opc.Ua.ITransportWaitingConnection>>? ReverseConnectWaitHookForTest { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Test seam — pluggable session factory invoked in the reverse-connect path.
|
||||
/// Tests can use this to verify that the session-create call receives the
|
||||
/// expected <c>ITransportWaitingConnection</c> without instantiating the SDK
|
||||
/// <see cref="DefaultSessionFactory"/> (which hits real cert + transport code).
|
||||
/// </summary>
|
||||
internal Func<ApplicationConfiguration, Opc.Ua.ITransportWaitingConnection, ConfiguredEndpoint, UserIdentity, CancellationToken, Task<ISession>>? ReverseConnectSessionFactoryForTest { get; set; }
|
||||
|
||||
/// <summary>Test seam — last reverse-connect listener acquired (null when reverse-connect is disabled or shut down).</summary>
|
||||
internal ReverseConnectListener? ReverseListenerForTest => _reverseListener;
|
||||
/// <summary>
|
||||
/// SDK-provided reconnect handler that owns the retry loop + session-transfer machinery
|
||||
/// when the session's keep-alive channel reports a bad status. Null outside the
|
||||
@@ -202,34 +285,60 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
|
||||
var identity = BuildUserIdentity(_options);
|
||||
|
||||
// Failover sweep: try each endpoint in order, return the session from the first
|
||||
// one that successfully connects. Per-endpoint failures are captured so the final
|
||||
// aggregate exception names every URL that was tried and why — critical diag for
|
||||
// operators debugging 'why did the failover pick #3?'.
|
||||
var attemptErrors = new List<string>(candidates.Count);
|
||||
ISession? session = null;
|
||||
string? connectedUrl = null;
|
||||
foreach (var url in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
session = await OpenSessionOnEndpointAsync(
|
||||
appConfig, url, _options.SecurityPolicy, _options.SecurityMode,
|
||||
identity, cancellationToken).ConfigureAwait(false);
|
||||
connectedUrl = url;
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
attemptErrors.Add($"{url} -> {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (session is null)
|
||||
throw new AggregateException(
|
||||
"OPC UA Client failed to connect to any of the configured endpoints. " +
|
||||
"Tried:\n " + string.Join("\n ", attemptErrors),
|
||||
attemptErrors.Select(e => new InvalidOperationException(e)));
|
||||
if (_options.ReverseConnect.Enabled)
|
||||
{
|
||||
// Reverse-connect path: instead of dialling each candidate URL, we register
|
||||
// our listener URL with the process-wide ReverseConnectManager and wait for
|
||||
// the upstream server to dial in. The first candidate URL still drives
|
||||
// EndpointDescription selection so SecurityPolicy/Mode + user-identity flow
|
||||
// through the same code path as the conventional dial — only the transport
|
||||
// direction flips. ExpectedServerUri filters incoming connections so the
|
||||
// listener can be shared across drivers targeting different upstreams.
|
||||
if (string.IsNullOrWhiteSpace(_options.ReverseConnect.ListenerUrl))
|
||||
throw new InvalidOperationException(
|
||||
"ReverseConnect.Enabled=true but ReverseConnect.ListenerUrl is not set. " +
|
||||
"Configure a listener URL like 'opc.tcp://0.0.0.0:4844' so the upstream server can dial in.");
|
||||
|
||||
var endpointForReverse = candidates.FirstOrDefault()
|
||||
?? throw new InvalidOperationException(
|
||||
"ReverseConnect requires at least one EndpointUrl in the candidate list to derive the EndpointDescription from.");
|
||||
|
||||
session = await OpenReverseConnectSessionAsync(
|
||||
appConfig, endpointForReverse, identity, cancellationToken).ConfigureAwait(false);
|
||||
connectedUrl = endpointForReverse;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Failover sweep: try each endpoint in order, return the session from the first
|
||||
// one that successfully connects. Per-endpoint failures are captured so the final
|
||||
// aggregate exception names every URL that was tried and why — critical diag for
|
||||
// operators debugging 'why did the failover pick #3?'.
|
||||
var attemptErrors = new List<string>(candidates.Count);
|
||||
foreach (var url in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
session = await OpenSessionOnEndpointAsync(
|
||||
appConfig, url, _options.SecurityPolicy, _options.SecurityMode,
|
||||
identity, cancellationToken).ConfigureAwait(false);
|
||||
connectedUrl = url;
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
attemptErrors.Add($"{url} -> {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (session is null)
|
||||
throw new AggregateException(
|
||||
"OPC UA Client failed to connect to any of the configured endpoints. " +
|
||||
"Tried:\n " + string.Join("\n ", attemptErrors),
|
||||
attemptErrors.Select(e => new InvalidOperationException(e)));
|
||||
}
|
||||
|
||||
// Wire the session's keep-alive channel into HostState + the reconnect trigger.
|
||||
// OPC UA keep-alives are authoritative for session liveness: the SDK pings on
|
||||
@@ -263,11 +372,35 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
// the absence of re-import on topology change rather than a hard init fail.
|
||||
}
|
||||
}
|
||||
|
||||
// PR-14: discover the upstream's redundant peer list + start the ServiceLevel
|
||||
// watch. Best-effort so an upstream advertising RedundancySupport=None (or one
|
||||
// that simply doesn't expose the redundancy nodes) doesn't fail init — we just
|
||||
// disable the failover path for the duration of this session.
|
||||
if (_options.Redundancy.Enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
await DiscoverRedundancyAsync(session, connectedUrl, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best-effort — operators see this through the absence of failover on
|
||||
// ServiceLevel drop rather than a hard init fail.
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try { if (Session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { }
|
||||
Session = null;
|
||||
// Release the reverse-connect listener if we acquired it but session-create failed
|
||||
// — leaks a port-bind otherwise, blocking the next init attempt.
|
||||
if (_reverseListener is not null)
|
||||
{
|
||||
try { _reverseListener.Release(); } catch { /* best-effort */ }
|
||||
_reverseListener = null;
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||
throw;
|
||||
}
|
||||
@@ -644,6 +777,96 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Open a session over a server-initiated (reverse) connect. Acquires a process-wide
|
||||
/// <see cref="ReverseConnectListener"/> for the configured listener URL, waits for the
|
||||
/// upstream server to dial in (filtered by <see cref="ReverseConnectOptions.ExpectedServerUri"/>),
|
||||
/// then hands the resulting <see cref="Opc.Ua.ITransportWaitingConnection"/> into the
|
||||
/// session-create path. The endpoint description still comes from the candidate URL so
|
||||
/// SecurityPolicy / Mode / cert handling are identical to the dial path — only the
|
||||
/// transport direction flips.
|
||||
/// </summary>
|
||||
private async Task<ISession> OpenReverseConnectSessionAsync(
|
||||
ApplicationConfiguration appConfig,
|
||||
string endpointUrl,
|
||||
UserIdentity identity,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var listenerUrl = _options.ReverseConnect.ListenerUrl!;
|
||||
var expectedServerUri = _options.ReverseConnect.ExpectedServerUri;
|
||||
|
||||
// Acquire a ref to the singleton listener for this URL. Multiple driver instances
|
||||
// sharing a URL share one underlying SDK manager — see ReverseConnectListener for
|
||||
// the ref-count model.
|
||||
if (ReverseConnectWaitHookForTest is null)
|
||||
{
|
||||
_reverseListener = ReverseConnectListener.Acquire(listenerUrl, appConfig);
|
||||
}
|
||||
|
||||
// Build the ConfiguredEndpoint from the configured endpointUrl. We DON'T call
|
||||
// GetEndpointsAsync over reverse connect here — the SDK's reverse-connect overload
|
||||
// accepts a synthetic EndpointDescription and the upstream resends its real one
|
||||
// during ReverseHello, so a static description is fine for the create call.
|
||||
var endpointDescription = new EndpointDescription(endpointUrl)
|
||||
{
|
||||
SecurityPolicyUri = MapSecurityPolicy(_options.SecurityPolicy),
|
||||
SecurityMode = _options.SecurityMode switch
|
||||
{
|
||||
OpcUaSecurityMode.None => MessageSecurityMode.None,
|
||||
OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
|
||||
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
|
||||
_ => MessageSecurityMode.None,
|
||||
},
|
||||
};
|
||||
var endpointConfig = EndpointConfiguration.Create(appConfig);
|
||||
endpointConfig.OperationTimeout = (int)_options.Timeout.TotalMilliseconds;
|
||||
var endpoint = new ConfiguredEndpoint(null, endpointDescription, endpointConfig);
|
||||
|
||||
// Wait for the upstream to dial in. Bounded by Timeout so a stuck listener doesn't
|
||||
// hang init forever — operators see a clear timeout error rather than a silent stall.
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_options.Timeout);
|
||||
|
||||
Opc.Ua.ITransportWaitingConnection connection;
|
||||
if (ReverseConnectWaitHookForTest is not null)
|
||||
{
|
||||
connection = await ReverseConnectWaitHookForTest(
|
||||
new Uri(listenerUrl), expectedServerUri, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
connection = await _reverseListener!.WaitForServerAsync(
|
||||
new Uri(listenerUrl), expectedServerUri, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Hand the inbound connection into the session-create path. The factory hook lets
|
||||
// unit tests assert that the right connection + endpoint flow through without
|
||||
// standing up a real DefaultSessionFactory (which expects a fully-wired transport).
|
||||
ISession session;
|
||||
if (ReverseConnectSessionFactoryForTest is not null)
|
||||
{
|
||||
session = await ReverseConnectSessionFactoryForTest(
|
||||
appConfig, connection, endpoint, identity, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
|
||||
appConfig,
|
||||
connection,
|
||||
endpoint,
|
||||
updateBeforeConnect: false,
|
||||
checkDomain: false,
|
||||
_options.SessionName,
|
||||
(uint)_options.SessionTimeout.TotalMilliseconds,
|
||||
identity,
|
||||
preferredLocales: null,
|
||||
cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
session.KeepAliveInterval = (int)_options.KeepAliveInterval.TotalMilliseconds;
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Select the remote endpoint matching both the requested <paramref name="policy"/>
|
||||
/// and <paramref name="mode"/>. The SDK's <c>CoreClientUtils.SelectEndpointAsync</c>
|
||||
@@ -777,6 +1000,18 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
try { _modelChangeDebounceTimer?.Dispose(); } catch { }
|
||||
_modelChangeDebounceTimer = null;
|
||||
|
||||
// Tear down the redundancy ServiceLevel watch. Same best-effort pattern as the
|
||||
// model-change subscription — if the wire-side delete fails the next init pass
|
||||
// will still build a fresh subscription.
|
||||
if (_serviceLevelSubscription is not null)
|
||||
{
|
||||
try { await _serviceLevelSubscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
|
||||
catch { /* best-effort */ }
|
||||
_serviceLevelSubscription = null;
|
||||
}
|
||||
_redundancyPeers = [];
|
||||
_diagnostics.SetActiveServerUri(null);
|
||||
|
||||
// Abort any in-flight reconnect attempts before touching the session — BeginReconnect's
|
||||
// retry loop holds a reference to the current session and would fight Session.CloseAsync
|
||||
// if left spinning.
|
||||
@@ -799,6 +1034,15 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
_connectedEndpointUrl = null;
|
||||
_operationLimits = null;
|
||||
|
||||
// Release our hold on the reverse-connect listener. Last release tears the manager
|
||||
// down; siblings that share the URL keep it alive. Idempotent — releasing a null
|
||||
// listener (e.g. shutdown after a failed init) is a no-op.
|
||||
if (_reverseListener is not null)
|
||||
{
|
||||
try { _reverseListener.Release(); } catch { /* best-effort */ }
|
||||
_reverseListener = null;
|
||||
}
|
||||
|
||||
TransitionTo(HostState.Unknown);
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
@@ -2437,6 +2681,307 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
public string DiagnosticId => $"opcua-alarm-sub-{Id}";
|
||||
}
|
||||
|
||||
// ---- PR-14 upstream-redundancy plumbing ----
|
||||
|
||||
/// <summary>
|
||||
/// Read <c>Server.ServerRedundancy.RedundancySupport</c> + <c>ServerUriArray</c>
|
||||
/// from the upstream so the driver knows whether to honour ServiceLevel-driven
|
||||
/// failover and where the peer set lives. When <c>RedundancySupport=None</c>
|
||||
/// the driver records an empty peer list — the ServiceLevel watch still runs but
|
||||
/// <see cref="OnServiceLevelChanged"/> short-circuits without trying to swap.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per OPC UA Part 5 §6.3.13 the standard variable is
|
||||
/// <c>Server.ServerRedundancy.ServerUriArray</c>; the issue text refers to
|
||||
/// <c>Server.ServerArray</c> (top-level URIs the server federates over). We pull
|
||||
/// <c>ServerUriArray</c> as the canonical redundant-peer source — when missing
|
||||
/// we fall through to <c>Server.ServerArray</c> as a heuristic so the failover
|
||||
/// path still has a peer list against legacy servers that conflate the two.
|
||||
/// </remarks>
|
||||
private async Task DiscoverRedundancyAsync(ISession session, string? activeUrl, CancellationToken ct)
|
||||
{
|
||||
// RedundancySupport: when None, skip ServiceLevel watching entirely — the upstream
|
||||
// explicitly declares it has no peers, so a drop is meaningless.
|
||||
try
|
||||
{
|
||||
var rsValue = await session.ReadValueAsync(
|
||||
VariableIds.Server_ServerRedundancy_RedundancySupport, ct).ConfigureAwait(false);
|
||||
// The variable is an Int32-encoded RedundancySupport enum. 0 = None.
|
||||
if (rsValue.Value is int rsInt && rsInt == (int)RedundancySupport.None)
|
||||
{
|
||||
_redundancyPeers = [];
|
||||
_diagnostics.SetActiveServerUri(activeUrl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Upstream doesn't expose the variable — fall through and try ServerUriArray
|
||||
// anyway; some servers ship the array without exposing RedundancySupport.
|
||||
}
|
||||
|
||||
var peers = new List<string>();
|
||||
try
|
||||
{
|
||||
var uriArrayValue = await session.ReadValueAsync(
|
||||
VariableIds.Server_ServerRedundancy_ServerUriArray, ct).ConfigureAwait(false);
|
||||
if (uriArrayValue.Value is string[] uris)
|
||||
{
|
||||
foreach (var u in uris)
|
||||
if (!string.IsNullOrWhiteSpace(u)) peers.Add(u);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ServerUriArray missing — try the top-level Server.ServerArray as the
|
||||
// fallback the issue text hints at. Many servers populate this even when
|
||||
// the redundancy node is absent.
|
||||
try
|
||||
{
|
||||
var saValue = await session.ReadValueAsync(
|
||||
VariableIds.Server_ServerArray, ct).ConfigureAwait(false);
|
||||
if (saValue.Value is string[] uris)
|
||||
{
|
||||
foreach (var u in uris)
|
||||
if (!string.IsNullOrWhiteSpace(u)) peers.Add(u);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// No peer list available from either node — leave _redundancyPeers empty;
|
||||
// the ServiceLevel watch can still run but OnServiceLevelChanged will
|
||||
// no-op since there's nowhere to fail over to.
|
||||
}
|
||||
}
|
||||
|
||||
_redundancyPeers = peers;
|
||||
_diagnostics.SetActiveServerUri(activeUrl);
|
||||
|
||||
// Subscribe to ServiceLevel so a drop propagates via PublishResponse rather than
|
||||
// a polling loop. Best-effort: a server that rejects the EventFilter still leaves
|
||||
// the failover hook reachable from InjectServiceLevelDropForTest in tests.
|
||||
try
|
||||
{
|
||||
await SubscribeServiceLevelAsync(session, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// No subscription = no automatic failover; manual ReinitializeAsync still works.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire <c>Server.ServiceLevel</c> as a monitored item so a drop fires
|
||||
/// <see cref="OnServiceLevelChanged"/> via the SDK's notification path. Uses a
|
||||
/// dedicated <see cref="Subscription"/> rather than co-tenanting with the alarm
|
||||
/// subscription so the publish cadence can be tuned independently — service
|
||||
/// level rarely changes in steady state, so we use a 1s interval like the
|
||||
/// model-change watch.
|
||||
/// </summary>
|
||||
private async Task SubscribeServiceLevelAsync(ISession session, CancellationToken cancellationToken)
|
||||
{
|
||||
var subDefaults = _options.Subscriptions;
|
||||
var subscription = new Subscription(telemetry: null!, new SubscriptionOptions
|
||||
{
|
||||
DisplayName = "opcua-servicelevel-watch",
|
||||
PublishingInterval = 1000,
|
||||
KeepAliveCount = (uint)subDefaults.KeepAliveCount,
|
||||
LifetimeCount = subDefaults.LifetimeCount,
|
||||
MaxNotificationsPerPublish = subDefaults.MaxNotificationsPerPublish,
|
||||
PublishingEnabled = true,
|
||||
Priority = subDefaults.Priority,
|
||||
TimestampsToReturn = TimestampsToReturn.Both,
|
||||
});
|
||||
|
||||
session.AddSubscription(subscription);
|
||||
await subscription.CreateAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var item = new MonitoredItem(telemetry: null!, new MonitoredItemOptions
|
||||
{
|
||||
DisplayName = "Server/ServiceLevel",
|
||||
StartNodeId = VariableIds.Server_ServiceLevel,
|
||||
AttributeId = Attributes.Value,
|
||||
MonitoringMode = MonitoringMode.Reporting,
|
||||
QueueSize = 1,
|
||||
DiscardOldest = true,
|
||||
});
|
||||
item.Notification += OnServiceLevelNotification;
|
||||
subscription.AddItem(item);
|
||||
await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_serviceLevelSubscription = subscription;
|
||||
}
|
||||
|
||||
private void OnServiceLevelNotification(MonitoredItem item, MonitoredItemNotificationEventArgs e)
|
||||
{
|
||||
// Drain any queued DataChangeNotifications. The SDK pushes one notification per
|
||||
// change event; ServiceLevel is a Byte (0..255 per the spec) so a value of any
|
||||
// other CLR type is a server bug — defensively coerce.
|
||||
foreach (var dv in item.DequeueValues())
|
||||
{
|
||||
byte sl;
|
||||
try { sl = Convert.ToByte(dv.Value, System.Globalization.CultureInfo.InvariantCulture); }
|
||||
catch { continue; }
|
||||
OnServiceLevelChanged(sl);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggered by every ServiceLevel data-change. When the value drops below
|
||||
/// <see cref="RedundancyOptions.ServiceLevelThreshold"/> AND we have a peer to
|
||||
/// swap to AND the recheck-interval window has elapsed since the last failover,
|
||||
/// kicks off <see cref="FailoverAsync"/>. All other paths short-circuit.
|
||||
/// </summary>
|
||||
private void OnServiceLevelChanged(byte serviceLevel)
|
||||
{
|
||||
if (!_options.Redundancy.Enabled) return;
|
||||
if (serviceLevel >= _options.Redundancy.ServiceLevelThreshold) return;
|
||||
if (_redundancyPeers.Count == 0) return;
|
||||
|
||||
// Recheck-interval guard: if a failover committed within the last RecheckInterval
|
||||
// window, suppress further swaps. Without this a flapping ServiceLevel could
|
||||
// ping-pong the driver between primary and secondary on every notification.
|
||||
var nowTicks = DateTime.UtcNow.Ticks;
|
||||
var lastTicks = Interlocked.Read(ref _lastFailoverTicks);
|
||||
if (lastTicks != 0)
|
||||
{
|
||||
var elapsed = TimeSpan.FromTicks(nowTicks - lastTicks);
|
||||
if (elapsed < _options.Redundancy.ResolvedRecheckInterval) return;
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _redundancyFailoverInvocations);
|
||||
|
||||
// Pick the next peer that isn't the active URI. Round-robin within the cached list.
|
||||
var active = _diagnostics.ActiveServerUri;
|
||||
string? next = null;
|
||||
foreach (var u in _redundancyPeers)
|
||||
{
|
||||
if (!string.Equals(u, active, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
next = u;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (next is null) return;
|
||||
|
||||
_ = FailoverAsync(next, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Open a parallel session against <paramref name="newUri"/>, transfer live
|
||||
/// subscriptions onto it, swap <see cref="Session"/>, close the old one. On any
|
||||
/// failure leaves the existing session in place + increments the failures
|
||||
/// counter so operators see the dashboard reflect the failed swap.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Shared client-cert prerequisite</b>: <c>TransferSubscriptionsAsync</c>
|
||||
/// requires the secondary's secure channel to accept the same client cert the
|
||||
/// primary did, otherwise the SDK returns <c>BadSecureChannelClosed</c> /
|
||||
/// <c>BadCertificateUntrusted</c> — see docs/drivers/OpcUaClient.md
|
||||
/// "Upstream redundancy" section for the certificate trust model.
|
||||
/// </remarks>
|
||||
private async Task FailoverAsync(string newUri, CancellationToken cancellationToken)
|
||||
{
|
||||
// Test seam: the unit tests use this hook to assert the driver decided to fail
|
||||
// over without standing up a real ISession. The hook returns the swap outcome.
|
||||
var hook = RedundancyFailoverHookForTest;
|
||||
if (hook is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var success = await hook(newUri, cancellationToken).ConfigureAwait(false);
|
||||
if (success)
|
||||
{
|
||||
Interlocked.Exchange(ref _lastFailoverTicks, DateTime.UtcNow.Ticks);
|
||||
_diagnostics.RecordRedundancyFailover(newUri);
|
||||
}
|
||||
else
|
||||
{
|
||||
_diagnostics.RecordRedundancyFailoverFailure();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
_diagnostics.RecordRedundancyFailoverFailure();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var oldSession = Session;
|
||||
if (oldSession is null) return;
|
||||
|
||||
ISession? newSession = null;
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
|
||||
var identity = BuildUserIdentity(_options);
|
||||
try
|
||||
{
|
||||
newSession = await OpenSessionOnEndpointAsync(
|
||||
appConfig, newUri, _options.SecurityPolicy, _options.SecurityMode,
|
||||
identity, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_diagnostics.RecordRedundancyFailoverFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
// TransferSubscriptions across all live subscriptions. SDK's TransferSubscriptionsAsync
|
||||
// takes the source session + target subscriptions; if the secondary rejects any
|
||||
// subscription the call returns false and we leave Session untouched.
|
||||
try
|
||||
{
|
||||
var subs = new SubscriptionCollection();
|
||||
foreach (var rs in _subscriptions.Values) subs.Add(rs.Subscription);
|
||||
foreach (var ras in _alarmSubscriptions.Values) subs.Add(ras.Subscription);
|
||||
if (_modelChangeSubscription is not null) subs.Add(_modelChangeSubscription);
|
||||
if (_serviceLevelSubscription is not null) subs.Add(_serviceLevelSubscription);
|
||||
|
||||
if (subs.Count > 0)
|
||||
{
|
||||
var transferred = await oldSession.TransferSubscriptionsAsync(
|
||||
subs, sendInitialValues: true, cancellationToken).ConfigureAwait(false);
|
||||
if (!transferred)
|
||||
{
|
||||
_diagnostics.RecordRedundancyFailoverFailure();
|
||||
try { if (newSession is Session s) await s.CloseAsync(cancellationToken).ConfigureAwait(false); } catch { }
|
||||
try { newSession?.Dispose(); } catch { }
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
_diagnostics.RecordRedundancyFailoverFailure();
|
||||
try { if (newSession is Session s) await s.CloseAsync(cancellationToken).ConfigureAwait(false); } catch { }
|
||||
try { newSession?.Dispose(); } catch { }
|
||||
return;
|
||||
}
|
||||
|
||||
// Swap. Move keep-alive + diagnostics hooks onto the new session so the
|
||||
// failover-driven session continues to feed reconnect + counter pipelines.
|
||||
if (_keepAliveHandler is not null)
|
||||
{
|
||||
try { oldSession.KeepAlive -= _keepAliveHandler; } catch { }
|
||||
newSession.KeepAlive += _keepAliveHandler;
|
||||
}
|
||||
UnwireSessionDiagnostics(oldSession);
|
||||
WireSessionDiagnostics(newSession);
|
||||
|
||||
Session = newSession;
|
||||
_connectedEndpointUrl = newUri;
|
||||
_operationLimits = null; // refetch against new server
|
||||
Interlocked.Exchange(ref _lastFailoverTicks, DateTime.UtcNow.Ticks);
|
||||
_diagnostics.RecordRedundancyFailover(newUri);
|
||||
|
||||
try { if (oldSession is Session os) await os.CloseAsync(cancellationToken).ConfigureAwait(false); } catch { }
|
||||
try { oldSession.Dispose(); } catch { }
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
// ---- IHistoryProvider (passthrough to upstream server) ----
|
||||
|
||||
public async Task<Core.Abstractions.HistoryReadResult> ReadRawAsync(
|
||||
@@ -2540,22 +3085,238 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
/// <summary>Map <see cref="HistoryAggregateType"/> to the OPC UA Part 13 standard aggregate NodeId.</summary>
|
||||
/// <summary>
|
||||
/// Map <see cref="HistoryAggregateType"/> to the OPC UA Part 13 standard aggregate
|
||||
/// NodeId. Each enum value resolves to <c>Opc.Ua.ObjectIds.AggregateFunction_*</c>;
|
||||
/// the upstream server may still reject individual aggregates with
|
||||
/// <c>BadAggregateNotSupported</c> on the per-row HistoryRead result — that's a
|
||||
/// server-capability signal, not a driver-side error, so callers should treat the
|
||||
/// mapping itself as best-effort.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// The supplied enum value is outside the declared <see cref="HistoryAggregateType"/>
|
||||
/// range — most likely a future-extension value the driver hasn't been recompiled for.
|
||||
/// </exception>
|
||||
internal static NodeId MapAggregateToNodeId(HistoryAggregateType aggregate) => aggregate switch
|
||||
{
|
||||
// ---- Original 5 (ordinals 0-4) ----
|
||||
HistoryAggregateType.Average => ObjectIds.AggregateFunction_Average,
|
||||
HistoryAggregateType.Minimum => ObjectIds.AggregateFunction_Minimum,
|
||||
HistoryAggregateType.Maximum => ObjectIds.AggregateFunction_Maximum,
|
||||
HistoryAggregateType.Total => ObjectIds.AggregateFunction_Total,
|
||||
HistoryAggregateType.Count => ObjectIds.AggregateFunction_Count,
|
||||
|
||||
// ---- Time-weighted averages ----
|
||||
HistoryAggregateType.TimeAverage => ObjectIds.AggregateFunction_TimeAverage,
|
||||
HistoryAggregateType.TimeAverage2 => ObjectIds.AggregateFunction_TimeAverage2,
|
||||
|
||||
// ---- Interpolation ----
|
||||
HistoryAggregateType.Interpolative => ObjectIds.AggregateFunction_Interpolative,
|
||||
|
||||
// ---- Min/Max with timestamps + range ----
|
||||
HistoryAggregateType.MinimumActualTime => ObjectIds.AggregateFunction_MinimumActualTime,
|
||||
HistoryAggregateType.MaximumActualTime => ObjectIds.AggregateFunction_MaximumActualTime,
|
||||
HistoryAggregateType.Range => ObjectIds.AggregateFunction_Range,
|
||||
HistoryAggregateType.Range2 => ObjectIds.AggregateFunction_Range2,
|
||||
|
||||
// ---- Annotation / duration / quality coverage ----
|
||||
HistoryAggregateType.AnnotationCount => ObjectIds.AggregateFunction_AnnotationCount,
|
||||
HistoryAggregateType.DurationGood => ObjectIds.AggregateFunction_DurationGood,
|
||||
HistoryAggregateType.DurationBad => ObjectIds.AggregateFunction_DurationBad,
|
||||
HistoryAggregateType.PercentGood => ObjectIds.AggregateFunction_PercentGood,
|
||||
HistoryAggregateType.PercentBad => ObjectIds.AggregateFunction_PercentBad,
|
||||
HistoryAggregateType.WorstQuality => ObjectIds.AggregateFunction_WorstQuality,
|
||||
HistoryAggregateType.WorstQuality2 => ObjectIds.AggregateFunction_WorstQuality2,
|
||||
|
||||
// ---- Statistical ----
|
||||
HistoryAggregateType.StandardDeviationSample => ObjectIds.AggregateFunction_StandardDeviationSample,
|
||||
HistoryAggregateType.StandardDeviationPopulation => ObjectIds.AggregateFunction_StandardDeviationPopulation,
|
||||
HistoryAggregateType.VarianceSample => ObjectIds.AggregateFunction_VarianceSample,
|
||||
HistoryAggregateType.VariancePopulation => ObjectIds.AggregateFunction_VariancePopulation,
|
||||
|
||||
// ---- State-based ----
|
||||
HistoryAggregateType.NumberOfTransitions => ObjectIds.AggregateFunction_NumberOfTransitions,
|
||||
HistoryAggregateType.DurationInStateZero => ObjectIds.AggregateFunction_DurationInStateZero,
|
||||
HistoryAggregateType.DurationInStateNonZero => ObjectIds.AggregateFunction_DurationInStateNonZero,
|
||||
|
||||
// ---- Interval bounds and deltas ----
|
||||
HistoryAggregateType.Start => ObjectIds.AggregateFunction_Start,
|
||||
HistoryAggregateType.End => ObjectIds.AggregateFunction_End,
|
||||
HistoryAggregateType.Delta => ObjectIds.AggregateFunction_Delta,
|
||||
HistoryAggregateType.StartBound => ObjectIds.AggregateFunction_StartBound,
|
||||
HistoryAggregateType.EndBound => ObjectIds.AggregateFunction_EndBound,
|
||||
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, null),
|
||||
};
|
||||
|
||||
// ReadEventsAsync stays at the interface default (throws NotSupportedException) per
|
||||
// IHistoryProvider contract -- the OPC UA Client driver CAN forward HistoryReadEvents,
|
||||
// but the call-site needs an EventFilter SelectClauses surface which the interface
|
||||
// doesn't carry. Landing the event-history passthrough requires extending
|
||||
// IHistoryProvider.ReadEventsAsync with a filter-spec parameter; out of scope for this PR.
|
||||
// The fixed-field ReadEventsAsync(sourceName,...) overload stays at the interface
|
||||
// default. The OPC UA Client driver implements the filter-aware
|
||||
// ReadEventsAsync(fullReference, EventHistoryRequest, ct) overload below — that one
|
||||
// carries the EventFilter SelectClauses + WhereClause shape we need to translate the
|
||||
// upstream ReadEventDetails verbatim.
|
||||
|
||||
/// <summary>
|
||||
/// Filter-aware HistoryReadEvents passthrough. Translates an
|
||||
/// <see cref="EventHistoryRequest"/> into an OPC UA <c>ReadEventDetails</c> + the
|
||||
/// filter the upstream server expects, calls
|
||||
/// <c>Session.HistoryReadAsync</c>, and unwraps the returned
|
||||
/// <see cref="HistoryEvent"/> into <see cref="HistoricalEventBatch"/> rows whose
|
||||
/// <see cref="HistoricalEventRow.Fields"/> dictionaries are keyed by the
|
||||
/// <see cref="SimpleAttributeSpec.FieldName"/> the caller supplied (so the
|
||||
/// server-side dispatcher can re-align with the wire-side SelectClause order).
|
||||
/// </summary>
|
||||
public async Task<HistoricalEventBatch> ReadEventsAsync(
|
||||
string fullReference, EventHistoryRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null) throw new ArgumentNullException(nameof(request));
|
||||
|
||||
// Default SelectClauses cover the standard BaseEventType columns when the caller
|
||||
// didn't customize. Order matches BuildHistoryEvent on the server side so unfiltered
|
||||
// browse-history clients see "EventId / SourceName / Time / Message / Severity".
|
||||
var selectClauses = request.SelectClauses;
|
||||
if (selectClauses is null || selectClauses.Count == 0)
|
||||
selectClauses = DefaultEventSelectClauses;
|
||||
|
||||
var session = RequireSession();
|
||||
var filter = ToOpcEventFilter(selectClauses, request.WhereClause, session.MessageContext);
|
||||
var details = new ReadEventDetails
|
||||
{
|
||||
StartTime = request.StartTime,
|
||||
EndTime = request.EndTime,
|
||||
NumValuesPerNode = request.NumValuesPerNode,
|
||||
Filter = filter,
|
||||
};
|
||||
if (!TryParseNodeId(session, fullReference, out var nodeId))
|
||||
{
|
||||
// Same shape ExecuteHistoryReadAsync uses for an unparseable NodeId — empty
|
||||
// result, not an exception, so a batch HistoryReadEvents over many notifiers
|
||||
// doesn't fail the whole request when one identifier is malformed.
|
||||
return new HistoricalEventBatch([], null);
|
||||
}
|
||||
|
||||
var nodesToRead = new HistoryReadValueIdCollection
|
||||
{
|
||||
new HistoryReadValueId { NodeId = nodeId },
|
||||
};
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var resp = await session.HistoryReadAsync(
|
||||
requestHeader: null,
|
||||
historyReadDetails: new ExtensionObject(details),
|
||||
timestampsToReturn: TimestampsToReturn.Both,
|
||||
releaseContinuationPoints: false,
|
||||
nodesToRead: nodesToRead,
|
||||
ct: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (resp.Results.Count == 0) return new HistoricalEventBatch([], null);
|
||||
var r = resp.Results[0];
|
||||
|
||||
var rows = new List<HistoricalEventRow>();
|
||||
if (r.HistoryData?.Body is HistoryEvent he)
|
||||
{
|
||||
foreach (var fieldList in he.Events)
|
||||
{
|
||||
var dict = new Dictionary<string, object?>(selectClauses.Count, StringComparer.Ordinal);
|
||||
var values = fieldList.EventFields;
|
||||
// Walk SelectClauses + EventFields in lockstep — OPC UA Part 4 guarantees
|
||||
// the field order on the wire matches the SelectClauses we sent.
|
||||
var max = Math.Min(values.Count, selectClauses.Count);
|
||||
DateTimeOffset occurrence = default;
|
||||
for (var i = 0; i < max; i++)
|
||||
{
|
||||
var key = selectClauses[i].FieldName;
|
||||
var value = values[i].Value;
|
||||
dict[key] = value;
|
||||
// Capture occurrence time when we recognize a "Time" field — used for
|
||||
// ordering / windowing; the dictionary still carries it verbatim.
|
||||
if (occurrence == default && value is DateTime dtVal)
|
||||
{
|
||||
if (string.Equals(key, "Time", StringComparison.OrdinalIgnoreCase) ||
|
||||
IsTimeBrowsePath(selectClauses[i]))
|
||||
{
|
||||
occurrence = new DateTimeOffset(
|
||||
DateTime.SpecifyKind(dtVal, DateTimeKind.Utc));
|
||||
}
|
||||
}
|
||||
}
|
||||
rows.Add(new HistoricalEventRow(dict, occurrence));
|
||||
}
|
||||
}
|
||||
|
||||
var contPt = r.ContinuationPoint is { Length: > 0 } ? r.ContinuationPoint : null;
|
||||
return new HistoricalEventBatch(rows, contPt);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default SelectClause set for the filter-aware ReadEventsAsync overload when the
|
||||
/// caller didn't supply one. Matches <c>BuildHistoryEvent</c> on the server side so
|
||||
/// "no filter specified" still produces recognizable BaseEventType columns.
|
||||
/// </summary>
|
||||
internal static readonly IReadOnlyList<SimpleAttributeSpec> DefaultEventSelectClauses =
|
||||
[
|
||||
new SimpleAttributeSpec(null, ["EventId"], "EventId"),
|
||||
new SimpleAttributeSpec(null, ["SourceName"], "SourceName"),
|
||||
new SimpleAttributeSpec(null, ["Time"], "Time"),
|
||||
new SimpleAttributeSpec(null, ["Message"], "Message"),
|
||||
new SimpleAttributeSpec(null, ["Severity"], "Severity"),
|
||||
new SimpleAttributeSpec(null, ["ReceiveTime"], "ReceiveTime"),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Translate transport-neutral <see cref="EventHistoryRequest"/> filter pieces into
|
||||
/// an OPC UA <see cref="EventFilter"/>. The where-clause path forwards the encoded
|
||||
/// bytes verbatim — when present they were captured upstream of the driver
|
||||
/// (server-side wire decode) and the upstream server expects to re-decode them.
|
||||
/// </summary>
|
||||
internal static EventFilter ToOpcEventFilter(
|
||||
IReadOnlyList<SimpleAttributeSpec> selectClauses,
|
||||
ContentFilterSpec? whereClause,
|
||||
IServiceMessageContext? messageContext = null)
|
||||
{
|
||||
var filter = new EventFilter();
|
||||
foreach (var sc in selectClauses)
|
||||
{
|
||||
var operand = new SimpleAttributeOperand
|
||||
{
|
||||
TypeDefinitionId = sc.TypeDefinitionId is null
|
||||
? ObjectTypeIds.BaseEventType
|
||||
: NodeId.Parse(sc.TypeDefinitionId),
|
||||
BrowsePath = [.. sc.BrowsePath.Select(seg => new QualifiedName(seg))],
|
||||
AttributeId = Attributes.Value,
|
||||
};
|
||||
filter.SelectClauses.Add(operand);
|
||||
}
|
||||
if (whereClause?.EncodedOperands is { Length: > 0 } bytes && messageContext is not null)
|
||||
{
|
||||
// Decode the wire-side ContentFilter the server-side dispatcher captured. We
|
||||
// route through the SDK's BinaryDecoder using the live session's MessageContext
|
||||
// so the upstream server sees an exact round-trip of the original bytes — the
|
||||
// OPC UA Client driver is a passthrough for filter semantics; it does not
|
||||
// evaluate them.
|
||||
try
|
||||
{
|
||||
using var decoder = new BinaryDecoder(bytes, messageContext);
|
||||
var decoded = decoder.ReadEncodeable(null, typeof(ContentFilter)) as ContentFilter;
|
||||
if (decoded is not null) filter.WhereClause = decoded;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort — a malformed where-clause shouldn't poison the SelectClause path.
|
||||
}
|
||||
}
|
||||
return filter;
|
||||
}
|
||||
|
||||
private static bool IsTimeBrowsePath(SimpleAttributeSpec spec)
|
||||
{
|
||||
if (spec.BrowsePath.Count != 1) return false;
|
||||
var seg = spec.BrowsePath[0];
|
||||
return string.Equals(seg, "Time", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
|
||||
@@ -253,8 +253,139 @@ public sealed class OpcUaClientDriverOptions
|
||||
/// short enough that single-node adds re-import promptly.
|
||||
/// </summary>
|
||||
public TimeSpan ModelChangeDebounce { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Reverse-connect (server-initiated) configuration. When
|
||||
/// <see cref="ReverseConnectOptions.Enabled"/> is <c>true</c> the driver flips the
|
||||
/// transport direction: instead of dialling the upstream server, it opens a TCP
|
||||
/// listener on <see cref="ReverseConnectOptions.ListenerUrl"/> and waits for the
|
||||
/// upstream server to initiate the connection ("ReverseHello"). Required for
|
||||
/// OT-DMZ deployments where the firewall only permits outbound traffic from the
|
||||
/// plant network — the upstream server reaches out, the gateway listens.
|
||||
/// </summary>
|
||||
public ReverseConnectOptions ReverseConnect { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Upstream-redundancy configuration (PR-14, issue #286). Distinct from the
|
||||
/// boot-time failover sweep on <see cref="EndpointUrls"/> — this section governs
|
||||
/// <i>mid-session</i> failover driven by the upstream's own
|
||||
/// <c>ServerStatus.ServerArray</c> + <c>Server.ServiceLevel</c> nodes. When
|
||||
/// enabled, the driver reads the upstream's redundant peer list at session
|
||||
/// activation, monitors <c>ServiceLevel</c> via subscription, and promotes a
|
||||
/// secondary upstream when the active upstream's ServiceLevel drops below
|
||||
/// <see cref="RedundancyOptions.ServiceLevelThreshold"/>. Subscriptions transfer
|
||||
/// to the secondary so monitored-item handles survive the swap.
|
||||
/// </summary>
|
||||
public RedundancyOptions Redundancy { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upstream-side redundancy knobs for the OPC UA Client driver. The OPC UA spec
|
||||
/// models redundant servers via <c>ServerStatus.ServerArray</c> (the peer URI list)
|
||||
/// and <c>Server.ServiceLevel</c> (an unsigned byte where higher = healthier; spec
|
||||
/// range is 0..255 with 200 typical for a healthy primary, 100..199 for degraded,
|
||||
/// 0..99 for unrecoverable). When the upstream advertises non-<c>None</c>
|
||||
/// <c>ServerRedundancyType.RedundancySupport</c>, the driver subscribes to
|
||||
/// <c>ServiceLevel</c> on the active upstream and fails over to the next URI in
|
||||
/// <c>ServerArray</c> the moment a drop crosses
|
||||
/// <see cref="ServiceLevelThreshold"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Shared client-cert prerequisite</b>: the failover path uses
|
||||
/// <c>Session.TransferSubscriptionsAsync</c> to migrate live subscriptions onto
|
||||
/// the secondary session, which means the secondary's secure-channel auth must
|
||||
/// accept the same client certificate the primary did. Operators running a
|
||||
/// heterogeneous secondary (different cert trust store) must fall back to
|
||||
/// re-creating subscriptions on the swap, which is tracked as a follow-up.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why not unconditional</b>: this section defaults to disabled because most
|
||||
/// deployments wire client-side redundancy via <see cref="OpcUaClientDriverOptions.EndpointUrls"/>
|
||||
/// (one-shot connect failover). Upstream redundancy is opt-in so existing
|
||||
/// deployments don't suddenly start subscribing to <c>ServiceLevel</c> on every
|
||||
/// upstream, which would surprise operators reading their server's session list.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Enabled">
|
||||
/// Enable mid-session failover driven by the upstream's <c>ServerArray</c> +
|
||||
/// <c>ServiceLevel</c>. Default <c>false</c> — opt-in so existing deployments
|
||||
/// keep the current "EndpointUrls is the failover list" semantics.
|
||||
/// </param>
|
||||
/// <param name="ServiceLevelThreshold">
|
||||
/// ServiceLevel value below which the driver triggers failover. Default 200 mirrors
|
||||
/// the OPC UA spec's "healthy" floor — anything below is at least degraded. Lower
|
||||
/// to 100 for "only fail over on unrecoverable drops", raise toward 255 for an
|
||||
/// aggressive failover policy that swaps on any health degradation.
|
||||
/// </param>
|
||||
/// <param name="RecheckInterval">
|
||||
/// Lower bound on time between two consecutive failovers when ServiceLevel oscillates.
|
||||
/// Without this guard a flapping primary (alternating 199 → 200 → 199) could trigger
|
||||
/// N failovers per second; the recheck window pins the rate at one swap per interval.
|
||||
/// Default 5 seconds — long enough to dampen oscillation, short enough that real
|
||||
/// drops still produce timely failover.
|
||||
/// </param>
|
||||
public sealed record RedundancyOptions(
|
||||
bool Enabled = false,
|
||||
ushort ServiceLevelThreshold = 200,
|
||||
TimeSpan? RecheckInterval = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolved recheck interval — defaults to 5 seconds when
|
||||
/// <see cref="RecheckInterval"/> is null. Property rather than ctor default so the
|
||||
/// record stays JSON-friendly (System.Text.Json doesn't honour
|
||||
/// parameter defaults on records when the property is missing).
|
||||
/// </summary>
|
||||
public TimeSpan ResolvedRecheckInterval => RecheckInterval ?? TimeSpan.FromSeconds(5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Driver knobs for OPC UA reverse-connect (server-initiated) sessions. Mirrors the
|
||||
/// SDK's <c>Opc.Ua.Client.ReverseConnectManager</c> surface but expressed as plain
|
||||
/// config so the driver can decide listener-mode vs dial-mode at startup.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Direction</b>: in conventional OPC UA the client opens the TCP connection
|
||||
/// to the server. Reverse-connect inverts this — the server initiates a TCP
|
||||
/// connection to a listener the client exposes, then sends a <c>ReverseHello</c>
|
||||
/// message naming itself; the client picks the right session config and continues
|
||||
/// the OPC UA handshake on the inbound socket. Critical for OT-DMZ networks where
|
||||
/// the plant firewall only allows outbound traffic.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Singleton listener</b>: a single <c>ReverseConnectManager</c> per process
|
||||
/// keyed on <see cref="ListenerUrl"/> multiplexes inbound connections across
|
||||
/// driver instances. Two drivers configured with the same listener URL share one
|
||||
/// underlying TCP socket; the manager dispatches by the upstream's reported
|
||||
/// <c>ServerUri</c>. See <c>ReverseConnectListener</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Enabled">
|
||||
/// When <c>true</c>, the driver opens a listener at <see cref="ListenerUrl"/> and
|
||||
/// waits for the upstream server to initiate the session. When <c>false</c>
|
||||
/// (default), the driver uses the conventional dial path against
|
||||
/// <see cref="OpcUaClientDriverOptions.EndpointUrls"/>.
|
||||
/// </param>
|
||||
/// <param name="ListenerUrl">
|
||||
/// Local listener URL the SDK binds when reverse-connect is enabled. Typically
|
||||
/// <c>opc.tcp://0.0.0.0:4844</c> to accept on every interface, or pinned to a
|
||||
/// specific NIC for multi-homed gateways. Required when
|
||||
/// <see cref="Enabled"/> is <c>true</c>.
|
||||
/// </param>
|
||||
/// <param name="ExpectedServerUri">
|
||||
/// The upstream server's <c>ApplicationUri</c> the driver expects to see in the
|
||||
/// <c>ReverseHello</c>. The SDK passes this as the <c>serverUri</c> filter to
|
||||
/// <c>WaitForConnectionAsync</c> so connections from a different upstream are ignored
|
||||
/// — important when the listener is shared across multiple drivers and several
|
||||
/// upstreams might dial in. Leave <c>null</c> to accept the first connection regardless
|
||||
/// (only safe when exactly one upstream targets the listener).
|
||||
/// </param>
|
||||
public sealed record ReverseConnectOptions(
|
||||
bool Enabled = false,
|
||||
string? ListenerUrl = null,
|
||||
string? ExpectedServerUri = null);
|
||||
|
||||
/// <summary>
|
||||
/// Selective import + namespace remap rules for the OPC UA Client driver. Pure local
|
||||
/// filtering inside <c>BrowseRecursiveAsync</c> + <c>EnrichAndRegisterVariablesAsync</c>;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide ref-counted wrapper around <see cref="ReverseConnectManager"/>.
|
||||
/// Multiple <see cref="OpcUaClientDriver"/> instances that share a listener URL
|
||||
/// multiplex onto a single underlying manager — opening N sockets on the same
|
||||
/// port would conflict at the OS layer, and the SDK's manager already dispatches
|
||||
/// inbound connections by <c>ServerUri</c>, so one manager per listener URL is
|
||||
/// both correct and unavoidable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Lifecycle</b>: callers <see cref="Acquire"/> a listener for their
|
||||
/// configured URL during driver init and <see cref="Release"/> on shutdown.
|
||||
/// The first <see cref="Acquire"/> for a given URL constructs the manager,
|
||||
/// calls <c>AddEndpoint</c>, and starts the listener. The last
|
||||
/// <see cref="Release"/> stops the listener + disposes the manager. Reference
|
||||
/// counting lets two drivers come up + go down independently without racing on
|
||||
/// port-bind / port-unbind.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why not Lazy<T></b>: the listener key is dynamic (the URL string)
|
||||
/// which Lazy doesn't model. A <see cref="ConcurrentDictionary{TKey,TValue}"/>
|
||||
/// + locked entry counter is the simplest pattern that handles add/remove
|
||||
/// atomically without a global lock.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class ReverseConnectListener : IDisposable
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, ReverseConnectListener> s_instances
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static readonly object s_globalLock = new();
|
||||
|
||||
private readonly string _listenerUrl;
|
||||
private readonly ReverseConnectManager _manager;
|
||||
private int _refCount;
|
||||
private bool _started;
|
||||
private ApplicationConfiguration? _appConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Test seam — total instances ever created for a given URL. Lets unit tests
|
||||
/// assert that two drivers sharing a URL only spin up one underlying manager.
|
||||
/// </summary>
|
||||
internal static int InstanceCountForTest(string listenerUrl) =>
|
||||
s_instances.TryGetValue(listenerUrl, out var inst) ? inst._refCount : 0;
|
||||
|
||||
/// <summary>Test seam — peek the underlying SDK manager for assertion / mocking.</summary>
|
||||
internal ReverseConnectManager Manager => _manager;
|
||||
|
||||
/// <summary>Test seam — current refcount on this entry.</summary>
|
||||
internal int RefCountForTest => _refCount;
|
||||
|
||||
private ReverseConnectListener(string listenerUrl)
|
||||
{
|
||||
_listenerUrl = listenerUrl;
|
||||
#pragma warning disable CS0618 // ITelemetryContext-less ctor is obsolete; the driver doesn't plumb telemetry
|
||||
_manager = new ReverseConnectManager();
|
||||
#pragma warning restore CS0618
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acquire a reference to the listener for <paramref name="listenerUrl"/>. The
|
||||
/// first acquire constructs the manager + binds the socket; subsequent acquires
|
||||
/// bump the ref-count and reuse the live manager. Idempotent on
|
||||
/// <c>AddEndpoint</c> — the SDK's manager tolerates the same URL being added
|
||||
/// twice but the listener guards against it anyway so the wire-side state stays
|
||||
/// deterministic across test runs. <paramref name="appConfig"/> is needed by
|
||||
/// <c>StartService</c> so the manager has the cert validator + transport quotas
|
||||
/// of the calling driver. The first acquire's config wins — subsequent acquires
|
||||
/// trust that all drivers sharing the listener use compatible cert / quota config.
|
||||
/// </summary>
|
||||
public static ReverseConnectListener Acquire(string listenerUrl, ApplicationConfiguration appConfig)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(listenerUrl))
|
||||
throw new ArgumentException("listenerUrl is required", nameof(listenerUrl));
|
||||
ArgumentNullException.ThrowIfNull(appConfig);
|
||||
|
||||
lock (s_globalLock)
|
||||
{
|
||||
var entry = s_instances.GetOrAdd(listenerUrl, u => new ReverseConnectListener(u));
|
||||
entry._refCount++;
|
||||
entry.EnsureStarted(appConfig);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureStarted(ApplicationConfiguration appConfig)
|
||||
{
|
||||
if (_started) return;
|
||||
_appConfig = appConfig;
|
||||
var uri = new Uri(_listenerUrl);
|
||||
_manager.AddEndpoint(uri);
|
||||
_manager.StartService(appConfig);
|
||||
_started = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam — construct a listener entry without binding a real socket. Used
|
||||
/// by unit tests to verify acquire/release ref-counting without paying the
|
||||
/// SDK + OS port-bind cost.
|
||||
/// </summary>
|
||||
internal static ReverseConnectListener AcquireForTest(string listenerUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(listenerUrl))
|
||||
throw new ArgumentException("listenerUrl is required", nameof(listenerUrl));
|
||||
|
||||
lock (s_globalLock)
|
||||
{
|
||||
var entry = s_instances.GetOrAdd(listenerUrl, u => new ReverseConnectListener(u));
|
||||
entry._refCount++;
|
||||
// Skip EnsureStarted — tests don't want the real port bind.
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for the upstream named by <paramref name="serverUri"/> to dial in to
|
||||
/// this listener. Proxies straight to the SDK's
|
||||
/// <c>WaitForConnection</c> — the listener owns lifecycle, the SDK owns the
|
||||
/// wait + the inbound transport object.
|
||||
/// </summary>
|
||||
public Task<ITransportWaitingConnection> WaitForServerAsync(
|
||||
Uri endpointUrl,
|
||||
string? serverUri,
|
||||
CancellationToken ct = default) =>
|
||||
_manager.WaitForConnectionAsync(endpointUrl, serverUri, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Drop one reference. When the last reference goes away the manager is stopped
|
||||
/// and the dictionary entry removed so a future driver can rebind the same port
|
||||
/// cleanly. Releasing a listener that's already been fully torn down is a no-op
|
||||
/// so disposal paths can be defensive.
|
||||
/// </summary>
|
||||
public void Release()
|
||||
{
|
||||
lock (s_globalLock)
|
||||
{
|
||||
_refCount--;
|
||||
if (_refCount > 0) return;
|
||||
|
||||
// Dispose() walks DisposeHosts() → CloseHosts() internally so the listener
|
||||
// socket is released cleanly. There's no public StopService; the SDK exposes
|
||||
// only Dispose() for external teardown.
|
||||
try { _manager.Dispose(); } catch { /* best-effort during shutdown */ }
|
||||
s_instances.TryRemove(_listenerUrl, out _);
|
||||
_started = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => Release();
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D1 / #299 — read a TIA Portal CSV ("Show all tags" export) or a STEP 7 Classic
|
||||
/// AWL declaration file and emit either an <c>appsettings.json</c> tag fragment or a
|
||||
/// summary line. Mirrors the AB Legacy <c>import-rslogix</c> command in shape.
|
||||
/// </summary>
|
||||
[Command("import-symbols", Description =
|
||||
"Read a TIA Portal CSV symbol export or STEP 7 Classic AWL file and emit a JSON tag " +
|
||||
"fragment for appsettings.json. UDT-typed symbols import as placeholders until PR-S7-D2.")]
|
||||
public sealed class ImportSymbolsCommand : ICommand
|
||||
{
|
||||
[CommandOption("file", 'f', Description =
|
||||
"Path to the symbol export. CSV or .AWL — see --format.",
|
||||
IsRequired = true)]
|
||||
public string File { get; init; } = default!;
|
||||
|
||||
[CommandOption("format", Description =
|
||||
"Source format: 'tia' (TIA Portal CSV — Name,Path,Data type,Logical address,…) or " +
|
||||
"'awl' (STEP 7 Classic VAR_GLOBAL + DATA_BLOCK declarations). Default: 'tia'.")]
|
||||
public string Format { get; init; } = "tia";
|
||||
|
||||
[CommandOption("device", 'd', Description =
|
||||
"Optional documentation tag for the device the imported symbols belong to. The S7 driver " +
|
||||
"ties tags to the driver instance (one Host per instance), so the value is currently emitted " +
|
||||
"as a comment-only annotation in the summary; reserved so the field exists for symmetry " +
|
||||
"with the AB Legacy import CLI.")]
|
||||
public string? Device { get; init; }
|
||||
|
||||
[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("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))
|
||||
{
|
||||
throw new CommandException($"Symbol export file not found: {File}", exitCode: 1);
|
||||
}
|
||||
|
||||
var opts = new S7ImportOptions(
|
||||
MaxRowsToImport: MaxRows,
|
||||
IgnoreInvalid: !Strict);
|
||||
|
||||
var format = Format?.Trim().ToLowerInvariant() ?? "tia";
|
||||
IS7SymbolImporter importer = format switch
|
||||
{
|
||||
"tia" or "csv" or null or "" => new TiaCsvImporter(),
|
||||
"awl" or "step7" or "stl" => new AwlImporter(),
|
||||
_ => throw new CommandException(
|
||||
$"Unknown --format value '{Format}'. Use 'tia' or 'awl'.", exitCode: 2),
|
||||
};
|
||||
|
||||
S7ImportResult result;
|
||||
using (var stream = System.IO.File.OpenRead(File))
|
||||
{
|
||||
result = importer.Parse(stream, 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}, udt={result.UdtPlaceholderCount}).");
|
||||
}
|
||||
else
|
||||
{
|
||||
await console.Output.WriteLineAsync(payload);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialise the imported tag list as a JSON fragment shaped like the
|
||||
/// <c>S7DriverConfigDto</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(S7ImportResult result)
|
||||
{
|
||||
var tags = result.Tags.Select(t => new
|
||||
{
|
||||
Name = t.Name,
|
||||
Address = t.Address,
|
||||
DataType = t.DataType.ToString(),
|
||||
Writable = t.Writable,
|
||||
StringLength = t.StringLength,
|
||||
}).ToArray();
|
||||
var doc = new { Tags = tags };
|
||||
return JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
|
||||
private static string FormatSummary(S7ImportResult result) =>
|
||||
$"Imported {result.ParsedCount} tag(s), skipped {result.SkippedCount}, errors {result.ErrorCount}, udt-placeholders {result.UdtPlaceholderCount}.";
|
||||
}
|
||||
@@ -50,6 +50,20 @@ public abstract class S7CommandBase : DriverCommandBase
|
||||
"the class default under Pg/Op/S7Basic.")]
|
||||
public ushort? RemoteTsap { get; init; }
|
||||
|
||||
[CommandOption("password", Description =
|
||||
"Connection-level password emitted to the PLC right after OpenAsync. Used by hardened " +
|
||||
"S7-300/400 deployments running protection levels 1-3 and S7-1200/1500 deployments with " +
|
||||
"a connection-mechanism password set. Default unset. Never logged. NB: S7netplus 0.20 " +
|
||||
"does not yet expose SendPassword — when the linked library lacks the API the CLI prints " +
|
||||
"a warning and continues. See docs/v2/s7.md \"PLC password / protection levels\".")]
|
||||
public string? Password { get; init; }
|
||||
|
||||
[CommandOption("protection-level", Description =
|
||||
"Declarative hint about the PLC's protection scheme: Auto (default), None, Level1, Level2, " +
|
||||
"Level3 (S7-300/400), or ConnectionMechanism (S7-1200/1500). Diagnostic hint only; the " +
|
||||
"wire path is driven by --password.")]
|
||||
public ProtectionLevel ProtectionLevel { get; init; } = ProtectionLevel.Auto;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override TimeSpan Timeout
|
||||
{
|
||||
@@ -75,6 +89,8 @@ public abstract class S7CommandBase : DriverCommandBase
|
||||
TsapMode = TsapMode,
|
||||
LocalTsap = LocalTsap,
|
||||
RemoteTsap = RemoteTsap,
|
||||
Password = string.IsNullOrEmpty(Password) ? null : Password,
|
||||
ProtectionLevel = ProtectionLevel,
|
||||
};
|
||||
|
||||
protected string DriverInstanceId => $"s7-cli-{Host}:{Port}";
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using S7.Net;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
@@ -67,6 +71,14 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
private readonly Dictionary<string, S7TagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, S7ParsedAddress> _parsedByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D2 — post-fan-out tag list, in declaration order. Mirrors
|
||||
/// <see cref="_tagsByName"/> as a list so <see cref="DiscoverAsync"/> can produce a
|
||||
/// stable browse-tree ordering. Always preserves the original declaration order;
|
||||
/// UDT tags are replaced in-place by their fanned-out leaf children.
|
||||
/// </summary>
|
||||
private readonly List<S7TagDefinition> _effectiveTags = new();
|
||||
|
||||
private readonly S7DriverOptions _options = options;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
@@ -87,6 +99,68 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
private bool _disposed;
|
||||
|
||||
// ---- PR-S7-E1 — SZL / @System.* virtual address state ----
|
||||
//
|
||||
// SzlReader is the wire surface (interface so tests can substitute fakes); SzlCache
|
||||
// is the per-driver TTL cache fronting every SZL read so a burst of @System.* reads
|
||||
// from one OPC UA subscription tick produces exactly one wire request per SZL ID.
|
||||
// Both are constructed in InitializeAsync once Plc is open; both stay null when
|
||||
// ExposeSystemTags is false (cheap shortcut on the read path).
|
||||
private IS7SzlReader? _szlReader;
|
||||
private S7SzlCache? _szlCache;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E1 — test seam for the SZL wire reader. Setting this overrides the
|
||||
/// default <see cref="S7NetSzlReader"/> created from the live <see cref="Plc"/>
|
||||
/// so unit tests can drive <c>@System.*</c> reads with golden-byte payloads
|
||||
/// without needing a real PLC. Setting before <see cref="InitializeAsync"/> is
|
||||
/// fine — InitializeAsync only swaps in the production reader when this is null.
|
||||
/// </summary>
|
||||
internal IS7SzlReader? SzlReader
|
||||
{
|
||||
get => _szlReader;
|
||||
set => _szlReader = value;
|
||||
}
|
||||
|
||||
/// <summary>Test-only access to the SZL cache for assertions about TTL behaviour.</summary>
|
||||
internal S7SzlCache? SzlCache => _szlCache;
|
||||
|
||||
// ---- PR-S7-E2 / #303 — connection-level password (SendPassword) seam ----
|
||||
//
|
||||
// AuthGate wraps the reflective probe over S7.Net.Plc.SendPassword; setting it
|
||||
// before InitializeAsync lets unit tests inject a fake that reports
|
||||
// SupportsSendPassword + observes the call without standing up a real PLC.
|
||||
// Logger is an ILogger seam so the warning ("S7netplus does not expose
|
||||
// SendPassword") and the success line ("S7 password sent") flow into Serilog
|
||||
// through the host's default factory; tests inject a capturing logger.
|
||||
private IS7PlcAuthGate? _authGate;
|
||||
private ILogger<S7Driver> _logger = NullLogger<S7Driver>.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E2 — test seam for the password-send path. Setting before
|
||||
/// <see cref="InitializeAsync"/> overrides the default reflective gate so unit
|
||||
/// tests can verify the call site without needing a live PLC. <c>null</c> =
|
||||
/// production behaviour: <see cref="ReflectionS7PlcAuthGate"/> is constructed
|
||||
/// once <see cref="Plc"/> is open.
|
||||
/// </summary>
|
||||
internal IS7PlcAuthGate? AuthGate
|
||||
{
|
||||
get => _authGate;
|
||||
set => _authGate = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E2 — ILogger seam. Production callers go through the host's DI
|
||||
/// container to wire a Serilog-backed <see cref="ILoggerFactory"/>; tests
|
||||
/// inject a capturing logger to assert the warning-vs-info contract on the
|
||||
/// password path.
|
||||
/// </summary>
|
||||
internal ILogger<S7Driver> Logger
|
||||
{
|
||||
get => _logger;
|
||||
set => _logger = value ?? NullLogger<S7Driver>.Instance;
|
||||
}
|
||||
|
||||
// ---- Block-read coalescing diagnostics (PR-S7-B2) ----
|
||||
//
|
||||
// Counters surface through DriverHealth.Diagnostics so the driver-diagnostics
|
||||
@@ -141,8 +215,39 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
// story this lets the Admin UI's "Save" round-trip stay sub-second on bad input.
|
||||
_tagsByName.Clear();
|
||||
_parsedByName.Clear();
|
||||
_effectiveTags.Clear();
|
||||
foreach (var t in _options.Tags)
|
||||
{
|
||||
// PR-S7-D2 — UDT-typed tags are fanned out into N scalar leaf member tags
|
||||
// before any address parsing happens against the parent. Reads / writes /
|
||||
// subscribes never see the parent UDT tag; only its scalar children.
|
||||
if (!string.IsNullOrWhiteSpace(t.UdtName))
|
||||
{
|
||||
if (t.ElementCount is int udtElems && udtElems > 1)
|
||||
throw new FormatException(
|
||||
$"S7 tag '{t.Name}' is UDT-typed (UdtName='{t.UdtName}') and ElementCount > 1; " +
|
||||
"array-of-UDT must be expressed via array members inside the UDT layout, " +
|
||||
"not at the parent-tag level");
|
||||
|
||||
// Parse the parent-tag base address once so the fan-out can compute
|
||||
// member byte offsets relative to ByteOffset. Parser uses CpuType for
|
||||
// V-memory mapping on S7-200 / LOGO families.
|
||||
var parentParsed = S7AddressParser.Parse(t.Address, _options.CpuType);
|
||||
var leaves = S7UdtFanOut.Expand(t, _options.Udts, parentParsed);
|
||||
foreach (var leaf in leaves)
|
||||
{
|
||||
var leafParsed = S7AddressParser.Parse(leaf.Address, _options.CpuType);
|
||||
if (_tagsByName.ContainsKey(leaf.Name))
|
||||
throw new InvalidOperationException(
|
||||
$"S7 tag '{leaf.Name}' (fanned out from UDT '{t.UdtName}' on tag '{t.Name}') " +
|
||||
"collides with an existing tag name");
|
||||
_tagsByName[leaf.Name] = leaf;
|
||||
_parsedByName[leaf.Name] = leafParsed;
|
||||
_effectiveTags.Add(leaf);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pass CpuType so V-memory addresses (S7-200 / S7-200 Smart / LOGO!) resolve
|
||||
// against the device's family-specific DB mapping.
|
||||
var parsed = S7AddressParser.Parse(t.Address, _options.CpuType); // throws FormatException
|
||||
@@ -161,6 +266,7 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
}
|
||||
_tagsByName[t.Name] = t;
|
||||
_parsedByName[t.Name] = parsed;
|
||||
_effectiveTags.Add(t);
|
||||
}
|
||||
|
||||
var plc = BuildPlc();
|
||||
@@ -181,6 +287,21 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
// CPUs negotiate 240 bytes; CPUs running the extended PDU advertise 480 or 960.
|
||||
_negotiatedPduSize = plc.MaxPDUSize;
|
||||
|
||||
// PR-S7-E1 — wire up the SZL reader + cache. The reader respects an explicit
|
||||
// test-supplied override (set before InitializeAsync) so unit tests can drive
|
||||
// @System.* reads with canned payloads; production constructs the live S7netplus-
|
||||
// backed reader (which currently surfaces every read as "not supported" until
|
||||
// S7netplus exposes a public ReadSzlAsync).
|
||||
_szlReader ??= new S7NetSzlReader(plc);
|
||||
_szlCache = new S7SzlCache(_options.SzlCacheTtl);
|
||||
|
||||
// PR-S7-E2 / #303 — connection-level password. After a clean OpenAsync, if the
|
||||
// operator supplied Password, hand it to the auth gate. The gate is reflective
|
||||
// over S7.Net.Plc.SendPassword by default; tests inject a fake. When S7netplus
|
||||
// doesn't yet expose SendPassword (true for 0.20), we log a one-line warning
|
||||
// and continue — failure shifts to first per-tag read on a hardened CPU.
|
||||
await TrySendPlcPasswordAsync(plc, cts.Token).ConfigureAwait(false);
|
||||
|
||||
// PR-S7-C5 — pre-flight PUT/GET enablement probe. After a clean OpenAsync,
|
||||
// issue a tiny 2-byte read against Probe.ProbeAddress (default MW0). Hardened
|
||||
// S7-1200 / S7-1500 CPUs that have PUT/GET communication disabled in TIA
|
||||
@@ -244,6 +365,18 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
// Reset the snapshot so a post-shutdown diagnostics read doesn't display a stale
|
||||
// PDU size from the previous connection. Reinit will repopulate after OpenAsync.
|
||||
_negotiatedPduSize = 0;
|
||||
// PR-S7-D2 — drop the post-fan-out tag list so a Reinit can rebuild it cleanly
|
||||
// without the previous run's UDT leaves leaking into the new tag map.
|
||||
_effectiveTags.Clear();
|
||||
// PR-S7-E1 — drop the SZL state so a fresh Plc on Reinit gets fresh CPU info /
|
||||
// cycle stats / diagnostic-buffer entries. Clearing here keeps the test-supplied
|
||||
// SzlReader override intact (set before Initialize) so a Shutdown / re-Initialize
|
||||
// cycle from a unit test can re-use the same fake reader.
|
||||
_szlCache?.Clear();
|
||||
_szlCache = null;
|
||||
// _szlReader: keep an explicit test-supplied reader (set via SzlReader property);
|
||||
// drop the production one tied to the now-closed Plc so re-Init constructs fresh.
|
||||
if (_szlReader is S7NetSzlReader) _szlReader = null;
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
@@ -264,10 +397,35 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
var plc = RequirePlc();
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
|
||||
// PR-S7-E1 — short-circuit @System.* virtual addresses before taking the Plc
|
||||
// gate. SZL reads don't go through the regular tag map / address parser; they
|
||||
// dispatch through IS7SzlReader (cached for SzlCacheTtl) and parse with
|
||||
// S7SzlParser. Doing this first means the test path can read @System.* without
|
||||
// a Plc connection (the reader is injectable).
|
||||
var nonSystemIndexes = new List<int>(fullReferences.Count);
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var name = fullReferences[i];
|
||||
if (S7SystemTags.IsSystemAddress(name))
|
||||
{
|
||||
results[i] = await ReadSystemTagAsync(name, now, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
nonSystemIndexes.Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
// If every requested reference was a @System.* tag, we're done before touching
|
||||
// the Plc gate at all — keeps the @System.* surface usable in test setups that
|
||||
// injected an SzlReader without ever calling InitializeAsync against a real PLC.
|
||||
if (nonSystemIndexes.Count == 0) return results;
|
||||
|
||||
var plc = RequirePlc();
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
@@ -277,9 +435,9 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
// (arrays, strings, dates, 64-bit ints, UDT-fanout). Packable tags feed
|
||||
// the block-coalescing planner first (PR-S7-B2); whatever survives as a
|
||||
// singleton range falls through to the multi-var packer (PR-S7-B1).
|
||||
var packableIndexes = new List<int>(fullReferences.Count);
|
||||
var packableIndexes = new List<int>(nonSystemIndexes.Count);
|
||||
var fallbackIndexes = new List<int>();
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
foreach (var i in nonSystemIndexes)
|
||||
{
|
||||
var name = fullReferences[i];
|
||||
if (!_tagsByName.TryGetValue(name, out var tag))
|
||||
@@ -726,6 +884,105 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E1 — read one virtual <c>@System.*</c> address by dispatching through
|
||||
/// the SZL cache + reader, parsing the raw payload, and projecting the requested
|
||||
/// scalar field. Surfaces <c>BadNotSupported</c> when the reader returns null
|
||||
/// (snap7 / S7netplus 0.20 / hardened CPUs that reject SZL); <c>BadNodeIdUnknown</c>
|
||||
/// when the address starts with <c>@System.</c> but doesn't match a known tag;
|
||||
/// <c>BadInternalError</c> when the parser throws on a malformed payload.
|
||||
/// </summary>
|
||||
private async Task<DataValueSnapshot> ReadSystemTagAsync(string address, DateTime now, CancellationToken ct)
|
||||
{
|
||||
if (!S7SystemTags.TryResolve(address, out var descriptor, out var diagBufferIndex) || descriptor is null)
|
||||
return new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
|
||||
|
||||
var reader = _szlReader;
|
||||
if (reader is null)
|
||||
{
|
||||
// No reader wired (driver not initialised + no test override) — surface
|
||||
// BadNotSupported so a stray @System.* read doesn't masquerade as a code bug.
|
||||
return new DataValueSnapshot(null, StatusBadNotSupported, null, now);
|
||||
}
|
||||
|
||||
// Cache-front the wire read. When SzlCacheTtl is zero, the cache always misses
|
||||
// (TimeSpan.Zero < TimeSpan.Zero is false → every entry is stale instantly).
|
||||
// Lazily create the cache when InitializeAsync hasn't run yet (test seam) so
|
||||
// repeated reads in a unit test still de-dup against the same cache instance.
|
||||
_szlCache ??= new S7SzlCache(_options.SzlCacheTtl);
|
||||
var cache = _szlCache;
|
||||
byte[]? payload;
|
||||
try
|
||||
{
|
||||
payload = await cache.GetOrFetchAsync(
|
||||
descriptor.SzlId, descriptor.SzlIndex,
|
||||
tok => reader.ReadSzlAsync(descriptor.SzlId, descriptor.SzlIndex, tok),
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch
|
||||
{
|
||||
return new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||
}
|
||||
|
||||
if (payload is null)
|
||||
{
|
||||
// SZL not supported — snap7 and S7netplus 0.20 both land here.
|
||||
return new DataValueSnapshot(null, StatusBadNotSupported, null, now);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var value = ProjectSystemTagValue(descriptor, diagBufferIndex, payload);
|
||||
return new DataValueSnapshot(value, 0u, now, now);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// Malformed SZL payload — surface BadInternalError so a downstream client can
|
||||
// distinguish "wire failed" from "PLC sent garbage".
|
||||
return new DataValueSnapshot(null, StatusBadInternalError, null, now);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Project a parsed SZL payload to the scalar value the requested
|
||||
/// <paramref name="descriptor"/> exposes.
|
||||
/// </summary>
|
||||
private object? ProjectSystemTagValue(
|
||||
S7SystemTags.SystemTagDescriptor descriptor,
|
||||
int diagBufferIndex,
|
||||
byte[] payload)
|
||||
{
|
||||
switch (descriptor.Kind)
|
||||
{
|
||||
case S7SystemTags.SystemTagKind.CpuType:
|
||||
return S7SzlParser.ParseCpuInfo(payload).CpuType;
|
||||
case S7SystemTags.SystemTagKind.Firmware:
|
||||
return S7SzlParser.ParseCpuInfo(payload).Firmware;
|
||||
case S7SystemTags.SystemTagKind.OrderNo:
|
||||
return S7SzlParser.ParseCpuInfo(payload).OrderNo;
|
||||
case S7SystemTags.SystemTagKind.CycleMin:
|
||||
return S7SzlParser.ParseCycleStats(payload).MinMs;
|
||||
case S7SystemTags.SystemTagKind.CycleMax:
|
||||
return S7SzlParser.ParseCycleStats(payload).MaxMs;
|
||||
case S7SystemTags.SystemTagKind.CycleAvg:
|
||||
return S7SzlParser.ParseCycleStats(payload).AvgMs;
|
||||
case S7SystemTags.SystemTagKind.DiagBufferEntry:
|
||||
var depth = Math.Min(_options.DiagBufferDepth, S7SystemTags.MaxDiagBufferDepth);
|
||||
if (diagBufferIndex < 0 || diagBufferIndex >= depth)
|
||||
return null; // out of range — surface as null value with Good status
|
||||
var entries = S7SzlParser.ParseDiagBuffer(payload, depth);
|
||||
if (diagBufferIndex >= entries.Count) return null;
|
||||
var e = entries[diagBufferIndex];
|
||||
// Render each entry as one human-readable line — keeps the OPC UA surface
|
||||
// a flat array of strings, which clients can split / grep without needing
|
||||
// a custom structured DataType. Format is stable so log scrapers can parse it.
|
||||
return $"{e.OccurrenceUtc:O} | 0x{e.EventId:X4} | prio={e.Priority} | {e.EventText}";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Map driver-internal <see cref="S7Area"/> to S7.Net's <see cref="global::S7.Net.DataType"/>.</summary>
|
||||
private static global::S7.Net.DataType MapArea(S7Area area) => area switch
|
||||
{
|
||||
@@ -906,6 +1163,73 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
private global::S7.Net.Plc RequirePlc() =>
|
||||
Plc ?? throw new InvalidOperationException("S7Driver not initialized");
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E2 / #303 — emit the connection-level password to the freshly-opened PLC.
|
||||
/// Caller is <see cref="InitializeAsync"/>, immediately after <c>OpenAsync</c> and
|
||||
/// before <see cref="RunPreflightAsync"/> — that ordering matters because the
|
||||
/// pre-flight read is exactly the operation a hardened CPU will refuse without an
|
||||
/// unlock. No-op when <see cref="S7DriverOptions.Password"/> is null/empty (the
|
||||
/// standard development case). When the underlying S7netplus build doesn't expose
|
||||
/// <c>SendPassword</c>, surfaces a single warning log and continues — see the
|
||||
/// "Library limitation" remark on <see cref="S7DriverOptions.Password"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>No-log invariant:</b> never include the password value in any log, exception
|
||||
/// message, or diagnostic surface. The host name is logged as the only identifier.
|
||||
/// </remarks>
|
||||
private async Task TrySendPlcPasswordAsync(global::S7.Net.Plc plc, CancellationToken ct)
|
||||
{
|
||||
var password = _options.Password;
|
||||
if (string.IsNullOrEmpty(password)) return;
|
||||
|
||||
// Lazily build the gate so tests can pre-inject a fake; production gets the
|
||||
// reflective gate over the live S7.Net.Plc instance.
|
||||
_authGate ??= new ReflectionS7PlcAuthGate(plc);
|
||||
|
||||
if (!_authGate.SupportsSendPassword)
|
||||
{
|
||||
// Library doesn't oblige (S7netplus 0.20). Don't fail Init — emit one
|
||||
// warning so the operator sees the limitation in Serilog, then continue.
|
||||
// Hardened CPUs will surface a per-read failure later, which is the same
|
||||
// shape as a missing PUT/GET enable.
|
||||
_logger.LogWarning(
|
||||
"S7 password is set on driver '{DriverInstanceId}' against host '{Host}', " +
|
||||
"but the linked S7netplus library does not expose SendPassword; " +
|
||||
"password is being ignored at the wire. Hardened-CPU connect may fail at " +
|
||||
"first read. See docs/v2/s7.md \"PLC password / protection levels\" for the " +
|
||||
"library-limitation note.",
|
||||
DriverInstanceId, _options.Host);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var sent = await _authGate.TrySendPasswordAsync(password, ct).ConfigureAwait(false);
|
||||
if (sent)
|
||||
{
|
||||
// Identifier-only log line — no password leakage.
|
||||
_logger.LogInformation(
|
||||
"S7 password sent for {Host} (driver '{DriverInstanceId}', protection {ProtectionLevel}).",
|
||||
_options.Host, DriverInstanceId, _options.ProtectionLevel);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Wire reported auth-failed. Wrap in a clean InvalidOperationException so the
|
||||
// operator sees a typed message rather than a raw S7.Net.PlcException stack;
|
||||
// inner exception preserved for diagnostics. No password value in the message.
|
||||
throw new InvalidOperationException(
|
||||
$"S7 password authentication failed for host '{_options.Host}'. " +
|
||||
"Check the protection password configured in TIA Portal's Protection & Security pane " +
|
||||
"and the ProtectionLevel option matches the CPU's actual scheme.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-C5 — issue the post-<c>OpenAsync</c> pre-flight probe read against
|
||||
/// <see cref="S7ProbeOptions.ProbeAddress"/> and translate a "PUT/GET disabled"
|
||||
@@ -1019,7 +1343,11 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
var folder = builder.Folder("S7", "S7");
|
||||
foreach (var t in _options.Tags)
|
||||
// PR-S7-D2 — iterate the post-fan-out effective tag list so UDT leaves surface as
|
||||
// browseable variables and the original parent UDT tag (which is no longer in the
|
||||
// read/write pipeline) never appears in the address space.
|
||||
var sourceTags = _effectiveTags.Count > 0 ? (IEnumerable<S7TagDefinition>)_effectiveTags : _options.Tags;
|
||||
foreach (var t in sourceTags)
|
||||
{
|
||||
var isArr = t.ElementCount is int ec && ec > 1;
|
||||
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
|
||||
@@ -1032,6 +1360,53 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: t.WriteIdempotent));
|
||||
}
|
||||
|
||||
// PR-S7-E1 / #302 — surface the SZL-backed @System.* virtual tags under a
|
||||
// Diagnostics/ sub-folder when the operator has opted in. Variables are
|
||||
// ViewOnly (SZL is read-only) and never historized / alarming.
|
||||
if (_options.ExposeSystemTags)
|
||||
{
|
||||
var diag = folder.Folder(S7SystemTags.FolderName, S7SystemTags.FolderName);
|
||||
// Static descriptors: CpuType / Firmware / OrderNo + 3 cycle-time scalars.
|
||||
foreach (var d in S7SystemTags.Descriptors)
|
||||
{
|
||||
// Browse name strips the "@System." prefix — operators see "CpuType",
|
||||
// "CycleMs.Min", etc. The full reference (used by ReadAsync) keeps the
|
||||
// raw "@System.*" form so the system-tag short-circuit fires.
|
||||
var browseName = d.Address[S7SystemTags.Prefix.Length..];
|
||||
diag.Variable(browseName, browseName, new DriverAttributeInfo(
|
||||
FullName: d.Address,
|
||||
DriverDataType: d.DriverDataType,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
|
||||
// Diagnostic-buffer entries — depth comes from S7DriverOptions.DiagBufferDepth
|
||||
// (capped at MaxDiagBufferDepth = 50 to keep the browse tree readable).
|
||||
var depth = Math.Clamp(_options.DiagBufferDepth, 0, S7SystemTags.MaxDiagBufferDepth);
|
||||
if (depth > 0)
|
||||
{
|
||||
var bufFolder = diag.Folder("DiagBuffer", "DiagBuffer");
|
||||
for (var i = 0; i < depth; i++)
|
||||
{
|
||||
var browse = $"Entry[{i}]";
|
||||
var fullRef = $"{S7SystemTags.DiagBufferEntryPrefix}{i}]";
|
||||
bufFolder.Variable(browse, browse, new DriverAttributeInfo(
|
||||
FullName: fullRef,
|
||||
DriverDataType: DriverDataType.String,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.S7.SymbolImport;
|
||||
using S7NetCpuType = global::S7.Net.CpuType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
@@ -81,7 +85,20 @@ public static class S7DriverFactoryExtensions
|
||||
fallback: TsapMode.Auto),
|
||||
LocalTsap = dto.LocalTsap,
|
||||
RemoteTsap = dto.RemoteTsap,
|
||||
// PR-S7-E2 / #303 — connection-level password + declarative protection-level
|
||||
// hint. Password defaults to null (no auth) per the no-log invariant; an
|
||||
// explicit empty-string in JSON also collapses to null so a "Password": ""
|
||||
// typo doesn't try to send a 0-byte password to the PLC. ProtectionLevel
|
||||
// defaults to Auto when the field is absent.
|
||||
Password = string.IsNullOrEmpty(dto.Password) ? null : dto.Password,
|
||||
ProtectionLevel = ParseEnum<ProtectionLevel>(dto.ProtectionLevel, driverInstanceId,
|
||||
"ProtectionLevel", fallback: ProtectionLevel.Auto),
|
||||
ScanGroupIntervals = scanGroupMap,
|
||||
// PR-S7-D2 — UDT layout declarations referenced by tags whose UdtName is set.
|
||||
// Empty list when the config doesn't declare any UDTs (the typical scalar-only case).
|
||||
Udts = dto.Udts is { Count: > 0 }
|
||||
? [.. dto.Udts.Select(u => BuildUdt(u, driverInstanceId))]
|
||||
: [],
|
||||
};
|
||||
|
||||
return new S7Driver(options, driverInstanceId);
|
||||
@@ -91,16 +108,174 @@ public static class S7DriverFactoryExtensions
|
||||
new(
|
||||
Name: t.Name ?? throw new InvalidOperationException(
|
||||
$"S7 config for '{driverInstanceId}' has a tag missing Name"),
|
||||
// PR-S7-D2 — UDT-typed tags use the UDT name as their type rather than a primitive
|
||||
// S7 data type; Address is still required (the parent base address that the
|
||||
// fan-out adds member offsets to). Address may legitimately be DBn.DBX0.0 for an
|
||||
// entire-DB UDT pointer; the parser accepts that and the fan-out walks from there.
|
||||
Address: t.Address ?? throw new InvalidOperationException(
|
||||
$"S7 tag '{t.Name}' in '{driverInstanceId}' missing Address"),
|
||||
DataType: ParseEnum<S7DataType>(t.DataType, driverInstanceId, "DataType",
|
||||
tagName: t.Name),
|
||||
DataType: string.IsNullOrWhiteSpace(t.UdtName)
|
||||
? ParseEnum<S7DataType>(t.DataType, driverInstanceId, "DataType", tagName: t.Name)
|
||||
: ParseEnum<S7DataType>(t.DataType, driverInstanceId, "DataType", tagName: t.Name,
|
||||
fallback: S7DataType.Byte),
|
||||
Writable: t.Writable ?? true,
|
||||
StringLength: t.StringLength ?? 254,
|
||||
WriteIdempotent: t.WriteIdempotent ?? false,
|
||||
ScanGroup: string.IsNullOrWhiteSpace(t.ScanGroup) ? null : t.ScanGroup,
|
||||
DeadbandAbsolute: t.DeadbandAbsolute,
|
||||
DeadbandPercent: t.DeadbandPercent);
|
||||
DeadbandPercent: t.DeadbandPercent,
|
||||
UdtName: string.IsNullOrWhiteSpace(t.UdtName) ? null : t.UdtName);
|
||||
|
||||
private static S7UdtDefinition BuildUdt(S7UdtDto u, string driverInstanceId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(u.Name))
|
||||
throw new InvalidOperationException(
|
||||
$"S7 config for '{driverInstanceId}' has a UDT entry missing Name");
|
||||
var members = (u.Members ?? new List<S7UdtMemberDto>()).Select(m => BuildUdtMember(m, u.Name!, driverInstanceId)).ToList();
|
||||
return new S7UdtDefinition(u.Name!, members, u.SizeBytes ?? 0);
|
||||
}
|
||||
|
||||
private static S7UdtMember BuildUdtMember(S7UdtMemberDto m, string udtName, string driverInstanceId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(m.Name))
|
||||
throw new InvalidOperationException(
|
||||
$"S7 UDT '{udtName}' in '{driverInstanceId}' has a member missing Name");
|
||||
var dataType = string.IsNullOrWhiteSpace(m.UdtName)
|
||||
? ParseEnum<S7DataType>(m.DataType, driverInstanceId, $"UDT '{udtName}' member '{m.Name}' DataType")
|
||||
: ParseEnum<S7DataType>(m.DataType, driverInstanceId, $"UDT '{udtName}' member '{m.Name}' DataType",
|
||||
fallback: S7DataType.Byte);
|
||||
return new S7UdtMember(
|
||||
Name: m.Name!,
|
||||
Offset: m.Offset ?? 0,
|
||||
DataType: dataType,
|
||||
ArrayDim: m.ArrayDim,
|
||||
UdtName: string.IsNullOrWhiteSpace(m.UdtName) ? null : m.UdtName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D1 / #299 — append TIA Portal "Show all tags" CSV rows to
|
||||
/// <paramref name="options"/> as <see cref="S7TagDefinition"/> entries. Returns a new
|
||||
/// <see cref="S7DriverOptions"/> 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-symbols</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="S7ImportResult"/> counts surface on
|
||||
/// <paramref name="result"/> for callers that want to assert "we got the row count
|
||||
/// we expected".
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// UDT-typed rows materialise as placeholder tags (data type forced to
|
||||
/// <see cref="S7DataType.Byte"/>); PR-S7-D2 will replace the placeholders with
|
||||
/// proper UDT layout. See <c>docs/drivers/S7-TIA-Import.md</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static S7DriverOptions AddTiaCsvImport(
|
||||
this S7DriverOptions options,
|
||||
string path,
|
||||
out S7ImportResult result,
|
||||
S7ImportOptions? importOptions = null,
|
||||
ILogger<TiaCsvImporter>? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
using var stream = File.OpenRead(path);
|
||||
var importer = new TiaCsvImporter(logger ?? NullLogger<TiaCsvImporter>.Instance);
|
||||
result = importer.Parse(stream, importOptions);
|
||||
|
||||
return MergeImportedTags(options, result.Tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-friendly overload that returns the <see cref="S7ImportResult"/> alongside the
|
||||
/// modified options as a tuple. Mirrors <see cref="AddTiaCsvImport"/> but avoids the
|
||||
/// <c>out</c> parameter for call sites that prefer pattern-matched destructuring.
|
||||
/// </summary>
|
||||
public static (S7DriverOptions Options, S7ImportResult Result) AddTiaCsvImportWithResult(
|
||||
this S7DriverOptions options,
|
||||
string path,
|
||||
S7ImportOptions? importOptions = null,
|
||||
ILogger<TiaCsvImporter>? logger = null)
|
||||
{
|
||||
var updated = options.AddTiaCsvImport(path, out var result, importOptions, logger);
|
||||
return (updated, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D1 / #299 — append STEP 7 Classic AWL <c>VAR_GLOBAL</c> + <c>DATA_BLOCK</c>
|
||||
/// declarations to <paramref name="options"/> as <see cref="S7TagDefinition"/> entries.
|
||||
/// Best-effort heuristic — see <see cref="AwlImporter"/> for the position-based
|
||||
/// addressing rules.
|
||||
/// </summary>
|
||||
public static S7DriverOptions AddAwlImport(
|
||||
this S7DriverOptions options,
|
||||
string path,
|
||||
out S7ImportResult result,
|
||||
S7ImportOptions? importOptions = null,
|
||||
ILogger<AwlImporter>? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
using var stream = File.OpenRead(path);
|
||||
var importer = new AwlImporter(logger ?? NullLogger<AwlImporter>.Instance);
|
||||
result = importer.Parse(stream, importOptions);
|
||||
|
||||
return MergeImportedTags(options, result.Tags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-friendly overload that returns the <see cref="S7ImportResult"/> alongside the
|
||||
/// modified options as a tuple. Mirrors <see cref="AddAwlImport"/>.
|
||||
/// </summary>
|
||||
public static (S7DriverOptions Options, S7ImportResult Result) AddAwlImportWithResult(
|
||||
this S7DriverOptions options,
|
||||
string path,
|
||||
S7ImportOptions? importOptions = null,
|
||||
ILogger<AwlImporter>? logger = null)
|
||||
{
|
||||
var updated = options.AddAwlImport(path, out var result, importOptions, logger);
|
||||
return (updated, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Concatenate <paramref name="imported"/> onto <paramref name="options"/>.<see cref="S7DriverOptions.Tags"/>
|
||||
/// and return a new options object with every other field untouched. The importers
|
||||
/// are additive so hand-edited Tags rows (e.g., system-status fields not surfaced by
|
||||
/// the TIA / AWL export) keep sitting alongside the bulk-imported symbol rows.
|
||||
/// </summary>
|
||||
private static S7DriverOptions MergeImportedTags(
|
||||
S7DriverOptions options, IReadOnlyList<S7TagDefinition> imported)
|
||||
{
|
||||
var merged = new List<S7TagDefinition>(options.Tags.Count + imported.Count);
|
||||
merged.AddRange(options.Tags);
|
||||
merged.AddRange(imported);
|
||||
|
||||
return new S7DriverOptions
|
||||
{
|
||||
Host = options.Host,
|
||||
Port = options.Port,
|
||||
CpuType = options.CpuType,
|
||||
Rack = options.Rack,
|
||||
Slot = options.Slot,
|
||||
Timeout = options.Timeout,
|
||||
Tags = merged,
|
||||
Probe = options.Probe,
|
||||
BlockCoalescingGapBytes = options.BlockCoalescingGapBytes,
|
||||
TsapMode = options.TsapMode,
|
||||
LocalTsap = options.LocalTsap,
|
||||
RemoteTsap = options.RemoteTsap,
|
||||
ScanGroupIntervals = options.ScanGroupIntervals,
|
||||
Udts = options.Udts,
|
||||
Password = options.Password,
|
||||
ProtectionLevel = options.ProtectionLevel,
|
||||
};
|
||||
}
|
||||
|
||||
private static T ParseEnum<T>(string? raw, string driverInstanceId, string field,
|
||||
string? tagName = null, T? fallback = null) where T : struct, Enum
|
||||
@@ -159,6 +334,34 @@ public static class S7DriverFactoryExtensions
|
||||
/// <c>docs/v2/s7.md</c> "Per-tag scan groups" section.
|
||||
/// </summary>
|
||||
public Dictionary<string, int>? ScanGroupIntervalsMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D2 — UDT / STRUCT layout declarations. Tags whose
|
||||
/// <see cref="S7TagDto.UdtName"/> matches a UDT here get fanned out into
|
||||
/// scalar leaf member tags at <c>S7Driver.InitializeAsync</c> time.
|
||||
/// See <c>docs/v2/s7.md</c> "UDT / STRUCT support" section.
|
||||
/// </summary>
|
||||
public List<S7UdtDto>? Udts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E2 / #303 — connection-level password emitted to the PLC right
|
||||
/// after <c>OpenAsync</c> succeeds and before the pre-flight PUT/GET probe
|
||||
/// runs. Default <c>null</c> = no password is sent (the standard case).
|
||||
/// <b>Secret:</b> never logged. See <c>docs/v2/s7.md</c> §"PLC password /
|
||||
/// protection levels" for the no-log invariant and the S7netplus 0.20
|
||||
/// library-limitation note.
|
||||
/// </summary>
|
||||
public string? Password { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E2 / #303 — declarative hint about the protection scheme on the
|
||||
/// target PLC. One of <c>Auto</c> (default), <c>None</c>, <c>Level1</c>,
|
||||
/// <c>Level2</c>, <c>Level3</c> (S7-300/400), or <c>ConnectionMechanism</c>
|
||||
/// (S7-1200/1500). Surfaced via the driver-diagnostics RPC so a
|
||||
/// misconfigured "level 3 PLC seen as level 1" deployment is spottable
|
||||
/// from the Admin UI.
|
||||
/// </summary>
|
||||
public string? ProtectionLevel { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class S7TagDto
|
||||
@@ -194,6 +397,39 @@ public static class S7DriverFactoryExtensions
|
||||
/// set the filters are OR'd — publish if EITHER threshold triggers.
|
||||
/// </summary>
|
||||
public double? DeadbandPercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D2 — when set, the tag is a UDT / STRUCT-typed pointer. The driver
|
||||
/// looks up the named UDT in <see cref="S7DriverConfigDto.Udts"/> and fans the
|
||||
/// tag out into scalar leaf tags at <c>InitializeAsync</c> time. The primitive
|
||||
/// <see cref="DataType"/> field is ignored when <c>UdtName</c> is set; the
|
||||
/// leaves take their data types from the UDT member declarations.
|
||||
/// </summary>
|
||||
public string? UdtName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D2 — JSON wire form for <see cref="S7UdtDefinition"/>. See
|
||||
/// <c>docs/v2/s7.md</c> "UDT / STRUCT support" for the round-trip example.
|
||||
/// </summary>
|
||||
internal sealed class S7UdtDto
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public List<S7UdtMemberDto>? Members { get; init; }
|
||||
public int? SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D2 — JSON wire form for <see cref="S7UdtMember"/>. <c>UdtName</c> is set
|
||||
/// for nested-UDT members; <c>DataType</c> is set for primitive members.
|
||||
/// </summary>
|
||||
internal sealed class S7UdtMemberDto
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public int? Offset { get; init; }
|
||||
public string? DataType { get; init; }
|
||||
public int? ArrayDim { get; init; }
|
||||
public string? UdtName { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class S7ProbeDto
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||
using S7NetCpuType = global::S7.Net.CpuType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
@@ -139,6 +140,143 @@ public sealed class S7DriverOptions
|
||||
/// batch any more, because the slow batch's <c>Task.Delay</c> isn't holding the gate.
|
||||
/// </remarks>
|
||||
public IReadOnlyDictionary<string, TimeSpan>? ScanGroupIntervals { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D2 — UDT / STRUCT layout declarations referenced by tags whose
|
||||
/// <see cref="S7TagDefinition.UdtName"/> is set. At <see cref="S7Driver.InitializeAsync"/>
|
||||
/// time the driver fans every UDT-typed tag into N scalar leaf-member tags whose
|
||||
/// addresses equal <c>parent.Address + member.Offset</c>; reads / writes / subscribes
|
||||
/// never see the parent UDT tag, so the rest of the pipeline stays scalar-only.
|
||||
/// See <c>docs/v2/s7.md</c> "UDT / STRUCT support" section for the full semantics
|
||||
/// including the 4-level nesting cap and the Optimized-DB prerequisite.
|
||||
/// </summary>
|
||||
public IReadOnlyList<S7UdtDefinition> Udts { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E1 / #302 — when <c>true</c>, <see cref="S7Driver.DiscoverAsync"/> emits a
|
||||
/// <c>Diagnostics/</c> sub-folder under the driver root containing virtual
|
||||
/// <c>@System.*</c> variables backed by SZL (System Status List) reads:
|
||||
/// <c>CpuType</c>, <c>Firmware</c>, <c>OrderNo</c> (SZL 0x0011),
|
||||
/// <c>CycleMs.Min</c> / <c>.Max</c> / <c>.Avg</c> (SZL 0x0132 / 0x0432), and
|
||||
/// <c>DiagBuffer/Entry[0..N]</c> (SZL 0x00A0). Default <c>false</c> — operators opt
|
||||
/// in per driver instance because the virtual nodes show up in OPC UA Browse
|
||||
/// under every connected client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// S7netplus 0.20 doesn't yet expose a public <c>ReadSzlAsync</c>, so the
|
||||
/// in-process default surfaces every SZL read as <c>BadNotSupported</c>. The
|
||||
/// tag tree still lights up — operators see the structure and can wire
|
||||
/// clients to it — only the values come back as not-supported. Tests inject
|
||||
/// a fake reader that returns golden bytes to prove the dispatch + parser
|
||||
/// + cache path works end-to-end. snap7 also doesn't implement SZL, so the
|
||||
/// integration-test surface inherits the same not-supported behaviour.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool ExposeSystemTags { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E1 — number of diagnostic-buffer entries to discover under
|
||||
/// <c>Diagnostics/DiagBuffer/Entry[N]</c>. Capped at
|
||||
/// <see cref="Szl.S7SystemTags.MaxDiagBufferDepth"/> = 50; the default 10 mirrors
|
||||
/// the plan-section's "max-10 cap" guidance and matches typical SZL 0x00A0
|
||||
/// PDU-size budgets. Ignored when <see cref="ExposeSystemTags"/> is <c>false</c>.
|
||||
/// </summary>
|
||||
public int DiagBufferDepth { get; init; } = Szl.S7SystemTags.DefaultDiagBufferDepth;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E1 — TTL for the <see cref="Szl.S7SzlCache"/> that fronts every SZL
|
||||
/// wire request. Diagnostics shouldn't poll faster than this anyway; the
|
||||
/// default 5 s window means a burst of <c>@System.*</c> subscriptions ticking
|
||||
/// at 100 ms each produces exactly one wire request per distinct SZL ID per
|
||||
/// 5-second window. Set to <see cref="TimeSpan.Zero"/> to disable caching
|
||||
/// (every read goes to the wire) — only useful for diagnostics tests.
|
||||
/// </summary>
|
||||
public TimeSpan SzlCacheTtl { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E2 / #303 — connection-level password emitted to the PLC right after
|
||||
/// <c>OpenAsync</c> succeeds and before the pre-flight PUT/GET probe runs. Used
|
||||
/// for hardened S7-300/400 deployments running protection level 1, 2 or 3, and
|
||||
/// for S7-1200/1500 deployments that have a connection-mechanism password set
|
||||
/// in TIA Portal's "Protection & Security" pane. Default <c>null</c> = no
|
||||
/// password is sent (the standard case for development PLCs and freshly-flashed
|
||||
/// CPUs without a protection password).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>No-log invariant.</b> <see cref="Password"/> is a secret. The driver
|
||||
/// MUST NOT log it; the override on <see cref="ToString"/> below redacts
|
||||
/// the field as <c>***</c>, and any new logging surface that touches an
|
||||
/// <see cref="S7DriverOptions"/> instance must continue to do the same.
|
||||
/// See <c>docs/v2/s7.md</c> §"PLC password / protection levels".
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Library limitation.</b> S7netplus 0.20 does not expose a public
|
||||
/// <c>SendPassword</c> method. When <see cref="Password"/> is set on a
|
||||
/// driver linked against a library version that lacks the API, the driver
|
||||
/// logs a one-line warning at Init time and continues — the connection
|
||||
/// succeeds at the COTP layer but a hardened CPU may then refuse the very
|
||||
/// first read with a "function not allowed" PDU. The driver discovers the
|
||||
/// method reflectively (<see cref="ReflectionS7PlcAuthGate"/>), so a future
|
||||
/// S7netplus minor release that adds <c>SendPasswordAsync(string,
|
||||
/// CancellationToken)</c> or <c>SendPassword(string)</c> gets used
|
||||
/// automatically without requiring a code change in this driver.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string? Password { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E2 / #303 — declarative hint about the protection scheme the operator
|
||||
/// expects on the target PLC. The driver currently uses this for diagnostics
|
||||
/// and forward-compat (the value is exposed via the driver-diagnostics RPC
|
||||
/// surface so a misconfigured "level 3 PLC seen as level 1" deployment can be
|
||||
/// spotted from the Admin UI), and as a place to hang per-protection-level
|
||||
/// behaviour as S7netplus matures. Default <see cref="ProtectionLevel.Auto"/> =
|
||||
/// no hint, which matches existing behaviour.
|
||||
/// </summary>
|
||||
public ProtectionLevel ProtectionLevel { get; init; } = ProtectionLevel.Auto;
|
||||
|
||||
/// <summary>
|
||||
/// Override the auto-generated reference-typed <c>ToString</c> with one that
|
||||
/// redacts <see cref="Password"/>. Mirrors the FOCAS-F4-d
|
||||
/// <c>FocasDeviceOptions.PrintMembers</c> pattern (which uses positional-record
|
||||
/// plumbing); this class is a reference type, so an explicit override is the
|
||||
/// cheap equivalent. Field set kept compact — only the fields an operator is
|
||||
/// likely to want in a log line are emitted.
|
||||
/// </summary>
|
||||
public override string ToString() =>
|
||||
$"S7DriverOptions {{ Host = {Host}, Port = {Port}, CpuType = {CpuType}, " +
|
||||
$"Rack = {Rack}, Slot = {Slot}, TsapMode = {TsapMode}, " +
|
||||
$"ProtectionLevel = {ProtectionLevel}, Password = {(Password is null ? "<null>" : "***")} }}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E2 / #303 — declarative hint about the protection scheme on the target
|
||||
/// PLC. S7-300/400 firmware exposes three CPU-side levels via the
|
||||
/// <c>SFC 109 / 110</c> family; S7-1200/1500 firmware uses TIA Portal's "Connection
|
||||
/// Mechanism" instead (a single PUT/GET-vs-password switch with a different wire
|
||||
/// handshake). The enum carries both vocabularies and Auto for the no-hint case.
|
||||
/// </summary>
|
||||
public enum ProtectionLevel
|
||||
{
|
||||
/// <summary>No declared protection scheme — driver doesn't surface a hint. Default.</summary>
|
||||
Auto,
|
||||
|
||||
/// <summary>Operator asserts the PLC has no protection set. Equivalent to Auto for the wire path; surfaces as "None" in diagnostics.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>S7-300/400 protection level 1 — write-protected unless password is supplied.</summary>
|
||||
Level1,
|
||||
|
||||
/// <summary>S7-300/400 protection level 2 — read- and write-protected unless password is supplied.</summary>
|
||||
Level2,
|
||||
|
||||
/// <summary>S7-300/400 protection level 3 — full protection, all reads/writes require password.</summary>
|
||||
Level3,
|
||||
|
||||
/// <summary>S7-1200/1500 "Connection Mechanism" password gate (TIA Portal Protection & Security pane).</summary>
|
||||
ConnectionMechanism,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -285,6 +423,16 @@ public sealed class S7ProbeOptions
|
||||
/// When both deadbands are set the filters are OR'd — the value publishes if EITHER
|
||||
/// threshold says publish (Kepware-style semantics). See <c>docs/v2/s7.md</c>.
|
||||
/// </param>
|
||||
/// <param name="UdtName">
|
||||
/// PR-S7-D2 — when set, this tag is a UDT / STRUCT-typed pointer. The driver looks up
|
||||
/// the named UDT in <see cref="S7DriverOptions.Udts"/> and fans the tag out into N scalar
|
||||
/// member tags at <see cref="S7Driver.InitializeAsync"/> time. <see cref="DataType"/> is
|
||||
/// ignored when <see cref="UdtName"/> is set (the parent UDT tag never reaches the
|
||||
/// read/write code path; only its scalar leaves do). Mutually exclusive with
|
||||
/// <see cref="ElementCount"/> > 1 — array-of-UDT is supported via array members
|
||||
/// inside the UDT layout, not at the parent-tag level. See <c>docs/v2/s7.md</c>
|
||||
/// "UDT / STRUCT support" section.
|
||||
/// </param>
|
||||
public sealed record S7TagDefinition(
|
||||
string Name,
|
||||
string Address,
|
||||
@@ -295,7 +443,8 @@ public sealed record S7TagDefinition(
|
||||
int? ElementCount = null,
|
||||
string? ScanGroup = null,
|
||||
double? DeadbandAbsolute = null,
|
||||
double? DeadbandPercent = null);
|
||||
double? DeadbandPercent = null,
|
||||
string? UdtName = null);
|
||||
|
||||
public enum S7DataType
|
||||
{
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E2 / #303 — narrow seam covering the "send a password to a hardened CPU"
|
||||
/// wire path. <see cref="S7Driver.InitializeAsync"/> calls
|
||||
/// <see cref="TrySendPasswordAsync"/> after <c>OpenAsync</c> succeeds and before the
|
||||
/// pre-flight PUT/GET probe runs, so a hardened S7-1500 / ET 200SP CPU that gates
|
||||
/// reads behind a connection-level password unlocks before the probe drops it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The runtime implementation (<see cref="ReflectionS7PlcAuthGate"/>) discovers
|
||||
/// the underlying <c>S7.Net.Plc.SendPassword</c> / <c>SendPasswordAsync</c>
|
||||
/// methods reflectively because S7netplus 0.20 doesn't yet expose them in a
|
||||
/// strongly-typed surface — the seam keeps this driver compiling against the
|
||||
/// current pinned package version while still calling whatever the next minor
|
||||
/// release ships. When neither method exists,
|
||||
/// <see cref="SupportsSendPassword"/> stays <c>false</c> and
|
||||
/// <see cref="TrySendPasswordAsync"/> is a no-op so a misconfigured "Password
|
||||
/// set, library doesn't oblige" deployment surfaces as a one-line warning at
|
||||
/// Init rather than a hard failure (failure shifts to first per-tag read on the
|
||||
/// hardened CPU, which is the same shape as if the operator had forgotten to
|
||||
/// enable PUT/GET).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Tests inject a fake to exercise both branches without touching the live
|
||||
/// S7netplus stack.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal interface IS7PlcAuthGate
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>true</c> when the underlying S7netplus <c>Plc</c> exposes a public
|
||||
/// <c>SendPassword(string)</c> or <c>SendPasswordAsync(string, CancellationToken)</c>
|
||||
/// method. <c>false</c> on S7netplus 0.20 (which has no such surface).
|
||||
/// </summary>
|
||||
bool SupportsSendPassword { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Send <paramref name="password"/> to the connected PLC. No-op (and returns
|
||||
/// <c>false</c>) when <see cref="SupportsSendPassword"/> is <c>false</c>;
|
||||
/// returns <c>true</c> after a successful send. Throws cleanly when the wire
|
||||
/// reports auth-failed — <see cref="S7Driver.InitializeAsync"/> wraps the
|
||||
/// throw into a typed <see cref="InvalidOperationException"/> so the operator
|
||||
/// sees a "password authentication failed" message rather than a generic
|
||||
/// <c>S7.Net.PlcException</c>.
|
||||
/// </summary>
|
||||
Task<bool> TrySendPasswordAsync(string password, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="IS7PlcAuthGate"/> backed by reflection over the S7netplus
|
||||
/// <c>S7.Net.Plc</c> instance. S7netplus 0.20 does NOT expose a
|
||||
/// <c>SendPassword</c>; the reflection probe survives that gracefully and a future
|
||||
/// 0.21+ that adds the API gets called automatically without a code change here.
|
||||
/// </summary>
|
||||
internal sealed class ReflectionS7PlcAuthGate : IS7PlcAuthGate
|
||||
{
|
||||
private readonly object _plc;
|
||||
private readonly MethodInfo? _syncMethod;
|
||||
private readonly MethodInfo? _asyncMethod;
|
||||
|
||||
public ReflectionS7PlcAuthGate(object plc)
|
||||
{
|
||||
_plc = plc ?? throw new ArgumentNullException(nameof(plc));
|
||||
var type = plc.GetType();
|
||||
|
||||
// Probe both shapes: synchronous void SendPassword(string) and async
|
||||
// Task SendPasswordAsync(string, CancellationToken). Either is acceptable;
|
||||
// the async overload wins when both exist (no thread-block on init).
|
||||
_asyncMethod = type.GetMethod(
|
||||
"SendPasswordAsync",
|
||||
BindingFlags.Instance | BindingFlags.Public,
|
||||
binder: null,
|
||||
types: [typeof(string), typeof(CancellationToken)],
|
||||
modifiers: null);
|
||||
_syncMethod = type.GetMethod(
|
||||
"SendPassword",
|
||||
BindingFlags.Instance | BindingFlags.Public,
|
||||
binder: null,
|
||||
types: [typeof(string)],
|
||||
modifiers: null);
|
||||
}
|
||||
|
||||
public bool SupportsSendPassword => _asyncMethod is not null || _syncMethod is not null;
|
||||
|
||||
public async Task<bool> TrySendPasswordAsync(string password, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(password);
|
||||
if (_asyncMethod is not null)
|
||||
{
|
||||
// Unwrap TargetInvocationException so the caller sees the real S7.Net.PlcException
|
||||
// (or whatever the library threw) rather than the reflection wrapper.
|
||||
try
|
||||
{
|
||||
var result = _asyncMethod.Invoke(_plc, [password, cancellationToken]);
|
||||
if (result is Task task)
|
||||
{
|
||||
await task.ConfigureAwait(false);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (TargetInvocationException tie) when (tie.InnerException is not null)
|
||||
{
|
||||
throw tie.InnerException;
|
||||
}
|
||||
}
|
||||
if (_syncMethod is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_syncMethod.Invoke(_plc, [password]);
|
||||
return true;
|
||||
}
|
||||
catch (TargetInvocationException tie) when (tie.InnerException is not null)
|
||||
{
|
||||
throw tie.InnerException;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort AWL (Anweisungsliste / STL — Statement List) importer for legacy STEP 7
|
||||
/// Classic projects. Recognises two block types:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>VAR_GLOBAL</c> … <c>END_VAR</c> — global memory area declarations.
|
||||
/// Each entry maps to a sequential <c>M{B|W|D}{offset}</c> address based on
|
||||
/// declaration order.</item>
|
||||
/// <item><c>DATA_BLOCK "name"</c> … <c>END_DATA_BLOCK</c> — DB declarations.
|
||||
/// Each field maps to a <c>DB{n}.DB{B|W|D}{offset}</c> address based on
|
||||
/// declaration order; the DB number is parsed from the <c>DATA_BLOCK</c>
|
||||
/// line's <c>DB</c> keyword (e.g. <c>DATA_BLOCK DB1</c>).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Position-based addressing is heuristic.</b> Real STEP 7 Classic projects
|
||||
/// carry exact byte offsets in the .gr8 / .awl emitted symbol table, but a hand-
|
||||
/// exported AWL file omits them. The importer assumes:
|
||||
/// <list type="bullet">
|
||||
/// <item>BOOL → 1 byte (rounded up to byte alignment)</item>
|
||||
/// <item>BYTE / SINT / USINT → 1 byte</item>
|
||||
/// <item>INT / WORD / UINT → 2 bytes</item>
|
||||
/// <item>DINT / DWORD / UDINT / REAL → 4 bytes</item>
|
||||
/// <item>LREAL / LINT / ULINT → 8 bytes</item>
|
||||
/// <item>STRING — sized by <c>STRING[N]</c> if specified, else 256 (S7 default + 2-byte header)</item>
|
||||
/// </list>
|
||||
/// A site needing exact offsets should drive its symbol import from the TIA Portal
|
||||
/// CSV path instead — <see cref="TiaCsvImporter"/> takes the offsets verbatim from
|
||||
/// the export.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Comments (<c>(* ... *)</c> block, <c>// ...</c> line) are stripped before
|
||||
/// declaration parsing. Initial-value clauses (<c>:= 0</c>) are recognised and
|
||||
/// discarded. Multi-line declarations (a single var split across lines) are
|
||||
/// supported because the parser scans the entire <c>VAR_GLOBAL</c> body as a
|
||||
/// token stream.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AwlImporter : IS7SymbolImporter
|
||||
{
|
||||
private readonly ILogger<AwlImporter> _logger;
|
||||
|
||||
public AwlImporter() : this(NullLogger<AwlImporter>.Instance) { }
|
||||
|
||||
public AwlImporter(ILogger<AwlImporter> logger)
|
||||
{
|
||||
_logger = logger ?? NullLogger<AwlImporter>.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public S7ImportResult Parse(Stream stream, S7ImportOptions? options = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
var opts = options ?? new S7ImportOptions();
|
||||
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8,
|
||||
detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
|
||||
var raw = reader.ReadToEnd();
|
||||
|
||||
// Strip comments first — keeps the block-walker simpler.
|
||||
var stripped = StripComments(raw);
|
||||
|
||||
var tags = new List<S7TagDefinition>();
|
||||
var parsed = 0;
|
||||
var skipped = 0;
|
||||
var errors = 0;
|
||||
var udtPlaceholders = 0;
|
||||
|
||||
// VAR_GLOBAL block — assign sequential MW offsets.
|
||||
foreach (var (body, _) in ExtractBlocks(stripped, "VAR_GLOBAL", "END_VAR"))
|
||||
{
|
||||
var globalOffset = 0;
|
||||
foreach (var decl in ExtractDeclarations(body))
|
||||
{
|
||||
if (opts.MaxRowsToImport is int cap && parsed >= cap)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"AWL VAR_GLOBAL hit MaxRowsToImport={Cap}; remaining declarations skipped.", cap);
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var (s7Type, sizeBytes, isUdt) = ResolveType(decl.TypeName);
|
||||
if (isUdt)
|
||||
{
|
||||
// UDT placeholder — synthesise a Byte tag at the next aligned offset
|
||||
// so the operator at least sees the symbol surface in the Admin UI.
|
||||
globalOffset = AlignTo(globalOffset, 1);
|
||||
var placeholderAddr = $"MB{globalOffset}";
|
||||
tags.Add(new S7TagDefinition(
|
||||
Name: decl.Name,
|
||||
Address: placeholderAddr,
|
||||
DataType: S7DataType.Byte,
|
||||
Writable: false));
|
||||
udtPlaceholders++;
|
||||
parsed++;
|
||||
globalOffset += 1;
|
||||
_logger.LogInformation(
|
||||
"AWL VAR_GLOBAL '{Name}' imported as UDT placeholder ({TypeName}). [UDT placeholder — wait for D2]",
|
||||
decl.Name, decl.TypeName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (s7Type is null)
|
||||
{
|
||||
if (!opts.IgnoreInvalid)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"AWL VAR_GLOBAL declaration '{decl.Name} : {decl.TypeName}' has unrecognised type.");
|
||||
}
|
||||
errors++;
|
||||
_logger.LogWarning(
|
||||
"AWL VAR_GLOBAL '{Name}' skipped — unrecognised type '{TypeName}'.",
|
||||
decl.Name, decl.TypeName);
|
||||
continue;
|
||||
}
|
||||
|
||||
globalOffset = AlignTo(globalOffset, sizeBytes);
|
||||
var address = BuildMAddress(s7Type.Value, globalOffset);
|
||||
tags.Add(new S7TagDefinition(
|
||||
Name: decl.Name,
|
||||
Address: address,
|
||||
DataType: s7Type.Value,
|
||||
Writable: true));
|
||||
parsed++;
|
||||
globalOffset += sizeBytes;
|
||||
}
|
||||
catch (InvalidDataException) when (opts.IgnoreInvalid)
|
||||
{
|
||||
errors++;
|
||||
_logger.LogWarning("AWL VAR_GLOBAL declaration '{Name}' skipped — invalid data.", decl.Name);
|
||||
}
|
||||
catch (Exception ex) when (opts.IgnoreInvalid)
|
||||
{
|
||||
errors++;
|
||||
_logger.LogWarning(ex, "AWL VAR_GLOBAL declaration '{Name}' skipped — parser threw.", decl.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DATA_BLOCK "name" / DBn — assign sequential DBW offsets per block.
|
||||
foreach (var (body, header) in ExtractBlocks(stripped, "DATA_BLOCK", "END_DATA_BLOCK"))
|
||||
{
|
||||
var dbNumber = ExtractDbNumber(header);
|
||||
if (dbNumber is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"AWL DATA_BLOCK header '{Header}' missing DBn — entries skipped.",
|
||||
header.Trim());
|
||||
continue;
|
||||
}
|
||||
var dbOffset = 0;
|
||||
|
||||
// The DB body in real STEP 7 wraps the field declarations in another VAR_TEMP /
|
||||
// STRUCT block. Walk every nested declaration via the same regex.
|
||||
foreach (var decl in ExtractDeclarations(body))
|
||||
{
|
||||
if (opts.MaxRowsToImport is int cap && parsed >= cap)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"AWL DATA_BLOCK hit MaxRowsToImport={Cap}; remaining declarations skipped.", cap);
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var (s7Type, sizeBytes, isUdt) = ResolveType(decl.TypeName);
|
||||
if (isUdt)
|
||||
{
|
||||
dbOffset = AlignTo(dbOffset, 1);
|
||||
var placeholderAddr = $"DB{dbNumber.Value}.DBB{dbOffset}";
|
||||
tags.Add(new S7TagDefinition(
|
||||
Name: decl.Name,
|
||||
Address: placeholderAddr,
|
||||
DataType: S7DataType.Byte,
|
||||
Writable: false));
|
||||
udtPlaceholders++;
|
||||
parsed++;
|
||||
dbOffset += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (s7Type is null)
|
||||
{
|
||||
if (!opts.IgnoreInvalid)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"AWL DATA_BLOCK DB{dbNumber} declaration '{decl.Name} : {decl.TypeName}' has unrecognised type.");
|
||||
}
|
||||
errors++;
|
||||
_logger.LogWarning(
|
||||
"AWL DB{DbNumber} '{Name}' skipped — unrecognised type '{TypeName}'.",
|
||||
dbNumber.Value, decl.Name, decl.TypeName);
|
||||
continue;
|
||||
}
|
||||
|
||||
dbOffset = AlignTo(dbOffset, sizeBytes);
|
||||
var address = BuildDbAddress(dbNumber.Value, s7Type.Value, dbOffset);
|
||||
tags.Add(new S7TagDefinition(
|
||||
Name: decl.Name,
|
||||
Address: address,
|
||||
DataType: s7Type.Value,
|
||||
Writable: true));
|
||||
parsed++;
|
||||
dbOffset += sizeBytes;
|
||||
}
|
||||
catch (InvalidDataException) when (opts.IgnoreInvalid)
|
||||
{
|
||||
errors++;
|
||||
_logger.LogWarning("AWL DATA_BLOCK declaration '{Name}' skipped — invalid data.", decl.Name);
|
||||
}
|
||||
catch (Exception ex) when (opts.IgnoreInvalid)
|
||||
{
|
||||
errors++;
|
||||
_logger.LogWarning(ex, "AWL DATA_BLOCK declaration '{Name}' skipped — parser threw.", decl.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new S7ImportResult(tags, parsed, skipped, errors, udtPlaceholders);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strip both <c>(* ... *)</c> block comments and <c>// ...</c> line comments.
|
||||
/// Block comments don't nest in AWL.
|
||||
/// </summary>
|
||||
internal static string StripComments(string input)
|
||||
{
|
||||
// Block (* ... *)
|
||||
var noBlock = Regex.Replace(input, @"\(\*.*?\*\)", " ", RegexOptions.Singleline);
|
||||
// Line // ...
|
||||
var noLine = Regex.Replace(noBlock, @"//[^\r\n]*", string.Empty);
|
||||
return noLine;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Yield (body, header-line) pairs for every block bounded by
|
||||
/// <paramref name="startKeyword"/>/<paramref name="endKeyword"/>. The header line
|
||||
/// is everything between the start keyword and the first newline, useful for
|
||||
/// extracting block names / DB numbers.
|
||||
/// </summary>
|
||||
internal static IEnumerable<(string Body, string Header)> ExtractBlocks(
|
||||
string input, string startKeyword, string endKeyword)
|
||||
{
|
||||
var idx = 0;
|
||||
while (idx < input.Length)
|
||||
{
|
||||
var startMatch = Regex.Match(input.Substring(idx),
|
||||
$@"\b{Regex.Escape(startKeyword)}\b", RegexOptions.IgnoreCase);
|
||||
if (!startMatch.Success) yield break;
|
||||
|
||||
var blockStart = idx + startMatch.Index;
|
||||
// Header = from after the keyword to the first newline.
|
||||
var headerStart = blockStart + startMatch.Length;
|
||||
var newlineIdx = input.IndexOf('\n', headerStart);
|
||||
if (newlineIdx < 0) newlineIdx = input.Length;
|
||||
var header = input.Substring(headerStart, newlineIdx - headerStart);
|
||||
|
||||
// End keyword.
|
||||
var endIdx = input.IndexOf(endKeyword, newlineIdx, StringComparison.OrdinalIgnoreCase);
|
||||
if (endIdx < 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var body = input.Substring(newlineIdx, endIdx - newlineIdx);
|
||||
yield return (body, header);
|
||||
idx = endIdx + endKeyword.Length;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract <c>name : TYPE [ := initial ];</c> declarations from a block body.
|
||||
/// Permissive — the regex anchors on the colon-and-type pattern so accompanying
|
||||
/// STRUCT / VAR_TEMP wrappers don't have to be parsed structurally.
|
||||
/// </summary>
|
||||
internal static IEnumerable<AwlDeclaration> ExtractDeclarations(string body)
|
||||
{
|
||||
// name : TYPE [optional := init];
|
||||
// Captures: 1=name, 2=type (everything up to := or ;)
|
||||
// Type may start with " (UDT reference like "MyType"), [ (Array bracket), or a
|
||||
// bare letter for primitive / struct keywords.
|
||||
var rx = new Regex(
|
||||
@"([A-Za-z_][A-Za-z0-9_]*)\s*:\s*((?:[A-Za-z_""\[][\w\[\]\.\,\s\""]*?))\s*(?::=\s*[^;]+)?;",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
// Skip well-known keywords at column-0 like STRUCT / END_STRUCT / VAR_TEMP / etc.
|
||||
// by filtering on captured names that match an AWL reserved word.
|
||||
foreach (Match m in rx.Matches(body))
|
||||
{
|
||||
var name = m.Groups[1].Value.Trim();
|
||||
var typeName = m.Groups[2].Value.Trim().TrimEnd(';').Trim();
|
||||
if (IsReservedAwlWord(name)) continue;
|
||||
if (string.IsNullOrEmpty(typeName)) continue;
|
||||
yield return new AwlDeclaration(name, typeName);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsReservedAwlWord(string s) =>
|
||||
s.ToUpperInvariant() switch
|
||||
{
|
||||
"VAR" or "VAR_INPUT" or "VAR_OUTPUT" or "VAR_IN_OUT" or "VAR_TEMP" or
|
||||
"VAR_GLOBAL" or "END_VAR" or "STRUCT" or "END_STRUCT" or
|
||||
"DATA_BLOCK" or "END_DATA_BLOCK" or
|
||||
"TYPE" or "END_TYPE" or
|
||||
"TITLE" or "VERSION" or "BEGIN" or "ORGANIZATION_BLOCK" or "FUNCTION_BLOCK" or
|
||||
"FUNCTION" or "END_FUNCTION" or "END_FUNCTION_BLOCK" or "END_ORGANIZATION_BLOCK" => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Pull the DB number from a <c>DATA_BLOCK</c> header. Accepts both
|
||||
/// <c>DATA_BLOCK DB1</c> (bare keyword) and <c>DATA_BLOCK "MyDB"</c> with a follow-on
|
||||
/// <c>// DB1</c> comment-style number — for the v1 we only support the bare
|
||||
/// <c>DBn</c> form because that's what stock STEP 7 emits.
|
||||
/// </summary>
|
||||
internal static int? ExtractDbNumber(string header)
|
||||
{
|
||||
var m = Regex.Match(header, @"\bDB\s*(\d+)\b", RegexOptions.IgnoreCase);
|
||||
if (!m.Success) return null;
|
||||
return int.Parse(m.Groups[1].Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map an AWL type name to (S7 data type, on-wire byte size, isUdt). UDTs and
|
||||
/// unknown types return (<c>null</c>, <c>0</c>, <c>true</c>) — caller handles them
|
||||
/// as placeholders.
|
||||
/// </summary>
|
||||
internal static (S7DataType? S7Type, int SizeBytes, bool IsUdt) ResolveType(string typeName)
|
||||
{
|
||||
var t = typeName.Trim().Trim('"').ToUpperInvariant();
|
||||
// STRING[N] → S7 String with N+2 byte length.
|
||||
var strMatch = Regex.Match(t, @"^STRING\s*\[\s*(\d+)\s*\]");
|
||||
if (strMatch.Success)
|
||||
{
|
||||
var len = int.Parse(strMatch.Groups[1].Value);
|
||||
return (S7DataType.String, len + 2, false);
|
||||
}
|
||||
if (t == "STRING") return (S7DataType.String, 256, false);
|
||||
|
||||
// Array of UDT or array of primitive — placeholder for now (D2 territory).
|
||||
if (t.StartsWith("ARRAY", StringComparison.Ordinal))
|
||||
{
|
||||
return (null, 0, true);
|
||||
}
|
||||
|
||||
return t switch
|
||||
{
|
||||
"BOOL" => (S7DataType.Bool, 1, false),
|
||||
"BYTE" or "SINT" or "USINT" or "CHAR" => (S7DataType.Byte, 1, false),
|
||||
"WORD" or "UINT" => (S7DataType.UInt16, 2, false),
|
||||
"INT" => (S7DataType.Int16, 2, false),
|
||||
"DWORD" or "UDINT" => (S7DataType.UInt32, 4, false),
|
||||
"DINT" => (S7DataType.Int32, 4, false),
|
||||
"REAL" => (S7DataType.Float32, 4, false),
|
||||
"LREAL" => (S7DataType.Float64, 8, false),
|
||||
"LINT" => (S7DataType.Int64, 8, false),
|
||||
"ULINT" or "LWORD" => (S7DataType.UInt64, 8, false),
|
||||
"TIME" => (S7DataType.Time, 4, false),
|
||||
"DATE" => (S7DataType.Date, 2, false),
|
||||
"TOD" or "TIME_OF_DAY" => (S7DataType.TimeOfDay, 4, false),
|
||||
"DT" or "DATE_AND_TIME" => (S7DataType.DateAndTime, 8, false),
|
||||
"DTL" => (S7DataType.Dtl, 12, false),
|
||||
"S5TIME" => (S7DataType.S5Time, 2, false),
|
||||
"STRUCT" => (null, 0, true),
|
||||
// Anything else is treated as a UDT reference — surfaces as a placeholder.
|
||||
_ => (null, 0, IsLikelyUdtName(typeName)),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsLikelyUdtName(string raw)
|
||||
{
|
||||
var s = raw.Trim();
|
||||
// Quoted identifier — TIA / STEP 7 wrap UDT references in double quotes.
|
||||
if (s.StartsWith('"') && s.EndsWith('"')) return true;
|
||||
// Bare CamelCase identifier we don't recognise as a primitive — treat as UDT.
|
||||
if (s.Length > 0 && (char.IsLetter(s[0]) || s[0] == '_')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Round <paramref name="offset"/> up to a multiple of <paramref name="size"/>.
|
||||
/// S7 alignment rule: 16-bit values align to 2-byte boundaries; 32/64-bit values
|
||||
/// align to 2-byte boundaries (NOT 4 — STEP 7 packs DBs at word granularity).
|
||||
/// We use min(size, 2) for the alignment factor.
|
||||
/// </summary>
|
||||
internal static int AlignTo(int offset, int size)
|
||||
{
|
||||
var align = size <= 1 ? 1 : 2;
|
||||
var rem = offset % align;
|
||||
if (rem == 0) return offset;
|
||||
return offset + (align - rem);
|
||||
}
|
||||
|
||||
private static string BuildMAddress(S7DataType type, int offset) => type switch
|
||||
{
|
||||
S7DataType.Bool => $"M{offset}.0",
|
||||
S7DataType.Byte => $"MB{offset}",
|
||||
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.WChar or S7DataType.Date or S7DataType.S5Time
|
||||
=> $"MW{offset}",
|
||||
S7DataType.Int32 or S7DataType.UInt32 or S7DataType.Float32 or S7DataType.Time or S7DataType.TimeOfDay
|
||||
=> $"MD{offset}",
|
||||
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 or S7DataType.DateAndTime
|
||||
=> $"MLD{offset}",
|
||||
_ => $"MB{offset}",
|
||||
};
|
||||
|
||||
private static string BuildDbAddress(int dbNumber, S7DataType type, int offset) => type switch
|
||||
{
|
||||
S7DataType.Bool => $"DB{dbNumber}.DBX{offset}.0",
|
||||
S7DataType.Byte => $"DB{dbNumber}.DBB{offset}",
|
||||
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.WChar or S7DataType.Date or S7DataType.S5Time
|
||||
=> $"DB{dbNumber}.DBW{offset}",
|
||||
S7DataType.Int32 or S7DataType.UInt32 or S7DataType.Float32 or S7DataType.Time or S7DataType.TimeOfDay
|
||||
=> $"DB{dbNumber}.DBD{offset}",
|
||||
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 or S7DataType.DateAndTime
|
||||
=> $"DB{dbNumber}.DBLD{offset}",
|
||||
S7DataType.String or S7DataType.WString or S7DataType.Char or S7DataType.Dtl
|
||||
=> $"DB{dbNumber}.DBB{offset}",
|
||||
_ => $"DB{dbNumber}.DBB{offset}",
|
||||
};
|
||||
|
||||
/// <summary>One <c>name : TYPE</c> declaration extracted from an AWL block body.</summary>
|
||||
internal sealed record AwlDeclaration(string Name, string TypeName);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||
|
||||
/// <summary>
|
||||
/// Materialises <see cref="S7TagDefinition"/> entries from a vendor-supplied symbol
|
||||
/// export — TIA Portal CSV (<see cref="TiaCsvImporter"/>) or STEP 7 Classic AWL
|
||||
/// declaration text (<see cref="AwlImporter"/>). The interface exists so additional
|
||||
/// formats (e.g. STEP 7 / TIA Portal native binary, openness API, …) can slot in
|
||||
/// later without reshaping the call sites.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="Parse"/> never throws on per-row parse errors when
|
||||
/// <see cref="S7ImportOptions.IgnoreInvalid"/> is <c>true</c> (default) — malformed
|
||||
/// rows are skipped with a structured warning logged via the importer's
|
||||
/// <c>ILogger</c>, and the counters surface on <see cref="S7ImportResult"/>.
|
||||
/// With <see cref="S7ImportOptions.IgnoreInvalid"/> set to <c>false</c> the first
|
||||
/// malformed row throws <see cref="System.IO.InvalidDataException"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// UDT-typed symbols import as <em>placeholders</em> — the resulting
|
||||
/// <see cref="S7TagDefinition"/> is well-formed enough to drop into the driver's
|
||||
/// tag list but it carries the comment marker <c>[UDT placeholder — wait for D2]</c>
|
||||
/// and an <see cref="S7DataType.Byte"/> data type as a non-functional default.
|
||||
/// PR-S7-D2 will replace the placeholder with proper UDT layout once the symbol
|
||||
/// table covers nested struct fields.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IS7SymbolImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Read the entire <paramref name="stream"/> and emit one
|
||||
/// <see cref="S7TagDefinition"/> per recognised symbol row.
|
||||
/// </summary>
|
||||
/// <param name="stream">Open, readable stream over the export. Caller owns it.</param>
|
||||
/// <param name="options">Filter + safety knobs; <c>null</c> ≡ default options.</param>
|
||||
S7ImportResult Parse(Stream stream, S7ImportOptions? options = null);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D3 / #301 — recognise and resolve TIA Portal CSV rows that belong to a
|
||||
/// <b>multi-instance Function-Block instance DB</b>. Multi-instance FBs are addressed
|
||||
/// symbolically (<c>MyFB_Instance.MyParam</c>) inside the PLC program, but the runtime
|
||||
/// wire access still needs the absolute <c>DBn.DBW_offset</c>. TIA's "Show all tags"
|
||||
/// CSV export tags these rows with a different <c>DB type</c> column value
|
||||
/// (locale-variant: <c>Instance DB</c> / <c>Instance</c> / <c>Instance data block</c>)
|
||||
/// and — most of the time — already supplies the resolved absolute address in the
|
||||
/// <c>Logical address</c> column.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Two-phase model</b>:
|
||||
/// <list type="number">
|
||||
/// <item>
|
||||
/// <c>DB type</c> column == <c>Instance DB</c> (or locale variant) AND
|
||||
/// <c>Logical address</c> non-empty → accept verbatim, normalise the address
|
||||
/// the same way as a Global-DB row, count it under
|
||||
/// <see cref="S7ImportResult.InstanceDbCount"/>.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <c>DB type</c> column == <c>Instance DB</c> AND <c>Logical address</c>
|
||||
/// empty BUT a parent FB-interface declaration is reachable → compute
|
||||
/// <c>DBn.DBW(parentOffset + memberOffset)</c> from the interface table.
|
||||
/// The pure-compute fallback is rare in practice (TIA almost always emits the
|
||||
/// resolved address) but is required by the PR plan.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Re-import on FB-interface edit</b>: when the FB interface changes (member
|
||||
/// added, removed, or reordered in TIA), the instance-DB layout shifts. Operators
|
||||
/// must re-import the CSV after any such edit; absolute offsets that cached against
|
||||
/// the previous layout will silently point at the wrong member otherwise. See
|
||||
/// <c>docs/drivers/S7-TIA-Import.md</c> "Instance DBs / FB parameters" section.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class InstanceDbResolver
|
||||
{
|
||||
private readonly ILogger<InstanceDbResolver> _logger;
|
||||
|
||||
public InstanceDbResolver() : this(NullLogger<InstanceDbResolver>.Instance) { }
|
||||
|
||||
public InstanceDbResolver(ILogger<InstanceDbResolver> logger)
|
||||
{
|
||||
_logger = logger ?? NullLogger<InstanceDbResolver>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recognise an arbitrary <c>DB type</c> column value as identifying an
|
||||
/// instance-DB row. Accepts the canonical TIA en-US value <c>Instance DB</c>,
|
||||
/// the bare <c>Instance</c>, the German <c>Instanz-Datenbaustein</c> /
|
||||
/// <c>Instanz-DB</c>, and a few other common locale variants. The match is
|
||||
/// case-insensitive and tolerates surrounding whitespace + quotes.
|
||||
/// </summary>
|
||||
public static bool IsInstanceDbType(string? dbTypeColumn)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dbTypeColumn)) return false;
|
||||
var v = dbTypeColumn.Trim().Trim('"').Trim().ToLowerInvariant();
|
||||
return v switch
|
||||
{
|
||||
"instance" => true,
|
||||
"instance db" => true,
|
||||
"instance-db" => true,
|
||||
"instance data block" => true,
|
||||
// German + dashed variants TIA emits when the project locale is DE.
|
||||
"instanz" => true,
|
||||
"instanz-db" => true,
|
||||
"instanz db" => true,
|
||||
"instanz-datenbaustein" => true,
|
||||
"instanz datenbaustein" => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recognise the <c>DB type</c> column value as identifying a Global / standard
|
||||
/// data block (the default that PR-S7-D1 already handles). Useful for explicit
|
||||
/// downstream branching — anything that is neither Global nor Instance is logged
|
||||
/// and treated as Global so the existing pipeline keeps working.
|
||||
/// </summary>
|
||||
public static bool IsGlobalDbType(string? dbTypeColumn)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dbTypeColumn)) return false;
|
||||
var v = dbTypeColumn.Trim().Trim('"').Trim().ToLowerInvariant();
|
||||
return v switch
|
||||
{
|
||||
"global" or "global db" or "global-db" or "global data block" => true,
|
||||
"globaler datenbaustein" or "globaler-datenbaustein" or "global-datenbaustein" => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve an instance-DB row into an <see cref="S7TagDefinition"/>. Accepts both
|
||||
/// the canonical "address-already-resolved" form (the common case) and the
|
||||
/// "compute from interface offsets" form for exports that ship offsets only.
|
||||
/// </summary>
|
||||
/// <param name="row">Raw row state — Name, resolved address (if any), declared data type, parent FB info.</param>
|
||||
/// <param name="resolved">On success, the materialised tag definition; address is normalised (no leading <c>%</c>) the same way <see cref="TiaCsvImporter.NormaliseAddress"/> does it.</param>
|
||||
/// <returns><c>true</c> if the row was recognised AND resolved; <c>false</c> when neither a resolved address nor sufficient interface info was available.</returns>
|
||||
public bool TryResolve(InstanceDbRow row, out S7TagDefinition? resolved)
|
||||
{
|
||||
resolved = null;
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
|
||||
// Path 1 — address already resolved by TIA (the common case; the export ships
|
||||
// %DB7.DBW0 verbatim for a multi-instance FB member).
|
||||
if (!string.IsNullOrWhiteSpace(row.LogicalAddress))
|
||||
{
|
||||
var normalised = TiaCsvImporter.NormaliseAddress(row.LogicalAddress, row.DeLocale);
|
||||
if (!S7AddressParser.TryParse(normalised, out _))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"InstanceDbResolver: row '{Name}' has unparseable address '{Address}' (DB type='{DbType}').",
|
||||
row.Name, normalised, row.DbType);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TiaCsvImporter.TryResolveDataType(row.DataType ?? string.Empty, normalised, out var s7Type))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"InstanceDbResolver: row '{Name}' has unrecognised Data type '{DataType}' for address '{Address}'.",
|
||||
row.Name, row.DataType, normalised);
|
||||
return false;
|
||||
}
|
||||
|
||||
resolved = new S7TagDefinition(
|
||||
Name: row.Name,
|
||||
Address: normalised,
|
||||
DataType: s7Type,
|
||||
Writable: true,
|
||||
StringLength: row.StringLength ?? 254);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Path 2 — compute the absolute address from the FB interface declaration.
|
||||
// Required only when the TIA export ships interface offsets without resolved
|
||||
// addresses; rare in practice but in the PR plan.
|
||||
if (row.ParentDbNumber is int dbNumber && row.MemberOffset is int memberOffset)
|
||||
{
|
||||
if (!TiaCsvImporter.TryResolveDataType(row.DataType ?? string.Empty, string.Empty, out var s7Type))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"InstanceDbResolver: row '{Name}' has unrecognised Data type '{DataType}' (interface-resolved path).",
|
||||
row.Name, row.DataType);
|
||||
return false;
|
||||
}
|
||||
|
||||
var sizeLetter = SizeLetterFor(s7Type);
|
||||
var absOffset = (row.ParentBaseOffset ?? 0) + memberOffset;
|
||||
var address = $"DB{dbNumber.ToString(CultureInfo.InvariantCulture)}.DB{sizeLetter}{absOffset.ToString(CultureInfo.InvariantCulture)}";
|
||||
|
||||
if (s7Type == S7DataType.Bool && row.MemberBitOffset is int bitOffset)
|
||||
{
|
||||
address = $"DB{dbNumber.ToString(CultureInfo.InvariantCulture)}.DBX{absOffset.ToString(CultureInfo.InvariantCulture)}.{bitOffset.ToString(CultureInfo.InvariantCulture)}";
|
||||
}
|
||||
|
||||
if (!S7AddressParser.TryParse(address, out _))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"InstanceDbResolver: row '{Name}' computed unparseable address '{Address}'.",
|
||||
row.Name, address);
|
||||
return false;
|
||||
}
|
||||
|
||||
resolved = new S7TagDefinition(
|
||||
Name: row.Name,
|
||||
Address: address,
|
||||
DataType: s7Type,
|
||||
Writable: true,
|
||||
StringLength: row.StringLength ?? 254);
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"InstanceDbResolver: row '{Name}' has no resolved address and insufficient interface info to compute one (DB type='{DbType}').",
|
||||
row.Name, row.DbType);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static char SizeLetterFor(S7DataType t) => t switch
|
||||
{
|
||||
S7DataType.Bool => 'X',
|
||||
S7DataType.Byte or S7DataType.Char => 'B',
|
||||
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.WChar or S7DataType.Date => 'W',
|
||||
S7DataType.Int32 or S7DataType.UInt32 or S7DataType.Float32
|
||||
or S7DataType.Time or S7DataType.TimeOfDay => 'D',
|
||||
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 => 'D', // S7-1500 LWord lives in DBD-stride pairs
|
||||
// STRING / WSTRING / DTL / DT / S5Time map to byte access — the codec slices client-side.
|
||||
_ => 'B',
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D3 — input row to <see cref="InstanceDbResolver.TryResolve"/>. Carries
|
||||
/// everything the resolver needs to either accept a TIA-resolved address verbatim
|
||||
/// or compute one from FB-interface offsets.
|
||||
/// </summary>
|
||||
/// <param name="Name">Tag name; same semantics as <see cref="S7TagDefinition.Name"/>.</param>
|
||||
/// <param name="DbType">Raw <c>DB type</c> column value as it appeared in the CSV — used in diagnostic logs only.</param>
|
||||
/// <param name="LogicalAddress">TIA <c>Logical address</c> column. May be empty when the export ships only interface offsets.</param>
|
||||
/// <param name="DataType">TIA <c>Data type</c> column (primitive type name).</param>
|
||||
/// <param name="DeLocale">DE-locale flag forwarded from the importer so address normalisation rewrites comma-decimals.</param>
|
||||
/// <param name="StringLength">Optional <c>Length</c> column for STRING tags; null = default 254.</param>
|
||||
/// <param name="ParentDbNumber">For the interface-offset path: the DB number the FB instance lives in.</param>
|
||||
/// <param name="ParentBaseOffset">For the interface-offset path: the FB instance's base byte offset within the DB. Defaults to 0 when null.</param>
|
||||
/// <param name="MemberOffset">For the interface-offset path: the member's byte offset within the FB interface.</param>
|
||||
/// <param name="MemberBitOffset">For the interface-offset path: the bit offset for BOOL members; null for non-BOOL members.</param>
|
||||
public sealed record InstanceDbRow(
|
||||
string Name,
|
||||
string? DbType,
|
||||
string? LogicalAddress,
|
||||
string? DataType,
|
||||
bool DeLocale = false,
|
||||
int? StringLength = null,
|
||||
int? ParentDbNumber = null,
|
||||
int? ParentBaseOffset = null,
|
||||
int? MemberOffset = null,
|
||||
int? MemberBitOffset = null);
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||
|
||||
/// <summary>
|
||||
/// Options that drive an <see cref="IS7SymbolImporter"/> run. Two knobs:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <see cref="MaxRowsToImport"/> — defensive cap; the parser stops past this
|
||||
/// count and emits a warning so a stray multi-thousand-row export doesn't
|
||||
/// silently hammer the host process at startup.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <see cref="IgnoreInvalid"/> — default <c>true</c>: per-row parse errors are
|
||||
/// logged and skipped; counter increments. When <c>false</c> the first
|
||||
/// malformed row surfaces as <see cref="System.IO.InvalidDataException"/> for
|
||||
/// fail-fast CI lint paths.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// UDT placeholders are <em>not</em> governed by <see cref="IgnoreInvalid"/> — they're
|
||||
/// a deliberate import outcome (track the symbol so it lands in the Admin UI tag list
|
||||
/// even before D2 ships proper UDT layout) and always succeed regardless of the flag.
|
||||
/// </remarks>
|
||||
public sealed record S7ImportOptions(
|
||||
int? MaxRowsToImport = null,
|
||||
bool IgnoreInvalid = true);
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of a single <see cref="IS7SymbolImporter"/> run. <see cref="Tags"/> carries
|
||||
/// the imported tag definitions ready to drop into <c>S7DriverOptions.Tags</c>;
|
||||
/// <see cref="ParsedCount"/>, <see cref="SkippedCount"/>, <see cref="ErrorCount"/>,
|
||||
/// <see cref="UdtPlaceholderCount"/>, and <see cref="InstanceDbCount"/> give the operator
|
||||
/// a single line of telemetry ("imported 142 / skipped 3 / errored 0 / udt 5 / instance-db 9")
|
||||
/// suitable for either a CLI summary or a startup-time log line.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="ParsedCount"/> includes UDT placeholders and instance-DB rows —
|
||||
/// both count as imported tags (they materialise as <see cref="S7TagDefinition"/>
|
||||
/// rows the driver can list in the Admin UI). <see cref="UdtPlaceholderCount"/>
|
||||
/// and <see cref="InstanceDbCount"/> are sub-counts operators can compare against
|
||||
/// <see cref="ParsedCount"/> to spot how the import breaks down.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="InstanceDbCount"/> (PR-S7-D3 / #301) tracks rows whose <c>DB type</c>
|
||||
/// column identified them as belonging to a multi-instance FB-instance DB. They
|
||||
/// import as fully-functional tags — the count is informational so an operator
|
||||
/// knows how many of the imported rows came from FB-instance DBs (and therefore
|
||||
/// need a re-import after any FB-interface edit on the PLC side).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record S7ImportResult(
|
||||
IReadOnlyList<S7TagDefinition> Tags,
|
||||
int ParsedCount,
|
||||
int SkippedCount,
|
||||
int ErrorCount,
|
||||
int UdtPlaceholderCount,
|
||||
int InstanceDbCount = 0);
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D2 — one named member of an <see cref="S7UdtDefinition"/>. Mirrors the
|
||||
/// STEP 7 / TIA Portal UDT layout: each member sits at a fixed byte offset relative
|
||||
/// to the UDT's base, has a primitive S7 type (or another UDT for nested STRUCTs),
|
||||
/// and may be a 1-D array.
|
||||
/// </summary>
|
||||
/// <param name="Name">Member name; concatenated with the parent tag's name via dot-separator at fan-out.</param>
|
||||
/// <param name="Offset">Byte offset within the parent UDT. Must be ascending and non-overlapping across the member list.</param>
|
||||
/// <param name="DataType">Primitive S7 type. Ignored when <paramref name="UdtName"/> is set (nested-UDT case).</param>
|
||||
/// <param name="ArrayDim">Optional 1-D array length. <c>null</c> / <c>1</c> = scalar; <c>> 1</c> emits indexed sub-tags <c>Member[0]</c>, <c>Member[1]</c>, ...</param>
|
||||
/// <param name="UdtName">When set, this member is itself a UDT — the fan-out recurses into the named UDT's layout. Mutually exclusive with the primitive interpretation of <paramref name="DataType"/>.</param>
|
||||
public sealed record S7UdtMember(
|
||||
string Name,
|
||||
int Offset,
|
||||
S7DataType DataType,
|
||||
int? ArrayDim = null,
|
||||
string? UdtName = null);
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D2 — declarative description of a Siemens UDT (User-Defined Type) /
|
||||
/// STRUCT layout. Tags whose <see cref="S7TagDefinition.UdtName"/> matches
|
||||
/// <see cref="Name"/> get fanned-out into one scalar leaf tag per recursive
|
||||
/// member at <see cref="S7Driver.InitializeAsync"/> time, so the rest of the
|
||||
/// read/write/subscribe pipeline never has to know about UDTs.
|
||||
/// </summary>
|
||||
/// <param name="Name">UDT name. Case-insensitively matched against <see cref="S7TagDefinition.UdtName"/> and against nested members' <see cref="S7UdtMember.UdtName"/>.</param>
|
||||
/// <param name="Members">Ordered member list. Members must have ascending non-overlapping offsets; offsets that re-use bytes are rejected at fan-out time.</param>
|
||||
/// <param name="SizeBytes">Total UDT byte size, used as a sanity bound for the last member's offset + width.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Optimized block access</b>: TIA Portal can mark a DB or UDT as
|
||||
/// "Optimized block access" which lets the runtime reorder members for
|
||||
/// memory alignment. The static-offset model used here REQUIRES "Optimized
|
||||
/// block access" turned OFF on the parent DB; otherwise the declared
|
||||
/// offsets won't match the runtime layout. Same constraint applies to
|
||||
/// general absolute-offset DB addressing (see <c>docs/v2/s7.md</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nesting depth</b>: nested UDT-of-UDT is supported up to 4 levels.
|
||||
/// The fan-out throws <see cref="InvalidOperationException"/> on the 5th
|
||||
/// level — picked as a generous-but-still-bounded ceiling that catches
|
||||
/// pathological / accidentally-recursive declarations early.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record S7UdtDefinition(
|
||||
string Name,
|
||||
IReadOnlyList<S7UdtMember> Members,
|
||||
int SizeBytes);
|
||||
@@ -0,0 +1,305 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-D2 — UDT / STRUCT fan-out helper. Walks a parent <see cref="S7TagDefinition"/>
|
||||
/// whose <see cref="S7TagDefinition.UdtName"/> is set, looks up the matching
|
||||
/// <see cref="S7UdtDefinition"/>, recursively flattens its member tree into N scalar
|
||||
/// leaf tags, and returns the produced list so the driver's tag map can keep working
|
||||
/// in scalar-only mode for read / write / subscribe.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The fan-out happens once at <see cref="S7Driver.InitializeAsync"/> and the
|
||||
/// result REPLACES the parent UDT tag in the driver's tag map. Reads / writes /
|
||||
/// subscribes only ever target scalar leaves; the rest of the driver pipeline
|
||||
/// (S7AddressParser, S7BlockCoalescingPlanner, S7ReadPacker) remains UDT-unaware.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Naming is dot-separated: <c>ParentTag.Member.SubMember</c>. Array members
|
||||
/// emit indexed children (<c>ParentTag.Sensors[0]</c>, <c>ParentTag.Sensors[1]</c>, ...).
|
||||
/// Per-member addresses are constructed by adding the member byte offset (and the
|
||||
/// array-element index × element-bytes for array members) to the parent's parsed
|
||||
/// <see cref="S7ParsedAddress.ByteOffset"/> and re-rendering the address string in
|
||||
/// a width-suffix form the standard <see cref="S7AddressParser"/> will re-parse
|
||||
/// without modification.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class S7UdtFanOut
|
||||
{
|
||||
/// <summary>
|
||||
/// Hard ceiling on UDT-of-UDT recursion depth. Picked as "generous but bounded"
|
||||
/// so a real 3-level analytics struct still fans out, but an accidentally-recursive
|
||||
/// declaration ("MyUdt contains MyUdt") fails fast at Init instead of stack-overflowing.
|
||||
/// </summary>
|
||||
public const int MaxNestingDepth = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Fan a parent UDT-typed tag into N scalar member tags, emitting one scalar
|
||||
/// <see cref="S7TagDefinition"/> per leaf member.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<S7TagDefinition> Expand(
|
||||
S7TagDefinition parent,
|
||||
IReadOnlyList<S7UdtDefinition> udts,
|
||||
S7ParsedAddress parentAddress)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(parent.UdtName))
|
||||
throw new InvalidOperationException(
|
||||
$"S7UdtFanOut.Expand called for tag '{parent.Name}' which has no UdtName");
|
||||
|
||||
var udtIndex = BuildUdtIndex(udts);
|
||||
var emitted = new List<S7TagDefinition>();
|
||||
ExpandRecursive(
|
||||
tagPath: parent.Name,
|
||||
udtName: parent.UdtName!,
|
||||
baseByteOffset: parentAddress.ByteOffset,
|
||||
parsedTemplate: parentAddress,
|
||||
parent: parent,
|
||||
udtIndex: udtIndex,
|
||||
depth: 1,
|
||||
emitted: emitted);
|
||||
return emitted;
|
||||
}
|
||||
|
||||
private static Dictionary<string, S7UdtDefinition> BuildUdtIndex(IReadOnlyList<S7UdtDefinition> udts)
|
||||
{
|
||||
var dict = new Dictionary<string, S7UdtDefinition>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var udt in udts)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(udt.Name))
|
||||
continue;
|
||||
dict[udt.Name] = udt;
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
private static void ExpandRecursive(
|
||||
string tagPath,
|
||||
string udtName,
|
||||
int baseByteOffset,
|
||||
S7ParsedAddress parsedTemplate,
|
||||
S7TagDefinition parent,
|
||||
Dictionary<string, S7UdtDefinition> udtIndex,
|
||||
int depth,
|
||||
List<S7TagDefinition> emitted)
|
||||
{
|
||||
if (depth > MaxNestingDepth)
|
||||
throw new InvalidOperationException(
|
||||
$"UDT nesting depth exceeds {MaxNestingDepth} levels at tag '{tagPath}' (UDT '{udtName}')");
|
||||
|
||||
if (!udtIndex.TryGetValue(udtName, out var udt))
|
||||
throw new InvalidOperationException(
|
||||
$"UDT '{udtName}' referenced by tag '{tagPath}' but not declared in S7DriverOptions.Udts");
|
||||
|
||||
ValidateMemberLayout(udt, tagPath);
|
||||
|
||||
foreach (var member in udt.Members)
|
||||
{
|
||||
var memberOffset = baseByteOffset + member.Offset;
|
||||
if (!string.IsNullOrWhiteSpace(member.UdtName))
|
||||
{
|
||||
// Nested UDT — recurse. Arrays of UDT walk one nesting level per index slot
|
||||
// sharing the same depth budget; the depth cap counts UDT layers, not array
|
||||
// expansions, because array indices don't grow the call-graph fan-out shape.
|
||||
if (member.ArrayDim is int udtArrLen && udtArrLen > 1)
|
||||
{
|
||||
var nestedUdt = LookupOrThrow(udtIndex, member.UdtName!, $"{tagPath}.{member.Name}");
|
||||
var stride = nestedUdt.SizeBytes;
|
||||
if (stride <= 0)
|
||||
throw new InvalidOperationException(
|
||||
$"UDT '{member.UdtName}' has SizeBytes <= 0; cannot stride array-of-UDT for member '{member.Name}'");
|
||||
for (var i = 0; i < udtArrLen; i++)
|
||||
{
|
||||
ExpandRecursive(
|
||||
tagPath: $"{tagPath}.{member.Name}[{i}]",
|
||||
udtName: member.UdtName!,
|
||||
baseByteOffset: memberOffset + i * stride,
|
||||
parsedTemplate: parsedTemplate,
|
||||
parent: parent,
|
||||
udtIndex: udtIndex,
|
||||
depth: depth + 1,
|
||||
emitted: emitted);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ExpandRecursive(
|
||||
tagPath: $"{tagPath}.{member.Name}",
|
||||
udtName: member.UdtName!,
|
||||
baseByteOffset: memberOffset,
|
||||
parsedTemplate: parsedTemplate,
|
||||
parent: parent,
|
||||
udtIndex: udtIndex,
|
||||
depth: depth + 1,
|
||||
emitted: emitted);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Primitive leaf — emit one scalar tag, or N tags for an array member.
|
||||
if (member.ArrayDim is int arrLen && arrLen > 1)
|
||||
{
|
||||
var elemBytes = PrimitiveElementBytes(member.DataType);
|
||||
for (var i = 0; i < arrLen; i++)
|
||||
{
|
||||
var elementOffset = memberOffset + i * elemBytes;
|
||||
var leafName = $"{tagPath}.{member.Name}[{i}]";
|
||||
emitted.Add(BuildLeafTag(leafName, elementOffset, member.DataType, parsedTemplate, parent));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var leafName = $"{tagPath}.{member.Name}";
|
||||
emitted.Add(BuildLeafTag(leafName, memberOffset, member.DataType, parsedTemplate, parent));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static S7UdtDefinition LookupOrThrow(
|
||||
Dictionary<string, S7UdtDefinition> index, string udtName, string referencedFrom)
|
||||
{
|
||||
if (!index.TryGetValue(udtName, out var udt))
|
||||
throw new InvalidOperationException(
|
||||
$"UDT '{udtName}' referenced by tag '{referencedFrom}' but not declared in S7DriverOptions.Udts");
|
||||
return udt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reject misordered or overlapping member offsets — each UDT must be sortable as a
|
||||
/// monotone non-overlapping sequence of byte ranges, and the last range must fit
|
||||
/// within <see cref="S7UdtDefinition.SizeBytes"/>.
|
||||
/// </summary>
|
||||
private static void ValidateMemberLayout(S7UdtDefinition udt, string tagPath)
|
||||
{
|
||||
var prevEnd = -1;
|
||||
var prevName = "<start>";
|
||||
for (var i = 0; i < udt.Members.Count; i++)
|
||||
{
|
||||
var m = udt.Members[i];
|
||||
if (m.Offset < prevEnd)
|
||||
throw new InvalidOperationException(
|
||||
$"UDT '{udt.Name}' (used by tag '{tagPath}') has overlapping or misordered " +
|
||||
$"member offsets: '{m.Name}' at {m.Offset} overlaps previous member '{prevName}' ending at {prevEnd}");
|
||||
|
||||
// Approximate member width — for nested UDT we don't have its size at this point,
|
||||
// but ascending-offset ordering still catches the typical mistake. Primitive widths
|
||||
// come from PrimitiveElementBytes; arrays multiply by ArrayDim.
|
||||
int width;
|
||||
if (!string.IsNullOrWhiteSpace(m.UdtName))
|
||||
width = 1; // sentinel — recursive expansion validates the nested layout itself.
|
||||
else
|
||||
width = PrimitiveElementBytes(m.DataType) * Math.Max(1, m.ArrayDim ?? 1);
|
||||
|
||||
prevEnd = m.Offset + width;
|
||||
prevName = m.Name;
|
||||
}
|
||||
|
||||
if (udt.SizeBytes > 0 && prevEnd > udt.SizeBytes)
|
||||
throw new InvalidOperationException(
|
||||
$"UDT '{udt.Name}' members extend past declared SizeBytes={udt.SizeBytes} " +
|
||||
$"(last member '{prevName}' ends at {prevEnd})");
|
||||
}
|
||||
|
||||
private static S7TagDefinition BuildLeafTag(
|
||||
string name,
|
||||
int byteOffset,
|
||||
S7DataType dataType,
|
||||
S7ParsedAddress parsedTemplate,
|
||||
S7TagDefinition parent)
|
||||
{
|
||||
var address = BuildAddressString(parsedTemplate, byteOffset, dataType);
|
||||
return new S7TagDefinition(
|
||||
Name: name,
|
||||
Address: address,
|
||||
DataType: dataType,
|
||||
Writable: parent.Writable,
|
||||
StringLength: parent.StringLength,
|
||||
WriteIdempotent: parent.WriteIdempotent,
|
||||
ElementCount: null,
|
||||
ScanGroup: parent.ScanGroup,
|
||||
DeadbandAbsolute: parent.DeadbandAbsolute,
|
||||
DeadbandPercent: parent.DeadbandPercent,
|
||||
UdtName: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render a synthetic S7 address string for a fanned-out leaf tag. Re-uses the parent
|
||||
/// tag's parsed address for the area / DB number; picks a width suffix matching the
|
||||
/// leaf <paramref name="dataType"/> so the standard parser will accept it on the
|
||||
/// return trip.
|
||||
/// </summary>
|
||||
public static string BuildAddressString(S7ParsedAddress parent, int byteOffset, S7DataType dataType)
|
||||
{
|
||||
var width = AddressWidthFor(dataType);
|
||||
switch (parent.Area)
|
||||
{
|
||||
case S7Area.DataBlock:
|
||||
return width switch
|
||||
{
|
||||
AddressWidth.Bit => $"DB{parent.DbNumber}.DBX{byteOffset}.0",
|
||||
AddressWidth.Byte => $"DB{parent.DbNumber}.DBB{byteOffset}",
|
||||
AddressWidth.Word => $"DB{parent.DbNumber}.DBW{byteOffset}",
|
||||
AddressWidth.DWord => $"DB{parent.DbNumber}.DBD{byteOffset}",
|
||||
AddressWidth.LWord => $"DB{parent.DbNumber}.DBLD{byteOffset}",
|
||||
_ => throw new InvalidOperationException($"Unhandled address width {width}"),
|
||||
};
|
||||
case S7Area.Memory:
|
||||
return RenderAreaPrefix("M", width, byteOffset);
|
||||
case S7Area.Input:
|
||||
return RenderAreaPrefix("I", width, byteOffset);
|
||||
case S7Area.Output:
|
||||
return RenderAreaPrefix("Q", width, byteOffset);
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"UDT fan-out only supports DataBlock / M / I / Q parents; got {parent.Area}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string RenderAreaPrefix(string prefix, AddressWidth width, int byteOffset) => width switch
|
||||
{
|
||||
AddressWidth.Bit => $"{prefix}{byteOffset}.0",
|
||||
AddressWidth.Byte => $"{prefix}B{byteOffset}",
|
||||
AddressWidth.Word => $"{prefix}W{byteOffset}",
|
||||
AddressWidth.DWord => $"{prefix}D{byteOffset}",
|
||||
AddressWidth.LWord => $"{prefix}LD{byteOffset}",
|
||||
_ => throw new InvalidOperationException($"Unhandled address width {width}"),
|
||||
};
|
||||
|
||||
private enum AddressWidth { Bit, Byte, Word, DWord, LWord }
|
||||
|
||||
private static AddressWidth AddressWidthFor(S7DataType t) => t switch
|
||||
{
|
||||
S7DataType.Bool => AddressWidth.Bit,
|
||||
S7DataType.Byte or S7DataType.Char => AddressWidth.Byte,
|
||||
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.WChar or S7DataType.Date or S7DataType.S5Time
|
||||
=> AddressWidth.Word,
|
||||
S7DataType.Int32 or S7DataType.UInt32 or S7DataType.Float32 or S7DataType.Time or S7DataType.TimeOfDay
|
||||
=> AddressWidth.DWord,
|
||||
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64
|
||||
=> AddressWidth.LWord,
|
||||
// Variable-width / structured leaves don't have a single fixed-width address shape;
|
||||
// rendered as a byte-prefixed address — caller's S7AddressParser will accept and
|
||||
// the codec layer handles the structured payload from byteOffset onward.
|
||||
S7DataType.String or S7DataType.WString or S7DataType.DateTime or S7DataType.Dtl or S7DataType.DateAndTime
|
||||
=> AddressWidth.Byte,
|
||||
_ => throw new InvalidOperationException($"AddressWidthFor: unhandled S7DataType {t}"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// On-wire byte width of a primitive UDT member used for array-element stride and
|
||||
/// overlap validation. Variable-width / structured types are rejected at fan-out
|
||||
/// time because their layout cannot be treated as a fixed array stride.
|
||||
/// </summary>
|
||||
public static int PrimitiveElementBytes(S7DataType t) => t switch
|
||||
{
|
||||
S7DataType.Bool or S7DataType.Byte or S7DataType.Char => 1,
|
||||
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.WChar or S7DataType.Date or S7DataType.S5Time => 2,
|
||||
S7DataType.Int32 or S7DataType.UInt32 or S7DataType.Float32 or S7DataType.Time or S7DataType.TimeOfDay => 4,
|
||||
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 or S7DataType.DateAndTime => 8,
|
||||
S7DataType.Dtl => 12,
|
||||
S7DataType.DateTime => 8,
|
||||
// Variable-width string types as UDT members would need explicit per-member length;
|
||||
// not yet supported. Fall back to "treat as 1-byte" for the prevEnd math but caller
|
||||
// gets a clear runtime error from the address parser for unsupported types.
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||
|
||||
/// <summary>
|
||||
/// Materialises <see cref="S7TagDefinition"/> entries from a TIA Portal "Show all
|
||||
/// tags" CSV export. The expected column shape (TIA Portal v15+ default) is
|
||||
/// <c>Name,Path,Data type,Logical address,Comment,Hmi accessible,Hmi visible,
|
||||
/// Hmi writeable,Length</c> — only <c>Name</c> + <c>Logical address</c> are strictly
|
||||
/// required; everything else is optional metadata. Older TIA Portal versions emit a
|
||||
/// subset (e.g. <c>Name,Address,Data type,Comment</c> for v13) — the parser maps
|
||||
/// whatever subset the header row carries and tolerates missing optional columns.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Locale detection</b>: TIA Portal honours the Windows display locale when
|
||||
/// writing CSV. A DE-locale install emits comma-decimal addresses
|
||||
/// (<c>%MW0,5</c>) and <c>WAHR</c> / <c>FALSCH</c> for the boolean HMI columns.
|
||||
/// The importer detects DE locale by sniffing the first data row's address — if
|
||||
/// it contains a digit-comma-digit sequence the parser interprets the comma as
|
||||
/// the decimal separator and rewrites the address to en-US shape (<c>%MW0.5</c>)
|
||||
/// before parsing. Boolean column values are recognised in both languages
|
||||
/// (<c>true/false/wahr/falsch/yes/no/ja/nein</c>, case-insensitive).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>UDT placeholders</b>: rows whose <c>Data type</c> is <c>Struct</c> or names
|
||||
/// a user-defined type (<c>"udt_name"</c> with the surrounding quotes that TIA
|
||||
/// emits) import as a non-functional placeholder tag — the result still lands in
|
||||
/// the driver options so it shows up in the Admin UI tag list, but the data type
|
||||
/// is forced to <see cref="S7DataType.Byte"/> and the comment carries the
|
||||
/// marker <c>[UDT placeholder — wait for D2]</c>. <see cref="S7ImportResult.UdtPlaceholderCount"/>
|
||||
/// tracks how many of the imported tags fell into this bucket.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>HMI-accessible filter</b>: rows whose <c>Hmi accessible</c> column is
|
||||
/// explicitly <c>false</c> / <c>FALSCH</c> / <c>nein</c> are skipped — these are
|
||||
/// internal symbols TIA shows in the editor but doesn't expose to client
|
||||
/// interfaces. Missing or blank <c>Hmi accessible</c> defaults to <c>true</c>
|
||||
/// (older TIA Portal versions don't emit the column at all).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class TiaCsvImporter : IS7SymbolImporter
|
||||
{
|
||||
private readonly ILogger<TiaCsvImporter> _logger;
|
||||
|
||||
public TiaCsvImporter() : this(NullLogger<TiaCsvImporter>.Instance) { }
|
||||
|
||||
public TiaCsvImporter(ILogger<TiaCsvImporter> logger)
|
||||
{
|
||||
_logger = logger ?? NullLogger<TiaCsvImporter>.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public S7ImportResult Parse(Stream stream, S7ImportOptions? options = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
var opts = options ?? new S7ImportOptions();
|
||||
|
||||
var tags = new List<S7TagDefinition>();
|
||||
var parsed = 0;
|
||||
var skipped = 0;
|
||||
var errors = 0;
|
||||
var udtPlaceholders = 0;
|
||||
var instanceDbCount = 0;
|
||||
|
||||
var resolver = new InstanceDbResolver();
|
||||
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8,
|
||||
detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
|
||||
|
||||
int? nameIdx = null;
|
||||
int? addressIdx = null;
|
||||
int? dataTypeIdx = null;
|
||||
int? commentIdx = null;
|
||||
int? hmiAccessibleIdx = null;
|
||||
int? lengthIdx = null;
|
||||
int? dbTypeIdx = null;
|
||||
var headerSeen = false;
|
||||
var deLocale = false;
|
||||
var lineNumber = 0;
|
||||
|
||||
// Sniff: TIA emits ';' as the field separator under DE locale (because comma is
|
||||
// the decimal separator). en-US uses ','. Detect from the first non-blank line by
|
||||
// counting which separator yields more fields when we tokenise the header.
|
||||
var allLines = new List<string>();
|
||||
string? line;
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
allLines.Add(line);
|
||||
}
|
||||
|
||||
char separator = DetectSeparator(allLines);
|
||||
|
||||
foreach (var rawLine in allLines)
|
||||
{
|
||||
lineNumber++;
|
||||
if (string.IsNullOrWhiteSpace(rawLine)) continue;
|
||||
var trimmed = rawLine.TrimStart();
|
||||
// Comment-only safety net — most TIA exports don't carry comments, but
|
||||
// operators sometimes annotate fixtures by hand. Treat lines starting
|
||||
// with `#` as comments. `;` is ambiguous (it's the DE-locale separator)
|
||||
// so we explicitly do NOT treat it as a comment marker.
|
||||
if (trimmed.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fields = SplitCsv(rawLine, separator);
|
||||
if (fields.Count == 0) continue;
|
||||
|
||||
if (!headerSeen)
|
||||
{
|
||||
for (var i = 0; i < fields.Count; i++)
|
||||
{
|
||||
var header = fields[i].Trim().Trim('"').ToLowerInvariant();
|
||||
switch (header)
|
||||
{
|
||||
case "name": nameIdx = i; break;
|
||||
case "logical address":
|
||||
case "address": addressIdx = i; break;
|
||||
case "data type":
|
||||
case "datatype":
|
||||
case "type": dataTypeIdx = i; break;
|
||||
case "comment": commentIdx = i; break;
|
||||
case "hmi accessible":
|
||||
case "hmi-accessible": hmiAccessibleIdx = i; break;
|
||||
case "length": lengthIdx = i; break;
|
||||
// PR-S7-D3 — TIA Portal exports include a "DB type" column
|
||||
// that distinguishes Global DBs (already handled by D1) from
|
||||
// multi-instance FB instance DBs (the new bucket). DE locale
|
||||
// emits "Datenbaustein-Typ"; both header forms are accepted.
|
||||
case "db type":
|
||||
case "db-type":
|
||||
case "datenbaustein-typ":
|
||||
case "datenbausteintyp": dbTypeIdx = i; break;
|
||||
}
|
||||
}
|
||||
|
||||
if (nameIdx is null || addressIdx is null)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"TIA CSV header at line {lineNumber} is missing required Name or Logical address column. " +
|
||||
$"Got: {string.Join(separator.ToString(), fields)}");
|
||||
}
|
||||
headerSeen = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Locale sniff: examine the first data row's address column for a comma between
|
||||
// digits, which only happens in DE locale (en-US uses '.' for the decimal point).
|
||||
if (!deLocale && addressIdx is { } ai && fields.Count > ai)
|
||||
{
|
||||
var rawAddr = fields[ai].Trim().Trim('"');
|
||||
if (LooksDeLocale(rawAddr))
|
||||
{
|
||||
deLocale = true;
|
||||
_logger.LogInformation(
|
||||
"TIA CSV locale auto-detected as DE (decimal-comma in address '{Address}' at line {LineNumber})",
|
||||
rawAddr, lineNumber);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.MaxRowsToImport is int cap && parsed >= cap)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"TIA CSV import hit MaxRowsToImport={Cap} at line {LineNumber}; remaining rows skipped.",
|
||||
cap, lineNumber);
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var name = SafeField(fields, nameIdx!.Value).Trim().Trim('"');
|
||||
var address = SafeField(fields, addressIdx!.Value).Trim().Trim('"');
|
||||
var dataTypeStr = dataTypeIdx.HasValue ? SafeField(fields, dataTypeIdx.Value).Trim().Trim('"') : string.Empty;
|
||||
var comment = commentIdx.HasValue ? SafeField(fields, commentIdx.Value).Trim().Trim('"') : null;
|
||||
var hmiAccessible = hmiAccessibleIdx.HasValue
|
||||
? ParseBoolColumn(SafeField(fields, hmiAccessibleIdx.Value), defaultValue: true)
|
||||
: true;
|
||||
var lengthStr = lengthIdx.HasValue ? SafeField(fields, lengthIdx.Value).Trim().Trim('"') : null;
|
||||
var dbTypeStr = dbTypeIdx.HasValue ? SafeField(fields, dbTypeIdx.Value).Trim().Trim('"') : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(address))
|
||||
{
|
||||
skipped++;
|
||||
_logger.LogWarning(
|
||||
"TIA CSV row at line {LineNumber} skipped — missing Name or Logical address (name='{Name}', address='{Address}').",
|
||||
lineNumber, name, address);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hmiAccessible)
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strip the leading '%' that TIA always emits and rewrite DE-locale comma
|
||||
// to '.' so the rest of the pipeline sees a single canonical address shape.
|
||||
var normalised = NormaliseAddress(address, deLocale);
|
||||
|
||||
// PR-S7-D3 — recognise instance-DB rows (multi-instance FB members) BEFORE
|
||||
// the UDT detection runs. TIA's CSV typically already ships the resolved
|
||||
// absolute address (%DB7.DBW0) so the data type column is a primitive — but
|
||||
// the row still wants to land in the InstanceDbCount sub-counter so the
|
||||
// operator knows how much of the import depends on the FB-interface layout.
|
||||
// (Re-import is required after any FB-interface edit; see docs.)
|
||||
if (InstanceDbResolver.IsInstanceDbType(dbTypeStr))
|
||||
{
|
||||
var idbRow = new InstanceDbRow(
|
||||
Name: name,
|
||||
DbType: dbTypeStr,
|
||||
LogicalAddress: address, // raw address — resolver normalises internally
|
||||
DataType: dataTypeStr,
|
||||
DeLocale: deLocale,
|
||||
StringLength: int.TryParse(lengthStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var idbLen) && idbLen > 0
|
||||
? idbLen
|
||||
: null);
|
||||
|
||||
if (resolver.TryResolve(idbRow, out var idbTag) && idbTag is not null)
|
||||
{
|
||||
tags.Add(idbTag);
|
||||
instanceDbCount++;
|
||||
parsed++;
|
||||
_logger.LogInformation(
|
||||
"TIA CSV row at line {LineNumber} accepted as instance-DB member (Name='{Name}', Address='{Address}').",
|
||||
lineNumber, idbTag.Name, idbTag.Address);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolver rejected the row — fall through to the strict-error path
|
||||
// so the outer try/catch consistently increments errors / skips.
|
||||
if (!opts.IgnoreInvalid)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"TIA CSV row at line {lineNumber} flagged as instance-DB but failed to resolve (Name='{name}', address='{address}').");
|
||||
}
|
||||
errors++;
|
||||
_logger.LogWarning(
|
||||
"TIA CSV row at line {LineNumber} skipped — instance-DB row failed to resolve (Name='{Name}', address='{Address}').",
|
||||
lineNumber, name, address);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect UDT placeholder before the strict address parser runs. UDTs in TIA
|
||||
// typically address as DBn (whole DB) and don't carry a width suffix; the
|
||||
// address parser would reject them.
|
||||
var isUdtPlaceholder = IsUdtTypeName(dataTypeStr);
|
||||
if (isUdtPlaceholder)
|
||||
{
|
||||
var placeholder = new S7TagDefinition(
|
||||
Name: name,
|
||||
Address: normalised,
|
||||
DataType: S7DataType.Byte,
|
||||
Writable: false);
|
||||
tags.Add(placeholder);
|
||||
udtPlaceholders++;
|
||||
parsed++;
|
||||
_logger.LogInformation(
|
||||
"TIA CSV row at line {LineNumber} imported as UDT placeholder (Name='{Name}', DataType='{DataType}'). [UDT placeholder — wait for D2]",
|
||||
lineNumber, name, dataTypeStr);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryResolveDataType(dataTypeStr, normalised, out var s7Type))
|
||||
{
|
||||
if (!opts.IgnoreInvalid)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"TIA CSV row at line {lineNumber} has unrecognised Data type '{dataTypeStr}' for address '{address}'.");
|
||||
}
|
||||
errors++;
|
||||
_logger.LogWarning(
|
||||
"TIA CSV row at line {LineNumber} skipped — unrecognised Data type '{DataType}' for address '{Address}'.",
|
||||
lineNumber, dataTypeStr, address);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate the address syntax — strict so a typo in TIA's "Show all tags"
|
||||
// export surfaces at import time, not as a misleading runtime read failure.
|
||||
if (!S7AddressParser.TryParse(normalised, out _))
|
||||
{
|
||||
if (!opts.IgnoreInvalid)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"TIA CSV row at line {lineNumber} has invalid S7 address '{normalised}'.");
|
||||
}
|
||||
errors++;
|
||||
_logger.LogWarning(
|
||||
"TIA CSV row at line {LineNumber} skipped — invalid S7 address '{Address}'.",
|
||||
lineNumber, normalised);
|
||||
continue;
|
||||
}
|
||||
|
||||
var stringLength = 254;
|
||||
if (s7Type == S7DataType.String && !string.IsNullOrWhiteSpace(lengthStr) &&
|
||||
int.TryParse(lengthStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var len) &&
|
||||
len > 0)
|
||||
{
|
||||
stringLength = len;
|
||||
}
|
||||
|
||||
_ = comment; // currently parsed but unused — S7TagDefinition has no Comment field today.
|
||||
tags.Add(new S7TagDefinition(
|
||||
Name: name,
|
||||
Address: normalised,
|
||||
DataType: s7Type,
|
||||
Writable: true,
|
||||
StringLength: stringLength));
|
||||
parsed++;
|
||||
}
|
||||
catch (InvalidDataException) when (opts.IgnoreInvalid)
|
||||
{
|
||||
errors++;
|
||||
_logger.LogWarning("TIA CSV row at line {LineNumber} skipped — invalid data.", lineNumber);
|
||||
}
|
||||
catch (Exception ex) when (opts.IgnoreInvalid)
|
||||
{
|
||||
errors++;
|
||||
_logger.LogWarning(ex, "TIA CSV row at line {LineNumber} skipped — parser threw.", lineNumber);
|
||||
}
|
||||
}
|
||||
|
||||
if (!headerSeen)
|
||||
{
|
||||
return new S7ImportResult([], 0, skipped, errors, udtPlaceholders, instanceDbCount);
|
||||
}
|
||||
|
||||
return new S7ImportResult(tags, parsed, skipped, errors, udtPlaceholders, instanceDbCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detect the field separator a TIA CSV uses. en-US locale uses ','; DE locale
|
||||
/// uses ';' (because ',' is the decimal separator). We sniff by counting the
|
||||
/// occurrences in the first non-blank line — whichever character appears more
|
||||
/// wins, defaulting to ',' for ties or empty inputs.
|
||||
/// </summary>
|
||||
internal static char DetectSeparator(IReadOnlyList<string> lines)
|
||||
{
|
||||
foreach (var l in lines)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(l)) continue;
|
||||
var commas = 0;
|
||||
var semicolons = 0;
|
||||
var inQuotes = false;
|
||||
foreach (var c in l)
|
||||
{
|
||||
if (c == '"') inQuotes = !inQuotes;
|
||||
else if (!inQuotes && c == ',') commas++;
|
||||
else if (!inQuotes && c == ';') semicolons++;
|
||||
}
|
||||
if (semicolons > commas) return ';';
|
||||
return ',';
|
||||
}
|
||||
return ',';
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Heuristic: a TIA address column carrying <c>%MW0,5</c>, <c>%DB1.DBD0,3</c>, or
|
||||
/// similar digit-comma-digit pattern indicates DE locale. en-US would have written
|
||||
/// <c>%MW0.5</c> instead.
|
||||
/// </summary>
|
||||
internal static bool LooksDeLocale(string address)
|
||||
{
|
||||
if (string.IsNullOrEmpty(address)) return false;
|
||||
for (var i = 1; i + 1 < address.Length; i++)
|
||||
{
|
||||
if (address[i] == ',' &&
|
||||
address[i - 1] >= '0' && address[i - 1] <= '9' &&
|
||||
address[i + 1] >= '0' && address[i + 1] <= '9')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strip the leading '%' that TIA always emits, and rewrite DE-locale ',' to '.'
|
||||
/// when the importer has detected DE locale. The S7AddressParser only understands
|
||||
/// en-US-style decimals, so we canonicalise here.
|
||||
/// </summary>
|
||||
internal static string NormaliseAddress(string address, bool deLocale)
|
||||
{
|
||||
var s = address.Trim();
|
||||
if (s.StartsWith('%')) s = s.Substring(1);
|
||||
if (deLocale) s = s.Replace(',', '.');
|
||||
return s;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recognise a TIA <c>Data type</c> column value as a UDT type name. TIA emits
|
||||
/// UDT references as the bare name (sometimes wrapped in quotes), and the literal
|
||||
/// string <c>Struct</c> for inline anonymous structs. Standard primitive type
|
||||
/// names (Bool, Byte, Int, Real, …) are excluded — anything else is treated as
|
||||
/// a UDT reference and imports as a placeholder.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The CSV splitter strips the surrounding quotes that TIA wraps UDT references
|
||||
/// in, so by the time this method runs the value is bare (e.g. <c>CookerSettings</c>
|
||||
/// rather than <c>"CookerSettings"</c>). We use the primitive-name allow-list as
|
||||
/// the discriminator and treat any unrecognised non-empty string as a UDT.
|
||||
/// </remarks>
|
||||
internal static bool IsUdtTypeName(string dataType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dataType)) return false;
|
||||
var stripped = dataType.Trim().Trim('"');
|
||||
if (string.IsNullOrEmpty(stripped)) return false;
|
||||
if (string.Equals(stripped, "Struct", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
|
||||
// Array of UDT — `Array[0..9] of "MyUdt"` — also placeholder-worthy. Array of
|
||||
// primitive (`Array[0..9] of Int`) is array, also placeholder until D2 ships
|
||||
// proper array-of-UDT layout (PR-S7-D2 territory).
|
||||
if (stripped.StartsWith("Array", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Anything that isn't a recognised primitive is treated as a UDT reference.
|
||||
return !IsPrimitiveType(stripped);
|
||||
}
|
||||
|
||||
private static bool IsPrimitiveType(string raw)
|
||||
{
|
||||
return raw.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"bool" or "byte" or "char" or "wchar" or "word" or "dword" or "lword" or
|
||||
"int" or "uint" or "dint" or "udint" or "lint" or "ulint" or "sint" or "usint" or
|
||||
"real" or "lreal" or
|
||||
"string" or "wstring" or
|
||||
"date" or "time" or "time_of_day" or "tod" or "date_and_time" or "dt" or
|
||||
"dtl" or "s5time" => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a TIA <c>Data type</c> column value (or, when blank, the address's
|
||||
/// size suffix) to the matching <see cref="S7DataType"/>. Returns <c>false</c> for
|
||||
/// unparsable types.
|
||||
/// </summary>
|
||||
public static bool TryResolveDataType(string dataType, string address, out S7DataType s7Type)
|
||||
{
|
||||
s7Type = S7DataType.Byte;
|
||||
var dt = (dataType ?? string.Empty).Trim().Trim('"').ToLowerInvariant();
|
||||
|
||||
// Direct mapping for the TIA primitive type names.
|
||||
var direct = dt switch
|
||||
{
|
||||
"bool" => S7DataType.Bool,
|
||||
"byte" or "sint" or "usint" => S7DataType.Byte,
|
||||
"int" => S7DataType.Int16,
|
||||
"word" or "uint" => S7DataType.UInt16,
|
||||
"dint" => S7DataType.Int32,
|
||||
"dword" or "udint" => S7DataType.UInt32,
|
||||
"lint" => S7DataType.Int64,
|
||||
"lword" or "ulint" => S7DataType.UInt64,
|
||||
"real" => S7DataType.Float32,
|
||||
"lreal" => S7DataType.Float64,
|
||||
"string" => S7DataType.String,
|
||||
"wstring" => S7DataType.WString,
|
||||
"char" => S7DataType.Char,
|
||||
"wchar" => S7DataType.WChar,
|
||||
"date" => S7DataType.Date,
|
||||
"time" => S7DataType.Time,
|
||||
"time_of_day" or "tod" => S7DataType.TimeOfDay,
|
||||
"date_and_time" or "dt" => S7DataType.DateAndTime,
|
||||
"dtl" => S7DataType.Dtl,
|
||||
"s5time" => S7DataType.S5Time,
|
||||
_ => (S7DataType?)null,
|
||||
};
|
||||
if (direct.HasValue)
|
||||
{
|
||||
s7Type = direct.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: derive from the address size suffix. TIA exports sometimes leave the
|
||||
// Data type column blank when the address's size letter (B/W/D/X) already pins it.
|
||||
if (S7AddressParser.TryParse(address, out var parsed))
|
||||
{
|
||||
s7Type = parsed.Size switch
|
||||
{
|
||||
S7Size.Bit => S7DataType.Bool,
|
||||
S7Size.Byte => S7DataType.Byte,
|
||||
S7Size.Word => S7DataType.UInt16,
|
||||
S7Size.DWord => S7DataType.UInt32,
|
||||
S7Size.LWord => S7DataType.UInt64,
|
||||
_ => S7DataType.Byte,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a TIA boolean column. Recognises both en-US (true/false/yes/no) and
|
||||
/// DE-locale (wahr/falsch/ja/nein) values, plus the bare integer 0/1 some older
|
||||
/// export tools emit. Empty / blank → <paramref name="defaultValue"/>.
|
||||
/// </summary>
|
||||
internal static bool ParseBoolColumn(string raw, bool defaultValue)
|
||||
{
|
||||
var s = raw?.Trim().Trim('"').ToLowerInvariant() ?? string.Empty;
|
||||
return s switch
|
||||
{
|
||||
"" => defaultValue,
|
||||
"true" or "wahr" or "yes" or "ja" or "1" => true,
|
||||
"false" or "falsch" or "no" or "nein" or "0" => false,
|
||||
_ => defaultValue,
|
||||
};
|
||||
}
|
||||
|
||||
private static string SafeField(IReadOnlyList<string> fields, int idx) =>
|
||||
idx >= 0 && idx < fields.Count ? fields[idx] : string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// RFC 4180-ish CSV splitter that accepts either ',' or ';' as the field
|
||||
/// separator. Quoted fields, doubled-quote escape, embedded separators inside
|
||||
/// quoted fields. Lifted from the AbLegacy importer pattern.
|
||||
/// </summary>
|
||||
internal static List<string> SplitCsv(string line, char separator)
|
||||
{
|
||||
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 == '"')
|
||||
{
|
||||
if (i + 1 < line.Length && line[i + 1] == '"')
|
||||
{
|
||||
sb.Append('"');
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
inQuotes = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(c);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (c == '"') inQuotes = true;
|
||||
else if (c == separator)
|
||||
{
|
||||
fields.Add(sb.ToString());
|
||||
sb.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
fields.Add(sb.ToString());
|
||||
return fields;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E1 — abstraction over SZL (System Status List) wire reads. The driver dispatches
|
||||
/// <c>@System.*</c> virtual reads through this interface so the parser code never depends
|
||||
/// on a specific transport. Concrete implementations:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <see cref="S7NetSzlReader"/> — the production implementation. S7netplus 0.20
|
||||
/// does not expose a public <c>ReadSzlAsync</c> API (the SZL request builder is
|
||||
/// internal), so this implementation returns <c>null</c> on every call —
|
||||
/// surfacing as <c>BadNotSupported</c> at the OPC UA layer. Replace once
|
||||
/// S7netplus exposes a public surface or we ship a raw-PDU helper.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// A test fake that returns canned byte payloads — used by the
|
||||
/// driver-side unit tests in <c>tests/.../Szl/</c>.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public interface IS7SzlReader
|
||||
{
|
||||
/// <summary>
|
||||
/// Read SZL <paramref name="szlId"/> at <paramref name="szlIndex"/> and return the
|
||||
/// payload <em>without</em> the S7comm parameter / data headers — the response is
|
||||
/// positioned at the SZL header (<c>SzlId | SzlIndex | LenThdr | NDr</c>) so it can
|
||||
/// feed <see cref="S7SzlParser"/> directly.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// Byte payload on success, or <c>null</c> when the SZL read is unsupported (snap7,
|
||||
/// S7netplus 0.20 without raw PDU helper, hardened CPUs that reject the SZL
|
||||
/// function code). The caller surfaces <c>null</c> as <c>BadNotSupported</c>.
|
||||
/// </returns>
|
||||
Task<byte[]?> ReadSzlAsync(ushort szlId, ushort szlIndex, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using S7.Net;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E1 — production <see cref="IS7SzlReader"/> backed by S7netplus's
|
||||
/// <see cref="Plc"/> connection. S7netplus 0.20 builds SZL request packages
|
||||
/// internally (<c>SzlReadRequestPackage</c> / <c>WriteSzlReadRequest</c>) but does
|
||||
/// <b>not</b> expose a public <c>ReadSzlAsync</c> API, so this implementation
|
||||
/// currently returns <c>null</c> on every call — the SZL feature surface ships as
|
||||
/// <c>BadNotSupported</c> through the OPC UA address space until either
|
||||
/// <list type="bullet">
|
||||
/// <item>S7netplus publishes a stable public SZL surface (tracked upstream), or</item>
|
||||
/// <item>We ship a raw S7comm PDU helper that side-steps the library.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The driver-side parser code (<see cref="S7SzlParser"/>) is fully tested
|
||||
/// against golden bytes regardless — when the wire path lights up the parser
|
||||
/// starts producing real CPU info / cycle stats / diagnostic-buffer entries
|
||||
/// without further changes. Tests inject a fake <see cref="IS7SzlReader"/>
|
||||
/// to exercise the dispatch + caching paths.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why no raw socket today?</b> S7netplus's <c>_stream</c> + <c>tcpClient</c>
|
||||
/// fields are <c>private</c> and the request-builder helpers are <c>internal</c>.
|
||||
/// Reflecting into them would break on every minor S7netplus release; the cost-
|
||||
/// benefit only flips once the SZL feature has live customer demand.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class S7NetSzlReader(Plc plc) : IS7SzlReader
|
||||
{
|
||||
#pragma warning disable IDE0052 // unused while raw-PDU support is gated behind public S7netplus API
|
||||
private readonly Plc _plc = plc ?? throw new ArgumentNullException(nameof(plc));
|
||||
#pragma warning restore IDE0052
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<byte[]?> ReadSzlAsync(ushort szlId, ushort szlIndex, CancellationToken cancellationToken)
|
||||
{
|
||||
// S7netplus 0.20 doesn't expose a public ReadSzlAsync — surface every SZL request
|
||||
// as "not supported" so the OPC UA layer maps it to BadNotSupported. The parser
|
||||
// code is wired and tested; flipping this method to a real implementation is the
|
||||
// only change needed when S7netplus catches up. Synchronous return because
|
||||
// there's no I/O to await — keep the signature for future-proofing.
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult<byte[]?>(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E1 — virtual <c>@System.*</c> address map. Each entry pairs the public
|
||||
/// address (e.g. <c>@System.CpuType</c>) with the SZL ID + index it dispatches
|
||||
/// against and a field-extractor that pulls the requested scalar out of the parsed
|
||||
/// payload. The driver short-circuits any <see cref="S7Driver.ReadAsync"/> reference
|
||||
/// whose name starts with <c>@System.</c> through this table — there's no Plc
|
||||
/// round-trip for non-SZL paths.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The map is <em>static</em> because the SZL surface doesn't change between
|
||||
/// deployments — every CPU answers the same SZL IDs (or returns "not supported"
|
||||
/// uniformly). The diagnostic-buffer entries <c>@System.DiagBuffer.Entry[N]</c>
|
||||
/// are not in the table; the driver computes their address dynamically from the
|
||||
/// parsed entry list because the depth is configurable via
|
||||
/// <see cref="S7DriverOptions.DiagBufferDepth"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class S7SystemTags
|
||||
{
|
||||
/// <summary>Prefix every virtual system tag carries on the wire.</summary>
|
||||
public const string Prefix = "@System.";
|
||||
|
||||
/// <summary>Browse-tree folder name where the driver's discovery step emits the system-tag variables.</summary>
|
||||
public const string FolderName = "Diagnostics";
|
||||
|
||||
/// <summary>Maximum diagnostic-buffer entries the driver discovers / reads (capped to keep the OPC UA browse tree readable).</summary>
|
||||
public const int MaxDiagBufferDepth = 50;
|
||||
|
||||
/// <summary>Default diagnostic-buffer depth — matches the plan-section's "10 entries" baseline.</summary>
|
||||
public const int DefaultDiagBufferDepth = 10;
|
||||
|
||||
/// <summary>Address prefix for diagnostic-buffer entries: <c>@System.DiagBuffer.Entry[N]</c>.</summary>
|
||||
public const string DiagBufferEntryPrefix = "@System.DiagBuffer.Entry[";
|
||||
|
||||
/// <summary>OPC UA data type each system tag projects as. Used by both the driver's discovery step and its read-result boxing.</summary>
|
||||
public sealed record SystemTagDescriptor(
|
||||
string Address,
|
||||
ushort SzlId,
|
||||
ushort SzlIndex,
|
||||
DriverDataType DriverDataType,
|
||||
SystemTagKind Kind);
|
||||
|
||||
/// <summary>What kind of value the descriptor extracts from its SZL payload.</summary>
|
||||
public enum SystemTagKind
|
||||
{
|
||||
CpuType,
|
||||
Firmware,
|
||||
OrderNo,
|
||||
CycleMin,
|
||||
CycleMax,
|
||||
CycleAvg,
|
||||
DiagBufferEntry, // resolved dynamically via the entry index encoded in the address
|
||||
}
|
||||
|
||||
/// <summary>Static descriptors for the non-buffer system tags (CPU info + cycle-time scalars).</summary>
|
||||
public static readonly IReadOnlyList<SystemTagDescriptor> Descriptors =
|
||||
[
|
||||
new("@System.CpuType", S7SzlIds.ModuleIdentification, 0x0000, DriverDataType.String, SystemTagKind.CpuType),
|
||||
new("@System.Firmware", S7SzlIds.ModuleIdentification, 0x0000, DriverDataType.String, SystemTagKind.Firmware),
|
||||
new("@System.OrderNo", S7SzlIds.ModuleIdentification, 0x0000, DriverDataType.String, SystemTagKind.OrderNo),
|
||||
new("@System.CycleMs.Min", S7SzlIds.CpuStatusData, S7SzlIds.CpuStatusCycleTimeIndex, DriverDataType.Float64, SystemTagKind.CycleMin),
|
||||
new("@System.CycleMs.Max", S7SzlIds.CpuStatusData, S7SzlIds.CpuStatusCycleTimeIndex, DriverDataType.Float64, SystemTagKind.CycleMax),
|
||||
new("@System.CycleMs.Avg", S7SzlIds.CpuStatusData, S7SzlIds.CpuStatusCycleTimeIndex, DriverDataType.Float64, SystemTagKind.CycleAvg),
|
||||
];
|
||||
|
||||
/// <summary>True when <paramref name="address"/> is a recognised virtual system address.</summary>
|
||||
public static bool IsSystemAddress(string address)
|
||||
=> address is not null && address.StartsWith(Prefix, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a virtual address to a (SzlId, SzlIndex, kind, optional buffer index)
|
||||
/// dispatch tuple. Returns <c>false</c> when the address starts with the prefix but
|
||||
/// doesn't match a known descriptor — the caller surfaces that as
|
||||
/// <c>BadNodeIdUnknown</c>.
|
||||
/// </summary>
|
||||
public static bool TryResolve(string address, out SystemTagDescriptor? descriptor, out int diagBufferIndex)
|
||||
{
|
||||
descriptor = null;
|
||||
diagBufferIndex = -1;
|
||||
if (string.IsNullOrEmpty(address)) return false;
|
||||
|
||||
// Diagnostic-buffer entries: @System.DiagBuffer.Entry[N]
|
||||
if (address.StartsWith(DiagBufferEntryPrefix, StringComparison.Ordinal) && address.EndsWith(']'))
|
||||
{
|
||||
var idxStr = address[DiagBufferEntryPrefix.Length..^1];
|
||||
if (!int.TryParse(idxStr, out var idx) || idx < 0 || idx >= MaxDiagBufferDepth)
|
||||
return false;
|
||||
diagBufferIndex = idx;
|
||||
descriptor = new SystemTagDescriptor(
|
||||
address,
|
||||
S7SzlIds.DiagnosticBuffer,
|
||||
0x0000,
|
||||
DriverDataType.String,
|
||||
SystemTagKind.DiagBufferEntry);
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var d in Descriptors)
|
||||
{
|
||||
if (string.Equals(d.Address, address, StringComparison.Ordinal))
|
||||
{
|
||||
descriptor = d;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E1 — short-TTL cache of SZL responses keyed by <c>(SzlId, SzlIndex)</c>.
|
||||
/// A diagnostics-only feature should never hammer the comms mailbox; one read per
|
||||
/// SZL ID per <see cref="Ttl"/> window is the intended ceiling. Cache state is
|
||||
/// thread-safe — <see cref="GetOrFetchAsync"/> serialises concurrent fetchers per
|
||||
/// key so a burst of <c>@System.*</c> reads from one OPC UA subscription tick
|
||||
/// produces exactly one wire request per distinct SZL.
|
||||
/// </summary>
|
||||
public sealed class S7SzlCache(TimeSpan ttl, Func<DateTime>? clock = null)
|
||||
{
|
||||
private readonly TimeSpan _ttl = ttl;
|
||||
private readonly Func<DateTime> _clock = clock ?? (() => DateTime.UtcNow);
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<(ushort SzlId, ushort SzlIndex), CacheEntry> _entries = new();
|
||||
|
||||
/// <summary>Configured TTL — exposed for diagnostics / test assertions.</summary>
|
||||
public TimeSpan Ttl => _ttl;
|
||||
|
||||
/// <summary>
|
||||
/// Look up <paramref name="szlId"/> / <paramref name="szlIndex"/> in the cache; on
|
||||
/// miss or stale entry, invoke <paramref name="fetcher"/> exactly once and store the
|
||||
/// result. Negative cache (null payload) is intentionally <em>also</em> cached for
|
||||
/// the TTL window — repeatedly hammering a CPU that has already said "not supported"
|
||||
/// wouldn't help anything.
|
||||
/// </summary>
|
||||
public async Task<byte[]?> GetOrFetchAsync(
|
||||
ushort szlId,
|
||||
ushort szlIndex,
|
||||
Func<CancellationToken, Task<byte[]?>> fetcher,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fetcher);
|
||||
var key = (szlId, szlIndex);
|
||||
var now = _clock();
|
||||
|
||||
// Phase 1: cache hit — return without taking any locks beyond the dictionary lookup.
|
||||
lock (_gate)
|
||||
{
|
||||
if (_entries.TryGetValue(key, out var hit) && now - hit.FetchedAtUtc < _ttl)
|
||||
return hit.Payload;
|
||||
}
|
||||
|
||||
// Phase 2: miss — fetch outside the lock so concurrent keys don't serialize on
|
||||
// each other. We accept a small race where two callers both miss + both fetch on
|
||||
// the same key; the second store wins, which is fine for a TTL cache.
|
||||
var payload = await fetcher(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_entries[key] = new CacheEntry(payload, _clock());
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/// <summary>Drop every cached entry — call on driver shutdown / reinit so a fresh CPU advertises fresh SZL.</summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_gate) _entries.Clear();
|
||||
}
|
||||
|
||||
private readonly record struct CacheEntry(byte[]? Payload, DateTime FetchedAtUtc);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E1 — SZL (System Status List) IDs surfaced through the driver's virtual
|
||||
/// <c>@System.*</c> address space. SZL is the S7comm "System Status List" sub-protocol
|
||||
/// documented in the Siemens function manual (Entry ID 6ES7810-4CA08-8BW1) — every
|
||||
/// S7-300 / S7-400 / S7-1200 / S7-1500 CPU answers SZL queries with metadata about
|
||||
/// itself: CPU type / order number / firmware (SZL 0x0011), cycle-time min/max/avg
|
||||
/// (SZL 0x0132 / 0x0432), and the diagnostic-buffer ring (SZL 0x00A0).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// IDs are 16-bit big-endian on the wire. The driver pairs each ID with an SZL
|
||||
/// <em>index</em> (also 16-bit) — most diagnostic SZLs accept index <c>0</c>;
|
||||
/// the diagnostic-buffer SZL accepts index <c>0..N-1</c> to address a specific
|
||||
/// entry but the driver always reads index <c>0</c> and parses the full ring
|
||||
/// in one shot.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// S7netplus 0.20 has internal SZL request building (<c>SzlReadRequestPackage</c> /
|
||||
/// <c>WriteSzlReadRequest</c>) but does not expose a public <c>ReadSzlAsync</c> API.
|
||||
/// The driver therefore goes through <see cref="IS7SzlReader"/>, whose default
|
||||
/// <see cref="S7NetSzlReader"/> implementation surfaces every SZL read as
|
||||
/// "not supported" until S7netplus exposes the public surface or we ship a
|
||||
/// raw-PDU helper. snap7 doesn't implement SZL at all so the integration profile
|
||||
/// exercises the same not-supported path.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class S7SzlIds
|
||||
{
|
||||
/// <summary>SZL ID 0x0011 — module identification: CPU type, MLFB / order number, firmware version.</summary>
|
||||
public const ushort ModuleIdentification = 0x0011;
|
||||
|
||||
/// <summary>SZL ID 0x0132 — CPU status data including cycle-time stats. Index 0x0005 carries the cycle-time record.</summary>
|
||||
public const ushort CpuStatusData = 0x0132;
|
||||
|
||||
/// <summary>SZL ID 0x0132 sub-index 0x0005 — cycle-time statistics record.</summary>
|
||||
public const ushort CpuStatusCycleTimeIndex = 0x0005;
|
||||
|
||||
/// <summary>SZL ID 0x0432 — extended CPU status data; index 0x0001 carries the cycle-time record on S7-1500.</summary>
|
||||
public const ushort CpuStatusDataExtended = 0x0432;
|
||||
|
||||
/// <summary>SZL ID 0x00A0 — diagnostic buffer ring (most-recent entry first). Index 0 returns up to N records depending on PDU budget.</summary>
|
||||
public const ushort DiagnosticBuffer = 0x00A0;
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E1 — pure parsers for the SZL (System Status List) response payloads the
|
||||
/// driver dispatches against <c>@System.*</c> virtual addresses. Every parser takes a
|
||||
/// byte payload <em>without</em> the S7comm transport envelope (parameter / data
|
||||
/// headers stripped already by <see cref="IS7SzlReader"/>) and returns a strongly-typed
|
||||
/// record. The byte layouts below match the Siemens function manual (Entry ID
|
||||
/// 6ES7810-4CA08-8BW1) and the open-source <c>snap7</c> reference (the source of truth
|
||||
/// for unofficial layouts) — see <c>docs/v2/s7.md</c> "CPU diagnostics (SZL)" for the
|
||||
/// wire-level field-by-field map.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Common SZL payload header</b> (8 bytes):
|
||||
/// <code>
|
||||
/// u16 SzlId // BE — echoes the requested SZL ID
|
||||
/// u16 SzlIndex // BE — echoes the requested SZL index
|
||||
/// u16 LenThdr // BE — bytes per record
|
||||
/// u16 NDr // BE — number of records following
|
||||
/// </code>
|
||||
/// Records follow contiguously, total <c>LenThdr * NDr</c> bytes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// All multi-byte integers are big-endian. Fixed-width strings (MLFB, FW version)
|
||||
/// are space-padded ASCII; the parser trims trailing whitespace and NULs.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class S7SzlParser
|
||||
{
|
||||
/// <summary>Length of the common SZL response header in bytes.</summary>
|
||||
public const int HeaderLength = 8;
|
||||
|
||||
/// <summary>Hard upper bound on diagnostic-buffer entries returned in one parse — caps test allocations even if a malformed payload claims a huge count.</summary>
|
||||
public const int MaxDiagBufferEntriesPerResponse = 256;
|
||||
|
||||
/// <summary>
|
||||
/// Parse SZL 0x0011 (module identification) — produces the CPU type / order number /
|
||||
/// firmware version triple. The SZL contains multiple records keyed by an index in
|
||||
/// the first 2 bytes of each record:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>0x0001</c> — module identification (MLFB / order number)</item>
|
||||
/// <item><c>0x0006</c> — basic firmware</item>
|
||||
/// <item><c>0x0007</c> — basic hardware (CPU type derived from MLFB)</item>
|
||||
/// </list>
|
||||
/// Each record is 28 bytes: 2-byte index, 20-byte MLFB (ASCII, space-padded), 2-byte
|
||||
/// BGTyp, 2-byte Ausbg1 (firmware big-version), 2-byte Ausbg2 (firmware small-version
|
||||
/// / patch).
|
||||
/// </summary>
|
||||
public static S7CpuInfo ParseCpuInfo(byte[] payload)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
EnsureHeader(payload, out _, out _, out var lenThdr, out var nDr);
|
||||
|
||||
// Each module-identification record is 28 bytes per Siemens. Anything else is a
|
||||
// protocol-level mismatch — surface it loudly rather than silently mis-decoding.
|
||||
if (lenThdr != 28)
|
||||
throw new ArgumentException(
|
||||
$"S7 SZL 0x0011 expected record length 28, got {lenThdr}", nameof(payload));
|
||||
var expected = HeaderLength + lenThdr * nDr;
|
||||
if (payload.Length < expected)
|
||||
throw new ArgumentException(
|
||||
$"S7 SZL 0x0011 payload truncated: header claims {nDr} × {lenThdr} byte records " +
|
||||
$"({expected} bytes total) but buffer is {payload.Length}", nameof(payload));
|
||||
|
||||
string? mlfb = null;
|
||||
string? fw = null;
|
||||
string? cpuType = null;
|
||||
|
||||
for (var i = 0; i < nDr; i++)
|
||||
{
|
||||
var off = HeaderLength + i * lenThdr;
|
||||
var idx = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(off, 2));
|
||||
// Fields per Siemens function manual §"SSL-ID 0011H":
|
||||
// index (2) | MLFB (20) | BGTyp (2) | Ausbg1 (2) | Ausbg2 (2)
|
||||
var mlfbBytes = payload.AsSpan(off + 2, 20);
|
||||
var ausbg1 = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(off + 24, 2));
|
||||
var ausbg2 = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(off + 26, 2));
|
||||
|
||||
switch (idx)
|
||||
{
|
||||
case 0x0001:
|
||||
mlfb = TrimAscii(mlfbBytes);
|
||||
// CPU type: prefer the dedicated record if present, else derive from MLFB
|
||||
// (the prefix before the first space, e.g. "6ES7 516-3AN01-0AB0" → CPU 1516-3 PN/DP
|
||||
// — we surface the raw MLFB and let docs map to the marketing name).
|
||||
cpuType ??= DeriveCpuTypeFromMlfb(mlfb);
|
||||
break;
|
||||
case 0x0006:
|
||||
// Firmware version: high byte of Ausbg1 = major, low byte of Ausbg1 = minor,
|
||||
// high byte of Ausbg2 = patch. Encoded as two ASCII chars in some firmwares;
|
||||
// the manual normalises to "Vmajor.minor.patch".
|
||||
fw = $"V{(ausbg1 >> 8) & 0xFF}.{ausbg1 & 0xFF}.{(ausbg2 >> 8) & 0xFF}";
|
||||
break;
|
||||
case 0x0007:
|
||||
// Module-identification "basic hardware" — some CPUs surface the friendly
|
||||
// CPU name here as ASCII inside the MLFB slot. Override only if the field
|
||||
// looks like a real string (non-empty, printable).
|
||||
var hwName = TrimAscii(mlfbBytes);
|
||||
if (!string.IsNullOrEmpty(hwName)) cpuType = hwName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new S7CpuInfo(
|
||||
CpuType: cpuType ?? "(unknown)",
|
||||
Firmware: fw ?? "(unknown)",
|
||||
OrderNo: mlfb ?? "(unknown)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse SZL 0x0132 / 0x0432 (CPU status data — cycle-time record). The cycle-time
|
||||
/// record carries 6 × UInt32 BE values starting at offset 4 of the record:
|
||||
/// <list type="bullet">
|
||||
/// <item>Reserved (2 bytes index echo)</item>
|
||||
/// <item>Reserved (2 bytes)</item>
|
||||
/// <item>CycleAvg ms (UInt32 BE)</item>
|
||||
/// <item>CycleMin ms (UInt32 BE)</item>
|
||||
/// <item>CycleMax ms (UInt32 BE)</item>
|
||||
/// <item>… padding</item>
|
||||
/// </list>
|
||||
/// The driver pulls the first record (index <c>0x0005</c> on S7-300/400/1200,
|
||||
/// index <c>0x0001</c> on S7-1500's 0x0432) and reports the three cycle-time
|
||||
/// scalars in milliseconds as <see cref="double"/>s — matching the OPC UA Float64
|
||||
/// representation in <c>DriverDataType.Float64</c>.
|
||||
/// </summary>
|
||||
public static S7CycleStats ParseCycleStats(byte[] payload)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
EnsureHeader(payload, out _, out _, out var lenThdr, out var nDr);
|
||||
if (nDr < 1)
|
||||
throw new ArgumentException(
|
||||
"S7 SZL cycle-time response has no records", nameof(payload));
|
||||
if (lenThdr < 16)
|
||||
throw new ArgumentException(
|
||||
$"S7 SZL cycle-time record too short: {lenThdr} bytes; need ≥ 16", nameof(payload));
|
||||
var expected = HeaderLength + lenThdr;
|
||||
if (payload.Length < expected)
|
||||
throw new ArgumentException(
|
||||
$"S7 SZL cycle-time payload truncated: need {expected} bytes, got {payload.Length}", nameof(payload));
|
||||
|
||||
var recOff = HeaderLength;
|
||||
// Layout (per Siemens function manual §"SSL-ID 0132H, Index 5"):
|
||||
// u16 index | u16 reserved | u32 avgMs | u32 minMs | u32 maxMs
|
||||
var avg = BinaryPrimitives.ReadUInt32BigEndian(payload.AsSpan(recOff + 4, 4));
|
||||
var min = BinaryPrimitives.ReadUInt32BigEndian(payload.AsSpan(recOff + 8, 4));
|
||||
var max = BinaryPrimitives.ReadUInt32BigEndian(payload.AsSpan(recOff + 12, 4));
|
||||
|
||||
return new S7CycleStats(MinMs: min, MaxMs: max, AvgMs: avg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse SZL 0x00A0 (diagnostic buffer). Each record is 20 bytes:
|
||||
/// <list type="bullet">
|
||||
/// <item>EventId (UInt16 BE) — Siemens-defined event code, see manual</item>
|
||||
/// <item>Priority (UInt8) — alarm priority class 0–26 (S7-1500: 0–26)</item>
|
||||
/// <item>OB number (UInt8) — OB the event triggered (or 0 if no OB)</item>
|
||||
/// <item>DatId (UInt16 BE) — event-class group (FB / OB / async / …)</item>
|
||||
/// <item>Info1 (UInt16 BE) — event-specific extra info (e.g. block number)</item>
|
||||
/// <item>Info2 (UInt32 BE) — event-specific extra info</item>
|
||||
/// <item>TimeStamp (8 bytes BCD — IEC year/month/day/hour/minute/second/ms)</item>
|
||||
/// </list>
|
||||
/// Returns <em>up to</em> <paramref name="maxEntries"/> entries (capped at
|
||||
/// <see cref="MaxDiagBufferEntriesPerResponse"/>) so a malformed payload claiming
|
||||
/// a huge count can't blow the test allocator.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<S7DiagBufferEntry> ParseDiagBuffer(byte[] payload, int maxEntries)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
if (maxEntries < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxEntries), maxEntries, "maxEntries must be ≥ 0");
|
||||
|
||||
EnsureHeader(payload, out _, out _, out var lenThdr, out var nDr);
|
||||
if (lenThdr != 20)
|
||||
throw new ArgumentException(
|
||||
$"S7 SZL 0x00A0 expected record length 20, got {lenThdr}", nameof(payload));
|
||||
|
||||
var cap = Math.Min(Math.Min(maxEntries, nDr), MaxDiagBufferEntriesPerResponse);
|
||||
var expected = HeaderLength + lenThdr * cap;
|
||||
if (payload.Length < expected)
|
||||
throw new ArgumentException(
|
||||
$"S7 SZL 0x00A0 payload truncated: need ≥ {expected} bytes for {cap} entries, " +
|
||||
$"got {payload.Length}", nameof(payload));
|
||||
|
||||
var entries = new S7DiagBufferEntry[cap];
|
||||
for (var i = 0; i < cap; i++)
|
||||
{
|
||||
var off = HeaderLength + i * lenThdr;
|
||||
var eventId = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(off, 2));
|
||||
var priority = payload[off + 2];
|
||||
// payload[off+3] = OB number (kept implicit in EventText below)
|
||||
// payload[off+4..6] = DatId, payload[off+6..8] = Info1, payload[off+8..12] = Info2
|
||||
// payload[off+12..20] = BCD timestamp.
|
||||
var ts = DecodeBcdTimestamp(payload.AsSpan(off + 12, 8));
|
||||
entries[i] = new S7DiagBufferEntry(
|
||||
OccurrenceUtc: ts,
|
||||
EventId: eventId,
|
||||
Priority: priority,
|
||||
EventText: $"Event 0x{eventId:X4} (priority {priority})");
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encode a parsed <see cref="S7CpuInfo"/> back into a SZL 0x0011 byte payload.
|
||||
/// Round-trip helper used by the parser unit tests so encode-then-decode is the
|
||||
/// identity. Not used at runtime — the driver only ever decodes responses.
|
||||
/// </summary>
|
||||
public static byte[] EncodeCpuInfo(S7CpuInfo info, ushort szlId = S7SzlIds.ModuleIdentification)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(info);
|
||||
const int LenThdr = 28;
|
||||
const int NDr = 3;
|
||||
var buf = new byte[HeaderLength + LenThdr * NDr];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), szlId);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(2, 2), 0x0000);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), LenThdr);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), NDr);
|
||||
|
||||
// Record 0: index 0x0001 — MLFB / order number
|
||||
WriteRecord(buf.AsSpan(HeaderLength, LenThdr), 0x0001, info.OrderNo, ausbg1: 0, ausbg2: 0);
|
||||
// Record 1: index 0x0006 — firmware version
|
||||
var (a1, a2) = ParseFirmwareString(info.Firmware);
|
||||
WriteRecord(buf.AsSpan(HeaderLength + LenThdr, LenThdr), 0x0006, "", ausbg1: a1, ausbg2: a2);
|
||||
// Record 2: index 0x0007 — CPU type as ASCII
|
||||
WriteRecord(buf.AsSpan(HeaderLength + LenThdr * 2, LenThdr), 0x0007, info.CpuType, ausbg1: 0, ausbg2: 0);
|
||||
return buf;
|
||||
|
||||
static void WriteRecord(Span<byte> rec, ushort idx, string mlfb, ushort ausbg1, ushort ausbg2)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt16BigEndian(rec[..2], idx);
|
||||
// 20-byte ASCII space-padded MLFB
|
||||
rec[2..22].Fill((byte)' ');
|
||||
var bytes = Encoding.ASCII.GetBytes(mlfb ?? "");
|
||||
var copy = Math.Min(bytes.Length, 20);
|
||||
bytes.AsSpan(0, copy).CopyTo(rec[2..(2 + copy)]);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(rec.Slice(22, 2), 0); // BGTyp
|
||||
BinaryPrimitives.WriteUInt16BigEndian(rec.Slice(24, 2), ausbg1);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(rec.Slice(26, 2), ausbg2);
|
||||
}
|
||||
|
||||
static (ushort, ushort) ParseFirmwareString(string fw)
|
||||
{
|
||||
// Best-effort parse of "Vmaj.min.patch" — falls back to zeros so a parse failure
|
||||
// doesn't break round-trip tests for hand-crafted CpuInfo records.
|
||||
if (string.IsNullOrEmpty(fw)) return (0, 0);
|
||||
var s = fw.StartsWith('V') ? fw[1..] : fw;
|
||||
var parts = s.Split('.');
|
||||
byte maj = 0, min = 0, patch = 0;
|
||||
if (parts.Length > 0) byte.TryParse(parts[0], out maj);
|
||||
if (parts.Length > 1) byte.TryParse(parts[1], out min);
|
||||
if (parts.Length > 2) byte.TryParse(parts[2], out patch);
|
||||
return ((ushort)((maj << 8) | min), (ushort)(patch << 8));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Encode a <see cref="S7CycleStats"/> back into a SZL 0x0132 byte payload (round-trip helper).</summary>
|
||||
public static byte[] EncodeCycleStats(S7CycleStats stats, ushort szlId = S7SzlIds.CpuStatusData)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stats);
|
||||
const int LenThdr = 16;
|
||||
const int NDr = 1;
|
||||
var buf = new byte[HeaderLength + LenThdr * NDr];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), szlId);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(2, 2), S7SzlIds.CpuStatusCycleTimeIndex);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), LenThdr);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), NDr);
|
||||
|
||||
var rec = buf.AsSpan(HeaderLength);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(rec[..2], S7SzlIds.CpuStatusCycleTimeIndex);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(rec.Slice(2, 2), 0);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(rec.Slice(4, 4), (uint)stats.AvgMs);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(rec.Slice(8, 4), (uint)stats.MinMs);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(rec.Slice(12, 4), (uint)stats.MaxMs);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/// <summary>Encode a list of <see cref="S7DiagBufferEntry"/> back into a SZL 0x00A0 byte payload (round-trip helper).</summary>
|
||||
public static byte[] EncodeDiagBuffer(IReadOnlyList<S7DiagBufferEntry> entries)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
const int LenThdr = 20;
|
||||
var buf = new byte[HeaderLength + LenThdr * entries.Count];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), S7SzlIds.DiagnosticBuffer);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(2, 2), 0);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), LenThdr);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), (ushort)entries.Count);
|
||||
for (var i = 0; i < entries.Count; i++)
|
||||
{
|
||||
var rec = buf.AsSpan(HeaderLength + i * LenThdr, LenThdr);
|
||||
var e = entries[i];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(rec[..2], e.EventId);
|
||||
rec[2] = e.Priority;
|
||||
rec[3] = 0; // OB number — not surfaced
|
||||
BinaryPrimitives.WriteUInt16BigEndian(rec.Slice(4, 2), 0);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(rec.Slice(6, 2), 0);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(rec.Slice(8, 4), 0);
|
||||
EncodeBcdTimestamp(e.OccurrenceUtc, rec.Slice(12, 8));
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
private static void EnsureHeader(byte[] payload, out ushort szlId, out ushort szlIndex, out ushort lenThdr, out ushort nDr)
|
||||
{
|
||||
if (payload.Length < HeaderLength)
|
||||
throw new ArgumentException(
|
||||
$"S7 SZL payload truncated: need at least {HeaderLength}-byte header, got {payload.Length}", nameof(payload));
|
||||
szlId = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(0, 2));
|
||||
szlIndex = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(2, 2));
|
||||
lenThdr = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(4, 2));
|
||||
nDr = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(6, 2));
|
||||
}
|
||||
|
||||
private static string TrimAscii(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
var s = Encoding.ASCII.GetString(bytes);
|
||||
return s.TrimEnd(' ', '\0');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort CPU type derivation from MLFB. The MLFB encodes the CPU model — e.g.
|
||||
/// <c>6ES7 516-3AN01-0AB0</c> identifies a CPU 1516-3 PN/DP. Without a full lookup
|
||||
/// table we just return the MLFB so operators can grep the manual; SZL index 0x0007
|
||||
/// overrides this when the CPU surfaces a friendly name there.
|
||||
/// </summary>
|
||||
private static string DeriveCpuTypeFromMlfb(string mlfb) => mlfb;
|
||||
|
||||
/// <summary>
|
||||
/// Decode an 8-byte BCD timestamp (Siemens IEC representation):
|
||||
/// <c>year(2) month(1) day(1) hour(1) minute(1) second(1) ms-day-of-week(2)</c>.
|
||||
/// The last 2 bytes pack three BCD ms digits and a day-of-week nibble.
|
||||
/// </summary>
|
||||
private static DateTimeOffset DecodeBcdTimestamp(ReadOnlySpan<byte> b)
|
||||
{
|
||||
// Year: 2-byte BCD (e.g. 0x20 0x24 = 2024)
|
||||
var year = FromBcd(b[0]) * 100 + FromBcd(b[1]);
|
||||
var month = Math.Clamp(FromBcd(b[2]), 1, 12);
|
||||
var day = Math.Clamp(FromBcd(b[3]), 1, 31);
|
||||
var hour = Math.Clamp(FromBcd(b[4]), 0, 23);
|
||||
var minute = Math.Clamp(FromBcd(b[5]), 0, 59);
|
||||
var second = Math.Clamp(FromBcd(b[6]), 0, 59);
|
||||
// ms: high nibble of b[7] = first ms digit, low nibble of b[7] is reserved /
|
||||
// day-of-week. Some CPUs pack three ms digits across b[7] high/low + the high
|
||||
// nibble of the last byte; per Siemens function manual the simplest portable
|
||||
// decode is to drop ms and surface only second-precision.
|
||||
var ms = 0;
|
||||
|
||||
try
|
||||
{
|
||||
return new DateTimeOffset(year, month, day, hour, minute, second, ms, TimeSpan.Zero);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
// Malformed timestamp — surface as epoch rather than throw so a single bad
|
||||
// entry doesn't take out the whole diag-buffer parse.
|
||||
return DateTimeOffset.UnixEpoch;
|
||||
}
|
||||
|
||||
static int FromBcd(byte v) => ((v >> 4) & 0xF) * 10 + (v & 0xF);
|
||||
}
|
||||
|
||||
private static void EncodeBcdTimestamp(DateTimeOffset ts, Span<byte> dst)
|
||||
{
|
||||
var u = ts.UtcDateTime;
|
||||
dst[0] = ToBcd(u.Year / 100);
|
||||
dst[1] = ToBcd(u.Year % 100);
|
||||
dst[2] = ToBcd(u.Month);
|
||||
dst[3] = ToBcd(u.Day);
|
||||
dst[4] = ToBcd(u.Hour);
|
||||
dst[5] = ToBcd(u.Minute);
|
||||
dst[6] = ToBcd(u.Second);
|
||||
dst[7] = 0; // ms / day-of-week — not round-tripped
|
||||
static byte ToBcd(int v) => (byte)(((v / 10) << 4) | (v % 10));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>CPU identification parsed from SZL 0x0011.</summary>
|
||||
/// <param name="CpuType">Marketing / friendly CPU name from SZL index 0x0007 (or MLFB fallback).</param>
|
||||
/// <param name="Firmware">Firmware version, formatted "Vmaj.min.patch".</param>
|
||||
/// <param name="OrderNo">MLFB / order number from SZL index 0x0001 (e.g. "6ES7 516-3AN01-0AB0").</param>
|
||||
public sealed record S7CpuInfo(string CpuType, string Firmware, string OrderNo);
|
||||
|
||||
/// <summary>CPU cycle-time statistics parsed from SZL 0x0132 / 0x0432 — values in milliseconds.</summary>
|
||||
/// <param name="MinMs">Shortest scan cycle observed since last reset.</param>
|
||||
/// <param name="MaxMs">Longest scan cycle observed since last reset.</param>
|
||||
/// <param name="AvgMs">Rolling average scan-cycle time.</param>
|
||||
public sealed record S7CycleStats(double MinMs, double MaxMs, double AvgMs);
|
||||
|
||||
/// <summary>One diagnostic-buffer entry parsed from SZL 0x00A0.</summary>
|
||||
/// <param name="OccurrenceUtc">Event timestamp decoded from the BCD timestamp field (UTC).</param>
|
||||
/// <param name="EventId">Siemens event code (e.g. 0x113A = "communication initiated").</param>
|
||||
/// <param name="Priority">Alarm priority class 0–26.</param>
|
||||
/// <param name="EventText">Human-readable rendering of the event — currently the raw 0x???? code; future PR can plug a lookup table.</param>
|
||||
public sealed record S7DiagBufferEntry(
|
||||
DateTimeOffset OccurrenceUtc,
|
||||
ushort EventId,
|
||||
byte Priority,
|
||||
string EventText);
|
||||
@@ -19,6 +19,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="S7netplus" Version="0.20.0"/>
|
||||
<!-- PR-S7-D1 / #299 — TiaCsvImporter + AwlImporter log skipped/malformed 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>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.1 / #316 — stream TC3 EventLogger alarms to the terminal until Ctrl+C.
|
||||
/// Mirrors the OPC UA Client CLI <c>alarms</c> verb shape: subscribe + print every
|
||||
/// incoming <see cref="AlarmEventArgs"/> with timestamp, source, severity, and
|
||||
/// message text. Requires <c>EnableAlarms=true</c> on the driver — set via the
|
||||
/// base options builder when the verb is selected.
|
||||
/// </summary>
|
||||
[Command("alarms", Description =
|
||||
"Subscribe to TC3 EventLogger alarms via the driver's IAlarmSource bridge and " +
|
||||
"stream events to stdout until Ctrl+C.")]
|
||||
public sealed class AlarmsCommand : TwinCATCommandBase
|
||||
{
|
||||
[CommandOption("source", Description =
|
||||
"Optional alarm source filter (matched case-insensitively against the event's " +
|
||||
"Source field). Repeat the flag to match multiple sources; omit to subscribe to " +
|
||||
"every event the EventLogger surfaces.")]
|
||||
public IReadOnlyList<string> Sources { get; init; } = Array.Empty<string>();
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
// Empty Tags + EnableAlarms=true builds a driver that opens only the alarm path.
|
||||
// TwinCATDriverOptions is a regular class with init-only properties — rebuild
|
||||
// the instance instead of using a record-style `with` clone.
|
||||
var baseOptions = BuildOptions([]);
|
||||
var options = new TwinCATDriverOptions
|
||||
{
|
||||
Devices = baseOptions.Devices,
|
||||
Tags = baseOptions.Tags,
|
||||
Probe = baseOptions.Probe,
|
||||
Timeout = baseOptions.Timeout,
|
||||
UseNativeNotifications = baseOptions.UseNativeNotifications,
|
||||
EnableControllerBrowse = baseOptions.EnableControllerBrowse,
|
||||
MaxArrayExpansion = baseOptions.MaxArrayExpansion,
|
||||
EnableAlarms = true,
|
||||
};
|
||||
|
||||
await using var driver = new TwinCATDriver(options, DriverInstanceId);
|
||||
IAlarmSubscriptionHandle? handle = null;
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
driver.OnAlarmEvent += (_, e) =>
|
||||
{
|
||||
var line =
|
||||
$"[{e.SourceTimestampUtc:HH:mm:ss.fff}] " +
|
||||
$"{e.SourceNodeId} " +
|
||||
$"sev={e.Severity} " +
|
||||
$"type={e.AlarmType} " +
|
||||
$"cond={e.ConditionId} " +
|
||||
$"\"{e.Message}\"";
|
||||
console.Output.WriteLine(line);
|
||||
};
|
||||
|
||||
handle = await driver.SubscribeAlarmsAsync(Sources, ct);
|
||||
|
||||
var filterDesc = Sources.Count == 0
|
||||
? "all sources"
|
||||
: $"sources [{string.Join(", ", Sources)}]";
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Subscribed to TC3 EventLogger alarms on {AmsNetId}:{AmsPort} ({filterDesc}). Ctrl+C to stop.");
|
||||
await console.Output.WriteLineAsync(
|
||||
"Note: Beckhoff doesn't ship a managed TcEventLogger wrapper; the driver " +
|
||||
"uses a best-effort decode of AMS port 110 notifications. Some fields may " +
|
||||
"surface as 'Unknown' until a binary-protocol decoder lands.");
|
||||
try
|
||||
{
|
||||
await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected on Ctrl+C.
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (handle is not null)
|
||||
{
|
||||
try { await driver.UnsubscribeAlarmsAsync(handle, CancellationToken.None); }
|
||||
catch { /* teardown best-effort */ }
|
||||
}
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -485,21 +485,39 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(
|
||||
int maxArrayExpansion,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
// SymbolLoaderFactory downloads the symbol-info blob once then iterates locally — the
|
||||
// async surface on this interface is for our callers, not for the underlying call which
|
||||
// is effectively sync on top of the already-open AdsClient.
|
||||
var settings = new SymbolLoaderSettings(SymbolsLoadMode.Flat);
|
||||
//
|
||||
// PR 4.1 / #315 — switched from VirtualView-flat (top-level only) to VirtualTree so the
|
||||
// loader hands us each top-level symbol with its full IDataType graph wired up. We then
|
||||
// run TwinCATTypeWalker against each root to flatten structs / arrays into per-leaf
|
||||
// entries. Atomic top-level symbols still surface as a single leaf — the walker treats
|
||||
// a primitive root as a 1-leaf walk.
|
||||
var settings = new SymbolLoaderSettings(SymbolsLoadMode.VirtualTree);
|
||||
var loader = SymbolLoaderFactory.Create(_client, settings);
|
||||
await Task.Yield(); // honors the async surface; pragmatic given the loader itself is sync
|
||||
|
||||
// The walker only needs MaxArrayExpansion off TwinCATDriverOptions — synthesise a
|
||||
// throwaway options instance with just that field set so the walker doesn't gain a
|
||||
// direct dependency on TwinCATDriverOptions.
|
||||
var walkerOptions = new TwinCATDriverOptions { MaxArrayExpansion = Math.Max(1, maxArrayExpansion) };
|
||||
|
||||
foreach (ISymbol symbol in loader.Symbols)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) yield break;
|
||||
var mapped = ResolveSymbolDataType(symbol.DataType);
|
||||
var readOnly = !IsSymbolWritable(symbol);
|
||||
yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly);
|
||||
foreach (var leaf in TwinCATTypeWalker.Walk(
|
||||
symbol.DataType, symbol.InstancePath, offsetRoot: 0, readOnly, walkerOptions))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) yield break;
|
||||
yield return new TwinCATDiscoveredSymbol(
|
||||
leaf.InstancePath, leaf.AtomicType, leaf.ReadOnly,
|
||||
leaf.IsArrayRoot, leaf.ArrayLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,13 +115,22 @@ public interface ITwinCATClient : IDisposable
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Walk the target's symbol table via the TwinCAT <c>SymbolLoaderFactory</c> (flat mode).
|
||||
/// Yields each top-level symbol the PLC exposes — global variables, program-scope locals,
|
||||
/// function-block instance fields. Filters for our atomic type surface; structured /
|
||||
/// UDT / function-block typed symbols surface with <c>DataType = null</c> so callers can
|
||||
/// decide whether to drill in via their own walker.
|
||||
/// Walk the target's symbol table via the TwinCAT <c>SymbolLoaderFactory</c> (flat mode)
|
||||
/// and yield one <see cref="TwinCATDiscoveredSymbol"/> per atomic leaf. PR 4.1 / #315
|
||||
/// extended this to recurse into struct / UDT members and array elements via
|
||||
/// <see cref="TwinCATTypeWalker"/> so callers see <c>MyStruct.Inner.Field</c> /
|
||||
/// <c>aTags[3]</c> rows instead of the parent symbol with <c>DataType = null</c>.
|
||||
/// </summary>
|
||||
IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(CancellationToken cancellationToken);
|
||||
/// <param name="maxArrayExpansion">
|
||||
/// Upper bound on per-element expansion for array-typed members. Arrays whose element
|
||||
/// count exceeds this value surface as a single whole-array root with
|
||||
/// <see cref="TwinCATDiscoveredSymbol.IsArrayRoot"/> set instead of N individual
|
||||
/// <c>Path[i]</c> rows.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancels the enumeration; in-flight loader downloads still complete.</param>
|
||||
IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(
|
||||
int maxArrayExpansion,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// PR 2.2 — wipe process-scoped optional caches (today: the ADS variable-handle
|
||||
@@ -139,16 +148,28 @@ public interface ITwinCATNotificationHandle : IDisposable { }
|
||||
|
||||
/// <summary>
|
||||
/// One symbol yielded by <see cref="ITwinCATClient.BrowseSymbolsAsync"/> — full instance
|
||||
/// path + detected <see cref="TwinCATDataType"/> + read-only flag.
|
||||
/// path + detected <see cref="TwinCATDataType"/> + read-only flag. PR 4.1 / #315 added
|
||||
/// <see cref="IsArrayRoot"/> + <see cref="ArrayLength"/> so the discovery layer can
|
||||
/// surface arrays that exceeded <see cref="TwinCATDriverOptions.MaxArrayExpansion"/>
|
||||
/// as whole-array tags instead of per-element entries.
|
||||
/// </summary>
|
||||
/// <param name="InstancePath">Full dotted symbol path (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>).</param>
|
||||
/// <param name="DataType">Mapped <see cref="TwinCATDataType"/>; <c>null</c> when the symbol's type
|
||||
/// doesn't map onto our supported atomic surface (UDTs, pointers, function blocks).</param>
|
||||
/// <param name="InstancePath">Full dotted symbol path (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>,
|
||||
/// <c>MyStruct.Inner.Field</c>, <c>aTags[3]</c>).</param>
|
||||
/// <param name="DataType">Mapped <see cref="TwinCATDataType"/>; <c>null</c> when the symbol's
|
||||
/// type doesn't map onto our supported atomic surface (struct-typed array root over the
|
||||
/// expansion cap, pointer / reference / function-block instance).</param>
|
||||
/// <param name="ReadOnly"><c>true</c> when the symbol's AccessRights flag forbids writes.</param>
|
||||
/// <param name="IsArrayRoot"><c>true</c> when the symbol represents an array whose element count
|
||||
/// exceeded <see cref="TwinCATDriverOptions.MaxArrayExpansion"/>; the caller should surface
|
||||
/// it as a whole-array tag rather than per-element.</param>
|
||||
/// <param name="ArrayLength">Element count when <see cref="IsArrayRoot"/> is <c>true</c>;
|
||||
/// <c>null</c> otherwise.</param>
|
||||
public sealed record TwinCATDiscoveredSymbol(
|
||||
string InstancePath,
|
||||
TwinCATDataType? DataType,
|
||||
bool ReadOnly);
|
||||
bool ReadOnly,
|
||||
bool IsArrayRoot = false,
|
||||
int? ArrayLength = null);
|
||||
|
||||
/// <summary>Factory for <see cref="ITwinCATClient"/>s. One client per device.</summary>
|
||||
public interface ITwinCATClientFactory
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.1 / #316 — single TC3 EventLogger event payload exposed by
|
||||
/// <see cref="ITwinCATAlarmGate"/> + projected onto
|
||||
/// <see cref="AlarmEventArgs"/> for the driver's <see cref="IAlarmSource"/> surface.
|
||||
/// Carries the four fields the EventLogger surfaces on the wire (event class GUID /
|
||||
/// source name / severity / message text) plus the originating timestamp + an
|
||||
/// <c>Acked</c> flag so a re-fired event after operator acknowledgement is
|
||||
/// distinguishable from a fresh raise.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Beckhoff doesn't ship a managed <c>TcEventLogger</c> wrapper in the regular
|
||||
/// <c>Beckhoff.TwinCAT.Ads</c> v6 NuGet (the C++ TcCOM headers exist, the .NET ones
|
||||
/// don't — see <c>docs/v3/twincat-eventlogger-spike.md</c>). The production gate
|
||||
/// therefore opens a second <see cref="ITwinCATClient"/> against AMS port 110
|
||||
/// (<c>AMSPORT_EVENTLOG</c>) + adds a device notification on
|
||||
/// <c>ADSIGRP_TCEVENTLOG_ALARMS</c>; the binary-protocol decode is best-effort and
|
||||
/// unrecognised fields surface as <c>"Unknown"</c>.
|
||||
/// </remarks>
|
||||
public sealed record TwinCATAlarmEvent(
|
||||
string EventClass,
|
||||
string Source,
|
||||
ushort Severity,
|
||||
string Message,
|
||||
DateTimeOffset OccurrenceUtc,
|
||||
bool Acked);
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.1 / #316 — driver-internal seam for the TC3 EventLogger wire path. Production
|
||||
/// opens a second <see cref="ITwinCATClient"/> against AMS port 110 + adds a device
|
||||
/// notification on the alarm-list index group; the binary-protocol decoder lands on
|
||||
/// a follow-up PR (see <c>docs/v3/twincat-eventlogger-spike.md</c>). Tests substitute
|
||||
/// a fake gate to drive synthetic events.
|
||||
/// </summary>
|
||||
public interface ITwinCATAlarmGate : IDisposable
|
||||
{
|
||||
/// <summary>Connect / register the device-notification subscription.</summary>
|
||||
Task StartAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Fired by the gate for every alarm transition the EventLogger surfaces (raise /
|
||||
/// clear / acknowledge). The driver's <see cref="TwinCATAlarmSource"/> projects this
|
||||
/// onto <see cref="AlarmEventArgs"/> for every active subscription.
|
||||
/// </summary>
|
||||
event EventHandler<TwinCATAlarmEvent>? OnAlarmEvent;
|
||||
|
||||
/// <summary>
|
||||
/// Issue an acknowledge against the EventLogger for the supplied source / condition.
|
||||
/// Best-effort — the wire format is undocumented in managed code; the production
|
||||
/// gate writes the acknowledge index-group when reachable + returns silently otherwise.
|
||||
/// </summary>
|
||||
Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Snapshot of currently-active alarms — empty when the gate has no events buffered.</summary>
|
||||
IReadOnlyList<TwinCATAlarmEvent> ActiveAlarms { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.1 / #316 — default no-op alarm gate. Keeps the construction path simple for
|
||||
/// deployments without TcEventLogger configured + for unit tests that exercise the
|
||||
/// <c>EnableAlarms=false</c> short-circuit. <see cref="OnAlarmEvent"/> never fires;
|
||||
/// <see cref="AcknowledgeAsync"/> is a no-op.
|
||||
/// </summary>
|
||||
internal sealed class NullTwinCATAlarmGate : ITwinCATAlarmGate
|
||||
{
|
||||
public IReadOnlyList<TwinCATAlarmEvent> ActiveAlarms => Array.Empty<TwinCATAlarmEvent>();
|
||||
|
||||
public event EventHandler<TwinCATAlarmEvent>? OnAlarmEvent;
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Touch the event so the unused-warning analyzer keeps quiet without suppressing
|
||||
// the diagnostic outright. The handler list stays empty in production.
|
||||
_ = OnAlarmEvent;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
|
||||
CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.1 / #316 — projects <see cref="ITwinCATAlarmGate"/> events onto the driver's
|
||||
/// <see cref="IAlarmSource"/> surface. Subscriptions filter by source-node id (each
|
||||
/// id is matched against <see cref="TwinCATAlarmEvent.Source"/>); an empty filter list
|
||||
/// subscribes to every event.
|
||||
/// </summary>
|
||||
internal sealed class TwinCATAlarmSource : IAsyncDisposable
|
||||
{
|
||||
private readonly TwinCATDriver _driver;
|
||||
private readonly ITwinCATAlarmGate _gate;
|
||||
private readonly ConcurrentDictionary<long, Subscription> _subs = new();
|
||||
private long _nextId;
|
||||
private bool _started;
|
||||
private readonly Lock _startLock = new();
|
||||
|
||||
public TwinCATAlarmSource(TwinCATDriver driver, ITwinCATAlarmGate gate)
|
||||
{
|
||||
_driver = driver;
|
||||
_gate = gate;
|
||||
_gate.OnAlarmEvent += OnGateAlarm;
|
||||
}
|
||||
|
||||
public async Task<IAlarmSubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextId);
|
||||
var handle = new TwinCATAlarmSubscriptionHandle(id);
|
||||
_subs[id] = new Subscription(handle, [..sourceNodeIds]);
|
||||
|
||||
// First subscription wins the start race; subsequent subscribes find the gate
|
||||
// already connected. Single-shot start keeps the AMS-port-110 session count to
|
||||
// exactly one per driver instance.
|
||||
var shouldStart = false;
|
||||
lock (_startLock)
|
||||
{
|
||||
if (!_started)
|
||||
{
|
||||
_started = true;
|
||||
shouldStart = true;
|
||||
}
|
||||
}
|
||||
if (shouldStart)
|
||||
await _gate.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
return handle;
|
||||
}
|
||||
|
||||
public Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is TwinCATAlarmSubscriptionHandle h)
|
||||
_subs.TryRemove(h.Id, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
||||
=> _gate.AcknowledgeAsync(acknowledgements, cancellationToken);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_gate.OnAlarmEvent -= OnGateAlarm;
|
||||
_subs.Clear();
|
||||
try { _gate.Dispose(); } catch { }
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Translate a raw <see cref="TwinCATAlarmEvent"/> from the gate into one
|
||||
/// <see cref="AlarmEventArgs"/> per matching subscription. Source-node-id filters
|
||||
/// match on case-insensitive equality against <see cref="TwinCATAlarmEvent.Source"/>;
|
||||
/// an empty subscription filter list passes every event through.
|
||||
/// </summary>
|
||||
private void OnGateAlarm(object? sender, TwinCATAlarmEvent evt)
|
||||
{
|
||||
var conditionId = $"{evt.Source}#{evt.EventClass}";
|
||||
var sourceTimestamp = evt.OccurrenceUtc.UtcDateTime;
|
||||
foreach (var sub in _subs.Values)
|
||||
{
|
||||
if (!sub.Matches(evt.Source)) continue;
|
||||
var args = new AlarmEventArgs(
|
||||
SubscriptionHandle: sub.Handle,
|
||||
SourceNodeId: evt.Source,
|
||||
ConditionId: conditionId,
|
||||
AlarmType: evt.EventClass,
|
||||
Message: evt.Message,
|
||||
Severity: MapSeverity(evt.Severity),
|
||||
SourceTimestampUtc: sourceTimestamp);
|
||||
_driver.InvokeAlarmEvent(args);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map TC3 EventLogger 0–255 severity values onto the driver-agnostic
|
||||
/// <see cref="AlarmSeverity"/> bucket. Buckets follow the same low-quartile cuts the
|
||||
/// OPC UA AC mapping in <c>docs/drivers/TwinCAT.md</c> documents.
|
||||
/// </summary>
|
||||
internal static AlarmSeverity MapSeverity(ushort raw) => raw switch
|
||||
{
|
||||
<= 64 => AlarmSeverity.Low,
|
||||
<= 128 => AlarmSeverity.Medium,
|
||||
<= 192 => AlarmSeverity.High,
|
||||
_ => AlarmSeverity.Critical,
|
||||
};
|
||||
|
||||
private sealed class Subscription
|
||||
{
|
||||
public Subscription(TwinCATAlarmSubscriptionHandle handle, IReadOnlyList<string> sourceFilters)
|
||||
{
|
||||
Handle = handle;
|
||||
SourceFilters = sourceFilters;
|
||||
}
|
||||
|
||||
public TwinCATAlarmSubscriptionHandle Handle { get; }
|
||||
public IReadOnlyList<string> SourceFilters { get; }
|
||||
|
||||
public bool Matches(string source)
|
||||
{
|
||||
if (SourceFilters.Count == 0) return true; // wildcard
|
||||
for (var i = 0; i < SourceFilters.Count; i++)
|
||||
{
|
||||
if (string.Equals(SourceFilters[i], source, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.1 / #316 — handle returned by <see cref="TwinCATAlarmSource.SubscribeAsync"/>.
|
||||
/// </summary>
|
||||
public sealed record TwinCATAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => $"twincat-alarm-sub-{Id}";
|
||||
}
|
||||
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
/// resolver land in PRs 2 and 3.
|
||||
/// </summary>
|
||||
public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly TwinCATDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
@@ -17,13 +17,25 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
private readonly PollGroupEngine _poll;
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, TwinCATTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TwinCATAlarmSource? _alarmSource;
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.1 / #316 — internal seam for <see cref="TwinCATAlarmSource"/> to raise
|
||||
/// <see cref="OnAlarmEvent"/> against the driver's public surface. Mirrors the
|
||||
/// <see cref="OnDataChange"/> raise pattern so capability invokers see one event
|
||||
/// source per <c>IAlarmSource</c> driver instance regardless of how many internal
|
||||
/// subscriptions are open.
|
||||
/// </summary>
|
||||
internal void InvokeAlarmEvent(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
|
||||
|
||||
public TwinCATDriver(TwinCATDriverOptions options, string driverInstanceId,
|
||||
ITwinCATClientFactory? clientFactory = null)
|
||||
ITwinCATClientFactory? clientFactory = null,
|
||||
ITwinCATAlarmGate? alarmGate = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
@@ -33,6 +45,16 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
reader: ReadAsync,
|
||||
onChange: (handle, tagRef, snapshot) =>
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)));
|
||||
|
||||
// PR 5.1 / #316 — only stand up the alarm source when the option is on. With the
|
||||
// option off, IAlarmSource.SubscribeAlarmsAsync returns a no-op handle so capability
|
||||
// negotiation still works without paying for the second AMS session against port
|
||||
// 110 / TcEventLogger that the production gate would open.
|
||||
if (_options.EnableAlarms)
|
||||
{
|
||||
var gate = alarmGate ?? new NullTwinCATAlarmGate();
|
||||
_alarmSource = new TwinCATAlarmSource(this, gate);
|
||||
}
|
||||
}
|
||||
|
||||
public string DriverInstanceId => _driverInstanceId;
|
||||
@@ -85,6 +107,16 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
foreach (var r in sub.Registrations) { try { r.Dispose(); } catch { } }
|
||||
_nativeSubs.Clear();
|
||||
|
||||
// PR 5.1 / #316 — alarm source teardown. Disposes the gate (closes the second
|
||||
// AMS-port-110 client + drops the device-notification handle in the production
|
||||
// path) + clears the subscription bookkeeping. Best-effort — a flaky teardown
|
||||
// should not block shutdown of the wire-data path.
|
||||
if (_alarmSource is not null)
|
||||
{
|
||||
try { await _alarmSource.DisposeAsync().ConfigureAwait(false); }
|
||||
catch { /* swallow — see comment above */ }
|
||||
}
|
||||
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
foreach (var state in _devices.Values)
|
||||
{
|
||||
@@ -408,23 +440,42 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
|
||||
// Controller-side symbol browse — opt-in. Falls back to pre-declared-only on any
|
||||
// client-side error so a flaky symbol-table download doesn't block discovery.
|
||||
//
|
||||
// PR 4.1 / #315 — the symbol stream now contains one entry per atomic leaf rather
|
||||
// than per top-level symbol. Dotted paths (`MyStruct.Inner.Field`) split into
|
||||
// nested folders so the OPC UA browse tree mirrors the PLC's UDT hierarchy. Array
|
||||
// elements (`aTags[3]`) stay as variable leaves under the parent folder — they
|
||||
// don't get an extra folder level because that would explode the tree for every
|
||||
// array element while adding no useful navigation.
|
||||
if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
|
||||
{
|
||||
IAddressSpaceBuilder? discoveredFolder = null;
|
||||
// Cached per-path folder builders so a struct with N members shares the parent
|
||||
// folder rather than creating it N times. Keyed on the full dotted prefix.
|
||||
var folderCache = new Dictionary<string, IAddressSpaceBuilder>(StringComparer.Ordinal);
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(state, cancellationToken).ConfigureAwait(false);
|
||||
await foreach (var sym in client.BrowseSymbolsAsync(cancellationToken).ConfigureAwait(false))
|
||||
await foreach (var sym in client.BrowseSymbolsAsync(_options.MaxArrayExpansion, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (TwinCATSystemSymbolFilter.IsSystemSymbol(sym.InstancePath)) continue;
|
||||
if (sym.DataType is not TwinCATDataType dt) continue; // unsupported type
|
||||
|
||||
// Array root over the expansion cap surfaces with IsArrayRoot=true; the
|
||||
// element type may or may not be atomic. Skip unless the element is
|
||||
// atomic — there's no useful read shape for a struct-of-N>cap today.
|
||||
if (sym.IsArrayRoot && sym.DataType is null) continue;
|
||||
if (sym.DataType is not TwinCATDataType dt) continue; // unsupported leaf type
|
||||
|
||||
discoveredFolder ??= deviceFolder.Folder("Discovered", "Discovered");
|
||||
discoveredFolder.Variable(sym.InstancePath, sym.InstancePath, new DriverAttributeInfo(
|
||||
var (parentBuilder, leafName) = ResolveLeafFolder(
|
||||
discoveredFolder, sym.InstancePath, folderCache);
|
||||
|
||||
parentBuilder.Variable(leafName, leafName, new DriverAttributeInfo(
|
||||
FullName: sym.InstancePath,
|
||||
DriverDataType: dt.ToDriverDataType(),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
IsArray: sym.IsArrayRoot,
|
||||
ArrayDim: sym.IsArrayRoot && sym.ArrayLength is int n && n > 0
|
||||
? (uint)n : null,
|
||||
SecurityClass: sym.ReadOnly
|
||||
? SecurityClassification.ViewOnly
|
||||
: SecurityClassification.Operate,
|
||||
@@ -443,6 +494,48 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR 4.1 / #315 — split a dotted instance path into (parent folder, leaf segment).
|
||||
/// Each interior segment maps to a folder; the final segment is the variable name.
|
||||
/// Folders are cached so two members of the same struct share the parent. Array
|
||||
/// subscripts on interior segments (e.g. <c>aMotors[0].Status.Running</c>) keep the
|
||||
/// bracketed segment as a single folder name — the OPC UA browse name preserves the
|
||||
/// array-element identifier.
|
||||
/// </summary>
|
||||
internal static (IAddressSpaceBuilder Parent, string LeafName) ResolveLeafFolder(
|
||||
IAddressSpaceBuilder root, string instancePath,
|
||||
Dictionary<string, IAddressSpaceBuilder> folderCache)
|
||||
{
|
||||
var lastDot = instancePath.LastIndexOf('.');
|
||||
if (lastDot < 0)
|
||||
return (root, instancePath); // top-level — no parent folder
|
||||
|
||||
var prefix = instancePath.Substring(0, lastDot);
|
||||
var leaf = instancePath.Substring(lastDot + 1);
|
||||
|
||||
if (folderCache.TryGetValue(prefix, out var cached))
|
||||
return (cached, leaf);
|
||||
|
||||
// Walk segment-by-segment, materialising each folder once. Reuse cached prefixes so
|
||||
// sibling members share the same parent.
|
||||
var segments = prefix.Split('.');
|
||||
var current = root;
|
||||
var sb = new System.Text.StringBuilder();
|
||||
foreach (var seg in segments)
|
||||
{
|
||||
if (sb.Length > 0) sb.Append('.');
|
||||
sb.Append(seg);
|
||||
var key = sb.ToString();
|
||||
if (!folderCache.TryGetValue(key, out var folder))
|
||||
{
|
||||
folder = current.Folder(seg, seg);
|
||||
folderCache[key] = folder;
|
||||
}
|
||||
current = folder;
|
||||
}
|
||||
return (current, leaf);
|
||||
}
|
||||
|
||||
// ---- ISubscribable (native ADS notifications with poll fallback) ----
|
||||
|
||||
private readonly ConcurrentDictionary<long, NativeSubscription> _nativeSubs = new();
|
||||
@@ -747,6 +840,43 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
|
||||
}
|
||||
|
||||
// ---- IAlarmSource (TC3 EventLogger bridge, #316) ----
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.1 / #316 — subscribe to TC3 EventLogger alarms scoped to <paramref name="sourceNodeIds"/>.
|
||||
/// Each id matches against <see cref="TwinCATAlarmEvent.Source"/>; an empty list passes every
|
||||
/// event through the gate. Feature-gated — when <see cref="TwinCATDriverOptions.EnableAlarms"/>
|
||||
/// is <c>false</c> (the default), returns a sentinel handle without opening the AMS-port-110
|
||||
/// session. Capability negotiation succeeds either way + <see cref="OnAlarmEvent"/> simply
|
||||
/// never fires while the gate is disabled.
|
||||
/// </summary>
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_alarmSource is null)
|
||||
{
|
||||
// Disabled-gate sentinel — Id 0 is reserved for the no-op shape so a follow-up
|
||||
// unsubscribe doesn't accidentally remove a real subscription.
|
||||
var disabled = new TwinCATAlarmSubscriptionHandle(0);
|
||||
return Task.FromResult<IAlarmSubscriptionHandle>(disabled);
|
||||
}
|
||||
return _alarmSource.SubscribeAsync(sourceNodeIds, cancellationToken);
|
||||
}
|
||||
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) =>
|
||||
_alarmSource is null
|
||||
? Task.CompletedTask
|
||||
: _alarmSource.UnsubscribeAsync(handle, cancellationToken);
|
||||
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
||||
_alarmSource is null
|
||||
? Task.CompletedTask
|
||||
: _alarmSource.AcknowledgeAsync(acknowledgements, cancellationToken);
|
||||
|
||||
/// <summary>Test-only — <c>true</c> when an alarm source has been instantiated.</summary>
|
||||
internal bool HasAlarmSource => _alarmSource is not null;
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
public string ResolveHost(string fullReference)
|
||||
|
||||
@@ -32,6 +32,35 @@ public sealed class TwinCATDriverOptions
|
||||
/// the strict-config path for deployments where only declared tags should appear.
|
||||
/// </summary>
|
||||
public bool EnableControllerBrowse { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PR 4.1 / #315 — upper bound on per-element array expansion during nested-UDT browse.
|
||||
/// Arrays whose total <c>ElementCount</c> exceeds this value surface as a single
|
||||
/// whole-array root leaf instead of N individual <c>Path[i]</c> entries; that keeps
|
||||
/// pathological multi-thousand-element arrays from inflating the discovered address
|
||||
/// space. The default of <c>1024</c> covers typical recipe / lookup tables; raise it
|
||||
/// when the PLC routinely ships larger arrays whose individual elements are operationally
|
||||
/// interesting, lower it to keep discovery cheap. Has no effect on whole-array tags
|
||||
/// declared in <see cref="Tags"/> (those bypass the walker entirely).
|
||||
/// </summary>
|
||||
public int MaxArrayExpansion { get; init; } = 1024;
|
||||
|
||||
/// <summary>
|
||||
/// PR 5.1 / #316 — opt-in TC3 EventLogger bridge. When <c>true</c>, the driver
|
||||
/// surfaces TwinCAT alarm events via <see cref="Core.Abstractions.IAlarmSource"/> by
|
||||
/// opening a second <c>AdsClient</c> against AMS port 110 (<c>AMSPORT_EVENTLOG</c>)
|
||||
/// and adding a device notification on <c>ADSIGRP_TCEVENTLOG_ALARMS</c>. Default
|
||||
/// <c>false</c> because (a) Beckhoff doesn't ship a managed <c>TcEventLogger</c>
|
||||
/// wrapper in the regular <c>Beckhoff.TwinCAT.Ads</c> v6 NuGet, so the binary-protocol
|
||||
/// decode is best-effort and fields may surface as <c>"Unknown"</c>; (b) deployments
|
||||
/// without TcEventLogger configured shouldn't pay the cost of a second AMS session
|
||||
/// + notification handle; (c) leaves the spike output (
|
||||
/// <c>docs/v3/twincat-eventlogger-spike.md</c>) as the source of truth for the wire
|
||||
/// decode while the implementation lands incrementally. When
|
||||
/// <see cref="EnableAlarms"/> is <c>false</c>, <see cref="Core.Abstractions.IAlarmSource"/>
|
||||
/// methods short-circuit to a no-op subscription so capability negotiation still works.
|
||||
/// </summary>
|
||||
public bool EnableAlarms { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
using TwinCAT.TypeSystem;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// PR 4.1 / #315 — pure helper that walks a TwinCAT <see cref="IDataType"/> tree starting at
|
||||
/// a top-level instance and yields one <see cref="DiscoveredLeaf"/> per atomic leaf along
|
||||
/// the dotted instance path. Lets <see cref="AdsTwinCATClient.BrowseSymbolsAsync"/> expose
|
||||
/// UDT members as individually-readable OPC UA variable nodes instead of dropping the whole
|
||||
/// symbol the way PR 1.5 did.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Scope</b> — IEC primitives + <c>STRING</c>/<c>WSTRING</c> + IEC time/date types
|
||||
/// resolve to a leaf; aliases recurse into their base type; structs recurse into each
|
||||
/// member; arrays expand element-by-element up to <see cref="TwinCATDriverOptions.MaxArrayExpansion"/>;
|
||||
/// pointers / references / unions / function-block instances / interfaces are skipped.</para>
|
||||
///
|
||||
/// <para><b>Recursion guard</b> — the walker tracks visited <see cref="IDataType"/> instances
|
||||
/// plus a hard depth cap (<see cref="MaxDepth"/>) so a self-pointer or cyclic typedef
|
||||
/// terminates rather than blowing the stack.</para>
|
||||
///
|
||||
/// <para><b>Array policy</b> — when an array's <c>ElementCount > MaxArrayExpansion</c>
|
||||
/// a single leaf is emitted with <see cref="DiscoveredLeaf.IsArrayRoot"/> set so the
|
||||
/// caller can surface the array root as a whole-array tag (the existing 1.4 read-path
|
||||
/// handles that). Arrays-of-structs follow the same cap — over the limit, only the
|
||||
/// root surfaces.</para>
|
||||
///
|
||||
/// <para><b>Offset</b> — every leaf carries the cumulative byte offset from the symbol
|
||||
/// root. Not used by the current discovery path (which still resolves writes/reads by
|
||||
/// symbolic name) but PR 4.x will consume it for offset-based bulk reads.</para>
|
||||
/// </remarks>
|
||||
internal static class TwinCATTypeWalker
|
||||
{
|
||||
/// <summary>Hard recursion cap — kicks in before <see cref="System.StackOverflowException"/>.</summary>
|
||||
internal const int MaxDepth = 8;
|
||||
|
||||
/// <summary>
|
||||
/// One atomic leaf yielded by <see cref="Walk"/>. Either an atomic-typed scalar / array
|
||||
/// element (<see cref="IsArrayRoot"/> = false, <see cref="AtomicType"/> non-null) or a
|
||||
/// "too-large array" placeholder where the element count exceeded
|
||||
/// <see cref="TwinCATDriverOptions.MaxArrayExpansion"/> (<see cref="IsArrayRoot"/> = true
|
||||
/// + <see cref="ArrayLength"/> set).
|
||||
/// </summary>
|
||||
/// <param name="InstancePath">Dotted symbol path (e.g. <c>MAIN.Recipe.Step</c>, <c>GVL.Tags[3]</c>).</param>
|
||||
/// <param name="AtomicType">Mapped atomic <see cref="TwinCATDataType"/>; <c>null</c> only when
|
||||
/// <see cref="IsArrayRoot"/> is <c>true</c> and the element type itself is a struct.</param>
|
||||
/// <param name="Offset">Byte offset of this leaf relative to the walked root.</param>
|
||||
/// <param name="ReadOnly"><c>true</c> when the parent symbol forbids writes.</param>
|
||||
/// <param name="IsArrayRoot"><c>true</c> when this leaf represents an array whose element
|
||||
/// count exceeded the configured expansion limit; the caller should expose it as a
|
||||
/// whole-array tag rather than per-element.</param>
|
||||
/// <param name="ArrayLength">Element count when <see cref="IsArrayRoot"/> is <c>true</c>.</param>
|
||||
public sealed record DiscoveredLeaf(
|
||||
string InstancePath,
|
||||
TwinCATDataType? AtomicType,
|
||||
int Offset,
|
||||
bool ReadOnly,
|
||||
bool IsArrayRoot = false,
|
||||
int? ArrayLength = null);
|
||||
|
||||
/// <summary>
|
||||
/// Walk <paramref name="root"/> and yield every atomic leaf reachable from it. Path
|
||||
/// prefixes start at <paramref name="instancePathRoot"/>; offsets accumulate from
|
||||
/// <paramref name="offsetRoot"/>.
|
||||
/// </summary>
|
||||
public static IEnumerable<DiscoveredLeaf> Walk(
|
||||
IDataType? root,
|
||||
string instancePathRoot,
|
||||
int offsetRoot,
|
||||
bool readOnly,
|
||||
TwinCATDriverOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
if (root is null) yield break;
|
||||
|
||||
var visited = new HashSet<IDataType>(ReferenceEqualityComparer.Instance);
|
||||
foreach (var leaf in WalkInner(root, instancePathRoot, offsetRoot, readOnly, options, visited, depth: 0))
|
||||
yield return leaf;
|
||||
}
|
||||
|
||||
private static IEnumerable<DiscoveredLeaf> WalkInner(
|
||||
IDataType type,
|
||||
string instancePath,
|
||||
int offset,
|
||||
bool readOnly,
|
||||
TwinCATDriverOptions options,
|
||||
HashSet<IDataType> visited,
|
||||
int depth)
|
||||
{
|
||||
if (depth >= MaxDepth) yield break; // depth cap — self-pointer / cyclic alias defence
|
||||
if (!visited.Add(type)) yield break; // already on the recursion stack — break the cycle
|
||||
|
||||
try
|
||||
{
|
||||
// Pointers / references — out of scope. Surfacing them would require dereferencing
|
||||
// through the AMS routing layer which has its own access patterns. The Beckhoff
|
||||
// 7.x stack moved the IsPointer / IsReference checks off IDataType onto the
|
||||
// DataTypeExtension static helpers; using DataTypeCategory directly avoids the
|
||||
// obsolete-warning and is the same wire-level signal.
|
||||
if (type.Category is DataTypeCategory.Pointer or DataTypeCategory.Reference) yield break;
|
||||
|
||||
// Aliases (incl. enums, since IEnumType : IAliasType) — recurse into BaseType
|
||||
// without changing the path / offset. Atomic terminus is handled when the base
|
||||
// hits a primitive.
|
||||
if (type.Category is DataTypeCategory.Alias or DataTypeCategory.Enum
|
||||
&& type is IAliasType alias && alias.BaseType is not null)
|
||||
{
|
||||
foreach (var leaf in WalkInner(alias.BaseType, instancePath, offset, readOnly, options, visited, depth + 1))
|
||||
yield return leaf;
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Atomic primitives + strings — terminate. AdsTwinCATClient.MapSymbolTypeName is the
|
||||
// single source of truth for IEC name → enum mapping; reuse it so alias chains that
|
||||
// bottom out at primitives match the same surface as flat-mode browse.
|
||||
if (type.Category is DataTypeCategory.Primitive or DataTypeCategory.String)
|
||||
{
|
||||
var atomic = AdsTwinCATClient.ResolveSymbolDataType(type);
|
||||
if (atomic is TwinCATDataType dt)
|
||||
yield return new DiscoveredLeaf(instancePath, dt, offset, readOnly);
|
||||
// Unknown primitive name — drop. Caller still gets the rest of the tree.
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Structs — recurse into each member, appending ".Name" to the path and adding the
|
||||
// member's byte offset. IStructType.AllMembers walks the inheritance chain so derived
|
||||
// FB / struct types still surface members from the base — Members alone would miss them.
|
||||
if (type is IStructType structType)
|
||||
{
|
||||
var members = structType.AllMembers ?? structType.Members;
|
||||
if (members is null) yield break;
|
||||
foreach (var member in members)
|
||||
{
|
||||
if (member?.DataType is null) continue;
|
||||
var childPath = string.IsNullOrEmpty(instancePath)
|
||||
? member.InstanceName
|
||||
: instancePath + "." + member.InstanceName;
|
||||
var childOffset = offset + member.ByteOffset;
|
||||
foreach (var leaf in WalkInner(member.DataType, childPath, childOffset, readOnly, options, visited, depth + 1))
|
||||
yield return leaf;
|
||||
}
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Arrays — element-by-element expansion up to MaxArrayExpansion. Arrays of atoms
|
||||
// produce N indexed leaves; arrays of structs produce N × (struct-member-count)
|
||||
// leaves, also bounded by the cap. Multi-dim arrays surface as ElementCount in
|
||||
// row-major order; a follow-up PR can break that out into per-dim indices if
|
||||
// operators ever need them.
|
||||
if (type is IArrayType arrayType)
|
||||
{
|
||||
var elementType = arrayType.ElementType;
|
||||
var dims = arrayType.Dimensions;
|
||||
if (elementType is null || dims is null)
|
||||
yield break;
|
||||
|
||||
var elementCount = dims.ElementCount;
|
||||
if (elementCount <= 0)
|
||||
yield break;
|
||||
|
||||
if (elementCount > options.MaxArrayExpansion)
|
||||
{
|
||||
// Over the cap — surface only the array root. Element type may be atomic or
|
||||
// structured; for atomic element types we set AtomicType so the caller can
|
||||
// configure a whole-array read against it. For struct element types we
|
||||
// leave AtomicType null + flag IsArrayRoot — the address-space layer
|
||||
// typically renders this as a folder placeholder.
|
||||
var rootAtomic = AdsTwinCATClient.ResolveSymbolDataType(elementType);
|
||||
yield return new DiscoveredLeaf(
|
||||
instancePath, rootAtomic, offset, readOnly,
|
||||
IsArrayRoot: true, ArrayLength: elementCount);
|
||||
yield break;
|
||||
}
|
||||
|
||||
var elementSize = Math.Max(0, elementType.ByteSize);
|
||||
var lowerBounds = dims.LowerBounds;
|
||||
var dimLengths = dims.GetDimensionLengths();
|
||||
// Multi-dim — emit per-element with row-major index linearisation. Single-dim
|
||||
// is just the trivial case of that loop.
|
||||
var indices = new int[dimLengths.Length];
|
||||
for (var flat = 0; flat < elementCount; flat++)
|
||||
{
|
||||
LinearToIndices(flat, dimLengths, indices);
|
||||
var indexedPath = AppendIndices(instancePath, indices, lowerBounds);
|
||||
var elementOffset = offset + flat * elementSize;
|
||||
foreach (var leaf in WalkInner(elementType, indexedPath, elementOffset, readOnly, options, visited, depth + 1))
|
||||
yield return leaf;
|
||||
}
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Anything else (Union / FB instance / Interface / Unknown) — out of scope.
|
||||
yield break;
|
||||
}
|
||||
finally
|
||||
{
|
||||
visited.Remove(type);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a flat row-major index into per-dimension indices (in-place into
|
||||
/// <paramref name="indices"/>). Single-dim arrays just write <c>flat</c> to slot 0.
|
||||
/// </summary>
|
||||
private static void LinearToIndices(int flat, int[] dimLengths, int[] indices)
|
||||
{
|
||||
for (var d = dimLengths.Length - 1; d >= 0; d--)
|
||||
{
|
||||
var len = dimLengths[d] <= 0 ? 1 : dimLengths[d];
|
||||
indices[d] = flat % len;
|
||||
flat /= len;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render <c>Path[i]</c> / <c>Path[i,j]</c> form for an array element. Lower bounds add
|
||||
/// so an <c>ARRAY[1..N]</c> element 0 surfaces as <c>Path[1]</c> rather than <c>Path[0]</c>.
|
||||
/// </summary>
|
||||
private static string AppendIndices(string path, int[] indices, int[]? lowerBounds)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder(path.Length + 4 + indices.Length * 4);
|
||||
sb.Append(path);
|
||||
sb.Append('[');
|
||||
for (var i = 0; i < indices.Length; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(',');
|
||||
var lb = lowerBounds is { Length: > 0 } && i < lowerBounds.Length ? lowerBounds[i] : 0;
|
||||
sb.Append(indices[i] + lb);
|
||||
}
|
||||
sb.Append(']');
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,11 @@ public sealed class HostStatusPublisher(
|
||||
HostState.Running => DriverHostState.Running,
|
||||
HostState.Stopped => DriverHostState.Stopped,
|
||||
HostState.Faulted => DriverHostState.Faulted,
|
||||
// PR ablegacy-12 / #255 — Demoted is a driver-side back-off (skipped reads while
|
||||
// we wait for a flaky host to recover). The Configuration enum doesn't have a
|
||||
// dedicated value; surface it as Stopped so the Admin UI lights it up red-ish
|
||||
// without the publisher needing a schema migration to differentiate.
|
||||
HostState.Demoted => DriverHostState.Stopped,
|
||||
_ => DriverHostState.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.2 — integration scaffold for HSBY failover routing through
|
||||
/// <see cref="AbCipDriver.ResolveHost"/>. Skipped by default because the paired
|
||||
/// fixture (controllogix-secondary <c>ab_server</c> instance + <c>hsby-mux</c>
|
||||
/// sidecar that flips the role tag on demand) is not yet stable in the Docker
|
||||
/// compose layout. The scaffold lives here so:
|
||||
/// <list type="bullet">
|
||||
/// <item>The trait is discoverable by <c>dotnet test --filter "Category=Hsby"</c>.</item>
|
||||
/// <item>The companion E2E script (<c>scripts/e2e/test-abcip-hsby.ps1</c>) has a
|
||||
/// paired surface already wired in tests when an operator stands up the fixture
|
||||
/// manually.</item>
|
||||
/// <item>A future PR can flip the skip into a real assertion without restructuring
|
||||
/// the test layout.</item>
|
||||
/// </list>
|
||||
/// The unit-level coverage in <c>AbCipHsbyFailoverTests</c> (in the unit tests
|
||||
/// project) exercises the active-address-routing + cache-invalidation contract in
|
||||
/// full against the FakeAbCipTagFactory; this scaffold is just the wire-level shape.
|
||||
/// </summary>
|
||||
[Trait("Category", "Hsby")]
|
||||
[Trait("Requires", "AbServer")]
|
||||
public sealed class AbCipHsbyFailoverTests
|
||||
{
|
||||
[AbServerFact]
|
||||
public Task ResolveHost_routes_to_partner_after_role_flip_through_hsby_mux()
|
||||
{
|
||||
// The paired-fixture compose service (controllogix + controllogix-secondary +
|
||||
// hsby-mux sidecar at http://localhost:7080) is not yet wired. When it ships,
|
||||
// the test body will:
|
||||
// 1. POST {"active": "primary"} to hsby-mux → assert ResolveHost = primary
|
||||
// gateway via a CLI read.
|
||||
// 2. POST {"active": "partner"} → wait for the probe loop to catch up →
|
||||
// assert ResolveHost = partner gateway via a second CLI read.
|
||||
// 3. Assert AbCip.HsbyFailoverCount on the driver's diagnostics
|
||||
// ≥ 1 by reading the driver-diagnostics RPC through the OPC UA Admin
|
||||
// surface.
|
||||
Assert.Skip("HSBY paired fixture (controllogix-secondary + hsby-mux sidecar) " +
|
||||
"not yet promoted out of scaffold. Run scripts/e2e/test-abcip-hsby.ps1 against a " +
|
||||
"manually-stood-up paired fixture when verifying this PR end-to-end.");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.1 — integration scaffold for HSBY paired-IP role probing. Skipped by default
|
||||
/// because <c>ab_server</c> cannot emulate a ControlLogix HSBY pair (it has no second-chassis
|
||||
/// concept + no <c>WallClockTime.SyncStatus</c> tag). Promoted from skipped to active when
|
||||
/// the Docker fixture grows the <c>hsby-mux</c> sidecar (planned in PR abcip-5.2 follow-up
|
||||
/// work) or when a real lab rig is available; the unit-level coverage in
|
||||
/// <c>AbCipHsbyTests</c> exercises the value-mapping + active-resolution rules in the
|
||||
/// meantime.
|
||||
/// <para>
|
||||
/// The skip lives in the test body so the file still compiles + the trait is discoverable
|
||||
/// by <c>dotnet test --filter "Category=Hsby"</c>; the body never gets to assert anything
|
||||
/// against <c>ab_server</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Trait("Category", "Hsby")]
|
||||
[Trait("Requires", "AbServer")]
|
||||
public sealed class AbCipHsbyRoleProberTests
|
||||
{
|
||||
[AbServerFact]
|
||||
public Task Role_prober_resolves_active_chassis_against_paired_fixture()
|
||||
{
|
||||
// ab_server cannot emulate an HSBY pair; the paired-fixture compose service +
|
||||
// hsby-mux sidecar that PR abcip-5.2 ships will let this body do real wire work.
|
||||
// For PR abcip-5.1 we keep the file as a scaffold so the integration trait is
|
||||
// discoverable and a future PR can flip the skip into a real assertion.
|
||||
Assert.Skip("HSBY paired-fixture (controllogix-secondary + hsby-mux sidecar) not yet wired — PR abcip-5.2 follow-up.");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -95,3 +95,81 @@ services:
|
||||
"--tag=TestDINT:DINT[1]",
|
||||
"--tag=SafetyDINT_S:DINT[1]"
|
||||
]
|
||||
|
||||
# ---- PR abcip-5.1 — paired-fixture for HSBY role probing ------------------
|
||||
# The "paired" profile spins up two ab_server instances (controllogix-primary
|
||||
# on :44818, controllogix-secondary on :44819) plus a stub hsby-mux sidecar
|
||||
# that flips a role bit on demand. The mux is a placeholder — it does NOT
|
||||
# currently inject role bits because ab_server has no WallClockTime.SyncStatus
|
||||
# tag concept. PR abcip-5.2 follow-up will land:
|
||||
# 1. A patched ab_server image (or a separate Python TCP shim) that exposes
|
||||
# a writable WallClockTime.SyncStatus DINT per chassis.
|
||||
# 2. A real hsby-mux REST endpoint (POST /flip {"active": "primary"}) that
|
||||
# writes 1 to the chosen chassis + 0 to the other.
|
||||
# For now the services exist so the compose file documents the topology + the
|
||||
# AbCipHsbyRoleProberTests integration test has a place to land its
|
||||
# [AbServerFact] without breaking the pre-5.1 ab_server profiles.
|
||||
controllogix-primary:
|
||||
profiles: ["paired"]
|
||||
image: otopcua-ab-server:libplctag-release
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: otopcua-ab-server-controllogix-primary
|
||||
restart: "no"
|
||||
ports:
|
||||
- "44818:44818"
|
||||
command: [
|
||||
"ab_server",
|
||||
"--plc=ControlLogix",
|
||||
"--path=1,0",
|
||||
"--port=44818",
|
||||
"--tag=TestDINT:DINT[1]",
|
||||
# Stand-in for WallClockTime.SyncStatus until the patched image lands.
|
||||
"--tag=SyncStatus:DINT[1]"
|
||||
]
|
||||
|
||||
controllogix-secondary:
|
||||
profiles: ["paired"]
|
||||
image: otopcua-ab-server:libplctag-release
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: otopcua-ab-server-controllogix-secondary
|
||||
restart: "no"
|
||||
ports:
|
||||
- "44819:44818"
|
||||
command: [
|
||||
"ab_server",
|
||||
"--plc=ControlLogix",
|
||||
"--path=1,0",
|
||||
"--port=44818",
|
||||
"--tag=TestDINT:DINT[1]",
|
||||
"--tag=SyncStatus:DINT[1]"
|
||||
]
|
||||
|
||||
# Stub hsby-mux — placeholder. Today's image is a tiny Python script that
|
||||
# exposes a /health endpoint + nothing else. PR abcip-5.2 will replace this
|
||||
# with a real role-flip endpoint that writes SyncStatus on either chassis.
|
||||
hsby-mux:
|
||||
profiles: ["paired"]
|
||||
image: python:3.12-alpine
|
||||
container_name: otopcua-ab-hsby-mux
|
||||
restart: "no"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
python -c "
|
||||
import http.server, socketserver
|
||||
class H(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(s):
|
||||
s.send_response(200); s.send_header('Content-Type','text/plain'); s.end_headers()
|
||||
s.wfile.write(b'hsby-mux stub - PR abcip-5.2 follow-up will wire role flips')
|
||||
socketserver.TCPServer(('', 8080), H).serve_forever()
|
||||
"
|
||||
depends_on:
|
||||
- controllogix-primary
|
||||
- controllogix-secondary
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.2 — unit tests for HSBY failover routing in
|
||||
/// <see cref="AbCipDriver.ResolveHost"/>. Drives a paired-IP HSBY device through
|
||||
/// primary→partner role flips via the FakeAbCipTagFactory's <c>Customise</c> hook +
|
||||
/// asserts:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="AbCipDriver.ResolveHost"/> returns the address of the
|
||||
/// currently-Active chassis (and the configured primary when HSBY is off /
|
||||
/// both Standby).</item>
|
||||
/// <item>The per-device runtime cache is invalidated on flip — disposed handles
|
||||
/// prove the failover handler ran.</item>
|
||||
/// <item><see cref="AbCipWriteCoalescer"/> drops cached values for the device so
|
||||
/// the partner pays the full round-trip on next write.</item>
|
||||
/// <item><c>AbCip.HsbyFailoverCount</c> in driver-diagnostics increments per flip.</item>
|
||||
/// <item>Multiple flips count correctly.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipHsbyFailoverTests
|
||||
{
|
||||
private const string Primary = "ab://10.0.0.5/1,0";
|
||||
private const string Partner = "ab://10.0.0.6/1,0";
|
||||
|
||||
// ---- ResolveHost routing ----
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_returns_partner_when_partner_active()
|
||||
{
|
||||
var (drv, _) = await BuildHsbyDriverAsync(primaryRoleValue: 0, partnerRoleValue: 1);
|
||||
try
|
||||
{
|
||||
await WaitForActiveAsync(drv, Partner);
|
||||
var resolved = drv.ResolveHost("Motor01_Speed");
|
||||
// Tag isn't registered; resolver still falls through ResolveActiveHostFor on
|
||||
// the first configured device, which has the partner active.
|
||||
resolved.ShouldBe(Partner);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_returns_primary_when_primary_active()
|
||||
{
|
||||
var (drv, _) = await BuildHsbyDriverAsync(primaryRoleValue: 1, partnerRoleValue: 0);
|
||||
try
|
||||
{
|
||||
await WaitForActiveAsync(drv, Primary);
|
||||
drv.ResolveHost("Motor01_Speed").ShouldBe(Primary);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Toggling_role_flips_ResolveHost_output()
|
||||
{
|
||||
var (factory, tracker) = BuildTrackingFactory(initialPrimary: 1, initialPartner: 0);
|
||||
var drv = BuildDriver(factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
try
|
||||
{
|
||||
await WaitForActiveAsync(drv, Primary);
|
||||
drv.ResolveHost("anything").ShouldBe(Primary);
|
||||
|
||||
FlipRoles(tracker, newPrimary: 0, newPartner: 1);
|
||||
|
||||
await WaitForActiveAsync(drv, Partner);
|
||||
drv.ResolveHost("anything").ShouldBe(Partner);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_falls_back_to_primary_when_both_standby()
|
||||
{
|
||||
var (drv, _) = await BuildHsbyDriverAsync(primaryRoleValue: 0, partnerRoleValue: 0);
|
||||
try
|
||||
{
|
||||
// Wait for the role state to settle so we know the loop ticked at least once.
|
||||
await WaitForAsync(() => drv.GetDeviceState(Primary)?.PrimaryRole != HsbyRole.Unknown);
|
||||
drv.ResolveHost("anything").ShouldBe(Primary,
|
||||
"neither chassis Active means ActiveAddress is null; ResolveHost falls back to the configured primary");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_ignores_ActiveAddress_when_Hsby_disabled()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbCipDeviceOptions(
|
||||
Primary,
|
||||
PartnerHostAddress: Partner,
|
||||
Hsby: new AbCipHsbyOptions { Enabled = false }),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-hsby-off-resolve", factory);
|
||||
try
|
||||
{
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
// Manually plant an ActiveAddress that conflicts with the primary; ResolveHost
|
||||
// must still pick the primary because Hsby is disabled.
|
||||
var state = drv.GetDeviceState(Primary).ShouldNotBeNull();
|
||||
state.ActiveAddress = Partner;
|
||||
drv.ResolveHost("anything").ShouldBe(Primary);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Cache invalidation on flip ----
|
||||
|
||||
[Fact]
|
||||
public async Task Failover_invalidates_runtime_cache_and_increments_counter()
|
||||
{
|
||||
var (factory, tracker) = BuildTrackingFactory(initialPrimary: 1, initialPartner: 0);
|
||||
var drv = BuildDriverWithTag(factory, "Motor01_Speed");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
try
|
||||
{
|
||||
await WaitForActiveAsync(drv, Primary);
|
||||
|
||||
// Force a per-tag runtime to be created against the primary.
|
||||
var initialReads = await drv.ReadAsync(["Motor01_Speed"], CancellationToken.None);
|
||||
initialReads.Count.ShouldBe(1);
|
||||
var state = drv.GetDeviceState(Primary).ShouldNotBeNull();
|
||||
state.Runtimes.ShouldContainKey("Motor01_Speed");
|
||||
var runtimeBeforeFlip = (FakeAbCipTag)state.Runtimes["Motor01_Speed"];
|
||||
runtimeBeforeFlip.CreationParams.Gateway.ShouldBe("10.0.0.5");
|
||||
|
||||
// Flip — primary→Standby, partner→Active.
|
||||
FlipRoles(tracker, newPrimary: 0, newPartner: 1);
|
||||
await WaitForActiveAsync(drv, Partner);
|
||||
|
||||
// The pre-flip runtime should have been disposed by the failover handler.
|
||||
runtimeBeforeFlip.Disposed.ShouldBeTrue();
|
||||
// Cache should be empty until the next read repopulates it.
|
||||
state.Runtimes.ShouldNotContainKey("Motor01_Speed");
|
||||
|
||||
// Diagnostics counter ticked.
|
||||
var diag = drv.GetHealth().Diagnostics.ShouldNotBeNull();
|
||||
diag.ShouldContainKey("AbCip.HsbyFailoverCount");
|
||||
diag["AbCip.HsbyFailoverCount"].ShouldBeGreaterThanOrEqualTo(1);
|
||||
|
||||
// Next read recreates against the partner gateway.
|
||||
var afterReads = await drv.ReadAsync(["Motor01_Speed"], CancellationToken.None);
|
||||
afterReads.Count.ShouldBe(1);
|
||||
state.Runtimes.ShouldContainKey("Motor01_Speed");
|
||||
var runtimeAfterFlip = (FakeAbCipTag)state.Runtimes["Motor01_Speed"];
|
||||
runtimeAfterFlip.CreationParams.Gateway.ShouldBe("10.0.0.6",
|
||||
"post-flip runtime must target the partner's gateway");
|
||||
runtimeAfterFlip.ShouldNotBeSameAs(runtimeBeforeFlip);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failover_resets_write_coalescer_for_device()
|
||||
{
|
||||
var (factory, tracker) = BuildTrackingFactory(initialPrimary: 1, initialPartner: 0);
|
||||
var drv = BuildDriverWithTag(factory, "Motor01_Speed");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
try
|
||||
{
|
||||
await WaitForActiveAsync(drv, Primary);
|
||||
// Seed the coalescer cache for this device + tag. We poke it directly via
|
||||
// the test seam so we don't depend on the multi-write planner accepting our
|
||||
// synthetic Motor01_Speed definition.
|
||||
var def = new AbCipTagDefinition(
|
||||
Name: "Motor01_Speed",
|
||||
DeviceHostAddress: Primary,
|
||||
TagPath: "Motor01_Speed",
|
||||
DataType: AbCipDataType.DInt,
|
||||
Writable: true,
|
||||
WriteOnChange: true);
|
||||
drv.WriteCoalescer.Record(Primary, def, 42);
|
||||
drv.WriteCoalescer.ShouldSuppress(Primary, def, 42).ShouldBeTrue(
|
||||
"baseline: identical re-write must be suppressed pre-failover");
|
||||
|
||||
FlipRoles(tracker, newPrimary: 0, newPartner: 1);
|
||||
await WaitForActiveAsync(drv, Partner);
|
||||
|
||||
// The cache for this device was cleared so the same write is no longer suppressed.
|
||||
drv.WriteCoalescer.ShouldSuppress(Primary, def, 42).ShouldBeFalse(
|
||||
"failover must drop cached known-written values; partner needs the wire round-trip");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Multiple_flips_each_increment_HsbyFailoverCount()
|
||||
{
|
||||
var (factory, tracker) = BuildTrackingFactory(initialPrimary: 1, initialPartner: 0);
|
||||
var drv = BuildDriver(factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
try
|
||||
{
|
||||
await WaitForActiveAsync(drv, Primary);
|
||||
var diagBaseline = drv.GetHealth().Diagnostics.ShouldNotBeNull();
|
||||
var startCount = diagBaseline.TryGetValue("AbCip.HsbyFailoverCount", out var v) ? v : 0;
|
||||
|
||||
// Flip 1: primary→partner
|
||||
FlipRoles(tracker, newPrimary: 0, newPartner: 1);
|
||||
await WaitForActiveAsync(drv, Partner);
|
||||
|
||||
// Flip 2: partner→primary
|
||||
FlipRoles(tracker, newPrimary: 1, newPartner: 0);
|
||||
await WaitForActiveAsync(drv, Primary);
|
||||
|
||||
// Flip 3: primary→partner again
|
||||
FlipRoles(tracker, newPrimary: 0, newPartner: 1);
|
||||
await WaitForActiveAsync(drv, Partner);
|
||||
|
||||
var diag = drv.GetHealth().Diagnostics.ShouldNotBeNull();
|
||||
diag["AbCip.HsbyFailoverCount"].ShouldBeGreaterThanOrEqualTo(startCount + 3);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
private static AbCipDriver BuildDriver(FakeAbCipTagFactory factory) =>
|
||||
new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbCipDeviceOptions(
|
||||
Primary,
|
||||
PartnerHostAddress: Partner,
|
||||
Hsby: new AbCipHsbyOptions
|
||||
{
|
||||
Enabled = true,
|
||||
RoleTagAddress = "WallClockTime.SyncStatus",
|
||||
ProbeInterval = TimeSpan.FromMilliseconds(40),
|
||||
}),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-hsby-failover", factory);
|
||||
|
||||
private static AbCipDriver BuildDriverWithTag(FakeAbCipTagFactory factory, string tagName) =>
|
||||
new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbCipDeviceOptions(
|
||||
Primary,
|
||||
PartnerHostAddress: Partner,
|
||||
Hsby: new AbCipHsbyOptions
|
||||
{
|
||||
Enabled = true,
|
||||
RoleTagAddress = "WallClockTime.SyncStatus",
|
||||
ProbeInterval = TimeSpan.FromMilliseconds(40),
|
||||
}),
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition(
|
||||
Name: tagName,
|
||||
DeviceHostAddress: Primary,
|
||||
TagPath: tagName,
|
||||
DataType: AbCipDataType.DInt,
|
||||
Writable: true),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-hsby-failover-tag", factory);
|
||||
|
||||
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)>
|
||||
BuildHsbyDriverAsync(int primaryRoleValue, int partnerRoleValue)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => p.Gateway == "10.0.0.5"
|
||||
? new FakeAbCipTag(p) { Value = primaryRoleValue }
|
||||
: new FakeAbCipTag(p) { Value = partnerRoleValue },
|
||||
};
|
||||
var drv = BuildDriver(factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the live primary + partner role-tag fakes the factory has handed
|
||||
/// out, keyed by gateway. Populated by the <c>Customise</c> hook on the
|
||||
/// <see cref="FakeAbCipTagFactory"/> via a side-effecting lambda; the
|
||||
/// <see cref="FakeAbCipTagFactory.Tags"/> dict alone is insufficient because both
|
||||
/// chassis use the same role-tag TagName + the dict overwrites on the second
|
||||
/// create.
|
||||
/// </summary>
|
||||
private sealed class HsbyRoleTagTracker
|
||||
{
|
||||
public FakeAbCipTag? Primary { get; set; }
|
||||
public FakeAbCipTag? Partner { get; set; }
|
||||
}
|
||||
|
||||
private static (FakeAbCipTagFactory Factory, HsbyRoleTagTracker Tracker)
|
||||
BuildTrackingFactory(int initialPrimary, int initialPartner)
|
||||
{
|
||||
var tracker = new HsbyRoleTagTracker();
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
factory.Customise = p =>
|
||||
{
|
||||
if (p.TagName == "WallClockTime.SyncStatus")
|
||||
{
|
||||
var fake = new FakeAbCipTag(p)
|
||||
{
|
||||
Value = p.Gateway == "10.0.0.5" ? initialPrimary : initialPartner,
|
||||
};
|
||||
if (p.Gateway == "10.0.0.5") tracker.Primary = fake;
|
||||
else tracker.Partner = fake;
|
||||
return fake;
|
||||
}
|
||||
// Non-role-tag handles (e.g. per-tag runtimes) — return a default fake.
|
||||
return new FakeAbCipTag(p) { Value = 0 };
|
||||
};
|
||||
return (factory, tracker);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mutate the live primary / partner role-tag fakes' <c>Value</c> so the next
|
||||
/// probe-loop tick reads the new role. Probe loop reuses one runtime per chassis
|
||||
/// once initialised, so direct mutation of <see cref="FakeAbCipTag.Value"/> is
|
||||
/// sufficient — no re-create required.
|
||||
/// </summary>
|
||||
private static void FlipRoles(HsbyRoleTagTracker tracker, int newPrimary, int newPartner)
|
||||
{
|
||||
if (tracker.Primary is not null) tracker.Primary.Value = newPrimary;
|
||||
if (tracker.Partner is not null) tracker.Partner.Value = newPartner;
|
||||
}
|
||||
|
||||
private static Task WaitForActiveAsync(AbCipDriver drv, string expectedActive) =>
|
||||
WaitForAsync(() => drv.GetDeviceState(Primary)?.ActiveAddress == expectedActive);
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, TimeSpan? timeout = null)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(2));
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-5.1 — unit tests for HSBY paired-IP role probing. Drives two fake-runtime
|
||||
/// gateways (primary + partner), forces each to return a chosen <c>WallClockTime.SyncStatus</c>
|
||||
/// value, asserts <see cref="AbCipDriver.GetHealth"/> diagnostics + the device-state
|
||||
/// <c>ActiveAddress</c> resolves to the expected chassis under each split-state combination.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipHsbyTests
|
||||
{
|
||||
// ---- Pure value mapping ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(0L, HsbyRole.Standby)] // WallClockTime.SyncStatus matrix
|
||||
[InlineData(1L, HsbyRole.Active)]
|
||||
[InlineData(2L, HsbyRole.Disqualified)]
|
||||
[InlineData(99L, HsbyRole.Unknown)] // out-of-range integer
|
||||
public void MapValueToRole_handles_WallClockTime_SyncStatus_matrix(long raw, HsbyRole expected)
|
||||
{
|
||||
AbCipHsbyRoleProber.MapValueToRole(raw, "WallClockTime.SyncStatus").ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0L, HsbyRole.Standby)] // bit 0 = 0 → Standby
|
||||
[InlineData(1L, HsbyRole.Active)] // bit 0 = 1 → Active
|
||||
[InlineData(2L, HsbyRole.Standby)] // bit 0 = 0 → Standby (2 = 0b10)
|
||||
[InlineData(3L, HsbyRole.Active)] // bit 0 = 1 → Active (3 = 0b11)
|
||||
public void MapValueToRole_handles_S34_bitmask_fallback(long raw, HsbyRole expected)
|
||||
{
|
||||
AbCipHsbyRoleProber.MapValueToRole(raw, "S:34").ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapValueToRole_returns_Unknown_for_null_raw()
|
||||
{
|
||||
AbCipHsbyRoleProber.MapValueToRole(null, "WallClockTime.SyncStatus").ShouldBe(HsbyRole.Unknown);
|
||||
}
|
||||
|
||||
// ---- ProbeAsync against fake runtime ----
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeAsync_returns_Active_when_runtime_decodes_one()
|
||||
{
|
||||
var rt = new FakeAbCipTag(MakeParams("WallClockTime.SyncStatus")) { Value = 1 };
|
||||
var role = await AbCipHsbyRoleProber.ProbeAsync(rt, "WallClockTime.SyncStatus", CancellationToken.None);
|
||||
role.ShouldBe(HsbyRole.Active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeAsync_returns_Unknown_when_read_throws()
|
||||
{
|
||||
var rt = new FakeAbCipTag(MakeParams("WallClockTime.SyncStatus")) { ThrowOnRead = true };
|
||||
var role = await AbCipHsbyRoleProber.ProbeAsync(rt, "WallClockTime.SyncStatus", CancellationToken.None);
|
||||
role.ShouldBe(HsbyRole.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeAsync_returns_Unknown_on_non_zero_status()
|
||||
{
|
||||
var rt = new FakeAbCipTag(MakeParams("WallClockTime.SyncStatus")) { Value = 1, Status = -1 };
|
||||
var role = await AbCipHsbyRoleProber.ProbeAsync(rt, "WallClockTime.SyncStatus", CancellationToken.None);
|
||||
role.ShouldBe(HsbyRole.Unknown);
|
||||
}
|
||||
|
||||
// ---- End-to-end driver loop ----
|
||||
|
||||
[Fact]
|
||||
public async Task Primary_active_partner_standby_resolves_ActiveAddress_to_primary()
|
||||
{
|
||||
var (drv, _) = await BuildHsbyDriverAsync(primaryRoleValue: 1, partnerRoleValue: 0);
|
||||
try
|
||||
{
|
||||
await WaitForRoleAsync(drv, "ab://10.0.0.5/1,0");
|
||||
var state = drv.GetDeviceState("ab://10.0.0.5/1,0").ShouldNotBeNull();
|
||||
state.ActiveAddress.ShouldBe("ab://10.0.0.5/1,0");
|
||||
state.PrimaryRole.ShouldBe(HsbyRole.Active);
|
||||
state.PartnerRole.ShouldBe(HsbyRole.Standby);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Both_active_primary_wins_and_warning_is_emitted()
|
||||
{
|
||||
var warnings = new ConcurrentQueue<string>();
|
||||
var (drv, _) = await BuildHsbyDriverAsync(primaryRoleValue: 1, partnerRoleValue: 1,
|
||||
warningSink: warnings.Enqueue);
|
||||
try
|
||||
{
|
||||
await WaitForRoleAsync(drv, "ab://10.0.0.5/1,0");
|
||||
var state = drv.GetDeviceState("ab://10.0.0.5/1,0").ShouldNotBeNull();
|
||||
state.ActiveAddress.ShouldBe("ab://10.0.0.5/1,0",
|
||||
"split-brain ties must resolve to primary deterministically");
|
||||
state.PrimaryRole.ShouldBe(HsbyRole.Active);
|
||||
state.PartnerRole.ShouldBe(HsbyRole.Active);
|
||||
warnings.ShouldContain(w => w.Contains("split-brain", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Both_standby_clears_ActiveAddress()
|
||||
{
|
||||
var (drv, _) = await BuildHsbyDriverAsync(primaryRoleValue: 0, partnerRoleValue: 0);
|
||||
try
|
||||
{
|
||||
// Let the loop tick at least once + sample the role state.
|
||||
await WaitForAsync(() => drv.GetDeviceState("ab://10.0.0.5/1,0")?.PrimaryRole != HsbyRole.Unknown);
|
||||
var state = drv.GetDeviceState("ab://10.0.0.5/1,0").ShouldNotBeNull();
|
||||
state.ActiveAddress.ShouldBeNull(
|
||||
"neither chassis Active means no routing target — PR abcip-5.2 will fault writes here");
|
||||
state.PrimaryRole.ShouldBe(HsbyRole.Standby);
|
||||
state.PartnerRole.ShouldBe(HsbyRole.Standby);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Primary_read_fails_and_partner_active_routes_to_partner()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => p.Gateway == "10.0.0.5"
|
||||
? new FakeAbCipTag(p) { ThrowOnRead = true }
|
||||
: new FakeAbCipTag(p) { Value = 1 },
|
||||
};
|
||||
var drv = BuildDriver(factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
try
|
||||
{
|
||||
await WaitForRoleAsync(drv, "ab://10.0.0.6/1,0");
|
||||
var state = drv.GetDeviceState("ab://10.0.0.5/1,0").ShouldNotBeNull();
|
||||
state.ActiveAddress.ShouldBe("ab://10.0.0.6/1,0");
|
||||
state.PrimaryRole.ShouldBe(HsbyRole.Unknown);
|
||||
state.PartnerRole.ShouldBe(HsbyRole.Active);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Hsby_disabled_skips_role_probing_entirely()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbCipDeviceOptions(
|
||||
"ab://10.0.0.5/1,0",
|
||||
PartnerHostAddress: "ab://10.0.0.6/1,0",
|
||||
Hsby: new AbCipHsbyOptions { Enabled = false }),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-hsby-off", factory);
|
||||
try
|
||||
{
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await Task.Delay(150);
|
||||
|
||||
var state = drv.GetDeviceState("ab://10.0.0.5/1,0").ShouldNotBeNull();
|
||||
state.PrimaryRole.ShouldBe(HsbyRole.Unknown);
|
||||
state.PartnerRole.ShouldBe(HsbyRole.Unknown);
|
||||
state.ActiveAddress.ShouldBeNull();
|
||||
// Factory must not have been used since Hsby.Enabled = false + probe disabled.
|
||||
factory.Tags.ShouldBeEmpty();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Diagnostics_surface_HsbyActive_and_role_codes()
|
||||
{
|
||||
var (drv, _) = await BuildHsbyDriverAsync(primaryRoleValue: 1, partnerRoleValue: 0);
|
||||
try
|
||||
{
|
||||
await WaitForRoleAsync(drv, "ab://10.0.0.5/1,0");
|
||||
|
||||
var diag = drv.GetHealth().Diagnostics.ShouldNotBeNull();
|
||||
diag.ShouldContainKey("AbCip.HsbyActive");
|
||||
diag["AbCip.HsbyActive"].ShouldBe(1); // primary is the active chassis
|
||||
diag["AbCip.HsbyPrimaryRole"].ShouldBe((int)HsbyRole.Active);
|
||||
diag["AbCip.HsbyPartnerRole"].ShouldBe((int)HsbyRole.Standby);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- DTO round-trip ----
|
||||
|
||||
[Fact]
|
||||
public async Task DTO_json_round_trip_preserves_PartnerHostAddress_and_Hsby()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"Devices": [
|
||||
{
|
||||
"HostAddress": "ab://10.0.0.5/1,0",
|
||||
"PartnerHostAddress": "ab://10.0.0.6/1,0",
|
||||
"Hsby": {
|
||||
"Enabled": true,
|
||||
"RoleTagAddress": "S:34",
|
||||
"ProbeIntervalMs": 5000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
var driver = AbCipDriverFactoryExtensions.CreateInstance("drv-roundtrip", json);
|
||||
try
|
||||
{
|
||||
// Initialise so the device map is populated, then read back via GetDeviceState.
|
||||
await driver.InitializeAsync(json, CancellationToken.None);
|
||||
var state = driver.GetDeviceState("ab://10.0.0.5/1,0").ShouldNotBeNull();
|
||||
state.Options.PartnerHostAddress.ShouldBe("ab://10.0.0.6/1,0");
|
||||
state.Options.Hsby.ShouldNotBeNull();
|
||||
state.Options.Hsby!.Enabled.ShouldBeTrue();
|
||||
state.Options.Hsby.RoleTagAddress.ShouldBe("S:34");
|
||||
state.Options.Hsby.ProbeInterval.ShouldBe(TimeSpan.FromMilliseconds(5000));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
private static AbCipDriver BuildDriver(FakeAbCipTagFactory factory, Action<string>? warningSink = null) =>
|
||||
new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbCipDeviceOptions(
|
||||
"ab://10.0.0.5/1,0",
|
||||
PartnerHostAddress: "ab://10.0.0.6/1,0",
|
||||
Hsby: new AbCipHsbyOptions
|
||||
{
|
||||
Enabled = true,
|
||||
RoleTagAddress = "WallClockTime.SyncStatus",
|
||||
ProbeInterval = TimeSpan.FromMilliseconds(50),
|
||||
}),
|
||||
],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
OnWarning = warningSink,
|
||||
}, "drv-hsby", factory);
|
||||
|
||||
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)>
|
||||
BuildHsbyDriverAsync(int primaryRoleValue, int partnerRoleValue, Action<string>? warningSink = null)
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => p.Gateway == "10.0.0.5"
|
||||
? new FakeAbCipTag(p) { Value = primaryRoleValue }
|
||||
: new FakeAbCipTag(p) { Value = partnerRoleValue },
|
||||
};
|
||||
var drv = BuildDriver(factory, warningSink);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
private static AbCipTagCreateParams MakeParams(string tagName) => new(
|
||||
Gateway: "10.0.0.5",
|
||||
Port: 44818,
|
||||
CipPath: "1,0",
|
||||
LibplctagPlcAttribute: "ControlLogix",
|
||||
TagName: tagName,
|
||||
Timeout: TimeSpan.FromSeconds(2));
|
||||
|
||||
private static Task WaitForRoleAsync(AbCipDriver drv, string expectedActive) =>
|
||||
WaitForAsync(() => drv.GetDeviceState("ab://10.0.0.5/1,0")?.ActiveAddress == expectedActive);
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, TimeSpan? timeout = null)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(2));
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// PR ablegacy-12 / #255 — wire-level smoke for auto-demote on comm failure.
|
||||
/// Runs only when ab_server is reachable. Two devices: one healthy (the live
|
||||
/// ab_server slc500 simulator), one pointed at <c>127.0.0.1:1</c> which
|
||||
/// refuses every connection. After three consecutive failures the faulty
|
||||
/// device's reads must short-circuit with <c>BadCommunicationError</c>
|
||||
/// while the healthy device keeps returning <c>Good</c> — the whole point
|
||||
/// of the feature: one slow / unreachable PLC sharing the driver thread
|
||||
/// can't starve faster peers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Build-only by default — the assertion that demotion latency is
|
||||
/// bounded depends on the ab_server simulator timing out on the faulty
|
||||
/// port within the per-device timeout. We pin the faulty endpoint at
|
||||
/// <c>127.0.0.1:1</c> (the bogus-port standard) which RST's the
|
||||
/// connection immediately on most stacks; environments that whitelist
|
||||
/// outbound to localhost:1 will see different timing but still trip
|
||||
/// the threshold within the test budget.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The Docker fixture extension (<c>slc500-faulty</c>) noted in the PR
|
||||
/// plan is a documentation-only placeholder for now — implementing a
|
||||
/// refusing-proxy container is non-trivial and the localhost:1 trick
|
||||
/// covers the same surface deterministically.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(AbLegacyServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Simulator", "ab_server-PCCC")]
|
||||
public sealed class AbLegacyAutoDemoteTests(AbLegacyServerFixture sim)
|
||||
{
|
||||
[AbLegacyFact]
|
||||
public async Task Two_devices_one_unreachable_does_not_starve_healthy_reads()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
var healthy = $"ab://{sim.Host}:{sim.Port}/{sim.CipPath}";
|
||||
// 127.0.0.1:1 is the bogus-port standard — typical Linux/Windows TCP
|
||||
// stacks RST immediately. The driver still reports it as a comm
|
||||
// failure (libplctag wraps the failure as a transient throw).
|
||||
var faulty = "ab://127.0.0.1:1/1,0";
|
||||
|
||||
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbLegacyDeviceOptions(healthy, AbLegacyPlcFamily.Slc500,
|
||||
Timeout: TimeSpan.FromSeconds(5)),
|
||||
new AbLegacyDeviceOptions(faulty, AbLegacyPlcFamily.Slc500,
|
||||
// Snappy timeout so the test budget stays short.
|
||||
Timeout: TimeSpan.FromMilliseconds(500),
|
||||
Demote: new AbLegacyDemoteOptions(
|
||||
FailureThreshold: 3,
|
||||
DemoteFor: TimeSpan.FromSeconds(30))),
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new AbLegacyTagDefinition("Healthy", healthy, "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("Faulty", faulty, "N7:0", AbLegacyDataType.Int),
|
||||
],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, driverInstanceId: "ablegacy-auto-demote-it");
|
||||
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
// Trip the demote on the faulty device.
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
await drv.ReadAsync(["Faulty"], TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
// Healthy host MUST keep returning Good even though the sibling is demoted.
|
||||
var healthyResult = await drv.ReadAsync(["Healthy"], TestContext.Current.CancellationToken);
|
||||
healthyResult[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
|
||||
// Faulty host now short-circuits without waiting on libplctag's timeout.
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var faultyResult = await drv.ReadAsync(["Faulty"], TestContext.Current.CancellationToken);
|
||||
sw.Stop();
|
||||
faultyResult[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
// Short-circuit should be ~1 ms; pad generously for CI noise. The pre-PR-12
|
||||
// path would have waited the full 500 ms timeout.
|
||||
sw.ElapsedMilliseconds.ShouldBeLessThan(200);
|
||||
|
||||
// Counter access via the public diagnostic short-circuit path — the
|
||||
// internal Snapshot() seam isn't visible from this assembly.
|
||||
var demoteCountRef = $"_Diagnostics/{faulty}/DemoteCount";
|
||||
var lastDemotedRef = $"_Diagnostics/{faulty}/LastDemotedUtc";
|
||||
var diag = await drv.ReadAsync(
|
||||
[demoteCountRef, lastDemotedRef], TestContext.Current.CancellationToken);
|
||||
((long)diag[0].Value!).ShouldBeGreaterThan(0);
|
||||
((string)diag[1].Value!).Length.ShouldBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
@@ -72,3 +72,30 @@ services:
|
||||
"--tag=F8[120]",
|
||||
"--tag=B3[10]"
|
||||
]
|
||||
|
||||
# PR ablegacy-12 / #255 — faulty-PLC fixture for the auto-demote contract.
|
||||
# FIXTURE-TIER FOLLOW-UP: implementing a refusing-proxy container that
|
||||
# round-trips libplctag's CIP framing far enough to trigger comm failures
|
||||
# (vs. just RST'ing the TCP handshake) is non-trivial — the integration
|
||||
# test currently uses 127.0.0.1:1 (the bogus-port standard) which RST's
|
||||
# immediately on most TCP stacks. That gets us deterministic comm-failure
|
||||
# coverage without standing up a second container; if the localhost:1
|
||||
# trick stops working on a future test runner (e.g. a sandbox that
|
||||
# blocks port 1) re-enable this stub:
|
||||
#
|
||||
# slc500-faulty:
|
||||
# profiles: ["slc500-faulty"]
|
||||
# image: otopcua-ab-server:libplctag-release
|
||||
# build:
|
||||
# context: ../../ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: otopcua-ab-server-slc500-faulty
|
||||
# restart: "no"
|
||||
# ports:
|
||||
# - "44819:44819"
|
||||
# # Hostile entrypoint: bind the port but exit immediately so subsequent
|
||||
# # connection attempts get RST'd. Future iteration: a libplctag-aware
|
||||
# # proxy that accepts the CIP open and then drops the wire halfway
|
||||
# # through, exercising the read-timeout path rather than the
|
||||
# # connection-refused path.
|
||||
# entrypoint: ["sh", "-c", "exit 1"]
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PR ablegacy-12 / #255 — auto-demote on consecutive comm failure. After
|
||||
/// <c>FailureThreshold</c> consecutive read or probe failures the driver
|
||||
/// marks the device <c>Demoted</c> for <c>DemoteFor</c>; subsequent reads
|
||||
/// short-circuit with <c>BadCommunicationError</c> without invoking
|
||||
/// libplctag, so one slow PLC sharing the driver thread can't starve faster
|
||||
/// peers. Probe success clears the demote early; read success resets the
|
||||
/// consecutive-failure tally without leaving the demote window.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyAutoDemoteTests
|
||||
{
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
private const string SecondHost = "ab://10.0.0.6/1,0";
|
||||
|
||||
/// <summary>
|
||||
/// Disable the probe by default — every test wants deterministic
|
||||
/// control over the failure tally without a background loop racing
|
||||
/// against the read path.
|
||||
/// </summary>
|
||||
private static AbLegacyDriverOptions BaseOptions(
|
||||
AbLegacyDemoteOptions? demote = null,
|
||||
IReadOnlyList<AbLegacyDeviceOptions>? devices = null,
|
||||
IReadOnlyList<AbLegacyTagDefinition>? tags = null) => new()
|
||||
{
|
||||
Devices = devices ?? [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500, Demote: demote)],
|
||||
Tags = tags ?? [new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
};
|
||||
|
||||
private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver(
|
||||
AbLegacyDemoteOptions? demote = null,
|
||||
IReadOnlyList<AbLegacyDeviceOptions>? devices = null,
|
||||
IReadOnlyList<AbLegacyTagDefinition>? tags = null)
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
var drv = new AbLegacyDriver(BaseOptions(demote, devices, tags), "drv-demote", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
private static FakeAbLegacyTag SeedFailingTag(FakeAbLegacyTagFactory factory)
|
||||
{
|
||||
// Cause every read to throw — exception-driven failures count as
|
||||
// BadCommunicationError per RecordError(commFailure:true).
|
||||
factory.Customise = p => new FakeAbLegacyTag(p)
|
||||
{
|
||||
ThrowOnRead = true,
|
||||
Exception = new TimeoutException("simulated comm failure"),
|
||||
};
|
||||
// Return value is the prototype so a caller that wants to flip the
|
||||
// failure off later can do so via factory.Tags["N7:0"].
|
||||
return null!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Three_consecutive_failures_demote_the_device()
|
||||
{
|
||||
var (drv, factory) = NewDriver();
|
||||
SeedFailingTag(factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
var state = drv.GetDeviceState(Host).ShouldNotBeNull();
|
||||
state.DemotedUntilUtc.ShouldNotBeNull();
|
||||
var snap = drv.DiagnosticTags.Snapshot(Host);
|
||||
snap.DemoteCount.ShouldBe(1);
|
||||
snap.LastDemotedUtc.ShouldNotBeNull();
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Demoted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_while_demoted_short_circuit_without_invoking_libplctag()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyDemoteOptions(FailureThreshold: 3, DemoteFor: TimeSpan.FromMinutes(5)));
|
||||
SeedFailingTag(factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Trip the demotion.
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
var readsBeforeDemote = factory.Tags["N7:0"].ReadCount;
|
||||
|
||||
// Subsequent reads MUST NOT call into libplctag — the short-circuit
|
||||
// returns BadCommunicationError before EnsureTagRuntimeAsync.
|
||||
var result = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
result[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
factory.Tags["N7:0"].ReadCount.ShouldBe(readsBeforeDemote);
|
||||
|
||||
var result2 = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
result2[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
factory.Tags["N7:0"].ReadCount.ShouldBe(readsBeforeDemote);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task After_DemoteFor_expires_next_read_dispatches_through()
|
||||
{
|
||||
// Tiny window so the cool-down expires within the test.
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyDemoteOptions(FailureThreshold: 2, DemoteFor: TimeSpan.FromMilliseconds(50)));
|
||||
SeedFailingTag(factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Trip with two failures.
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
var state = drv.GetDeviceState(Host).ShouldNotBeNull();
|
||||
state.DemotedUntilUtc.ShouldNotBeNull();
|
||||
var readsBeforeWait = factory.Tags["N7:0"].ReadCount;
|
||||
|
||||
// Flip the fake to succeed and wait past the demote window.
|
||||
factory.Tags["N7:0"].ThrowOnRead = false;
|
||||
factory.Tags["N7:0"].Value = 42;
|
||||
factory.Tags["N7:0"].Status = 0;
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(120));
|
||||
|
||||
var result = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
result[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
result[0].Value.ShouldBe(42);
|
||||
// The window expiry path dispatched through to libplctag.
|
||||
factory.Tags["N7:0"].ReadCount.ShouldBeGreaterThan(readsBeforeWait);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_read_resets_consecutive_failure_counter()
|
||||
{
|
||||
var (drv, factory) = NewDriver();
|
||||
// Initial state — every read fails.
|
||||
SeedFailingTag(factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
var state = drv.GetDeviceState(Host).ShouldNotBeNull();
|
||||
state.ConsecutiveFailures.ShouldBe(2);
|
||||
|
||||
// One successful read — flip the existing fake.
|
||||
factory.Tags["N7:0"].ThrowOnRead = false;
|
||||
factory.Tags["N7:0"].Value = 99;
|
||||
factory.Tags["N7:0"].Status = 0;
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
state.ConsecutiveFailures.ShouldBe(0);
|
||||
state.DemotedUntilUtc.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failure_success_failure_does_not_demote_at_threshold_three()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyDemoteOptions(FailureThreshold: 3));
|
||||
SeedFailingTag(factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// 2 failures.
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
// 1 success — counter resets.
|
||||
factory.Tags["N7:0"].ThrowOnRead = false;
|
||||
factory.Tags["N7:0"].Status = 0;
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
// 2 more failures — should still be below the threshold.
|
||||
factory.Tags["N7:0"].ThrowOnRead = true;
|
||||
factory.Tags["N7:0"].Exception = new TimeoutException("flap");
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
var state = drv.GetDeviceState(Host).ShouldNotBeNull();
|
||||
state.DemotedUntilUtc.ShouldBeNull();
|
||||
drv.DiagnosticTags.Snapshot(Host).DemoteCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DemoteCount_and_LastDemotedUtc_surface_via_diagnostic_short_circuit()
|
||||
{
|
||||
var (drv, factory) = NewDriver();
|
||||
SeedFailingTag(factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
// Read the synthetic _Diagnostics counters.
|
||||
var demoteCountRef = $"{AbLegacyDiagnosticTags.DiagnosticsFolderPrefix}{Host}/DemoteCount";
|
||||
var lastDemotedRef = $"{AbLegacyDiagnosticTags.DiagnosticsFolderPrefix}{Host}/LastDemotedUtc";
|
||||
var counts = await drv.ReadAsync([demoteCountRef, lastDemotedRef], CancellationToken.None);
|
||||
|
||||
counts[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
counts[0].Value.ShouldBe(1L);
|
||||
counts[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
counts[1].Value.ShouldBeOfType<string>();
|
||||
((string)counts[1].Value!).Length.ShouldBeGreaterThan(0); // ISO-8601 stamp
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Demote_disabled_never_short_circuits_reads()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyDemoteOptions(FailureThreshold: 1, Enabled: false));
|
||||
SeedFailingTag(factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// 5 failures — would normally trip a single-fail threshold, but Enabled=false.
|
||||
for (var i = 0; i < 5; i++) await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
var state = drv.GetDeviceState(Host).ShouldNotBeNull();
|
||||
state.DemotedUntilUtc.ShouldBeNull();
|
||||
var snap = drv.DiagnosticTags.Snapshot(Host);
|
||||
snap.DemoteCount.ShouldBe(0);
|
||||
// Failures still get recorded as comm errors though — the diagnostic
|
||||
// surface is honest about what happened, just no auto-throttle.
|
||||
snap.CommFailures.ShouldBe(5);
|
||||
// libplctag was invoked every time — that's the whole point of opting out.
|
||||
factory.Tags["N7:0"].ReadCount.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reinit_preserves_DemoteCount_but_clears_active_demotion()
|
||||
{
|
||||
var (drv, factory) = NewDriver();
|
||||
SeedFailingTag(factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
drv.DiagnosticTags.Snapshot(Host).DemoteCount.ShouldBe(1);
|
||||
drv.GetDeviceState(Host)!.DemotedUntilUtc.ShouldNotBeNull();
|
||||
|
||||
await drv.ReinitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Active demotion cleared (the device is freshly tracked); cumulative count survives.
|
||||
drv.GetDeviceState(Host)!.DemotedUntilUtc.ShouldBeNull();
|
||||
drv.GetDeviceState(Host)!.ConsecutiveFailures.ShouldBe(0);
|
||||
drv.DiagnosticTags.Snapshot(Host).DemoteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disposing_driver_after_demotion_does_not_throw()
|
||||
{
|
||||
var (drv, factory) = NewDriver();
|
||||
SeedFailingTag(factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
await drv.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Demote_options_dto_round_trips_through_factory_extensions()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"Devices": [
|
||||
{
|
||||
"HostAddress": "ab://10.0.0.5/1,0",
|
||||
"PlcFamily": "Slc500",
|
||||
"Demote": {
|
||||
"FailureThreshold": 5,
|
||||
"DemoteForMs": 60000,
|
||||
"Enabled": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"Probe": { "Enabled": false },
|
||||
"Tags": [
|
||||
{ "Name": "X", "DeviceHostAddress": "ab://10.0.0.5/1,0", "Address": "N7:0", "DataType": "Int" }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var drv = AbLegacyDriverFactoryExtensions.CreateInstance("drv-demote-roundtrip", json);
|
||||
await drv.InitializeAsync(json, CancellationToken.None);
|
||||
|
||||
var state = drv.GetDeviceState(Host).ShouldNotBeNull();
|
||||
state.Options.Demote.ShouldNotBeNull();
|
||||
state.Options.Demote!.FailureThreshold.ShouldBe(5);
|
||||
state.Options.Demote.EffectiveDemoteFor.ShouldBe(TimeSpan.FromMinutes(1));
|
||||
state.Options.Demote.Enabled.ShouldBeTrue();
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Two_devices_one_faulty_does_not_starve_the_healthy_one()
|
||||
{
|
||||
// Mixed factory — one host's tag throws, the other's reads cleanly.
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
factory.Customise = p =>
|
||||
{
|
||||
// Identify by the Gateway portion of the create params.
|
||||
var fail = p.Gateway == "10.0.0.6";
|
||||
return new FakeAbLegacyTag(p)
|
||||
{
|
||||
ThrowOnRead = fail,
|
||||
Exception = fail ? new TimeoutException("faulty") : null,
|
||||
Value = 42,
|
||||
Status = 0,
|
||||
};
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500),
|
||||
new AbLegacyDeviceOptions(SecondHost, AbLegacyPlcFamily.Slc500),
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new AbLegacyTagDefinition("Healthy", Host, "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("Faulty", SecondHost, "N7:0", AbLegacyDataType.Int),
|
||||
],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-mix", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Trip the faulty side.
|
||||
for (var i = 0; i < 3; i++)
|
||||
await drv.ReadAsync(["Faulty"], CancellationToken.None);
|
||||
|
||||
// Healthy host MUST keep returning Good even though the sibling is demoted.
|
||||
var healthyResult = await drv.ReadAsync(["Healthy"], CancellationToken.None);
|
||||
healthyResult[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
healthyResult[0].Value.ShouldBe(42);
|
||||
|
||||
// Reads against the faulty host short-circuit.
|
||||
var faultyResult = await drv.ReadAsync(["Faulty"], CancellationToken.None);
|
||||
faultyResult[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
|
||||
drv.GetDeviceState(Host)!.DemotedUntilUtc.ShouldBeNull();
|
||||
drv.GetDeviceState(SecondHost)!.DemotedUntilUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BadNodeIdUnknown_does_not_count_toward_demote_tally()
|
||||
{
|
||||
// -14 maps to BadNodeIdUnknown — terminal, not a comm failure.
|
||||
var (drv, factory) = NewDriver();
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Status = -14 };
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
var state = drv.GetDeviceState(Host).ShouldNotBeNull();
|
||||
// Five terminal failures shouldn't trip the demote threshold — they're
|
||||
// a config / decoder mismatch, not a sign of a flapping link.
|
||||
state.DemotedUntilUtc.ShouldBeNull();
|
||||
drv.DiagnosticTags.Snapshot(Host).DemoteCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostState_enum_has_Demoted_value()
|
||||
{
|
||||
// Belt-and-braces: the abstraction surface must carry the new value
|
||||
// for downstream consumers (HostStatusPublisher, Admin UI, …) to
|
||||
// see and route it.
|
||||
Enum.IsDefined(typeof(HostState), HostState.Demoted).ShouldBeTrue();
|
||||
((int)HostState.Demoted).ShouldBeGreaterThan((int)HostState.Faulted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PR ablegacy-13 / #256 — coverage for the 1756-DHRIO DH+ bridge form on
|
||||
/// <see cref="AbLegacyHostAddress"/>: octal-station validation, slot bounds, port shape,
|
||||
/// and the PLC-5-only family guard at <c>AbLegacyDriver.InitializeAsync</c>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyDhPlusBridgingTests
|
||||
{
|
||||
[Theory]
|
||||
// station 07 octal → 7 decimal
|
||||
[InlineData("ab://10.0.0.1/1,3,2,07", 3, 2, 7)]
|
||||
// station 77 octal → 63 decimal (top of the DH+ address range)
|
||||
[InlineData("ab://10.0.0.1/1,3,2,77", 3, 2, 63)]
|
||||
// single-digit station 0..7
|
||||
[InlineData("ab://10.0.0.1/1,0,2,0", 0, 2, 0)]
|
||||
[InlineData("ab://10.0.0.1/1,16,2,77", 16, 2, 63)]
|
||||
// station 10 octal → 8 decimal
|
||||
[InlineData("ab://10.0.0.1/1,3,2,10", 3, 2, 8)]
|
||||
public void DhPlusBridge_parses_valid_octal_station(string input, int slot, int dhPort, int station)
|
||||
{
|
||||
var parsed = AbLegacyHostAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.IsDhPlusBridge.ShouldBeTrue();
|
||||
parsed.BackplaneSlot.ShouldBe(slot);
|
||||
parsed.DhPlusPort.ShouldBe(dhPort);
|
||||
parsed.DhPlusStation.ShouldBe(station);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// octal-illegal digits 8 / 9 in the station segment
|
||||
[InlineData("ab://10.0.0.1/1,3,2,80")]
|
||||
[InlineData("ab://10.0.0.1/1,3,2,90")]
|
||||
[InlineData("ab://10.0.0.1/1,3,2,08")]
|
||||
[InlineData("ab://10.0.0.1/1,3,2,79")]
|
||||
// port-1 not the backplane (must be exactly 1)
|
||||
[InlineData("ab://10.0.0.1/0,3,2,77")]
|
||||
[InlineData("ab://10.0.0.1/2,3,2,77")]
|
||||
// port-3 not the DH+ port (must be exactly 2)
|
||||
[InlineData("ab://10.0.0.1/1,3,3,77")]
|
||||
[InlineData("ab://10.0.0.1/1,3,1,77")]
|
||||
// slot out of range (max 16)
|
||||
[InlineData("ab://10.0.0.1/1,17,2,07")]
|
||||
[InlineData("ab://10.0.0.1/1,99,2,07")]
|
||||
[InlineData("ab://10.0.0.1/1,-1,2,07")]
|
||||
// wrong segment count
|
||||
[InlineData("ab://10.0.0.1/1,3,2")]
|
||||
[InlineData("ab://10.0.0.1/1,3,2,07,extra")]
|
||||
public void DhPlusBridge_rejects_invalid_form(string input)
|
||||
{
|
||||
// The host-address parser still succeeds (the CIP path stays opaque) — only the
|
||||
// DH+ accessors are absent. That keeps AB-Legacy compatible with paths shaped
|
||||
// similarly to a DHRIO bridge but not actually one (e.g. a future DHRIO-clone
|
||||
// module on a non-backplane-1 port).
|
||||
var parsed = AbLegacyHostAddress.TryParse(input);
|
||||
// For the wrong-segment-count and trailing-extra cases, parsing still produces a
|
||||
// generic record with an opaque CipPath and no DH+ surface. For the octal-illegal
|
||||
// and slot/port out-of-range cases the same applies — the CIP path is preserved
|
||||
// verbatim for libplctag, but the structured DH+ accessors are null because the
|
||||
// path didn't pass the strict bridge-form validator.
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.IsDhPlusBridge.ShouldBeFalse();
|
||||
parsed.BackplaneSlot.ShouldBeNull();
|
||||
parsed.DhPlusPort.ShouldBeNull();
|
||||
parsed.DhPlusStation.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DhPlusBridge_diagnostics_surface_all_three_fields()
|
||||
{
|
||||
var parsed = AbLegacyHostAddress.TryParse("ab://10.0.0.1/1,7,2,42");
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.IsDhPlusBridge.ShouldBeTrue();
|
||||
parsed.BackplaneSlot.ShouldBe(7);
|
||||
parsed.DhPlusPort.ShouldBe(2);
|
||||
// 42 octal == 4*8 + 2 == 34 decimal
|
||||
parsed.DhPlusStation.ShouldBe(34);
|
||||
// CIP path is preserved verbatim — libplctag receives the original octal-station
|
||||
// representation, not the decimal-translated one.
|
||||
parsed.CipPath.ShouldBe("1,7,2,42");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonBridgePath_leaves_dhplus_fields_null()
|
||||
{
|
||||
// Direct-wired SLC 5/05 — empty path
|
||||
var direct = AbLegacyHostAddress.TryParse("ab://10.0.0.1/");
|
||||
direct.ShouldNotBeNull();
|
||||
direct.IsDhPlusBridge.ShouldBeFalse();
|
||||
direct.BackplaneSlot.ShouldBeNull();
|
||||
direct.DhPlusPort.ShouldBeNull();
|
||||
direct.DhPlusStation.ShouldBeNull();
|
||||
|
||||
// Standard ControlLogix backplane bridge — only two segments, not four
|
||||
var twoHop = AbLegacyHostAddress.TryParse("ab://10.0.0.1/1,0");
|
||||
twoHop.ShouldNotBeNull();
|
||||
twoHop.IsDhPlusBridge.ShouldBeFalse();
|
||||
twoHop.BackplaneSlot.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500)]
|
||||
[InlineData(AbLegacyPlcFamily.MicroLogix)]
|
||||
[InlineData(AbLegacyPlcFamily.LogixPccc)]
|
||||
public async Task DhPlusBridge_with_non_plc5_family_throws(AbLegacyPlcFamily nonPlc5)
|
||||
{
|
||||
var options = new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = new[]
|
||||
{
|
||||
new AbLegacyDeviceOptions(
|
||||
HostAddress: "ab://10.0.0.1/1,3,2,07",
|
||||
PlcFamily: nonPlc5),
|
||||
},
|
||||
};
|
||||
var driver = new AbLegacyDriver(options, "drv-test", new FakeAbLegacyTagFactory());
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await driver.InitializeAsync("{}", CancellationToken.None));
|
||||
ex.Message.ShouldContain("DHRIO");
|
||||
ex.Message.ShouldContain("PLC-5-only");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DhPlusBridge_with_plc5_family_initialises_cleanly()
|
||||
{
|
||||
var options = new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = new[]
|
||||
{
|
||||
new AbLegacyDeviceOptions(
|
||||
HostAddress: "ab://10.0.0.1/1,3,2,07",
|
||||
PlcFamily: AbLegacyPlcFamily.Plc5),
|
||||
},
|
||||
// Probe disabled so InitializeAsync doesn't try to spin a probe loop against a
|
||||
// non-existent gateway; the only thing under test is the family validation.
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
};
|
||||
var driver = new AbLegacyDriver(options, "drv-test", new FakeAbLegacyTagFactory());
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -173,7 +173,9 @@ public sealed class AbLegacyDiagnosticsTests
|
||||
var diagVars = builder.Variables
|
||||
.Where(v => v.Info.FullName.StartsWith(AbLegacyDiagnosticTags.DiagnosticsFolderPrefix))
|
||||
.ToList();
|
||||
diagVars.Count.ShouldBe(14); // 7 names × 2 devices
|
||||
// PR ablegacy-12 / #255 — DemoteCount + LastDemotedUtc bring the canonical
|
||||
// count to 9 names per device (was 7 in PR ablegacy-10).
|
||||
diagVars.Count.ShouldBe(AbLegacyDiagnosticTags.DiagnosticTagNames.Count * 2);
|
||||
diagVars.ShouldAllBe(v => v.Info.SecurityClass == SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,14 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
|
||||
|
||||
internal sealed class FakeAbLegacyTagFactory : IAbLegacyTagFactory
|
||||
{
|
||||
public Dictionary<string, FakeAbLegacyTag> Tags { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
// PR ablegacy-12 / #255 — switched from plain Dictionary to ConcurrentDictionary so
|
||||
// the read path (test thread) and the probe loop (background Task) can both call
|
||||
// Create without corrupting the dict. Pre-PR-12 the race existed but only tipped
|
||||
// a few percent of test runs into KeyNotFoundException; PR-12's added
|
||||
// Interlocked.Exchange writes shifted timing enough to make it deterministic-flaky
|
||||
// (~60%).
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, FakeAbLegacyTag> Tags { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
public Func<AbLegacyTagCreateParams, FakeAbLegacyTag>? Customise { get; set; }
|
||||
|
||||
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p)
|
||||
|
||||
@@ -40,6 +40,37 @@ internal class FakeFocasClient : IFocasClient
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-d (issue #271) — count of <see cref="UnlockAsync"/> invocations.
|
||||
/// Tests assert this to verify the driver routed <c>cnc_wrunlockparam</c> on
|
||||
/// connect when <c>FocasDeviceOptions.Password</c> was non-null and re-issued
|
||||
/// unlock on EW_PASSWD retry exactly once.
|
||||
/// </summary>
|
||||
public int UnlockCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-d (issue #271) — last password observed by <see cref="UnlockAsync"/>.
|
||||
/// Used by the round-trip test to confirm the driver passed the configured
|
||||
/// password through unmodified. <b>This field exists ONLY in the fake — no
|
||||
/// production wire client retains the password past the wire call.</b>
|
||||
/// </summary>
|
||||
public string? LastUnlockPassword { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-d (issue #271) — when set, <see cref="UnlockAsync"/> throws on
|
||||
/// invocation so tests can drive the failed-unlock retry path (where the
|
||||
/// driver surfaces BadUserAccessDenied as-is rather than retrying).
|
||||
/// </summary>
|
||||
public bool ThrowOnUnlock { get; set; }
|
||||
|
||||
public virtual Task UnlockAsync(string password, CancellationToken ct)
|
||||
{
|
||||
UnlockCount++;
|
||||
LastUnlockPassword = password;
|
||||
if (ThrowOnUnlock) throw Exception ?? new InvalidOperationException("Unlock fails");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken ct)
|
||||
{
|
||||
@@ -91,6 +122,44 @@ internal class FakeFocasClient : IFocasClient
|
||||
return Task.FromResult(status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-c (issue #270) — typed PMC range-write entry point. Records
|
||||
/// the call in <see cref="PmcRangeWriteLog"/> and applies the bytes to
|
||||
/// <see cref="PmcByteRanges"/> at <c>(letter, pathId)</c> so a subsequent
|
||||
/// <see cref="ReadPmcRangeAsync"/> sees the updated bytes (round-trip
|
||||
/// shape). Status looked up by the canonical PMC address (e.g. <c>R100</c>)
|
||||
/// of the first byte if seeded; otherwise Good.
|
||||
/// </summary>
|
||||
public List<(string Letter, int PathId, int StartByte, byte[] Bytes)> PmcRangeWriteLog { get; } = new();
|
||||
|
||||
public virtual Task<uint> WritePmcRangeAsync(
|
||||
string letter, int pathId, int startByte, byte[] bytes, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
|
||||
var copy = bytes.ToArray();
|
||||
PmcRangeWriteLog.Add((letter, pathId, startByte, copy));
|
||||
// Persist into PmcByteRanges so subsequent range reads see the write — this
|
||||
// mirrors the simulator round-trip the integration tests check.
|
||||
var key = (letter.ToUpperInvariant(), pathId);
|
||||
if (!PmcByteRanges.TryGetValue(key, out var src))
|
||||
{
|
||||
src = new byte[startByte + copy.Length];
|
||||
PmcByteRanges[key] = src;
|
||||
}
|
||||
else if (src.Length < startByte + copy.Length)
|
||||
{
|
||||
var grown = new byte[startByte + copy.Length];
|
||||
Array.Copy(src, 0, grown, 0, src.Length);
|
||||
src = grown;
|
||||
PmcByteRanges[key] = src;
|
||||
}
|
||||
Array.Copy(copy, 0, src, startByte, copy.Length);
|
||||
// Status seeded by canonical PMC address of the first byte (no bit index).
|
||||
var canonical = $"{letter.ToUpperInvariant()}{startByte}";
|
||||
var status = WriteStatuses.TryGetValue(canonical, out var sx) ? sx : FocasStatusMapper.Good;
|
||||
return Task.FromResult(status);
|
||||
}
|
||||
|
||||
public List<(int number, int axis, FocasDataType type)> DiagnosticReads { get; } = new();
|
||||
|
||||
public virtual Task<(object? value, uint status)> ReadDiagnosticAsync(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user