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);
+ }
+}