v3(s7): apply Modbus RawTags exemplar to the S7 driver

Rename S7EquipmentTagParser -> S7TagDefinitionFactory; TryParse(reference)
-> FromTagConfig(tagConfig, rawPath, out def) (drops the leading-{ heuristic,
keys the def by RawPath, adds inverse ToTagConfig). Replace S7DriverOptions.Tags
(pre-declared S7TagDefinition list) with RawTags (IReadOnlyList<RawTagEntry>);
the driver builds its RawPath->def table from RawTags at Initialize via the
factory (skip+log on miss), resolver is byName-only, DiscoverAsync + init guards
+ address pre-parse now run off that table. Factory retires the pre-declared DTO
tag path (RawTags binding only). Cli BuildOptions serialises typed defs to
RawTagEntry. Tests migrated to a S7RawTags helper + FromTagConfig; parser test
files renamed. Coordinator's EquipmentTagConfigInspector S7 rename left to Wave-B
coordinator; Driver.S7.IntegrationTests (Docker-gated) left red per scope.

Driver.S7 + Cli build green; Driver.S7.Tests 264/264, S7.Cli.Tests 49/49.
This commit is contained in:
Joseph Doherty
2026-07-15 20:11:04 -04:00
parent c379e246d0
commit 1e26b9ab58
21 changed files with 425 additions and 251 deletions
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
@@ -53,8 +54,15 @@ public sealed class S7DriverOptions
/// <summary>Connect + per-operation timeout.</summary>
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>
public IReadOnlyList<S7TagDefinition> Tags { get; init; } = [];
/// <summary>
/// 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>
/// 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>
/// (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>
/// (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
/// <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.).
@@ -1,29 +1,45 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>Parses an equipment tag's <c>TagConfig</c> JSON (the shape authored by the AdminUI
/// <c>S7TagConfigModel</c> — <c>address</c> / <c>dataType</c> / <c>stringLength</c>) into a transient
/// <see cref="S7TagDefinition"/> whose <see cref="S7TagDefinition.Name"/> equals the reference string
/// itself, so a value the driver publishes back keys the runtime's forward router correctly.</summary>
public static class S7EquipmentTagParser
/// <summary>
/// v3 pure mapper: turns an authored raw tag's <c>TagConfig</c> JSON (the shape produced by the
/// AdminUI <c>S7TagConfigModel</c> — <c>address</c> / <c>dataType</c> / <c>stringLength</c>) into a
/// <see cref="S7TagDefinition"/>. Under v3 a tag's identity is its <b>RawPath</b> (a cluster-scoped
/// 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>
private const int MaxStringLength = 254;
/// <summary>Attempts to parse an equipment-tag reference into a transient definition.</summary>
/// <param name="reference">The equipment tag's TagConfig JSON (also used as the def identity).</param>
/// <param name="def">The transient definition when parsing succeeds.</param>
/// <returns><see langword="true"/> when <paramref name="reference"/> is an S7 address object.</returns>
public static bool TryParse(string reference, out S7TagDefinition def)
/// <summary>
/// Maps an authored <c>TagConfig</c> object to a typed definition keyed by <paramref name="rawPath"/>.
/// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3).
/// The <c>dataType</c> enum is read STRICTLY — a present-but-invalid (typo'd) value rejects the whole
/// 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!;
// Authored tag names never start with '{' (AdminUI name validation), so a leading brace marks an equipment-tag TagConfig blob.
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return false;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
using var doc = JsonDocument.Parse(reference);
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("address", out var addrEl)
@@ -42,18 +58,19 @@ public static class S7EquipmentTagParser
if (dataType == S7DataType.String && (stringLength < 0 || stringLength > MaxStringLength)) return false;
// Array intent: the canonical sink-side parse (DeploymentArtifact.ExtractTagArray)
// 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
// OPC UA node's ValueRank/ArrayDimensions. Absent / isArray=false ⇒ null (scalar).
// that here so the driver's def agrees byte-for-byte with the materialised OPC UA node's
// ValueRank/ArrayDimensions. Absent / isArray=false ⇒ null (scalar).
var arrayCount = ReadArrayCount(root);
// "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).
var writable = TagConfigJson.ReadWritable(root);
def = new S7TagDefinition(
Name: reference,
Name: rawPath,
Address: address,
DataType: dataType,
Writable: writable,
StringLength: stringLength == 0 ? MaxStringLength : stringLength,
WriteIdempotent: false,
ArrayCount: arrayCount);
return true;
}
@@ -62,6 +79,37 @@ public static class S7EquipmentTagParser
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>
/// 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