Merge B1-s7: v3 RawPath read-path

This commit is contained in:
Joseph Doherty
2026-07-15 20:20:48 -04:00
21 changed files with 425 additions and 251 deletions
@@ -1,5 +1,6 @@
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Exceptions; using CliFx.Exceptions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common; using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli;
@@ -48,8 +49,11 @@ public abstract class S7CommandBase : DriverCommandBase
/// <summary> /// <summary>
/// Build an <see cref="S7DriverOptions"/> with the endpoint fields this base /// Build an <see cref="S7DriverOptions"/> with the endpoint fields this base
/// collected + whatever <paramref name="tags"/> the subclass declares. Probe /// collected + whatever <paramref name="tags"/> the subclass declares. Each typed
/// disabled — CLI runs are one-shot. /// definition is serialised to its <c>TagConfig</c> blob via
/// <see cref="S7TagDefinitionFactory.ToTagConfig"/> and packaged as a
/// <see cref="RawTagEntry"/> (RawPath = the def's <c>Name</c>) — the v3 delivery shape the
/// driver rebuilds definitions from. Probe disabled — CLI runs are one-shot.
/// </summary> /// </summary>
/// <param name="tags">The tag definitions to include in the options.</param> /// <param name="tags">The tag definitions to include in the options.</param>
/// <returns>The constructed <see cref="S7DriverOptions"/>.</returns> /// <returns>The constructed <see cref="S7DriverOptions"/>.</returns>
@@ -61,7 +65,7 @@ public abstract class S7CommandBase : DriverCommandBase
Rack = Rack, Rack = Rack,
Slot = Slot, Slot = Slot,
Timeout = Timeout, Timeout = Timeout,
Tags = tags, RawTags = [.. tags.Select(t => new RawTagEntry(t.Name, S7TagDefinitionFactory.ToTagConfig(t), t.WriteIdempotent))],
Probe = new S7ProbeOptions { Enabled = false }, Probe = new S7ProbeOptions { Enabled = false },
}; };
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7; namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
@@ -53,8 +54,15 @@ public sealed class S7DriverOptions
/// <summary>Connect + per-operation timeout.</summary> /// <summary>Connect + per-operation timeout.</summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(5); public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>Pre-declared tag map. S7 has a symbol-table protocol but S7.Net does not expose it, so the driver operates off a static tag list configured per-site. Address grammar documented in S7AddressParser (PR 63).</summary> /// <summary>
public IReadOnlyList<S7TagDefinition> Tags { get; init; } = []; /// Authored raw tags this driver serves. The deploy artifact hands each authored raw
/// <c>Tag</c> as a <see cref="RawTagEntry"/> (RawPath identity + driver <c>TagConfig</c> blob +
/// WriteIdempotent flag); the driver maps each through
/// <see cref="S7TagDefinitionFactory.FromTagConfig"/> into its RawPath → definition table.
/// S7 has a symbol-table protocol but S7.Net does not expose it, so the driver serves exactly
/// these authored tags. Address grammar documented in S7AddressParser (PR 63).
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary> /// <summary>
/// Background connectivity-probe settings. When enabled, the driver runs a tick loop /// Background connectivity-probe settings. When enabled, the driver runs a tick loop
@@ -104,7 +112,7 @@ public sealed class S7ProbeOptions
/// Element count when the tag is a 1-D array; <c>null</c> for a scalar. Any value <c>&gt;= 1</c> /// Element count when the tag is a 1-D array; <c>null</c> for a scalar. Any value <c>&gt;= 1</c>
/// (including 1) surfaces as a 1-D OPC UA array node. /// (including 1) surfaces as a 1-D OPC UA array node.
/// For an equipment tag this is threaded from the <c>TagConfig</c> JSON's <c>arrayLength</c> /// For an equipment tag this is threaded from the <c>TagConfig</c> JSON's <c>arrayLength</c>
/// (honoured only when <c>isArray</c> is true) by <see cref="S7EquipmentTagParser"/>. When /// (honoured only when <c>isArray</c> is true) by <see cref="S7TagDefinitionFactory"/>. When
/// set, the driver issues a single contiguous block read of /// set, the driver issues a single contiguous block read of
/// <c>ArrayCount × element-bytes</c> from the tag's start address and decodes each element /// <c>ArrayCount × element-bytes</c> from the tag's start address and decodes each element
/// into an element-typed CLR array (<c>short[]</c> / <c>int[]</c> / <c>float[]</c> / etc.). /// into an element-typed CLR array (<c>short[]</c> / <c>int[]</c> / <c>float[]</c> / etc.).
@@ -1,29 +1,45 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7; namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>Parses an equipment tag's <c>TagConfig</c> JSON (the shape authored by the AdminUI /// <summary>
/// <c>S7TagConfigModel</c> — <c>address</c> / <c>dataType</c> / <c>stringLength</c>) into a transient /// v3 pure mapper: turns an authored raw tag's <c>TagConfig</c> JSON (the shape produced by the
/// <see cref="S7TagDefinition"/> whose <see cref="S7TagDefinition.Name"/> equals the reference string /// AdminUI <c>S7TagConfigModel</c> — <c>address</c> / <c>dataType</c> / <c>stringLength</c>) into a
/// itself, so a value the driver publishes back keys the runtime's forward router correctly.</summary> /// <see cref="S7TagDefinition"/>. Under v3 a tag's identity is its <b>RawPath</b> (a cluster-scoped
public static class S7EquipmentTagParser /// slash path), not the address blob — so the produced definition's <see cref="S7TagDefinition.Name"/>
/// is the RawPath the driver was handed, which is exactly the wire reference the driver's
/// <c>RawPath → def</c> resolver keys on. The driver builds that table by mapping each
/// <see cref="RawTagEntry"/> the deploy artifact delivers through <see cref="FromTagConfig"/>;
/// <see cref="ToTagConfig"/> is the inverse, used by authoring paths (the Client CLI) that hold a typed
/// definition and need to synthesise the <c>TagConfig</c> blob for a <see cref="RawTagEntry"/>.
/// </summary>
public static class S7TagDefinitionFactory
{ {
/// <summary>S7 string max length (DBSTRING header reserves 2 bytes; 254 chars max).</summary> /// <summary>S7 string max length (DBSTRING header reserves 2 bytes; 254 chars max).</summary>
private const int MaxStringLength = 254; private const int MaxStringLength = 254;
/// <summary>Attempts to parse an equipment-tag reference into a transient definition.</summary> /// <summary>
/// <param name="reference">The equipment tag's TagConfig JSON (also used as the def identity).</param> /// Maps an authored <c>TagConfig</c> object to a typed definition keyed by <paramref name="rawPath"/>.
/// <param name="def">The transient definition when parsing succeeds.</param> /// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3).
/// <returns><see langword="true"/> when <paramref name="reference"/> is an S7 address object.</returns> /// The <c>dataType</c> enum is read STRICTLY — a present-but-invalid (typo'd) value rejects the whole
public static bool TryParse(string reference, out S7TagDefinition def) /// tag (returns <see langword="false"/> ⇒ the driver surfaces <c>BadNodeIdUnknown</c>) rather than
/// silently defaulting to a wrong-width Good. <see cref="S7TagDefinition.WriteIdempotent"/> is NOT read
/// from the blob — it is a platform flag the caller threads in from the tag's
/// <see cref="RawTagEntry.WriteIdempotent"/>.
/// </summary>
/// <param name="tagConfig">The authored equipment-tag TagConfig JSON.</param>
/// <param name="rawPath">The tag's RawPath — becomes the definition's identity (<c>Name</c>).</param>
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when <paramref name="tagConfig"/> is a valid S7 address object.</returns>
public static bool FromTagConfig(string tagConfig, string rawPath, out S7TagDefinition def)
{ {
def = null!; def = null!;
// Authored tag names never start with '{' (AdminUI name validation), so a leading brace marks an equipment-tag TagConfig blob. if (string.IsNullOrWhiteSpace(tagConfig)) return false;
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return false;
try try
{ {
using var doc = JsonDocument.Parse(reference); using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement; var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("address", out var addrEl) || !root.TryGetProperty("address", out var addrEl)
@@ -42,18 +58,19 @@ public static class S7EquipmentTagParser
if (dataType == S7DataType.String && (stringLength < 0 || stringLength > MaxStringLength)) return false; if (dataType == S7DataType.String && (stringLength < 0 || stringLength > MaxStringLength)) return false;
// Array intent: the canonical sink-side parse (DeploymentArtifact.ExtractTagArray) // Array intent: the canonical sink-side parse (DeploymentArtifact.ExtractTagArray)
// honours arrayLength ONLY when isArray is true AND the prop is a JSON number — mirror // honours arrayLength ONLY when isArray is true AND the prop is a JSON number — mirror
// that here so the driver's transient def agrees byte-for-byte with the materialised // that here so the driver's def agrees byte-for-byte with the materialised OPC UA node's
// OPC UA node's ValueRank/ArrayDimensions. Absent / isArray=false ⇒ null (scalar). // ValueRank/ArrayDimensions. Absent / isArray=false ⇒ null (scalar).
var arrayCount = ReadArrayCount(root); var arrayCount = ReadArrayCount(root);
// "writable" defaults to true when absent (today's value); node-level authz still governs // "writable" defaults to true when absent (today's value); node-level authz still governs
// writes. Honouring the key makes read-only equipment tags authorable (UNDER-6). // writes. Honouring the key makes read-only equipment tags authorable (UNDER-6).
var writable = TagConfigJson.ReadWritable(root); var writable = TagConfigJson.ReadWritable(root);
def = new S7TagDefinition( def = new S7TagDefinition(
Name: reference, Name: rawPath,
Address: address, Address: address,
DataType: dataType, DataType: dataType,
Writable: writable, Writable: writable,
StringLength: stringLength == 0 ? MaxStringLength : stringLength, StringLength: stringLength == 0 ? MaxStringLength : stringLength,
WriteIdempotent: false,
ArrayCount: arrayCount); ArrayCount: arrayCount);
return true; return true;
} }
@@ -62,6 +79,37 @@ public static class S7EquipmentTagParser
catch (InvalidOperationException) { return false; } catch (InvalidOperationException) { return false; }
} }
/// <summary>
/// Inverse of <see cref="FromTagConfig"/>: serialises a typed definition back to the camelCase
/// <c>TagConfig</c> JSON the mapper reads. Used by authoring paths (the Client CLI) that construct an
/// <see cref="S7TagDefinition"/> from operator flags and need the <c>TagConfig</c> string to hand the
/// driver as a <see cref="RawTagEntry"/>. The <c>dataType</c> enum is written as its name string;
/// <c>stringLength</c> is emitted only when it diverges from the 254 default so the blob stays minimal.
/// <c>Name</c> is NOT emitted — it becomes the <see cref="RawTagEntry.RawPath"/>. <c>WriteIdempotent</c>
/// is NOT emitted — it travels on the <see cref="RawTagEntry"/>, not inside the blob.
/// </summary>
/// <param name="def">The definition to serialise.</param>
/// <returns>The camelCase TagConfig JSON string.</returns>
public static string ToTagConfig(S7TagDefinition def)
{
ArgumentNullException.ThrowIfNull(def);
var o = new JsonObject
{
["address"] = def.Address,
["dataType"] = def.DataType.ToString(),
["writable"] = def.Writable,
};
// FromTagConfig maps an absent / zero stringLength to MaxStringLength (254), so a def carrying
// the 254 default round-trips whether or not we emit it — emit only when it diverges.
if (def.StringLength != MaxStringLength) o["stringLength"] = def.StringLength;
if (def.ArrayCount is { } count)
{
o["isArray"] = true;
o["arrayLength"] = count;
}
return o.ToJsonString();
}
/// <summary> /// <summary>
/// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid <c>dataType</c> (silently /// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid <c>dataType</c> (silently
/// defaulted by the lenient runtime) and on a structurally unparseable TagConfig (a silent /// defaulted by the lenient runtime) and on a structurally unparseable TagConfig (a silent
@@ -51,8 +51,7 @@ public sealed class S7Driver
_plcFactory = plcFactory ?? new S7PlcFactory(); _plcFactory = plcFactory ?? new S7PlcFactory();
_logger = logger ?? NullLogger<S7Driver>.Instance; _logger = logger ?? NullLogger<S7Driver>.Instance;
_resolver = new EquipmentTagRefResolver<S7TagDefinition>( _resolver = new EquipmentTagRefResolver<S7TagDefinition>(
r => _tagsByName.TryGetValue(r, out var t) ? t : null, r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
r => S7EquipmentTagParser.TryParse(r, out var d) ? d : null);
// CONV-1: the polling overlay runs on the shared PollGroupEngine (structural array diff + // CONV-1: the polling overlay runs on the shared PollGroupEngine (structural array diff +
// drain-before-dispose + capped-exponential failure backoff + caller-token-filtered OCE), // drain-before-dispose + capped-exponential failure backoff + caller-token-filtered OCE),
// retiring the bespoke S7 poll fork. minInterval 100 ms matches the S7 scan-mailbox floor; // retiring the bespoke S7 poll fork. minInterval 100 ms matches the S7 scan-mailbox floor;
@@ -104,12 +103,15 @@ public sealed class S7Driver
/// <summary>OPC UA StatusCode used for a genuine device fault (CPU error, hardware fault).</summary> /// <summary>OPC UA StatusCode used for a genuine device fault (CPU error, hardware fault).</summary>
private const uint StatusBadDeviceFailure = 0x808B0000u; private const uint StatusBadDeviceFailure = 0x808B0000u;
private readonly Dictionary<string, S7TagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase); // v3: the RawPath → definition table. Keyed by RawPath (the v3 tag identity, == def.Name),
private readonly Dictionary<string, S7ParsedAddress> _parsedByName = new(StringComparer.OrdinalIgnoreCase); // case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure factory.
private readonly Dictionary<string, S7TagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
// Pre-parsed S7 address per tag, keyed by RawPath (== def.Name). Populated alongside
// _tagsByRawPath at init so a config typo fails fast here rather than as a per-read fault.
private readonly Dictionary<string, S7ParsedAddress> _parsedByRawPath = new(StringComparer.Ordinal);
// Resolves a read/write/subscribe fullReference to a tag definition, bridging the two // Resolves a read/write/subscribe fullReference (a RawPath in v3) to a tag definition by a single
// authoring models: an authored tag-table entry (by name) OR an equipment tag whose // authored-table hit; a miss is a miss (the driver surfaces BadNodeIdUnknown, never a parse attempt).
// reference is its raw TagConfig JSON (parsed once via S7EquipmentTagParser, cached).
private readonly EquipmentTagRefResolver<S7TagDefinition> _resolver; private readonly EquipmentTagRefResolver<S7TagDefinition> _resolver;
/// <summary> /// <summary>
@@ -175,6 +177,24 @@ public sealed class S7Driver
if (HasConfigBody(driverConfigJson)) if (HasConfigBody(driverConfigJson))
_options = S7DriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson); _options = S7DriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
// v3: build the RawPath → definition table from the authored raw tags BEFORE the config
// guards run (they now iterate the mapped table). Each entry's TagConfig blob is mapped by
// the pure factory; the entry's WriteIdempotent flag is threaded onto the def (it lives on
// the RawTagEntry, not inside the blob). A mapper miss is skipped (logged), never thrown —
// one bad tag must not fail the whole driver init.
_tagsByRawPath.Clear();
_parsedByRawPath.Clear();
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
foreach (var entry in _options.RawTags)
{
if (S7TagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
_tagsByRawPath[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
else
_logger.LogWarning(
"S7 tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
}
// Phase 4d combined config guard. Timer/Counter addresses and the wide/structured // Phase 4d combined config guard. Timer/Counter addresses and the wide/structured
// types (Int64/UInt64/Float64/String/DateTime) are now SUPPORTED, but only in // types (Int64/UInt64/Float64/String/DateTime) are now SUPPORTED, but only in
// specific shapes — this rejects the genuinely-unsupported configurations // specific shapes — this rejects the genuinely-unsupported configurations
@@ -220,21 +240,17 @@ public sealed class S7Driver
// Parse every tag's address once at init so config typos fail fast here instead // Parse every tag's address once at init so config typos fail fast here instead
// of surfacing as BadInternalError on every Read against the bad tag. The parser // of surfacing as BadInternalError on every Read against the bad tag. The parser
// also rejects bit-offset > 7, DB 0, unknown area letters, etc. // also rejects bit-offset > 7, DB 0, unknown area letters, etc. Keyed by RawPath
_tagsByName.Clear(); // (== def.Name) so Read/Write can pick up the pre-parsed address.
_parsedByName.Clear(); foreach (var t in _tagsByRawPath.Values)
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
foreach (var t in _options.Tags)
{ {
var parsed = S7AddressParser.Parse(t.Address); // throws FormatException _parsedByRawPath[t.Name] = S7AddressParser.Parse(t.Address); // throws FormatException
_tagsByName[t.Name] = t;
_parsedByName[t.Name] = parsed;
} }
_initialized = true; _initialized = true;
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
_logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} Tags={TagCount}", _logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} Tags={TagCount}",
_driverInstanceId, _options.Host, _options.CpuType, _options.Tags.Count); _driverInstanceId, _options.Host, _options.CpuType, _tagsByRawPath.Count);
// Kick off the probe loop once the connection is up. Initial HostState stays // Kick off the probe loop once the connection is up. Initial HostState stays
// Unknown until the first probe tick succeeds — avoids broadcasting a premature // Unknown until the first probe tick succeeds — avoids broadcasting a premature
@@ -360,7 +376,7 @@ public sealed class S7Driver
/// </summary> /// </summary>
private void RejectUnsupportedTagConfigs() private void RejectUnsupportedTagConfigs()
{ {
foreach (var t in _options.Tags) foreach (var t in _tagsByRawPath.Values)
{ {
var isWide = ByteAnchoredDataTypes.Contains(t.DataType); var isWide = ByteAnchoredDataTypes.Contains(t.DataType);
@@ -442,7 +458,7 @@ public sealed class S7Driver
/// </summary> /// </summary>
private void RejectUnsupportedTagDataTypes() private void RejectUnsupportedTagDataTypes()
{ {
foreach (var t in _options.Tags) foreach (var t in _tagsByRawPath.Values)
{ {
if (UnimplementedDataTypes.Contains(t.DataType)) if (UnimplementedDataTypes.Contains(t.DataType))
{ {
@@ -573,12 +589,10 @@ public sealed class S7Driver
private async Task<object> ReadOneAsync(IS7Plc plc, S7TagDefinition tag, CancellationToken ct) private async Task<object> ReadOneAsync(IS7Plc plc, S7TagDefinition tag, CancellationToken ct)
{ {
// Authored tags pre-parse their address at init (_parsedByName); an equipment-tag ref // Every tag pre-parses its address at init (_parsedByRawPath); the on-demand
// (resolved transiently by _resolver) has no _parsedByName entry, so parse its address // S7AddressParser.Parse is a defensive fallback for a tag not in that table. Parse throws
// on demand. S7AddressParser.Parse throws FormatException on a bad address, which the // FormatException on a bad address, which the caller's catch maps to BadCommunicationError.
// caller's catch maps to BadCommunicationError — the same surface a bad authored tag var addr = _parsedByRawPath.TryGetValue(tag.Name, out var parsed)
// would have hit at init (transient defs aren't init-validated).
var addr = _parsedByName.TryGetValue(tag.Name, out var parsed)
? parsed ? parsed
: S7AddressParser.Parse(tag.Address); : S7AddressParser.Parse(tag.Address);
@@ -1045,16 +1059,16 @@ public sealed class S7Driver
results[i] = new WriteResult(StatusBadNotWritable); results[i] = new WriteResult(StatusBadNotWritable);
continue; continue;
} }
// Timer/Counter transient-write guard: a transient equipment-tag ref is always // Timer/Counter write guard: an equipment tag authored writable=true (S7 mapper
// resolved with Writable=true (S7EquipmentTagParser hardcodes it, and node-level // honours the "writable" key, and node-level auth governs writes) whose address is a
// auth governs writes), so Timer/Counter addresses bypass the !Writable gate above // Timer/Counter would bypass the !Writable gate above and reach EncodeScalarBlock,
// and would otherwise reach EncodeScalarBlock, which throws NotSupportedException → // which throws NotSupportedException → BadNotSupported. The docs say Timer/Counter
// BadNotSupported. The docs say Timer/Counter writes return BadNotWritable. Detect // writes return BadNotWritable. Detect the area here before the call so it
// the area here before the call so BOTH authored (caught by guard-d at init) and // consistently returns BadNotWritable (init guard-d also rejects a writable
// transient Timer/Counter writes consistently return BadNotWritable. // Timer/Counter, but the mapper doesn't run that guard).
// TryParse (not Parse) so a malformed address falls through to the existing // TryParse (not Parse) so a malformed address falls through to the existing
// error path rather than throwing here. // error path rather than throwing here.
if (_parsedByName.TryGetValue(tag.Name, out var tcParsed) if (_parsedByRawPath.TryGetValue(tag.Name, out var tcParsed)
? tcParsed.Area is S7Area.Timer or S7Area.Counter ? tcParsed.Area is S7Area.Timer or S7Area.Counter
: (S7AddressParser.TryParse(tag.Address, out tcParsed) && tcParsed.Area is S7Area.Timer or S7Area.Counter)) : (S7AddressParser.TryParse(tag.Address, out tcParsed) && tcParsed.Area is S7Area.Timer or S7Area.Counter))
{ {
@@ -1126,11 +1140,11 @@ public sealed class S7Driver
$"S7 array writes are not yet supported (tag '{tag.Name}'). " + $"S7 array writes are not yet supported (tag '{tag.Name}'). " +
"Use individual scalar tags or set Writable=false for read-only arrays."); "Use individual scalar tags or set Writable=false for read-only arrays.");
// Parse the address the same way ReadOneAsync does: authored tags pre-parse at init // Parse the address the same way ReadOneAsync does: every tag pre-parses at init
// (_parsedByName); an equipment-tag ref (resolved transiently) parses on demand. Needed // (_parsedByRawPath), with an on-demand parse as a defensive fallback. Needed here so the
// here so the wide-type write can byte-address the block (the narrow path below addresses // wide-type write can byte-address the block (the narrow path below addresses by the raw
// by the raw address string instead). // address string instead).
var addr = _parsedByName.TryGetValue(tag.Name, out var parsed) var addr = _parsedByRawPath.TryGetValue(tag.Name, out var parsed)
? parsed ? parsed
: S7AddressParser.Parse(tag.Address); : S7AddressParser.Parse(tag.Address);
@@ -1370,7 +1384,7 @@ public sealed class S7Driver
{ {
ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(builder);
var folder = builder.Folder("S7", "S7"); var folder = builder.Folder("S7", "S7");
foreach (var t in _options.Tags) foreach (var t in _tagsByRawPath.Values)
{ {
// A tag carrying a non-null array count (>= 1) surfaces as a 1-D OPC UA array node. // A tag carrying a non-null array count (>= 1) surfaces as a 1-D OPC UA array node.
// A null count stays scalar. A count of 1 IS a valid 1-element array: the foundation // A null count stays scalar. A count of 1 IS a valid 1-element array: the foundation
@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7; namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
@@ -64,9 +65,10 @@ public static class S7DriverFactoryExtensions
Rack = dto.Rack ?? 0, Rack = dto.Rack ?? 0,
Slot = dto.Slot ?? 0, Slot = dto.Slot ?? 0,
Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 5_000), Timeout = TimeSpan.FromMilliseconds(dto.TimeoutMs ?? 5_000),
Tags = dto.Tags is { Count: > 0 } // v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw S7
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))] // tags. The driver maps each RawTagEntry's TagConfig blob through S7TagDefinitionFactory
: [], // at Initialize; the legacy pre-declared DTO tag path (Name/Address → BuildTag) is retired.
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
Probe = new S7ProbeOptions Probe = new S7ProbeOptions
{ {
Enabled = dto.Probe?.Enabled ?? true, Enabled = dto.Probe?.Enabled ?? true,
@@ -77,18 +79,6 @@ public static class S7DriverFactoryExtensions
}; };
} }
private static S7TagDefinition BuildTag(S7TagDto t, string driverInstanceId) =>
new(
Name: t.Name ?? throw new InvalidOperationException(
$"S7 config for '{driverInstanceId}' has a tag missing Name"),
Address: t.Address ?? throw new InvalidOperationException(
$"S7 tag '{t.Name}' in '{driverInstanceId}' missing Address"),
DataType: ParseEnum<S7DataType>(t.DataType, driverInstanceId, "DataType",
tagName: t.Name),
Writable: t.Writable ?? true,
StringLength: t.StringLength ?? 254,
WriteIdempotent: t.WriteIdempotent ?? false);
private static T ParseEnum<T>(string? raw, string driverInstanceId, string field, private static T ParseEnum<T>(string? raw, string driverInstanceId, string field,
string? tagName = null, T? fallback = null) where T : struct, Enum string? tagName = null, T? fallback = null) where T : struct, Enum
{ {
@@ -127,29 +117,13 @@ public static class S7DriverFactoryExtensions
public short? Slot { get; init; } public short? Slot { get; init; }
/// <summary>Gets the connection timeout in milliseconds.</summary> /// <summary>Gets the connection timeout in milliseconds.</summary>
public int? TimeoutMs { get; init; } public int? TimeoutMs { get; init; }
/// <summary>Gets the list of tag definitions.</summary> /// <summary>Gets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent) the deploy
public List<S7TagDto>? Tags { get; init; } /// artifact delivers. The driver maps each through <see cref="S7TagDefinitionFactory"/> at Initialize.</summary>
public List<RawTagEntry>? RawTags { get; init; }
/// <summary>Gets the probe configuration.</summary> /// <summary>Gets the probe configuration.</summary>
public S7ProbeDto? Probe { get; init; } public S7ProbeDto? Probe { get; init; }
} }
/// <summary>Data transfer object for S7 tag definition.</summary>
internal sealed class S7TagDto
{
/// <summary>Gets the tag name.</summary>
public string? Name { get; init; }
/// <summary>Gets the S7 address (e.g., DB1.DBD0).</summary>
public string? Address { get; init; }
/// <summary>Gets the data type name.</summary>
public string? DataType { get; init; }
/// <summary>Gets a value indicating whether the tag is writable.</summary>
public bool? Writable { get; init; }
/// <summary>Gets the string length for string types.</summary>
public int? StringLength { get; init; }
/// <summary>Gets a value indicating whether write is idempotent.</summary>
public bool? WriteIdempotent { get; init; }
}
/// <summary>Data transfer object for S7 probe configuration.</summary> /// <summary>Data transfer object for S7 probe configuration.</summary>
internal sealed class S7ProbeDto internal sealed class S7ProbeDto
{ {
@@ -87,16 +87,25 @@ public sealed class S7CommandBaseBuildOptionsTests
options.Slot.ShouldBe((short)2); options.Slot.ShouldBe((short)2);
} }
/// <summary>Verifies that BuildOptions forwards the tag list verbatim.</summary> /// <summary>Verifies that BuildOptions serialises each typed tag to a RawTagEntry (RawPath =
/// the def's Name) whose TagConfig round-trips back to the same definition (v3 delivery shape).</summary>
[Fact] [Fact]
public void BuildOptions_forwards_tag_list_verbatim() public void BuildOptions_serialises_tags_to_raw_tag_entries()
{ {
var sut = new ProbeOnly { Host = "h" }; var sut = new ProbeOnly { Host = "h" };
var tag = new S7TagDefinition("t", "MW0", S7DataType.Int16, Writable: false); var tag = new S7TagDefinition("t", "MW0", S7DataType.Int16, Writable: false);
var options = sut.Invoke([tag]); var options = sut.Invoke([tag]);
options.Tags.Count.ShouldBe(1); options.RawTags.Count.ShouldBe(1);
options.Tags[0].ShouldBeSameAs(tag); var entry = options.RawTags[0];
entry.RawPath.ShouldBe("t");
entry.WriteIdempotent.ShouldBe(tag.WriteIdempotent);
S7TagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var round).ShouldBeTrue();
round!.Name.ShouldBe("t");
round.Address.ShouldBe("MW0");
round.DataType.ShouldBe(S7DataType.Int16);
round.Writable.ShouldBeFalse();
} }
} }
@@ -180,13 +180,15 @@ public sealed class S7ArrayReadTests
var opts = new S7DriverOptions var opts = new S7DriverOptions
{ {
Host = "192.0.2.1", Host = "192.0.2.1",
Tags = Probe = new S7ProbeOptions { Enabled = false },
[ // Array tags are read-only this phase (writable arrays are rejected by the init guard).
new("Scalar", "DB1.DBW0", S7DataType.Int16), RawTags = S7RawTags.Entries(
new("Arr", "DB1.DBW10", S7DataType.Int16, ArrayCount: 8), new S7TagDefinition("Scalar", "DB1.DBW0", S7DataType.Int16),
], new S7TagDefinition("Arr", "DB1.DBW10", S7DataType.Int16, Writable: false, ArrayCount: 8)),
}; };
using var drv = new S7Driver(opts, "s7-arr-disco"); // v3: DiscoverAsync projects the table built at Initialize — initialize with a fake PLC first.
using var drv = new S7Driver(opts, "s7-arr-disco", new ConnectingFakeS7PlcFactory());
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var builder = new RecordingAddressSpaceBuilder(); var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
@@ -209,9 +211,12 @@ public sealed class S7ArrayReadTests
var opts = new S7DriverOptions var opts = new S7DriverOptions
{ {
Host = "192.0.2.1", Host = "192.0.2.1",
Tags = [new("One", "DB1.DBW0", S7DataType.Int16, ArrayCount: 1)], Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(new S7TagDefinition("One", "DB1.DBW0", S7DataType.Int16, Writable: false, ArrayCount: 1)),
}; };
using var drv = new S7Driver(opts, "s7-arr-one"); // v3: DiscoverAsync projects the table built at Initialize — initialize with a fake PLC first.
using var drv = new S7Driver(opts, "s7-arr-one", new ConnectingFakeS7PlcFactory());
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var builder = new RecordingAddressSpaceBuilder(); var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
@@ -228,9 +233,12 @@ public sealed class S7ArrayReadTests
var opts = new S7DriverOptions var opts = new S7DriverOptions
{ {
Host = "192.0.2.1", Host = "192.0.2.1",
Tags = [new("Scalar", "DB1.DBW0", S7DataType.Int16, ArrayCount: null)], Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(new S7TagDefinition("Scalar", "DB1.DBW0", S7DataType.Int16, ArrayCount: null)),
}; };
using var drv = new S7Driver(opts, "s7-arr-null"); // v3: DiscoverAsync projects the table built at Initialize — initialize with a fake PLC first.
using var drv = new S7Driver(opts, "s7-arr-null", new ConnectingFakeS7PlcFactory());
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var builder = new RecordingAddressSpaceBuilder(); var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
@@ -253,32 +261,33 @@ public sealed class S7ArrayReadTests
arr[0].ShouldBe((short)100); arr[0].ShouldBe((short)100);
} }
// ── Equipment-tag resolver threads arrayLength → ArrayCount ─────────────────────────── // ── Tag-definition factory threads arrayLength → ArrayCount ───────────────────────────
/// <summary>Verifies the equipment-tag parser threads isArray/arrayLength into ArrayCount.</summary> /// <summary>Verifies the tag-definition factory threads isArray/arrayLength into ArrayCount.</summary>
[Fact] [Fact]
public void EquipmentTagParser_threads_array_length_into_ArrayCount() public void TagDefinitionFactory_threads_array_length_into_ArrayCount()
{ {
var json = """{"address":"DB1.DBW0","dataType":"Int16","isArray":true,"arrayLength":16}"""; var json = """{"address":"DB1.DBW0","dataType":"Int16","isArray":true,"arrayLength":16}""";
S7EquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); S7TagDefinitionFactory.FromTagConfig(json, "line/eq/arr", out var def).ShouldBeTrue();
def!.ArrayCount.ShouldBe(16); def!.Name.ShouldBe("line/eq/arr", "the definition's identity is the RawPath");
def.ArrayCount.ShouldBe(16);
} }
/// <summary>Verifies arrayLength is ignored when isArray is false (mirrors the sink foundation).</summary> /// <summary>Verifies arrayLength is ignored when isArray is false (mirrors the sink foundation).</summary>
[Fact] [Fact]
public void EquipmentTagParser_ignores_arrayLength_when_isArray_false() public void TagDefinitionFactory_ignores_arrayLength_when_isArray_false()
{ {
var json = """{"address":"DB1.DBW0","dataType":"Int16","isArray":false,"arrayLength":16}"""; var json = """{"address":"DB1.DBW0","dataType":"Int16","isArray":false,"arrayLength":16}""";
S7EquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); S7TagDefinitionFactory.FromTagConfig(json, "line/eq/arr", out var def).ShouldBeTrue();
def!.ArrayCount.ShouldBeNull("arrayLength is honoured only when isArray is true"); def!.ArrayCount.ShouldBeNull("arrayLength is honoured only when isArray is true");
} }
/// <summary>Verifies a scalar equipment tag (no array keys) has a null ArrayCount.</summary> /// <summary>Verifies a scalar tag config (no array keys) has a null ArrayCount.</summary>
[Fact] [Fact]
public void EquipmentTagParser_scalar_has_null_ArrayCount() public void TagDefinitionFactory_scalar_has_null_ArrayCount()
{ {
var json = """{"address":"DB1.DBW0","dataType":"Int16"}"""; var json = """{"address":"DB1.DBW0","dataType":"Int16"}""";
S7EquipmentTagParser.TryParse(json, out var def).ShouldBeTrue(); S7TagDefinitionFactory.FromTagConfig(json, "line/eq/scalar", out var def).ShouldBeTrue();
def!.ArrayCount.ShouldBeNull(); def!.ArrayCount.ShouldBeNull();
} }
} }
@@ -22,14 +22,16 @@ public sealed class S7DiscoveryAndSubscribeTests
var opts = new S7DriverOptions var opts = new S7DriverOptions
{ {
Host = "192.0.2.1", Host = "192.0.2.1",
Tags = Probe = new S7ProbeOptions { Enabled = false },
[ RawTags = S7RawTags.Entries(
new("TempSetpoint", "DB1.DBW0", S7DataType.Int16, Writable: true), new S7TagDefinition("TempSetpoint", "DB1.DBW0", S7DataType.Int16, Writable: true),
new("FaultBit", "M0.0", S7DataType.Bool, Writable: false), new S7TagDefinition("FaultBit", "M0.0", S7DataType.Bool, Writable: false),
new("PIDOutput", "DB5.DBD12", S7DataType.Float32, Writable: true), new S7TagDefinition("PIDOutput", "DB5.DBD12", S7DataType.Float32, Writable: true)),
],
}; };
using var drv = new S7Driver(opts, "s7-disco"); // v3: DiscoverAsync projects the RawPath → definition table, which is built at Initialize —
// so the driver must be initialized (with a connecting fake PLC) before Discover.
using var drv = new S7Driver(opts, "s7-disco", new ConnectingFakeS7PlcFactory());
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var builder = new RecordingAddressSpaceBuilder(); var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
@@ -49,13 +51,14 @@ public sealed class S7DiscoveryAndSubscribeTests
var opts = new S7DriverOptions var opts = new S7DriverOptions
{ {
Host = "192.0.2.1", Host = "192.0.2.1",
Tags = Probe = new S7ProbeOptions { Enabled = false },
[ RawTags = S7RawTags.Entries(
new("SetPoint", "DB1.DBW0", S7DataType.Int16, WriteIdempotent: true), new S7TagDefinition("SetPoint", "DB1.DBW0", S7DataType.Int16, WriteIdempotent: true),
new("StartBit", "M0.0", S7DataType.Bool), new S7TagDefinition("StartBit", "M0.0", S7DataType.Bool)),
],
}; };
using var drv = new S7Driver(opts, "s7-idem"); // v3: DiscoverAsync projects the table built at Initialize — initialize with a fake PLC first.
using var drv = new S7Driver(opts, "s7-idem", new ConnectingFakeS7PlcFactory());
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var builder = new RecordingAddressSpaceBuilder(); var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
@@ -34,7 +34,7 @@ public sealed class S7DriverCodeReviewFixTests
{ {
Host = "192.0.2.1", Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250), Timeout = TimeSpan.FromMilliseconds(250),
Tags = [new S7TagDefinition("Quirk", address, S7DataType.Int16)], RawTags = S7RawTags.Entries(new S7TagDefinition("Quirk", address, S7DataType.Int16)),
}; };
using var drv = new S7Driver(opts, "s7-tc"); using var drv = new S7Driver(opts, "s7-tc");
@@ -58,11 +58,9 @@ public sealed class S7DriverCodeReviewFixTests
{ {
Host = "192.0.2.1", Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250), Timeout = TimeSpan.FromMilliseconds(250),
Tags = RawTags = S7RawTags.Entries(
[
new S7TagDefinition("Word", "DB1.DBW0", S7DataType.Int16), new S7TagDefinition("Word", "DB1.DBW0", S7DataType.Int16),
new S7TagDefinition("Bit", "M0.0", S7DataType.Bool), new S7TagDefinition("Bit", "M0.0", S7DataType.Bool)),
],
}; };
using var drv = new S7Driver(opts, "s7-ok"); using var drv = new S7Driver(opts, "s7-ok");
@@ -111,7 +109,7 @@ public sealed class S7DriverCodeReviewFixTests
const string json = """ const string json = """
{ "Host": "192.0.2.1", "TimeoutMs": 250, { "Host": "192.0.2.1", "TimeoutMs": 250,
"Tags": [ { "Name": "TimerTag", "Address": "T5", "DataType": "Int16" } ] } "RawTags": [ { "RawPath": "TimerTag", "TagConfig": "{\"address\":\"T5\",\"dataType\":\"Int16\"}", "WriteIdempotent": false } ] }
"""; """;
var ex = await Should.ThrowAsync<NotSupportedException>(async () => var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
@@ -165,7 +165,7 @@ public sealed class S7DriverCodeReviewFixTests2
// DB1.DBD0 is a DWord (non-byte) address — guard-(b) must fault on the address shape // DB1.DBD0 is a DWord (non-byte) address — guard-(b) must fault on the address shape
// for a wide type. (At a byte address like DB1.DBB0 the 8-byte numerics round-trip; // for a wide type. (At a byte address like DB1.DBB0 the 8-byte numerics round-trip;
// see S7ScalarBlockTests.) // see S7ScalarBlockTests.)
Tags = [new S7TagDefinition("X", "DB1.DBD0", dt)], RawTags = S7RawTags.Entries(new S7TagDefinition("X", "DB1.DBD0", dt)),
}; };
using var drv = new S7Driver(opts, $"s7-wide-nonbyte-{dt}"); using var drv = new S7Driver(opts, $"s7-wide-nonbyte-{dt}");
@@ -197,7 +197,7 @@ public sealed class S7DriverCodeReviewFixTests2
{ {
Host = "192.0.2.1", Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250), Timeout = TimeSpan.FromMilliseconds(250),
Tags = [new S7TagDefinition("X", addr, dt)], RawTags = S7RawTags.Entries(new S7TagDefinition("X", addr, dt)),
}; };
using var drv = new S7Driver(opts, $"s7-good-dt-{dt}"); using var drv = new S7Driver(opts, $"s7-good-dt-{dt}");
@@ -24,7 +24,7 @@ public sealed class S7DriverReadWriteTests
{ {
Host = "192.0.2.1", // reserved — will never complete TCP handshake Host = "192.0.2.1", // reserved — will never complete TCP handshake
Timeout = TimeSpan.FromMilliseconds(250), Timeout = TimeSpan.FromMilliseconds(250),
Tags = [new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16)], RawTags = S7RawTags.Entries(new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16)),
}; };
using var drv = new S7Driver(opts, "s7-bad-tag"); using var drv = new S7Driver(opts, "s7-bad-tag");
@@ -22,7 +22,7 @@ public sealed class S7DriverReconnectTests
// Probe off — these tests drive reconnect through the read path deterministically; a // Probe off — these tests drive reconnect through the read path deterministically; a
// background probe would race the created-connection assertions. // background probe would race the created-connection assertions.
Probe = new S7ProbeOptions { Enabled = false }, Probe = new S7ProbeOptions { Enabled = false },
Tags = [new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)], RawTags = S7RawTags.Entries(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)),
}; };
/// <summary> /// <summary>
@@ -265,7 +265,7 @@ public sealed class S7DriverReconnectTests
Host = "192.0.2.1", Host = "192.0.2.1",
Timeout = TimeSpan.FromSeconds(5), // long, so the CALLER token fires first, not the internal timeout Timeout = TimeSpan.FromSeconds(5), // long, so the CALLER token fires first, not the internal timeout
Probe = new S7ProbeOptions { Enabled = false }, Probe = new S7ProbeOptions { Enabled = false },
Tags = [new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)], RawTags = S7RawTags.Entries(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)),
}; };
var factory = new FakeS7PlcFactory(); var factory = new FakeS7PlcFactory();
using var drv = new S7Driver(opts, "s7-caller-cancel", factory); using var drv = new S7Driver(opts, "s7-caller-cancel", factory);
@@ -367,7 +367,7 @@ public sealed class S7DriverReconnectTests
Host = "192.0.2.1", Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250), Timeout = TimeSpan.FromMilliseconds(250),
Probe = new S7ProbeOptions { Enabled = false }, Probe = new S7ProbeOptions { Enabled = false },
Tags = [new S7TagDefinition("WW0", "DB1.DBW0", S7DataType.UInt16, Writable: true)], RawTags = S7RawTags.Entries(new S7TagDefinition("WW0", "DB1.DBW0", S7DataType.UInt16, Writable: true)),
}; };
var factory = new FakeS7PlcFactory(); var factory = new FakeS7PlcFactory();
using var drv = new S7Driver(opts, "s7-cancel-write", factory); using var drv = new S7Driver(opts, "s7-cancel-write", factory);
@@ -448,7 +448,7 @@ public sealed class S7DriverReconnectTests
Interval = TimeSpan.FromMilliseconds(50), Interval = TimeSpan.FromMilliseconds(50),
Timeout = TimeSpan.FromMilliseconds(250), Timeout = TimeSpan.FromMilliseconds(250),
}, },
Tags = [new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)], RawTags = S7RawTags.Entries(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)),
}; };
/// <summary>Polls <paramref name="condition"/> until it holds or <paramref name="timeout"/> elapses (returns either way).</summary> /// <summary>Polls <paramref name="condition"/> until it holds or <paramref name="timeout"/> elapses (returns either way).</summary>
@@ -85,7 +85,7 @@ public sealed class S7DriverScaffoldTests
{ {
Host = "192.0.2.1", Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250), Timeout = TimeSpan.FromMilliseconds(250),
Tags = [new S7TagDefinition("LReal", "DB1.DBB0", S7DataType.Float64)], RawTags = S7RawTags.Entries(new S7TagDefinition("LReal", "DB1.DBB0", S7DataType.Float64)),
}; };
using var drv = new S7Driver(opts, "s7-wide-ok"); using var drv = new S7Driver(opts, "s7-wide-ok");
@@ -105,7 +105,7 @@ public sealed class S7DriverScaffoldTests
{ {
Host = "192.0.2.1", Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250), Timeout = TimeSpan.FromMilliseconds(250),
Tags = [new S7TagDefinition("Batch64", "DB1.DBB0", S7DataType.Int64, ArrayCount: 4)], RawTags = S7RawTags.Entries(new S7TagDefinition("Batch64", "DB1.DBB0", S7DataType.Int64, ArrayCount: 4)),
}; };
using var drv = new S7Driver(opts, "s7-wide-array"); using var drv = new S7Driver(opts, "s7-wide-array");
@@ -125,7 +125,7 @@ public sealed class S7DriverScaffoldTests
{ {
Host = "192.0.2.1", Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250), Timeout = TimeSpan.FromMilliseconds(250),
Tags = [new S7TagDefinition("WideWord", "DB1.DBW0", S7DataType.Float64)], RawTags = S7RawTags.Entries(new S7TagDefinition("WideWord", "DB1.DBW0", S7DataType.Float64)),
}; };
using var drv = new S7Driver(opts, "s7-wide-nonbyte"); using var drv = new S7Driver(opts, "s7-wide-nonbyte");
@@ -145,7 +145,7 @@ public sealed class S7DriverScaffoldTests
{ {
Host = "192.0.2.1", Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250), Timeout = TimeSpan.FromMilliseconds(250),
Tags = [new S7TagDefinition("Timer5", "T5", S7DataType.Int32)], RawTags = S7RawTags.Entries(new S7TagDefinition("Timer5", "T5", S7DataType.Int32)),
}; };
using var drv = new S7Driver(opts, "s7-timer-bad"); using var drv = new S7Driver(opts, "s7-timer-bad");
@@ -165,7 +165,7 @@ public sealed class S7DriverScaffoldTests
{ {
Host = "192.0.2.1", Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250), Timeout = TimeSpan.FromMilliseconds(250),
Tags = [new S7TagDefinition("Counter3", "C3", S7DataType.Float32)], RawTags = S7RawTags.Entries(new S7TagDefinition("Counter3", "C3", S7DataType.Float32)),
}; };
using var drv = new S7Driver(opts, "s7-counter-bad"); using var drv = new S7Driver(opts, "s7-counter-bad");
@@ -192,7 +192,7 @@ public sealed class S7DriverScaffoldTests
{ {
Host = "192.0.2.1", Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250), Timeout = TimeSpan.FromMilliseconds(250),
Tags = [new S7TagDefinition("Timer5", "T5", S7DataType.Float64, Writable: true)], RawTags = S7RawTags.Entries(new S7TagDefinition("Timer5", "T5", S7DataType.Float64, Writable: true)),
}; };
using var drv = new S7Driver(opts, "s7-timer-writable"); using var drv = new S7Driver(opts, "s7-timer-writable");
@@ -213,7 +213,7 @@ public sealed class S7DriverScaffoldTests
{ {
Host = "192.0.2.1", Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250), Timeout = TimeSpan.FromMilliseconds(250),
Tags = [new S7TagDefinition("Counter3", "C3", S7DataType.Int32, Writable: true)], RawTags = S7RawTags.Entries(new S7TagDefinition("Counter3", "C3", S7DataType.Int32, Writable: true)),
}; };
using var drv = new S7Driver(opts, "s7-counter-writable"); using var drv = new S7Driver(opts, "s7-counter-writable");
@@ -250,7 +250,7 @@ public sealed class S7DriverScaffoldTests
{ {
Host = "192.0.2.1", Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250), Timeout = TimeSpan.FromMilliseconds(250),
Tags = [new S7TagDefinition("ArrTag", addr, dt, Writable: true, ArrayCount: count)], RawTags = S7RawTags.Entries(new S7TagDefinition("ArrTag", addr, dt, Writable: true, ArrayCount: count)),
}; };
using var drv = new S7Driver(opts, $"s7-writable-arr-{dt}"); using var drv = new S7Driver(opts, $"s7-writable-arr-{dt}");
@@ -272,7 +272,7 @@ public sealed class S7DriverScaffoldTests
{ {
Host = "192.0.2.1", Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250), Timeout = TimeSpan.FromMilliseconds(250),
Tags = [new S7TagDefinition("ReadArr", "DB1.DBW0", S7DataType.Int16, Writable: false, ArrayCount: 4)], RawTags = S7RawTags.Entries(new S7TagDefinition("ReadArr", "DB1.DBW0", S7DataType.Int16, Writable: false, ArrayCount: 4)),
}; };
using var drv = new S7Driver(opts, "s7-readonly-arr"); using var drv = new S7Driver(opts, "s7-readonly-arr");
@@ -1,57 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.S7;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>R2-11 Phase C strictness surface for the S7 equipment-tag parser: the runtime is now STRICT — a
/// typo'd <c>dataType</c> rejects the tag (<c>TryParse</c> ⇒ <see langword="false"/> ⇒ <c>BadNodeIdUnknown</c>),
/// <c>Inspect</c> reports the typo, and the <c>writable</c> key is honoured (default true).</summary>
public sealed class S7EquipmentTagParserStrictnessTests
{
[Fact]
public void Typo_dataType_rejects_the_tag()
{
S7EquipmentTagParser.TryParse("{\"address\":\"DB1.DBW0\",\"dataType\":\"Intt16\"}", out _)
.ShouldBeFalse();
}
[Fact]
public void Valid_dataType_still_parses()
{
S7EquipmentTagParser.TryParse("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}", out var def)
.ShouldBeTrue();
def.DataType.ShouldBe(S7DataType.Int16);
}
[Fact]
public void Inspect_reports_typo_dataType()
{
var warnings = S7EquipmentTagParser.Inspect("{\"address\":\"DB1.DBW0\",\"dataType\":\"Intt16\"}");
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("Intt16");
warnings[0].ShouldContain("dataType");
}
[Fact]
public void Inspect_clean_config_has_no_warnings()
{
S7EquipmentTagParser.Inspect("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}").ShouldBeEmpty();
}
[Fact]
public void Writable_false_is_honoured()
{
S7EquipmentTagParser.TryParse("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\",\"writable\":false}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_absent_defaults_to_true()
{
S7EquipmentTagParser.TryParse("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeTrue();
}
}
@@ -1,36 +0,0 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
[Trait("Category", "Unit")]
public class S7EquipmentTagTests
{
[Fact]
public void Parses_equipment_tagconfig_into_a_transient_definition()
{
var json = """{"address":"DB1.DBW0","dataType":"UInt16","stringLength":0}""";
S7EquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Name.ShouldBe(json);
def.Address.ShouldBe("DB1.DBW0");
def.DataType.ShouldBe(S7DataType.UInt16);
}
[Fact]
public void Rejects_a_non_address_blob()
=> S7EquipmentTagParser.TryParse("""{"FullName":"x"}""", out _).ShouldBeFalse();
[Fact]
public void Rejects_garbage()
=> S7EquipmentTagParser.TryParse("not json", out _).ShouldBeFalse();
[Fact]
public void Rejects_address_as_a_json_number()
=> S7EquipmentTagParser.TryParse(
"""{"address":40001,"dataType":"UInt16"}""", out _).ShouldBeFalse();
[Fact]
public void Rejects_string_length_out_of_range()
=> S7EquipmentTagParser.TryParse(
"""{"address":"DB1.DBW0","dataType":"String","stringLength":300}""", out _).ShouldBeFalse();
}
@@ -58,7 +58,7 @@ public sealed class S7PollEngineMigrationTests
Host = "192.0.2.1", Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250), Timeout = TimeSpan.FromMilliseconds(250),
Probe = new S7ProbeOptions { Enabled = false }, Probe = new S7ProbeOptions { Enabled = false },
Tags = [new S7TagDefinition("Arr", "DB1.DBW0", S7DataType.UInt16, Writable: false, ArrayCount: 3)], RawTags = S7RawTags.Entries(new S7TagDefinition("Arr", "DB1.DBW0", S7DataType.UInt16, Writable: false, ArrayCount: 3)),
}; };
/// <summary> /// <summary>
@@ -0,0 +1,28 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Test helper bridging the v2 authoring shape (a typed <see cref="S7TagDefinition"/>) to the v3
/// delivery shape (<see cref="RawTagEntry"/>). Under v3 the driver no longer takes pre-declared
/// definitions — the deploy artifact hands it authored raw tags (RawPath + TagConfig blob), and the
/// driver rebuilds the typed definitions via <see cref="S7TagDefinitionFactory"/>. These tests keep
/// expressing their intent as typed definitions; this helper serialises each back to its TagConfig blob
/// (via <see cref="S7TagDefinitionFactory.ToTagConfig"/>) and packages it as a <see cref="RawTagEntry"/>
/// whose RawPath is the def's <c>Name</c> — so the round-trip through the real mapper reproduces the same
/// definition the test authored, keyed by the same name the read/write/subscribe call sites use.
/// </summary>
internal static class S7RawTags
{
/// <summary>Serialises one typed definition into its <see cref="RawTagEntry"/> (RawPath = def.Name).</summary>
/// <param name="def">The typed definition to convert.</param>
/// <returns>The equivalent <see cref="RawTagEntry"/>.</returns>
public static RawTagEntry Entry(S7TagDefinition def)
=> new(def.Name, S7TagDefinitionFactory.ToTagConfig(def), def.WriteIdempotent);
/// <summary>Serialises a sequence of typed definitions into <see cref="RawTagEntry"/> records.</summary>
/// <param name="defs">The typed definitions to convert.</param>
/// <returns>The equivalent raw-tag entries.</returns>
public static IReadOnlyList<RawTagEntry> Entries(params S7TagDefinition[] defs)
=> [.. defs.Select(Entry)];
}
@@ -0,0 +1,58 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.S7;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>R2-11 Phase C strictness surface for the S7 tag-definition factory: the runtime is STRICT — a
/// typo'd <c>dataType</c> rejects the tag (<c>FromTagConfig</c> ⇒ <see langword="false"/> ⇒ <c>BadNodeIdUnknown</c>),
/// <c>Inspect</c> reports the typo, and the <c>writable</c> key is honoured (default true).</summary>
public sealed class S7TagDefinitionFactoryStrictnessTests
{
[Fact]
public void Typo_dataType_rejects_the_tag()
{
S7TagDefinitionFactory.FromTagConfig("{\"address\":\"DB1.DBW0\",\"dataType\":\"Intt16\"}", "p", out _)
.ShouldBeFalse();
}
[Fact]
public void Valid_dataType_still_parses()
{
S7TagDefinitionFactory.FromTagConfig("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}", "line/eq/t", out var def)
.ShouldBeTrue();
def!.Name.ShouldBe("line/eq/t");
def.DataType.ShouldBe(S7DataType.Int16);
}
[Fact]
public void Inspect_reports_typo_dataType()
{
var warnings = S7TagDefinitionFactory.Inspect("{\"address\":\"DB1.DBW0\",\"dataType\":\"Intt16\"}");
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("Intt16");
warnings[0].ShouldContain("dataType");
}
[Fact]
public void Inspect_clean_config_has_no_warnings()
{
S7TagDefinitionFactory.Inspect("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}").ShouldBeEmpty();
}
[Fact]
public void Writable_false_is_honoured()
{
S7TagDefinitionFactory.FromTagConfig("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\",\"writable\":false}", "p", out var def)
.ShouldBeTrue();
def!.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_absent_defaults_to_true()
{
S7TagDefinitionFactory.FromTagConfig("{\"address\":\"DB1.DBW0\",\"dataType\":\"Int16\"}", "p", out var def)
.ShouldBeTrue();
def!.Writable.ShouldBeTrue();
}
}
@@ -0,0 +1,68 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
[Trait("Category", "Unit")]
public class S7TagDefinitionFactoryTests
{
[Fact]
public void Maps_tagconfig_into_a_definition_keyed_by_rawpath()
{
var json = """{"address":"DB1.DBW0","dataType":"UInt16","stringLength":0}""";
S7TagDefinitionFactory.FromTagConfig(json, "line/eq/tag", out var def).ShouldBeTrue();
def!.Name.ShouldBe("line/eq/tag", "the definition's identity is the RawPath, not the blob");
def.Address.ShouldBe("DB1.DBW0");
def.DataType.ShouldBe(S7DataType.UInt16);
}
[Fact]
public void Rejects_a_non_address_blob()
=> S7TagDefinitionFactory.FromTagConfig("""{"FullName":"x"}""", "p", out _).ShouldBeFalse();
[Fact]
public void Rejects_garbage()
=> S7TagDefinitionFactory.FromTagConfig("not json", "p", out _).ShouldBeFalse();
[Fact]
public void Rejects_empty_tagconfig()
=> S7TagDefinitionFactory.FromTagConfig("", "p", out _).ShouldBeFalse();
[Fact]
public void Rejects_address_as_a_json_number()
=> S7TagDefinitionFactory.FromTagConfig(
"""{"address":40001,"dataType":"UInt16"}""", "p", out _).ShouldBeFalse();
[Fact]
public void Rejects_string_length_out_of_range()
=> S7TagDefinitionFactory.FromTagConfig(
"""{"address":"DB1.DBW0","dataType":"String","stringLength":300}""", "p", out _).ShouldBeFalse();
[Fact]
public void ToTagConfig_round_trips_through_FromTagConfig()
{
var original = new S7TagDefinition("line/eq/rt", "DB1.DBB0", S7DataType.String,
Writable: false, StringLength: 32, ArrayCount: null);
var blob = S7TagDefinitionFactory.ToTagConfig(original);
S7TagDefinitionFactory.FromTagConfig(blob, original.Name, out var round).ShouldBeTrue();
round!.Name.ShouldBe(original.Name);
round.Address.ShouldBe(original.Address);
round.DataType.ShouldBe(original.DataType);
round.Writable.ShouldBe(original.Writable);
round.StringLength.ShouldBe(original.StringLength);
round.ArrayCount.ShouldBe(original.ArrayCount);
}
[Fact]
public void ToTagConfig_round_trips_an_array_definition()
{
var original = new S7TagDefinition("line/eq/arr", "DB1.DBW0", S7DataType.Int16,
Writable: false, ArrayCount: 8);
var blob = S7TagDefinitionFactory.ToTagConfig(original);
S7TagDefinitionFactory.FromTagConfig(blob, original.Name, out var round).ShouldBeTrue();
round!.ArrayCount.ShouldBe(8);
round.DataType.ShouldBe(S7DataType.Int16);
}
}
@@ -1,4 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using S7NetDataType = global::S7.Net.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
@@ -63,3 +64,42 @@ internal sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
=> throw new NotImplementedException("S7 driver never calls this — no alarm surfacing"); => throw new NotImplementedException("S7 driver never calls this — no alarm surfacing");
} }
} }
/// <summary>
/// Minimal <see cref="IS7PlcFactory"/> whose connection opens cleanly and answers the CPU-status
/// probe, but serves no data. Used by the tests that need the driver <c>Initialize</c>d — which in
/// v3 is what builds the RawPath → definition table <see cref="S7Driver.DiscoverAsync"/> projects —
/// without a live PLC or wire I/O.
/// </summary>
internal sealed class ConnectingFakeS7PlcFactory : IS7PlcFactory
{
/// <summary>Creates a fresh connecting fake connection.</summary>
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
=> new ConnectingFakeS7Plc();
private sealed class ConnectingFakeS7Plc : IS7Plc
{
/// <summary>Gets a value indicating whether the fake connection is open.</summary>
public bool IsConnected { get; private set; }
/// <summary>Opens the fake connection.</summary>
public Task OpenAsync(CancellationToken ct) { IsConnected = true; return Task.CompletedTask; }
/// <summary>Closes the fake connection.</summary>
public void Close() => IsConnected = false;
/// <summary>Not exercised by these tests.</summary>
public Task<object?> ReadAsync(string address, CancellationToken ct)
=> throw new NotSupportedException("ConnectingFakeS7Plc serves no data");
/// <summary>Not exercised by these tests.</summary>
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken ct)
=> throw new NotSupportedException("ConnectingFakeS7Plc serves no data");
/// <summary>Not exercised by these tests.</summary>
public Task WriteAsync(string address, object value, CancellationToken ct)
=> throw new NotSupportedException("ConnectingFakeS7Plc serves no data");
/// <summary>Not exercised by these tests.</summary>
public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken ct)
=> throw new NotSupportedException("ConnectingFakeS7Plc serves no data");
/// <summary>Answers the CPU-status probe.</summary>
public Task ReadStatusAsync(CancellationToken ct) => Task.CompletedTask;
/// <summary>Disposes the fake connection.</summary>
public void Dispose() => IsConnected = false;
}
}
@@ -206,9 +206,12 @@ public sealed class S7TypeMappingTests
var opts = new S7DriverOptions var opts = new S7DriverOptions
{ {
Host = "192.0.2.1", Host = "192.0.2.1",
Tags = [new S7TagDefinition("Counter64", "DB1.DBB0", S7DataType.Int64)], Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(new S7TagDefinition("Counter64", "DB1.DBB0", S7DataType.Int64)),
}; };
using var drv = new S7Driver(opts, "s7-i64-map"); // v3: DiscoverAsync projects the table built at Initialize — initialize with a fake PLC first.
using var drv = new S7Driver(opts, "s7-i64-map", new ConnectingFakeS7PlcFactory());
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var builder = new RecordingAddressSpaceBuilder(); var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
@@ -224,9 +227,12 @@ public sealed class S7TypeMappingTests
var opts = new S7DriverOptions var opts = new S7DriverOptions
{ {
Host = "192.0.2.1", Host = "192.0.2.1",
Tags = [new S7TagDefinition("Total64", "DB1.DBB0", S7DataType.UInt64)], Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(new S7TagDefinition("Total64", "DB1.DBB0", S7DataType.UInt64)),
}; };
using var drv = new S7Driver(opts, "s7-u64-map"); // v3: DiscoverAsync projects the table built at Initialize — initialize with a fake PLC first.
using var drv = new S7Driver(opts, "s7-u64-map", new ConnectingFakeS7PlcFactory());
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var builder = new RecordingAddressSpaceBuilder(); var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken); await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);