Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 746c5decfe | |||
| b217ca61ce | |||
| 26708b6609 | |||
| c88e0b6bed | |||
| 3babfb8a99 | |||
| 30c3b10c94 | |||
| e0f3d1c925 | |||
| 108f69d198 |
@@ -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). |
|
| `--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. |
|
| `--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. |
|
| `--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 |
|
| `--verbose` | off | Serilog debug output |
|
||||||
|
|
||||||
## PUT/GET must be enabled
|
## 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
|
# 80-char S7 string
|
||||||
otopcua-s7-cli read -h 192.168.1.30 -a DB10.STRING[0] -t String --string-length 80
|
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`
|
### `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)
|
endpoint config. See [s7.md TSAP / Connection Type](v2/s7.md#tsap--connection-type)
|
||||||
for the byte table and motivation.
|
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`
|
### `subscribe`
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -217,3 +217,37 @@ in screen-recorded bug reports.
|
|||||||
`--poll-only` polls go through the same cached-handle path as `read`, so
|
`--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
|
repeated polls of the same symbol carry only a 4-byte handle on the wire
|
||||||
rather than the full symbolic path.
|
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.
|
||||||
|
|||||||
@@ -88,6 +88,18 @@ real PLC latency is not exercised.
|
|||||||
S7-1200 vs S7-1500 vs S7-300/400 connection semantics (PG vs OP vs S7-Basic)
|
S7-1200 vs S7-1500 vs S7-300/400 connection semantics (PG vs OP vs S7-Basic)
|
||||||
not differentiated at test time.
|
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
|
### 5. Data types beyond the scalars
|
||||||
|
|
||||||
`STRING` with length-prefix quirks, `DTL` / `DATE_AND_TIME`, arrays of
|
`STRING` with length-prefix quirks, `DTL` / `DATE_AND_TIME`, arrays of
|
||||||
@@ -95,6 +107,41 @@ 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
|
`udt_layout` meta-seed in `Docker/profiles/s7_1500.json` and the
|
||||||
`Driver_fans_out_udt_into_member_tags` integration test.
|
`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
|
## When to trust the S7 tests, when to reach for a rig
|
||||||
|
|
||||||
| Question | Unit tests | Real PLC |
|
| Question | Unit tests | Real PLC |
|
||||||
@@ -127,6 +174,44 @@ structs — not covered. UDT fan-out IS covered (PR-S7-D2 / #300) via the
|
|||||||
runner with the lab rig executes. The classifier branch
|
runner with the lab rig executes. The classifier branch
|
||||||
(`S7PreflightClassifier.IsPutGetDisabled`) is unit-tested without a
|
(`S7PreflightClassifier.IsPutGetDisabled`) is unit-tested without a
|
||||||
network in `S7PreflightTests.Classifier_matches_only_PUT_GET_disabled_error_codes`.
|
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
|
Without any of these, S7 driver correctness against real hardware is trusted
|
||||||
from field deployments, not from the test suite.
|
from field deployments, not from the test suite.
|
||||||
|
|||||||
@@ -89,7 +89,22 @@ default 1024-element cap (UDT per-member coverage; see
|
|||||||
|
|
||||||
Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
|
Capability surfaces whose contract is verified: `IDriver`, `IReadable`,
|
||||||
`IWritable`, `ITagDiscovery`, `ISubscribable`, `IHostConnectivityProbe`,
|
`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
|
## What it does NOT cover
|
||||||
|
|
||||||
@@ -134,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;
|
harness assumes notifications fire on a timer the test controls;
|
||||||
cycle-aligned firing under real PLC control is not verified.
|
cycle-aligned firing under real PLC control is not verified.
|
||||||
|
|
||||||
### 6. Alarms / history
|
### 6. History
|
||||||
|
|
||||||
Driver doesn't implement `IAlarmSource` or `IHistoryProvider` — not in
|
Driver doesn't implement `IHistoryProvider` — not in scope for this
|
||||||
scope for this driver family. TwinCAT 3's TcEventLogger could theoretically
|
driver family. (Alarms now have a dedicated `IAlarmSource` bridge — see
|
||||||
back an `IAlarmSource`, but shipping that is a separate feature.
|
the capability matrix below + `docs/drivers/TwinCAT.md`.)
|
||||||
|
|
||||||
## When to trust TwinCAT tests, when to reach for a rig
|
## 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.
|
||||||
+395
-1
@@ -1,5 +1,190 @@
|
|||||||
# Siemens SIMATIC S7 (S7-1200 / S7-1500 / S7-300 / S7-400 / ET 200SP) — Modbus TCP quirks
|
# 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
|
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
|
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
|
running on the CPU's PROFINET port (S7-1200 / S7-1500 / CPU 1510SP-series
|
||||||
@@ -1030,7 +1215,11 @@ addresses by absolute offset, including UDT-typed DBs.
|
|||||||
|
|
||||||
If a customer can't disable Optimized access (e.g., shared-DB constraints),
|
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
|
the workaround is to expose the UDT through the symbolic-tag path once that
|
||||||
ships — not in PR-S7-D2.
|
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
|
### Validation
|
||||||
|
|
||||||
@@ -1068,6 +1257,211 @@ FB-instance DBs imported via PR-S7-D3 / [#301](https://github.com/dohertj2/lmxop
|
|||||||
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)
|
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.
|
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
|
## 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
|
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.
|
||||||
@@ -50,6 +50,20 @@ public abstract class S7CommandBase : DriverCommandBase
|
|||||||
"the class default under Pg/Op/S7Basic.")]
|
"the class default under Pg/Op/S7Basic.")]
|
||||||
public ushort? RemoteTsap { get; init; }
|
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 />
|
/// <inheritdoc />
|
||||||
public override TimeSpan Timeout
|
public override TimeSpan Timeout
|
||||||
{
|
{
|
||||||
@@ -75,6 +89,8 @@ public abstract class S7CommandBase : DriverCommandBase
|
|||||||
TsapMode = TsapMode,
|
TsapMode = TsapMode,
|
||||||
LocalTsap = LocalTsap,
|
LocalTsap = LocalTsap,
|
||||||
RemoteTsap = RemoteTsap,
|
RemoteTsap = RemoteTsap,
|
||||||
|
Password = string.IsNullOrEmpty(Password) ? null : Password,
|
||||||
|
ProtectionLevel = ProtectionLevel,
|
||||||
};
|
};
|
||||||
|
|
||||||
protected string DriverInstanceId => $"s7-cli-{Host}:{Port}";
|
protected string DriverInstanceId => $"s7-cli-{Host}:{Port}";
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using S7.Net;
|
using S7.Net;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||||
|
|
||||||
@@ -96,6 +99,68 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
private bool _disposed;
|
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) ----
|
// ---- Block-read coalescing diagnostics (PR-S7-B2) ----
|
||||||
//
|
//
|
||||||
// Counters surface through DriverHealth.Diagnostics so the driver-diagnostics
|
// Counters surface through DriverHealth.Diagnostics so the driver-diagnostics
|
||||||
@@ -222,6 +287,21 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
// CPUs negotiate 240 bytes; CPUs running the extended PDU advertise 480 or 960.
|
// CPUs negotiate 240 bytes; CPUs running the extended PDU advertise 480 or 960.
|
||||||
_negotiatedPduSize = plc.MaxPDUSize;
|
_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,
|
// 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
|
// 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
|
// S7-1200 / S7-1500 CPUs that have PUT/GET communication disabled in TIA
|
||||||
@@ -288,6 +368,15 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
// PR-S7-D2 — drop the post-fan-out tag list so a Reinit can rebuild it cleanly
|
// 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.
|
// without the previous run's UDT leaves leaking into the new tag map.
|
||||||
_effectiveTags.Clear();
|
_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);
|
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -308,10 +397,35 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var plc = RequirePlc();
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var results = new DataValueSnapshot[fullReferences.Count];
|
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);
|
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -321,9 +435,9 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
// (arrays, strings, dates, 64-bit ints, UDT-fanout). Packable tags feed
|
// (arrays, strings, dates, 64-bit ints, UDT-fanout). Packable tags feed
|
||||||
// the block-coalescing planner first (PR-S7-B2); whatever survives as a
|
// the block-coalescing planner first (PR-S7-B2); whatever survives as a
|
||||||
// singleton range falls through to the multi-var packer (PR-S7-B1).
|
// 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>();
|
var fallbackIndexes = new List<int>();
|
||||||
for (var i = 0; i < fullReferences.Count; i++)
|
foreach (var i in nonSystemIndexes)
|
||||||
{
|
{
|
||||||
var name = fullReferences[i];
|
var name = fullReferences[i];
|
||||||
if (!_tagsByName.TryGetValue(name, out var tag))
|
if (!_tagsByName.TryGetValue(name, out var tag))
|
||||||
@@ -770,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>
|
/// <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
|
private static global::S7.Net.DataType MapArea(S7Area area) => area switch
|
||||||
{
|
{
|
||||||
@@ -950,6 +1163,73 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
private global::S7.Net.Plc RequirePlc() =>
|
private global::S7.Net.Plc RequirePlc() =>
|
||||||
Plc ?? throw new InvalidOperationException("S7Driver not initialized");
|
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>
|
/// <summary>
|
||||||
/// PR-S7-C5 — issue the post-<c>OpenAsync</c> pre-flight probe read against
|
/// PR-S7-C5 — issue the post-<c>OpenAsync</c> pre-flight probe read against
|
||||||
/// <see cref="S7ProbeOptions.ProbeAddress"/> and translate a "PUT/GET disabled"
|
/// <see cref="S7ProbeOptions.ProbeAddress"/> and translate a "PUT/GET disabled"
|
||||||
@@ -1080,6 +1360,53 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
|||||||
IsAlarm: false,
|
IsAlarm: false,
|
||||||
WriteIdempotent: t.WriteIdempotent));
|
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;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,14 @@ public static class S7DriverFactoryExtensions
|
|||||||
fallback: TsapMode.Auto),
|
fallback: TsapMode.Auto),
|
||||||
LocalTsap = dto.LocalTsap,
|
LocalTsap = dto.LocalTsap,
|
||||||
RemoteTsap = dto.RemoteTsap,
|
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,
|
ScanGroupIntervals = scanGroupMap,
|
||||||
// PR-S7-D2 — UDT layout declarations referenced by tags whose UdtName is set.
|
// 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).
|
// Empty list when the config doesn't declare any UDTs (the typical scalar-only case).
|
||||||
@@ -264,6 +272,8 @@ public static class S7DriverFactoryExtensions
|
|||||||
RemoteTsap = options.RemoteTsap,
|
RemoteTsap = options.RemoteTsap,
|
||||||
ScanGroupIntervals = options.ScanGroupIntervals,
|
ScanGroupIntervals = options.ScanGroupIntervals,
|
||||||
Udts = options.Udts,
|
Udts = options.Udts,
|
||||||
|
Password = options.Password,
|
||||||
|
ProtectionLevel = options.ProtectionLevel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,6 +342,26 @@ public static class S7DriverFactoryExtensions
|
|||||||
/// See <c>docs/v2/s7.md</c> "UDT / STRUCT support" section.
|
/// See <c>docs/v2/s7.md</c> "UDT / STRUCT support" section.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<S7UdtDto>? Udts { get; init; }
|
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
|
internal sealed class S7TagDto
|
||||||
|
|||||||
@@ -151,6 +151,132 @@ public sealed class S7DriverOptions
|
|||||||
/// including the 4-level nesting cap and the Optimized-DB prerequisite.
|
/// including the 4-level nesting cap and the Optimized-DB prerequisite.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<S7UdtDefinition> Udts { get; init; } = [];
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -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,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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
/// resolver land in PRs 2 and 3.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||||
IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable
|
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly TwinCATDriverOptions _options;
|
private readonly TwinCATDriverOptions _options;
|
||||||
private readonly string _driverInstanceId;
|
private readonly string _driverInstanceId;
|
||||||
@@ -17,13 +17,25 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
private readonly PollGroupEngine _poll;
|
private readonly PollGroupEngine _poll;
|
||||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, TwinCATTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, TwinCATTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly TwinCATAlarmSource? _alarmSource;
|
||||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
|
|
||||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
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,
|
public TwinCATDriver(TwinCATDriverOptions options, string driverInstanceId,
|
||||||
ITwinCATClientFactory? clientFactory = null)
|
ITwinCATClientFactory? clientFactory = null,
|
||||||
|
ITwinCATAlarmGate? alarmGate = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
_options = options;
|
_options = options;
|
||||||
@@ -33,6 +45,16 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
reader: ReadAsync,
|
reader: ReadAsync,
|
||||||
onChange: (handle, tagRef, snapshot) =>
|
onChange: (handle, tagRef, snapshot) =>
|
||||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(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;
|
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 { } }
|
foreach (var r in sub.Registrations) { try { r.Dispose(); } catch { } }
|
||||||
_nativeSubs.Clear();
|
_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);
|
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||||
foreach (var state in _devices.Values)
|
foreach (var state in _devices.Values)
|
||||||
{
|
{
|
||||||
@@ -808,6 +840,43 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
|||||||
new HostStatusChangedEventArgs(state.Options.HostAddress, old, newState));
|
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 ----
|
// ---- IPerCallHostResolver ----
|
||||||
|
|
||||||
public string ResolveHost(string fullReference)
|
public string ResolveHost(string fullReference)
|
||||||
|
|||||||
@@ -44,6 +44,23 @@ public sealed class TwinCATDriverOptions
|
|||||||
/// declared in <see cref="Tags"/> (those bypass the walker entirely).
|
/// declared in <see cref="Tags"/> (those bypass the walker entirely).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int MaxArrayExpansion { get; init; } = 1024;
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR-S7-E1 / #302 — integration scaffold for SZL (System Status List) reads against
|
||||||
|
/// a real S7-1500 CPU. snap7 (the simulator that backs <see cref="Snap7ServerFixture"/>)
|
||||||
|
/// does not implement SZL — every <c>@System.*</c> read returns <c>BadNotSupported</c>
|
||||||
|
/// against the simulator — so the asserts here verify the not-supported semantics
|
||||||
|
/// when running against snap7, and the live-firmware tests are gated on a real-PLC
|
||||||
|
/// env-var (<c>S7_LIVE_HOST</c>) the same way other PR-S7-* live tests are.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why scaffolding rather than full live verification?</b> The plan section
|
||||||
|
/// calls for a "live-firmware test against dev-box S7-1500"; that's a hardware-
|
||||||
|
/// gated test and it is parked behind an env-var so the CI pipeline + a fresh
|
||||||
|
/// developer checkout both stay green. The not-supported assertion against snap7
|
||||||
|
/// is the "always-runs" piece — proves the dispatch path lights up + surfaces
|
||||||
|
/// the right StatusCode without a live CPU.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Collection(Snap7ServerCollection.Name)]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
[Trait("Device", "S7_1500")]
|
||||||
|
public sealed class S7_1500SzlTests(Snap7ServerFixture sim)
|
||||||
|
{
|
||||||
|
/// <summary>OPC UA <c>BadNotSupported</c> status code — same constant the driver uses.</summary>
|
||||||
|
private const uint StatusBadNotSupported = 0x803D0000u;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-build the simulator profile with <c>ExposeSystemTags = true</c>. <see cref="S7DriverOptions"/>
|
||||||
|
/// is a class (not a record), so we copy fields manually rather than using a <c>with</c> expression.
|
||||||
|
/// </summary>
|
||||||
|
private static S7DriverOptions BuildOptionsWithSystemTags(string host, int port, int diagBufferDepth = 10)
|
||||||
|
{
|
||||||
|
var baseOpts = S7_1500Profile.BuildOptions(host, port);
|
||||||
|
return new S7DriverOptions
|
||||||
|
{
|
||||||
|
Host = baseOpts.Host,
|
||||||
|
Port = baseOpts.Port,
|
||||||
|
CpuType = baseOpts.CpuType,
|
||||||
|
Rack = baseOpts.Rack,
|
||||||
|
Slot = baseOpts.Slot,
|
||||||
|
Timeout = baseOpts.Timeout,
|
||||||
|
Probe = baseOpts.Probe,
|
||||||
|
Tags = baseOpts.Tags,
|
||||||
|
ExposeSystemTags = true,
|
||||||
|
DiagBufferDepth = diagBufferDepth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task System_CpuType_returns_BadNotSupported_against_snap7_simulator()
|
||||||
|
{
|
||||||
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||||
|
|
||||||
|
var options = BuildOptionsWithSystemTags(sim.Host, sim.Port);
|
||||||
|
await using var drv = new S7Driver(options, driverInstanceId: "s7-szl-cputype");
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var snaps = await drv.ReadAsync(["@System.CpuType"], TestContext.Current.CancellationToken);
|
||||||
|
snaps.Count.ShouldBe(1);
|
||||||
|
// snap7 doesn't implement SZL; the production S7NetSzlReader returns null too
|
||||||
|
// (S7netplus 0.20 has no public ReadSzlAsync surface). Both paths converge on
|
||||||
|
// BadNotSupported.
|
||||||
|
snaps[0].StatusCode.ShouldBe(StatusBadNotSupported);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscoverAsync_emits_diagnostics_folder_against_real_simulator_when_opted_in()
|
||||||
|
{
|
||||||
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||||
|
|
||||||
|
var options = BuildOptionsWithSystemTags(sim.Host, sim.Port, diagBufferDepth: 5);
|
||||||
|
await using var drv = new S7Driver(options, driverInstanceId: "s7-szl-discover");
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var builder = new TestAddressSpaceBuilder();
|
||||||
|
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
builder.Folders.ShouldContain(S7SystemTags.FolderName);
|
||||||
|
builder.Folders.ShouldContain("DiagBuffer");
|
||||||
|
// 6 scalars + 5 buffer entries = 11 system variables.
|
||||||
|
builder.Variables
|
||||||
|
.Count(v => v.FullName.StartsWith(S7SystemTags.Prefix, StringComparison.Ordinal))
|
||||||
|
.ShouldBe(11);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Live-firmware gate: when the env-var <c>S7_LIVE_HOST</c> points at a real
|
||||||
|
/// S7-1500, this test runs end-to-end against the live CPU and expects a
|
||||||
|
/// non-empty CpuType / Firmware / OrderNo. Hardware-gated; CI skips it.
|
||||||
|
/// Currently parked at <see cref="Assert.Skip"/> because S7netplus 0.20 doesn't
|
||||||
|
/// expose a public SZL surface — even against a real CPU the production
|
||||||
|
/// <see cref="S7NetSzlReader"/> returns null. Flip this back on once the
|
||||||
|
/// S7netplus PR for ReadSzlAsync lands or we ship a raw-PDU helper.
|
||||||
|
/// </summary>
|
||||||
|
[Fact(Skip = "Requires real S7-1500 + S7netplus public ReadSzlAsync surface; see PR-S7-E1 docs.")]
|
||||||
|
public Task System_CpuType_against_live_S7_1500_returns_non_empty_string()
|
||||||
|
{
|
||||||
|
// var liveHost = Environment.GetEnvironmentVariable("S7_LIVE_HOST");
|
||||||
|
// if (string.IsNullOrWhiteSpace(liveHost))
|
||||||
|
// Assert.Skip("S7_LIVE_HOST not set — skipping live-firmware SZL test");
|
||||||
|
// var options = new S7DriverOptions { Host = liveHost, ExposeSystemTags = true, ... };
|
||||||
|
// ...
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestAddressSpaceBuilder : Core.Abstractions.IAddressSpaceBuilder
|
||||||
|
{
|
||||||
|
public List<string> Folders { get; } = [];
|
||||||
|
public List<Core.Abstractions.DriverAttributeInfo> Variables { get; } = [];
|
||||||
|
|
||||||
|
public Core.Abstractions.IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||||
|
{
|
||||||
|
Folders.Add(browseName);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Core.Abstractions.IVariableHandle Variable(
|
||||||
|
string browseName, string displayName, Core.Abstractions.DriverAttributeInfo info)
|
||||||
|
{
|
||||||
|
Variables.Add(info);
|
||||||
|
return new StubHandle();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddProperty(string browseName, Core.Abstractions.DriverDataType dataType, object? value) { }
|
||||||
|
|
||||||
|
private sealed class StubHandle : Core.Abstractions.IVariableHandle
|
||||||
|
{
|
||||||
|
public string FullReference => "stub";
|
||||||
|
public Core.Abstractions.IAlarmConditionSink MarkAsAlarmCondition(Core.Abstractions.AlarmConditionInfo info)
|
||||||
|
=> throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR-S7-E2 / #303 — connection-level password + protection-level options-binding
|
||||||
|
/// tests. Verifies the no-log invariant on <see cref="S7DriverOptions.ToString"/>,
|
||||||
|
/// DTO round-trip on the JSON wire form, and the
|
||||||
|
/// <see cref="IS7PlcAuthGate"/> dispatch contract that <see cref="S7Driver"/>
|
||||||
|
/// uses to send the password right after <c>OpenAsync</c>. The live wire path
|
||||||
|
/// (S7-1500 with a real protection password) is hardware-gated and exercised in
|
||||||
|
/// a separate fixture.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class S7PasswordOptionsTests
|
||||||
|
{
|
||||||
|
// ---- Defaults ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Default_Password_is_null_and_ProtectionLevel_is_Auto()
|
||||||
|
{
|
||||||
|
var opts = new S7DriverOptions();
|
||||||
|
opts.Password.ShouldBeNull();
|
||||||
|
opts.ProtectionLevel.ShouldBe(ProtectionLevel.Auto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ToString redaction (no-log invariant) ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToString_redacts_Password_when_set()
|
||||||
|
{
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
Host = "192.168.1.30",
|
||||||
|
Password = "super-secret-123",
|
||||||
|
ProtectionLevel = ProtectionLevel.Level3,
|
||||||
|
};
|
||||||
|
var s = opts.ToString();
|
||||||
|
s.ShouldNotContain("super-secret-123");
|
||||||
|
s.ShouldContain("***");
|
||||||
|
s.ShouldContain("Level3");
|
||||||
|
s.ShouldContain("192.168.1.30");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToString_emits_null_marker_when_Password_is_unset()
|
||||||
|
{
|
||||||
|
var opts = new S7DriverOptions { Host = "192.168.1.30" };
|
||||||
|
var s = opts.ToString();
|
||||||
|
s.ShouldContain("Password = <null>");
|
||||||
|
s.ShouldNotContain("***");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DTO round-trip ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DTO_round_trip_preserves_Password_and_ProtectionLevel()
|
||||||
|
{
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"Host": "192.168.1.30",
|
||||||
|
"CpuType": "S71500",
|
||||||
|
"Password": "p@ssw0rd",
|
||||||
|
"ProtectionLevel": "ConnectionMechanism",
|
||||||
|
"Tags": []
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var drv = (S7Driver)S7DriverFactoryExtensions.CreateInstance("s7-pwd-dto", json);
|
||||||
|
drv.ShouldNotBeNull();
|
||||||
|
drv.DriverInstanceId.ShouldBe("s7-pwd-dto");
|
||||||
|
drv.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DTO_round_trip_serialise_then_deserialise_preserves_Password_field()
|
||||||
|
{
|
||||||
|
var dto = new S7DriverFactoryExtensions.S7DriverConfigDto
|
||||||
|
{
|
||||||
|
Host = "10.0.0.5",
|
||||||
|
Password = "rotational-secret",
|
||||||
|
ProtectionLevel = "Level2",
|
||||||
|
};
|
||||||
|
var json = JsonSerializer.Serialize(dto);
|
||||||
|
var back = JsonSerializer.Deserialize<S7DriverFactoryExtensions.S7DriverConfigDto>(json)!;
|
||||||
|
back.Password.ShouldBe("rotational-secret");
|
||||||
|
back.ProtectionLevel.ShouldBe("Level2");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DTO_explicit_empty_Password_collapses_to_null()
|
||||||
|
{
|
||||||
|
// A typo'd "" Password must NOT try to send an empty password to the PLC.
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"Host": "192.168.1.30",
|
||||||
|
"Password": "",
|
||||||
|
"Tags": []
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
// This goes through the factory -> options pipeline and would fail Init if the
|
||||||
|
// empty-string slipped through. The factory is supposed to coerce to null; we
|
||||||
|
// can't easily probe the bound options without InternalsVisibleTo to the test
|
||||||
|
// factory, but we CAN verify the driver constructs successfully and Init-time
|
||||||
|
// is the only place that would observe a non-null empty password.
|
||||||
|
var drv = S7DriverFactoryExtensions.CreateInstance("s7-empty-pwd", json);
|
||||||
|
drv.ShouldNotBeNull();
|
||||||
|
drv.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DTO_unknown_ProtectionLevel_is_rejected()
|
||||||
|
{
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"Host": "192.168.1.30",
|
||||||
|
"ProtectionLevel": "MysteryMode",
|
||||||
|
"Tags": []
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
Should.Throw<InvalidOperationException>(() =>
|
||||||
|
S7DriverFactoryExtensions.CreateInstance("s7-bad-level", json));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DTO_omitting_Password_and_ProtectionLevel_falls_back_to_defaults()
|
||||||
|
{
|
||||||
|
// Backwards compat: pre-PR-S7-E2 configs must keep loading.
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"Host": "192.168.1.30",
|
||||||
|
"Tags": []
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
var drv = S7DriverFactoryExtensions.CreateInstance("s7-legacy", json);
|
||||||
|
drv.ShouldNotBeNull();
|
||||||
|
drv.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Auth-gate dispatch contract ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Password_null_does_not_call_auth_gate()
|
||||||
|
{
|
||||||
|
var fake = new FakeAuthGate();
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
Host = "192.0.2.1",
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(200),
|
||||||
|
// Probe disabled so we don't need an open socket
|
||||||
|
Probe = new S7ProbeOptions { Enabled = false, ProbeAddress = null },
|
||||||
|
};
|
||||||
|
using var drv = new S7Driver(opts, "s7-no-pwd") { AuthGate = fake };
|
||||||
|
|
||||||
|
// 192.0.2.1 (RFC 5737 TEST-NET-1) is unroutable, so OpenAsync will fail first.
|
||||||
|
await Should.ThrowAsync<Exception>(async () =>
|
||||||
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
// The point: even if Init failed at OpenAsync, the gate must NEVER have been
|
||||||
|
// invoked because Password was null.
|
||||||
|
fake.CallCount.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Password_set_with_unsupported_gate_logs_warning_and_does_not_throw_at_password_step()
|
||||||
|
{
|
||||||
|
var fake = new FakeAuthGate { Supports = false };
|
||||||
|
var capturingLogger = new CapturingLogger<S7Driver>();
|
||||||
|
const string secret = "ZZZ-distinct-secret-not-in-log-messages-ZZZ";
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
Host = "192.0.2.1",
|
||||||
|
Timeout = TimeSpan.FromMilliseconds(200),
|
||||||
|
Password = secret,
|
||||||
|
Probe = new S7ProbeOptions { Enabled = false, ProbeAddress = null },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drive the password code path directly using internals — the unit test seam
|
||||||
|
// exposes Logger / AuthGate. We call the helper via reflection on the driver
|
||||||
|
// method to keep coverage tight without standing up a real PLC.
|
||||||
|
using var drv = new S7Driver(opts, "s7-pwd-no-support")
|
||||||
|
{
|
||||||
|
AuthGate = fake,
|
||||||
|
Logger = capturingLogger,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 192.0.2.1 is unroutable, so InitializeAsync will fail at OpenAsync. The
|
||||||
|
// password step is *after* OpenAsync, so we won't actually reach it through
|
||||||
|
// InitializeAsync against a dead host. Instead drive the helper directly via
|
||||||
|
// its reflection seam.
|
||||||
|
var helper = typeof(S7Driver).GetMethod(
|
||||||
|
"TrySendPlcPasswordAsync",
|
||||||
|
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||||
|
helper.ShouldNotBeNull();
|
||||||
|
|
||||||
|
// Production helper expects a live S7.Net.Plc; pass null since the gate
|
||||||
|
// override means we never dereference it. Method signature accepts Plc + ct.
|
||||||
|
var task = (Task)helper!.Invoke(drv, [null!, TestContext.Current.CancellationToken])!;
|
||||||
|
await task;
|
||||||
|
|
||||||
|
fake.CallCount.ShouldBe(0); // gate.SupportsSendPassword=false → no call
|
||||||
|
capturingLogger.Entries.ShouldContain(e =>
|
||||||
|
e.Level == LogLevel.Warning &&
|
||||||
|
e.Message.Contains("does not expose SendPassword"));
|
||||||
|
// No-log invariant — secret value never appears.
|
||||||
|
capturingLogger.Entries.ShouldNotContain(e => e.Message.Contains(secret));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Password_set_with_supported_gate_invokes_gate_and_logs_success()
|
||||||
|
{
|
||||||
|
var fake = new FakeAuthGate { Supports = true };
|
||||||
|
var capturingLogger = new CapturingLogger<S7Driver>();
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
Host = "192.0.2.1",
|
||||||
|
Password = "rotational-secret",
|
||||||
|
Probe = new S7ProbeOptions { Enabled = false, ProbeAddress = null },
|
||||||
|
};
|
||||||
|
using var drv = new S7Driver(opts, "s7-pwd-ok")
|
||||||
|
{
|
||||||
|
AuthGate = fake,
|
||||||
|
Logger = capturingLogger,
|
||||||
|
};
|
||||||
|
|
||||||
|
var helper = typeof(S7Driver).GetMethod(
|
||||||
|
"TrySendPlcPasswordAsync",
|
||||||
|
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||||
|
var task = (Task)helper!.Invoke(drv, [null!, TestContext.Current.CancellationToken])!;
|
||||||
|
await task;
|
||||||
|
|
||||||
|
fake.CallCount.ShouldBe(1);
|
||||||
|
fake.LastPassword.ShouldBe("rotational-secret");
|
||||||
|
capturingLogger.Entries.ShouldContain(e =>
|
||||||
|
e.Level == LogLevel.Information &&
|
||||||
|
e.Message.Contains("S7 password sent"));
|
||||||
|
// No-log invariant.
|
||||||
|
capturingLogger.Entries.ShouldNotContain(e => e.Message.Contains("rotational-secret"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Password_send_throwing_propagates_clean_InvalidOperationException()
|
||||||
|
{
|
||||||
|
var fake = new FakeAuthGate
|
||||||
|
{
|
||||||
|
Supports = true,
|
||||||
|
ThrowOnSend = new global::S7.Net.PlcException(global::S7.Net.ErrorCode.WrongCPU_Type),
|
||||||
|
};
|
||||||
|
var capturingLogger = new CapturingLogger<S7Driver>();
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
Host = "192.0.2.1",
|
||||||
|
Password = "wrong-pwd",
|
||||||
|
Probe = new S7ProbeOptions { Enabled = false, ProbeAddress = null },
|
||||||
|
};
|
||||||
|
using var drv = new S7Driver(opts, "s7-pwd-bad")
|
||||||
|
{
|
||||||
|
AuthGate = fake,
|
||||||
|
Logger = capturingLogger,
|
||||||
|
};
|
||||||
|
|
||||||
|
var helper = typeof(S7Driver).GetMethod(
|
||||||
|
"TrySendPlcPasswordAsync",
|
||||||
|
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||||
|
// Direct invoke surfaces TargetInvocationException for synchronous throws; for
|
||||||
|
// an async helper the exception flows through the returned Task, so await it.
|
||||||
|
var task = (Task)helper!.Invoke(drv, [null!, TestContext.Current.CancellationToken])!;
|
||||||
|
var ex = await Should.ThrowAsync<InvalidOperationException>(async () => await task);
|
||||||
|
ex.Message.ShouldContain("password authentication failed");
|
||||||
|
// Inner exception preserved for diagnostics.
|
||||||
|
ex.InnerException.ShouldBeOfType<global::S7.Net.PlcException>();
|
||||||
|
// No-log invariant on the exception message itself.
|
||||||
|
ex.Message.ShouldNotContain("wrong-pwd");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Reflection probe sanity (production gate against current S7netplus 0.20) ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reflection_gate_against_S7netplus_0_20_reports_unsupported()
|
||||||
|
{
|
||||||
|
// PR-S7-E2 documented limitation: S7netplus 0.20 does not expose SendPassword.
|
||||||
|
// The reflective probe must report SupportsSendPassword=false on a real Plc
|
||||||
|
// instance built against the pinned package. This test pins the limitation —
|
||||||
|
// when S7netplus ships SendPassword in a future minor release, the test breaks
|
||||||
|
// and signals the team to remove the warning path.
|
||||||
|
var plc = new global::S7.Net.Plc(global::S7.Net.CpuType.S71500, "127.0.0.1", 0, 0);
|
||||||
|
var gate = new ReflectionS7PlcAuthGate(plc);
|
||||||
|
gate.SupportsSendPassword.ShouldBeFalse(
|
||||||
|
"S7netplus 0.20 does not expose SendPassword. If this assertion fails, " +
|
||||||
|
"S7netplus has added the API — update docs/v2/s7.md \"PLC password / " +
|
||||||
|
"protection levels\" library-limitation note and remove the warning path.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Test doubles ----
|
||||||
|
|
||||||
|
private sealed class FakeAuthGate : IS7PlcAuthGate
|
||||||
|
{
|
||||||
|
public bool Supports { get; init; } = true;
|
||||||
|
public bool SupportsSendPassword => Supports;
|
||||||
|
|
||||||
|
public Exception? ThrowOnSend { get; init; }
|
||||||
|
public int CallCount { get; private set; }
|
||||||
|
public string? LastPassword { get; private set; }
|
||||||
|
|
||||||
|
public Task<bool> TrySendPasswordAsync(string password, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!Supports) return Task.FromResult(false);
|
||||||
|
CallCount++;
|
||||||
|
LastPassword = password;
|
||||||
|
if (ThrowOnSend is not null) throw ThrowOnSend;
|
||||||
|
return Task.FromResult(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record CapturedLogEntry(LogLevel Level, string Message);
|
||||||
|
|
||||||
|
private sealed class CapturingLogger<T> : ILogger<T>
|
||||||
|
{
|
||||||
|
public List<CapturedLogEntry> Entries { get; } = new();
|
||||||
|
|
||||||
|
IDisposable? ILogger.BeginScope<TState>(TState state) => NullScope.Instance;
|
||||||
|
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => true;
|
||||||
|
|
||||||
|
public void Log<TState>(
|
||||||
|
LogLevel logLevel,
|
||||||
|
EventId eventId,
|
||||||
|
TState state,
|
||||||
|
Exception? exception,
|
||||||
|
Func<TState, Exception?, string> formatter)
|
||||||
|
{
|
||||||
|
Entries.Add(new CapturedLogEntry(logLevel, formatter(state, exception)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullScope : IDisposable
|
||||||
|
{
|
||||||
|
public static readonly NullScope Instance = new();
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.Szl;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR-S7-E1 — driver-side wiring for SZL-backed <c>@System.*</c> virtual addresses.
|
||||||
|
/// Tests run without a real PLC by injecting an <see cref="IS7SzlReader"/> fake and
|
||||||
|
/// calling <see cref="S7Driver.ReadAsync"/> against <c>@System.*</c> references — the
|
||||||
|
/// driver short-circuits those before <c>RequirePlc()</c> so the read path lights up
|
||||||
|
/// without touching the wire.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class S7SystemTagsTests
|
||||||
|
{
|
||||||
|
private sealed class FakeSzlReader(Func<ushort, ushort, byte[]?> respond) : IS7SzlReader
|
||||||
|
{
|
||||||
|
public int CallCount { get; private set; }
|
||||||
|
public List<(ushort SzlId, ushort SzlIndex)> Calls { get; } = new();
|
||||||
|
|
||||||
|
public Task<byte[]?> ReadSzlAsync(ushort szlId, ushort szlIndex, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
CallCount++;
|
||||||
|
Calls.Add((szlId, szlIndex));
|
||||||
|
return Task.FromResult(respond(szlId, szlIndex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
|
||||||
|
{
|
||||||
|
public List<string> Folders { get; } = new();
|
||||||
|
public List<(string Browse, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||||
|
|
||||||
|
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||||
|
{
|
||||||
|
Folders.Add(browseName);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||||
|
{
|
||||||
|
Variables.Add((browseName, attributeInfo));
|
||||||
|
return new StubHandle();
|
||||||
|
}
|
||||||
|
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||||
|
|
||||||
|
private sealed class StubHandle : IVariableHandle
|
||||||
|
{
|
||||||
|
public string FullReference => "stub";
|
||||||
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscoverAsync_emits_no_diagnostics_folder_when_ExposeSystemTags_is_false()
|
||||||
|
{
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
Host = "192.0.2.1",
|
||||||
|
Tags = [new S7TagDefinition("Setpoint", "DB1.DBW0", S7DataType.Int16)],
|
||||||
|
};
|
||||||
|
using var drv = new S7Driver(opts, "s7-disco-no-system");
|
||||||
|
var builder = new RecordingAddressSpaceBuilder();
|
||||||
|
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
builder.Folders.ShouldNotContain(S7SystemTags.FolderName);
|
||||||
|
builder.Variables.Select(v => v.Info.FullName)
|
||||||
|
.ShouldNotContain(n => n.StartsWith(S7SystemTags.Prefix, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscoverAsync_emits_diagnostics_folder_with_six_scalars_and_ten_buffer_entries()
|
||||||
|
{
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
Host = "192.0.2.1",
|
||||||
|
ExposeSystemTags = true,
|
||||||
|
// Default DiagBufferDepth = 10 — six scalars + ten entries = 16 system variables.
|
||||||
|
Tags = [],
|
||||||
|
};
|
||||||
|
using var drv = new S7Driver(opts, "s7-disco-with-system");
|
||||||
|
var builder = new RecordingAddressSpaceBuilder();
|
||||||
|
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
builder.Folders.ShouldContain(S7SystemTags.FolderName);
|
||||||
|
builder.Folders.ShouldContain("DiagBuffer");
|
||||||
|
|
||||||
|
var systemVars = builder.Variables
|
||||||
|
.Where(v => v.Info.FullName.StartsWith(S7SystemTags.Prefix, StringComparison.Ordinal))
|
||||||
|
.ToList();
|
||||||
|
systemVars.Count.ShouldBe(16); // 6 scalars + 10 buffer entries
|
||||||
|
|
||||||
|
// CpuType / Firmware / OrderNo project as String; CycleMs.* as Float64.
|
||||||
|
systemVars.ShouldContain(v => v.Info.FullName == "@System.CpuType" && v.Info.DriverDataType == DriverDataType.String);
|
||||||
|
systemVars.ShouldContain(v => v.Info.FullName == "@System.CycleMs.Avg" && v.Info.DriverDataType == DriverDataType.Float64);
|
||||||
|
|
||||||
|
// Diagnostics tags are ViewOnly — never writable.
|
||||||
|
systemVars.ShouldAllBe(v => v.Info.SecurityClass == SecurityClassification.ViewOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DiscoverAsync_honours_custom_DiagBufferDepth()
|
||||||
|
{
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
ExposeSystemTags = true,
|
||||||
|
DiagBufferDepth = 3,
|
||||||
|
Tags = [],
|
||||||
|
};
|
||||||
|
using var drv = new S7Driver(opts, "s7-disco-custom-depth");
|
||||||
|
var builder = new RecordingAddressSpaceBuilder();
|
||||||
|
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var bufferEntries = builder.Variables
|
||||||
|
.Where(v => v.Info.FullName.StartsWith(S7SystemTags.DiagBufferEntryPrefix, StringComparison.Ordinal))
|
||||||
|
.ToList();
|
||||||
|
bufferEntries.Count.ShouldBe(3);
|
||||||
|
bufferEntries[0].Info.FullName.ShouldBe("@System.DiagBuffer.Entry[0]");
|
||||||
|
bufferEntries[2].Info.FullName.ShouldBe("@System.DiagBuffer.Entry[2]");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadAsync_returns_parsed_CpuType_via_injected_reader()
|
||||||
|
{
|
||||||
|
var info = new S7CpuInfo("CPU 1215C", "V4.5.0", "6ES7 215-1AG40-0XB0");
|
||||||
|
var reader = new FakeSzlReader((id, idx) =>
|
||||||
|
id == S7SzlIds.ModuleIdentification ? S7SzlParser.EncodeCpuInfo(info) : null);
|
||||||
|
|
||||||
|
var opts = new S7DriverOptions { ExposeSystemTags = true, Tags = [] };
|
||||||
|
using var drv = new S7Driver(opts, "s7-cputype") { SzlReader = reader };
|
||||||
|
|
||||||
|
var snaps = await drv.ReadAsync(["@System.CpuType"], TestContext.Current.CancellationToken);
|
||||||
|
snaps.Count.ShouldBe(1);
|
||||||
|
snaps[0].StatusCode.ShouldBe(0u);
|
||||||
|
snaps[0].Value.ShouldBe("CPU 1215C");
|
||||||
|
reader.CallCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadAsync_returns_BadNotSupported_when_reader_returns_null()
|
||||||
|
{
|
||||||
|
var reader = new FakeSzlReader((_, _) => null); // snap7 / S7netplus 0.20 path
|
||||||
|
var opts = new S7DriverOptions { ExposeSystemTags = true, Tags = [] };
|
||||||
|
using var drv = new S7Driver(opts, "s7-not-supported") { SzlReader = reader };
|
||||||
|
|
||||||
|
var snaps = await drv.ReadAsync(["@System.CpuType", "@System.Firmware"], TestContext.Current.CancellationToken);
|
||||||
|
snaps.Count.ShouldBe(2);
|
||||||
|
snaps[0].StatusCode.ShouldBe(0x803D0000u, "BadNotSupported");
|
||||||
|
snaps[0].Value.ShouldBeNull();
|
||||||
|
snaps[1].StatusCode.ShouldBe(0x803D0000u);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadAsync_caches_SZL_payload_within_TTL()
|
||||||
|
{
|
||||||
|
var info = new S7CpuInfo("CPU 1516", "V2.9.4", "6ES7 516-3AN01-0AB0");
|
||||||
|
var reader = new FakeSzlReader((_, _) => S7SzlParser.EncodeCpuInfo(info));
|
||||||
|
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
ExposeSystemTags = true,
|
||||||
|
// 1-hour TTL so the second read inside the test definitely hits the cache.
|
||||||
|
SzlCacheTtl = TimeSpan.FromHours(1),
|
||||||
|
Tags = [],
|
||||||
|
};
|
||||||
|
using var drv = new S7Driver(opts, "s7-cache-hit") { SzlReader = reader };
|
||||||
|
|
||||||
|
var first = await drv.ReadAsync(["@System.CpuType"], TestContext.Current.CancellationToken);
|
||||||
|
var second = await drv.ReadAsync(["@System.Firmware"], TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
first[0].Value.ShouldBe("CPU 1516");
|
||||||
|
second[0].Value.ShouldBe("V2.9.4");
|
||||||
|
// Both projections come from the same SZL 0x0011 payload — exactly one wire call.
|
||||||
|
reader.CallCount.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadAsync_misses_cache_when_TTL_is_zero()
|
||||||
|
{
|
||||||
|
var info = new S7CpuInfo("CPU 1516", "V2.9.4", "6ES7 516-3AN01-0AB0");
|
||||||
|
var reader = new FakeSzlReader((_, _) => S7SzlParser.EncodeCpuInfo(info));
|
||||||
|
|
||||||
|
var opts = new S7DriverOptions
|
||||||
|
{
|
||||||
|
ExposeSystemTags = true,
|
||||||
|
SzlCacheTtl = TimeSpan.Zero,
|
||||||
|
Tags = [],
|
||||||
|
};
|
||||||
|
using var drv = new S7Driver(opts, "s7-cache-miss") { SzlReader = reader };
|
||||||
|
|
||||||
|
await drv.ReadAsync(["@System.CpuType"], TestContext.Current.CancellationToken);
|
||||||
|
await drv.ReadAsync(["@System.CpuType"], TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// TTL=Zero means every read goes to the wire.
|
||||||
|
reader.CallCount.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadAsync_returns_BadNodeIdUnknown_for_unrecognised_system_address()
|
||||||
|
{
|
||||||
|
var reader = new FakeSzlReader((_, _) => null);
|
||||||
|
var opts = new S7DriverOptions { ExposeSystemTags = true, Tags = [] };
|
||||||
|
using var drv = new S7Driver(opts, "s7-bad-system") { SzlReader = reader };
|
||||||
|
|
||||||
|
var snaps = await drv.ReadAsync(["@System.NotARealField"], TestContext.Current.CancellationToken);
|
||||||
|
snaps.Count.ShouldBe(1);
|
||||||
|
snaps[0].StatusCode.ShouldBe(0x80340000u, "BadNodeIdUnknown");
|
||||||
|
// No SZL wire call — the address didn't resolve to a known descriptor.
|
||||||
|
reader.CallCount.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadAsync_returns_diag_buffer_entry_string_when_reader_supplies_payload()
|
||||||
|
{
|
||||||
|
var entries = new[]
|
||||||
|
{
|
||||||
|
new S7DiagBufferEntry(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero), 0xCAFE, 3, "Event 0xCAFE (priority 3)"),
|
||||||
|
new S7DiagBufferEntry(new DateTimeOffset(2024, 1, 1, 0, 0, 5, TimeSpan.Zero), 0xBEEF, 4, "Event 0xBEEF (priority 4)"),
|
||||||
|
};
|
||||||
|
var reader = new FakeSzlReader((id, _) =>
|
||||||
|
id == S7SzlIds.DiagnosticBuffer ? S7SzlParser.EncodeDiagBuffer(entries) : null);
|
||||||
|
|
||||||
|
var opts = new S7DriverOptions { ExposeSystemTags = true, DiagBufferDepth = 5, Tags = [] };
|
||||||
|
using var drv = new S7Driver(opts, "s7-diag") { SzlReader = reader };
|
||||||
|
|
||||||
|
var snaps = await drv.ReadAsync(
|
||||||
|
["@System.DiagBuffer.Entry[0]", "@System.DiagBuffer.Entry[1]"],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
snaps.Count.ShouldBe(2);
|
||||||
|
snaps[0].StatusCode.ShouldBe(0u);
|
||||||
|
((string)snaps[0].Value!).ShouldContain("0xCAFE");
|
||||||
|
((string)snaps[1].Value!).ShouldContain("0xBEEF");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadAsync_mixes_system_tags_with_unknown_regular_tags_returning_status_per_request()
|
||||||
|
{
|
||||||
|
var info = new S7CpuInfo("CPU 1500", "V2.9", "6ES7");
|
||||||
|
var reader = new FakeSzlReader((_, _) => S7SzlParser.EncodeCpuInfo(info));
|
||||||
|
var opts = new S7DriverOptions { ExposeSystemTags = true, Tags = [] };
|
||||||
|
using var drv = new S7Driver(opts, "s7-mixed") { SzlReader = reader };
|
||||||
|
|
||||||
|
// SystemTag should resolve via the short-circuit; "NoSuchTag" should never reach
|
||||||
|
// the Plc gate because the only other request was a system tag — but if it does,
|
||||||
|
// the test must not hang (RequirePlc would throw). Both are short-circuited so
|
||||||
|
// the call returns BadNodeIdUnknown for "NoSuchTag" via the system-prefix check
|
||||||
|
// failing. To exercise that, request both as @System.* references — one valid,
|
||||||
|
// one bogus.
|
||||||
|
var snaps = await drv.ReadAsync(
|
||||||
|
["@System.CpuType", "@System.Bogus"],
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
snaps[0].Value.ShouldBe("CPU 1500");
|
||||||
|
snaps[0].StatusCode.ShouldBe(0u);
|
||||||
|
snaps[1].StatusCode.ShouldBe(0x80340000u, "BadNodeIdUnknown for unrecognised @System.* address");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.Szl;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR-S7-E1 — golden-byte tests for <see cref="S7SzlParser"/>. Each test hand-crafts a
|
||||||
|
/// structurally-valid SZL response payload (matching the layout in the Siemens function
|
||||||
|
/// manual, §"SSL-IDs") and asserts the parser projects every field the driver surfaces
|
||||||
|
/// through <c>@System.*</c>. Round-trip tests prove encode-then-decode is the identity
|
||||||
|
/// so test fixtures stay self-consistent without leaning on real PLC traffic.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class S7SzlParserTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ParseCpuInfo_decodes_module_identification_records()
|
||||||
|
{
|
||||||
|
// Hand-craft a SZL 0x0011 response with three records:
|
||||||
|
// index 0x0001 — MLFB "6ES7 516-3AN01-0AB0 " (20 bytes ASCII)
|
||||||
|
// index 0x0006 — firmware Vmajor.minor.patch encoded in Ausbg1/Ausbg2
|
||||||
|
// index 0x0007 — friendly CPU name "CPU 1516-3 PN/DP"
|
||||||
|
var info = new S7CpuInfo(
|
||||||
|
CpuType: "CPU 1516-3 PN/DP",
|
||||||
|
Firmware: "V2.9.4",
|
||||||
|
OrderNo: "6ES7 516-3AN01-0AB0");
|
||||||
|
var payload = S7SzlParser.EncodeCpuInfo(info);
|
||||||
|
|
||||||
|
var parsed = S7SzlParser.ParseCpuInfo(payload);
|
||||||
|
parsed.OrderNo.ShouldBe("6ES7 516-3AN01-0AB0");
|
||||||
|
parsed.CpuType.ShouldBe("CPU 1516-3 PN/DP");
|
||||||
|
parsed.Firmware.ShouldBe("V2.9.4");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseCpuInfo_handles_missing_records_with_unknown_fallback()
|
||||||
|
{
|
||||||
|
// Header claims zero records — every field falls back to "(unknown)" rather than throwing.
|
||||||
|
var buf = new byte[S7SzlParser.HeaderLength];
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), S7SzlIds.ModuleIdentification);
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(2, 2), 0);
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), 28); // record length valid, count = 0
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), 0);
|
||||||
|
|
||||||
|
var parsed = S7SzlParser.ParseCpuInfo(buf);
|
||||||
|
parsed.CpuType.ShouldBe("(unknown)");
|
||||||
|
parsed.Firmware.ShouldBe("(unknown)");
|
||||||
|
parsed.OrderNo.ShouldBe("(unknown)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseCycleStats_decodes_min_max_avg_milliseconds()
|
||||||
|
{
|
||||||
|
// Hand-craft SZL 0x0132 with avg=10ms, min=5ms, max=42ms.
|
||||||
|
var stats = new S7CycleStats(MinMs: 5, MaxMs: 42, AvgMs: 10);
|
||||||
|
var payload = S7SzlParser.EncodeCycleStats(stats);
|
||||||
|
|
||||||
|
var parsed = S7SzlParser.ParseCycleStats(payload);
|
||||||
|
parsed.MinMs.ShouldBe(5);
|
||||||
|
parsed.MaxMs.ShouldBe(42);
|
||||||
|
parsed.AvgMs.ShouldBe(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseDiagBuffer_decodes_five_entries_with_timestamps_and_event_ids()
|
||||||
|
{
|
||||||
|
var entries = new List<S7DiagBufferEntry>
|
||||||
|
{
|
||||||
|
new(new DateTimeOffset(2024, 1, 15, 8, 30, 0, TimeSpan.Zero), 0x113A, 1, "Event 0x113A (priority 1)"),
|
||||||
|
new(new DateTimeOffset(2024, 1, 15, 8, 31, 5, TimeSpan.Zero), 0x4302, 5, "Event 0x4302 (priority 5)"),
|
||||||
|
new(new DateTimeOffset(2024, 1, 15, 8, 32, 17, TimeSpan.Zero), 0x4308, 5, "Event 0x4308 (priority 5)"),
|
||||||
|
new(new DateTimeOffset(2024, 1, 15, 8, 33, 42, TimeSpan.Zero), 0x39C0, 26, "Event 0x39C0 (priority 26)"),
|
||||||
|
new(new DateTimeOffset(2024, 1, 15, 8, 34, 59, TimeSpan.Zero), 0x4505, 1, "Event 0x4505 (priority 1)"),
|
||||||
|
};
|
||||||
|
var payload = S7SzlParser.EncodeDiagBuffer(entries);
|
||||||
|
|
||||||
|
var parsed = S7SzlParser.ParseDiagBuffer(payload, maxEntries: 10);
|
||||||
|
parsed.Count.ShouldBe(5);
|
||||||
|
parsed[0].EventId.ShouldBe((ushort)0x113A);
|
||||||
|
parsed[0].Priority.ShouldBe((byte)1);
|
||||||
|
parsed[0].OccurrenceUtc.Year.ShouldBe(2024);
|
||||||
|
parsed[0].OccurrenceUtc.Month.ShouldBe(1);
|
||||||
|
parsed[0].OccurrenceUtc.Day.ShouldBe(15);
|
||||||
|
parsed[0].OccurrenceUtc.Hour.ShouldBe(8);
|
||||||
|
parsed[0].OccurrenceUtc.Minute.ShouldBe(30);
|
||||||
|
|
||||||
|
parsed[3].EventId.ShouldBe((ushort)0x39C0);
|
||||||
|
parsed[3].Priority.ShouldBe((byte)26);
|
||||||
|
parsed[3].OccurrenceUtc.Second.ShouldBe(42);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseDiagBuffer_caps_entries_to_caller_supplied_max()
|
||||||
|
{
|
||||||
|
var entries = new List<S7DiagBufferEntry>();
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
entries.Add(new(DateTimeOffset.UnixEpoch, (ushort)(0x1000 + i), 1, ""));
|
||||||
|
var payload = S7SzlParser.EncodeDiagBuffer(entries);
|
||||||
|
|
||||||
|
var parsed = S7SzlParser.ParseDiagBuffer(payload, maxEntries: 3);
|
||||||
|
parsed.Count.ShouldBe(3);
|
||||||
|
parsed[0].EventId.ShouldBe((ushort)0x1000);
|
||||||
|
parsed[2].EventId.ShouldBe((ushort)0x1002);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseCpuInfo_throws_on_truncated_payload()
|
||||||
|
{
|
||||||
|
// Header claims 28-byte records × 3 but body is only 4 bytes — should reject.
|
||||||
|
var buf = new byte[S7SzlParser.HeaderLength + 4];
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), S7SzlIds.ModuleIdentification);
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), 28);
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), 3);
|
||||||
|
|
||||||
|
Should.Throw<ArgumentException>(() => S7SzlParser.ParseCpuInfo(buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseCycleStats_throws_on_short_record()
|
||||||
|
{
|
||||||
|
// Record length advertised as 8 bytes — too short for the cycle-time payload.
|
||||||
|
var buf = new byte[S7SzlParser.HeaderLength + 8];
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), S7SzlIds.CpuStatusData);
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), 8);
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), 1);
|
||||||
|
|
||||||
|
Should.Throw<ArgumentException>(() => S7SzlParser.ParseCycleStats(buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseDiagBuffer_throws_on_wrong_record_length()
|
||||||
|
{
|
||||||
|
// SZL 0x00A0 records are exactly 20 bytes; 16 should be rejected.
|
||||||
|
var buf = new byte[S7SzlParser.HeaderLength + 16];
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), S7SzlIds.DiagnosticBuffer);
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), 16);
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), 1);
|
||||||
|
|
||||||
|
Should.Throw<ArgumentException>(() => S7SzlParser.ParseDiagBuffer(buf, maxEntries: 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parser_throws_on_header_only_truncation()
|
||||||
|
{
|
||||||
|
Should.Throw<ArgumentException>(() => S7SzlParser.ParseCpuInfo(new byte[4]));
|
||||||
|
Should.Throw<ArgumentException>(() => S7SzlParser.ParseCycleStats(new byte[2]));
|
||||||
|
Should.Throw<ArgumentException>(() => S7SzlParser.ParseDiagBuffer(new byte[3], maxEntries: 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Round_trip_encode_then_parse_preserves_cpu_info()
|
||||||
|
{
|
||||||
|
var original = new S7CpuInfo("CPU 1215C", "V4.5.0", "6ES7 215-1AG40-0XB0");
|
||||||
|
var enc = S7SzlParser.EncodeCpuInfo(original);
|
||||||
|
var dec = S7SzlParser.ParseCpuInfo(enc);
|
||||||
|
dec.CpuType.ShouldBe(original.CpuType);
|
||||||
|
dec.Firmware.ShouldBe(original.Firmware);
|
||||||
|
dec.OrderNo.ShouldBe(original.OrderNo);
|
||||||
|
|
||||||
|
// Re-encode the parsed result and parse again — must equal the first decode.
|
||||||
|
var reenc = S7SzlParser.EncodeCpuInfo(dec);
|
||||||
|
var redec = S7SzlParser.ParseCpuInfo(reenc);
|
||||||
|
redec.ShouldBe(dec);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Round_trip_encode_then_parse_preserves_cycle_stats()
|
||||||
|
{
|
||||||
|
var original = new S7CycleStats(MinMs: 1, MaxMs: 999, AvgMs: 7);
|
||||||
|
var enc = S7SzlParser.EncodeCycleStats(original);
|
||||||
|
var dec = S7SzlParser.ParseCycleStats(enc);
|
||||||
|
dec.ShouldBe(original);
|
||||||
|
|
||||||
|
var reenc = S7SzlParser.EncodeCycleStats(dec);
|
||||||
|
var redec = S7SzlParser.ParseCycleStats(reenc);
|
||||||
|
redec.ShouldBe(dec);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Round_trip_encode_then_parse_preserves_diag_buffer_event_ids_and_priority()
|
||||||
|
{
|
||||||
|
// Use UTC midnight aligned timestamps so the BCD encoder's second-precision rounding
|
||||||
|
// (ms isn't round-tripped) doesn't cause a comparison miss.
|
||||||
|
var original = new[]
|
||||||
|
{
|
||||||
|
new S7DiagBufferEntry(new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero), 0xAAAA, 5, "Event 0xAAAA (priority 5)"),
|
||||||
|
new S7DiagBufferEntry(new DateTimeOffset(2024, 6, 2, 0, 30, 15, TimeSpan.Zero), 0xBBBB, 10, "Event 0xBBBB (priority 10)"),
|
||||||
|
};
|
||||||
|
var enc = S7SzlParser.EncodeDiagBuffer(original);
|
||||||
|
var dec = S7SzlParser.ParseDiagBuffer(enc, maxEntries: 10);
|
||||||
|
dec.Count.ShouldBe(2);
|
||||||
|
|
||||||
|
// EventId / Priority round-trip exactly; OccurrenceUtc round-trips at second precision.
|
||||||
|
dec[0].EventId.ShouldBe(original[0].EventId);
|
||||||
|
dec[0].Priority.ShouldBe(original[0].Priority);
|
||||||
|
dec[0].OccurrenceUtc.ShouldBe(original[0].OccurrenceUtc);
|
||||||
|
dec[1].EventId.ShouldBe(original[1].EventId);
|
||||||
|
dec[1].OccurrenceUtc.ShouldBe(original[1].OccurrenceUtc);
|
||||||
|
|
||||||
|
// Re-encode + re-parse should yield the same decoded list.
|
||||||
|
var reenc = S7SzlParser.EncodeDiagBuffer(dec);
|
||||||
|
var redec = S7SzlParser.ParseDiagBuffer(reenc, maxEntries: 10);
|
||||||
|
redec.Count.ShouldBe(dec.Count);
|
||||||
|
for (var i = 0; i < dec.Count; i++)
|
||||||
|
{
|
||||||
|
redec[i].EventId.ShouldBe(dec[i].EventId);
|
||||||
|
redec[i].Priority.ShouldBe(dec[i].Priority);
|
||||||
|
redec[i].OccurrenceUtc.ShouldBe(dec[i].OccurrenceUtc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 5.1 / #316 — end-to-end alarm-integration scaffold against a live TwinCAT 3 XAR
|
||||||
|
/// runtime. Skipped via <see cref="TwinCATFactAttribute"/> when the VM isn't reachable.
|
||||||
|
/// Proves the driver's <see cref="IAlarmSource"/> bridge surfaces TC3 EventLogger events
|
||||||
|
/// when the PLC's <c>FB_AlarmHarness</c> calls <c>FB_TcLogEvent</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para><b>Required VM project state</b> (see <c>TwinCatProject/README.md</c>
|
||||||
|
/// §"Alarm scenarios"):</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>GVL <c>GVL_Alarms</c> with <c>bTriggerEvent : BOOL</c> + <c>bAcked : BOOL</c>.
|
||||||
|
/// A test harness flips <c>bTriggerEvent</c> from <c>FALSE</c> to <c>TRUE</c> via the
|
||||||
|
/// driver's <c>WriteAsync</c> path; <c>FB_AlarmHarness</c> sees the rising edge +
|
||||||
|
/// calls <c>FB_TcLogEvent</c> on the PLC side.</item>
|
||||||
|
/// <item>FB <c>FB_AlarmHarness</c> wired into <c>MAIN</c> that calls <c>FB_TcLogEvent</c>
|
||||||
|
/// with a configured event class GUID + severity + source string when
|
||||||
|
/// <c>GVL_Alarms.bTriggerEvent</c> rises.</item>
|
||||||
|
/// </list>
|
||||||
|
/// <para><b>Decode caveat</b> — Beckhoff doesn't ship a managed wrapper for
|
||||||
|
/// <c>TcEventLogger</c> in <c>Beckhoff.TwinCAT.Ads</c> v6, so the production
|
||||||
|
/// gate is best-effort and several event fields may surface as <c>"Unknown"</c>.
|
||||||
|
/// This test asserts the bridge fires + the event has non-empty content; field-level
|
||||||
|
/// decode tightening lands on a follow-up PR (see
|
||||||
|
/// <c>docs/v3/twincat-eventlogger-spike.md</c>).</para>
|
||||||
|
/// </remarks>
|
||||||
|
[Collection("TwinCATXar")]
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
[Trait("Simulator", "TwinCAT-XAR")]
|
||||||
|
public sealed class TwinCATAlarmIntegrationTests(TwinCATXarFixture sim)
|
||||||
|
{
|
||||||
|
[TwinCATFact]
|
||||||
|
public async Task Driver_raises_alarm_event_when_PLC_logs_event()
|
||||||
|
{
|
||||||
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||||
|
|
||||||
|
// Fixture-side state is documented in TwinCatProject/README.md §"Alarm scenarios".
|
||||||
|
// The harness is currently a build-only placeholder — once the GVL + FB_AlarmHarness
|
||||||
|
// ship, replace the Skip below with the live-trigger flow:
|
||||||
|
// 1. Init driver with EnableAlarms=true + GVL_Alarms.bTriggerEvent declared as a
|
||||||
|
// writable BOOL tag.
|
||||||
|
// 2. SubscribeAlarmsAsync([], ct).
|
||||||
|
// 3. WriteAsync to flip bTriggerEvent from FALSE to TRUE — the PLC's
|
||||||
|
// FB_AlarmHarness sees the rising edge + calls FB_TcLogEvent.
|
||||||
|
// 4. Assert OnAlarmEvent fires within ~5s with a non-empty Source + Message.
|
||||||
|
Assert.Skip(
|
||||||
|
"PR 5.1 / #316 — alarm-integration build-only scaffold. The GVL_Alarms + " +
|
||||||
|
"FB_AlarmHarness fixture hasn't been authored on the XAR project yet (build-time " +
|
||||||
|
"stubs ship under TwinCatProject/PLC; live trigger lands once the XAR project " +
|
||||||
|
"imports them). Until then this test self-skips even when the runtime is up.");
|
||||||
|
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the AMS options with <see cref="TwinCATDriverOptions.EnableAlarms"/> on so
|
||||||
|
/// the driver instantiates the alarm source. Mirrors the smoke-test option builder
|
||||||
|
/// but flips the alarm gate on; once the live fixture lands, the test body above
|
||||||
|
/// calls this helper directly.
|
||||||
|
/// </summary>
|
||||||
|
[System.Diagnostics.CodeAnalysis.SuppressMessage(
|
||||||
|
"Style", "IDE0051", Justification = "Used by the live test body once the fixture ships.")]
|
||||||
|
private static TwinCATDriverOptions BuildAlarmOptions(TwinCATXarFixture sim) => new()
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions(
|
||||||
|
HostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
|
||||||
|
DeviceName: $"xar-{sim.TargetNetId}:{sim.AmsPort}")],
|
||||||
|
Tags =
|
||||||
|
[
|
||||||
|
// bTriggerEvent rises FALSE→TRUE to fire the PLC-side LogEvent call.
|
||||||
|
new TwinCATTagDefinition(
|
||||||
|
Name: "AlarmTrigger",
|
||||||
|
DeviceHostAddress: $"ads://{sim.TargetNetId}:{sim.AmsPort}",
|
||||||
|
SymbolPath: "GVL_Alarms.bTriggerEvent",
|
||||||
|
DataType: TwinCATDataType.Bool,
|
||||||
|
Writable: true),
|
||||||
|
],
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
EnableAlarms = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper kept for parity with the live test body once the fixture ships — collects
|
||||||
|
/// <see cref="IAlarmSource.OnAlarmEvent"/> off the driver into a queue caller can
|
||||||
|
/// drain after the trigger flip.
|
||||||
|
/// </summary>
|
||||||
|
[System.Diagnostics.CodeAnalysis.SuppressMessage(
|
||||||
|
"Style", "IDE0051", Justification = "Used by the live test body once the fixture ships.")]
|
||||||
|
private static ConcurrentQueue<AlarmEventArgs> WireAlarmCollector(IAlarmSource src)
|
||||||
|
{
|
||||||
|
var q = new ConcurrentQueue<AlarmEventArgs>();
|
||||||
|
src.OnAlarmEvent += (_, e) => q.Enqueue(e);
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||||
|
<GVL Name="GVL_Alarms" Id="{00000000-0000-0000-0000-000000000505}">
|
||||||
|
<Declaration><![CDATA[// PR 5.1 / #316 — TC3 EventLogger fixture for TwinCATAlarmIntegrationTests.
|
||||||
|
// bTriggerEvent rises FALSE -> TRUE to fire one EventLogger event via FB_AlarmHarness.
|
||||||
|
// bAcked is operator-side ACK toggle the alarm-source bridge writes back when the
|
||||||
|
// driver's AcknowledgeAsync runs. nLastEventClass / nLastSeverity track the last
|
||||||
|
// event the harness raised so the integration test can sanity-check the values it
|
||||||
|
// expects to surface through IAlarmSource.
|
||||||
|
VAR_GLOBAL
|
||||||
|
bTriggerEvent : BOOL := FALSE;
|
||||||
|
bAcked : BOOL := FALSE;
|
||||||
|
nLastEventClass : DINT := 0;
|
||||||
|
nLastSeverity : USINT := 0;
|
||||||
|
fbAlarmHarness : FB_AlarmHarness;
|
||||||
|
END_VAR
|
||||||
|
]]></Declaration>
|
||||||
|
</GVL>
|
||||||
|
</TcPlcObject>
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<TcPlcObject Version="1.1.0.1" ProductVersion="3.1.4024.0">
|
||||||
|
<POU Name="FB_AlarmHarness" Id="{00000000-0000-0000-0000-000000000303}" SpecialFunc="None">
|
||||||
|
<Declaration><![CDATA[// PR 5.1 / #316 — drives the TC3 EventLogger so TwinCATAlarmIntegrationTests
|
||||||
|
// can observe an event surfacing through the driver's IAlarmSource bridge.
|
||||||
|
//
|
||||||
|
// On a rising edge of GVL_Alarms.bTriggerEvent the harness calls FB_TcLogEvent
|
||||||
|
// with a fixed event class GUID + severity from GVL_Alarms.nLastSeverity and a
|
||||||
|
// short message string. The wire side of the EventLogger then dispatches a
|
||||||
|
// notification on AMS port 110 (AMSPORT_EVENTLOG); the driver's secondary
|
||||||
|
// AdsClient receives the event + projects it onto OnAlarmEvent.
|
||||||
|
//
|
||||||
|
// The harness intentionally targets a single event class GUID per fixture cycle;
|
||||||
|
// the test asserts shape + presence rather than per-event-class decoding because
|
||||||
|
// the binary protocol is undocumented in managed code (see
|
||||||
|
// docs/v3/twincat-eventlogger-spike.md).
|
||||||
|
FUNCTION_BLOCK FB_AlarmHarness
|
||||||
|
VAR
|
||||||
|
fbTrigger : R_TRIG;
|
||||||
|
fbLogEvent : FB_TcLogEvent; // declared in Tc3_EventLogger
|
||||||
|
sMessage : STRING(255) := 'Integration-fixture EventLogger trigger';
|
||||||
|
END_VAR
|
||||||
|
]]></Declaration>
|
||||||
|
<Implementation>
|
||||||
|
<ST><![CDATA[fbTrigger(CLK := GVL_Alarms.bTriggerEvent);
|
||||||
|
IF fbTrigger.Q THEN
|
||||||
|
// Fixed event-class GUID for the integration fixture; replace with whatever
|
||||||
|
// class the operator wires into the TC3 EventLogger configuration GUI.
|
||||||
|
fbLogEvent.ipMessage := 0; // placeholder — TwinCAT 3 ships richer
|
||||||
|
// overloads; the integration test only
|
||||||
|
// asserts an event surfaces, not the
|
||||||
|
// specific payload bytes.
|
||||||
|
fbLogEvent.eSeverity := TcEventSeverity.Warning;
|
||||||
|
fbLogEvent.bConfirmable := TRUE;
|
||||||
|
fbLogEvent.Execute(bExecute := TRUE);
|
||||||
|
|
||||||
|
GVL_Alarms.nLastEventClass := 1; // fixture-side echo so a watch window can
|
||||||
|
// confirm the harness fired.
|
||||||
|
GVL_Alarms.nLastSeverity := 100;
|
||||||
|
END_IF
|
||||||
|
fbLogEvent.Execute(bExecute := FALSE);
|
||||||
|
]]></ST>
|
||||||
|
</Implementation>
|
||||||
|
</POU>
|
||||||
|
</TcPlcObject>
|
||||||
@@ -278,6 +278,88 @@ dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests `
|
|||||||
--filter "FullyQualifiedName~TwinCATSymbolVersionTests"
|
--filter "FullyQualifiedName~TwinCATSymbolVersionTests"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Alarm scenarios
|
||||||
|
|
||||||
|
PR 5.1 (#316) ships an opt-in TC3 EventLogger bridge. The driver's
|
||||||
|
`IAlarmSource` implementation surfaces alarms by opening a second
|
||||||
|
`AdsClient` against AMS port `110` (`AMSPORT_EVENTLOG`) and adding a
|
||||||
|
device notification on `ADSIGRP_TCEVENTLOG_ALARMS`. The decode is
|
||||||
|
best-effort because Beckhoff doesn't ship a managed `TcEventLogger`
|
||||||
|
wrapper (only C++ TcCOM headers); some fields surface as `Unknown`
|
||||||
|
until a follow-up PR lands a binary-protocol decoder. Spike output
|
||||||
|
captured at `docs/v3/twincat-eventlogger-spike.md`.
|
||||||
|
|
||||||
|
The integration test
|
||||||
|
(`TwinCATAlarmIntegrationTests.Driver_raises_alarm_event_when_PLC_logs_event`)
|
||||||
|
ships build-only in PR 5.1 — once the XAR project imports the GVL +
|
||||||
|
FB_AlarmHarness below, swap the `Assert.Skip` in the test body for the
|
||||||
|
live flow:
|
||||||
|
|
||||||
|
1. Init the driver with `EnableAlarms=true`.
|
||||||
|
2. `SubscribeAlarmsAsync([], ct)`.
|
||||||
|
3. `WriteAsync` to flip `GVL_Alarms.bTriggerEvent` from `FALSE` to
|
||||||
|
`TRUE` — `FB_AlarmHarness` sees the rising edge and calls
|
||||||
|
`FB_TcLogEvent` on the PLC side.
|
||||||
|
4. Assert `OnAlarmEvent` fires within `~5 s` with non-empty
|
||||||
|
`Source` + `Message`.
|
||||||
|
|
||||||
|
### Global Variable List: `GVL_Alarms`
|
||||||
|
|
||||||
|
```st
|
||||||
|
VAR_GLOBAL
|
||||||
|
bTriggerEvent : BOOL := FALSE;
|
||||||
|
bAcked : BOOL := FALSE;
|
||||||
|
nLastEventClass : DINT := 0;
|
||||||
|
nLastSeverity : USINT := 0;
|
||||||
|
fbAlarmHarness : FB_AlarmHarness;
|
||||||
|
END_VAR
|
||||||
|
```
|
||||||
|
|
||||||
|
The XAE-form GVL ships at `PLC/GVLs/GVL_Alarms.TcGVL`; import it
|
||||||
|
alongside the other fixture GVLs.
|
||||||
|
|
||||||
|
### POU: `FB_AlarmHarness`
|
||||||
|
|
||||||
|
```st
|
||||||
|
FUNCTION_BLOCK FB_AlarmHarness
|
||||||
|
VAR
|
||||||
|
fbTrigger : R_TRIG;
|
||||||
|
fbLogEvent : FB_TcLogEvent; // declared in Tc3_EventLogger
|
||||||
|
sMessage : STRING(255) := 'Integration-fixture EventLogger trigger';
|
||||||
|
END_VAR
|
||||||
|
|
||||||
|
fbTrigger(CLK := GVL_Alarms.bTriggerEvent);
|
||||||
|
IF fbTrigger.Q THEN
|
||||||
|
fbLogEvent.eSeverity := TcEventSeverity.Warning;
|
||||||
|
fbLogEvent.bConfirmable := TRUE;
|
||||||
|
fbLogEvent.Execute(bExecute := TRUE);
|
||||||
|
GVL_Alarms.nLastEventClass := 1;
|
||||||
|
GVL_Alarms.nLastSeverity := 100;
|
||||||
|
END_IF
|
||||||
|
fbLogEvent.Execute(bExecute := FALSE);
|
||||||
|
```
|
||||||
|
|
||||||
|
The XAE-form POU ships at `PLC/POUs/FB_AlarmHarness.TcPOU`. Wire it
|
||||||
|
into `MAIN`:
|
||||||
|
|
||||||
|
```st
|
||||||
|
GVL_Alarms.fbAlarmHarness();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event class IDs / severity buckets / cleared-on transitions
|
||||||
|
|
||||||
|
| Symbol | Value | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `nLastEventClass` | `DINT`, fixture-side echo (`1` after a rising edge) | Watch-window aid; the actual EventLogger event class is configured in the TC3 GUI per project. |
|
||||||
|
| `nLastSeverity` | `USINT`, fixed `100` after a rising edge | Maps to `AlarmSeverity.Medium` via `TwinCATAlarmSource.MapSeverity` (≤128 = Medium). |
|
||||||
|
| `bTriggerEvent` | `BOOL`, operator/test writes | Rising edge only — flip back to `FALSE` then `TRUE` to re-fire. |
|
||||||
|
| `bAcked` | `BOOL`, driver writes when `AcknowledgeAsync` runs | Cleared by next event raise. |
|
||||||
|
|
||||||
|
The TC3 EventLogger surfaces the cleared transition automatically when
|
||||||
|
`fbLogEvent.bConfirmable=TRUE` and an operator confirms; the driver
|
||||||
|
projects the clear as a second `OnAlarmEvent` with the same condition
|
||||||
|
id.
|
||||||
|
|
||||||
## How to run the TwinCAT-tier tests
|
## How to run the TwinCAT-tier tests
|
||||||
|
|
||||||
On the dev box:
|
On the dev box:
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PR 5.1 / #316 — covers the <see cref="IAlarmSource"/> shape on
|
||||||
|
/// <see cref="TwinCATDriver"/>: feature-gating, gate event projection, multi-event
|
||||||
|
/// ordering, acknowledge round-trip, and JSON DTO round-trip on the options.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class TwinCATAlarmSourceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task EnableAlarms_false_does_not_create_alarm_source()
|
||||||
|
{
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
EnableAlarms = false,
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
drv.HasAlarmSource.ShouldBeFalse();
|
||||||
|
|
||||||
|
var handle = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||||
|
handle.ShouldBeOfType<TwinCATAlarmSubscriptionHandle>();
|
||||||
|
((TwinCATAlarmSubscriptionHandle)handle).Id.ShouldBe(0);
|
||||||
|
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnableAlarms_false_OnAlarmEvent_never_fires()
|
||||||
|
{
|
||||||
|
var gate = new FakeTwinCATAlarmGate();
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
EnableAlarms = false,
|
||||||
|
}, "drv-1", alarmGate: gate);
|
||||||
|
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
var raised = new ConcurrentQueue<AlarmEventArgs>();
|
||||||
|
drv.OnAlarmEvent += (_, e) => raised.Enqueue(e);
|
||||||
|
_ = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||||
|
|
||||||
|
// Even if a stray event is fired through the gate (a buggy operator wired in a
|
||||||
|
// fake), the disabled-mode driver doesn't subscribe + the event is dropped.
|
||||||
|
gate.RaiseAlarm(new TwinCATAlarmEvent("Class.A", "Source1", 100, "msg", DateTimeOffset.UtcNow, false));
|
||||||
|
await Task.Delay(20);
|
||||||
|
|
||||||
|
raised.ShouldBeEmpty();
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnableAlarms_true_creates_source_and_starts_gate_on_first_subscribe()
|
||||||
|
{
|
||||||
|
var gate = new FakeTwinCATAlarmGate();
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
EnableAlarms = true,
|
||||||
|
}, "drv-1", alarmGate: gate);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
drv.HasAlarmSource.ShouldBeTrue();
|
||||||
|
|
||||||
|
gate.StartCount.ShouldBe(0);
|
||||||
|
_ = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||||
|
gate.StartCount.ShouldBe(1);
|
||||||
|
|
||||||
|
// Second subscribe doesn't restart the gate.
|
||||||
|
_ = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||||
|
gate.StartCount.ShouldBe(1);
|
||||||
|
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Gate_event_raises_AlarmEvent_on_driver_with_correct_shape()
|
||||||
|
{
|
||||||
|
var gate = new FakeTwinCATAlarmGate();
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
EnableAlarms = true,
|
||||||
|
}, "drv-1", alarmGate: gate);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var raised = new ConcurrentQueue<AlarmEventArgs>();
|
||||||
|
drv.OnAlarmEvent += (_, e) => raised.Enqueue(e);
|
||||||
|
_ = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||||
|
|
||||||
|
var stamp = DateTimeOffset.UtcNow;
|
||||||
|
gate.RaiseAlarm(new TwinCATAlarmEvent(
|
||||||
|
EventClass: "TcEventClass.MachineFault",
|
||||||
|
Source: "Conveyor1.MotorOverload",
|
||||||
|
Severity: 200,
|
||||||
|
Message: "Motor overload tripped",
|
||||||
|
OccurrenceUtc: stamp,
|
||||||
|
Acked: false));
|
||||||
|
|
||||||
|
raised.Count.ShouldBe(1);
|
||||||
|
var args = raised.First();
|
||||||
|
args.SourceNodeId.ShouldBe("Conveyor1.MotorOverload");
|
||||||
|
args.AlarmType.ShouldBe("TcEventClass.MachineFault");
|
||||||
|
args.Message.ShouldBe("Motor overload tripped");
|
||||||
|
args.Severity.ShouldBe(AlarmSeverity.Critical);
|
||||||
|
args.SourceTimestampUtc.ShouldBe(stamp.UtcDateTime);
|
||||||
|
args.ConditionId.ShouldBe("Conveyor1.MotorOverload#TcEventClass.MachineFault");
|
||||||
|
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Multiple_alarm_events_are_delivered_in_order()
|
||||||
|
{
|
||||||
|
var gate = new FakeTwinCATAlarmGate();
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
EnableAlarms = true,
|
||||||
|
}, "drv-1", alarmGate: gate);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var raised = new List<AlarmEventArgs>();
|
||||||
|
drv.OnAlarmEvent += (_, e) => { lock (raised) raised.Add(e); };
|
||||||
|
_ = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||||
|
|
||||||
|
var t = DateTimeOffset.UtcNow;
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
gate.RaiseAlarm(new TwinCATAlarmEvent(
|
||||||
|
"Class.X", $"Source{i}", (ushort)(50 + i * 10), $"msg{i}", t.AddMilliseconds(i), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
raised.Count.ShouldBe(5);
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
raised[i].SourceNodeId.ShouldBe($"Source{i}");
|
||||||
|
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SourceFilter_only_passes_matching_source()
|
||||||
|
{
|
||||||
|
var gate = new FakeTwinCATAlarmGate();
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
EnableAlarms = true,
|
||||||
|
}, "drv-1", alarmGate: gate);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var raised = new ConcurrentQueue<AlarmEventArgs>();
|
||||||
|
drv.OnAlarmEvent += (_, e) => raised.Enqueue(e);
|
||||||
|
_ = await drv.SubscribeAlarmsAsync(["Conveyor1"], CancellationToken.None);
|
||||||
|
|
||||||
|
gate.RaiseAlarm(new TwinCATAlarmEvent("C", "Conveyor1", 100, "x", DateTimeOffset.UtcNow, false));
|
||||||
|
gate.RaiseAlarm(new TwinCATAlarmEvent("C", "OtherSource", 100, "y", DateTimeOffset.UtcNow, false));
|
||||||
|
gate.RaiseAlarm(new TwinCATAlarmEvent("C", "conveyor1", 100, "z", DateTimeOffset.UtcNow, false)); // case-insensitive
|
||||||
|
|
||||||
|
raised.Count.ShouldBe(2);
|
||||||
|
raised.ShouldAllBe(e => string.Equals(e.SourceNodeId, "Conveyor1", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acknowledge_round_trips_to_gate()
|
||||||
|
{
|
||||||
|
var gate = new FakeTwinCATAlarmGate();
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
EnableAlarms = true,
|
||||||
|
}, "drv-1", alarmGate: gate);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
_ = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.AcknowledgeAsync(
|
||||||
|
[new AlarmAcknowledgeRequest("Conveyor1", "cond-1", "operator A")],
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
gate.AckLog.Count.ShouldBe(1);
|
||||||
|
gate.AckLog.Single().SourceNodeId.ShouldBe("Conveyor1");
|
||||||
|
gate.AckLog.Single().ConditionId.ShouldBe("cond-1");
|
||||||
|
gate.AckLog.Single().Comment.ShouldBe("operator A");
|
||||||
|
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acknowledge_when_disabled_is_noop()
|
||||||
|
{
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
EnableAlarms = false,
|
||||||
|
}, "drv-1");
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
// Should complete without throwing even though no source is wired.
|
||||||
|
await drv.AcknowledgeAsync(
|
||||||
|
[new AlarmAcknowledgeRequest("X", "Y", null)], CancellationToken.None);
|
||||||
|
await drv.UnsubscribeAlarmsAsync(new TwinCATAlarmSubscriptionHandle(0), CancellationToken.None);
|
||||||
|
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unsubscribe_stops_event_delivery()
|
||||||
|
{
|
||||||
|
var gate = new FakeTwinCATAlarmGate();
|
||||||
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
||||||
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||||
|
EnableAlarms = true,
|
||||||
|
}, "drv-1", alarmGate: gate);
|
||||||
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||||
|
|
||||||
|
var raised = new ConcurrentQueue<AlarmEventArgs>();
|
||||||
|
drv.OnAlarmEvent += (_, e) => raised.Enqueue(e);
|
||||||
|
var handle = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||||
|
|
||||||
|
gate.RaiseAlarm(new TwinCATAlarmEvent("C", "S", 50, "before", DateTimeOffset.UtcNow, false));
|
||||||
|
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||||
|
gate.RaiseAlarm(new TwinCATAlarmEvent("C", "S", 50, "after", DateTimeOffset.UtcNow, false));
|
||||||
|
|
||||||
|
raised.Count.ShouldBe(1);
|
||||||
|
raised.First().Message.ShouldBe("before");
|
||||||
|
|
||||||
|
await drv.ShutdownAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Severity_mapping_buckets_match_quartile_cuts()
|
||||||
|
{
|
||||||
|
TwinCATAlarmSource.MapSeverity(0).ShouldBe(AlarmSeverity.Low);
|
||||||
|
TwinCATAlarmSource.MapSeverity(64).ShouldBe(AlarmSeverity.Low);
|
||||||
|
TwinCATAlarmSource.MapSeverity(65).ShouldBe(AlarmSeverity.Medium);
|
||||||
|
TwinCATAlarmSource.MapSeverity(128).ShouldBe(AlarmSeverity.Medium);
|
||||||
|
TwinCATAlarmSource.MapSeverity(129).ShouldBe(AlarmSeverity.High);
|
||||||
|
TwinCATAlarmSource.MapSeverity(192).ShouldBe(AlarmSeverity.High);
|
||||||
|
TwinCATAlarmSource.MapSeverity(193).ShouldBe(AlarmSeverity.Critical);
|
||||||
|
TwinCATAlarmSource.MapSeverity(255).ShouldBe(AlarmSeverity.Critical);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Options_round_trip_preserves_EnableAlarms()
|
||||||
|
{
|
||||||
|
var original = new TwinCATDriverOptions
|
||||||
|
{
|
||||||
|
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851", DeviceName: "Mach1")],
|
||||||
|
EnableAlarms = true,
|
||||||
|
};
|
||||||
|
var json = JsonSerializer.Serialize(original);
|
||||||
|
var restored = JsonSerializer.Deserialize<TwinCATDriverOptions>(json);
|
||||||
|
|
||||||
|
restored.ShouldNotBeNull();
|
||||||
|
restored.EnableAlarms.ShouldBeTrue();
|
||||||
|
|
||||||
|
var defaultRestored = JsonSerializer.Deserialize<TwinCATDriverOptions>("{}");
|
||||||
|
defaultRestored.ShouldNotBeNull();
|
||||||
|
defaultRestored.EnableAlarms.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fake alarm gate — captures Start invocations + ack requests, exposes
|
||||||
|
/// <see cref="RaiseAlarm"/> so tests can drive synthetic events without standing up
|
||||||
|
/// a second AMS-port-110 session against a real TC3 EventLogger.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class FakeTwinCATAlarmGate : ITwinCATAlarmGate
|
||||||
|
{
|
||||||
|
public int StartCount { get; private set; }
|
||||||
|
public List<AlarmAcknowledgeRequest> AckLog { get; } = new();
|
||||||
|
public List<TwinCATAlarmEvent> ActiveAlarmsList { get; } = new();
|
||||||
|
public IReadOnlyList<TwinCATAlarmEvent> ActiveAlarms => ActiveAlarmsList;
|
||||||
|
|
||||||
|
public event EventHandler<TwinCATAlarmEvent>? OnAlarmEvent;
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
StartCount++;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task AcknowledgeAsync(
|
||||||
|
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
AckLog.AddRange(acknowledgements);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RaiseAlarm(TwinCATAlarmEvent evt) => OnAlarmEvent?.Invoke(this, evt);
|
||||||
|
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user