v3(b1-twincat): apply Modbus exemplar pattern to TwinCAT (multi-device)

- Rename TwinCATEquipmentTagParser -> TwinCATTagDefinitionFactory; TryParse ->
  FromTagConfig(tagConfig, rawPath, out def). Drop leading-'{' heuristic + the
  deviceHostAddress key; def.Name = rawPath. Keep strict-enum + Inspect; add a
  Structure guard (sole tag path now) + inverse ToTagConfig.
- Options: Tags -> RawTags (RawTagEntry); keep Devices + protocol fields.
- Driver: _tagsByRawPath (Ordinal); byName-only resolver; build table from
  _options.RawTags via FromTagConfig, threading WriteIdempotent + DeviceName.
  Multi-device: ResolveDeviceHost matches entry.DeviceName against options.Devices
  (by name, then host) -> def.DeviceHostAddress (TODO(v3 WaveC): live host from the
  Device row's DeviceConfig). DiscoverAsync now emits from the table.
- Factory: retire pre-declared tags[] DTO path; bind rawTags; keep Devices.
- Cli: BuildOptions serialises typed defs -> RawTagEntry via ToTagConfig.
- Tests: TwinCATRawTags helper; migrate Tags= -> RawTags=; rename parser tests to
  FromTagConfig; equipment/resolve-host/array tests delivered via RawTags.

Gate: Contracts + Driver build green; Driver.TwinCAT.Tests 192 pass, Cli.Tests 70 pass.
This commit is contained in:
Joseph Doherty
2026-07-15 20:11:23 -04:00
parent c379e246d0
commit a53be2af65
23 changed files with 565 additions and 460 deletions
@@ -1,18 +1,30 @@
using System.ComponentModel.DataAnnotations;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// TwinCAT ADS driver configuration. One instance supports N targets (each identified by
/// an AMS Net ID + port). Compiles + runs without a local AMS router but every wire call
/// fails with <c>BadCommunicationError</c> until a router is reachable.
/// fails with <c>BadCommunicationError</c> until a router is reachable. The tags the driver
/// serves are delivered as authored raw tags in <see cref="RawTags"/>; the driver maps each
/// through <see cref="TwinCATTagDefinitionFactory.FromTagConfig"/> to build its
/// RawPath → definition table, routing each to its device by the entry's
/// <see cref="RawTagEntry.DeviceName"/> (multi-device).
/// </summary>
public sealed class TwinCATDriverOptions
{
/// <summary>Gets the list of TwinCAT devices to connect to.</summary>
public IReadOnlyList<TwinCATDeviceOptions> Devices { get; init; } = [];
/// <summary>Gets the list of TwinCAT tag definitions.</summary>
public IReadOnlyList<TwinCATTagDefinition> 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 + owning <see cref="RawTagEntry.DeviceName"/>); the driver maps each
/// through <see cref="TwinCATTagDefinitionFactory.FromTagConfig"/> into its RawPath → definition
/// table and routes it to the matching <see cref="Devices"/> entry by DeviceName.
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>Gets the probe options for TwinCAT connection probing.</summary>
public TwinCATProbeOptions Probe { get; init; } = new();
/// <summary>Gets the default communication timeout.</summary>
@@ -33,8 +45,8 @@ public sealed class TwinCATDriverOptions
/// <summary>
/// When <c>true</c>, <c>DiscoverAsync</c> walks each device's symbol table via the
/// TwinCAT <c>SymbolLoaderFactory</c> (flat mode) + surfaces controller-resident
/// globals / program locals under a <c>Discovered/</c> sub-folder. Pre-declared tags
/// from <see cref="Tags"/> always emit regardless. Default <c>false</c> to preserve
/// globals / program locals under a <c>Discovered/</c> sub-folder. Authored raw tags
/// from <see cref="RawTags"/> always emit regardless. Default <c>false</c> to preserve
/// the strict-config path for deployments where only declared tags should appear.
/// </summary>
public bool EnableControllerBrowse { get; init; }
@@ -1,102 +0,0 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>Parses an equipment tag's <c>TagConfig</c> JSON (the shape authored by the AdminUI
/// <c>TwinCATTagConfigModel</c>) into a transient <see cref="TwinCATTagDefinition"/> whose
/// <see cref="TwinCATTagDefinition.Name"/> equals the reference string itself, so a value the
/// driver publishes back keys the runtime's forward router correctly.</summary>
public static class TwinCATEquipmentTagParser
{
/// <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 a TwinCAT symbol object.</returns>
public static bool TryParse(string reference, out TwinCATTagDefinition 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;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("symbolPath", out var symbol)
|| symbol.ValueKind != JsonValueKind.String)
return false;
var symbolPath = symbol.GetString();
if (string.IsNullOrWhiteSpace(symbolPath)) return false;
var deviceHostAddress = ReadString(root, "deviceHostAddress");
// Strict enum read (R2-11 Phase C): a typo'd dataType rejects the tag (→ BadNodeIdUnknown)
// instead of silently defaulting to a wrong-width Good.
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", TwinCATDataType.DInt, out var dataType)) return false;
// Array intent — same shape the runtime/OPC-UA foundation parses (camelCase
// `isArray` bool + `arrayLength` uint). arrayLength is honoured ONLY when isArray
// is true AND it is a positive JSON number, so a stale length behind a cleared
// isArray never produces an orphan array tag (Phase 4c).
var arrayLength = ReadArrayLength(root);
// "writable" defaults to true when absent (today's value) — makes read-only equipment tags
// authorable (UNDER-6).
var writable = TagConfigJson.ReadWritable(root);
def = new TwinCATTagDefinition(
Name: reference, DeviceHostAddress: deviceHostAddress, SymbolPath: symbolPath,
DataType: dataType, Writable: writable, ArrayLength: arrayLength);
return true;
}
catch (JsonException) { return false; }
catch (FormatException) { return false; }
catch (InvalidOperationException) { return false; }
}
/// <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. Empty when clean
/// or not an equipment-tag object. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("TwinCAT TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
var w = TagConfigJson.DescribeInvalidEnum<TwinCATDataType>(root, "dataType");
if (w is not null) warnings.Add(w);
}
catch (JsonException)
{
warnings.Add("TwinCAT TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
}
return warnings;
}
private static string ReadString(JsonElement o, string name)
=> o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.String
? e.GetString() ?? "" : "";
/// <summary>
/// Reads the optional 1-D array length: <c>arrayLength</c> (a positive int32; values above
/// <see cref="int.MaxValue"/> are treated as absent/scalar) honoured ONLY when <c>isArray</c>
/// is the JSON literal <c>true</c>. Returns <c>null</c> (scalar) when isArray is absent/false,
/// when arrayLength is absent / non-numeric / zero / negative / greater than
/// <see cref="int.MaxValue"/>.
/// </summary>
private static int? ReadArrayLength(JsonElement o)
{
if (!o.TryGetProperty("isArray", out var aEl) || aEl.ValueKind != JsonValueKind.True)
return null;
if (!o.TryGetProperty("arrayLength", out var lEl) || lEl.ValueKind != JsonValueKind.Number)
return null;
return lEl.TryGetInt32(out var len) && len > 0 ? len : null;
}
}
@@ -0,0 +1,156 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// v3 pure mapper: turns an authored raw tag's <c>TagConfig</c> JSON (the shape produced by the
/// AdminUI <c>TwinCATTagConfigModel</c>) into a <see cref="TwinCATTagDefinition"/>. 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="TwinCATTagDefinition.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>
/// <remarks>
/// <b>Device routing is NOT read from the blob under v3.</b> The pre-v3 <c>deviceHostAddress</c> key is
/// dropped: which device a tag lives under is carried on the <see cref="RawTagEntry.DeviceName"/> the
/// deploy artifact populates (this driver is multi-device), threaded onto
/// <see cref="TwinCATTagDefinition.DeviceHostAddress"/> by the driver at table-build time. The mapper
/// therefore always produces a definition with an empty <see cref="TwinCATTagDefinition.DeviceHostAddress"/>.
/// </remarks>
public static class TwinCATTagDefinitionFactory
{
/// <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="TwinCATTagDefinition.WriteIdempotent"/> is NOT
/// read from the blob — it is a platform flag the caller threads in from the tag's
/// <see cref="RawTagEntry.WriteIdempotent"/>; <see cref="TwinCATTagDefinition.DeviceHostAddress"/> is
/// likewise threaded from the tag's <see cref="RawTagEntry.DeviceName"/> (multi-device routing), not
/// the blob.
/// </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 TwinCAT symbol object.</returns>
public static bool FromTagConfig(string tagConfig, string rawPath, out TwinCATTagDefinition def)
{
def = null!;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("symbolPath", out var symbol)
|| symbol.ValueKind != JsonValueKind.String)
return false;
var symbolPath = symbol.GetString();
if (string.IsNullOrWhiteSpace(symbolPath)) return false;
// Strict enum read (R2-11 Phase C): a typo'd dataType rejects the tag (→ BadNodeIdUnknown)
// instead of silently defaulting to a wrong-width Good.
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", TwinCATDataType.DInt, out var dataType)) return false;
// Guard (Driver.TwinCAT-003): the driver does not support UDT/FB-instance tags — a
// Structure-typed tag would read as a garbage int blob or fail late on a write. Reject it
// here so it never materialises (→ BadNodeIdUnknown); declare atomic-typed members instead,
// or use EnableControllerBrowse to discover UDT members individually.
if (dataType == TwinCATDataType.Structure) return false;
// Array intent — same shape the runtime/OPC-UA foundation parses (camelCase
// `isArray` bool + `arrayLength` uint). arrayLength is honoured ONLY when isArray
// is true AND it is a positive JSON number, so a stale length behind a cleared
// isArray never produces an orphan array tag (Phase 4c).
var arrayLength = ReadArrayLength(root);
// "writable" defaults to true when absent (today's value) — makes read-only equipment tags
// authorable (UNDER-6).
var writable = TagConfigJson.ReadWritable(root);
def = new TwinCATTagDefinition(
Name: rawPath, DeviceHostAddress: "", SymbolPath: symbolPath!,
DataType: dataType, Writable: writable, WriteIdempotent: false, ArrayLength: arrayLength);
return true;
}
catch (JsonException) { return false; }
catch (FormatException) { 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 a
/// <see cref="TwinCATTagDefinition"/> 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;
/// the array keys are emitted only for an array tag so the blob stays minimal. Neither
/// <c>WriteIdempotent</c> nor <c>DeviceHostAddress</c> is emitted — they travel on the
/// <see cref="RawTagEntry"/> (<see cref="RawTagEntry.WriteIdempotent"/> /
/// <see cref="RawTagEntry.DeviceName"/>), not inside the blob.
/// </summary>
/// <param name="def">The definition to serialise.</param>
/// <returns>The camelCase TagConfig JSON string.</returns>
public static string ToTagConfig(TwinCATTagDefinition def)
{
ArgumentNullException.ThrowIfNull(def);
var o = new JsonObject
{
["symbolPath"] = def.SymbolPath,
["dataType"] = def.DataType.ToString(),
["writable"] = def.Writable,
};
if (def.ArrayLength is > 0)
{
o["isArray"] = true;
o["arrayLength"] = def.ArrayLength.Value;
}
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. Empty when clean
/// or not an equipment-tag object. Never throws.
/// </summary>
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
/// <returns>The warnings; empty when clean.</returns>
public static IReadOnlyList<string> Inspect(string reference)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(reference) || reference[0] != '{') return warnings;
try
{
using var doc = JsonDocument.Parse(reference);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
warnings.Add("TwinCAT TagConfig root is not a JSON object — the tag will not resolve (BadNodeIdUnknown).");
return warnings;
}
var w = TagConfigJson.DescribeInvalidEnum<TwinCATDataType>(root, "dataType");
if (w is not null) warnings.Add(w);
}
catch (JsonException)
{
warnings.Add("TwinCAT TagConfig is not valid JSON — the tag will not resolve (BadNodeIdUnknown).");
}
return warnings;
}
/// <summary>
/// Reads the optional 1-D array length: <c>arrayLength</c> (a positive int32; values above
/// <see cref="int.MaxValue"/> are treated as absent/scalar) honoured ONLY when <c>isArray</c>
/// is the JSON literal <c>true</c>. Returns <c>null</c> (scalar) when isArray is absent/false,
/// when arrayLength is absent / non-numeric / zero / negative / greater than
/// <see cref="int.MaxValue"/>.
/// </summary>
private static int? ReadArrayLength(JsonElement o)
{
if (!o.TryGetProperty("isArray", out var aEl) || aEl.ValueKind != JsonValueKind.True)
return null;
if (!o.TryGetProperty("arrayLength", out var lEl) || lEl.ValueKind != JsonValueKind.Number)
return null;
return lEl.TryGetInt32(out var len) && len > 0 ? len : null;
}
}