@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using S7.Net;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7.SymbolImport;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7.Szl;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
@@ -96,6 +97,32 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
private bool _disposed;
|
||||
|
||||
// ---- PR-S7-E1 — SZL / @System.* virtual address state ----
|
||||
//
|
||||
// SzlReader is the wire surface (interface so tests can substitute fakes); SzlCache
|
||||
// is the per-driver TTL cache fronting every SZL read so a burst of @System.* reads
|
||||
// from one OPC UA subscription tick produces exactly one wire request per SZL ID.
|
||||
// Both are constructed in InitializeAsync once Plc is open; both stay null when
|
||||
// ExposeSystemTags is false (cheap shortcut on the read path).
|
||||
private IS7SzlReader? _szlReader;
|
||||
private S7SzlCache? _szlCache;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E1 — test seam for the SZL wire reader. Setting this overrides the
|
||||
/// default <see cref="S7NetSzlReader"/> created from the live <see cref="Plc"/>
|
||||
/// so unit tests can drive <c>@System.*</c> reads with golden-byte payloads
|
||||
/// without needing a real PLC. Setting before <see cref="InitializeAsync"/> is
|
||||
/// fine — InitializeAsync only swaps in the production reader when this is null.
|
||||
/// </summary>
|
||||
internal IS7SzlReader? SzlReader
|
||||
{
|
||||
get => _szlReader;
|
||||
set => _szlReader = value;
|
||||
}
|
||||
|
||||
/// <summary>Test-only access to the SZL cache for assertions about TTL behaviour.</summary>
|
||||
internal S7SzlCache? SzlCache => _szlCache;
|
||||
|
||||
// ---- Block-read coalescing diagnostics (PR-S7-B2) ----
|
||||
//
|
||||
// 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.
|
||||
_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,
|
||||
// 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
|
||||
@@ -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
|
||||
// without the previous run's UDT leaves leaking into the new tag map.
|
||||
_effectiveTags.Clear();
|
||||
// PR-S7-E1 — drop the SZL state so a fresh Plc on Reinit gets fresh CPU info /
|
||||
// cycle stats / diagnostic-buffer entries. Clearing here keeps the test-supplied
|
||||
// SzlReader override intact (set before Initialize) so a Shutdown / re-Initialize
|
||||
// cycle from a unit test can re-use the same fake reader.
|
||||
_szlCache?.Clear();
|
||||
_szlCache = null;
|
||||
// _szlReader: keep an explicit test-supplied reader (set via SzlReader property);
|
||||
// drop the production one tied to the now-closed Plc so re-Init constructs fresh.
|
||||
if (_szlReader is S7NetSzlReader) _szlReader = null;
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
@@ -308,10 +352,35 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
var plc = RequirePlc();
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
|
||||
// PR-S7-E1 — short-circuit @System.* virtual addresses before taking the Plc
|
||||
// gate. SZL reads don't go through the regular tag map / address parser; they
|
||||
// dispatch through IS7SzlReader (cached for SzlCacheTtl) and parse with
|
||||
// S7SzlParser. Doing this first means the test path can read @System.* without
|
||||
// a Plc connection (the reader is injectable).
|
||||
var nonSystemIndexes = new List<int>(fullReferences.Count);
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var name = fullReferences[i];
|
||||
if (S7SystemTags.IsSystemAddress(name))
|
||||
{
|
||||
results[i] = await ReadSystemTagAsync(name, now, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
nonSystemIndexes.Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
// If every requested reference was a @System.* tag, we're done before touching
|
||||
// the Plc gate at all — keeps the @System.* surface usable in test setups that
|
||||
// injected an SzlReader without ever calling InitializeAsync against a real PLC.
|
||||
if (nonSystemIndexes.Count == 0) return results;
|
||||
|
||||
var plc = RequirePlc();
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
@@ -321,9 +390,9 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
// (arrays, strings, dates, 64-bit ints, UDT-fanout). Packable tags feed
|
||||
// the block-coalescing planner first (PR-S7-B2); whatever survives as a
|
||||
// singleton range falls through to the multi-var packer (PR-S7-B1).
|
||||
var packableIndexes = new List<int>(fullReferences.Count);
|
||||
var packableIndexes = new List<int>(nonSystemIndexes.Count);
|
||||
var fallbackIndexes = new List<int>();
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
foreach (var i in nonSystemIndexes)
|
||||
{
|
||||
var name = fullReferences[i];
|
||||
if (!_tagsByName.TryGetValue(name, out var tag))
|
||||
@@ -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>
|
||||
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,
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,48 @@ public sealed class S7DriverOptions
|
||||
/// including the 4-level nesting cap and the Optimized-DB prerequisite.
|
||||
/// </summary>
|
||||
public IReadOnlyList<S7UdtDefinition> Udts { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E1 / #302 — when <c>true</c>, <see cref="S7Driver.DiscoverAsync"/> emits a
|
||||
/// <c>Diagnostics/</c> sub-folder under the driver root containing virtual
|
||||
/// <c>@System.*</c> variables backed by SZL (System Status List) reads:
|
||||
/// <c>CpuType</c>, <c>Firmware</c>, <c>OrderNo</c> (SZL 0x0011),
|
||||
/// <c>CycleMs.Min</c> / <c>.Max</c> / <c>.Avg</c> (SZL 0x0132 / 0x0432), and
|
||||
/// <c>DiagBuffer/Entry[0..N]</c> (SZL 0x00A0). Default <c>false</c> — operators opt
|
||||
/// in per driver instance because the virtual nodes show up in OPC UA Browse
|
||||
/// under every connected client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// S7netplus 0.20 doesn't yet expose a public <c>ReadSzlAsync</c>, so the
|
||||
/// in-process default surfaces every SZL read as <c>BadNotSupported</c>. The
|
||||
/// tag tree still lights up — operators see the structure and can wire
|
||||
/// clients to it — only the values come back as not-supported. Tests inject
|
||||
/// a fake reader that returns golden bytes to prove the dispatch + parser
|
||||
/// + cache path works end-to-end. snap7 also doesn't implement SZL, so the
|
||||
/// integration-test surface inherits the same not-supported behaviour.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool ExposeSystemTags { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E1 — number of diagnostic-buffer entries to discover under
|
||||
/// <c>Diagnostics/DiagBuffer/Entry[N]</c>. Capped at
|
||||
/// <see cref="Szl.S7SystemTags.MaxDiagBufferDepth"/> = 50; the default 10 mirrors
|
||||
/// the plan-section's "max-10 cap" guidance and matches typical SZL 0x00A0
|
||||
/// PDU-size budgets. Ignored when <see cref="ExposeSystemTags"/> is <c>false</c>.
|
||||
/// </summary>
|
||||
public int DiagBufferDepth { get; init; } = Szl.S7SystemTags.DefaultDiagBufferDepth;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-E1 — TTL for the <see cref="Szl.S7SzlCache"/> that fronts every SZL
|
||||
/// wire request. Diagnostics shouldn't poll faster than this anyway; the
|
||||
/// default 5 s window means a burst of <c>@System.*</c> subscriptions ticking
|
||||
/// at 100 ms each produces exactly one wire request per distinct SZL ID per
|
||||
/// 5-second window. Set to <see cref="TimeSpan.Zero"/> to disable caching
|
||||
/// (every read goes to the wire) — only useful for diagnostics tests.
|
||||
/// </summary>
|
||||
public TimeSpan SzlCacheTtl { get; init; } = TimeSpan.FromSeconds(5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
35
src/ZB.MOM.WW.OtOpcUa.Driver.S7/Szl/IS7SzlReader.cs
Normal file
35
src/ZB.MOM.WW.OtOpcUa.Driver.S7/Szl/IS7SzlReader.cs
Normal 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);
|
||||
}
|
||||
49
src/ZB.MOM.WW.OtOpcUa.Driver.S7/Szl/S7NetSzlReader.cs
Normal file
49
src/ZB.MOM.WW.OtOpcUa.Driver.S7/Szl/S7NetSzlReader.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
113
src/ZB.MOM.WW.OtOpcUa.Driver.S7/Szl/S7SystemTags.cs
Normal file
113
src/ZB.MOM.WW.OtOpcUa.Driver.S7/Szl/S7SystemTags.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
64
src/ZB.MOM.WW.OtOpcUa.Driver.S7/Szl/S7SzlCache.cs
Normal file
64
src/ZB.MOM.WW.OtOpcUa.Driver.S7/Szl/S7SzlCache.cs
Normal 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);
|
||||
}
|
||||
45
src/ZB.MOM.WW.OtOpcUa.Driver.S7/Szl/S7SzlIds.cs
Normal file
45
src/ZB.MOM.WW.OtOpcUa.Driver.S7/Szl/S7SzlIds.cs
Normal 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;
|
||||
}
|
||||
401
src/ZB.MOM.WW.OtOpcUa.Driver.S7/Szl/S7SzlParser.cs
Normal file
401
src/ZB.MOM.WW.OtOpcUa.Driver.S7/Szl/S7SzlParser.cs
Normal 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 0–26 (S7-1500: 0–26)</item>
|
||||
/// <item>OB number (UInt8) — OB the event triggered (or 0 if no OB)</item>
|
||||
/// <item>DatId (UInt16 BE) — event-class group (FB / OB / async / …)</item>
|
||||
/// <item>Info1 (UInt16 BE) — event-specific extra info (e.g. block number)</item>
|
||||
/// <item>Info2 (UInt32 BE) — event-specific extra info</item>
|
||||
/// <item>TimeStamp (8 bytes BCD — IEC year/month/day/hour/minute/second/ms)</item>
|
||||
/// </list>
|
||||
/// Returns <em>up to</em> <paramref name="maxEntries"/> entries (capped at
|
||||
/// <see cref="MaxDiagBufferEntriesPerResponse"/>) so a malformed payload claiming
|
||||
/// a huge count can't blow the test allocator.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<S7DiagBufferEntry> ParseDiagBuffer(byte[] payload, int maxEntries)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
if (maxEntries < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxEntries), maxEntries, "maxEntries must be ≥ 0");
|
||||
|
||||
EnsureHeader(payload, out _, out _, out var lenThdr, out var nDr);
|
||||
if (lenThdr != 20)
|
||||
throw new ArgumentException(
|
||||
$"S7 SZL 0x00A0 expected record length 20, got {lenThdr}", nameof(payload));
|
||||
|
||||
var cap = Math.Min(Math.Min(maxEntries, nDr), MaxDiagBufferEntriesPerResponse);
|
||||
var expected = HeaderLength + lenThdr * cap;
|
||||
if (payload.Length < expected)
|
||||
throw new ArgumentException(
|
||||
$"S7 SZL 0x00A0 payload truncated: need ≥ {expected} bytes for {cap} entries, " +
|
||||
$"got {payload.Length}", nameof(payload));
|
||||
|
||||
var entries = new S7DiagBufferEntry[cap];
|
||||
for (var i = 0; i < cap; i++)
|
||||
{
|
||||
var off = HeaderLength + i * lenThdr;
|
||||
var eventId = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(off, 2));
|
||||
var priority = payload[off + 2];
|
||||
// payload[off+3] = OB number (kept implicit in EventText below)
|
||||
// payload[off+4..6] = DatId, payload[off+6..8] = Info1, payload[off+8..12] = Info2
|
||||
// payload[off+12..20] = BCD timestamp.
|
||||
var ts = DecodeBcdTimestamp(payload.AsSpan(off + 12, 8));
|
||||
entries[i] = new S7DiagBufferEntry(
|
||||
OccurrenceUtc: ts,
|
||||
EventId: eventId,
|
||||
Priority: priority,
|
||||
EventText: $"Event 0x{eventId:X4} (priority {priority})");
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encode a parsed <see cref="S7CpuInfo"/> back into a SZL 0x0011 byte payload.
|
||||
/// Round-trip helper used by the parser unit tests so encode-then-decode is the
|
||||
/// identity. Not used at runtime — the driver only ever decodes responses.
|
||||
/// </summary>
|
||||
public static byte[] EncodeCpuInfo(S7CpuInfo info, ushort szlId = S7SzlIds.ModuleIdentification)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(info);
|
||||
const int LenThdr = 28;
|
||||
const int NDr = 3;
|
||||
var buf = new byte[HeaderLength + LenThdr * NDr];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), szlId);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(2, 2), 0x0000);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), LenThdr);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), NDr);
|
||||
|
||||
// Record 0: index 0x0001 — MLFB / order number
|
||||
WriteRecord(buf.AsSpan(HeaderLength, LenThdr), 0x0001, info.OrderNo, ausbg1: 0, ausbg2: 0);
|
||||
// Record 1: index 0x0006 — firmware version
|
||||
var (a1, a2) = ParseFirmwareString(info.Firmware);
|
||||
WriteRecord(buf.AsSpan(HeaderLength + LenThdr, LenThdr), 0x0006, "", ausbg1: a1, ausbg2: a2);
|
||||
// Record 2: index 0x0007 — CPU type as ASCII
|
||||
WriteRecord(buf.AsSpan(HeaderLength + LenThdr * 2, LenThdr), 0x0007, info.CpuType, ausbg1: 0, ausbg2: 0);
|
||||
return buf;
|
||||
|
||||
static void WriteRecord(Span<byte> rec, ushort idx, string mlfb, ushort ausbg1, ushort ausbg2)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt16BigEndian(rec[..2], idx);
|
||||
// 20-byte ASCII space-padded MLFB
|
||||
rec[2..22].Fill((byte)' ');
|
||||
var bytes = Encoding.ASCII.GetBytes(mlfb ?? "");
|
||||
var copy = Math.Min(bytes.Length, 20);
|
||||
bytes.AsSpan(0, copy).CopyTo(rec[2..(2 + copy)]);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(rec.Slice(22, 2), 0); // BGTyp
|
||||
BinaryPrimitives.WriteUInt16BigEndian(rec.Slice(24, 2), ausbg1);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(rec.Slice(26, 2), ausbg2);
|
||||
}
|
||||
|
||||
static (ushort, ushort) ParseFirmwareString(string fw)
|
||||
{
|
||||
// Best-effort parse of "Vmaj.min.patch" — falls back to zeros so a parse failure
|
||||
// doesn't break round-trip tests for hand-crafted CpuInfo records.
|
||||
if (string.IsNullOrEmpty(fw)) return (0, 0);
|
||||
var s = fw.StartsWith('V') ? fw[1..] : fw;
|
||||
var parts = s.Split('.');
|
||||
byte maj = 0, min = 0, patch = 0;
|
||||
if (parts.Length > 0) byte.TryParse(parts[0], out maj);
|
||||
if (parts.Length > 1) byte.TryParse(parts[1], out min);
|
||||
if (parts.Length > 2) byte.TryParse(parts[2], out patch);
|
||||
return ((ushort)((maj << 8) | min), (ushort)(patch << 8));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Encode a <see cref="S7CycleStats"/> back into a SZL 0x0132 byte payload (round-trip helper).</summary>
|
||||
public static byte[] EncodeCycleStats(S7CycleStats stats, ushort szlId = S7SzlIds.CpuStatusData)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stats);
|
||||
const int LenThdr = 16;
|
||||
const int NDr = 1;
|
||||
var buf = new byte[HeaderLength + LenThdr * NDr];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), szlId);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(2, 2), S7SzlIds.CpuStatusCycleTimeIndex);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), LenThdr);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), NDr);
|
||||
|
||||
var rec = buf.AsSpan(HeaderLength);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(rec[..2], S7SzlIds.CpuStatusCycleTimeIndex);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(rec.Slice(2, 2), 0);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(rec.Slice(4, 4), (uint)stats.AvgMs);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(rec.Slice(8, 4), (uint)stats.MinMs);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(rec.Slice(12, 4), (uint)stats.MaxMs);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/// <summary>Encode a list of <see cref="S7DiagBufferEntry"/> back into a SZL 0x00A0 byte payload (round-trip helper).</summary>
|
||||
public static byte[] EncodeDiagBuffer(IReadOnlyList<S7DiagBufferEntry> entries)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
const int LenThdr = 20;
|
||||
var buf = new byte[HeaderLength + LenThdr * entries.Count];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), S7SzlIds.DiagnosticBuffer);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(2, 2), 0);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(4, 2), LenThdr);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(6, 2), (ushort)entries.Count);
|
||||
for (var i = 0; i < entries.Count; i++)
|
||||
{
|
||||
var rec = buf.AsSpan(HeaderLength + i * LenThdr, LenThdr);
|
||||
var e = entries[i];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(rec[..2], e.EventId);
|
||||
rec[2] = e.Priority;
|
||||
rec[3] = 0; // OB number — not surfaced
|
||||
BinaryPrimitives.WriteUInt16BigEndian(rec.Slice(4, 2), 0);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(rec.Slice(6, 2), 0);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(rec.Slice(8, 4), 0);
|
||||
EncodeBcdTimestamp(e.OccurrenceUtc, rec.Slice(12, 8));
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
private static void EnsureHeader(byte[] payload, out ushort szlId, out ushort szlIndex, out ushort lenThdr, out ushort nDr)
|
||||
{
|
||||
if (payload.Length < HeaderLength)
|
||||
throw new ArgumentException(
|
||||
$"S7 SZL payload truncated: need at least {HeaderLength}-byte header, got {payload.Length}", nameof(payload));
|
||||
szlId = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(0, 2));
|
||||
szlIndex = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(2, 2));
|
||||
lenThdr = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(4, 2));
|
||||
nDr = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(6, 2));
|
||||
}
|
||||
|
||||
private static string TrimAscii(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
var s = Encoding.ASCII.GetString(bytes);
|
||||
return s.TrimEnd(' ', '\0');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort CPU type derivation from MLFB. The MLFB encodes the CPU model — e.g.
|
||||
/// <c>6ES7 516-3AN01-0AB0</c> identifies a CPU 1516-3 PN/DP. Without a full lookup
|
||||
/// table we just return the MLFB so operators can grep the manual; SZL index 0x0007
|
||||
/// overrides this when the CPU surfaces a friendly name there.
|
||||
/// </summary>
|
||||
private static string DeriveCpuTypeFromMlfb(string mlfb) => mlfb;
|
||||
|
||||
/// <summary>
|
||||
/// Decode an 8-byte BCD timestamp (Siemens IEC representation):
|
||||
/// <c>year(2) month(1) day(1) hour(1) minute(1) second(1) ms-day-of-week(2)</c>.
|
||||
/// The last 2 bytes pack three BCD ms digits and a day-of-week nibble.
|
||||
/// </summary>
|
||||
private static DateTimeOffset DecodeBcdTimestamp(ReadOnlySpan<byte> b)
|
||||
{
|
||||
// Year: 2-byte BCD (e.g. 0x20 0x24 = 2024)
|
||||
var year = FromBcd(b[0]) * 100 + FromBcd(b[1]);
|
||||
var month = Math.Clamp(FromBcd(b[2]), 1, 12);
|
||||
var day = Math.Clamp(FromBcd(b[3]), 1, 31);
|
||||
var hour = Math.Clamp(FromBcd(b[4]), 0, 23);
|
||||
var minute = Math.Clamp(FromBcd(b[5]), 0, 59);
|
||||
var second = Math.Clamp(FromBcd(b[6]), 0, 59);
|
||||
// ms: high nibble of b[7] = first ms digit, low nibble of b[7] is reserved /
|
||||
// day-of-week. Some CPUs pack three ms digits across b[7] high/low + the high
|
||||
// nibble of the last byte; per Siemens function manual the simplest portable
|
||||
// decode is to drop ms and surface only second-precision.
|
||||
var ms = 0;
|
||||
|
||||
try
|
||||
{
|
||||
return new DateTimeOffset(year, month, day, hour, minute, second, ms, TimeSpan.Zero);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
// Malformed timestamp — surface as epoch rather than throw so a single bad
|
||||
// entry doesn't take out the whole diag-buffer parse.
|
||||
return DateTimeOffset.UnixEpoch;
|
||||
}
|
||||
|
||||
static int FromBcd(byte v) => ((v >> 4) & 0xF) * 10 + (v & 0xF);
|
||||
}
|
||||
|
||||
private static void EncodeBcdTimestamp(DateTimeOffset ts, Span<byte> dst)
|
||||
{
|
||||
var u = ts.UtcDateTime;
|
||||
dst[0] = ToBcd(u.Year / 100);
|
||||
dst[1] = ToBcd(u.Year % 100);
|
||||
dst[2] = ToBcd(u.Month);
|
||||
dst[3] = ToBcd(u.Day);
|
||||
dst[4] = ToBcd(u.Hour);
|
||||
dst[5] = ToBcd(u.Minute);
|
||||
dst[6] = ToBcd(u.Second);
|
||||
dst[7] = 0; // ms / day-of-week — not round-tripped
|
||||
static byte ToBcd(int v) => (byte)(((v / 10) << 4) | (v % 10));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>CPU identification parsed from SZL 0x0011.</summary>
|
||||
/// <param name="CpuType">Marketing / friendly CPU name from SZL index 0x0007 (or MLFB fallback).</param>
|
||||
/// <param name="Firmware">Firmware version, formatted "Vmaj.min.patch".</param>
|
||||
/// <param name="OrderNo">MLFB / order number from SZL index 0x0001 (e.g. "6ES7 516-3AN01-0AB0").</param>
|
||||
public sealed record S7CpuInfo(string CpuType, string Firmware, string OrderNo);
|
||||
|
||||
/// <summary>CPU cycle-time statistics parsed from SZL 0x0132 / 0x0432 — values in milliseconds.</summary>
|
||||
/// <param name="MinMs">Shortest scan cycle observed since last reset.</param>
|
||||
/// <param name="MaxMs">Longest scan cycle observed since last reset.</param>
|
||||
/// <param name="AvgMs">Rolling average scan-cycle time.</param>
|
||||
public sealed record S7CycleStats(double MinMs, double MaxMs, double AvgMs);
|
||||
|
||||
/// <summary>One diagnostic-buffer entry parsed from SZL 0x00A0.</summary>
|
||||
/// <param name="OccurrenceUtc">Event timestamp decoded from the BCD timestamp field (UTC).</param>
|
||||
/// <param name="EventId">Siemens event code (e.g. 0x113A = "communication initiated").</param>
|
||||
/// <param name="Priority">Alarm priority class 0–26.</param>
|
||||
/// <param name="EventText">Human-readable rendering of the event — currently the raw 0x???? code; future PR can plug a lookup table.</param>
|
||||
public sealed record S7DiagBufferEntry(
|
||||
DateTimeOffset OccurrenceUtc,
|
||||
ushort EventId,
|
||||
byte Priority,
|
||||
string EventText);
|
||||
Reference in New Issue
Block a user