Auto: s7-e1 — CPU diagnostic buffer / SZL reads

Closes #302
This commit is contained in:
Joseph Doherty
2026-04-26 10:30:43 -04:00
parent f7e0d9a9e7
commit 108f69d198
14 changed files with 1701 additions and 3 deletions

View File

@@ -95,6 +95,17 @@ otopcua-s7-cli read -h 192.168.1.30 -a M0.0 -t Bool
# 80-char S7 string # 80-char S7 string
otopcua-s7-cli read -h 192.168.1.30 -a DB10.STRING[0] -t String --string-length 80 otopcua-s7-cli read -h 192.168.1.30 -a DB10.STRING[0] -t String --string-length 80
# CPU diagnostics (SZL) — virtual @System.* addresses (PR-S7-E1).
# Requires ExposeSystemTags = true on the driver instance; surfaces as
# BadNotSupported until S7netplus exposes a public ReadSzlAsync (or we ship
# a raw-PDU helper). See docs/v2/s7.md "CPU diagnostics (SZL)" for the full
# table and the snap7 / S7netplus 0.20 caveat.
otopcua-s7-cli read -h 192.168.1.30 -a @System.CpuType -t String
otopcua-s7-cli read -h 192.168.1.30 -a @System.Firmware -t String
otopcua-s7-cli read -h 192.168.1.30 -a @System.OrderNo -t String
otopcua-s7-cli read -h 192.168.1.30 -a @System.CycleMs.Min -t Float64
otopcua-s7-cli read -h 192.168.1.30 -a "@System.DiagBuffer.Entry[0]" -t String
``` ```
### `write` ### `write`

View File

@@ -95,6 +95,20 @@ structs — not covered. UDT fan-out IS covered (PR-S7-D2 / #300) via the
`udt_layout` meta-seed in `Docker/profiles/s7_1500.json` and the `udt_layout` meta-seed in `Docker/profiles/s7_1500.json` and the
`Driver_fans_out_udt_into_member_tags` integration test. `Driver_fans_out_udt_into_member_tags` integration test.
### 6. SZL (System Status List) — `@System.*` virtual addresses
PR-S7-E1 / [#302](https://github.com/dohertj2/dohertj2/lmxopcua/issues/302)
adds a virtual `@System.*` address surface (CPU type, firmware, scan-cycle
stats, diagnostic-buffer ring) backed by SZL reads. **snap7 does not
implement SZL** — the simulator answers every SZL request with a function-
not-supported error, so the integration profile exercises only the
not-supported semantics (`@System.CpuType` against snap7 returns
`BadNotSupported`). Live-firmware SZL coverage is parked behind a
`[Fact(Skip = ...)]` until either S7netplus exposes a public `ReadSzlAsync`
or we ship a raw S7comm PDU helper. See
[`docs/v2/s7.md` "CPU diagnostics (SZL)"](../v2/s7.md#cpu-diagnostics-szl)
for the wire-status detail.
## When to trust the S7 tests, when to reach for a rig ## When to trust the S7 tests, when to reach for a rig
| Question | Unit tests | Real PLC | | Question | Unit tests | Real PLC |
@@ -127,6 +141,15 @@ structs — not covered. UDT fan-out IS covered (PR-S7-D2 / #300) via the
runner with the lab rig executes. The classifier branch runner with the lab rig executes. The classifier branch
(`S7PreflightClassifier.IsPutGetDisabled`) is unit-tested without a (`S7PreflightClassifier.IsPutGetDisabled`) is unit-tested without a
network in `S7PreflightTests.Classifier_matches_only_PUT_GET_disabled_error_codes`. network in `S7PreflightTests.Classifier_matches_only_PUT_GET_disabled_error_codes`.
5. **PR-S7-E1 — live SZL test against a real S7-1500.** snap7 doesn't
implement SZL at all, and S7netplus 0.20 doesn't expose a public
`ReadSzlAsync`, so the `@System.*` virtual address surface currently
answers `BadNotSupported` against every backend. The parser
(`S7SzlParser`) is unit-tested against golden bytes; flipping the wire
path on requires either an S7netplus PR or a raw-PDU helper. Once that's
in, [`S7_1500SzlTests.System_CpuType_against_live_S7_1500_returns_non_empty_string`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SzlTests.cs)
should be flipped from `[Fact(Skip = ...)]` to env-var-gated against the
self-hosted runner with the lab rig.
Without any of these, S7 driver correctness against real hardware is trusted Without any of these, S7 driver correctness against real hardware is trusted
from field deployments, not from the test suite. from field deployments, not from the test suite.

View File

@@ -1068,6 +1068,94 @@ FB-instance DBs imported via PR-S7-D3 / [#301](https://github.com/dohertj2/lmxop
see [`docs/drivers/S7-TIA-Import.md` "Re-import on FB-interface edit"](../drivers/S7-TIA-Import.md#re-import-on-fb-interface-edit--caveat) see [`docs/drivers/S7-TIA-Import.md` "Re-import on FB-interface edit"](../drivers/S7-TIA-Import.md#re-import-on-fb-interface-edit--caveat)
for the FB-instance-specific workflow. for the FB-instance-specific workflow.
## CPU diagnostics (SZL)
PR-S7-E1 / [#302](https://github.com/dohertj2/lmxopcua/issues/302) — every S7
CPU answers SZL (System Status List) queries with metadata about itself: CPU
type, firmware, order number, scan-cycle min/avg/max, and the diagnostic
buffer ring. The driver surfaces those through a virtual `@System.*` address
space dispatched against the SZL sub-protocol — no DB / merker tag declarations
required.
### Opt-in: `ExposeSystemTags`
Off by default. Set `ExposeSystemTags = true` in `S7DriverOptions` and
`DiscoverAsync` adds a `Diagnostics/` sub-folder under the driver root with
the variables listed below. Knobs:
| Option | Default | Notes |
| --- | --- | --- |
| `ExposeSystemTags` | `false` | Master switch. When `false` the SZL surface is invisible — no extra browse nodes, no extra wire traffic. |
| `DiagBufferDepth` | `10` | Number of diagnostic-buffer entries to discover under `DiagBuffer/Entry[N]`. Capped at 50. |
| `SzlCacheTtl` | `5 s` | TTL for the per-driver SZL cache. A burst of `@System.*` reads inside this window reuses one wire response per SZL ID. Set to `TimeSpan.Zero` to disable caching (every read hits the wire). |
### `@System.*` address table
| Address | OPC UA type | SZL ID | Index | What it is |
| --- | --- | --- | --- | --- |
| `@System.CpuType` | `String` | `0x0011` | `0x0000` | CPU friendly name (SZL index 0x0007) or MLFB fallback. |
| `@System.Firmware` | `String` | `0x0011` | `0x0000` | Firmware version, formatted `Vmaj.min.patch`. |
| `@System.OrderNo` | `String` | `0x0011` | `0x0000` | MLFB / order number, e.g. `6ES7 516-3AN01-0AB0`. |
| `@System.CycleMs.Min` | `Float64` | `0x0132` | `0x0005` | Shortest scan cycle observed since last reset, in milliseconds. |
| `@System.CycleMs.Max` | `Float64` | `0x0132` | `0x0005` | Longest scan cycle observed since last reset, in milliseconds. |
| `@System.CycleMs.Avg` | `Float64` | `0x0132` | `0x0005` | Rolling average scan-cycle time, in milliseconds. |
| `@System.DiagBuffer.Entry[N]` | `String` | `0x00A0` | `0x0000` | Diagnostic-buffer entry rendered as `<UTC ISO-8601> \| 0x<event id> \| prio=<N> \| <event text>`. `N` ranges from `0` (most recent) through `DiagBufferDepth-1`. |
The diagnostic-buffer entries surface as flat strings rather than a structured
DataType so dashboards / log scrapers can split / grep them without a custom
schema.
### What's wired today vs not-supported
S7netplus 0.20 builds SZL request packages internally
(`SzlReadRequestPackage` / `WriteSzlReadRequest`) but does **not** expose a
public `ReadSzlAsync` API. Until S7netplus catches up (or we ship a raw S7comm
PDU helper that side-steps the library), the production
[`S7NetSzlReader`](../../src/ZB.MOM.WW.OtOpcUa.Driver.S7/Szl/S7NetSzlReader.cs)
returns `null` on every call and every `@System.*` read surfaces as
`BadNotSupported`. The browse tree still lights up — operators can wire
clients against it — only the values come back not-supported.
The parser code (`S7SzlParser`) is fully tested against golden bytes
regardless. Flipping the wire path on is a one-method change in
`S7NetSzlReader` once the upstream surface is available; no parser / dispatch
/ cache changes needed.
snap7 (the simulator backing the integration profile) also doesn't implement
SZL — the integration test
[`S7_1500SzlTests`](../../tests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SzlTests.cs)
asserts the not-supported semantics against snap7 + parks the live-firmware
test behind `[Fact(Skip = ...)]` until the wire path lights up.
### Caching
Diagnostics shouldn't poll faster than `SzlCacheTtl` — a 100 ms HMI
subscription on every `@System.*` tag would otherwise hammer the comms
mailbox for data that doesn't change between scans. The per-driver
[`S7SzlCache`](../../src/ZB.MOM.WW.OtOpcUa.Driver.S7/Szl/S7SzlCache.cs)
de-dups concurrent reads by `(SzlId, SzlIndex)`; one SZL 0x0011 round-trip
backs `CpuType` + `Firmware` + `OrderNo` for the whole TTL window. Negative
results (SZL not supported) are cached just as aggressively — repeatedly
hammering a CPU that already said "not supported" wouldn't help.
`SzlCacheTtl = TimeSpan.Zero` disables caching entirely; useful for
diagnostics tests where you want every read to hit the wire.
### JSON config example
```json
{
"DriverConfig": {
"Host": "192.168.10.50",
"Port": 102,
"CpuType": "S71500",
"ExposeSystemTags": true,
"DiagBufferDepth": 20,
"SzlCacheTtl": "00:00:05"
}
}
```
## 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

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using S7.Net; using S7.Net;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport; using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
using ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7; namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
@@ -96,6 +97,32 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
private DriverHealth _health = new(DriverState.Unknown, null, null); private DriverHealth _health = new(DriverState.Unknown, null, null);
private bool _disposed; private bool _disposed;
// ---- PR-S7-E1 — SZL / @System.* virtual address state ----
//
// SzlReader is the wire surface (interface so tests can substitute fakes); SzlCache
// is the per-driver TTL cache fronting every SZL read so a burst of @System.* reads
// from one OPC UA subscription tick produces exactly one wire request per SZL ID.
// Both are constructed in InitializeAsync once Plc is open; both stay null when
// ExposeSystemTags is false (cheap shortcut on the read path).
private IS7SzlReader? _szlReader;
private S7SzlCache? _szlCache;
/// <summary>
/// PR-S7-E1 — test seam for the SZL wire reader. Setting this overrides the
/// default <see cref="S7NetSzlReader"/> created from the live <see cref="Plc"/>
/// so unit tests can drive <c>@System.*</c> reads with golden-byte payloads
/// without needing a real PLC. Setting before <see cref="InitializeAsync"/> is
/// fine — InitializeAsync only swaps in the production reader when this is null.
/// </summary>
internal IS7SzlReader? SzlReader
{
get => _szlReader;
set => _szlReader = value;
}
/// <summary>Test-only access to the SZL cache for assertions about TTL behaviour.</summary>
internal S7SzlCache? SzlCache => _szlCache;
// ---- Block-read coalescing diagnostics (PR-S7-B2) ---- // ---- Block-read coalescing diagnostics (PR-S7-B2) ----
// //
// Counters surface through DriverHealth.Diagnostics so the driver-diagnostics // Counters surface through DriverHealth.Diagnostics so the driver-diagnostics
@@ -222,6 +249,14 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
// CPUs negotiate 240 bytes; CPUs running the extended PDU advertise 480 or 960. // CPUs negotiate 240 bytes; CPUs running the extended PDU advertise 480 or 960.
_negotiatedPduSize = plc.MaxPDUSize; _negotiatedPduSize = plc.MaxPDUSize;
// PR-S7-E1 — wire up the SZL reader + cache. The reader respects an explicit
// test-supplied override (set before InitializeAsync) so unit tests can drive
// @System.* reads with canned payloads; production constructs the live S7netplus-
// backed reader (which currently surfaces every read as "not supported" until
// S7netplus exposes a public ReadSzlAsync).
_szlReader ??= new S7NetSzlReader(plc);
_szlCache = new S7SzlCache(_options.SzlCacheTtl);
// PR-S7-C5 — pre-flight PUT/GET enablement probe. After a clean OpenAsync, // PR-S7-C5 — pre-flight PUT/GET enablement probe. After a clean OpenAsync,
// issue a tiny 2-byte read against Probe.ProbeAddress (default MW0). Hardened // issue a tiny 2-byte read against Probe.ProbeAddress (default MW0). Hardened
// S7-1200 / S7-1500 CPUs that have PUT/GET communication disabled in TIA // S7-1200 / S7-1500 CPUs that have PUT/GET communication disabled in TIA
@@ -288,6 +323,15 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
// PR-S7-D2 — drop the post-fan-out tag list so a Reinit can rebuild it cleanly // PR-S7-D2 — drop the post-fan-out tag list so a Reinit can rebuild it cleanly
// without the previous run's UDT leaves leaking into the new tag map. // without the previous run's UDT leaves leaking into the new tag map.
_effectiveTags.Clear(); _effectiveTags.Clear();
// PR-S7-E1 — drop the SZL state so a fresh Plc on Reinit gets fresh CPU info /
// cycle stats / diagnostic-buffer entries. Clearing here keeps the test-supplied
// SzlReader override intact (set before Initialize) so a Shutdown / re-Initialize
// cycle from a unit test can re-use the same fake reader.
_szlCache?.Clear();
_szlCache = null;
// _szlReader: keep an explicit test-supplied reader (set via SzlReader property);
// drop the production one tied to the now-closed Plc so re-Init constructs fresh.
if (_szlReader is S7NetSzlReader) _szlReader = null;
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -308,10 +352,35 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync( public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken) IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{ {
var plc = RequirePlc();
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count]; var results = new DataValueSnapshot[fullReferences.Count];
// PR-S7-E1 — short-circuit @System.* virtual addresses before taking the Plc
// gate. SZL reads don't go through the regular tag map / address parser; they
// dispatch through IS7SzlReader (cached for SzlCacheTtl) and parse with
// S7SzlParser. Doing this first means the test path can read @System.* without
// a Plc connection (the reader is injectable).
var nonSystemIndexes = new List<int>(fullReferences.Count);
for (var i = 0; i < fullReferences.Count; i++)
{
var name = fullReferences[i];
if (S7SystemTags.IsSystemAddress(name))
{
results[i] = await ReadSystemTagAsync(name, now, cancellationToken).ConfigureAwait(false);
}
else
{
nonSystemIndexes.Add(i);
}
}
// If every requested reference was a @System.* tag, we're done before touching
// the Plc gate at all — keeps the @System.* surface usable in test setups that
// injected an SzlReader without ever calling InitializeAsync against a real PLC.
if (nonSystemIndexes.Count == 0) return results;
var plc = RequirePlc();
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try try
{ {
@@ -321,9 +390,9 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
// (arrays, strings, dates, 64-bit ints, UDT-fanout). Packable tags feed // (arrays, strings, dates, 64-bit ints, UDT-fanout). Packable tags feed
// the block-coalescing planner first (PR-S7-B2); whatever survives as a // the block-coalescing planner first (PR-S7-B2); whatever survives as a
// singleton range falls through to the multi-var packer (PR-S7-B1). // singleton range falls through to the multi-var packer (PR-S7-B1).
var packableIndexes = new List<int>(fullReferences.Count); var packableIndexes = new List<int>(nonSystemIndexes.Count);
var fallbackIndexes = new List<int>(); var fallbackIndexes = new List<int>();
for (var i = 0; i < fullReferences.Count; i++) foreach (var i in nonSystemIndexes)
{ {
var name = fullReferences[i]; var name = fullReferences[i];
if (!_tagsByName.TryGetValue(name, out var tag)) if (!_tagsByName.TryGetValue(name, out var tag))
@@ -770,6 +839,105 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
}; };
} }
/// <summary>
/// PR-S7-E1 — read one virtual <c>@System.*</c> address by dispatching through
/// the SZL cache + reader, parsing the raw payload, and projecting the requested
/// scalar field. Surfaces <c>BadNotSupported</c> when the reader returns null
/// (snap7 / S7netplus 0.20 / hardened CPUs that reject SZL); <c>BadNodeIdUnknown</c>
/// when the address starts with <c>@System.</c> but doesn't match a known tag;
/// <c>BadInternalError</c> when the parser throws on a malformed payload.
/// </summary>
private async Task<DataValueSnapshot> ReadSystemTagAsync(string address, DateTime now, CancellationToken ct)
{
if (!S7SystemTags.TryResolve(address, out var descriptor, out var diagBufferIndex) || descriptor is null)
return new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
var reader = _szlReader;
if (reader is null)
{
// No reader wired (driver not initialised + no test override) — surface
// BadNotSupported so a stray @System.* read doesn't masquerade as a code bug.
return new DataValueSnapshot(null, StatusBadNotSupported, null, now);
}
// Cache-front the wire read. When SzlCacheTtl is zero, the cache always misses
// (TimeSpan.Zero < TimeSpan.Zero is false → every entry is stale instantly).
// Lazily create the cache when InitializeAsync hasn't run yet (test seam) so
// repeated reads in a unit test still de-dup against the same cache instance.
_szlCache ??= new S7SzlCache(_options.SzlCacheTtl);
var cache = _szlCache;
byte[]? payload;
try
{
payload = await cache.GetOrFetchAsync(
descriptor.SzlId, descriptor.SzlIndex,
tok => reader.ReadSzlAsync(descriptor.SzlId, descriptor.SzlIndex, tok),
ct).ConfigureAwait(false);
}
catch (OperationCanceledException) { throw; }
catch
{
return new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
}
if (payload is null)
{
// SZL not supported — snap7 and S7netplus 0.20 both land here.
return new DataValueSnapshot(null, StatusBadNotSupported, null, now);
}
try
{
var value = ProjectSystemTagValue(descriptor, diagBufferIndex, payload);
return new DataValueSnapshot(value, 0u, now, now);
}
catch (ArgumentException)
{
// Malformed SZL payload — surface BadInternalError so a downstream client can
// distinguish "wire failed" from "PLC sent garbage".
return new DataValueSnapshot(null, StatusBadInternalError, null, now);
}
}
/// <summary>
/// Project a parsed SZL payload to the scalar value the requested
/// <paramref name="descriptor"/> exposes.
/// </summary>
private object? ProjectSystemTagValue(
S7SystemTags.SystemTagDescriptor descriptor,
int diagBufferIndex,
byte[] payload)
{
switch (descriptor.Kind)
{
case S7SystemTags.SystemTagKind.CpuType:
return S7SzlParser.ParseCpuInfo(payload).CpuType;
case S7SystemTags.SystemTagKind.Firmware:
return S7SzlParser.ParseCpuInfo(payload).Firmware;
case S7SystemTags.SystemTagKind.OrderNo:
return S7SzlParser.ParseCpuInfo(payload).OrderNo;
case S7SystemTags.SystemTagKind.CycleMin:
return S7SzlParser.ParseCycleStats(payload).MinMs;
case S7SystemTags.SystemTagKind.CycleMax:
return S7SzlParser.ParseCycleStats(payload).MaxMs;
case S7SystemTags.SystemTagKind.CycleAvg:
return S7SzlParser.ParseCycleStats(payload).AvgMs;
case S7SystemTags.SystemTagKind.DiagBufferEntry:
var depth = Math.Min(_options.DiagBufferDepth, S7SystemTags.MaxDiagBufferDepth);
if (diagBufferIndex < 0 || diagBufferIndex >= depth)
return null; // out of range — surface as null value with Good status
var entries = S7SzlParser.ParseDiagBuffer(payload, depth);
if (diagBufferIndex >= entries.Count) return null;
var e = entries[diagBufferIndex];
// Render each entry as one human-readable line — keeps the OPC UA surface
// a flat array of strings, which clients can split / grep without needing
// a custom structured DataType. Format is stable so log scrapers can parse it.
return $"{e.OccurrenceUtc:O} | 0x{e.EventId:X4} | prio={e.Priority} | {e.EventText}";
default:
return null;
}
}
/// <summary>Map driver-internal <see cref="S7Area"/> to S7.Net's <see cref="global::S7.Net.DataType"/>.</summary> /// <summary>Map driver-internal <see cref="S7Area"/> to S7.Net's <see cref="global::S7.Net.DataType"/>.</summary>
private static global::S7.Net.DataType MapArea(S7Area area) => area switch private static global::S7.Net.DataType MapArea(S7Area area) => area switch
{ {
@@ -1080,6 +1248,53 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
IsAlarm: false, IsAlarm: false,
WriteIdempotent: t.WriteIdempotent)); WriteIdempotent: t.WriteIdempotent));
} }
// PR-S7-E1 / #302 — surface the SZL-backed @System.* virtual tags under a
// Diagnostics/ sub-folder when the operator has opted in. Variables are
// ViewOnly (SZL is read-only) and never historized / alarming.
if (_options.ExposeSystemTags)
{
var diag = folder.Folder(S7SystemTags.FolderName, S7SystemTags.FolderName);
// Static descriptors: CpuType / Firmware / OrderNo + 3 cycle-time scalars.
foreach (var d in S7SystemTags.Descriptors)
{
// Browse name strips the "@System." prefix — operators see "CpuType",
// "CycleMs.Min", etc. The full reference (used by ReadAsync) keeps the
// raw "@System.*" form so the system-tag short-circuit fires.
var browseName = d.Address[S7SystemTags.Prefix.Length..];
diag.Variable(browseName, browseName, new DriverAttributeInfo(
FullName: d.Address,
DriverDataType: d.DriverDataType,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
// Diagnostic-buffer entries — depth comes from S7DriverOptions.DiagBufferDepth
// (capped at MaxDiagBufferDepth = 50 to keep the browse tree readable).
var depth = Math.Clamp(_options.DiagBufferDepth, 0, S7SystemTags.MaxDiagBufferDepth);
if (depth > 0)
{
var bufFolder = diag.Folder("DiagBuffer", "DiagBuffer");
for (var i = 0; i < depth; i++)
{
var browse = $"Entry[{i}]";
var fullRef = $"{S7SystemTags.DiagBufferEntryPrefix}{i}]";
bufFolder.Variable(browse, browse, new DriverAttributeInfo(
FullName: fullRef,
DriverDataType: DriverDataType.String,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
}
}
}
return Task.CompletedTask; return Task.CompletedTask;
} }

View File

@@ -151,6 +151,48 @@ public sealed class S7DriverOptions
/// including the 4-level nesting cap and the Optimized-DB prerequisite. /// including the 4-level nesting cap and the Optimized-DB prerequisite.
/// </summary> /// </summary>
public IReadOnlyList<S7UdtDefinition> Udts { get; init; } = []; public IReadOnlyList<S7UdtDefinition> Udts { get; init; } = [];
/// <summary>
/// PR-S7-E1 / #302 — when <c>true</c>, <see cref="S7Driver.DiscoverAsync"/> emits a
/// <c>Diagnostics/</c> sub-folder under the driver root containing virtual
/// <c>@System.*</c> variables backed by SZL (System Status List) reads:
/// <c>CpuType</c>, <c>Firmware</c>, <c>OrderNo</c> (SZL 0x0011),
/// <c>CycleMs.Min</c> / <c>.Max</c> / <c>.Avg</c> (SZL 0x0132 / 0x0432), and
/// <c>DiagBuffer/Entry[0..N]</c> (SZL 0x00A0). Default <c>false</c> — operators opt
/// in per driver instance because the virtual nodes show up in OPC UA Browse
/// under every connected client.
/// </summary>
/// <remarks>
/// <para>
/// S7netplus 0.20 doesn't yet expose a public <c>ReadSzlAsync</c>, so the
/// in-process default surfaces every SZL read as <c>BadNotSupported</c>. The
/// tag tree still lights up — operators see the structure and can wire
/// clients to it — only the values come back as not-supported. Tests inject
/// a fake reader that returns golden bytes to prove the dispatch + parser
/// + cache path works end-to-end. snap7 also doesn't implement SZL, so the
/// integration-test surface inherits the same not-supported behaviour.
/// </para>
/// </remarks>
public bool ExposeSystemTags { get; init; } = false;
/// <summary>
/// PR-S7-E1 — number of diagnostic-buffer entries to discover under
/// <c>Diagnostics/DiagBuffer/Entry[N]</c>. Capped at
/// <see cref="Szl.S7SystemTags.MaxDiagBufferDepth"/> = 50; the default 10 mirrors
/// the plan-section's "max-10 cap" guidance and matches typical SZL 0x00A0
/// PDU-size budgets. Ignored when <see cref="ExposeSystemTags"/> is <c>false</c>.
/// </summary>
public int DiagBufferDepth { get; init; } = Szl.S7SystemTags.DefaultDiagBufferDepth;
/// <summary>
/// PR-S7-E1 — TTL for the <see cref="Szl.S7SzlCache"/> that fronts every SZL
/// wire request. Diagnostics shouldn't poll faster than this anyway; the
/// default 5 s window means a burst of <c>@System.*</c> subscriptions ticking
/// at 100 ms each produces exactly one wire request per distinct SZL ID per
/// 5-second window. Set to <see cref="TimeSpan.Zero"/> to disable caching
/// (every read goes to the wire) — only useful for diagnostics tests.
/// </summary>
public TimeSpan SzlCacheTtl { get; init; } = TimeSpan.FromSeconds(5);
} }
/// <summary> /// <summary>

View File

@@ -0,0 +1,35 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
/// <summary>
/// PR-S7-E1 — abstraction over SZL (System Status List) wire reads. The driver dispatches
/// <c>@System.*</c> virtual reads through this interface so the parser code never depends
/// on a specific transport. Concrete implementations:
/// <list type="bullet">
/// <item>
/// <see cref="S7NetSzlReader"/> — the production implementation. S7netplus 0.20
/// does not expose a public <c>ReadSzlAsync</c> API (the SZL request builder is
/// internal), so this implementation returns <c>null</c> on every call —
/// surfacing as <c>BadNotSupported</c> at the OPC UA layer. Replace once
/// S7netplus exposes a public surface or we ship a raw-PDU helper.
/// </item>
/// <item>
/// A test fake that returns canned byte payloads — used by the
/// driver-side unit tests in <c>tests/.../Szl/</c>.
/// </item>
/// </list>
/// </summary>
public interface IS7SzlReader
{
/// <summary>
/// Read SZL <paramref name="szlId"/> at <paramref name="szlIndex"/> and return the
/// payload <em>without</em> the S7comm parameter / data headers — the response is
/// positioned at the SZL header (<c>SzlId | SzlIndex | LenThdr | NDr</c>) so it can
/// feed <see cref="S7SzlParser"/> directly.
/// </summary>
/// <returns>
/// Byte payload on success, or <c>null</c> when the SZL read is unsupported (snap7,
/// S7netplus 0.20 without raw PDU helper, hardened CPUs that reject the SZL
/// function code). The caller surfaces <c>null</c> as <c>BadNotSupported</c>.
/// </returns>
Task<byte[]?> ReadSzlAsync(ushort szlId, ushort szlIndex, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,49 @@
using S7.Net;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
/// <summary>
/// PR-S7-E1 — production <see cref="IS7SzlReader"/> backed by S7netplus's
/// <see cref="Plc"/> connection. S7netplus 0.20 builds SZL request packages
/// internally (<c>SzlReadRequestPackage</c> / <c>WriteSzlReadRequest</c>) but does
/// <b>not</b> expose a public <c>ReadSzlAsync</c> API, so this implementation
/// currently returns <c>null</c> on every call — the SZL feature surface ships as
/// <c>BadNotSupported</c> through the OPC UA address space until either
/// <list type="bullet">
/// <item>S7netplus publishes a stable public SZL surface (tracked upstream), or</item>
/// <item>We ship a raw S7comm PDU helper that side-steps the library.</item>
/// </list>
/// </summary>
/// <remarks>
/// <para>
/// The driver-side parser code (<see cref="S7SzlParser"/>) is fully tested
/// against golden bytes regardless — when the wire path lights up the parser
/// starts producing real CPU info / cycle stats / diagnostic-buffer entries
/// without further changes. Tests inject a fake <see cref="IS7SzlReader"/>
/// to exercise the dispatch + caching paths.
/// </para>
/// <para>
/// <b>Why no raw socket today?</b> S7netplus's <c>_stream</c> + <c>tcpClient</c>
/// fields are <c>private</c> and the request-builder helpers are <c>internal</c>.
/// Reflecting into them would break on every minor S7netplus release; the cost-
/// benefit only flips once the SZL feature has live customer demand.
/// </para>
/// </remarks>
public sealed class S7NetSzlReader(Plc plc) : IS7SzlReader
{
#pragma warning disable IDE0052 // unused while raw-PDU support is gated behind public S7netplus API
private readonly Plc _plc = plc ?? throw new ArgumentNullException(nameof(plc));
#pragma warning restore IDE0052
/// <inheritdoc />
public Task<byte[]?> ReadSzlAsync(ushort szlId, ushort szlIndex, CancellationToken cancellationToken)
{
// S7netplus 0.20 doesn't expose a public ReadSzlAsync — surface every SZL request
// as "not supported" so the OPC UA layer maps it to BadNotSupported. The parser
// code is wired and tested; flipping this method to a real implementation is the
// only change needed when S7netplus catches up. Synchronous return because
// there's no I/O to await — keep the signature for future-proofing.
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult<byte[]?>(null);
}
}

View File

@@ -0,0 +1,113 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
/// <summary>
/// PR-S7-E1 — virtual <c>@System.*</c> address map. Each entry pairs the public
/// address (e.g. <c>@System.CpuType</c>) with the SZL ID + index it dispatches
/// against and a field-extractor that pulls the requested scalar out of the parsed
/// payload. The driver short-circuits any <see cref="S7Driver.ReadAsync"/> reference
/// whose name starts with <c>@System.</c> through this table — there's no Plc
/// round-trip for non-SZL paths.
/// </summary>
/// <remarks>
/// <para>
/// The map is <em>static</em> because the SZL surface doesn't change between
/// deployments — every CPU answers the same SZL IDs (or returns "not supported"
/// uniformly). The diagnostic-buffer entries <c>@System.DiagBuffer.Entry[N]</c>
/// are not in the table; the driver computes their address dynamically from the
/// parsed entry list because the depth is configurable via
/// <see cref="S7DriverOptions.DiagBufferDepth"/>.
/// </para>
/// </remarks>
public static class S7SystemTags
{
/// <summary>Prefix every virtual system tag carries on the wire.</summary>
public const string Prefix = "@System.";
/// <summary>Browse-tree folder name where the driver's discovery step emits the system-tag variables.</summary>
public const string FolderName = "Diagnostics";
/// <summary>Maximum diagnostic-buffer entries the driver discovers / reads (capped to keep the OPC UA browse tree readable).</summary>
public const int MaxDiagBufferDepth = 50;
/// <summary>Default diagnostic-buffer depth — matches the plan-section's "10 entries" baseline.</summary>
public const int DefaultDiagBufferDepth = 10;
/// <summary>Address prefix for diagnostic-buffer entries: <c>@System.DiagBuffer.Entry[N]</c>.</summary>
public const string DiagBufferEntryPrefix = "@System.DiagBuffer.Entry[";
/// <summary>OPC UA data type each system tag projects as. Used by both the driver's discovery step and its read-result boxing.</summary>
public sealed record SystemTagDescriptor(
string Address,
ushort SzlId,
ushort SzlIndex,
DriverDataType DriverDataType,
SystemTagKind Kind);
/// <summary>What kind of value the descriptor extracts from its SZL payload.</summary>
public enum SystemTagKind
{
CpuType,
Firmware,
OrderNo,
CycleMin,
CycleMax,
CycleAvg,
DiagBufferEntry, // resolved dynamically via the entry index encoded in the address
}
/// <summary>Static descriptors for the non-buffer system tags (CPU info + cycle-time scalars).</summary>
public static readonly IReadOnlyList<SystemTagDescriptor> Descriptors =
[
new("@System.CpuType", S7SzlIds.ModuleIdentification, 0x0000, DriverDataType.String, SystemTagKind.CpuType),
new("@System.Firmware", S7SzlIds.ModuleIdentification, 0x0000, DriverDataType.String, SystemTagKind.Firmware),
new("@System.OrderNo", S7SzlIds.ModuleIdentification, 0x0000, DriverDataType.String, SystemTagKind.OrderNo),
new("@System.CycleMs.Min", S7SzlIds.CpuStatusData, S7SzlIds.CpuStatusCycleTimeIndex, DriverDataType.Float64, SystemTagKind.CycleMin),
new("@System.CycleMs.Max", S7SzlIds.CpuStatusData, S7SzlIds.CpuStatusCycleTimeIndex, DriverDataType.Float64, SystemTagKind.CycleMax),
new("@System.CycleMs.Avg", S7SzlIds.CpuStatusData, S7SzlIds.CpuStatusCycleTimeIndex, DriverDataType.Float64, SystemTagKind.CycleAvg),
];
/// <summary>True when <paramref name="address"/> is a recognised virtual system address.</summary>
public static bool IsSystemAddress(string address)
=> address is not null && address.StartsWith(Prefix, StringComparison.Ordinal);
/// <summary>
/// Resolve a virtual address to a (SzlId, SzlIndex, kind, optional buffer index)
/// dispatch tuple. Returns <c>false</c> when the address starts with the prefix but
/// doesn't match a known descriptor — the caller surfaces that as
/// <c>BadNodeIdUnknown</c>.
/// </summary>
public static bool TryResolve(string address, out SystemTagDescriptor? descriptor, out int diagBufferIndex)
{
descriptor = null;
diagBufferIndex = -1;
if (string.IsNullOrEmpty(address)) return false;
// Diagnostic-buffer entries: @System.DiagBuffer.Entry[N]
if (address.StartsWith(DiagBufferEntryPrefix, StringComparison.Ordinal) && address.EndsWith(']'))
{
var idxStr = address[DiagBufferEntryPrefix.Length..^1];
if (!int.TryParse(idxStr, out var idx) || idx < 0 || idx >= MaxDiagBufferDepth)
return false;
diagBufferIndex = idx;
descriptor = new SystemTagDescriptor(
address,
S7SzlIds.DiagnosticBuffer,
0x0000,
DriverDataType.String,
SystemTagKind.DiagBufferEntry);
return true;
}
foreach (var d in Descriptors)
{
if (string.Equals(d.Address, address, StringComparison.Ordinal))
{
descriptor = d;
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,64 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
/// <summary>
/// PR-S7-E1 — short-TTL cache of SZL responses keyed by <c>(SzlId, SzlIndex)</c>.
/// A diagnostics-only feature should never hammer the comms mailbox; one read per
/// SZL ID per <see cref="Ttl"/> window is the intended ceiling. Cache state is
/// thread-safe — <see cref="GetOrFetchAsync"/> serialises concurrent fetchers per
/// key so a burst of <c>@System.*</c> reads from one OPC UA subscription tick
/// produces exactly one wire request per distinct SZL.
/// </summary>
public sealed class S7SzlCache(TimeSpan ttl, Func<DateTime>? clock = null)
{
private readonly TimeSpan _ttl = ttl;
private readonly Func<DateTime> _clock = clock ?? (() => DateTime.UtcNow);
private readonly object _gate = new();
private readonly Dictionary<(ushort SzlId, ushort SzlIndex), CacheEntry> _entries = new();
/// <summary>Configured TTL — exposed for diagnostics / test assertions.</summary>
public TimeSpan Ttl => _ttl;
/// <summary>
/// Look up <paramref name="szlId"/> / <paramref name="szlIndex"/> in the cache; on
/// miss or stale entry, invoke <paramref name="fetcher"/> exactly once and store the
/// result. Negative cache (null payload) is intentionally <em>also</em> cached for
/// the TTL window — repeatedly hammering a CPU that has already said "not supported"
/// wouldn't help anything.
/// </summary>
public async Task<byte[]?> GetOrFetchAsync(
ushort szlId,
ushort szlIndex,
Func<CancellationToken, Task<byte[]?>> fetcher,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fetcher);
var key = (szlId, szlIndex);
var now = _clock();
// Phase 1: cache hit — return without taking any locks beyond the dictionary lookup.
lock (_gate)
{
if (_entries.TryGetValue(key, out var hit) && now - hit.FetchedAtUtc < _ttl)
return hit.Payload;
}
// Phase 2: miss — fetch outside the lock so concurrent keys don't serialize on
// each other. We accept a small race where two callers both miss + both fetch on
// the same key; the second store wins, which is fine for a TTL cache.
var payload = await fetcher(cancellationToken).ConfigureAwait(false);
lock (_gate)
{
_entries[key] = new CacheEntry(payload, _clock());
}
return payload;
}
/// <summary>Drop every cached entry — call on driver shutdown / reinit so a fresh CPU advertises fresh SZL.</summary>
public void Clear()
{
lock (_gate) _entries.Clear();
}
private readonly record struct CacheEntry(byte[]? Payload, DateTime FetchedAtUtc);
}

View File

@@ -0,0 +1,45 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
/// <summary>
/// PR-S7-E1 — SZL (System Status List) IDs surfaced through the driver's virtual
/// <c>@System.*</c> address space. SZL is the S7comm "System Status List" sub-protocol
/// documented in the Siemens function manual (Entry ID 6ES7810-4CA08-8BW1) — every
/// S7-300 / S7-400 / S7-1200 / S7-1500 CPU answers SZL queries with metadata about
/// itself: CPU type / order number / firmware (SZL 0x0011), cycle-time min/max/avg
/// (SZL 0x0132 / 0x0432), and the diagnostic-buffer ring (SZL 0x00A0).
/// </summary>
/// <remarks>
/// <para>
/// IDs are 16-bit big-endian on the wire. The driver pairs each ID with an SZL
/// <em>index</em> (also 16-bit) — most diagnostic SZLs accept index <c>0</c>;
/// the diagnostic-buffer SZL accepts index <c>0..N-1</c> to address a specific
/// entry but the driver always reads index <c>0</c> and parses the full ring
/// in one shot.
/// </para>
/// <para>
/// S7netplus 0.20 has internal SZL request building (<c>SzlReadRequestPackage</c> /
/// <c>WriteSzlReadRequest</c>) but does not expose a public <c>ReadSzlAsync</c> API.
/// The driver therefore goes through <see cref="IS7SzlReader"/>, whose default
/// <see cref="S7NetSzlReader"/> implementation surfaces every SZL read as
/// "not supported" until S7netplus exposes the public surface or we ship a
/// raw-PDU helper. snap7 doesn't implement SZL at all so the integration profile
/// exercises the same not-supported path.
/// </para>
/// </remarks>
public static class S7SzlIds
{
/// <summary>SZL ID 0x0011 — module identification: CPU type, MLFB / order number, firmware version.</summary>
public const ushort ModuleIdentification = 0x0011;
/// <summary>SZL ID 0x0132 — CPU status data including cycle-time stats. Index 0x0005 carries the cycle-time record.</summary>
public const ushort CpuStatusData = 0x0132;
/// <summary>SZL ID 0x0132 sub-index 0x0005 — cycle-time statistics record.</summary>
public const ushort CpuStatusCycleTimeIndex = 0x0005;
/// <summary>SZL ID 0x0432 — extended CPU status data; index 0x0001 carries the cycle-time record on S7-1500.</summary>
public const ushort CpuStatusDataExtended = 0x0432;
/// <summary>SZL ID 0x00A0 — diagnostic buffer ring (most-recent entry first). Index 0 returns up to N records depending on PDU budget.</summary>
public const ushort DiagnosticBuffer = 0x00A0;
}

View File

@@ -0,0 +1,401 @@
using System.Buffers.Binary;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
/// <summary>
/// PR-S7-E1 — pure parsers for the SZL (System Status List) response payloads the
/// driver dispatches against <c>@System.*</c> virtual addresses. Every parser takes a
/// byte payload <em>without</em> the S7comm transport envelope (parameter / data
/// headers stripped already by <see cref="IS7SzlReader"/>) and returns a strongly-typed
/// record. The byte layouts below match the Siemens function manual (Entry ID
/// 6ES7810-4CA08-8BW1) and the open-source <c>snap7</c> reference (the source of truth
/// for unofficial layouts) — see <c>docs/v2/s7.md</c> "CPU diagnostics (SZL)" for the
/// wire-level field-by-field map.
/// </summary>
/// <remarks>
/// <para>
/// <b>Common SZL payload header</b> (8 bytes):
/// <code>
/// u16 SzlId // BE — echoes the requested SZL ID
/// u16 SzlIndex // BE — echoes the requested SZL index
/// u16 LenThdr // BE — bytes per record
/// u16 NDr // BE — number of records following
/// </code>
/// Records follow contiguously, total <c>LenThdr * NDr</c> bytes.
/// </para>
/// <para>
/// All multi-byte integers are big-endian. Fixed-width strings (MLFB, FW version)
/// are space-padded ASCII; the parser trims trailing whitespace and NULs.
/// </para>
/// </remarks>
public static class S7SzlParser
{
/// <summary>Length of the common SZL response header in bytes.</summary>
public const int HeaderLength = 8;
/// <summary>Hard upper bound on diagnostic-buffer entries returned in one parse — caps test allocations even if a malformed payload claims a huge count.</summary>
public const int MaxDiagBufferEntriesPerResponse = 256;
/// <summary>
/// Parse SZL 0x0011 (module identification) — produces the CPU type / order number /
/// firmware version triple. The SZL contains multiple records keyed by an index in
/// the first 2 bytes of each record:
/// <list type="bullet">
/// <item><c>0x0001</c> — module identification (MLFB / order number)</item>
/// <item><c>0x0006</c> — basic firmware</item>
/// <item><c>0x0007</c> — basic hardware (CPU type derived from MLFB)</item>
/// </list>
/// Each record is 28 bytes: 2-byte index, 20-byte MLFB (ASCII, space-padded), 2-byte
/// BGTyp, 2-byte Ausbg1 (firmware big-version), 2-byte Ausbg2 (firmware small-version
/// / patch).
/// </summary>
public static S7CpuInfo ParseCpuInfo(byte[] payload)
{
ArgumentNullException.ThrowIfNull(payload);
EnsureHeader(payload, out _, out _, out var lenThdr, out var nDr);
// Each module-identification record is 28 bytes per Siemens. Anything else is a
// protocol-level mismatch — surface it loudly rather than silently mis-decoding.
if (lenThdr != 28)
throw new ArgumentException(
$"S7 SZL 0x0011 expected record length 28, got {lenThdr}", nameof(payload));
var expected = HeaderLength + lenThdr * nDr;
if (payload.Length < expected)
throw new ArgumentException(
$"S7 SZL 0x0011 payload truncated: header claims {nDr} × {lenThdr} byte records " +
$"({expected} bytes total) but buffer is {payload.Length}", nameof(payload));
string? mlfb = null;
string? fw = null;
string? cpuType = null;
for (var i = 0; i < nDr; i++)
{
var off = HeaderLength + i * lenThdr;
var idx = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(off, 2));
// Fields per Siemens function manual §"SSL-ID 0011H":
// index (2) | MLFB (20) | BGTyp (2) | Ausbg1 (2) | Ausbg2 (2)
var mlfbBytes = payload.AsSpan(off + 2, 20);
var ausbg1 = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(off + 24, 2));
var ausbg2 = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(off + 26, 2));
switch (idx)
{
case 0x0001:
mlfb = TrimAscii(mlfbBytes);
// CPU type: prefer the dedicated record if present, else derive from MLFB
// (the prefix before the first space, e.g. "6ES7 516-3AN01-0AB0" → CPU 1516-3 PN/DP
// — we surface the raw MLFB and let docs map to the marketing name).
cpuType ??= DeriveCpuTypeFromMlfb(mlfb);
break;
case 0x0006:
// Firmware version: high byte of Ausbg1 = major, low byte of Ausbg1 = minor,
// high byte of Ausbg2 = patch. Encoded as two ASCII chars in some firmwares;
// the manual normalises to "Vmajor.minor.patch".
fw = $"V{(ausbg1 >> 8) & 0xFF}.{ausbg1 & 0xFF}.{(ausbg2 >> 8) & 0xFF}";
break;
case 0x0007:
// Module-identification "basic hardware" — some CPUs surface the friendly
// CPU name here as ASCII inside the MLFB slot. Override only if the field
// looks like a real string (non-empty, printable).
var hwName = TrimAscii(mlfbBytes);
if (!string.IsNullOrEmpty(hwName)) cpuType = hwName;
break;
}
}
return new S7CpuInfo(
CpuType: cpuType ?? "(unknown)",
Firmware: fw ?? "(unknown)",
OrderNo: mlfb ?? "(unknown)");
}
/// <summary>
/// Parse SZL 0x0132 / 0x0432 (CPU status data — cycle-time record). The cycle-time
/// record carries 6 × UInt32 BE values starting at offset 4 of the record:
/// <list type="bullet">
/// <item>Reserved (2 bytes index echo)</item>
/// <item>Reserved (2 bytes)</item>
/// <item>CycleAvg ms (UInt32 BE)</item>
/// <item>CycleMin ms (UInt32 BE)</item>
/// <item>CycleMax ms (UInt32 BE)</item>
/// <item>… padding</item>
/// </list>
/// The driver pulls the first record (index <c>0x0005</c> on S7-300/400/1200,
/// index <c>0x0001</c> on S7-1500's 0x0432) and reports the three cycle-time
/// scalars in milliseconds as <see cref="double"/>s — matching the OPC UA Float64
/// representation in <c>DriverDataType.Float64</c>.
/// </summary>
public static S7CycleStats ParseCycleStats(byte[] payload)
{
ArgumentNullException.ThrowIfNull(payload);
EnsureHeader(payload, out _, out _, out var lenThdr, out var nDr);
if (nDr < 1)
throw new ArgumentException(
"S7 SZL cycle-time response has no records", nameof(payload));
if (lenThdr < 16)
throw new ArgumentException(
$"S7 SZL cycle-time record too short: {lenThdr} bytes; need ≥ 16", nameof(payload));
var expected = HeaderLength + lenThdr;
if (payload.Length < expected)
throw new ArgumentException(
$"S7 SZL cycle-time payload truncated: need {expected} bytes, got {payload.Length}", nameof(payload));
var recOff = HeaderLength;
// Layout (per Siemens function manual §"SSL-ID 0132H, Index 5"):
// u16 index | u16 reserved | u32 avgMs | u32 minMs | u32 maxMs
var avg = BinaryPrimitives.ReadUInt32BigEndian(payload.AsSpan(recOff + 4, 4));
var min = BinaryPrimitives.ReadUInt32BigEndian(payload.AsSpan(recOff + 8, 4));
var max = BinaryPrimitives.ReadUInt32BigEndian(payload.AsSpan(recOff + 12, 4));
return new S7CycleStats(MinMs: min, MaxMs: max, AvgMs: avg);
}
/// <summary>
/// Parse SZL 0x00A0 (diagnostic buffer). Each record is 20 bytes:
/// <list type="bullet">
/// <item>EventId (UInt16 BE) — Siemens-defined event code, see manual</item>
/// <item>Priority (UInt8) — alarm priority class 026 (S7-1500: 026)</item>
/// <item>OB number (UInt8) — OB the event triggered (or 0 if no OB)</item>
/// <item>DatId (UInt16 BE) — event-class group (FB / OB / async / …)</item>
/// <item>Info1 (UInt16 BE) — event-specific extra info (e.g. block number)</item>
/// <item>Info2 (UInt32 BE) — event-specific extra info</item>
/// <item>TimeStamp (8 bytes BCD — IEC year/month/day/hour/minute/second/ms)</item>
/// </list>
/// Returns <em>up to</em> <paramref name="maxEntries"/> entries (capped at
/// <see cref="MaxDiagBufferEntriesPerResponse"/>) so a malformed payload claiming
/// a huge count can't blow the test allocator.
/// </summary>
public static IReadOnlyList<S7DiagBufferEntry> ParseDiagBuffer(byte[] payload, int maxEntries)
{
ArgumentNullException.ThrowIfNull(payload);
if (maxEntries < 0)
throw new ArgumentOutOfRangeException(nameof(maxEntries), maxEntries, "maxEntries must be ≥ 0");
EnsureHeader(payload, out _, out _, out var lenThdr, out var nDr);
if (lenThdr != 20)
throw new ArgumentException(
$"S7 SZL 0x00A0 expected record length 20, got {lenThdr}", nameof(payload));
var cap = Math.Min(Math.Min(maxEntries, nDr), MaxDiagBufferEntriesPerResponse);
var expected = HeaderLength + lenThdr * cap;
if (payload.Length < expected)
throw new ArgumentException(
$"S7 SZL 0x00A0 payload truncated: need ≥ {expected} bytes for {cap} entries, " +
$"got {payload.Length}", nameof(payload));
var entries = new S7DiagBufferEntry[cap];
for (var i = 0; i < cap; i++)
{
var off = HeaderLength + i * lenThdr;
var eventId = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(off, 2));
var priority = payload[off + 2];
// payload[off+3] = OB number (kept implicit in EventText below)
// payload[off+4..6] = DatId, payload[off+6..8] = Info1, payload[off+8..12] = Info2
// payload[off+12..20] = BCD timestamp.
var ts = DecodeBcdTimestamp(payload.AsSpan(off + 12, 8));
entries[i] = new S7DiagBufferEntry(
OccurrenceUtc: ts,
EventId: eventId,
Priority: priority,
EventText: $"Event 0x{eventId:X4} (priority {priority})");
}
return entries;
}
/// <summary>
/// Encode a parsed <see cref="S7CpuInfo"/> back into a SZL 0x0011 byte payload.
/// Round-trip helper used by the parser unit tests so encode-then-decode is the
/// identity. Not used at runtime — the driver only ever decodes responses.
/// </summary>
public static byte[] EncodeCpuInfo(S7CpuInfo info, ushort szlId = S7SzlIds.ModuleIdentification)
{
ArgumentNullException.ThrowIfNull(info);
const int LenThdr = 28;
const int NDr = 3;
var buf = new byte[HeaderLength + LenThdr * NDr];
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), szlId);
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(2, 2), 0x0000);
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), LenThdr);
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), NDr);
// Record 0: index 0x0001 — MLFB / order number
WriteRecord(buf.AsSpan(HeaderLength, LenThdr), 0x0001, info.OrderNo, ausbg1: 0, ausbg2: 0);
// Record 1: index 0x0006 — firmware version
var (a1, a2) = ParseFirmwareString(info.Firmware);
WriteRecord(buf.AsSpan(HeaderLength + LenThdr, LenThdr), 0x0006, "", ausbg1: a1, ausbg2: a2);
// Record 2: index 0x0007 — CPU type as ASCII
WriteRecord(buf.AsSpan(HeaderLength + LenThdr * 2, LenThdr), 0x0007, info.CpuType, ausbg1: 0, ausbg2: 0);
return buf;
static void WriteRecord(Span<byte> rec, ushort idx, string mlfb, ushort ausbg1, ushort ausbg2)
{
BinaryPrimitives.WriteUInt16BigEndian(rec[..2], idx);
// 20-byte ASCII space-padded MLFB
rec[2..22].Fill((byte)' ');
var bytes = Encoding.ASCII.GetBytes(mlfb ?? "");
var copy = Math.Min(bytes.Length, 20);
bytes.AsSpan(0, copy).CopyTo(rec[2..(2 + copy)]);
BinaryPrimitives.WriteUInt16BigEndian(rec.Slice(22, 2), 0); // BGTyp
BinaryPrimitives.WriteUInt16BigEndian(rec.Slice(24, 2), ausbg1);
BinaryPrimitives.WriteUInt16BigEndian(rec.Slice(26, 2), ausbg2);
}
static (ushort, ushort) ParseFirmwareString(string fw)
{
// Best-effort parse of "Vmaj.min.patch" — falls back to zeros so a parse failure
// doesn't break round-trip tests for hand-crafted CpuInfo records.
if (string.IsNullOrEmpty(fw)) return (0, 0);
var s = fw.StartsWith('V') ? fw[1..] : fw;
var parts = s.Split('.');
byte maj = 0, min = 0, patch = 0;
if (parts.Length > 0) byte.TryParse(parts[0], out maj);
if (parts.Length > 1) byte.TryParse(parts[1], out min);
if (parts.Length > 2) byte.TryParse(parts[2], out patch);
return ((ushort)((maj << 8) | min), (ushort)(patch << 8));
}
}
/// <summary>Encode a <see cref="S7CycleStats"/> back into a SZL 0x0132 byte payload (round-trip helper).</summary>
public static byte[] EncodeCycleStats(S7CycleStats stats, ushort szlId = S7SzlIds.CpuStatusData)
{
ArgumentNullException.ThrowIfNull(stats);
const int LenThdr = 16;
const int NDr = 1;
var buf = new byte[HeaderLength + LenThdr * NDr];
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), szlId);
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(2, 2), S7SzlIds.CpuStatusCycleTimeIndex);
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), LenThdr);
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), NDr);
var rec = buf.AsSpan(HeaderLength);
BinaryPrimitives.WriteUInt16BigEndian(rec[..2], S7SzlIds.CpuStatusCycleTimeIndex);
BinaryPrimitives.WriteUInt16BigEndian(rec.Slice(2, 2), 0);
BinaryPrimitives.WriteUInt32BigEndian(rec.Slice(4, 4), (uint)stats.AvgMs);
BinaryPrimitives.WriteUInt32BigEndian(rec.Slice(8, 4), (uint)stats.MinMs);
BinaryPrimitives.WriteUInt32BigEndian(rec.Slice(12, 4), (uint)stats.MaxMs);
return buf;
}
/// <summary>Encode a list of <see cref="S7DiagBufferEntry"/> back into a SZL 0x00A0 byte payload (round-trip helper).</summary>
public static byte[] EncodeDiagBuffer(IReadOnlyList<S7DiagBufferEntry> entries)
{
ArgumentNullException.ThrowIfNull(entries);
const int LenThdr = 20;
var buf = new byte[HeaderLength + LenThdr * entries.Count];
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), S7SzlIds.DiagnosticBuffer);
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(2, 2), 0);
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), LenThdr);
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), (ushort)entries.Count);
for (var i = 0; i < entries.Count; i++)
{
var rec = buf.AsSpan(HeaderLength + i * LenThdr, LenThdr);
var e = entries[i];
BinaryPrimitives.WriteUInt16BigEndian(rec[..2], e.EventId);
rec[2] = e.Priority;
rec[3] = 0; // OB number — not surfaced
BinaryPrimitives.WriteUInt16BigEndian(rec.Slice(4, 2), 0);
BinaryPrimitives.WriteUInt16BigEndian(rec.Slice(6, 2), 0);
BinaryPrimitives.WriteUInt32BigEndian(rec.Slice(8, 4), 0);
EncodeBcdTimestamp(e.OccurrenceUtc, rec.Slice(12, 8));
}
return buf;
}
private static void EnsureHeader(byte[] payload, out ushort szlId, out ushort szlIndex, out ushort lenThdr, out ushort nDr)
{
if (payload.Length < HeaderLength)
throw new ArgumentException(
$"S7 SZL payload truncated: need at least {HeaderLength}-byte header, got {payload.Length}", nameof(payload));
szlId = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(0, 2));
szlIndex = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(2, 2));
lenThdr = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(4, 2));
nDr = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(6, 2));
}
private static string TrimAscii(ReadOnlySpan<byte> bytes)
{
var s = Encoding.ASCII.GetString(bytes);
return s.TrimEnd(' ', '\0');
}
/// <summary>
/// Best-effort CPU type derivation from MLFB. The MLFB encodes the CPU model — e.g.
/// <c>6ES7 516-3AN01-0AB0</c> identifies a CPU 1516-3 PN/DP. Without a full lookup
/// table we just return the MLFB so operators can grep the manual; SZL index 0x0007
/// overrides this when the CPU surfaces a friendly name there.
/// </summary>
private static string DeriveCpuTypeFromMlfb(string mlfb) => mlfb;
/// <summary>
/// Decode an 8-byte BCD timestamp (Siemens IEC representation):
/// <c>year(2) month(1) day(1) hour(1) minute(1) second(1) ms-day-of-week(2)</c>.
/// The last 2 bytes pack three BCD ms digits and a day-of-week nibble.
/// </summary>
private static DateTimeOffset DecodeBcdTimestamp(ReadOnlySpan<byte> b)
{
// Year: 2-byte BCD (e.g. 0x20 0x24 = 2024)
var year = FromBcd(b[0]) * 100 + FromBcd(b[1]);
var month = Math.Clamp(FromBcd(b[2]), 1, 12);
var day = Math.Clamp(FromBcd(b[3]), 1, 31);
var hour = Math.Clamp(FromBcd(b[4]), 0, 23);
var minute = Math.Clamp(FromBcd(b[5]), 0, 59);
var second = Math.Clamp(FromBcd(b[6]), 0, 59);
// ms: high nibble of b[7] = first ms digit, low nibble of b[7] is reserved /
// day-of-week. Some CPUs pack three ms digits across b[7] high/low + the high
// nibble of the last byte; per Siemens function manual the simplest portable
// decode is to drop ms and surface only second-precision.
var ms = 0;
try
{
return new DateTimeOffset(year, month, day, hour, minute, second, ms, TimeSpan.Zero);
}
catch (ArgumentOutOfRangeException)
{
// Malformed timestamp — surface as epoch rather than throw so a single bad
// entry doesn't take out the whole diag-buffer parse.
return DateTimeOffset.UnixEpoch;
}
static int FromBcd(byte v) => ((v >> 4) & 0xF) * 10 + (v & 0xF);
}
private static void EncodeBcdTimestamp(DateTimeOffset ts, Span<byte> dst)
{
var u = ts.UtcDateTime;
dst[0] = ToBcd(u.Year / 100);
dst[1] = ToBcd(u.Year % 100);
dst[2] = ToBcd(u.Month);
dst[3] = ToBcd(u.Day);
dst[4] = ToBcd(u.Hour);
dst[5] = ToBcd(u.Minute);
dst[6] = ToBcd(u.Second);
dst[7] = 0; // ms / day-of-week — not round-tripped
static byte ToBcd(int v) => (byte)(((v / 10) << 4) | (v % 10));
}
}
/// <summary>CPU identification parsed from SZL 0x0011.</summary>
/// <param name="CpuType">Marketing / friendly CPU name from SZL index 0x0007 (or MLFB fallback).</param>
/// <param name="Firmware">Firmware version, formatted "Vmaj.min.patch".</param>
/// <param name="OrderNo">MLFB / order number from SZL index 0x0001 (e.g. "6ES7 516-3AN01-0AB0").</param>
public sealed record S7CpuInfo(string CpuType, string Firmware, string OrderNo);
/// <summary>CPU cycle-time statistics parsed from SZL 0x0132 / 0x0432 — values in milliseconds.</summary>
/// <param name="MinMs">Shortest scan cycle observed since last reset.</param>
/// <param name="MaxMs">Longest scan cycle observed since last reset.</param>
/// <param name="AvgMs">Rolling average scan-cycle time.</param>
public sealed record S7CycleStats(double MinMs, double MaxMs, double AvgMs);
/// <summary>One diagnostic-buffer entry parsed from SZL 0x00A0.</summary>
/// <param name="OccurrenceUtc">Event timestamp decoded from the BCD timestamp field (UTC).</param>
/// <param name="EventId">Siemens event code (e.g. 0x113A = "communication initiated").</param>
/// <param name="Priority">Alarm priority class 026.</param>
/// <param name="EventText">Human-readable rendering of the event — currently the raw 0x???? code; future PR can plug a lookup table.</param>
public sealed record S7DiagBufferEntry(
DateTimeOffset OccurrenceUtc,
ushort EventId,
byte Priority,
string EventText);

View File

@@ -0,0 +1,139 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// <summary>
/// PR-S7-E1 / #302 — integration scaffold for SZL (System Status List) reads against
/// a real S7-1500 CPU. snap7 (the simulator that backs <see cref="Snap7ServerFixture"/>)
/// does not implement SZL — every <c>@System.*</c> read returns <c>BadNotSupported</c>
/// against the simulator — so the asserts here verify the not-supported semantics
/// when running against snap7, and the live-firmware tests are gated on a real-PLC
/// env-var (<c>S7_LIVE_HOST</c>) the same way other PR-S7-* live tests are.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why scaffolding rather than full live verification?</b> The plan section
/// calls for a "live-firmware test against dev-box S7-1500"; that's a hardware-
/// gated test and it is parked behind an env-var so the CI pipeline + a fresh
/// developer checkout both stay green. The not-supported assertion against snap7
/// is the "always-runs" piece — proves the dispatch path lights up + surfaces
/// the right StatusCode without a live CPU.
/// </para>
/// </remarks>
[Collection(Snap7ServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "S7_1500")]
public sealed class S7_1500SzlTests(Snap7ServerFixture sim)
{
/// <summary>OPC UA <c>BadNotSupported</c> status code — same constant the driver uses.</summary>
private const uint StatusBadNotSupported = 0x803D0000u;
/// <summary>
/// Re-build the simulator profile with <c>ExposeSystemTags = true</c>. <see cref="S7DriverOptions"/>
/// is a class (not a record), so we copy fields manually rather than using a <c>with</c> expression.
/// </summary>
private static S7DriverOptions BuildOptionsWithSystemTags(string host, int port, int diagBufferDepth = 10)
{
var baseOpts = S7_1500Profile.BuildOptions(host, port);
return new S7DriverOptions
{
Host = baseOpts.Host,
Port = baseOpts.Port,
CpuType = baseOpts.CpuType,
Rack = baseOpts.Rack,
Slot = baseOpts.Slot,
Timeout = baseOpts.Timeout,
Probe = baseOpts.Probe,
Tags = baseOpts.Tags,
ExposeSystemTags = true,
DiagBufferDepth = diagBufferDepth,
};
}
[Fact]
public async Task System_CpuType_returns_BadNotSupported_against_snap7_simulator()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var options = BuildOptionsWithSystemTags(sim.Host, sim.Port);
await using var drv = new S7Driver(options, driverInstanceId: "s7-szl-cputype");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var snaps = await drv.ReadAsync(["@System.CpuType"], TestContext.Current.CancellationToken);
snaps.Count.ShouldBe(1);
// snap7 doesn't implement SZL; the production S7NetSzlReader returns null too
// (S7netplus 0.20 has no public ReadSzlAsync surface). Both paths converge on
// BadNotSupported.
snaps[0].StatusCode.ShouldBe(StatusBadNotSupported);
}
[Fact]
public async Task DiscoverAsync_emits_diagnostics_folder_against_real_simulator_when_opted_in()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var options = BuildOptionsWithSystemTags(sim.Host, sim.Port, diagBufferDepth: 5);
await using var drv = new S7Driver(options, driverInstanceId: "s7-szl-discover");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var builder = new TestAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
builder.Folders.ShouldContain(S7SystemTags.FolderName);
builder.Folders.ShouldContain("DiagBuffer");
// 6 scalars + 5 buffer entries = 11 system variables.
builder.Variables
.Count(v => v.FullName.StartsWith(S7SystemTags.Prefix, StringComparison.Ordinal))
.ShouldBe(11);
}
/// <summary>
/// Live-firmware gate: when the env-var <c>S7_LIVE_HOST</c> points at a real
/// S7-1500, this test runs end-to-end against the live CPU and expects a
/// non-empty CpuType / Firmware / OrderNo. Hardware-gated; CI skips it.
/// Currently parked at <see cref="Assert.Skip"/> because S7netplus 0.20 doesn't
/// expose a public SZL surface — even against a real CPU the production
/// <see cref="S7NetSzlReader"/> returns null. Flip this back on once the
/// S7netplus PR for ReadSzlAsync lands or we ship a raw-PDU helper.
/// </summary>
[Fact(Skip = "Requires real S7-1500 + S7netplus public ReadSzlAsync surface; see PR-S7-E1 docs.")]
public Task System_CpuType_against_live_S7_1500_returns_non_empty_string()
{
// var liveHost = Environment.GetEnvironmentVariable("S7_LIVE_HOST");
// if (string.IsNullOrWhiteSpace(liveHost))
// Assert.Skip("S7_LIVE_HOST not set — skipping live-firmware SZL test");
// var options = new S7DriverOptions { Host = liveHost, ExposeSystemTags = true, ... };
// ...
return Task.CompletedTask;
}
private sealed class TestAddressSpaceBuilder : Core.Abstractions.IAddressSpaceBuilder
{
public List<string> Folders { get; } = [];
public List<Core.Abstractions.DriverAttributeInfo> Variables { get; } = [];
public Core.Abstractions.IAddressSpaceBuilder Folder(string browseName, string displayName)
{
Folders.Add(browseName);
return this;
}
public Core.Abstractions.IVariableHandle Variable(
string browseName, string displayName, Core.Abstractions.DriverAttributeInfo info)
{
Variables.Add(info);
return new StubHandle();
}
public void AddProperty(string browseName, Core.Abstractions.DriverDataType dataType, object? value) { }
private sealed class StubHandle : Core.Abstractions.IVariableHandle
{
public string FullReference => "stub";
public Core.Abstractions.IAlarmConditionSink MarkAsAlarmCondition(Core.Abstractions.AlarmConditionInfo info)
=> throw new NotImplementedException();
}
}
}

View File

@@ -0,0 +1,260 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.Szl;
/// <summary>
/// PR-S7-E1 — driver-side wiring for SZL-backed <c>@System.*</c> virtual addresses.
/// Tests run without a real PLC by injecting an <see cref="IS7SzlReader"/> fake and
/// calling <see cref="S7Driver.ReadAsync"/> against <c>@System.*</c> references — the
/// driver short-circuits those before <c>RequirePlc()</c> so the read path lights up
/// without touching the wire.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7SystemTagsTests
{
private sealed class FakeSzlReader(Func<ushort, ushort, byte[]?> respond) : IS7SzlReader
{
public int CallCount { get; private set; }
public List<(ushort SzlId, ushort SzlIndex)> Calls { get; } = new();
public Task<byte[]?> ReadSzlAsync(ushort szlId, ushort szlIndex, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
CallCount++;
Calls.Add((szlId, szlIndex));
return Task.FromResult(respond(szlId, szlIndex));
}
}
private sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
{
public List<string> Folders { get; } = new();
public List<(string Browse, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
Folders.Add(browseName);
return this;
}
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add((browseName, attributeInfo));
return new StubHandle();
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class StubHandle : IVariableHandle
{
public string FullReference => "stub";
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotImplementedException();
}
}
[Fact]
public async Task DiscoverAsync_emits_no_diagnostics_folder_when_ExposeSystemTags_is_false()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Tags = [new S7TagDefinition("Setpoint", "DB1.DBW0", S7DataType.Int16)],
};
using var drv = new S7Driver(opts, "s7-disco-no-system");
var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
builder.Folders.ShouldNotContain(S7SystemTags.FolderName);
builder.Variables.Select(v => v.Info.FullName)
.ShouldNotContain(n => n.StartsWith(S7SystemTags.Prefix, StringComparison.Ordinal));
}
[Fact]
public async Task DiscoverAsync_emits_diagnostics_folder_with_six_scalars_and_ten_buffer_entries()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
ExposeSystemTags = true,
// Default DiagBufferDepth = 10 — six scalars + ten entries = 16 system variables.
Tags = [],
};
using var drv = new S7Driver(opts, "s7-disco-with-system");
var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
builder.Folders.ShouldContain(S7SystemTags.FolderName);
builder.Folders.ShouldContain("DiagBuffer");
var systemVars = builder.Variables
.Where(v => v.Info.FullName.StartsWith(S7SystemTags.Prefix, StringComparison.Ordinal))
.ToList();
systemVars.Count.ShouldBe(16); // 6 scalars + 10 buffer entries
// CpuType / Firmware / OrderNo project as String; CycleMs.* as Float64.
systemVars.ShouldContain(v => v.Info.FullName == "@System.CpuType" && v.Info.DriverDataType == DriverDataType.String);
systemVars.ShouldContain(v => v.Info.FullName == "@System.CycleMs.Avg" && v.Info.DriverDataType == DriverDataType.Float64);
// Diagnostics tags are ViewOnly — never writable.
systemVars.ShouldAllBe(v => v.Info.SecurityClass == SecurityClassification.ViewOnly);
}
[Fact]
public async Task DiscoverAsync_honours_custom_DiagBufferDepth()
{
var opts = new S7DriverOptions
{
ExposeSystemTags = true,
DiagBufferDepth = 3,
Tags = [],
};
using var drv = new S7Driver(opts, "s7-disco-custom-depth");
var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
var bufferEntries = builder.Variables
.Where(v => v.Info.FullName.StartsWith(S7SystemTags.DiagBufferEntryPrefix, StringComparison.Ordinal))
.ToList();
bufferEntries.Count.ShouldBe(3);
bufferEntries[0].Info.FullName.ShouldBe("@System.DiagBuffer.Entry[0]");
bufferEntries[2].Info.FullName.ShouldBe("@System.DiagBuffer.Entry[2]");
}
[Fact]
public async Task ReadAsync_returns_parsed_CpuType_via_injected_reader()
{
var info = new S7CpuInfo("CPU 1215C", "V4.5.0", "6ES7 215-1AG40-0XB0");
var reader = new FakeSzlReader((id, idx) =>
id == S7SzlIds.ModuleIdentification ? S7SzlParser.EncodeCpuInfo(info) : null);
var opts = new S7DriverOptions { ExposeSystemTags = true, Tags = [] };
using var drv = new S7Driver(opts, "s7-cputype") { SzlReader = reader };
var snaps = await drv.ReadAsync(["@System.CpuType"], TestContext.Current.CancellationToken);
snaps.Count.ShouldBe(1);
snaps[0].StatusCode.ShouldBe(0u);
snaps[0].Value.ShouldBe("CPU 1215C");
reader.CallCount.ShouldBe(1);
}
[Fact]
public async Task ReadAsync_returns_BadNotSupported_when_reader_returns_null()
{
var reader = new FakeSzlReader((_, _) => null); // snap7 / S7netplus 0.20 path
var opts = new S7DriverOptions { ExposeSystemTags = true, Tags = [] };
using var drv = new S7Driver(opts, "s7-not-supported") { SzlReader = reader };
var snaps = await drv.ReadAsync(["@System.CpuType", "@System.Firmware"], TestContext.Current.CancellationToken);
snaps.Count.ShouldBe(2);
snaps[0].StatusCode.ShouldBe(0x803D0000u, "BadNotSupported");
snaps[0].Value.ShouldBeNull();
snaps[1].StatusCode.ShouldBe(0x803D0000u);
}
[Fact]
public async Task ReadAsync_caches_SZL_payload_within_TTL()
{
var info = new S7CpuInfo("CPU 1516", "V2.9.4", "6ES7 516-3AN01-0AB0");
var reader = new FakeSzlReader((_, _) => S7SzlParser.EncodeCpuInfo(info));
var opts = new S7DriverOptions
{
ExposeSystemTags = true,
// 1-hour TTL so the second read inside the test definitely hits the cache.
SzlCacheTtl = TimeSpan.FromHours(1),
Tags = [],
};
using var drv = new S7Driver(opts, "s7-cache-hit") { SzlReader = reader };
var first = await drv.ReadAsync(["@System.CpuType"], TestContext.Current.CancellationToken);
var second = await drv.ReadAsync(["@System.Firmware"], TestContext.Current.CancellationToken);
first[0].Value.ShouldBe("CPU 1516");
second[0].Value.ShouldBe("V2.9.4");
// Both projections come from the same SZL 0x0011 payload — exactly one wire call.
reader.CallCount.ShouldBe(1);
}
[Fact]
public async Task ReadAsync_misses_cache_when_TTL_is_zero()
{
var info = new S7CpuInfo("CPU 1516", "V2.9.4", "6ES7 516-3AN01-0AB0");
var reader = new FakeSzlReader((_, _) => S7SzlParser.EncodeCpuInfo(info));
var opts = new S7DriverOptions
{
ExposeSystemTags = true,
SzlCacheTtl = TimeSpan.Zero,
Tags = [],
};
using var drv = new S7Driver(opts, "s7-cache-miss") { SzlReader = reader };
await drv.ReadAsync(["@System.CpuType"], TestContext.Current.CancellationToken);
await drv.ReadAsync(["@System.CpuType"], TestContext.Current.CancellationToken);
// TTL=Zero means every read goes to the wire.
reader.CallCount.ShouldBe(2);
}
[Fact]
public async Task ReadAsync_returns_BadNodeIdUnknown_for_unrecognised_system_address()
{
var reader = new FakeSzlReader((_, _) => null);
var opts = new S7DriverOptions { ExposeSystemTags = true, Tags = [] };
using var drv = new S7Driver(opts, "s7-bad-system") { SzlReader = reader };
var snaps = await drv.ReadAsync(["@System.NotARealField"], TestContext.Current.CancellationToken);
snaps.Count.ShouldBe(1);
snaps[0].StatusCode.ShouldBe(0x80340000u, "BadNodeIdUnknown");
// No SZL wire call — the address didn't resolve to a known descriptor.
reader.CallCount.ShouldBe(0);
}
[Fact]
public async Task ReadAsync_returns_diag_buffer_entry_string_when_reader_supplies_payload()
{
var entries = new[]
{
new S7DiagBufferEntry(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero), 0xCAFE, 3, "Event 0xCAFE (priority 3)"),
new S7DiagBufferEntry(new DateTimeOffset(2024, 1, 1, 0, 0, 5, TimeSpan.Zero), 0xBEEF, 4, "Event 0xBEEF (priority 4)"),
};
var reader = new FakeSzlReader((id, _) =>
id == S7SzlIds.DiagnosticBuffer ? S7SzlParser.EncodeDiagBuffer(entries) : null);
var opts = new S7DriverOptions { ExposeSystemTags = true, DiagBufferDepth = 5, Tags = [] };
using var drv = new S7Driver(opts, "s7-diag") { SzlReader = reader };
var snaps = await drv.ReadAsync(
["@System.DiagBuffer.Entry[0]", "@System.DiagBuffer.Entry[1]"],
TestContext.Current.CancellationToken);
snaps.Count.ShouldBe(2);
snaps[0].StatusCode.ShouldBe(0u);
((string)snaps[0].Value!).ShouldContain("0xCAFE");
((string)snaps[1].Value!).ShouldContain("0xBEEF");
}
[Fact]
public async Task ReadAsync_mixes_system_tags_with_unknown_regular_tags_returning_status_per_request()
{
var info = new S7CpuInfo("CPU 1500", "V2.9", "6ES7");
var reader = new FakeSzlReader((_, _) => S7SzlParser.EncodeCpuInfo(info));
var opts = new S7DriverOptions { ExposeSystemTags = true, Tags = [] };
using var drv = new S7Driver(opts, "s7-mixed") { SzlReader = reader };
// SystemTag should resolve via the short-circuit; "NoSuchTag" should never reach
// the Plc gate because the only other request was a system tag — but if it does,
// the test must not hang (RequirePlc would throw). Both are short-circuited so
// the call returns BadNodeIdUnknown for "NoSuchTag" via the system-prefix check
// failing. To exercise that, request both as @System.* references — one valid,
// one bogus.
var snaps = await drv.ReadAsync(
["@System.CpuType", "@System.Bogus"],
TestContext.Current.CancellationToken);
snaps[0].Value.ShouldBe("CPU 1500");
snaps[0].StatusCode.ShouldBe(0u);
snaps[1].StatusCode.ShouldBe(0x80340000u, "BadNodeIdUnknown for unrecognised @System.* address");
}
}

View File

@@ -0,0 +1,213 @@
using System.Buffers.Binary;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.Szl;
/// <summary>
/// PR-S7-E1 — golden-byte tests for <see cref="S7SzlParser"/>. Each test hand-crafts a
/// structurally-valid SZL response payload (matching the layout in the Siemens function
/// manual, §"SSL-IDs") and asserts the parser projects every field the driver surfaces
/// through <c>@System.*</c>. Round-trip tests prove encode-then-decode is the identity
/// so test fixtures stay self-consistent without leaning on real PLC traffic.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7SzlParserTests
{
[Fact]
public void ParseCpuInfo_decodes_module_identification_records()
{
// Hand-craft a SZL 0x0011 response with three records:
// index 0x0001 — MLFB "6ES7 516-3AN01-0AB0 " (20 bytes ASCII)
// index 0x0006 — firmware Vmajor.minor.patch encoded in Ausbg1/Ausbg2
// index 0x0007 — friendly CPU name "CPU 1516-3 PN/DP"
var info = new S7CpuInfo(
CpuType: "CPU 1516-3 PN/DP",
Firmware: "V2.9.4",
OrderNo: "6ES7 516-3AN01-0AB0");
var payload = S7SzlParser.EncodeCpuInfo(info);
var parsed = S7SzlParser.ParseCpuInfo(payload);
parsed.OrderNo.ShouldBe("6ES7 516-3AN01-0AB0");
parsed.CpuType.ShouldBe("CPU 1516-3 PN/DP");
parsed.Firmware.ShouldBe("V2.9.4");
}
[Fact]
public void ParseCpuInfo_handles_missing_records_with_unknown_fallback()
{
// Header claims zero records — every field falls back to "(unknown)" rather than throwing.
var buf = new byte[S7SzlParser.HeaderLength];
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), S7SzlIds.ModuleIdentification);
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(2, 2), 0);
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), 28); // record length valid, count = 0
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), 0);
var parsed = S7SzlParser.ParseCpuInfo(buf);
parsed.CpuType.ShouldBe("(unknown)");
parsed.Firmware.ShouldBe("(unknown)");
parsed.OrderNo.ShouldBe("(unknown)");
}
[Fact]
public void ParseCycleStats_decodes_min_max_avg_milliseconds()
{
// Hand-craft SZL 0x0132 with avg=10ms, min=5ms, max=42ms.
var stats = new S7CycleStats(MinMs: 5, MaxMs: 42, AvgMs: 10);
var payload = S7SzlParser.EncodeCycleStats(stats);
var parsed = S7SzlParser.ParseCycleStats(payload);
parsed.MinMs.ShouldBe(5);
parsed.MaxMs.ShouldBe(42);
parsed.AvgMs.ShouldBe(10);
}
[Fact]
public void ParseDiagBuffer_decodes_five_entries_with_timestamps_and_event_ids()
{
var entries = new List<S7DiagBufferEntry>
{
new(new DateTimeOffset(2024, 1, 15, 8, 30, 0, TimeSpan.Zero), 0x113A, 1, "Event 0x113A (priority 1)"),
new(new DateTimeOffset(2024, 1, 15, 8, 31, 5, TimeSpan.Zero), 0x4302, 5, "Event 0x4302 (priority 5)"),
new(new DateTimeOffset(2024, 1, 15, 8, 32, 17, TimeSpan.Zero), 0x4308, 5, "Event 0x4308 (priority 5)"),
new(new DateTimeOffset(2024, 1, 15, 8, 33, 42, TimeSpan.Zero), 0x39C0, 26, "Event 0x39C0 (priority 26)"),
new(new DateTimeOffset(2024, 1, 15, 8, 34, 59, TimeSpan.Zero), 0x4505, 1, "Event 0x4505 (priority 1)"),
};
var payload = S7SzlParser.EncodeDiagBuffer(entries);
var parsed = S7SzlParser.ParseDiagBuffer(payload, maxEntries: 10);
parsed.Count.ShouldBe(5);
parsed[0].EventId.ShouldBe((ushort)0x113A);
parsed[0].Priority.ShouldBe((byte)1);
parsed[0].OccurrenceUtc.Year.ShouldBe(2024);
parsed[0].OccurrenceUtc.Month.ShouldBe(1);
parsed[0].OccurrenceUtc.Day.ShouldBe(15);
parsed[0].OccurrenceUtc.Hour.ShouldBe(8);
parsed[0].OccurrenceUtc.Minute.ShouldBe(30);
parsed[3].EventId.ShouldBe((ushort)0x39C0);
parsed[3].Priority.ShouldBe((byte)26);
parsed[3].OccurrenceUtc.Second.ShouldBe(42);
}
[Fact]
public void ParseDiagBuffer_caps_entries_to_caller_supplied_max()
{
var entries = new List<S7DiagBufferEntry>();
for (var i = 0; i < 10; i++)
entries.Add(new(DateTimeOffset.UnixEpoch, (ushort)(0x1000 + i), 1, ""));
var payload = S7SzlParser.EncodeDiagBuffer(entries);
var parsed = S7SzlParser.ParseDiagBuffer(payload, maxEntries: 3);
parsed.Count.ShouldBe(3);
parsed[0].EventId.ShouldBe((ushort)0x1000);
parsed[2].EventId.ShouldBe((ushort)0x1002);
}
[Fact]
public void ParseCpuInfo_throws_on_truncated_payload()
{
// Header claims 28-byte records × 3 but body is only 4 bytes — should reject.
var buf = new byte[S7SzlParser.HeaderLength + 4];
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), S7SzlIds.ModuleIdentification);
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), 28);
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), 3);
Should.Throw<ArgumentException>(() => S7SzlParser.ParseCpuInfo(buf));
}
[Fact]
public void ParseCycleStats_throws_on_short_record()
{
// Record length advertised as 8 bytes — too short for the cycle-time payload.
var buf = new byte[S7SzlParser.HeaderLength + 8];
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), S7SzlIds.CpuStatusData);
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), 8);
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), 1);
Should.Throw<ArgumentException>(() => S7SzlParser.ParseCycleStats(buf));
}
[Fact]
public void ParseDiagBuffer_throws_on_wrong_record_length()
{
// SZL 0x00A0 records are exactly 20 bytes; 16 should be rejected.
var buf = new byte[S7SzlParser.HeaderLength + 16];
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), S7SzlIds.DiagnosticBuffer);
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), 16);
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), 1);
Should.Throw<ArgumentException>(() => S7SzlParser.ParseDiagBuffer(buf, maxEntries: 1));
}
[Fact]
public void Parser_throws_on_header_only_truncation()
{
Should.Throw<ArgumentException>(() => S7SzlParser.ParseCpuInfo(new byte[4]));
Should.Throw<ArgumentException>(() => S7SzlParser.ParseCycleStats(new byte[2]));
Should.Throw<ArgumentException>(() => S7SzlParser.ParseDiagBuffer(new byte[3], maxEntries: 1));
}
[Fact]
public void Round_trip_encode_then_parse_preserves_cpu_info()
{
var original = new S7CpuInfo("CPU 1215C", "V4.5.0", "6ES7 215-1AG40-0XB0");
var enc = S7SzlParser.EncodeCpuInfo(original);
var dec = S7SzlParser.ParseCpuInfo(enc);
dec.CpuType.ShouldBe(original.CpuType);
dec.Firmware.ShouldBe(original.Firmware);
dec.OrderNo.ShouldBe(original.OrderNo);
// Re-encode the parsed result and parse again — must equal the first decode.
var reenc = S7SzlParser.EncodeCpuInfo(dec);
var redec = S7SzlParser.ParseCpuInfo(reenc);
redec.ShouldBe(dec);
}
[Fact]
public void Round_trip_encode_then_parse_preserves_cycle_stats()
{
var original = new S7CycleStats(MinMs: 1, MaxMs: 999, AvgMs: 7);
var enc = S7SzlParser.EncodeCycleStats(original);
var dec = S7SzlParser.ParseCycleStats(enc);
dec.ShouldBe(original);
var reenc = S7SzlParser.EncodeCycleStats(dec);
var redec = S7SzlParser.ParseCycleStats(reenc);
redec.ShouldBe(dec);
}
[Fact]
public void Round_trip_encode_then_parse_preserves_diag_buffer_event_ids_and_priority()
{
// Use UTC midnight aligned timestamps so the BCD encoder's second-precision rounding
// (ms isn't round-tripped) doesn't cause a comparison miss.
var original = new[]
{
new S7DiagBufferEntry(new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero), 0xAAAA, 5, "Event 0xAAAA (priority 5)"),
new S7DiagBufferEntry(new DateTimeOffset(2024, 6, 2, 0, 30, 15, TimeSpan.Zero), 0xBBBB, 10, "Event 0xBBBB (priority 10)"),
};
var enc = S7SzlParser.EncodeDiagBuffer(original);
var dec = S7SzlParser.ParseDiagBuffer(enc, maxEntries: 10);
dec.Count.ShouldBe(2);
// EventId / Priority round-trip exactly; OccurrenceUtc round-trips at second precision.
dec[0].EventId.ShouldBe(original[0].EventId);
dec[0].Priority.ShouldBe(original[0].Priority);
dec[0].OccurrenceUtc.ShouldBe(original[0].OccurrenceUtc);
dec[1].EventId.ShouldBe(original[1].EventId);
dec[1].OccurrenceUtc.ShouldBe(original[1].OccurrenceUtc);
// Re-encode + re-parse should yield the same decoded list.
var reenc = S7SzlParser.EncodeDiagBuffer(dec);
var redec = S7SzlParser.ParseDiagBuffer(reenc, maxEntries: 10);
redec.Count.ShouldBe(dec.Count);
for (var i = 0; i < dec.Count; i++)
{
redec[i].EventId.ShouldBe(dec[i].EventId);
redec[i].Priority.ShouldBe(dec[i].Priority);
redec[i].OccurrenceUtc.ShouldBe(dec[i].OccurrenceUtc);
}
}
}