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 (TryParseBadNodeIdUnknown), -/// 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 (FromTagConfigBadNodeIdUnknown), +/// 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); + } +}