feat(mtconnect): read-only MTConnect Agent driver (P1) — 23 tasks + 5-leg live gate #506
@@ -1,3 +1,5 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -69,6 +71,32 @@ public sealed class MTConnectDriverOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<MTConnectTagDefinition> Tags { get; init; } = [];
|
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>
|
/// <summary>
|
||||||
/// Background connectivity-probe settings. When <see cref="MTConnectProbeOptions.Enabled"/>
|
/// Background connectivity-probe settings. When <see cref="MTConnectProbeOptions.Enabled"/>
|
||||||
/// is true the driver periodically issues a cheap <c>/probe</c> request and raises
|
/// 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.Globalization;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -261,6 +262,15 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
|||||||
private long _announcedInstanceId;
|
private long _announcedInstanceId;
|
||||||
|
|
||||||
private MTConnectDriverOptions _options;
|
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 MTConnectObservationIndex _index;
|
||||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||||
private long _nextSubscriptionId;
|
private long _nextSubscriptionId;
|
||||||
@@ -370,16 +380,21 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
|||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
|
|
||||||
_options = options;
|
|
||||||
_driverInstanceId = driverInstanceId;
|
_driverInstanceId = driverInstanceId;
|
||||||
_logger = logger ?? NullLogger<MTConnectDriver>.Instance;
|
_logger = logger ?? NullLogger<MTConnectDriver>.Instance;
|
||||||
_agentClientFactory = agentClientFactory ?? (o => new MTConnectAgentClient(o, logger));
|
_agentClientFactory = agentClientFactory ?? (o => new MTConnectAgentClient(o, logger));
|
||||||
_probeScheduler = probeScheduler ?? Task.Delay;
|
_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
|
// 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
|
// 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.
|
// tag and BadNodeIdUnknown otherwise, which is exactly the truth before Initialize runs.
|
||||||
_index = new MTConnectObservationIndex(options.Tags);
|
_index = new MTConnectObservationIndex(_binding.IndexTags);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -591,7 +606,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
|||||||
public long GetMemoryFootprint() =>
|
public long GetMemoryFootprint() =>
|
||||||
((long)(Volatile.Read(ref _session)?.ProbeModelDataItemCount ?? 0) * ProbeModelBytesPerDataItem)
|
((long)(Volatile.Read(ref _session)?.ProbeModelDataItemCount ?? 0) * ProbeModelBytesPerDataItem)
|
||||||
+ ((long)ObservationIndex.Count * IndexBytesPerEntry)
|
+ ((long)ObservationIndex.Count * IndexBytesPerEntry)
|
||||||
+ ((long)_options.Tags.Count * TagBytesPerEntry);
|
+ ((long)Volatile.Read(ref _binding).IndexTags.Count * TagBytesPerEntry);
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -695,6 +710,11 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
|||||||
var client = Volatile.Read(ref _session)?.Client;
|
var client = Volatile.Read(ref _session)?.Client;
|
||||||
var options = _options;
|
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)
|
if (client is null)
|
||||||
{
|
{
|
||||||
// Before InitializeAsync, after a faulted one, or after ShutdownAsync. Health is left
|
// 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
|
// 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.
|
// 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);
|
snapshot.Apply(current);
|
||||||
|
|
||||||
var results = new DataValueSnapshot[fullReferences.Count];
|
var results = new DataValueSnapshot[fullReferences.Count];
|
||||||
for (var i = 0; i < results.Length; i++)
|
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.
|
// 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;
|
_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).
|
// 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 index = ObservationIndex;
|
||||||
|
var binding = Volatile.Read(ref _binding);
|
||||||
foreach (var reference in references)
|
foreach (var reference in references)
|
||||||
{
|
{
|
||||||
RaiseDataChange(handle, reference, index.Get(reference));
|
RaiseDataChange(handle, reference, index.Get(binding.ToDataItemId(reference)));
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogDebug(
|
_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
|
// 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
|
// (a Condition container carries several simultaneously-active states), and the index has
|
||||||
// already reconciled those into a single snapshot.
|
// 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 touched = new List<string>(document.Observations.Count);
|
||||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
foreach (var observation in document.Observations)
|
foreach (var observation in document.Observations)
|
||||||
@@ -1978,7 +2010,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
|||||||
|
|
||||||
if (seen.Add(observation.DataItemId))
|
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
|
/// whole device, and republishing all of it would flood the server with values no client
|
||||||
/// asked for.
|
/// asked for.
|
||||||
/// </summary>
|
/// </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)
|
private void FanOut(IReadOnlyList<string> references, MTConnectObservationIndex index)
|
||||||
{
|
{
|
||||||
if (references.Count == 0)
|
if (references.Count == 0)
|
||||||
@@ -2009,6 +2047,8 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
|||||||
states = [.. _subscriptions.Values];
|
states = [.. _subscriptions.Values];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var binding = Volatile.Read(ref _binding);
|
||||||
|
|
||||||
// The lock is released before any callback: handlers are caller code.
|
// The lock is released before any callback: handlers are caller code.
|
||||||
foreach (var reference in references)
|
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.
|
// 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);
|
RaiseDataChange(state.Handle, reference, snapshot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2283,6 +2323,12 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
|||||||
Tags = dto.Tags is { Count: > 0 }
|
Tags = dto.Tags is { Count: > 0 }
|
||||||
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
|
? [.. 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
|
Probe = new MTConnectProbeOptions
|
||||||
{
|
{
|
||||||
Enabled = dto.Probe?.Enabled ?? true,
|
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 ----
|
// ---- commit point: everything below is non-throwing bookkeeping ----
|
||||||
_options = options;
|
_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;
|
built = null;
|
||||||
|
|
||||||
// The Agent this session talks to, as of now. A restart is measured against THIS, so a
|
// 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,
|
ref _session,
|
||||||
new AgentSession(client, probe, CountDataItems(probe), current.InstanceId, current.NextSequence));
|
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);
|
index.Apply(current);
|
||||||
Volatile.Write(ref _index, index);
|
Volatile.Write(ref _index, index);
|
||||||
|
|
||||||
@@ -2431,8 +2484,11 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
|||||||
SafeDispose(session?.Client);
|
SafeDispose(session?.Client);
|
||||||
|
|
||||||
// A fresh index rather than Clear(): the tag table it coerces against belongs to the options
|
// A fresh index rather than Clear(): the tag table it coerces against belongs to the options
|
||||||
// that are being torn down.
|
// that are being torn down. The binding is left in place — it is a pure function of the
|
||||||
Volatile.Write(ref _index, new MTConnectObservationIndex(_options.Tags));
|
// 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;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -2886,6 +2942,230 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
|||||||
dto.Units);
|
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>
|
/// <summary>
|
||||||
/// Parses an enum authored as a <b>name</b>. Config surfaces serialize enums as names
|
/// 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
|
/// 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 InstanceId,
|
||||||
long NextSequence);
|
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>
|
/// <summary>
|
||||||
/// One live subscription: its handle and the references it wants. Both are effectively
|
/// 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 —
|
/// 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? SampleCount { get; init; }
|
||||||
public int? HeartbeatMs { get; init; }
|
public int? HeartbeatMs { get; init; }
|
||||||
public List<MTConnectTagDto>? Tags { 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 MTConnectProbeDto? Probe { get; init; }
|
||||||
public MTConnectReconnectDto? Reconnect { 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>
|
/// <summary>One authored tag. <c>DriverDataType</c> stays a string — see <see cref="ParseEnum{T}"/>.</summary>
|
||||||
private sealed class MTConnectTagDto
|
private sealed class MTConnectTagDto
|
||||||
{
|
{
|
||||||
|
|||||||
+458
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user