fix(s7): refuse unservable tag shapes per tag at every gate (R07:H3, R07:U4)

The init-time tag guards THREW: one mis-authored tag (DB1.DBZ0, DB1.DBW0 typed
Float64, T5 typed Int32, a writable Bool array) faulted the whole driver and the
actor retried the same init every 10 s forever, darkening every tag on the PLC.
Neither the editor nor the deploy gate checked any of it.

- S7AddressParser moves to S7.Contracts (same namespace) and a new
  S7TagShapeRules there is the single authority on servable shapes.
- S7TagDefinitionFactory.FromTagConfig applies it, so the AdminUI editor, the
  deploy gate (Inspect, with the specific reason) and the driver init refuse
  the same tags. Init now skips a refused tag (ISkippedTagReporter) instead of
  throwing; the throwing RejectUnsupportedTagConfigs is gone.
- New rules on top of the old guards: a narrow type at the wrong access width
  (every read failed) and a Bool array not starting at bit 0 (U4: DB1.DBX0.3
  silently read bit 0 of bytes 0..n).
- AdminUI test fixtures that used addresses the driver could never serve
  (DB1.DBS0, a browse "SomeAddress", Int32/Float32 on DBW) now use real ones.

Refs #742
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:04:19 -04:00
parent 7bcb6331ed
commit 87a7414df7
19 changed files with 598 additions and 368 deletions
+13 -4
View File
@@ -103,9 +103,18 @@ Addresses use Siemens TIA-Portal / STEP 7 Classic syntax, parsed by
| Timer | `T0` / `T15` | Timer area (read-only — see Wide types section) |
| Counter | `C0` / `C10` | Counter area (read-only — see Wide types section) |
Parsing is strict and runs once at `InitializeAsync` so a config typo fails fast
at load instead of surfacing as `BadInternalError` on every read. Bit offsets
must be 0-7, byte offsets non-negative, DB numbers >= 1.
Parsing is strict. Bit offsets must be 0-7, byte offsets non-negative, DB numbers >= 1.
**Tag shape rules are judged per tag, at every gate** (`S7TagShapeRules`, called by
`S7TagDefinitionFactory.FromTagConfig`): the AdminUI editor refuses the tag, the deploy gate
(`Inspect`) warns, and the driver's init **skips** it (reported through `ISkippedTagReporter`; it reads
`BadNodeIdUnknown`) while every other tag keeps working. Refused: an address that does not parse, a
wide/structured type that is an array or is not byte-addressed (`DBB`/`MB`/`IB`/`QB`), a narrow type at the
wrong access width (`Int32` on `DBW0`, `Bool` on `DBB0`...), a Timer not typed `Float64` or a Counter not typed
`Int32`, a writable or array Timer/Counter, a writable `Bool` array, and a `Bool` array that does not start
at bit 0 (a Bool array reads bit 0 of each byte, so `DB1.DBX0.3` used to silently return bit 0 of bytes
0..n). These used to be init guards that THREW, so one typo faulted every tag on the PLC and the actor
retried the same init every 10 s forever (R07:H3).
## Data types
@@ -359,7 +368,7 @@ normal dev (the S7 sim is on the shared Docker host but currently offline).
**Array writes (S7-016)** — a writable `Byte` / `Int16` / `UInt16` / `Int32` / `UInt32` / `Float32` array
is written as ONE contiguous `WriteBytesAsync` of `count x element-bytes` (the inverse of the read
decode), and the value must be an array of exactly `arrayLength` elements (else `BadTypeMismatch`, no
health impact). **Writable `Bool` arrays are refused at init** (`NotSupportedException`): the array
health impact). **Writable `Bool` arrays are refused per tag** (see the tag shape rules above): the array
read is byte-granular, so a matching write would overwrite the 7 neighbouring bits of every byte it
touches, and a safe bit-packed write needs read-modify-write. Mark Bool arrays `writable: false`. A `null` array element is rejected (`BadTypeMismatch`, nothing written)
rather than coerced to 0. An array write larger than one PDU is split by S7.Net into several writes and is
@@ -48,8 +48,23 @@ public static class S7TagDefinitionFactory
/// <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)
=> FromTagConfig(tagConfig, rawPath, out def, out _);
/// <summary>
/// <see cref="FromTagConfig(string, string, out S7TagDefinition)"/>, also reporting WHY a structurally
/// valid blob was refused by <see cref="S7TagShapeRules"/> (an unparseable address, or a shape the
/// driver cannot serve). <paramref name="shapeRejection"/> stays <see langword="null"/> for a JSON-level
/// refusal (missing keys, bad enum), which <see cref="Inspect"/> explains on its own.
/// </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>
/// <param name="shapeRejection">The shape/address rejection reason, when that is why it refused.</param>
/// <returns><see langword="true"/> when the driver can serve the tag.</returns>
public static bool FromTagConfig(string tagConfig, string rawPath, out S7TagDefinition def, out string? shapeRejection)
{
def = null!;
shapeRejection = null;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
@@ -78,7 +93,7 @@ public static class S7TagDefinitionFactory
// "writable" defaults to true when absent (today's value); node-level authz still governs
// writes. Honouring the key makes read-only equipment tags authorable.
var writable = TagConfigJson.ReadWritable(root);
def = new S7TagDefinition(
var mapped = new S7TagDefinition(
Name: rawPath,
Address: address,
DataType: dataType,
@@ -86,6 +101,15 @@ public static class S7TagDefinitionFactory
StringLength: stringLength == 0 ? MaxStringLength : stringLength,
WriteIdempotent: false,
ArrayCount: arrayCount);
// The address must parse and the shape must be one the driver can serve. These used to be
// init-time guards that THREW and faulted every tag on the PLC; refusing here skips only
// this tag, and the editor + deploy gate (which ask this method) refuse it too.
if (S7TagShapeRules.Validate(mapped, out _) is { } reason)
{
shapeRejection = reason;
return false;
}
def = mapped;
return true;
}
catch (JsonException) { return false; }
@@ -162,7 +186,9 @@ public static class S7TagDefinitionFactory
/// <b>reject</b> — a structurally unparseable or non-object document, an absent / non-string /
/// blank <c>address</c>, an <b>absent</b> <c>dataType</c> (see <see cref="DeclaresDataType"/>), a
/// present-but-invalid <c>dataType</c> (the runtime enum read is STRICT, so a
/// typo rejects the tag), or an out-of-range <c>stringLength</c> on a <c>String</c> tag. Such a tag
/// typo rejects the tag), an out-of-range <c>stringLength</c> on a <c>String</c> tag, or a shape
/// <see cref="S7TagShapeRules"/> refuses (an unparseable address, a wrong access width, a mis-typed
/// Timer/Counter, an unsupported array). Such a tag
/// is skipped by the driver and reads <c>BadNodeIdUnknown</c> forever. Empty when the mapper accepts
/// the blob, when it is blank, or when it is not an equipment-tag object. Never throws.
/// </summary>
@@ -184,7 +210,13 @@ public static class S7TagDefinitionFactory
// The accept gate: whatever the driver reads draws nothing. The RAW reference is handed to the
// mapper — the driver reads the stored blob, not a trimmed copy of it.
if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings;
if (FromTagConfig(reference, InspectionRawPath, out _, out var shapeRejection)) return warnings;
if (shapeRejection is not null)
{
warnings.Add(shapeRejection + " The driver will skip this tag and it will read BadNodeIdUnknown.");
return warnings;
}
try
{
@@ -0,0 +1,149 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// <b>The single authority on which S7 tag shapes the driver can serve.</b> Consulted by
/// <see cref="S7TagDefinitionFactory.FromTagConfig"/>, so the AdminUI editor, the deploy gate
/// (<see cref="S7TagDefinitionFactory.Inspect"/>) and the driver's own init all refuse the same
/// tags with the same words.
/// </summary>
/// <remarks>
/// <para>
/// These rules used to be init-time guards in <c>S7Driver</c> that THREW. One mis-authored tag
/// (<c>DB1.DBZ0</c>, <c>DB1.DBW0</c> typed <c>Float64</c>, <c>T5</c> typed <c>Int32</c>) faulted the
/// whole driver, and the actor retried that same init every 10 s forever, darkening every tag on
/// the PLC. The editor and the deploy gate checked none of it, and a CSV import or API blob
/// bypasses the editor entirely. A rejected tag is now skipped on its own (it reads
/// <c>BadNodeIdUnknown</c>) and the deploy gate warns about it.
/// </para>
/// <para>
/// Every rule rejects a tag that could never be served correctly. None of them can newly break
/// a tag that used to work: each one either faulted init, or failed (or silently mis-decoded)
/// every read of that tag.
/// </para>
/// </remarks>
public static class S7TagShapeRules
{
/// <summary>
/// S7 data types addressed by START BYTE (the <c>B</c> suffix) rather than by an access-width
/// suffix. The driver decodes them from a contiguous byte buffer, so a wide tag authored against a
/// non-byte suffix (DBW/DBD/...) would mis-frame its value.
/// </summary>
private static readonly HashSet<S7DataType> ByteAnchoredDataTypes =
[
S7DataType.Int64,
S7DataType.UInt64,
S7DataType.Float64,
S7DataType.String,
S7DataType.DateTime,
S7DataType.WString,
S7DataType.Char,
S7DataType.WChar,
S7DataType.Dtl,
S7DataType.Date,
S7DataType.Tod,
S7DataType.Time,
S7DataType.S5Time,
];
/// <summary>True when <paramref name="dataType"/> is a wide/structured type read from a byte-anchored block.</summary>
/// <param name="dataType">The declared data type.</param>
/// <returns><see langword="true"/> for the byte-anchored types.</returns>
public static bool IsByteAnchored(S7DataType dataType) => ByteAnchoredDataTypes.Contains(dataType);
/// <summary>
/// Judges one tag definition. Returns <see langword="null"/> when the driver can serve it, or
/// the operator-facing reason it cannot.
/// </summary>
/// <param name="def">The mapped tag definition.</param>
/// <param name="parsed">The parsed address when this returns <see langword="null"/>.</param>
/// <returns>The rejection reason, or <see langword="null"/> when the tag is servable.</returns>
public static string? Validate(S7TagDefinition def, out S7ParsedAddress parsed)
{
ArgumentNullException.ThrowIfNull(def);
parsed = default;
S7ParsedAddress addr;
try
{
addr = S7AddressParser.Parse(def.Address);
}
catch (FormatException ex)
{
return $"S7 address '{def.Address}' does not parse: {ex.Message}";
}
var isWide = IsByteAnchored(def.DataType);
var isArray = def.ArrayCount is >= 1;
// Wide-type arrays have no decoder.
if (isWide && isArray)
return $"S7 {def.DataType} arrays are not supported. Use a scalar {def.DataType} tag, or an " +
"Int16/UInt16/Int32/UInt32/Float32/Byte/Bool element type for an array.";
// Timer / Counter type constraints: a timer decodes to Float64 seconds, a counter to an Int32 count.
if (addr.Area is S7Area.Timer && def.DataType != S7DataType.Float64)
return $"S7 Timer address '{def.Address}' is typed {def.DataType}. Timer tags must be " +
"DataType=Float64 (decoded to seconds, read-only).";
if (addr.Area is S7Area.Counter && def.DataType != S7DataType.Int32)
return $"S7 Counter address '{def.Address}' is typed {def.DataType}. Counter tags must be " +
"DataType=Int32 (decoded to a count, read-only).";
if (addr.Area is S7Area.Timer or S7Area.Counter)
{
if (isArray)
return $"S7 Timer/Counter address '{def.Address}' cannot be an array.";
if (def.Writable)
return $"S7 Timer/Counter address '{def.Address}' is declared writable. Timer/Counter tags " +
"are read-only; set Writable=false.";
parsed = addr;
return null;
}
// Wide/structured scalars on DB/M/I/Q must be byte-anchored (DBB/MB/IB/QB).
if (isWide)
{
if (addr.Size != S7Size.Byte)
return $"S7 {def.DataType} at '{def.Address}' is not byte-addressed. Wide/structured types " +
"must be byte-addressed (DBB/MB/IB/QB), e.g. 'DB1.DBB0', 'MB4'.";
parsed = addr;
return null;
}
// Narrow types: the declared type must match the address's access width, or every read fails
// (or decodes garbage) with a type mismatch.
var required = def.DataType switch
{
S7DataType.Bool => S7Size.Bit,
S7DataType.Byte => S7Size.Byte,
S7DataType.Int16 or S7DataType.UInt16 => S7Size.Word,
_ => S7Size.DWord, // Int32 / UInt32 / Float32
};
if (addr.Size != required)
return $"S7 {def.DataType} at '{def.Address}' has the wrong access width ({addr.Size}). " +
$"A {def.DataType} tag needs a {SuffixHint(required)} address.";
if (isArray && def.DataType == S7DataType.Bool)
{
// Element i of a Bool array is bit 0 of byte (start + i): the array is byte-granular. A
// non-zero bit offset used to be silently ignored (DB1.DBX0.3 returned bit 0 of bytes 0..n).
if (addr.BitOffset != 0)
return $"S7 Bool array at '{def.Address}' starts at bit {addr.BitOffset}. A Bool array reads " +
"one byte per element (bit 0 of each byte), so it must start at bit 0 (e.g. 'DB1.DBX0.0').";
// A byte-granular write would overwrite the 7 neighbouring bits of every byte it touches.
if (def.Writable)
return $"S7 Bool array at '{def.Address}' is declared writable. Bool array writes are not " +
"supported (a byte-granular write would clobber neighbouring bits); set Writable=false " +
"or write the bits as individual scalar Bool tags.";
}
parsed = addr;
return null;
}
private static string SuffixHint(S7Size size) => size switch
{
S7Size.Bit => "bit (DBX/M/I/Q n.b)",
S7Size.Byte => "byte (DBB/MB/IB/QB)",
S7Size.Word => "word (DBW/MW/IW/QW)",
_ => "double-word (DBD/MD/ID/QD)",
};
}
@@ -239,41 +239,36 @@ public sealed class S7Driver
if (HasConfigBody(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.
// v3: build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
// blob is mapped by the pure factory, which also applies S7TagShapeRules (the address must
// parse; wide arrays, mis-typed Timer/Counter, wrong access widths, writable Timer/Counter or
// Bool arrays and offset Bool arrays are refused). A refused tag is SKIPPED (logged, reported
// through ISkippedTagReporter, reads BadNodeIdUnknown), never thrown: these used to be init
// guards that faulted every tag on the PLC and made the actor retry the same init forever.
// The entry's WriteIdempotent flag is threaded onto the def (it lives on the RawTagEntry).
_tagsByRawPath.Clear();
_parsedByRawPath.Clear();
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
_skippedTags.BeginRebuild(_options.RawTags.Count);
foreach (var entry in _options.RawTags)
{
if (S7TagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
if (S7TagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def, out var shapeRejection)
&& S7AddressParser.TryParse(def.Address, out var parsedAddress))
{
_tagsByRawPath[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
_parsedByRawPath[entry.RawPath] = parsedAddress;
}
else
{
_skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
var reason = shapeRejection ?? "TagConfig did not map to a definition";
_skippedTags.Skip(entry.RawPath, reason);
_logger.LogWarning(
"S7 tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
"S7 tag skipped: {Reason} Driver={DriverInstanceId} RawPath={RawPath}",
reason, _driverInstanceId, entry.RawPath);
}
}
_skippedTags.Commit();
// Phase 4d combined config guard. Timer/Counter addresses and the wide/structured
// types (Int64/UInt64/Float64/String/DateTime) are now SUPPORTED, but only in
// specific shapes — this rejects the genuinely-unsupported configurations
// (wide-type arrays, non-byte-addressed wide scalars, wrong Timer/Counter DataType)
// so a config typo fails fast at init instead of as a misleading per-read fault.
RejectUnsupportedTagConfigs();
// Legacy data-type seam. The UnimplementedDataTypes set is now
// empty (Phase 4d unblocked all five wide types), so this rejects nothing — kept as
// the single grep target / future seam should a type ever need re-gating at init.
RejectUnsupportedTagDataTypes();
// Build the connection through the factory (the same seam EnsureConnectedAsync's reopen
// uses) so the read/write timeouts are baked in identically on connect and reconnect.
var plc = _plcFactory.Create(_options);
@@ -306,15 +301,6 @@ public sealed class S7Driver
_connectBackoff.RecordSuccess();
Volatile.Write(ref _negotiatedPduSize, plc.MaxPduSize);
// 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
// also rejects bit-offset > 7, DB 0, unknown area letters, etc. Keyed by RawPath
// (== def.Name) so Read/Write can pick up the pre-parsed address.
foreach (var t in _tagsByRawPath.Values)
{
_parsedByRawPath[t.Name] = S7AddressParser.Parse(t.Address); // throws FormatException
}
_initialized = true;
Health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
_logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} PduSize={PduSize} Tags={TagCount}",
@@ -437,13 +423,6 @@ public sealed class S7Driver
return trimmed is not "{}" and not "[]";
}
/// <summary>
/// S7 data types addressed by START BYTE (the <c>B</c> suffix) rather than by an
/// access-width suffix. Phase 4d decodes these from a contiguous byte buffer
/// (<c>DB{n}.DBB{offset}</c> / <c>MB</c> / <c>IB</c> / <c>QB{offset}</c>), so a wide tag
/// authored against a non-byte suffix (DBW/DBD/…) is a config error — see
/// <see cref="RejectUnsupportedTagConfigs"/>.
/// </summary>
/// <summary>
/// PDU size the CPU negotiated on the most recent successful connect, in bytes; 0 before the first
/// connect and after shutdown. Snapshotted at connect so it stays readable while the handle is
@@ -474,143 +453,6 @@ public sealed class S7Driver
: new Dictionary<string, string>();
}
private static readonly HashSet<S7DataType> ByteAnchoredDataTypes = new()
{
S7DataType.Int64,
S7DataType.UInt64,
S7DataType.Float64,
S7DataType.String,
S7DataType.DateTime,
S7DataType.WString,
S7DataType.Char,
S7DataType.WChar,
S7DataType.Dtl,
S7DataType.Date,
S7DataType.Tod,
S7DataType.Time,
S7DataType.S5Time,
};
/// <summary>
/// Combined init-time config guard (Phase 4d, supersedes the old Timer/Counter-only
/// <c>RejectUnsupportedTagAddresses</c>). Timer/Counter tags and the wide/structured
/// types (Int64/UInt64/Float64/String/DateTime) are now <i>supported</i> — but only in
/// specific shapes. This guard fails fast on the genuinely-unsupported configurations so
/// a config typo throws here at init rather than as a misleading per-read fault:
/// <list type="bullet">
/// <item>(a) a wide/structured type declared as an array — wide-type arrays are out of scope this phase.</item>
/// <item>(b) a wide/structured type on a DB/M/I/Q area whose address isn't byte-anchored (<c>DBB</c>/<c>MB</c>/<c>IB</c>/<c>QB</c>).</item>
/// <item>(c) a Timer tag not typed <c>Float64</c>, or a Counter tag not typed <c>Int32</c>.</item>
/// </list>
/// Addresses that don't parse are left to the existing <see cref="S7AddressParser.Parse"/>
/// pass in <see cref="InitializeAsync"/> so the parse error isn't double-handled.
/// </summary>
private void RejectUnsupportedTagConfigs()
{
foreach (var t in _tagsByRawPath.Values)
{
var isWide = ByteAnchoredDataTypes.Contains(t.DataType);
// (a) wide-type arrays — out of scope this phase.
if (isWide && t.ArrayCount is >= 1)
{
throw new NotSupportedException(
$"S7 tag '{t.Name}' is a {t.DataType} array, which the S7 driver does not yet " +
"support. Use a scalar wide tag, or an Int16/UInt16/Int32/UInt32/Float32/Byte/Bool " +
"element type for arrays.");
}
// Parse the address; if it doesn't parse, leave it for the InitializeAsync
// S7AddressParser.Parse pass to throw the FormatException (no double-handling).
if (!S7AddressParser.TryParse(t.Address, out var parsed))
continue;
// (b) wide/structured scalar on DB/M/I/Q must be byte-anchored (Size == Byte). The
// codec decodes from a byte buffer at the start byte; a non-byte suffix (DBW/DBD/…)
// would mis-frame the value.
if (isWide
&& parsed.Area is S7Area.DataBlock or S7Area.Memory or S7Area.Input or S7Area.Output
&& parsed.Size != S7Size.Byte)
{
throw new NotSupportedException(
$"S7 tag '{t.Name}' is a {t.DataType} at '{t.Address}', which is not byte-addressed. " +
"Wide/structured types must be byte-addressed (DBB/MB/IB/QB) — e.g. 'DB1.DBB0', 'MB4'.");
}
// (c) Timer/Counter type constraints — Timer decodes to a Float64 (seconds), Counter
// to an Int32 (count); both are read-only.
if (parsed.Area is S7Area.Timer && t.DataType != S7DataType.Float64)
{
throw new NotSupportedException(
$"S7 tag '{t.Name}' is a Timer address ('{t.Address}') but is typed {t.DataType}. " +
"Timer tags must be DataType=Float64 (decoded to seconds, read-only).");
}
if (parsed.Area is S7Area.Counter && t.DataType != S7DataType.Int32)
{
throw new NotSupportedException(
$"S7 tag '{t.Name}' is a Counter address ('{t.Address}') but is typed {t.DataType}. " +
"Counter tags must be DataType=Int32 (decoded to count, read-only).");
}
// (d) Timer/Counter declared Writable — writes are read-only this phase; without this
// a node is discovered as Operate-writable but every write returns BadNotSupported.
if (parsed.Area is S7Area.Timer or S7Area.Counter && t.Writable)
{
throw new NotSupportedException(
$"S7 tag '{t.Name}' is a Timer/Counter ('{t.Address}') declared Writable — " +
"Timer/Counter are read-only this phase; set Writable=false.");
}
// (e) Bool array declared Writable — element writes are byte-granular (one byte per
// element, mirroring the read), so a write would overwrite the 7 neighbouring bits of
// every byte it touches; a safe bit-packed write needs read-modify-write and is not
// implemented. Every other supported element type (Byte/Int16/UInt16/Int32/UInt32/
// Float32) writes as ONE contiguous block (EncodeArrayBlock). Wide-type arrays
// are already rejected above (guard-a).
if (t.ArrayCount is >= 1 && t.Writable && t.DataType == S7DataType.Bool)
{
throw new NotSupportedException(
$"S7 tag '{t.Name}' is a Bool array (ArrayCount={t.ArrayCount}) declared Writable — " +
"Bool array writes are not supported (a byte-granular write would clobber the neighbouring " +
"bits). Set Writable=false, or write the bits as individual scalar Bool tags.");
}
}
}
/// <summary>
/// Rejects tags configured with an <see cref="S7DataType"/> still on the
/// <see cref="UnimplementedDataTypes"/> list — types <see cref="ReinterpretRawValue"/> /
/// <see cref="BoxValueForWrite"/> throw <see cref="NotSupportedException"/> for, which
/// would otherwise create live OPC UA nodes via <see cref="DiscoverAsync"/> that return
/// <c>BadNotSupported</c> on every access.
/// <b>The set is now empty</b> (Phase 4d unblocked all five wide types), so this rejects
/// nothing — kept as the seam / grep target should a type ever need re-gating at init.
/// </summary>
private void RejectUnsupportedTagDataTypes()
{
foreach (var t in _tagsByRawPath.Values)
{
if (UnimplementedDataTypes.Contains(t.DataType))
{
throw new NotSupportedException(
$"S7 tag '{t.Name}' uses data type '{t.DataType}' which is not yet " +
"supported by the S7 driver — Read/Write would return BadNotSupported. " +
"Remove the tag or use Bool/Byte/Int16/UInt16/Int32/UInt32/Float32 until " +
$"{t.DataType} is wired through.");
}
}
}
/// <summary>
/// S7DataType members that the read/write helpers throw NotSupportedException for.
/// <b>Now empty</b> — Phase 4d (S7 wide types + Timer/Counter) wired Int64, UInt64,
/// Float64, String, and DateTime through the byte-buffer codec, and Timer/Counter read
/// through it on AREA. Kept here (rather than reflecting over
/// <see cref="ReinterpretRawValue"/>) as the single grep target / future seam should a
/// data type ever need re-gating at init.
/// </summary>
private static readonly HashSet<S7DataType> UnimplementedDataTypes = new();
// ---- IReadable ----
// The batch is served in two passes under ONE gate acquisition. First S7ReadSpanPlanner groups
@@ -1378,7 +1220,7 @@ public sealed class S7Driver
/// <returns><c>true</c> if the tag routes through the byte-buffer codec.</returns>
internal static bool IsBufferType(S7TagDefinition tag, S7ParsedAddress addr) =>
addr.Area is S7Area.Timer or S7Area.Counter
|| ByteAnchoredDataTypes.Contains(tag.DataType);
|| S7TagShapeRules.IsByteAnchored(tag.DataType);
/// <summary>
/// Reads a wide/structured scalar as ONE contiguous byte block via S7.Net's
@@ -112,8 +112,9 @@ public sealed class S7TagConfigModel
+ string.Join(", ", Enum.GetNames<S7DataType>()) + ".";
}
// Array shape is judged against the driver's INIT guards, which THROW (failing the whole
// driver's init, not just this tag) for a wide-type array and for a writable Bool array. The
// Array shape: the friendlier editor wording for two of the S7TagShapeRules refusals (a wide-type
// array, a writable Bool array). The reader below refuses them too (and the rest of the shape
// rules), so this is wording, not a second authority. The
// isArray / arrayLength keys themselves belong to the TagModal's generic array controls
// (TagArrayConfig) and are intentionally not fields of this model, so read them off the key bag.
if (TagConfigJson.GetBool(_bag, "isArray"))
@@ -0,0 +1,57 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// <summary>
/// Live gate for R07:H3 — one mis-authored tag must not darken the PLC. Before the fix each of
/// these shapes threw at init, so the driver Faulted and served nothing (and the actor retried the
/// same init every 10 s forever). Now each is skipped on its own and the good tags keep reading
/// their seeded values over real S7comm.
/// </summary>
[Collection(Snap7ServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "S7_1500")]
public sealed class S7_1500TagShapeSkipTests(Snap7ServerFixture sim)
{
/// <summary>Unservable tags are skipped per tag; the seeded neighbours still read Good.</summary>
[Fact]
public async Task Mis_authored_tags_are_skipped_and_the_rest_of_the_PLC_still_reads()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
var good = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
var bad = new[]
{
new S7TagDefinition("Bad/Typo", "DB1.DBZ0", S7DataType.Int16),
new S7TagDefinition("Bad/WideOnWord", "DB1.DBW0", S7DataType.Float64),
new S7TagDefinition("Bad/TimerAsInt", "T5", S7DataType.Int32, Writable: false),
new S7TagDefinition("Bad/OffsetBoolArray", "DB1.DBX50.3", S7DataType.Bool, Writable: false, ArrayCount: 8),
};
var options = new S7DriverOptions
{
Host = good.Host, Port = good.Port, CpuType = good.CpuType, Rack = good.Rack, Slot = good.Slot,
Timeout = good.Timeout, Probe = good.Probe,
RawTags = [.. good.RawTags, .. S7RawTags.Entries(bad)],
};
await using var drv = new S7Driver(options, driverInstanceId: "s7-shape-skip-live");
await drv.InitializeAsync("{}", ct);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetSkippedTags().ShouldNotBeNull().Sample.Select(s => s.RawPath)
.ShouldBe(bad.Select(b => b.Name), ignoreOrder: true);
var r = await drv.ReadAsync(
[S7_1500Profile.ProbeTag, S7_1500Profile.SmokeI16Tag, S7_1500Profile.SmokeBoolTag, bad[0].Name], ct);
r[0].StatusCode.ShouldBe(0u);
Convert.ToInt32(r[0].Value).ShouldBe(S7_1500Profile.ProbeSeedValue);
r[1].StatusCode.ShouldBe(0u);
Convert.ToInt32(r[1].Value).ShouldBe((int)S7_1500Profile.SmokeI16SeedValue);
r[2].StatusCode.ShouldBe(0u);
Convert.ToBoolean(r[2].Value).ShouldBeTrue();
r[3].StatusCode.ShouldBe(0x80340000u, "a skipped tag reads BadNodeIdUnknown");
}
}
@@ -25,26 +25,10 @@ public sealed class S7DriverCodeReviewFixTests
[InlineData("C10")]
public async Task Initialize_rejects_timer_or_counter_tag_with_NotSupportedException(string address)
{
// A Timer/Counter address parses cleanly but the read path has no decode case for it,
// so it must fail fast at init rather than throw a misleading type-mismatch on every
// read. The host is reserved-for-documentation so the TCP connect can never succeed —
// the unsupported-address guard runs before the connect, so the NotSupportedException
// is what surfaces.
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("Quirk", address, S7DataType.Int16)),
};
using var drv = new S7Driver(opts, "s7-tc");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain(address);
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Faulted, "an unsupported-address config error must Fault the driver");
health.LastError.ShouldNotBeNull();
// A Timer/Counter address typed anything but Float64 (Timer) / Int32 (Counter) cannot be decoded.
// It is refused PER TAG at every gate; it used to throw at init and fault every tag on the PLC.
await S7TagSkipAssert.RefusedPerTagAsync(
new S7TagDefinition("Quirk", address, S7DataType.Int16, Writable: false), address);
}
/// <summary>Verifies that initialize accepts DB and MIQ addresses without triggering the unsupported guard.</summary>
@@ -102,19 +86,21 @@ public sealed class S7DriverCodeReviewFixTests
[Fact]
public async Task Initialize_rejects_a_timer_tag_supplied_only_through_driverConfigJson()
{
// The Timer/Counter guard must run against the *re-parsed* config, not just the
// constructor options — proves Driver.S7-001 and Driver.S7-011 compose correctly.
// The shape gate must run against the *re-parsed* config, not just the constructor options —
// proves the per-tag skip and Driver.S7-011 compose correctly.
var ctorOpts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250) };
using var drv = new S7Driver(ctorOpts, "s7-cfg-tc");
using var drv = new S7Driver(ctorOpts, "s7-cfg-tc", new ScriptedS7PlcFactory());
const string json = """
{ "Host": "192.0.2.1", "TimeoutMs": 250,
{ "Host": "192.0.2.1", "TimeoutMs": 250, "Probe": { "Enabled": false },
"RawTags": [ { "RawPath": "TimerTag", "TagConfig": "{\"address\":\"T5\",\"dataType\":\"Int16\"}", "WriteIdempotent": false } ] }
""";
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync(json, TestContext.Current.CancellationToken));
ex.Message.ShouldContain("T5");
await drv.InitializeAsync(json, TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
var skipped = drv.GetSkippedTags().ShouldNotBeNull().Sample.ShouldHaveSingleItem();
skipped.RawPath.ShouldBe("TimerTag");
skipped.Reason.ShouldContain("T5");
}
/// <summary>Verifies that reinitialize applies a changed driverConfigJson.</summary>
@@ -158,23 +158,9 @@ public sealed class S7DriverCodeReviewFixTests2
[InlineData(S7DataType.DateTime)]
public async Task Initialize_rejects_wide_type_at_non_byte_address_with_NotSupportedException(S7DataType dt)
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
// 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;
// see S7ScalarBlockTests.)
RawTags = S7RawTags.Entries(new S7TagDefinition("X", "DB1.DBD0", dt)),
};
using var drv = new S7Driver(opts, $"s7-wide-nonbyte-{dt}");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain(dt.ToString());
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Faulted);
// DB1.DBD0 is a DWord (non-byte) address — a wide type there would mis-frame its value. (At a
// byte address like DB1.DBB0 the 8-byte numerics round-trip; see S7ScalarBlockTests.)
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("X", "DB1.DBD0", dt), dt.ToString(), "byte-addressed");
}
/// <summary>Verifies that Initialize accepts implemented data types.</summary>
@@ -17,22 +17,11 @@ public sealed class S7DriverReadWriteTests
[Fact]
public async Task Initialize_rejects_invalid_tag_address_and_fails_fast()
{
// Bad address at init time must throw; the alternative (deferring the parse to the
// first read) would surface the config bug as BadInternalError on every subsequent
// Read which is impossible for an operator to diagnose from the OPC UA client.
var opts = new S7DriverOptions
{
Host = "192.0.2.1", // reserved — will never complete TCP handshake
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16)),
};
using var drv = new S7Driver(opts, "s7-bad-tag");
// Either the TCP connect fails first (Exception) or the parser fails (FormatException)
// — both are acceptable since both are init-time fail-fast. What matters is that we
// don't return a "healthy" driver with a latent bad tag.
await Should.ThrowAsync<Exception>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
// A bad address is refused PER TAG at every gate (mapper/editor, deploy gate, driver init):
// it reads BadNodeIdUnknown while its neighbours keep working. It used to throw at init and
// fault every tag on the PLC, with the actor retrying the same init every 10 s forever.
await S7TagSkipAssert.RefusedPerTagAsync(
new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16), "does not parse");
}
/// <summary>Verifies that ReadAsync without initialize throws InvalidOperationException.</summary>
@@ -101,80 +101,32 @@ public sealed class S7DriverScaffoldTests
[Fact]
public async Task Initialize_rejects_wide_type_array_with_clear_message()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("Batch64", "DB1.DBB0", S7DataType.Int64, ArrayCount: 4)),
};
using var drv = new S7Driver(opts, "s7-wide-array");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Batch64");
ex.Message.ShouldContain("array", Case.Insensitive);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Batch64", "DB1.DBB0", S7DataType.Int64, ArrayCount: 4), "Int64", "array");
}
/// <summary>Verifies that a wide scalar with a non-byte address (Float64 at DB1.DBW0) is rejected with a byte-address message.</summary>
[Fact]
public async Task Initialize_rejects_wide_scalar_with_non_byte_address()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("WideWord", "DB1.DBW0", S7DataType.Float64)),
};
using var drv = new S7Driver(opts, "s7-wide-nonbyte");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("WideWord");
ex.Message.ShouldContain("byte", Case.Insensitive);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("WideWord", "DB1.DBW0", S7DataType.Float64), "Float64", "byte-addressed");
}
/// <summary>Verifies that a Timer tag with the wrong DataType (Int32, not Float64) is rejected.</summary>
[Fact]
public async Task Initialize_rejects_Timer_tag_with_wrong_data_type()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("Timer5", "T5", S7DataType.Int32)),
};
using var drv = new S7Driver(opts, "s7-timer-bad");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Timer5");
ex.Message.ShouldContain("Float64");
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Timer5", "T5", S7DataType.Int32, Writable: false), "T5", "Float64");
}
/// <summary>Verifies that a Counter tag with the wrong DataType (Float32, not Int32) is rejected.</summary>
[Fact]
public async Task Initialize_rejects_Counter_tag_with_wrong_data_type()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("Counter3", "C3", S7DataType.Float32)),
};
using var drv = new S7Driver(opts, "s7-counter-bad");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Counter3");
ex.Message.ShouldContain("Int32");
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Counter3", "C3", S7DataType.Float32, Writable: false), "C3", "Int32");
}
// ── Phase 4d bundle-review fix — Writable Timer/Counter guard ───────────────────────
@@ -188,42 +140,16 @@ public sealed class S7DriverScaffoldTests
[Fact]
public async Task Initialize_rejects_Timer_tag_declared_Writable()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("Timer5", "T5", S7DataType.Float64, Writable: true)),
};
using var drv = new S7Driver(opts, "s7-timer-writable");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Timer5");
ex.Message.ShouldContain("Writable", Case.Insensitive);
ex.Message.ShouldContain("read-only", Case.Insensitive);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Timer5", "T5", S7DataType.Float64, Writable: true), "T5", "writable", "read-only");
}
/// <summary>Verifies that a Counter tag declared Writable=true is rejected at init with a Writable message.</summary>
[Fact]
public async Task Initialize_rejects_Counter_tag_declared_Writable()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("Counter3", "C3", S7DataType.Int32, Writable: true)),
};
using var drv = new S7Driver(opts, "s7-counter-writable");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Counter3");
ex.Message.ShouldContain("Writable", Case.Insensitive);
ex.Message.ShouldContain("read-only", Case.Insensitive);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Counter3", "C3", S7DataType.Int32, Writable: true), "C3", "writable", "read-only");
}
// ── Driver.S7-015 / S7-016 — writable Bool array must fail fast at init ─────────────
@@ -236,20 +162,8 @@ public sealed class S7DriverScaffoldTests
[Fact]
public async Task Initialize_rejects_writable_bool_array_tag_with_NotSupportedException()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("ArrTag", "DB1.DBX0.0", S7DataType.Bool, Writable: true, ArrayCount: 4)),
};
using var drv = new S7Driver(opts, "s7-writable-bool-arr");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("ArrTag");
ex.Message.ShouldContain("Bool array", Case.Insensitive);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("ArrTag", "DB1.DBX0.0", S7DataType.Bool, Writable: true, ArrayCount: 4), "Bool array", "writable");
}
/// <summary>Verifies that writable non-bool arrays pass the init guard — the failure
@@ -0,0 +1,66 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// R07:H3 / R07:U4 — the tag shapes the driver cannot serve are refused per tag at the mapper,
/// the deploy gate and the driver's init (see <see cref="S7TagShapeRules"/>), instead of faulting
/// the whole driver at init or being silently mis-read.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7TagShapeGateTests
{
/// <summary>A typo'd area/size letter is refused per tag (it used to throw FormatException at init).</summary>
[Fact]
public Task Unparseable_address_is_refused_per_tag()
=> S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Typo", "DB1.DBZ0", S7DataType.Int16), "DB1.DBZ0", "does not parse");
/// <summary>
/// R07:U4 — a Bool array reads bit 0 of each byte, so a non-zero start bit was silently ignored
/// (<c>DB1.DBX0.3</c> returned bit 0 of bytes 0..7). It is refused at authoring now.
/// </summary>
[Fact]
public Task Bool_array_starting_at_a_nonzero_bit_is_refused()
=> S7TagSkipAssert.RefusedPerTagAsync(
new S7TagDefinition("OffsetBits", "DB1.DBX0.3", S7DataType.Bool, Writable: false, ArrayCount: 8),
"Bool array", "bit 3");
/// <summary>A narrow type at the wrong access width fails every read with a type mismatch; refused up front.</summary>
/// <param name="address">The address.</param>
/// <param name="dt">The declared type, which does not match the address's access width.</param>
[Theory]
[InlineData("DB1.DBW0", S7DataType.Int32)]
[InlineData("DB1.DBD0", S7DataType.Int16)]
[InlineData("DB1.DBB0", S7DataType.Bool)]
[InlineData("M0.0", S7DataType.Byte)]
[InlineData("MW0", S7DataType.Float32)]
public Task Narrow_type_at_the_wrong_access_width_is_refused(string address, S7DataType dt)
=> S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Mismatch", address, dt), "access width");
/// <summary>Positive control: the shapes the driver serves are all still accepted by the mapper and the gate.</summary>
/// <param name="address">The address.</param>
/// <param name="dt">The declared type.</param>
/// <param name="arrayCount">The array count, or 0 for a scalar.</param>
/// <param name="writable">Whether the tag is writable.</param>
[Theory]
[InlineData("DB1.DBX0.0", S7DataType.Bool, 8, false)]
[InlineData("DB1.DBX50.3", S7DataType.Bool, 0, true)]
[InlineData("DB1.DBB0", S7DataType.Byte, 4, true)]
[InlineData("DB1.DBW0", S7DataType.Int16, 0, true)]
[InlineData("MW2", S7DataType.UInt16, 3, true)]
[InlineData("DB1.DBD0", S7DataType.Float32, 0, true)]
[InlineData("QD4", S7DataType.Int32, 2, true)]
[InlineData("DB1.DBB0", S7DataType.Float64, 0, true)]
[InlineData("DB1.DBB0", S7DataType.String, 0, true)]
[InlineData("T5", S7DataType.Float64, 0, false)]
[InlineData("C3", S7DataType.Int32, 0, false)]
public void Servable_shapes_are_accepted(string address, S7DataType dt, int arrayCount, bool writable)
{
var def = new S7TagDefinition("Ok", address, dt, Writable: writable, ArrayCount: arrayCount == 0 ? null : arrayCount);
var blob = S7TagDefinitionFactory.ToTagConfig(def);
S7TagDefinitionFactory.FromTagConfig(blob, "Ok", out var mapped, out var reason).ShouldBeTrue(reason);
mapped.Address.ShouldBe(address);
S7TagDefinitionFactory.Inspect(blob).ShouldBeEmpty();
}
}
@@ -0,0 +1,60 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Shared assertion for an S7 tag shape the driver cannot serve. It must be refused PER TAG at
/// every gate, never fault the whole driver: the mapper (and so the AdminUI editor) refuses it with
/// the reason, the deploy gate (<see cref="S7TagDefinitionFactory.Inspect"/>) warns, and a driver
/// carrying it next to a good tag initializes Healthy, reports the bad one through
/// <see cref="ISkippedTagReporter"/>, and still serves the good one (the positive control).
/// </summary>
internal static class S7TagSkipAssert
{
private const string GoodName = "Good";
/// <summary>Asserts <paramref name="bad"/> is refused per tag at the mapper, the deploy gate and the driver.</summary>
/// <param name="bad">The unservable tag.</param>
/// <param name="reasonFragments">Fragments the operator-facing rejection reason must contain.</param>
/// <returns>A task that completes when every gate has been checked.</returns>
public static async Task RefusedPerTagAsync(S7TagDefinition bad, params string[] reasonFragments)
{
var ct = TestContext.Current.CancellationToken;
var blob = S7TagDefinitionFactory.ToTagConfig(bad);
// Mapper (and therefore the AdminUI editor, which asks it) — refused, with the reason.
S7TagDefinitionFactory.FromTagConfig(blob, bad.Name, out _, out var reason).ShouldBeFalse();
reason.ShouldNotBeNull();
foreach (var f in reasonFragments) reason.ShouldContain(f, Case.Insensitive);
// Deploy gate — warns with the same reason.
var warnings = S7TagDefinitionFactory.Inspect(blob);
warnings.ShouldHaveSingleItem().ShouldContain(reason);
// Driver — initializes Healthy with the bad tag skipped and the good tag served.
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(bad, new S7TagDefinition(GoodName, "DB1.DBW0", S7DataType.UInt16, Writable: false)),
};
using var drv = new S7Driver(opts, "s7-skip-" + bad.Name, new ScriptedS7PlcFactory());
await drv.InitializeAsync("{}", ct);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "one unservable tag must not fault the driver");
var skipped = drv.GetSkippedTags().ShouldNotBeNull();
skipped.Sample.ShouldHaveSingleItem().RawPath.ShouldBe(bad.Name);
skipped.Sample[0].Reason.ShouldBe(reason);
var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, ct);
builder.Variables.Select(v => v.Name).ShouldBe([GoodName]);
var results = await drv.ReadAsync([bad.Name, GoodName], ct);
results[0].StatusCode.ShouldBe(0x80340000u, "the skipped tag reads BadNodeIdUnknown");
results[1].StatusCode.ShouldBe(0u, "the good neighbour is still served");
}
}
@@ -318,16 +318,9 @@ public sealed class S7ValueCodecTests
[Fact]
public async Task New_types_declared_as_arrays_or_off_byte_addresses_fail_init()
{
S7DriverOptions With(S7TagDefinition t) => new()
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(t),
};
using var arr = new S7Driver(With(Tag(S7DataType.Dtl) with { ArrayCount = 2 }), "a", new ConnectingFakeS7PlcFactory());
await Should.ThrowAsync<NotSupportedException>(() => arr.InitializeAsync("{}", TestContext.Current.CancellationToken));
using var word = new S7Driver(With(Tag(S7DataType.Tod, address: "DB1.DBW0")), "b", new ConnectingFakeS7PlcFactory());
await Should.ThrowAsync<NotSupportedException>(() => word.InitializeAsync("{}", TestContext.Current.CancellationToken));
// Refused PER TAG at every gate (they used to throw at init and fault the whole driver).
await S7TagSkipAssert.RefusedPerTagAsync(Tag(S7DataType.Dtl) with { Name = "DtlArr", ArrayCount = 2 }, "Dtl", "array");
await S7TagSkipAssert.RefusedPerTagAsync(Tag(S7DataType.Tod, address: "DB1.DBW0") with { Name = "TodWord" }, "Tod", "byte-addressed");
}
// ---- TagConfig ----
@@ -0,0 +1,142 @@
using S7DataItem = global::S7.Net.Types.DataItem;
using S7NetDataType = global::S7.Net.DataType;
using VarType = global::S7.Net.VarType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// A scriptable <see cref="IS7PlcFactory"/>: every connection it creates delegates its wire
/// operations to the factory's handlers, so a test scripts the device once and observes every
/// connection (including reopened ones). Counts wire operations so a test can prove what did
/// NOT go to the wire without timing anything.
/// </summary>
internal sealed class ScriptedS7PlcFactory : IS7PlcFactory
{
private int _wireOps;
/// <summary>Every connection created, in order.</summary>
public List<ScriptedS7Plc> Created { get; } = [];
/// <summary>Wire operations issued (reads, multi-var reads, writes; not opens or status probes).</summary>
public int WireOps => Volatile.Read(ref _wireOps);
/// <summary>Hook run inside every <see cref="IS7Plc.OpenAsync"/>; the default opens immediately.</summary>
public Func<int, CancellationToken, Task> Open { get; set; } = (_, _) => Task.CompletedTask;
/// <summary>Per-address string read; the default returns <c>(ushort)42</c>.</summary>
public Func<string, CancellationToken, Task<object?>> Read { get; set; } = (_, _) => Task.FromResult<object?>((ushort)42);
/// <summary>Block read; the default returns <c>count</c> zero bytes.</summary>
public Func<S7NetDataType, int, int, int, CancellationToken, Task<byte[]>> ReadBytes { get; set; }
= (_, _, _, count, _) => Task.FromResult(new byte[count]);
/// <summary>Multi-var read; the default answers every item with a zero of its var type.</summary>
public Func<List<S7DataItem>, CancellationToken, Task<IReadOnlyList<S7DataItem>>> ReadMulti { get; set; }
= (items, _) =>
{
foreach (var i in items)
i.Value = i.VarType switch
{
VarType.Bit => false,
VarType.Byte => (byte)0,
VarType.Word => (ushort)0,
_ => (uint)0,
};
return Task.FromResult<IReadOnlyList<S7DataItem>>(items);
};
/// <summary>Per-address string write; the default succeeds.</summary>
public Func<string, object, CancellationToken, Task> Write { get; set; } = (_, _, _) => Task.CompletedTask;
/// <summary>Block write; the default succeeds.</summary>
public Func<S7NetDataType, int, int, byte[], CancellationToken, Task> WriteBytes { get; set; }
= (_, _, _, _, _) => Task.CompletedTask;
/// <summary>CPU-status probe; the default answers.</summary>
public Func<CancellationToken, Task> ReadStatus { get; set; } = _ => Task.CompletedTask;
/// <inheritdoc />
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout)
{
lock (Created)
{
var plc = new ScriptedS7Plc(this, Created.Count);
Created.Add(plc);
return plc;
}
}
/// <summary>Counts one wire operation.</summary>
internal void CountWireOp() => Interlocked.Increment(ref _wireOps);
}
/// <summary>One connection created by <see cref="ScriptedS7PlcFactory"/>.</summary>
internal sealed class ScriptedS7Plc(ScriptedS7PlcFactory factory, int index) : IS7Plc
{
/// <summary>Zero-based creation index.</summary>
public int Index { get; } = index;
/// <inheritdoc />
public bool IsConnected { get; private set; }
/// <summary>True once disposed.</summary>
public bool Disposed { get; private set; }
/// <inheritdoc />
public int MaxPduSize => 240;
/// <inheritdoc />
public async Task OpenAsync(CancellationToken cancellationToken)
{
await factory.Open(Index, cancellationToken).ConfigureAwait(false);
IsConnected = true;
}
/// <inheritdoc />
public Task<object?> ReadAsync(string address, CancellationToken cancellationToken)
{
factory.CountWireOp();
return factory.Read(address, cancellationToken);
}
/// <inheritdoc />
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken)
{
factory.CountWireOp();
return factory.ReadBytes(area, db, startByteAdr, count, cancellationToken);
}
/// <inheritdoc />
public Task<IReadOnlyList<S7DataItem>> ReadMultipleVarsAsync(List<S7DataItem> items, CancellationToken cancellationToken)
{
factory.CountWireOp();
return factory.ReadMulti(items, cancellationToken);
}
/// <inheritdoc />
public Task WriteAsync(string address, object value, CancellationToken cancellationToken)
{
factory.CountWireOp();
return factory.Write(address, value, cancellationToken);
}
/// <inheritdoc />
public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken)
{
factory.CountWireOp();
return factory.WriteBytes(area, db, startByteAdr, value, cancellationToken);
}
/// <inheritdoc />
public Task ReadStatusAsync(CancellationToken cancellationToken) => factory.ReadStatus(cancellationToken);
/// <inheritdoc />
public void Close() => IsConnected = false;
/// <inheritdoc />
public void Dispose()
{
Disposed = true;
IsConnected = false;
}
}
@@ -142,7 +142,7 @@ public sealed class CsvMergeEvidenceTests
var row = SingleRow(
DriverTypeNames.S7,
"Name,DataType,Address,S7DataType,TagConfigJson",
"t1,Int16,DB1.DBW0,Int32,\"{\"\"pollHint\"\":\"\"fast\"\"}\"");
"t1,Int16,DB1.DBD0,Int32,\"{\"\"pollHint\"\":\"\"fast\"\"}\"");
var blob = Blob(row);
blob["dataType"]!.GetValue<string>().ShouldBe("Int32");
@@ -346,7 +346,11 @@ public sealed class RawTagConfigGateTests
public void A_browse_committed_config_passes_the_gate(string driverType)
{
var tagConfig = RawBrowseCommitMapper.BuildTagConfig(
driverType, driverType == DriverTypeNames.AbLegacy ? "N7:0" : "SomeAddress", BrowseFieldsFor(driverType));
driverType,
// Drivers whose address grammar is validated by their reader need a real address: AbLegacy's
// file grammar, and S7's (S7TagShapeRules — an unparseable or wrong-width address is refused).
driverType == DriverTypeNames.AbLegacy ? "N7:0" : driverType == DriverTypeNames.S7 ? "DB1.DBW0" : "SomeAddress",
BrowseFieldsFor(driverType));
TagConfigValidator.Validate(driverType, tagConfig).ShouldBeNull(
$"browse-commit for {driverType} must not author a config the import refuses");
@@ -98,13 +98,13 @@ public sealed class S7TagConfigModelTests
/// driver serves. (An absent key is deliberately not asserted: S7 alone hard-requires the key, an
/// asymmetry that predates this change and that its own <c>Inspect</c> does not report either.)</summary>
[Theory]
[InlineData("""{"address":"DB1.DBW0","dataType":"float32"}""")]
[InlineData("""{"address":"DB1.DBW0","dataType":"FLOAT32"}""")]
[InlineData("""{"address":"DB1.DBD0","dataType":"float32"}""")]
[InlineData("""{"address":"DB1.DBD0","dataType":"FLOAT32"}""")]
[InlineData("""{"address":"DB1.DBW0","dataType":7}""")]
public void Validate_accepts_every_dataType_the_driver_accepts(string json)
=> S7TagConfigModel.FromJson(json).Validate().ShouldBeNull();
// ---- S7-016: array shape vs the driver's init guards (which fail the WHOLE driver init) ----
// ---- S7-016: array shape vs S7TagShapeRules (refused per tag at every gate) ----
[Theory]
[InlineData("Int64")]
@@ -160,9 +160,9 @@ public sealed class TagConfigNonEnumParityTests
/// <summary>S7's remaining non-enum refusal: a <c>String</c> tag whose <c>stringLength</c> falls outside
/// 0–254. The reader REJECTS it (it does not clamp), so the tag would never resolve.</summary>
[Theory]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":255}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":300}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":-1}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":255}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":300}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":-1}""")]
public void S7_refuses_a_string_length_its_reader_refuses(string blob)
{
RuntimeAccepts(DriverTypeNames.S7, blob).ShouldBeFalse(
@@ -174,9 +174,9 @@ public sealed class TagConfigNonEnumParityTests
/// stops this becoming an over-rejection — an out-of-range <c>stringLength</c> on a tag that is not a
/// String at all, which the reader ignores entirely.</summary>
[Theory]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":10}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":254}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":0}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":10}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":254}""")]
[InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":0}""")]
[InlineData("""{"address":"DB1.DBW0","dataType":"Int16","stringLength":300}""")]
public void S7_accepts_a_string_length_its_reader_accepts(string blob)
{
@@ -360,8 +360,8 @@ public sealed class TagConfigNonEnumParityTests
(DriverTypeNames.S7, """{"address":"DB1.DBW0","dataType":"Int16"}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBW0"}"""),
(DriverTypeNames.S7, """{"address":" ","dataType":"Int16"}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBS0","dataType":"String","stringLength":300}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBS0","dataType":"String","stringLength":24}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBB0","dataType":"String","stringLength":300}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBB0","dataType":"String","stringLength":24}"""),
(DriverTypeNames.AbCip, "{}"),
(DriverTypeNames.AbCip, """{"tagPath":"Program:Main.Speed"}"""),
@@ -576,7 +576,7 @@ public sealed class TagConfigNonEnumParityTests
data.Add(DriverTypeNames.Modbus, new ModbusTagConfigModel { Address = 1 }.ToJson());
data.Add(DriverTypeNames.S7, new S7TagConfigModel { Address = "DB1.DBW0" }.ToJson());
data.Add(DriverTypeNames.S7,
new S7TagConfigModel { Address = "DB1.DBS0", DataType = S7DataType.String, StringLength = 24 }.ToJson());
new S7TagConfigModel { Address = "DB1.DBB0", DataType = S7DataType.String, StringLength = 24 }.ToJson());
data.Add(DriverTypeNames.AbCip, new AbCipTagConfigModel { TagPath = "Program:Main.Speed" }.ToJson());
data.Add(DriverTypeNames.AbLegacy, new AbLegacyTagConfigModel { Address = "N7:0" }.ToJson());
data.Add(DriverTypeNames.TwinCAT, new TwinCATTagConfigModel { SymbolPath = "MAIN.bStart" }.ToJson());