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
@@ -45,7 +45,7 @@ public sealed class BrowseCommand : TwinCATCommandBase
var options = new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Gateway, $"cli-{AmsNetId}:{AmsPort}")],
Tags = [],
RawTags = [],
Timeout = Timeout,
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
@@ -1,4 +1,5 @@
using CliFx.Attributes;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli;
@@ -20,8 +21,13 @@ public abstract class TwinCATTagCommandBase : TwinCATCommandBase
/// <summary>
/// Build a <see cref="TwinCATDriverOptions"/> with the AMS target this base collected +
/// the tag list a subclass supplies. Probe disabled, controller-browse disabled,
/// native notifications toggled by <see cref="PollOnly"/>.
/// the tag list a subclass supplies. The CLI holds typed definitions (synthesised from
/// operator flags), so each is serialised back to a <see cref="RawTagEntry"/> — the v3
/// delivery unit — via <see cref="TwinCATTagDefinitionFactory.ToTagConfig"/>, keyed by the
/// def's synthesised name as its RawPath, with the def's <c>DeviceHostAddress</c> carried as
/// the entry's <see cref="RawTagEntry.DeviceName"/> so the driver routes it to the sole
/// device (matched by host). Probe disabled, controller-browse disabled, native
/// notifications toggled by <see cref="PollOnly"/>.
/// </summary>
/// <param name="tags">Tag definitions for the driver.</param>
/// <returns>The built <see cref="TwinCATDriverOptions"/>.</returns>
@@ -30,7 +36,8 @@ public abstract class TwinCATTagCommandBase : TwinCATCommandBase
Devices = [new TwinCATDeviceOptions(
HostAddress: Gateway,
DeviceName: $"cli-{AmsNetId}:{AmsPort}")],
Tags = tags,
RawTags = [.. tags.Select(t => new RawTagEntry(
t.Name, TwinCATTagDefinitionFactory.ToTagConfig(t), t.WriteIdempotent, t.DeviceHostAddress))],
Timeout = Timeout,
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = !PollOnly,
@@ -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;
}
}
@@ -24,8 +24,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
// (TryGetValue) don't race — plain Dictionary is not safe for concurrent read+write.
private readonly ConcurrentDictionary<string, DeviceState> _devices =
new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, TwinCATTagDefinition> _tagsByName =
new(StringComparer.OrdinalIgnoreCase);
// v3: the authored RawPath → definition table. Keyed case-sensitive ordinal (the RawPath is
// the exact wire reference). Built in InitializeAsync from _options.RawTags via the pure mapper.
private readonly ConcurrentDictionary<string, TwinCATTagDefinition> _tagsByRawPath =
new(StringComparer.Ordinal);
// Per-parent-word RMW gate for BOOL-within-word writes: a single-bit write is a
// read-modify-write of the parent word (TwinCAT's symbol table doesn't expose "Word.N"),
@@ -35,9 +37,9 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
private readonly ConcurrentDictionary<string, SemaphoreSlim> _bitRmwLocks =
new(StringComparer.OrdinalIgnoreCase);
// Resolves a read/write/subscribe fullReference to a tag definition, bridging the two
// authoring models: an authored tag-table entry (by name) OR an equipment tag whose
// reference is its raw TagConfig JSON (parsed once via TwinCATEquipmentTagParser, cached).
// v3: resolves a read/write/subscribe fullReference (always a RawPath) to a tag definition by a
// single hit on the authored _tagsByRawPath table. A miss is a miss — the driver surfaces
// BadNodeIdUnknown (no blob-parse fallback).
private readonly EquipmentTagRefResolver<TwinCATTagDefinition> _resolver;
private DriverHealth _health = new(DriverState.Unknown, null, null);
@@ -64,8 +66,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
_clientFactory = clientFactory ?? new AdsTwinCATClientFactory();
_logger = logger ?? NullLogger<TwinCATDriver>.Instance;
_resolver = new EquipmentTagRefResolver<TwinCATTagDefinition>(
r => _tagsByName.TryGetValue(r, out var t) ? t : null,
r => TwinCATEquipmentTagParser.TryParse(r, out var d) ? d : null);
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
_poll = new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
@@ -108,7 +109,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
if (!string.IsNullOrWhiteSpace(driverConfigJson))
{
var parsed = TwinCATDriverFactoryExtensions.ParseOptions(driverConfigJson, _driverInstanceId);
if (parsed.Devices.Count > 0 || parsed.Tags.Count > 0)
if (parsed.Devices.Count > 0 || parsed.RawTags.Count > 0)
_options = parsed;
}
@@ -119,7 +120,26 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
$"TwinCAT device has invalid HostAddress '{device.HostAddress}' — expected 'ads://{{netId}}:{{port}}'.");
_devices[device.HostAddress] = new DeviceState(addr, device);
}
foreach (var tag in _options.Tags) _tagsByName[tag.Name] = tag;
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
// blob is mapped by the pure factory; the entry's WriteIdempotent flag AND its owning
// DeviceName are threaded onto the def (both live on the RawTagEntry, not inside the blob).
// DeviceName is resolved to the target's live host (def.DeviceHostAddress) via ResolveDeviceHost
// so the existing device routing (_devices keyed by HostAddress) is unchanged. A mapper miss is
// skipped (logged), never thrown — a bad tag must not fail the whole driver init.
foreach (var entry in _options.RawTags)
{
if (TwinCATTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
_tagsByRawPath[entry.RawPath] = def with
{
WriteIdempotent = entry.WriteIdempotent,
DeviceHostAddress = ResolveDeviceHost(entry.DeviceName),
};
else
_logger.LogWarning(
"TwinCAT tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
_driverInstanceId, entry.RawPath);
}
if (_options.Probe.Enabled)
{
@@ -176,8 +196,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
state.DisposeGate();
}
_devices.Clear();
_tagsByName.Clear();
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
_tagsByRawPath.Clear();
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
// Dispose + clear the per-parent-word RMW gates so ReinitializeAsync cycles don't orphan
// their SemaphoreSlim instances (each leaks a wait handle once contended).
foreach (var sem in _bitRmwLocks.Values) sem.Dispose();
@@ -193,7 +213,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
// footprint reflects live allocations only: ~256 bytes per pre-declared tag (tag-definition
// record + dictionary overhead) and ~512 bytes per active native subscription.
public long GetMemoryFootprint() =>
(_tagsByName.Count * 256L) + (_nativeSubs.Count * 512L);
(_tagsByRawPath.Count * 256L) + (_nativeSubs.Count * 512L);
/// <inheritdoc />
// No flushable cache exists in this driver — the symbol table is streamed fresh on every
@@ -394,8 +414,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
var label = device.DeviceName ?? device.HostAddress;
var deviceFolder = root.Folder(device.HostAddress, label);
// Pre-declared tags — always emitted as the authoritative config path.
var tagsForDevice = _options.Tags.Where(t =>
// Authored raw tags (mapped into the RawPath → def table at Initialize) — always emitted
// as the authoritative config path. The def's Name is the RawPath, used for both the browse
// name and the driver FullName; it is routed to this device by def.DeviceHostAddress.
var tagsForDevice = _tagsByRawPath.Values.Where(t =>
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
foreach (var tag in tagsForDevice)
{
@@ -747,6 +769,33 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// </summary>
public const string UnresolvedHostSentinel = "";
/// <summary>
/// Resolve a <see cref="RawTagEntry.DeviceName"/> to the device's live host address
/// (<see cref="TwinCATTagDefinition.DeviceHostAddress"/>) used to route reads/writes/subscriptions
/// to the right connection. <b>TODO(v3 WaveC):</b> the live device host must come from the
/// <c>Device</c> row's <c>DeviceConfig</c> (the deploy artifact will carry it); today we resolve
/// the DeviceName against this driver's own <see cref="TwinCATDriverOptions.Devices"/> list —
/// first by <see cref="TwinCATDeviceOptions.DeviceName"/>, then by
/// <see cref="TwinCATDeviceOptions.HostAddress"/> (callers that route by host, e.g. the Client
/// CLI, pass the host string as the DeviceName). An empty DeviceName routes to the sole device
/// on a single-device instance; an unmatched name leaves the def unrouted (→ BadNodeIdUnknown).
/// </summary>
/// <param name="deviceName">The owning device's name (or host) from the raw-tag entry.</param>
/// <returns>The resolved device host address, or empty when unresolved.</returns>
private string ResolveDeviceHost(string deviceName)
{
var devices = _options.Devices;
if (string.IsNullOrEmpty(deviceName))
return devices.Count == 1 ? devices[0].HostAddress : "";
foreach (var d in devices)
if (string.Equals(d.DeviceName, deviceName, StringComparison.OrdinalIgnoreCase))
return d.HostAddress;
foreach (var d in devices)
if (string.Equals(d.HostAddress, deviceName, StringComparison.OrdinalIgnoreCase))
return d.HostAddress;
return "";
}
/// <summary>
/// Resolve a reference to the device host that keys its per-host resilience
/// (bulkhead / circuit breaker). CONV-4: routes through
@@ -1054,8 +1103,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
state.DisposeGate();
}
_devices.Clear();
_tagsByName.Clear();
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
_tagsByRawPath.Clear();
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
// Dispose + clear the per-parent-word RMW gates (mirrors ShutdownAsync) so the
// SemaphoreSlim instances aren't orphaned on disposal.
foreach (var sem in _bitRmwLocks.Values) sem.Dispose();
@@ -1,4 +1,5 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
@@ -57,9 +58,11 @@ public static class TwinCATDriverFactoryExtensions
$"TwinCAT config for '{driverInstanceId}' has a device missing HostAddress"),
DeviceName: d.DeviceName))]
: [],
Tags = dto.Tags is { Count: > 0 }
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
: [],
// v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw TwinCAT
// tags (RawPath + TagConfig blob + WriteIdempotent + owning DeviceName). The driver maps each
// through TwinCATTagDefinitionFactory at Initialize; the legacy pre-declared DTO tag path
// (tags[] → BuildTag) is retired.
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
Probe = new TwinCATProbeOptions
{
Enabled = dto.Probe?.Enabled ?? true,
@@ -85,43 +88,6 @@ public static class TwinCATDriverFactoryExtensions
public static TwinCATDriverOptions ParseOptionsForTests(string driverConfigJson, string driverInstanceId)
=> ParseOptions(driverConfigJson, driverInstanceId);
private static TwinCATTagDefinition BuildTag(TwinCATTagDto t, string driverInstanceId)
{
var dataType = ParseEnum<TwinCATDataType>(t.DataType, t.Name, driverInstanceId, "DataType");
if (dataType == TwinCATDataType.Structure)
throw new InvalidOperationException(
$"TwinCAT tag '{t.Name ?? "<unnamed>"}' in '{driverInstanceId}' specifies " +
"DataType 'Structure'. The driver does not support UDT/FB-instance pre-declared tags. " +
"Use EnableControllerBrowse to discover UDT members individually, or declare " +
"individual atomic-typed tags for the fields you need.");
return new TwinCATTagDefinition(
Name: t.Name ?? throw new InvalidOperationException(
$"TwinCAT config for '{driverInstanceId}' has a tag missing Name"),
DeviceHostAddress: t.DeviceHostAddress ?? throw new InvalidOperationException(
$"TwinCAT tag '{t.Name}' in '{driverInstanceId}' missing DeviceHostAddress"),
SymbolPath: t.SymbolPath ?? throw new InvalidOperationException(
$"TwinCAT tag '{t.Name}' in '{driverInstanceId}' missing SymbolPath"),
DataType: dataType,
Writable: t.Writable ?? true,
WriteIdempotent: t.WriteIdempotent ?? false,
ArrayLength: t.ArrayLength is > 0 ? t.ArrayLength : null);
}
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field)
where T : struct, Enum
{
if (string.IsNullOrWhiteSpace(raw))
throw new InvalidOperationException(
$"TwinCAT tag '{tagName ?? "<unnamed>"}' in '{driverInstanceId}' missing {field}");
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
? v
: throw new InvalidOperationException(
$"TwinCAT tag '{tagName}' has unknown {field} '{raw}'. " +
$"Expected one of {string.Join(", ", Enum.GetNames<T>())}");
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
@@ -146,8 +112,10 @@ public static class TwinCATDriverFactoryExtensions
/// <summary>Gets or sets the list of configured devices.</summary>
public List<TwinCATDeviceDto>? Devices { get; init; }
/// <summary>Gets or sets the list of configured tags.</summary>
public List<TwinCATTagDto>? Tags { get; init; }
/// <summary>Gets or sets the authored raw tags (RawPath + TagConfig blob + WriteIdempotent +
/// owning DeviceName) the deploy artifact delivers. The driver maps each through the
/// tag-definition factory at Initialize; the legacy pre-declared <c>tags[]</c> path is retired.</summary>
public List<RawTagEntry>? RawTags { get; init; }
/// <summary>Gets or sets the probe configuration.</summary>
public TwinCATProbeDto? Probe { get; init; }
@@ -162,35 +130,6 @@ public static class TwinCATDriverFactoryExtensions
public string? DeviceName { get; init; }
}
internal sealed class TwinCATTagDto
{
/// <summary>Gets or sets the tag name.</summary>
public string? Name { get; init; }
/// <summary>Gets or sets the device host address.</summary>
public string? DeviceHostAddress { get; init; }
/// <summary>Gets or sets the symbol path.</summary>
public string? SymbolPath { get; init; }
/// <summary>Gets or sets the data type.</summary>
public string? DataType { get; init; }
/// <summary>Gets or sets a value indicating whether the tag is writable.</summary>
public bool? Writable { get; init; }
/// <summary>Gets or sets a value indicating whether writes are idempotent.</summary>
public bool? WriteIdempotent { get; init; }
/// <summary>
/// Optional 1-D array element count. When positive, the tag is a 1-D array of this
/// many <see cref="DataType"/> elements — drives <c>IsArray</c>/<c>ArrayDim</c> at
/// discovery and a native ADS array read at runtime (Phase 4c).
/// <c>null</c> or non-positive = scalar (the default).
/// </summary>
public int? ArrayLength { get; init; }
}
internal sealed class TwinCATProbeDto
{
/// <summary>Gets or sets a value indicating whether probing is enabled.</summary>
@@ -99,7 +99,16 @@ public sealed class TwinCATCommandBaseTests
options.Devices.Count.ShouldBe(1);
options.Devices[0].HostAddress.ShouldBe("ads://10.0.0.1.1.1:851");
options.Devices[0].DeviceName.ShouldBe("cli-10.0.0.1.1.1:851");
options.Tags.ShouldBe([tag]);
// v3: the CLI serialises its typed def into a RawTagEntry (RawPath = def.Name, DeviceName =
// def.DeviceHostAddress) that round-trips through the factory back to the same def.
options.RawTags.Count.ShouldBe(1);
var entry = options.RawTags[0];
entry.RawPath.ShouldBe("n1");
entry.DeviceName.ShouldBe(cmd.GatewayForTest);
TwinCAT.TwinCATTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var roundTripped)
.ShouldBeTrue();
roundTripped.SymbolPath.ShouldBe("MAIN.x");
roundTripped.DataType.ShouldBe(TwinCAT.TwinCATDataType.DInt);
options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(4321));
options.Probe.Enabled.ShouldBeFalse();
options.EnableControllerBrowse.ShouldBeFalse();
@@ -27,11 +27,11 @@ public sealed class TwinCATArraySupportTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags =
RawTags = TwinCATRawTags.Entries(
[
new TwinCATTagDefinition("Speeds", Host, "MAIN.Speeds", TwinCATDataType.DInt,
Writable: true, WriteIdempotent: false, ArrayLength: 8),
],
]),
Probe = new TwinCATProbeOptions { Enabled = false },
EnableControllerBrowse = false,
}, "drv-1", new FakeTwinCATClientFactory());
@@ -52,7 +52,7 @@ public sealed class TwinCATArraySupportTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags = [new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)]),
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", new FakeTwinCATClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -104,12 +104,14 @@ public sealed class TwinCATArraySupportTests
// ---- (2) read path: array read returns a typed CLR array ----
/// <summary>An equipment array tag reads a typed CLR array (boxed as object) through the driver.</summary>
private const string ArrayRawPath = "equip/Speeds";
private const string ScalarRawPath = "equip/Speed";
/// <summary>An array raw tag reads a typed CLR array (boxed as object) through the driver.</summary>
[Fact]
public async Task Driver_reads_an_array_equipment_tag_as_typed_clr_array()
public async Task Driver_reads_an_array_raw_tag_as_typed_clr_array()
{
var json =
$$"""{"deviceHostAddress":"{{Host}}","symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":4}""";
var blob = """{"symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":4}""";
var expected = new[] { 10, 20, 30, 40 };
var factory = new FakeTwinCATClientFactory
{
@@ -118,12 +120,12 @@ public sealed class TwinCATArraySupportTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags = [],
RawTags = [new RawTagEntry(ArrayRawPath, blob, WriteIdempotent: false, DeviceName: Host)],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "twincat-arr", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync([json], CancellationToken.None);
var r = await drv.ReadAsync([ArrayRawPath], CancellationToken.None);
r[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
r[0].Value.ShouldBeOfType<int[]>().ShouldBe(expected);
@@ -131,11 +133,11 @@ public sealed class TwinCATArraySupportTests
factory.Clients[0].LastReadArrayCount.ShouldBe(4);
}
/// <summary>A scalar equipment tag reads with a null array count (scalar path unchanged).</summary>
/// <summary>A scalar raw tag reads with a null array count (scalar path unchanged).</summary>
[Fact]
public async Task Driver_reads_a_scalar_equipment_tag_with_null_array_count()
public async Task Driver_reads_a_scalar_raw_tag_with_null_array_count()
{
var json = $$"""{"deviceHostAddress":"{{Host}}","symbolPath":"MAIN.Speed","dataType":"DInt"}""";
var blob = """{"symbolPath":"MAIN.Speed","dataType":"DInt"}""";
var factory = new FakeTwinCATClientFactory
{
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Speed"] = 7 } },
@@ -143,100 +145,83 @@ public sealed class TwinCATArraySupportTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags = [],
RawTags = [new RawTagEntry(ScalarRawPath, blob, WriteIdempotent: false, DeviceName: Host)],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "twincat-scalar", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync([json], CancellationToken.None);
var r = await drv.ReadAsync([ScalarRawPath], CancellationToken.None);
r[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
r[0].Value.ShouldBe(7);
factory.Clients[0].LastReadArrayCount.ShouldBeNull();
}
// ---- (3) resolver: equipment-tag parser threads arrayLength ----
// ---- (3) mapper: FromTagConfig threads arrayLength ----
/// <summary>The equipment-tag parser threads <c>arrayLength</c> into the transient definition.</summary>
/// <summary>The tag-definition factory threads <c>arrayLength</c> into the definition.</summary>
[Fact]
public void Parser_threads_arrayLength_into_the_definition()
public void FromTagConfig_threads_arrayLength_into_the_definition()
{
var json =
"""{"deviceHostAddress":"ads://5.23.91.23.1.1:851","symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":12}""";
TwinCATEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
var blob = """{"symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":12}""";
TwinCATTagDefinitionFactory.FromTagConfig(blob, ArrayRawPath, out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBe(12);
}
/// <summary>A blob without isArray (or with isArray=false) leaves ArrayLength null.</summary>
[Fact]
public void Parser_leaves_ArrayLength_null_for_a_scalar_blob()
public void FromTagConfig_leaves_ArrayLength_null_for_a_scalar_blob()
{
TwinCATEquipmentTagParser.TryParse(
"""{"symbolPath":"MAIN.X","dataType":"DInt"}""", out var def).ShouldBeTrue();
TwinCATTagDefinitionFactory.FromTagConfig(
"""{"symbolPath":"MAIN.X","dataType":"DInt"}""", ScalarRawPath, out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBeNull();
}
/// <summary>arrayLength is ignored when isArray is absent/false (no orphan length).</summary>
[Fact]
public void Parser_ignores_arrayLength_when_isArray_is_false()
public void FromTagConfig_ignores_arrayLength_when_isArray_is_false()
{
TwinCATEquipmentTagParser.TryParse(
TwinCATTagDefinitionFactory.FromTagConfig(
"""{"symbolPath":"MAIN.X","dataType":"DInt","isArray":false,"arrayLength":9}""",
out var def).ShouldBeTrue();
ScalarRawPath, out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBeNull();
}
// ---- (4) Driver.TwinCAT-017 — pre-declared array tag parseable via driver config JSON ----
// ---- (4) Driver.TwinCAT-017 — array raw tag delivered via driver config JSON rawTags ----
/// <summary>
/// A pre-declared tag in the driver config JSON with <c>arrayLength</c> must produce a
/// An array raw tag in the driver config JSON <c>rawTags</c> array must bind + map to a
/// <see cref="TwinCATTagDefinition"/> with the correct <see cref="TwinCATTagDefinition.ArrayLength"/>,
/// which in turn drives <c>IsArray</c>/<c>ArrayDim</c> at discovery and a native array read
/// at runtime (Driver.TwinCAT-017).
/// </summary>
[Fact]
public void Predeclared_array_tag_arrayLength_parses_from_driver_config_json()
public void Array_raw_tag_arrayLength_binds_from_driver_config_json()
{
var blob = """{"symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":8}""";
var json = System.Text.Json.JsonSerializer.Serialize(new
{
devices = new[] { new { hostAddress = Host } },
tags = new[]
{
new
{
name = "Speeds",
deviceHostAddress = Host,
symbolPath = "MAIN.Speeds",
dataType = "DInt",
arrayLength = 8,
},
},
rawTags = new[] { new { rawPath = "Speeds", tagConfig = blob, writeIdempotent = false, deviceName = Host } },
});
var parsed = TwinCATDriverFactoryExtensions.ParseOptionsForTests(json, "drv-1");
parsed.Tags.Single().ArrayLength.ShouldBe(8);
var entry = parsed.RawTags.Single();
TwinCATTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBe(8);
}
/// <summary>
/// A pre-declared array tag parsed from JSON must surface as <c>IsArray=true</c> /
/// An array raw tag delivered via the driver config JSON must surface as <c>IsArray=true</c> /
/// <c>ArrayDim=8</c> in the discovery address space.
/// </summary>
[Fact]
public async Task Predeclared_array_tag_from_json_reports_IsArray_in_discovery()
public async Task Array_raw_tag_from_json_reports_IsArray_in_discovery()
{
var blob = """{"symbolPath":"MAIN.Speeds","dataType":"DInt","isArray":true,"arrayLength":8}""";
var configJson = System.Text.Json.JsonSerializer.Serialize(new
{
devices = new[] { new { hostAddress = Host } },
tags = new[]
{
new
{
name = "Speeds",
deviceHostAddress = Host,
symbolPath = "MAIN.Speeds",
dataType = "DInt",
arrayLength = 8,
},
},
rawTags = new[] { new { rawPath = "Speeds", tagConfig = blob, writeIdempotent = false, deviceName = Host } },
});
var builder = new RecordingBuilder();
var drv = new TwinCATDriver(new TwinCATDriverOptions(), "drv-1", new FakeTwinCATClientFactory());
@@ -19,11 +19,11 @@ public sealed class TwinCATCapabilityTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851", DeviceName: "Mach1")],
Tags =
RawTags = TwinCATRawTags.Entries(
[
new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.DInt),
new TwinCATTagDefinition("Status", "ads://5.23.91.23.1.1:851", "GVL.Status", TwinCATDataType.Bool, Writable: false),
],
]),
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -49,7 +49,7 @@ public sealed class TwinCATCapabilityTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)]),
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = false, // poll-mode test
}, "drv-1", factory);
@@ -76,7 +76,7 @@ public sealed class TwinCATCapabilityTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)]),
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = false, // poll-mode test
}, "drv-1", factory);
@@ -198,11 +198,11 @@ public sealed class TwinCATCapabilityTests
new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851"),
new TwinCATDeviceOptions("ads://5.23.91.24.1.1:851"),
],
Tags =
RawTags = TwinCATRawTags.Entries(
[
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", "ads://5.23.91.24.1.1:851", "MAIN.B", TwinCATDataType.DInt),
],
]),
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -20,7 +20,7 @@ public sealed class TwinCATConnectBackoffTests
private static TwinCATDriverOptions Options(bool probeEnabled) => new()
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags = [new TwinCATTagDefinition("T", Host, "MAIN.T", TwinCATDataType.DInt)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("T", Host, "MAIN.T", TwinCATDataType.DInt)]),
Probe = new TwinCATProbeOptions
{
Enabled = probeEnabled,
@@ -1,57 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>R2-11 Phase C strictness surface for the TwinCAT 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 TwinCATEquipmentTagParserStrictnessTests
{
[Fact]
public void Typo_dataType_rejects_the_tag()
{
TwinCATEquipmentTagParser.TryParse("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DIntt\"}", out _)
.ShouldBeFalse();
}
[Fact]
public void Valid_dataType_still_parses()
{
TwinCATEquipmentTagParser.TryParse("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DInt\"}", out var def)
.ShouldBeTrue();
def.DataType.ShouldBe(TwinCATDataType.DInt);
}
[Fact]
public void Inspect_reports_typo_dataType()
{
var warnings = TwinCATEquipmentTagParser.Inspect("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DIntt\"}");
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("DIntt");
warnings[0].ShouldContain("dataType");
}
[Fact]
public void Inspect_clean_config_has_no_warnings()
{
TwinCATEquipmentTagParser.Inspect("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DInt\"}").ShouldBeEmpty();
}
[Fact]
public void Writable_false_is_honoured()
{
TwinCATEquipmentTagParser.TryParse("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DInt\",\"writable\":false}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_absent_defaults_to_true()
{
TwinCATEquipmentTagParser.TryParse("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DInt\"}", out var def)
.ShouldBeTrue();
def.Writable.ShouldBeTrue();
}
}
@@ -1,81 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
[Trait("Category", "Unit")]
public class TwinCATEquipmentTagTests
{
[Fact]
public void Parses_equipment_tagconfig_into_a_transient_definition()
{
var json = """{"deviceHostAddress":"ads://5.23.91.23.1.1:851","symbolPath":"MAIN.Speed","dataType":"DInt"}""";
TwinCATEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Name.ShouldBe(json);
def.SymbolPath.ShouldBe("MAIN.Speed");
def.DeviceHostAddress.ShouldBe("ads://5.23.91.23.1.1:851");
def.DataType.ShouldBe(TwinCATDataType.DInt);
def.Writable.ShouldBeTrue();
}
[Fact]
public void Defaults_optional_fields_when_only_symbol_path_is_present()
{
TwinCATEquipmentTagParser.TryParse("""{"symbolPath":"GVL.X"}""", out var def).ShouldBeTrue();
def!.SymbolPath.ShouldBe("GVL.X");
def.DeviceHostAddress.ShouldBe(""); // absent host → empty
def.DataType.ShouldBe(TwinCATDataType.DInt); // enum default
}
[Fact]
public void Rejects_a_non_address_blob()
=> TwinCATEquipmentTagParser.TryParse("""{"FullName":"x"}""", out _).ShouldBeFalse();
[Fact]
public void Rejects_garbage()
=> TwinCATEquipmentTagParser.TryParse("not json", out _).ShouldBeFalse();
[Fact]
public void Rejects_blank_reference()
=> TwinCATEquipmentTagParser.TryParse(" ", out _).ShouldBeFalse();
[Fact]
public void Rejects_symbol_path_as_a_non_string()
=> TwinCATEquipmentTagParser.TryParse("""{"symbolPath":42}""", out _).ShouldBeFalse();
[Fact]
public void Ignores_a_non_string_data_type_and_falls_back_to_default()
{
// dataType as a number is not a String enum — the guard ignores it and keeps the default.
TwinCATEquipmentTagParser.TryParse("""{"symbolPath":"MAIN.X","dataType":3}""", out var def).ShouldBeTrue();
def!.DataType.ShouldBe(TwinCATDataType.DInt);
}
/// <summary>
/// End-to-end driver-level proof: a TwinCAT driver with NO authored tags can still read an
/// equipment-tag ref (the raw TagConfig JSON) — the resolver parses it into a transient
/// definition (with a deviceHostAddress matching a configured device) and the read reaches
/// the fake client instead of returning BadNodeIdUnknown.
/// </summary>
[Fact]
public async Task Driver_resolves_an_equipment_ref_and_reads_instead_of_BadNodeIdUnknown()
{
var host = "ads://5.23.91.23.1.1:851";
var json = $$"""{"deviceHostAddress":"{{host}}","symbolPath":"MAIN.Speed","dataType":"DInt"}""";
var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Speed"] = 4242 } } };
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(host)],
Tags = [], // no authored tags — resolution must come from the equipment-ref parser
Probe = new TwinCATProbeOptions { Enabled = false },
}, "twincat-eq", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync([json], CancellationToken.None);
r[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
r[0].StatusCode.ShouldNotBe(TwinCATStatusMapper.BadNodeIdUnknown);
r[0].Value.ShouldBe(4242);
}
}
@@ -90,7 +90,7 @@ public sealed class TwinCATHighFindingsRegressionTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(DeviceA)],
Tags = [new TwinCATTagDefinition("Big", DeviceA, "GVL.Big", TwinCATDataType.LInt)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("Big", DeviceA, "GVL.Big", TwinCATDataType.LInt)]),
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -114,7 +114,7 @@ public sealed class TwinCATHighFindingsRegressionTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(DeviceA)],
Tags = [new TwinCATTagDefinition("X", DeviceA, "GVL.X", TwinCATDataType.DInt)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("X", DeviceA, "GVL.X", TwinCATDataType.DInt)]),
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -137,7 +137,7 @@ public sealed class TwinCATHighFindingsRegressionTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(DeviceA)],
Tags = [new TwinCATTagDefinition("X", DeviceA, "GVL.X", TwinCATDataType.DInt)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("X", DeviceA, "GVL.X", TwinCATDataType.DInt)]),
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -173,7 +173,7 @@ public sealed class TwinCATHighFindingsRegressionTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(DeviceA)],
Tags = [new TwinCATTagDefinition("X", DeviceA, "GVL.X", TwinCATDataType.DInt)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("X", DeviceA, "GVL.X", TwinCATDataType.DInt)]),
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -182,30 +182,15 @@ public sealed class TwinCATLowFindingsRegressionTests
// ---- Driver.TwinCAT-016 — gap-fill tests for previously closed findings ----
/// <summary>Verifies that structure-typed pre-declared tags are rejected at configuration parse time.</summary>
/// <summary>Verifies that a Structure-typed raw tag is rejected by the tag-definition factory.</summary>
[Fact]
public void Structure_typed_pre_declared_tag_is_rejected_at_config_parse()
public void Structure_typed_raw_tag_is_rejected_by_the_factory()
{
// Driver.TwinCAT-003 — config-time rejection. A Structure tag must fail loudly with a
// clear error rather than reading as a garbage int blob or failing late on a write.
var json = JsonSerializer.Serialize(new
{
devices = new[] { new { hostAddress = DeviceA } },
tags = new[]
{
new
{
name = "Udt1",
deviceHostAddress = DeviceA,
symbolPath = "MAIN.fbInstance",
dataType = "Structure",
},
},
});
var ex = Should.Throw<InvalidOperationException>(() =>
TwinCATDriverFactoryExtensions.ParseOptionsForTests(json, "drv-1"));
ex.Message.ShouldContain("Structure");
ex.Message.ShouldContain("Udt1");
// Driver.TwinCAT-003 — v3 the mapper is the sole tag path. A Structure tag must NOT map to a
// definition (returns false ⇒ the driver skips it ⇒ BadNodeIdUnknown) rather than reading as a
// garbage int blob or failing late on a write.
var blob = """{"symbolPath":"MAIN.fbInstance","dataType":"Structure"}""";
TwinCATTagDefinitionFactory.FromTagConfig(blob, "equip/Udt1", out _).ShouldBeFalse();
}
/// <summary>Verifies that the probe loop and read operations share one client per device.</summary>
@@ -223,7 +208,7 @@ public sealed class TwinCATLowFindingsRegressionTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(DeviceA)],
Tags = [new TwinCATTagDefinition("X", DeviceA, "GVL.X", TwinCATDataType.DInt)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("X", DeviceA, "GVL.X", TwinCATDataType.DInt)]),
Probe = new TwinCATProbeOptions
{
Enabled = true,
@@ -15,7 +15,7 @@ public sealed class TwinCATNativeNotificationTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags = tags,
RawTags = TwinCATRawTags.Entries(tags),
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
}, "drv-1", factory);
@@ -110,11 +110,11 @@ public sealed class TwinCATNativeNotificationTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags =
RawTags = TwinCATRawTags.Entries(
[
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.DInt),
],
]),
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
}, "drv-1", factory);
@@ -182,7 +182,7 @@ public sealed class TwinCATNativeNotificationTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)]),
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = false,
}, "drv-1", factory);
@@ -216,7 +216,7 @@ public sealed class TwinCATNativeNotificationTests
var drvPoll = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)]),
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = false,
}, "drv-1", factoryPoll);
@@ -0,0 +1,32 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// Test helper bridging the v2 authoring shape (a typed <see cref="TwinCATTagDefinition"/>) 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 + owning
/// DeviceName), and the driver rebuilds the typed definitions via <see cref="TwinCATTagDefinitionFactory"/>.
/// These tests keep expressing their intent as typed definitions; this helper serialises each back to its
/// TagConfig blob (via <see cref="TwinCATTagDefinitionFactory.ToTagConfig"/>) and packages it as a
/// <see cref="RawTagEntry"/> whose RawPath is the def's <c>Name</c> and whose
/// <see cref="RawTagEntry.DeviceName"/> is the def's <c>DeviceHostAddress</c> — so the round-trip through
/// the real mapper + the driver's device resolution (which matches DeviceName against the configured
/// devices by host) reproduces the same definition + device routing the test authored, keyed by the same
/// name the read/write/subscribe call sites use.
/// </summary>
internal static class TwinCATRawTags
{
/// <summary>Serialises one typed definition into its <see cref="RawTagEntry"/>
/// (RawPath = def.Name, DeviceName = def.DeviceHostAddress).</summary>
/// <param name="def">The typed definition to convert.</param>
/// <returns>The equivalent <see cref="RawTagEntry"/>.</returns>
public static RawTagEntry Entry(TwinCATTagDefinition def)
=> new(def.Name, TwinCATTagDefinitionFactory.ToTagConfig(def), def.WriteIdempotent, def.DeviceHostAddress);
/// <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(IEnumerable<TwinCATTagDefinition> defs)
=> [.. defs.Select(Entry)];
}
@@ -14,7 +14,7 @@ public sealed class TwinCATReadWriteTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags = tags,
RawTags = TwinCATRawTags.Entries(tags),
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", factory);
return (drv, factory);
@@ -213,11 +213,11 @@ public sealed class TwinCATReadWriteTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags =
RawTags = TwinCATRawTags.Entries(
[
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.DInt, Writable: false),
],
]),
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -24,7 +24,7 @@ public sealed class TwinCATReconnectReplayTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags = tags,
RawTags = TwinCATRawTags.Entries(tags),
Probe = new TwinCATProbeOptions
{
Enabled = probe,
@@ -151,7 +151,7 @@ public sealed class TwinCATReconnectReplayTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags = [new TwinCATTagDefinition("X", Host, "MAIN.X", TwinCATDataType.DInt)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("X", Host, "MAIN.X", TwinCATDataType.DInt)]),
Probe = new TwinCATProbeOptions
{
Enabled = true,
@@ -194,7 +194,7 @@ public sealed class TwinCATReconnectReplayTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags = [new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)]),
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
}, "drv-1", factory);
@@ -240,7 +240,7 @@ public sealed class TwinCATReconnectReplayTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags = [new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)]),
Probe = new TwinCATProbeOptions
{
Enabled = true,
@@ -302,7 +302,7 @@ public sealed class TwinCATReconnectReplayTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
Tags = [new TwinCATTagDefinition("X", Host, "MAIN.X", TwinCATDataType.DInt)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("X", Host, "MAIN.X", TwinCATDataType.DInt)]),
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
}, "drv-1", factory);
@@ -1,13 +1,16 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// CONV-4 — <see cref="TwinCATDriver.ResolveHost"/> must resolve an equipment-tag reference
/// (raw TagConfig JSON) to its OWN device host so per-host breaker keys are correct for
/// equipment tags, not the first-device fallback.
/// CONV-4 — <see cref="TwinCATDriver.ResolveHost"/> must resolve an authored raw tag to its OWN
/// device host so per-host breaker keys are correct for equipment tags, not the first-device
/// fallback. Under v3 the tag is delivered as a <see cref="RawTagEntry"/> whose
/// <see cref="RawTagEntry.DeviceName"/> names the owning device; the driver threads that onto the
/// def's host at Initialize, and ResolveHost reads it back by RawPath.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATResolveHostTests
@@ -15,29 +18,35 @@ public sealed class TwinCATResolveHostTests
private const string DeviceA = "ads://5.23.91.23.1.1:851";
private const string DeviceB = "ads://5.23.91.23.1.2:851";
private static TwinCATDriver NewDriver() => new(
new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(DeviceA), new TwinCATDeviceOptions(DeviceB)],
Probe = new TwinCATProbeOptions { Enabled = false },
EnableControllerBrowse = false,
},
"twincat-1", new FakeTwinCATClientFactory());
/// <summary>An equipment-tag ref naming device B resolves to device B's host, not the first device.</summary>
[Fact]
public void ResolveHost_EquipmentTagRef_ReturnsItsOwnDeviceHost()
private static async Task<TwinCATDriver> NewDriverAsync(params RawTagEntry[] rawTags)
{
var drv = NewDriver();
var json = $$"""{"deviceHostAddress":"{{DeviceB}}","symbolPath":"MAIN.Speed","dataType":"DInt"}""";
drv.ResolveHost(json).ShouldBe(DeviceB);
var drv = new TwinCATDriver(
new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(DeviceA), new TwinCATDeviceOptions(DeviceB)],
RawTags = rawTags,
Probe = new TwinCATProbeOptions { Enabled = false },
EnableControllerBrowse = false,
},
"twincat-1", new FakeTwinCATClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
return drv;
}
/// <summary>A raw tag naming device B resolves to device B's host, not the first device.</summary>
[Fact]
public async Task ResolveHost_RawTagOnDeviceB_ReturnsItsOwnDeviceHost()
{
var blob = """{"symbolPath":"MAIN.Speed","dataType":"DInt"}""";
var drv = await NewDriverAsync(new RawTagEntry("equip/Speed", blob, WriteIdempotent: false, DeviceName: DeviceB));
drv.ResolveHost("equip/Speed").ShouldBe(DeviceB);
}
/// <summary>An unresolvable ref keeps the current first-device fallback.</summary>
[Fact]
public void ResolveHost_UnknownRef_KeepsCurrentFallback()
public async Task ResolveHost_UnknownRef_KeepsCurrentFallback()
{
var drv = NewDriver();
var drv = await NewDriverAsync();
drv.ResolveHost("totally-unknown").ShouldBe(DeviceA);
}
}
@@ -25,7 +25,7 @@ public sealed class TwinCATSymbolBrowserTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags = [new TwinCATTagDefinition("Declared", "ads://5.23.91.23.1.1:851", "MAIN.Declared", TwinCATDataType.DInt)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("Declared", "ads://5.23.91.23.1.1:851", "MAIN.Declared", TwinCATDataType.DInt)]),
Probe = new TwinCATProbeOptions { Enabled = false },
EnableControllerBrowse = false,
}, "drv-1", factory);
@@ -165,7 +165,7 @@ public sealed class TwinCATSymbolBrowserTests
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags = [new TwinCATTagDefinition("Declared", "ads://5.23.91.23.1.1:851", "MAIN.Declared", TwinCATDataType.DInt)],
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("Declared", "ads://5.23.91.23.1.1:851", "MAIN.Declared", TwinCATDataType.DInt)]),
Probe = new TwinCATProbeOptions { Enabled = false },
EnableControllerBrowse = true,
}, "drv-1", factory);
@@ -0,0 +1,60 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>R2-11 Phase C strictness surface for the TwinCAT tag-definition factory: the runtime is now 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>
[Trait("Category", "Unit")]
public sealed class TwinCATTagDefinitionFactoryStrictnessTests
{
private const string RawPath = "area/line/equip/Tag";
[Fact]
public void Typo_dataType_rejects_the_tag()
{
TwinCATTagDefinitionFactory.FromTagConfig("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DIntt\"}", RawPath, out _)
.ShouldBeFalse();
}
[Fact]
public void Valid_dataType_still_parses()
{
TwinCATTagDefinitionFactory.FromTagConfig("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DInt\"}", RawPath, out var def)
.ShouldBeTrue();
def.DataType.ShouldBe(TwinCATDataType.DInt);
}
[Fact]
public void Inspect_reports_typo_dataType()
{
var warnings = TwinCATTagDefinitionFactory.Inspect("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DIntt\"}");
warnings.ShouldHaveSingleItem();
warnings[0].ShouldContain("DIntt");
warnings[0].ShouldContain("dataType");
}
[Fact]
public void Inspect_clean_config_has_no_warnings()
{
TwinCATTagDefinitionFactory.Inspect("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DInt\"}").ShouldBeEmpty();
}
[Fact]
public void Writable_false_is_honoured()
{
TwinCATTagDefinitionFactory.FromTagConfig("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DInt\",\"writable\":false}", RawPath, out var def)
.ShouldBeTrue();
def.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_absent_defaults_to_true()
{
TwinCATTagDefinitionFactory.FromTagConfig("{\"symbolPath\":\"MAIN.x\",\"dataType\":\"DInt\"}", RawPath, out var def)
.ShouldBeTrue();
def.Writable.ShouldBeTrue();
}
}
@@ -0,0 +1,102 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
[Trait("Category", "Unit")]
public class TwinCATTagDefinitionFactoryTests
{
private const string RawPath = "area/line/equip/Speed";
[Fact]
public void Maps_tagconfig_into_a_definition_keyed_by_rawpath()
{
// v3: deviceHostAddress is NOT read from the blob (dropped); routing comes from the RawTagEntry.
var json = """{"symbolPath":"MAIN.Speed","dataType":"DInt"}""";
TwinCATTagDefinitionFactory.FromTagConfig(json, RawPath, out var def).ShouldBeTrue();
def!.Name.ShouldBe(RawPath); // identity is the RawPath, not the blob
def.SymbolPath.ShouldBe("MAIN.Speed");
def.DeviceHostAddress.ShouldBe(""); // mapper never reads a device from the blob
def.DataType.ShouldBe(TwinCATDataType.DInt);
def.Writable.ShouldBeTrue();
}
[Fact]
public void Defaults_optional_fields_when_only_symbol_path_is_present()
{
TwinCATTagDefinitionFactory.FromTagConfig("""{"symbolPath":"GVL.X"}""", RawPath, out var def).ShouldBeTrue();
def!.SymbolPath.ShouldBe("GVL.X");
def.DeviceHostAddress.ShouldBe("");
def.DataType.ShouldBe(TwinCATDataType.DInt); // enum default
}
[Fact]
public void Rejects_a_non_address_blob()
=> TwinCATTagDefinitionFactory.FromTagConfig("""{"FullName":"x"}""", RawPath, out _).ShouldBeFalse();
[Fact]
public void Rejects_garbage()
=> TwinCATTagDefinitionFactory.FromTagConfig("not json", RawPath, out _).ShouldBeFalse();
[Fact]
public void Rejects_blank_tagconfig()
=> TwinCATTagDefinitionFactory.FromTagConfig(" ", RawPath, out _).ShouldBeFalse();
[Fact]
public void Rejects_symbol_path_as_a_non_string()
=> TwinCATTagDefinitionFactory.FromTagConfig("""{"symbolPath":42}""", RawPath, out _).ShouldBeFalse();
[Fact]
public void Ignores_a_non_string_data_type_and_falls_back_to_default()
{
// dataType as a number is not a String enum — the guard ignores it and keeps the default.
TwinCATTagDefinitionFactory.FromTagConfig("""{"symbolPath":"MAIN.X","dataType":3}""", RawPath, out var def).ShouldBeTrue();
def!.DataType.ShouldBe(TwinCATDataType.DInt);
}
[Fact]
public void ToTagConfig_round_trips_through_FromTagConfig()
{
var original = new TwinCATTagDefinition(
Name: RawPath, DeviceHostAddress: "ads://5.23.91.23.1.1:851", SymbolPath: "MAIN.Speeds",
DataType: TwinCATDataType.Real, Writable: false, ArrayLength: 4);
var blob = TwinCATTagDefinitionFactory.ToTagConfig(original);
TwinCATTagDefinitionFactory.FromTagConfig(blob, RawPath, out var def).ShouldBeTrue();
def!.SymbolPath.ShouldBe("MAIN.Speeds");
def.DataType.ShouldBe(TwinCATDataType.Real);
def.Writable.ShouldBeFalse();
def.ArrayLength.ShouldBe(4);
// Device + WriteIdempotent travel on the RawTagEntry, not the blob → mapper leaves them defaulted.
def.DeviceHostAddress.ShouldBe("");
def.WriteIdempotent.ShouldBeFalse();
}
/// <summary>
/// End-to-end driver-level proof (v3 delivery): a TwinCAT driver served an authored raw tag
/// (RawPath + TagConfig blob + owning DeviceName) resolves it by RawPath and routes it to the
/// matching device — the read reaches the fake client instead of returning BadNodeIdUnknown.
/// </summary>
[Fact]
public async Task Driver_resolves_a_raw_tag_and_reads_instead_of_BadNodeIdUnknown()
{
var host = "ads://5.23.91.23.1.1:851";
var blob = """{"symbolPath":"MAIN.Speed","dataType":"DInt"}""";
var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Speed"] = 4242 } } };
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(host)],
RawTags = [new RawTagEntry(RawPath, blob, WriteIdempotent: false, DeviceName: host)],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "twincat-eq", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var r = await drv.ReadAsync([RawPath], CancellationToken.None);
r[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
r[0].StatusCode.ShouldNotBe(TwinCATStatusMapper.BadNodeIdUnknown);
r[0].Value.ShouldBe(4242);
}
}