Compare commits

...

6 Commits

23 changed files with 2377 additions and 9 deletions
+39
View File
@@ -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
@@ -139,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
+34
View File
@@ -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.
+63 -1
View File
@@ -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
@@ -109,6 +121,27 @@ or we ship a raw S7comm PDU helper. See
[`docs/v2/s7.md` "CPU diagnostics (SZL)"](../v2/s7.md#cpu-diagnostics-szl) [`docs/v2/s7.md` "CPU diagnostics (SZL)"](../v2/s7.md#cpu-diagnostics-szl)
for the wire-status detail. 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 |
@@ -141,7 +174,36 @@ for the wire-status detail.
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. **PR-S7-E1 — live SZL test against a real S7-1500.** snap7 doesn't 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 implement SZL at all, and S7netplus 0.20 doesn't expose a public
`ReadSzlAsync`, so the `@System.*` virtual address surface currently `ReadSzlAsync`, so the `@System.*` virtual address surface currently
answers `BadNotSupported` against every backend. The parser answers `BadNotSupported` against every backend. The parser
+20 -5
View File
@@ -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
+115
View File
@@ -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 0255 `USINT`. The driver maps it onto
the four-bucket `AlarmSeverity` enum the OPC UA AC layer consumes:
| TC3 severity | `AlarmSeverity` |
| --- | --- |
| 064 | `Low` |
| 65128 | `Medium` |
| 129192 | `High` |
| 193255 | `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
+20
View File
@@ -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.
+307 -1
View File
@@ -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
@@ -1156,6 +1345,123 @@ diagnostics tests where you want every read to hit the wire.
} }
``` ```
## 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
+101
View File
@@ -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}";
+112
View File
@@ -1,5 +1,7 @@
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;
@@ -123,6 +125,42 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
/// <summary>Test-only access to the SZL cache for assertions about TTL behaviour.</summary> /// <summary>Test-only access to the SZL cache for assertions about TTL behaviour.</summary>
internal S7SzlCache? SzlCache => _szlCache; 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
@@ -257,6 +295,13 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
_szlReader ??= new S7NetSzlReader(plc); _szlReader ??= new S7NetSzlReader(plc);
_szlCache = new S7SzlCache(_options.SzlCacheTtl); _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
@@ -1118,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"
@@ -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
@@ -193,6 +193,90 @@ public sealed class S7DriverOptions
/// (every read goes to the wire) — only useful for diagnostics tests. /// (every read goes to the wire) — only useful for diagnostics tests.
/// </summary> /// </summary>
public TimeSpan SzlCacheTtl { get; init; } = TimeSpan.FromSeconds(5); 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 &amp; 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 &amp; 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,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 0255 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,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,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;
}
}
@@ -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>
@@ -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() { }
}
}