diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs index 0b1dce3b..da548efa 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs @@ -1,3 +1,5 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; /// @@ -69,6 +71,32 @@ public sealed class MTConnectDriverOptions /// public IReadOnlyList Tags { get; init; } = []; + /// + /// The v3 data-plane binding: the authored raw tags the deploy artifact delivers. Each + /// pairs the tag's RawPath — the driver's wire reference for + /// read / subscribe / publish, and the key DriverHostActor routes published values by — + /// with the driver-specific TagConfig blob naming the Agent DataItem id it binds. + /// Injected into every driver's merged config by DriverDeviceConfigMerger, so this is the + /// collection a deployed instance actually serves from. + /// + /// Relationship to . The driver builds ONE observation index and + /// ONE RawPath → dataItemId table from both collections: + /// + /// A tag in RawTags is reachable by its RawPath (production). Its coercion + /// type comes from the blob's driverDataType; failing that, from a + /// entry naming the same DataItem id; failing that, from the Agent's + /// own /probe declaration (); failing + /// that, — the coercion that cannot fail. + /// A tag in only is reachable by its DataItem id + /// (the driver CLI's authoring surface, and every pre-v3 test) and still contributes + /// its coercion type to the index. + /// + /// Both default to empty, so neither collection is required and a driver instance + /// authored with no tags yet starts cleanly. + /// + /// + public IReadOnlyList RawTags { get; init; } = []; + /// /// Background connectivity-probe settings. When /// is true the driver periodically issues a cheap /probe request and raises diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs index babf65b2..9c60197c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs @@ -1,3 +1,4 @@ +using System.Collections.Frozen; using System.Globalization; using System.Text.Json; using Microsoft.Extensions.Logging; @@ -261,6 +262,15 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis private long _announcedInstanceId; private MTConnectDriverOptions _options; + + /// + /// The tag tables derived from — the RawPath ↔ dataItemId + /// translation the whole data plane runs through, plus the definition set the observation + /// index coerces against. Rebuilt (never mutated) whenever options are installed, and always + /// published in the same breath as them, so a lock-free reader can never pair one options + /// document's tags with another's RawPaths. + /// + private TagBinding _binding; private MTConnectObservationIndex _index; private DriverHealth _health = new(DriverState.Unknown, null, null); private long _nextSubscriptionId; @@ -370,16 +380,21 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis ArgumentNullException.ThrowIfNull(options); ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); - _options = options; _driverInstanceId = driverInstanceId; _logger = logger ?? NullLogger.Instance; _agentClientFactory = agentClientFactory ?? (o => new MTConnectAgentClient(o, logger)); _probeScheduler = probeScheduler ?? Task.Delay; + // Sets _options AND the tag tables derived from it. No /probe model is available here (the + // constructor dials nothing), so a raw tag whose blob declares no type falls back to String + // until InitializeAsync rebuilds the binding against the Agent's own declaration. + _options = options; + _binding = BuildBinding(options, probeModel: null); + // Never null, so every consumer (Task 10's ReadAsync included) can ask it for a snapshot // without a null check: an un-primed index answers BadWaitingForInitialData for an authored // tag and BadNodeIdUnknown otherwise, which is exactly the truth before Initialize runs. - _index = new MTConnectObservationIndex(options.Tags); + _index = new MTConnectObservationIndex(_binding.IndexTags); } /// @@ -591,7 +606,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis public long GetMemoryFootprint() => ((long)(Volatile.Read(ref _session)?.ProbeModelDataItemCount ?? 0) * ProbeModelBytesPerDataItem) + ((long)ObservationIndex.Count * IndexBytesPerEntry) - + ((long)_options.Tags.Count * TagBytesPerEntry); + + ((long)Volatile.Read(ref _binding).IndexTags.Count * TagBytesPerEntry); /// /// @@ -695,6 +710,11 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis var client = Volatile.Read(ref _session)?.Client; var options = _options; + // Read once, for the same reason the session is: every reference in this batch must be + // resolved against ONE tag table, not half against the table a concurrent re-initialize + // just replaced. + var binding = Volatile.Read(ref _binding); + if (client is null) { // Before InitializeAsync, after a faulted one, or after ShutdownAsync. Health is left @@ -710,14 +730,16 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis // Built from the SAME authored tags as the shared index, so the authored-vs-unknown // distinction and every coercion behave identically — it is a snapshot, not a variant. - var snapshot = new MTConnectObservationIndex(options.Tags); + var snapshot = new MTConnectObservationIndex(binding.IndexTags); snapshot.Apply(current); var results = new DataValueSnapshot[fullReferences.Count]; for (var i = 0; i < results.Length; i++) { + // Each reference is a RawPath, translated to the DataItem id the index is keyed by. + // An unmapped reference falls through unchanged and misses — Bad, never a throw. // Get never throws and never returns null, including for a null/blank reference. - results[i] = snapshot.Get(fullReferences[i]); + results[i] = snapshot.Get(binding.ToDataItemId(fullReferences[i])); } _readDegraded = false; @@ -812,10 +834,13 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis } // Raised OUTSIDE the lock — a handler is caller code (see the _subscriptionLock remarks). + // The callback carries the caller's OWN reference (the RawPath); only the index lookup + // behind it is translated to the DataItem id. var index = ObservationIndex; + var binding = Volatile.Read(ref _binding); foreach (var reference in references) { - RaiseDataChange(handle, reference, index.Get(reference)); + RaiseDataChange(handle, reference, index.Get(binding.ToDataItemId(reference))); } _logger.LogDebug( @@ -1967,6 +1992,13 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis // De-duplicated in document order: one Agent document may report a data item more than once // (a Condition container carries several simultaneously-active states), and the index has // already reconciled those into a single snapshot. + // + // The Agent reports DataItem ids; subscribers hold RawPaths. Each touched id is therefore + // EXPANDED into the references it is published under — every RawPath bound to it (a DataItem + // may legitimately back more than one authored raw tag), plus the id itself for the + // dataItemId-keyed authoring surface (the driver CLI). Publishing the id instead of the + // RawPath would miss DriverHostActor's NodeId table and drop the value silently. + var binding = Volatile.Read(ref _binding); var touched = new List(document.Observations.Count); var seen = new HashSet(StringComparer.Ordinal); foreach (var observation in document.Observations) @@ -1978,7 +2010,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis if (seen.Add(observation.DataItemId)) { - touched.Add(observation.DataItemId); + binding.AppendReferences(observation.DataItemId, touched); } } @@ -1991,6 +2023,12 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis /// whole device, and republishing all of it would flood the server with values no client /// asked for. /// + /// + /// are subscriber references — RawPaths under v3 — and + /// each is carried through to the callback unchanged; only the index lookup behind it is + /// translated to the DataItem id the Agent reports under. That asymmetry is the point: the + /// value must arrive under the reference the caller subscribed, or it is routed nowhere. + /// private void FanOut(IReadOnlyList references, MTConnectObservationIndex index) { if (references.Count == 0) @@ -2009,6 +2047,8 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis states = [.. _subscriptions.Values]; } + var binding = Volatile.Read(ref _binding); + // The lock is released before any callback: handlers are caller code. foreach (var reference in references) { @@ -2022,7 +2062,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis } // Read once per reference, so every handle sees the identical snapshot. - snapshot ??= index.Get(reference); + snapshot ??= index.Get(binding.ToDataItemId(reference)); RaiseDataChange(state.Handle, reference, snapshot); } } @@ -2283,6 +2323,12 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis Tags = dto.Tags is { Count: > 0 } ? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))] : [], + + // v3: the deploy artifact injects the driver's authored raw tags here (see + // DriverDeviceConfigMerger). Bound verbatim — the per-entry TagConfig blob is mapped + // later, at binding-build time, where a bad blob can be skipped rather than failing the + // whole deployment over one tag. + RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [], Probe = new MTConnectProbeOptions { Enabled = dto.Probe?.Enabled ?? true, @@ -2352,6 +2398,13 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis // ---- commit point: everything below is non-throwing bookkeeping ---- _options = options; + + // Derived from the options AND the /probe model just fetched: a raw tag whose TagConfig + // blob declares no type takes the type the Agent itself declares for that DataItem, so a + // browse-committed tag (whose blob carries only the address) publishes as the number it + // is rather than as text. Published before the index, which is built from it. + var binding = BuildBinding(options, probe); + Volatile.Write(ref _binding, binding); built = null; // The Agent this session talks to, as of now. A restart is measured against THIS, so a @@ -2365,7 +2418,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis ref _session, new AgentSession(client, probe, CountDataItems(probe), current.InstanceId, current.NextSequence)); - var index = new MTConnectObservationIndex(options.Tags); + var index = new MTConnectObservationIndex(binding.IndexTags); index.Apply(current); Volatile.Write(ref _index, index); @@ -2431,8 +2484,11 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis SafeDispose(session?.Client); // A fresh index rather than Clear(): the tag table it coerces against belongs to the options - // that are being torn down. - Volatile.Write(ref _index, new MTConnectObservationIndex(_options.Tags)); + // that are being torn down. The binding is left in place — it is a pure function of the + // options still installed, and a reference must keep resolving to the tag it names so a read + // against a torn-down driver answers BadWaitingForInitialData ("authored, no value") rather + // than BadNodeIdUnknown ("no such tag"). + Volatile.Write(ref _index, new MTConnectObservationIndex(Volatile.Read(ref _binding).IndexTags)); return Task.CompletedTask; } @@ -2886,6 +2942,230 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis dto.Units); } + // ---- v3 tag binding: RawPath ↔ dataItemId, and the definitions the index coerces against ---- + + /// + /// Derives the driver's tag tables from an options document — the RawPath → dataItemId + /// translation the data plane runs through, its reverse, and the definition set the + /// observation index coerces against. + /// + /// + /// + /// A bad raw tag is skipped, never thrown. One unreadable TagConfig blob + /// must not fail a whole driver — and therefore a whole deployment — over a tag the rest + /// of the plant does not depend on. The skipped tag's RawPath then resolves to nothing + /// and reports BadNodeIdUnknown, which is the documented + /// posture ("a miss is a miss") and is + /// visible to an operator, unlike a silent default. + /// + /// + /// Coercion type precedence (see ): + /// the blob's own driverDataType, else a + /// entry naming the same DataItem id, else the Agent's /probe declaration for that + /// data item, else . The third step is what makes a + /// browse-committed tag work: RawBrowseCommitMapper writes only the address into + /// the blob, so without it every value from the address picker would publish as text + /// under a numerically-typed OPC UA node. + /// + /// + /// The options document to derive from. + /// The Agent's device model when one has been fetched; else null. + private TagBinding BuildBinding(MTConnectDriverOptions options, MTConnectProbeModel? probeModel) + { + // The dataItemId-keyed authoring surface first (the driver CLI, and every pre-v3 caller). + // Last-wins on a duplicate id, matching MTConnectObservationIndex's own documented rule. + var definitions = new Dictionary(StringComparer.Ordinal); + foreach (var tag in options.Tags) + { + if (tag is null || string.IsNullOrWhiteSpace(tag.FullName)) continue; + + definitions[tag.FullName] = tag; + } + + if (options.RawTags.Count == 0) + { + return new TagBinding( + FrozenDictionary.Empty, + FrozenDictionary>.Empty, + [.. definitions.Values]); + } + + var declared = IndexProbeDataItems(probeModel); + var idByRawPath = new Dictionary(options.RawTags.Count, StringComparer.Ordinal); + var rawPathsById = new Dictionary>(StringComparer.Ordinal); + + foreach (var entry in options.RawTags) + { + if (entry is null || string.IsNullOrWhiteSpace(entry.RawPath)) continue; + + if (!TryReadRawTag(entry, out var dataItemId, out var authored)) + { + _logger.LogWarning( + "MTConnect driver {DriverInstanceId} could not map the raw tag {RawPath} to an Agent DataItem; it names no readable id (expected 'fullName', 'dataItemId' or 'address') or declares an unrecognised driverDataType. The tag is skipped and reports BadNodeIdUnknown; every other tag is unaffected.", + _driverInstanceId, entry.RawPath); + + continue; + } + + idByRawPath[entry.RawPath] = dataItemId; + + if (!rawPathsById.TryGetValue(dataItemId, out var paths)) + { + rawPathsById[dataItemId] = paths = []; + } + + if (!paths.Contains(entry.RawPath, StringComparer.Ordinal)) + { + // One DataItem may legitimately back several authored raw tags; a duplicate RawPath + // (the same tag listed twice) must still publish once. + paths.Add(entry.RawPath); + } + + if (authored is not null) + { + // The blob declared a type: it is the deploy-time authority and supersedes an older + // Tags entry for the same DataItem. + definitions[dataItemId] = authored; + } + else if (!definitions.ContainsKey(dataItemId)) + { + definitions[dataItemId] = InferDefinition(dataItemId, declared); + } + } + + return new TagBinding( + idByRawPath.ToFrozenDictionary(StringComparer.Ordinal), + rawPathsById.ToFrozenDictionary( + pair => pair.Key, + pair => (IReadOnlyList)pair.Value, + StringComparer.Ordinal), + [.. definitions.Values]); + } + + /// + /// Reads one authored raw tag: the DataItem id it binds, and the definition its blob declares + /// (null when the blob declares no type and the caller must fall back). + /// + /// + /// when the blob is unreadable, names no DataItem id, or carries a + /// driverDataType that is not a — a present-but-invalid + /// type rejects the tag rather than silently defaulting, which would publish the Agent's text + /// as a Good value of the wrong type (the sibling drivers' strict-enum posture). + /// + private static bool TryReadRawTag( + RawTagEntry entry, out string dataItemId, out MTConnectTagDefinition? authored) + { + dataItemId = string.Empty; + authored = null; + + if (string.IsNullOrWhiteSpace(entry.TagConfig)) return false; + + MTConnectRawTagConfigDto? blob; + try + { + blob = JsonSerializer.Deserialize(entry.TagConfig, JsonOptions); + } + catch (JsonException) + { + return false; + } + + if (blob is null) return false; + + var id = FirstNonBlank(blob.FullName, blob.DataItemId, blob.Address); + if (id is null) return false; + + dataItemId = id; + + // "driverDataType" (the driver's own tags[] field) or "dataType" (what the AdminUI's typed + // MTConnectTagConfigModel writes) — see the DTO remarks. + var authoredType = FirstNonBlank(blob.DriverDataType, blob.DataType); + if (authoredType is null) return true; + + // A digits-only type is rejected outright, BEFORE the parse: Enum.TryParse accepts "8" as + // readily as "Float64" and returns the member with that ordinal, so a numerically-serialized + // enum (the repo-wide authoring trap) would otherwise coerce every value of that tag to a + // type nobody chose, under Good quality. The AdminUI's MTConnectTagConfigModel.Validate + // refuses the same shape; this is the runtime half of that guard. + if (authoredType.All(char.IsAsciiDigit) + || !Enum.TryParse(authoredType, ignoreCase: true, out var parsed) + || !Enum.IsDefined(parsed)) + { + return false; + } + + authored = new MTConnectTagDefinition( + dataItemId, + parsed, + blob.IsArray ?? false, + blob.ArrayDim ?? 0, + blob.MtCategory, + blob.MtType, + blob.MtSubType, + blob.Units); + + return true; + } + + /// + /// The definition for a raw tag whose blob declared no type: the Agent's own /probe + /// declaration when the model is in hand, else — the one + /// coercion that cannot fail, carrying the Agent's text through untouched. + /// + private static MTConnectTagDefinition InferDefinition( + string dataItemId, FrozenDictionary declared) + { + if (!declared.TryGetValue(dataItemId, out var dataItem)) + { + return new MTConnectTagDefinition(dataItemId, DriverDataType.String); + } + + var inferred = MTConnectDataTypeInference.Infer( + dataItem.Category, dataItem.Type, dataItem.Units, dataItem.Representation, dataItem.SampleCount); + + return new MTConnectTagDefinition( + dataItemId, + inferred.DataType, + inferred.IsArray, + (int)(inferred.ArrayDim ?? 0), + dataItem.Category, + dataItem.Type, + dataItem.SubType, + dataItem.Units); + } + + /// Flattens a device model into a dataItemId → DataItem lookup; null ⇒ empty. + private static FrozenDictionary IndexProbeDataItems(MTConnectProbeModel? probeModel) + { + if (probeModel is null) return FrozenDictionary.Empty; + + var map = new Dictionary(StringComparer.Ordinal); + foreach (var device in probeModel.Devices) + { + if (device is null) continue; + + foreach (var dataItem in device.AllDataItems()) + { + if (dataItem is null || string.IsNullOrWhiteSpace(dataItem.Id)) continue; + + map[dataItem.Id] = dataItem; + } + } + + return map.ToFrozenDictionary(StringComparer.Ordinal); + } + + /// The first of that carries text, trimmed; else null. + private static string? FirstNonBlank(params string?[] candidates) + { + foreach (var candidate in candidates) + { + if (!string.IsNullOrWhiteSpace(candidate)) return candidate.Trim(); + } + + return null; + } + /// /// Parses an enum authored as a name. Config surfaces serialize enums as names /// project-wide; a numerically-serialized enum is the known trap that faults a driver at @@ -2930,6 +3210,70 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis long InstanceId, long NextSequence); + /// + /// The tag tables one options document derives, as one immutable unit — so installing new + /// options is a single reference swap and no reader can pair one document's RawPaths with + /// another's definitions. + /// + /// + /// The v3 wire translation: the RawPath the server subscribes / reads / routes by, mapped to + /// the Agent DataItem id the observation index is keyed by. + /// + /// + /// The reverse, one-to-many: every RawPath a DataItem must be published under. A DataItem may + /// legitimately back more than one authored raw tag, and each of those tags is a separate + /// OPC UA node that must receive the value. + /// + /// + /// The definition set coerces against — the union of + /// the authored and the definitions derived from + /// . This is how a raw tag's declared (or + /// probe-inferred) DriverDataType reaches the coercion. + /// + private sealed record TagBinding( + FrozenDictionary DataItemIdByRawPath, + FrozenDictionary> RawPathsByDataItemId, + IReadOnlyList IndexTags) + { + /// + /// Translates a subscriber/read reference to the DataItem id the index is keyed by. + /// + /// + /// An unmapped reference is returned unchanged rather than rejected, which serves + /// two callers at once: the dataItemId-keyed authoring surface (the driver CLI, and a + /// driver configured with Tags and no RawTags) resolves normally, and a + /// RawPath that no authored raw tag claims falls through to an index miss — + /// BadNodeIdUnknown, never an exception. There is no third behaviour to add here: + /// the index already distinguishes "authored but not yet reported" from "unknown". + /// + /// The caller's reference (a RawPath under v3). + /// The DataItem id to look up. + public string ToDataItemId(string? reference) => + reference is not null && DataItemIdByRawPath.TryGetValue(reference, out var dataItemId) + ? dataItemId + : reference ?? string.Empty; + + /// + /// Appends every reference must be published under: each + /// RawPath bound to it, plus the id itself for the dataItemId-keyed authoring surface. + /// + /// The DataItem the Agent just reported. + /// The reference list being accumulated for the fan-out. + public void AppendReferences(string dataItemId, List into) + { + if (RawPathsByDataItemId.TryGetValue(dataItemId, out var rawPaths)) + { + into.AddRange(rawPaths); + + // Only when a RawPath is spelled identically to the id — otherwise a subscriber + // holding that one reference would be raised twice for one observation. + if (rawPaths.Contains(dataItemId, StringComparer.Ordinal)) return; + } + + into.Add(dataItemId); + } + } + /// /// One live subscription: its handle and the references it wants. Both are effectively /// immutable once registered, so the pump reads without a lock — @@ -2982,10 +3326,62 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis public int? SampleCount { get; init; } public int? HeartbeatMs { get; init; } public List? Tags { get; init; } + + /// + /// The v3 authored raw tags the deploy artifact injects (RawPath + driver TagConfig + /// blob). Bound verbatim into ; this is the + /// collection a deployed instance's data plane is keyed by. + /// + public List? RawTags { get; init; } public MTConnectProbeDto? Probe { get; init; } public MTConnectReconnectDto? Reconnect { get; init; } } + /// + /// A raw tag's TagConfig blob: the driver-specific address of one authored raw tag. + /// Every field is optional — the blob is operator-authorable and reaches the driver through + /// several authoring surfaces that populate different subsets of it. + /// + /// + /// + /// Three accepted spellings of the DataItem id, tried in order: + /// fullName (the driver's own tag shape, and what the typed editor writes), + /// dataItemId (the protocol's own word for it, and the spelling an operator + /// hand-authoring the blob reaches for first), and address (what + /// RawBrowseCommitMapper writes for a driver with no typed editor of its own — the + /// shape every browse-committed MTConnect tag currently arrives in). Accepting all three + /// costs two dictionary probes; rejecting two of them would make a tag committed from the + /// address picker silently unreadable. + /// + /// + /// Two accepted spellings of the coercion type, likewise: dataType — what + /// the AdminUI's MTConnectTagConfigModel writes, and therefore what every tag + /// authored through the typed editor carries — and driverDataType, matching the + /// driver's own tags[] field name. Reading only one of them would make the editor + /// and the driver disagree about the type of every tag. + /// + /// + /// Kept separate from rather than reusing it: that DTO is + /// the strict tags[] surface, where a missing fullName is a config error + /// worth failing the deployment over. A raw tag's blob is authored elsewhere and its + /// failure mode is per-tag, not per-driver. + /// + /// + private sealed class MTConnectRawTagConfigDto + { + public string? FullName { get; init; } + public string? DataItemId { get; init; } + public string? Address { get; init; } + public string? DriverDataType { get; init; } + public string? DataType { get; init; } + public bool? IsArray { get; init; } + public int? ArrayDim { get; init; } + public string? MtCategory { get; init; } + public string? MtType { get; init; } + public string? MtSubType { get; init; } + public string? Units { get; init; } + } + /// One authored tag. DriverDataType stays a string — see . private sealed class MTConnectTagDto { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectRawTagBindingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectRawTagBindingTests.cs new file mode 100644 index 00000000..9f12645e --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectRawTagBindingTests.cs @@ -0,0 +1,458 @@ +using System.Collections.Concurrent; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; + +/// +/// The v3 wire contract: the runtime hands this driver its authored tags as a RawTags +/// array of (RawPath identity + driver TagConfig blob) and then +/// reads, subscribes, and routes published values by RawPath — never by the driver's own +/// internal address (here, the MTConnect DataItem id). +/// +/// +/// +/// Every config in this file is built by the real +/// — the same merge +/// DeploymentArtifact.TryReadSpec runs to produce a +/// DriverInstanceSpec.DriverConfig. A hand-written JSON literal would only prove the +/// driver binds the shape the test author imagined; this proves it binds the shape the +/// deploy artifact actually produces, including the Pascal-case property names +/// RawTagEntry serialises under and the TagConfig blob arriving as an escaped +/// string rather than a nested object. +/// +/// +/// The load-bearing assertion is the reference on the event, not the value. +/// DriverHostActor routes a published value by (DriverInstanceId, RawPath) +/// against a NodeId table it built from the same RawPaths; a driver that published a +/// perfectly correct value under its own dataItemId would miss that table on every tick and +/// the value would be dropped silently — with no error anywhere and every unit test green. +/// +/// +public sealed class MTConnectRawTagBindingTests +{ + private const string AgentUri = "http://fixture-agent:5000"; + + /// The DataItem ids the canned Fixtures/ documents report. + private const string PositionDataItemId = "dev1_pos"; + private const string ExecutionDataItemId = "dev1_execution"; + private const string PartCountDataItemId = "dev1_partcount"; + + /// The RawPaths a deployed /raw tree would give those data items. + private const string PositionRawPath = "Plant/MTConnect/dev1/Position"; + private const string ExecutionRawPath = "Plant/MTConnect/dev1/Execution"; + private const string PartCountRawPath = "Plant/MTConnect/dev1/PartCount"; + + /// The instanceId every canned fixture shares. + private const long FixtureInstanceId = 1655000000L; + + /// The cursor Fixtures/current.xml primes the pump with. + private const long PrimedNextSequence = 108L; + + private const uint Good = 0x00000000u; + private const uint BadNoCommunication = 0x80310000u; + private const uint BadNodeIdUnknown = 0x80340000u; + private const uint BadTypeMismatch = 0x80740000u; + + private static readonly DateTime ObservedAt = new(2026, 7, 24, 12, 30, 0, DateTimeKind.Utc); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + /// + /// The constructor options a spawned driver starts from: the endpoint only. Everything the + /// data plane binds arrives in the merged config document, exactly as it does in production. + /// + private static MTConnectDriverOptions Bare() => new() { AgentUri = AgentUri }; + + /// + /// Builds the merged DriverConfig for one MTConnect driver instance the way the deploy + /// artifact does: driver-level config + the device's DeviceConfig + the authored raw + /// tags injected as the RawTags array. + /// + private static string MergedConfig(params RawTagEntry[] rawTags) => + DriverDeviceConfigMerger.Merge( + $$"""{"AgentUri":"{{AgentUri}}"}""", + [new DriverDeviceConfigMerger.DeviceRow("dev1", "{}")], + rawTags); + + /// One authored raw tag: a RawPath bound to a DataItem id, typed for coercion. + private static RawTagEntry Entry(string rawPath, string dataItemId, string? driverDataType = null) => + new( + rawPath, + driverDataType is null + ? $$"""{"fullName":"{{dataItemId}}"}""" + : $$"""{"fullName":"{{dataItemId}}","driverDataType":"{{driverDataType}}"}""", + WriteIdempotent: false, + DeviceName: "dev1"); + + private static MTConnectStreamsResult Chunk( + long firstSequence, long nextSequence, params (string Id, string Value)[] observations) => + new( + FixtureInstanceId, + nextSequence, + firstSequence, + [.. observations.Select(o => new MTConnectObservation(o.Id, o.Value, ObservedAt))]); + + private static IReadOnlyList For( + ConcurrentQueue seen, string reference) => + [.. seen.Where(e => e.FullReference == reference)]; + + private static ConcurrentQueue Record(MTConnectDriver driver) + { + var seen = new ConcurrentQueue(); + driver.OnDataChange += (_, e) => seen.Enqueue(e); + + return seen; + } + + private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> DeployedAsync( + params RawTagEntry[] rawTags) + { + var agent = CannedAgentClient.FromFixtures(); + var driver = new MTConnectDriver(Bare(), "mt1", _ => agent); + await driver.InitializeAsync(MergedConfig(rawTags), Ct); + + return (driver, agent); + } + + // ---- the production data plane: subscribe by RawPath, publish by RawPath ---- + + /// + /// The whole blocker in one case: a deployed driver is subscribed by RawPath (that is + /// what DriverInstanceActor.HandleSubscribeAsync passes, and what + /// DriverHostActor's NodeId table is keyed by) and must answer under that same + /// RawPath — both for the initial value and for every streamed chunk. + /// + [Fact] + public async Task Subscribing_by_raw_path_publishes_under_the_raw_path() + { + var (driver, agent) = await DeployedAsync( + Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64))); + var seen = Record(driver); + + await driver.SubscribeAsync([PositionRawPath], TimeSpan.FromMilliseconds(50), Ct); + + // Initial data, out of the priming /current — under the RawPath, not the dataItemId. + var initial = For(seen, PositionRawPath).ShouldHaveSingleItem(); + initial.Snapshot.StatusCode.ShouldBe(Good); + initial.Snapshot.Value.ShouldBe(123.4567d); + + await agent.PumpAsync( + Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000"))); + + var streamed = For(seen, PositionRawPath); + streamed.Count.ShouldBe(2); + streamed[1].Snapshot.StatusCode.ShouldBe(Good); + streamed[1].Snapshot.Value.ShouldBe(201.5d); + + // …and NOTHING is ever published under the driver-internal dataItemId: a value routed by that + // reference misses DriverHostActor's NodeId table and is dropped without a trace. + For(seen, PositionDataItemId).ShouldBeEmpty(); + + await driver.ShutdownAsync(Ct); + } + + /// + /// The coercion type must survive the new binding path. The TagConfig blob's + /// driverDataType is what tells the observation index to publish 201.5 as a + /// rather than the Agent's raw text — losing it would turn every value + /// into a Good-coded string, which the OPC UA node (typed Double) cannot carry. + /// + [Fact] + public async Task Raw_tag_config_carries_the_coercion_type_into_the_index() + { + var (driver, _) = await DeployedAsync( + Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)), + Entry(ExecutionRawPath, ExecutionDataItemId, nameof(DriverDataType.String))); + var seen = Record(driver); + + await driver.SubscribeAsync([PositionRawPath, ExecutionRawPath], TimeSpan.FromMilliseconds(50), Ct); + + For(seen, PositionRawPath)[0].Snapshot.Value.ShouldBeOfType(); + For(seen, ExecutionRawPath)[0].Snapshot.Value.ShouldBeOfType(); + + await driver.ShutdownAsync(Ct); + } + + /// + /// keys by RawPath too. The server never calls it (the data + /// plane is subscribe-only), but the driver CLI does, and a read that answered + /// BadNodeIdUnknown for a tag the subscription serves happily would be a lie about the + /// deployment. + /// + [Fact] + public async Task Read_resolves_raw_paths() + { + var (driver, _) = await DeployedAsync( + Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)), + Entry(PartCountRawPath, PartCountDataItemId, nameof(DriverDataType.Int32))); + + var values = await driver.ReadAsync([PositionRawPath, PartCountRawPath, "Plant/MTConnect/dev1/Nope"], Ct); + + values.Count.ShouldBe(3); + values[0].StatusCode.ShouldBe(Good); + values[0].Value.ShouldBe(123.4567d); + + // Authored + bound, but the Agent reports it UNAVAILABLE in the fixture. + values[1].StatusCode.ShouldBe(BadNoCommunication); + + // Not in RawTags at all: a miss is a miss, surfaced as Bad — never a throw. + values[2].StatusCode.ShouldBe(BadNodeIdUnknown); + + await driver.ShutdownAsync(Ct); + } + + /// + /// The AdminUI's typed editor (MTConnectTagConfigModel) writes the coercion type under + /// dataType, not driverDataType. Reading only the driver's own spelling would + /// make every editor-authored tag fall back to String — the editor and the runtime disagreeing + /// about the type of every value, with nothing anywhere reporting it. + /// + [Fact] + public async Task The_admin_ui_datatype_spelling_reaches_the_coercion() + { + var (driver, _) = await DeployedAsync( + new RawTagEntry( + PositionRawPath, + """{"fullName":"dev1_pos","dataType":"Float64","mtCategory":"SAMPLE","mtType":"POSITION"}""", + WriteIdempotent: false, + DeviceName: "dev1")); + var seen = Record(driver); + + await driver.SubscribeAsync([PositionRawPath], TimeSpan.FromMilliseconds(50), Ct); + + For(seen, PositionRawPath)[0].Snapshot.Value.ShouldBe(123.4567d); + + await driver.ShutdownAsync(Ct); + } + + /// + /// A browse-committed tag's blob carries ONLY the address (RawBrowseCommitMapper has no + /// MTConnect branch, so it writes the generic {"address": id} shape) — no type at all. + /// The driver falls back to the Agent's own /probe declaration, so a POSITION SAMPLE + /// publishes as a rather than as the Agent's raw text under a + /// numerically-typed OPC UA node. + /// + [Fact] + public async Task A_typeless_blob_takes_the_type_the_agent_declares() + { + var (driver, _) = await DeployedAsync( + new RawTagEntry(PositionRawPath, """{"address":"dev1_pos"}""", false, "dev1")); + var seen = Record(driver); + + await driver.SubscribeAsync([PositionRawPath], TimeSpan.FromMilliseconds(50), Ct); + + var initial = For(seen, PositionRawPath)[0]; + initial.Snapshot.StatusCode.ShouldBe(Good); + initial.Snapshot.Value.ShouldBeOfType().ShouldBe(123.4567d); + + await driver.ShutdownAsync(Ct); + } + + /// + /// A entry supplies the coercion type for a raw tag + /// whose blob declares none — the middle rung of the documented precedence, and what keeps a + /// driver authored through both surfaces coherent. + /// + [Fact] + public async Task Tags_supply_the_type_for_a_raw_tag_whose_blob_declares_none() + { + var agent = CannedAgentClient.FromFixtures(); + + // dev1_execution is an EVENT the probe model would infer as String; the Tags entry overrides + // that with Reference (a type nothing could infer), so the assertion can only pass if the + // Tags entry — not the inference — supplied it. + var driver = new MTConnectDriver( + new MTConnectDriverOptions + { + AgentUri = AgentUri, + Tags = [new MTConnectTagDefinition(ExecutionDataItemId, DriverDataType.Int32)], + }, + "mt1", + _ => agent); + + // A config document with a body REPLACES the constructor options, so the Tags entry has to + // travel in it too — this is the shape a driver authored through both surfaces deploys as. + await driver.InitializeAsync( + $$""" + {"AgentUri":"{{AgentUri}}", + "Tags":[{"FullName":"{{ExecutionDataItemId}}","DriverDataType":"Int32"}], + "RawTags":[{"RawPath":"{{ExecutionRawPath}}","TagConfig":"{\"fullName\":\"{{ExecutionDataItemId}}\"}","WriteIdempotent":false,"DeviceName":"dev1"}]} + """, + Ct); + + var seen = Record(driver); + await driver.SubscribeAsync([ExecutionRawPath], TimeSpan.FromMilliseconds(50), Ct); + + // The fixture reports dev1_execution as "ACTIVE" — not an Int32, so the Int32 the Tags entry + // declared is what makes this a type mismatch instead of a Good string. + For(seen, ExecutionRawPath)[0].Snapshot.StatusCode.ShouldBe(BadTypeMismatch); + + await driver.ShutdownAsync(Ct); + } + + /// + /// RawTags and Tags may both name the same DataItem. The RawTags blob is the + /// deploy-time authority and its declared type wins; the dataItemId-keyed reference keeps + /// working alongside the RawPath, so a CLI session against a deployed driver still resolves. + /// + [Fact] + public async Task Both_collections_together_bind_both_references_with_rawtags_winning_the_type() + { + var agent = CannedAgentClient.FromFixtures(); + var driver = new MTConnectDriver(Bare(), "mt1", _ => agent); + + await driver.InitializeAsync( + $$""" + {"AgentUri":"{{AgentUri}}", + "Tags":[{"FullName":"{{PositionDataItemId}}","DriverDataType":"String"}], + "RawTags":[{"RawPath":"{{PositionRawPath}}","TagConfig":"{\"fullName\":\"{{PositionDataItemId}}\",\"driverDataType\":\"Float64\"}","WriteIdempotent":false,"DeviceName":"dev1"}]} + """, + Ct); + + var seen = Record(driver); + await driver.SubscribeAsync( + [PositionRawPath, PositionDataItemId], TimeSpan.FromMilliseconds(50), Ct); + + // The RawTags blob's Float64 beat the Tags entry's String — for BOTH references, because + // there is one index and one definition per DataItem. + For(seen, PositionRawPath)[0].Snapshot.Value.ShouldBe(123.4567d); + For(seen, PositionDataItemId)[0].Snapshot.Value.ShouldBe(123.4567d); + + // …and a streamed chunk raises BOTH, once each. + await agent.PumpAsync( + Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000"))); + + For(seen, PositionRawPath).Count.ShouldBe(2); + For(seen, PositionDataItemId).Count.ShouldBe(2); + + await driver.ShutdownAsync(Ct); + } + + /// + /// One DataItem may back several authored raw tags (the same machine signal projected into two + /// places in the /raw tree). Each is its own OPC UA node and each must receive the + /// value — a one-to-one map would silently serve only whichever tag was authored last. + /// + [Fact] + public async Task One_data_item_fans_out_to_every_raw_path_bound_to_it() + { + const string secondRawPath = "Plant/MTConnect/dev1/Mirror/Position"; + var (driver, agent) = await DeployedAsync( + Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)), + Entry(secondRawPath, PositionDataItemId, nameof(DriverDataType.Float64))); + var seen = Record(driver); + + await driver.SubscribeAsync( + [PositionRawPath, secondRawPath], TimeSpan.FromMilliseconds(50), Ct); + await agent.PumpAsync( + Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000"))); + + For(seen, PositionRawPath).Count.ShouldBe(2); + For(seen, secondRawPath).Count.ShouldBe(2); + For(seen, secondRawPath)[1].Snapshot.Value.ShouldBe(201.5d); + + await driver.ShutdownAsync(Ct); + } + + /// + /// A blob that names no DataItem id, or declares a dataType that is not a + /// (including the numerically-serialized-enum trap, where + /// Enum.TryParse would happily take "8" as the member with that ordinal), skips + /// that ONE tag — the rest of the driver deploys and serves normally. + /// + [Theory] + [InlineData("""{"units":"MILLIMETER"}""")] + [InlineData("""{"fullName":"dev1_pos","dataType":"Flot64"}""")] + [InlineData("""{"fullName":"dev1_pos","dataType":"8"}""")] + [InlineData("not json at all")] + public async Task An_unusable_blob_skips_only_its_own_tag(string badBlob) + { + var (driver, _) = await DeployedAsync( + new RawTagEntry("Plant/MTConnect/dev1/Bad", badBlob, false, "dev1"), + Entry(PartCountRawPath, PartCountDataItemId, nameof(DriverDataType.Int32))); + + var values = await driver.ReadAsync(["Plant/MTConnect/dev1/Bad", PartCountRawPath], Ct); + + values[0].StatusCode.ShouldBe(BadNodeIdUnknown); + values[1].StatusCode.ShouldBe(BadNoCommunication); // bound; the Agent reports it UNAVAILABLE + + await driver.ShutdownAsync(Ct); + } + + /// + /// A redeploy that re-homes a tag in the /raw tree changes its RawPath. The driver must + /// rebind to the new one — and stop answering for the old one, which no longer names anything. + /// This is also the ordering pin: the binding is installed with the options, before the index + /// the pump republishes from, so a standing subscription can never be served through one + /// document's RawPaths against another's values. + /// + [Fact] + public async Task Reinitialize_rebinds_raw_paths() + { + const string movedRawPath = "Plant/MTConnect/dev1/Spindle/Position"; + var (driver, agent) = await DeployedAsync( + Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64))); + var seen = Record(driver); + + await driver.SubscribeAsync([PositionRawPath, movedRawPath], TimeSpan.FromMilliseconds(50), Ct); + For(seen, movedRawPath)[0].Snapshot.StatusCode.ShouldBe(BadNodeIdUnknown); + + await driver.ReinitializeAsync( + MergedConfig(Entry(movedRawPath, PositionDataItemId, nameof(DriverDataType.Float64))), Ct); + + // The restarted pump republishes every standing reference as its first act. + await agent.PumpAsync( + Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000"))); + + For(seen, movedRawPath)[^1].Snapshot.Value.ShouldBe(201.5d); + For(seen, PositionRawPath)[^1].Snapshot.StatusCode.ShouldBe(BadNodeIdUnknown); + + await driver.ShutdownAsync(Ct); + } + + /// + /// The factory is the production construction seam (DriverFactoryRegistry calls it with + /// the merged artifact config), so RawTags has to survive that path too — a driver whose + /// RawPaths only bind when constructed by hand is a driver that works in tests and nowhere else. + /// + [Fact] + public void The_factory_binds_raw_tags_from_the_merged_config() + { + var driver = MTConnectDriverFactoryExtensions.CreateInstance( + "mt1", + MergedConfig(Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)))); + + var bound = driver.EffectiveOptions.RawTags.ShouldHaveSingleItem(); + bound.RawPath.ShouldBe(PositionRawPath); + bound.DeviceName.ShouldBe("dev1"); + bound.TagConfig.ShouldContain(PositionDataItemId); + } + + /// + /// A subscribed RawPath with no RawTags entry behind it (an authoring slip, or a tag + /// whose blob would not map) must report Bad and keep serving every other reference — the + /// documented posture: "a miss is a miss". + /// + [Fact] + public async Task An_unmapped_raw_path_reports_bad_and_never_throws() + { + var (driver, agent) = await DeployedAsync( + Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64))); + var seen = Record(driver); + + await driver.SubscribeAsync( + [PositionRawPath, "Plant/MTConnect/dev1/Ghost"], TimeSpan.FromMilliseconds(50), Ct); + + For(seen, "Plant/MTConnect/dev1/Ghost")[0].Snapshot.StatusCode.ShouldBe(BadNodeIdUnknown); + + // The bound reference is unaffected — the miss did not poison the batch or the stream. + await agent.PumpAsync( + Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000"))); + For(seen, PositionRawPath).Count.ShouldBe(2); + + await driver.ShutdownAsync(Ct); + } +}