feat(mtconnect): read-only MTConnect Agent driver (P1) — 23 tasks + 5-leg live gate #506

Merged
dohertj2 merged 34 commits from feat/mtconnect-driver into master 2026-07-27 14:02:29 -04:00
3 changed files with 893 additions and 11 deletions
Showing only changes of commit 51c8c7f30b - Show all commits
@@ -1,3 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
@@ -69,6 +71,32 @@ public sealed class MTConnectDriverOptions
/// </summary>
public IReadOnlyList<MTConnectTagDefinition> Tags { get; init; } = [];
/// <summary>
/// <b>The v3 data-plane binding: the authored raw tags the deploy artifact delivers.</b> Each
/// <see cref="RawTagEntry"/> pairs the tag's <b>RawPath</b> — the driver's wire reference for
/// read / subscribe / publish, and the key <c>DriverHostActor</c> routes published values by —
/// with the driver-specific <c>TagConfig</c> blob naming the Agent DataItem <c>id</c> it binds.
/// Injected into every driver's merged config by <c>DriverDeviceConfigMerger</c>, so this is the
/// collection a deployed instance actually serves from.
/// <para>
/// <b>Relationship to <see cref="Tags"/>.</b> The driver builds ONE observation index and
/// ONE <c>RawPath → dataItemId</c> table from both collections:
/// <list type="bullet">
/// <item>A tag in <c>RawTags</c> is reachable by its RawPath (production). Its coercion
/// type comes from the blob's <c>driverDataType</c>; failing that, from a
/// <see cref="Tags"/> entry naming the same DataItem id; failing that, from the Agent's
/// own <c>/probe</c> declaration (<see cref="MTConnectDataTypeInference"/>); failing
/// that, <see cref="DriverDataType.String"/> — the coercion that cannot fail.</item>
/// <item>A tag in <see cref="Tags"/> only is reachable by its DataItem <c>id</c>
/// (the driver CLI's authoring surface, and every pre-v3 test) and still contributes
/// its coercion type to the index.</item>
/// </list>
/// Both default to empty, so neither collection is required and a driver instance
/// authored with no tags yet starts cleanly.
/// </para>
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>
/// Background connectivity-probe settings. When <see cref="MTConnectProbeOptions.Enabled"/>
/// is true the driver periodically issues a cheap <c>/probe</c> request and raises
@@ -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;
/// <summary>
/// The tag tables derived from <see cref="_options"/> — the <c>RawPath ↔ dataItemId</c>
/// 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.
/// </summary>
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<MTConnectDriver>.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);
}
/// <inheritdoc/>
@@ -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);
/// <inheritdoc/>
/// <remarks>
@@ -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<string>(document.Observations.Count);
var seen = new HashSet<string>(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.
/// </summary>
/// <remarks>
/// <paramref name="references"/> are <b>subscriber</b> 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.
/// </remarks>
private void FanOut(IReadOnlyList<string> 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 ----
/// <summary>
/// Derives the driver's tag tables from an options document — the <c>RawPath → dataItemId</c>
/// translation the data plane runs through, its reverse, and the definition set the
/// observation index coerces against.
/// </summary>
/// <remarks>
/// <para>
/// <b>A bad raw tag is skipped, never thrown.</b> One unreadable <c>TagConfig</c> 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 <c>BadNodeIdUnknown</c>, which is the documented
/// <see cref="EquipmentTagRefResolver{TDef}"/> posture ("a miss is a miss") and is
/// visible to an operator, unlike a silent default.
/// </para>
/// <para>
/// <b>Coercion type precedence</b> (see <see cref="MTConnectDriverOptions.RawTags"/>):
/// the blob's own <c>driverDataType</c>, else a <see cref="MTConnectDriverOptions.Tags"/>
/// entry naming the same DataItem id, else the Agent's <c>/probe</c> declaration for that
/// data item, else <see cref="DriverDataType.String"/>. The third step is what makes a
/// browse-committed tag work: <c>RawBrowseCommitMapper</c> 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.
/// </para>
/// </remarks>
/// <param name="options">The options document to derive from.</param>
/// <param name="probeModel">The Agent's device model when one has been fetched; else null.</param>
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<string, MTConnectTagDefinition>(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<string, string>.Empty,
FrozenDictionary<string, IReadOnlyList<string>>.Empty,
[.. definitions.Values]);
}
var declared = IndexProbeDataItems(probeModel);
var idByRawPath = new Dictionary<string, string>(options.RawTags.Count, StringComparer.Ordinal);
var rawPathsById = new Dictionary<string, List<string>>(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<string>)pair.Value,
StringComparer.Ordinal),
[.. definitions.Values]);
}
/// <summary>
/// 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).
/// </summary>
/// <returns>
/// <see langword="false"/> when the blob is unreadable, names no DataItem id, or carries a
/// <c>driverDataType</c> that is not a <see cref="DriverDataType"/> — 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).
/// </returns>
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<MTConnectRawTagConfigDto>(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<DriverDataType>(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;
}
/// <summary>
/// The definition for a raw tag whose blob declared no type: the Agent's own <c>/probe</c>
/// declaration when the model is in hand, else <see cref="DriverDataType.String"/> — the one
/// coercion that cannot fail, carrying the Agent's text through untouched.
/// </summary>
private static MTConnectTagDefinition InferDefinition(
string dataItemId, FrozenDictionary<string, MTConnectDataItem> 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);
}
/// <summary>Flattens a device model into a <c>dataItemId → DataItem</c> lookup; null ⇒ empty.</summary>
private static FrozenDictionary<string, MTConnectDataItem> IndexProbeDataItems(MTConnectProbeModel? probeModel)
{
if (probeModel is null) return FrozenDictionary<string, MTConnectDataItem>.Empty;
var map = new Dictionary<string, MTConnectDataItem>(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);
}
/// <summary>The first of <paramref name="candidates"/> that carries text, trimmed; else null.</summary>
private static string? FirstNonBlank(params string?[] candidates)
{
foreach (var candidate in candidates)
{
if (!string.IsNullOrWhiteSpace(candidate)) return candidate.Trim();
}
return null;
}
/// <summary>
/// Parses an enum authored as a <b>name</b>. 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);
/// <summary>
/// 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.
/// </summary>
/// <param name="DataItemIdByRawPath">
/// The v3 wire translation: the RawPath the server subscribes / reads / routes by, mapped to
/// the Agent DataItem <c>id</c> the observation index is keyed by.
/// </param>
/// <param name="RawPathsByDataItemId">
/// 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.
/// </param>
/// <param name="IndexTags">
/// The definition set <see cref="MTConnectObservationIndex"/> coerces against — the union of
/// the authored <see cref="MTConnectDriverOptions.Tags"/> and the definitions derived from
/// <see cref="MTConnectDriverOptions.RawTags"/>. This is how a raw tag's declared (or
/// probe-inferred) <c>DriverDataType</c> reaches the coercion.
/// </param>
private sealed record TagBinding(
FrozenDictionary<string, string> DataItemIdByRawPath,
FrozenDictionary<string, IReadOnlyList<string>> RawPathsByDataItemId,
IReadOnlyList<MTConnectTagDefinition> IndexTags)
{
/// <summary>
/// Translates a subscriber/read reference to the DataItem id the index is keyed by.
/// </summary>
/// <remarks>
/// An unmapped reference is returned <b>unchanged</b> rather than rejected, which serves
/// two callers at once: the dataItemId-keyed authoring surface (the driver CLI, and a
/// driver configured with <c>Tags</c> and no <c>RawTags</c>) resolves normally, and a
/// RawPath that no authored raw tag claims falls through to an index miss —
/// <c>BadNodeIdUnknown</c>, never an exception. There is no third behaviour to add here:
/// the index already distinguishes "authored but not yet reported" from "unknown".
/// </remarks>
/// <param name="reference">The caller's reference (a RawPath under v3).</param>
/// <returns>The DataItem id to look up.</returns>
public string ToDataItemId(string? reference) =>
reference is not null && DataItemIdByRawPath.TryGetValue(reference, out var dataItemId)
? dataItemId
: reference ?? string.Empty;
/// <summary>
/// Appends every reference <paramref name="dataItemId"/> must be published under: each
/// RawPath bound to it, plus the id itself for the dataItemId-keyed authoring surface.
/// </summary>
/// <param name="dataItemId">The DataItem the Agent just reported.</param>
/// <param name="into">The reference list being accumulated for the fan-out.</param>
public void AppendReferences(string dataItemId, List<string> 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);
}
}
/// <summary>
/// One live subscription: its handle and the references it wants. Both are effectively
/// immutable once registered, so the pump reads <see cref="References"/> 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<MTConnectTagDto>? Tags { get; init; }
/// <summary>
/// The v3 authored raw tags the deploy artifact injects (RawPath + driver <c>TagConfig</c>
/// blob). Bound verbatim into <see cref="MTConnectDriverOptions.RawTags"/>; this is the
/// collection a deployed instance's data plane is keyed by.
/// </summary>
public List<RawTagEntry>? RawTags { get; init; }
public MTConnectProbeDto? Probe { get; init; }
public MTConnectReconnectDto? Reconnect { get; init; }
}
/// <summary>
/// A raw tag's <c>TagConfig</c> 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.
/// </summary>
/// <remarks>
/// <para>
/// <b>Three accepted spellings of the DataItem id</b>, tried in order:
/// <c>fullName</c> (the driver's own tag shape, and what the typed editor writes),
/// <c>dataItemId</c> (the protocol's own word for it, and the spelling an operator
/// hand-authoring the blob reaches for first), and <c>address</c> (what
/// <c>RawBrowseCommitMapper</c> 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.
/// </para>
/// <para>
/// <b>Two accepted spellings of the coercion type</b>, likewise: <c>dataType</c> — what
/// the AdminUI's <c>MTConnectTagConfigModel</c> writes, and therefore what every tag
/// authored through the typed editor carries — and <c>driverDataType</c>, matching the
/// driver's own <c>tags[]</c> field name. Reading only one of them would make the editor
/// and the driver disagree about the type of every tag.
/// </para>
/// <para>
/// Kept separate from <see cref="MTConnectTagDto"/> rather than reusing it: that DTO is
/// the strict <c>tags[]</c> surface, where a missing <c>fullName</c> 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.
/// </para>
/// </remarks>
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; }
}
/// <summary>One authored tag. <c>DriverDataType</c> stays a string — see <see cref="ParseEnum{T}"/>.</summary>
private sealed class MTConnectTagDto
{
@@ -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;
/// <summary>
/// The v3 wire contract: the runtime hands this driver its authored tags as a <c>RawTags</c>
/// array of <see cref="RawTagEntry"/> (RawPath identity + driver <c>TagConfig</c> blob) and then
/// reads, subscribes, and routes published values <b>by RawPath</b> — never by the driver's own
/// internal address (here, the MTConnect DataItem <c>id</c>).
/// </summary>
/// <remarks>
/// <para>
/// <b>Every config in this file is built by the real
/// <see cref="DriverDeviceConfigMerger"/></b> — the same merge
/// <c>DeploymentArtifact.TryReadSpec</c> runs to produce a
/// <c>DriverInstanceSpec.DriverConfig</c>. 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
/// <c>RawTagEntry</c> serialises under and the <c>TagConfig</c> blob arriving as an escaped
/// string rather than a nested object.
/// </para>
/// <para>
/// <b>The load-bearing assertion is the reference on the event, not the value.</b>
/// <c>DriverHostActor</c> routes a published value by <c>(DriverInstanceId, RawPath)</c>
/// 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.
/// </para>
/// </remarks>
public sealed class MTConnectRawTagBindingTests
{
private const string AgentUri = "http://fixture-agent:5000";
/// <summary>The DataItem ids the canned <c>Fixtures/</c> documents report.</summary>
private const string PositionDataItemId = "dev1_pos";
private const string ExecutionDataItemId = "dev1_execution";
private const string PartCountDataItemId = "dev1_partcount";
/// <summary>The RawPaths a deployed <c>/raw</c> tree would give those data items.</summary>
private const string PositionRawPath = "Plant/MTConnect/dev1/Position";
private const string ExecutionRawPath = "Plant/MTConnect/dev1/Execution";
private const string PartCountRawPath = "Plant/MTConnect/dev1/PartCount";
/// <summary>The <c>instanceId</c> every canned fixture shares.</summary>
private const long FixtureInstanceId = 1655000000L;
/// <summary>The cursor <c>Fixtures/current.xml</c> primes the pump with.</summary>
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;
/// <summary>
/// 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.
/// </summary>
private static MTConnectDriverOptions Bare() => new() { AgentUri = AgentUri };
/// <summary>
/// Builds the merged <c>DriverConfig</c> for one MTConnect driver instance the way the deploy
/// artifact does: driver-level config + the device's <c>DeviceConfig</c> + the authored raw
/// tags injected as the <c>RawTags</c> array.
/// </summary>
private static string MergedConfig(params RawTagEntry[] rawTags) =>
DriverDeviceConfigMerger.Merge(
$$"""{"AgentUri":"{{AgentUri}}"}""",
[new DriverDeviceConfigMerger.DeviceRow("dev1", "{}")],
rawTags);
/// <summary>One authored raw tag: a RawPath bound to a DataItem id, typed for coercion.</summary>
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<DataChangeEventArgs> For(
ConcurrentQueue<DataChangeEventArgs> seen, string reference) =>
[.. seen.Where(e => e.FullReference == reference)];
private static ConcurrentQueue<DataChangeEventArgs> Record(MTConnectDriver driver)
{
var seen = new ConcurrentQueue<DataChangeEventArgs>();
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 ----
/// <summary>
/// The whole blocker in one case: a deployed driver is subscribed by <b>RawPath</b> (that is
/// what <c>DriverInstanceActor.HandleSubscribeAsync</c> passes, and what
/// <c>DriverHostActor</c>'s NodeId table is keyed by) and must answer under that same
/// RawPath — both for the initial value and for every streamed chunk.
/// </summary>
[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);
}
/// <summary>
/// The coercion type must survive the new binding path. The <c>TagConfig</c> blob's
/// <c>driverDataType</c> is what tells the observation index to publish <c>201.5</c> as a
/// <see cref="double"/> 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.
/// </summary>
[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<double>();
For(seen, ExecutionRawPath)[0].Snapshot.Value.ShouldBeOfType<string>();
await driver.ShutdownAsync(Ct);
}
/// <summary>
/// <see cref="IReadable.ReadAsync"/> 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
/// <c>BadNodeIdUnknown</c> for a tag the subscription serves happily would be a lie about the
/// deployment.
/// </summary>
[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);
}
/// <summary>
/// The AdminUI's typed editor (<c>MTConnectTagConfigModel</c>) writes the coercion type under
/// <c>dataType</c>, not <c>driverDataType</c>. 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.
/// </summary>
[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);
}
/// <summary>
/// A browse-committed tag's blob carries ONLY the address (<c>RawBrowseCommitMapper</c> has no
/// MTConnect branch, so it writes the generic <c>{"address": id}</c> shape) — no type at all.
/// The driver falls back to the Agent's own <c>/probe</c> declaration, so a POSITION SAMPLE
/// publishes as a <see cref="double"/> rather than as the Agent's raw text under a
/// numerically-typed OPC UA node.
/// </summary>
[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<double>().ShouldBe(123.4567d);
await driver.ShutdownAsync(Ct);
}
/// <summary>
/// A <see cref="MTConnectDriverOptions.Tags"/> 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.
/// </summary>
[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);
}
/// <summary>
/// <c>RawTags</c> and <c>Tags</c> 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.
/// </summary>
[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);
}
/// <summary>
/// One DataItem may back several authored raw tags (the same machine signal projected into two
/// places in the <c>/raw</c> 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.
/// </summary>
[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);
}
/// <summary>
/// A blob that names no DataItem id, or declares a <c>dataType</c> that is not a
/// <see cref="DriverDataType"/> (including the numerically-serialized-enum trap, where
/// <c>Enum.TryParse</c> would happily take <c>"8"</c> as the member with that ordinal), skips
/// that ONE tag — the rest of the driver deploys and serves normally.
/// </summary>
[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);
}
/// <summary>
/// A redeploy that re-homes a tag in the <c>/raw</c> 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.
/// </summary>
[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);
}
/// <summary>
/// The factory is the production construction seam (<c>DriverFactoryRegistry</c> calls it with
/// the merged artifact config), so <c>RawTags</c> 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.
/// </summary>
[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);
}
/// <summary>
/// A subscribed RawPath with no <c>RawTags</c> 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 <see cref="EquipmentTagRefResolver{TDef}"/> posture: "a miss is a miss".
/// </summary>
[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);
}
}