diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/BrowseCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/BrowseCommand.cs
index 8eb29b04..cfc8a36b 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/BrowseCommand.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/BrowseCommand.cs
@@ -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,
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/TwinCATTagCommandBase.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/TwinCATTagCommandBase.cs
index c7adb225..a962c7e8 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/TwinCATTagCommandBase.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/TwinCATTagCommandBase.cs
@@ -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
///
/// Build a with the AMS target this base collected +
- /// the tag list a subclass supplies. Probe disabled, controller-browse disabled,
- /// native notifications toggled by .
+ /// the tag list a subclass supplies. The CLI holds typed definitions (synthesised from
+ /// operator flags), so each is serialised back to a — the v3
+ /// delivery unit — via , keyed by the
+ /// def's synthesised name as its RawPath, with the def's DeviceHostAddress carried as
+ /// the entry's so the driver routes it to the sole
+ /// device (matched by host). Probe disabled, controller-browse disabled, native
+ /// notifications toggled by .
///
/// Tag definitions for the driver.
/// The built .
@@ -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,
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATDriverOptions.cs
index ec6a6a37..b37430e1 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATDriverOptions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATDriverOptions.cs
@@ -1,18 +1,30 @@
using System.ComponentModel.DataAnnotations;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
///
/// 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 BadCommunicationError until a router is reachable.
+/// fails with BadCommunicationError until a router is reachable. The tags the driver
+/// serves are delivered as authored raw tags in ; the driver maps each
+/// through to build its
+/// RawPath → definition table, routing each to its device by the entry's
+/// (multi-device).
///
public sealed class TwinCATDriverOptions
{
/// Gets the list of TwinCAT devices to connect to.
public IReadOnlyList Devices { get; init; } = [];
- /// Gets the list of TwinCAT tag definitions.
- public IReadOnlyList Tags { get; init; } = [];
+
+ ///
+ /// Authored raw tags this driver serves. The deploy artifact hands each authored raw
+ /// Tag as a (RawPath identity + driver TagConfig blob +
+ /// WriteIdempotent flag + owning ); the driver maps each
+ /// through into its RawPath → definition
+ /// table and routes it to the matching entry by DeviceName.
+ ///
+ public IReadOnlyList RawTags { get; init; } = [];
/// Gets the probe options for TwinCAT connection probing.
public TwinCATProbeOptions Probe { get; init; } = new();
/// Gets the default communication timeout.
@@ -33,8 +45,8 @@ public sealed class TwinCATDriverOptions
///
/// When true, DiscoverAsync walks each device's symbol table via the
/// TwinCAT SymbolLoaderFactory (flat mode) + surfaces controller-resident
- /// globals / program locals under a Discovered/ sub-folder. Pre-declared tags
- /// from always emit regardless. Default false to preserve
+ /// globals / program locals under a Discovered/ sub-folder. Authored raw tags
+ /// from always emit regardless. Default false to preserve
/// the strict-config path for deployments where only declared tags should appear.
///
public bool EnableControllerBrowse { get; init; }
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATEquipmentTagParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATEquipmentTagParser.cs
deleted file mode 100644
index af378af3..00000000
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATEquipmentTagParser.cs
+++ /dev/null
@@ -1,102 +0,0 @@
-using System.Text.Json;
-using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
-
-/// Parses an equipment tag's TagConfig JSON (the shape authored by the AdminUI
-/// TwinCATTagConfigModel) into a transient whose
-/// equals the reference string itself, so a value the
-/// driver publishes back keys the runtime's forward router correctly.
-public static class TwinCATEquipmentTagParser
-{
- /// Attempts to parse an equipment-tag reference into a transient definition.
- /// The equipment tag's TagConfig JSON (also used as the def identity).
- /// The transient definition when parsing succeeds.
- /// when is a TwinCAT symbol object.
- 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; }
- }
-
- ///
- /// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid dataType (silently
- /// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean
- /// or not an equipment-tag object. Never throws.
- ///
- /// The equipment tag's TagConfig JSON.
- /// The warnings; empty when clean.
- public static IReadOnlyList Inspect(string reference)
- {
- var warnings = new List();
- 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(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() ?? "" : "";
-
- ///
- /// Reads the optional 1-D array length: arrayLength (a positive int32; values above
- /// are treated as absent/scalar) honoured ONLY when isArray
- /// is the JSON literal true. Returns null (scalar) when isArray is absent/false,
- /// when arrayLength is absent / non-numeric / zero / negative / greater than
- /// .
- ///
- 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;
- }
-}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATTagDefinitionFactory.cs
new file mode 100644
index 00000000..92aca8c7
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/TwinCATTagDefinitionFactory.cs
@@ -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;
+
+///
+/// v3 pure mapper: turns an authored raw tag's TagConfig JSON (the shape produced by the
+/// AdminUI TwinCATTagConfigModel) into a . Under v3 a tag's
+/// identity is its RawPath (a cluster-scoped slash path), not the address blob — so the
+/// produced definition's is the RawPath the driver was handed,
+/// which is exactly the wire reference the driver's RawPath → def resolver keys on. The driver
+/// builds that table by mapping each the deploy artifact delivers through
+/// ; is the inverse, used by authoring paths (the
+/// Client CLI) that hold a typed definition and need to synthesise the TagConfig blob for a
+/// .
+///
+///
+/// Device routing is NOT read from the blob under v3. The pre-v3 deviceHostAddress key is
+/// dropped: which device a tag lives under is carried on the the
+/// deploy artifact populates (this driver is multi-device), threaded onto
+/// by the driver at table-build time. The mapper
+/// therefore always produces a definition with an empty .
+///
+public static class TwinCATTagDefinitionFactory
+{
+ ///
+ /// Maps an authored TagConfig object to a typed definition keyed by .
+ /// The input is always an authored TagConfig JSON object (there is no name-vs-blob heuristic in v3).
+ /// The dataType enum is read STRICTLY — a present-but-invalid (typo'd) value rejects the whole
+ /// tag (returns ⇒ the driver surfaces BadNodeIdUnknown) rather than
+ /// silently defaulting to a wrong-width Good. is NOT
+ /// read from the blob — it is a platform flag the caller threads in from the tag's
+ /// ; is
+ /// likewise threaded from the tag's (multi-device routing), not
+ /// the blob.
+ ///
+ /// The authored equipment-tag TagConfig JSON.
+ /// The tag's RawPath — becomes the definition's identity (Name).
+ /// The mapped definition when this returns .
+ /// when is a valid TwinCAT symbol object.
+ 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; }
+ }
+
+ ///
+ /// Inverse of : serialises a typed definition back to the camelCase
+ /// TagConfig JSON the mapper reads. Used by authoring paths (the Client CLI) that construct a
+ /// from operator flags and need the TagConfig string to hand
+ /// the driver as a . The dataType enum is written as its name string;
+ /// the array keys are emitted only for an array tag so the blob stays minimal. Neither
+ /// WriteIdempotent nor DeviceHostAddress is emitted — they travel on the
+ /// ( /
+ /// ), not inside the blob.
+ ///
+ /// The definition to serialise.
+ /// The camelCase TagConfig JSON string.
+ 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();
+ }
+
+ ///
+ /// Deploy-time inspection (05/CONV-2): warns on a present-but-invalid dataType (silently
+ /// defaulted by the lenient runtime) and on a structurally unparseable TagConfig. Empty when clean
+ /// or not an equipment-tag object. Never throws.
+ ///
+ /// The equipment tag's TagConfig JSON.
+ /// The warnings; empty when clean.
+ public static IReadOnlyList Inspect(string reference)
+ {
+ var warnings = new List();
+ 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(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;
+ }
+
+ ///
+ /// Reads the optional 1-D array length: arrayLength (a positive int32; values above
+ /// are treated as absent/scalar) honoured ONLY when isArray
+ /// is the JSON literal true. Returns null (scalar) when isArray is absent/false,
+ /// when arrayLength is absent / non-numeric / zero / negative / greater than
+ /// .
+ ///
+ 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;
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
index 1460d0dc..4acdbdf2 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
@@ -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 _devices =
new(StringComparer.OrdinalIgnoreCase);
- private readonly ConcurrentDictionary _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 _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 _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 _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.Instance;
_resolver = new EquipmentTagRefResolver(
- 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);
///
// 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
///
public const string UnresolvedHostSentinel = "";
+ ///
+ /// Resolve a to the device's live host address
+ /// () used to route reads/writes/subscriptions
+ /// to the right connection. TODO(v3 WaveC): the live device host must come from the
+ /// Device row's DeviceConfig (the deploy artifact will carry it); today we resolve
+ /// the DeviceName against this driver's own list —
+ /// first by , then by
+ /// (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).
+ ///
+ /// The owning device's name (or host) from the raw-tag entry.
+ /// The resolved device host address, or empty when unresolved.
+ 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 "";
+ }
+
///
/// 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();
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverFactoryExtensions.cs
index a628087e..a66a1595 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverFactoryExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverFactoryExtensions.cs
@@ -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(t.DataType, t.Name, driverInstanceId, "DataType");
-
- if (dataType == TwinCATDataType.Structure)
- throw new InvalidOperationException(
- $"TwinCAT tag '{t.Name ?? ""}' 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(string? raw, string? tagName, string driverInstanceId, string field)
- where T : struct, Enum
- {
- if (string.IsNullOrWhiteSpace(raw))
- throw new InvalidOperationException(
- $"TwinCAT tag '{tagName ?? ""}' in '{driverInstanceId}' missing {field}");
- return Enum.TryParse(raw, ignoreCase: true, out var v)
- ? v
- : throw new InvalidOperationException(
- $"TwinCAT tag '{tagName}' has unknown {field} '{raw}'. " +
- $"Expected one of {string.Join(", ", Enum.GetNames())}");
- }
-
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
@@ -146,8 +112,10 @@ public static class TwinCATDriverFactoryExtensions
/// Gets or sets the list of configured devices.
public List? Devices { get; init; }
- /// Gets or sets the list of configured tags.
- public List? Tags { get; init; }
+ /// 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 tags[] path is retired.
+ public List? RawTags { get; init; }
/// Gets or sets the probe configuration.
public TwinCATProbeDto? Probe { get; init; }
@@ -162,35 +130,6 @@ public static class TwinCATDriverFactoryExtensions
public string? DeviceName { get; init; }
}
- internal sealed class TwinCATTagDto
- {
- /// Gets or sets the tag name.
- public string? Name { get; init; }
-
- /// Gets or sets the device host address.
- public string? DeviceHostAddress { get; init; }
-
- /// Gets or sets the symbol path.
- public string? SymbolPath { get; init; }
-
- /// Gets or sets the data type.
- public string? DataType { get; init; }
-
- /// Gets or sets a value indicating whether the tag is writable.
- public bool? Writable { get; init; }
-
- /// Gets or sets a value indicating whether writes are idempotent.
- public bool? WriteIdempotent { get; init; }
-
- ///
- /// Optional 1-D array element count. When positive, the tag is a 1-D array of this
- /// many elements — drives IsArray/ArrayDim at
- /// discovery and a native ADS array read at runtime (Phase 4c).
- /// null or non-positive = scalar (the default).
- ///
- public int? ArrayLength { get; init; }
- }
-
internal sealed class TwinCATProbeDto
{
/// Gets or sets a value indicating whether probing is enabled.
diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/TwinCATCommandBaseTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/TwinCATCommandBaseTests.cs
index 18da65a8..05561ee1 100644
--- a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/TwinCATCommandBaseTests.cs
+++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/TwinCATCommandBaseTests.cs
@@ -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();
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArraySupportTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArraySupportTests.cs
index bb63b7ef..342ca92b 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArraySupportTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArraySupportTests.cs
@@ -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 ----
- /// An equipment array tag reads a typed CLR array (boxed as object) through the driver.
+ private const string ArrayRawPath = "equip/Speeds";
+ private const string ScalarRawPath = "equip/Speed";
+
+ /// An array raw tag reads a typed CLR array (boxed as object) through the driver.
[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().ShouldBe(expected);
@@ -131,11 +133,11 @@ public sealed class TwinCATArraySupportTests
factory.Clients[0].LastReadArrayCount.ShouldBe(4);
}
- /// A scalar equipment tag reads with a null array count (scalar path unchanged).
+ /// A scalar raw tag reads with a null array count (scalar path unchanged).
[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 ----
- /// The equipment-tag parser threads arrayLength into the transient definition.
+ /// The tag-definition factory threads arrayLength into the definition.
[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);
}
/// A blob without isArray (or with isArray=false) leaves ArrayLength null.
[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();
}
/// arrayLength is ignored when isArray is absent/false (no orphan length).
[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 ----
///
- /// A pre-declared tag in the driver config JSON with arrayLength must produce a
+ /// An array raw tag in the driver config JSON rawTags array must bind + map to a
/// with the correct ,
/// which in turn drives IsArray/ArrayDim at discovery and a native array read
/// at runtime (Driver.TwinCAT-017).
///
[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);
}
///
- /// A pre-declared array tag parsed from JSON must surface as IsArray=true /
+ /// An array raw tag delivered via the driver config JSON must surface as IsArray=true /
/// ArrayDim=8 in the discovery address space.
///
[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());
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATCapabilityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATCapabilityTests.cs
index e6238956..a3fa2752 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATCapabilityTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATCapabilityTests.cs
@@ -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);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATConnectBackoffTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATConnectBackoffTests.cs
index f840e810..8a77adf5 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATConnectBackoffTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATConnectBackoffTests.cs
@@ -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,
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATEquipmentTagParserStrictnessTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATEquipmentTagParserStrictnessTests.cs
deleted file mode 100644
index 4ce8dd8c..00000000
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATEquipmentTagParserStrictnessTests.cs
+++ /dev/null
@@ -1,57 +0,0 @@
-using Shouldly;
-using Xunit;
-using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
-
-namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
-
-/// R2-11 Phase C strictness surface for the TwinCAT equipment-tag parser: the runtime is now STRICT —
-/// a typo'd dataType rejects the tag (TryParse ⇒ ⇒ BadNodeIdUnknown),
-/// Inspect reports the typo, and the writable key is honoured (default true).
-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();
- }
-}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATEquipmentTagTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATEquipmentTagTests.cs
deleted file mode 100644
index 2b2090b5..00000000
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATEquipmentTagTests.cs
+++ /dev/null
@@ -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);
- }
-
- ///
- /// 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.
- ///
- [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);
- }
-}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATHighFindingsRegressionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATHighFindingsRegressionTests.cs
index b20b013f..3d86fc10 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATHighFindingsRegressionTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATHighFindingsRegressionTests.cs
@@ -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);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATLowFindingsRegressionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATLowFindingsRegressionTests.cs
index 019794b3..aa356530 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATLowFindingsRegressionTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATLowFindingsRegressionTests.cs
@@ -182,30 +182,15 @@ public sealed class TwinCATLowFindingsRegressionTests
// ---- Driver.TwinCAT-016 — gap-fill tests for previously closed findings ----
- /// Verifies that structure-typed pre-declared tags are rejected at configuration parse time.
+ /// Verifies that a Structure-typed raw tag is rejected by the tag-definition factory.
[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(() =>
- 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();
}
/// Verifies that the probe loop and read operations share one client per device.
@@ -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,
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeNotificationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeNotificationTests.cs
index 47df6aa9..1889144d 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeNotificationTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeNotificationTests.cs
@@ -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);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATRawTags.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATRawTags.cs
new file mode 100644
index 00000000..aaaccf01
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATRawTags.cs
@@ -0,0 +1,32 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
+
+///
+/// Test helper bridging the v2 authoring shape (a typed ) to the v3
+/// delivery shape (). 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 .
+/// These tests keep expressing their intent as typed definitions; this helper serialises each back to its
+/// TagConfig blob (via ) and packages it as a
+/// whose RawPath is the def's Name and whose
+/// is the def's DeviceHostAddress — 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.
+///
+internal static class TwinCATRawTags
+{
+ /// Serialises one typed definition into its
+ /// (RawPath = def.Name, DeviceName = def.DeviceHostAddress).
+ /// The typed definition to convert.
+ /// The equivalent .
+ public static RawTagEntry Entry(TwinCATTagDefinition def)
+ => new(def.Name, TwinCATTagDefinitionFactory.ToTagConfig(def), def.WriteIdempotent, def.DeviceHostAddress);
+
+ /// Serialises a sequence of typed definitions into records.
+ /// The typed definitions to convert.
+ /// The equivalent raw-tag entries.
+ public static IReadOnlyList Entries(IEnumerable defs)
+ => [.. defs.Select(Entry)];
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWriteTests.cs
index 6c08a226..6d80d837 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWriteTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWriteTests.cs
@@ -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);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReconnectReplayTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReconnectReplayTests.cs
index c22c60de..337aad4b 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReconnectReplayTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReconnectReplayTests.cs
@@ -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);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATResolveHostTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATResolveHostTests.cs
index c7a52c07..b305a070 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATResolveHostTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATResolveHostTests.cs
@@ -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;
///
-/// CONV-4 — 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 — 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 whose
+/// names the owning device; the driver threads that onto the
+/// def's host at Initialize, and ResolveHost reads it back by RawPath.
///
[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());
-
- /// An equipment-tag ref naming device B resolves to device B's host, not the first device.
- [Fact]
- public void ResolveHost_EquipmentTagRef_ReturnsItsOwnDeviceHost()
+ private static async Task 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;
+ }
+
+ /// A raw tag naming device B resolves to device B's host, not the first device.
+ [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);
}
/// An unresolvable ref keeps the current first-device fallback.
[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);
}
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolBrowserTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolBrowserTests.cs
index 73c04696..d2c3dd29 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolBrowserTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolBrowserTests.cs
@@ -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);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTagDefinitionFactoryStrictnessTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTagDefinitionFactoryStrictnessTests.cs
new file mode 100644
index 00000000..554c351f
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTagDefinitionFactoryStrictnessTests.cs
@@ -0,0 +1,60 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
+
+/// R2-11 Phase C strictness surface for the TwinCAT tag-definition factory: the runtime is now STRICT —
+/// a typo'd dataType rejects the tag (FromTagConfig ⇒ ⇒ BadNodeIdUnknown),
+/// Inspect reports the typo, and the writable key is honoured (default true).
+[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();
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTagDefinitionFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTagDefinitionFactoryTests.cs
new file mode 100644
index 00000000..0c55a3f8
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATTagDefinitionFactoryTests.cs
@@ -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();
+ }
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+}