fix(mtconnect): bind RawTags and key the data plane by RawPath
The driver could not receive data after a real deploy. The runtime hands every
driver its authored tags as a RawTags array (RawPath identity + TagConfig blob,
injected by DriverDeviceConfigMerger) and then subscribes, reads, and routes
published values by RawPath. MTConnectDriverConfigDto declared no RawTags
property, so System.Text.Json silently dropped the array, and every reference
the server passed missed an index keyed by DataItem id: every value came back
BadNodeIdUnknown, with the whole suite green because every test handed the
driver its own Tags array directly.
- MTConnectDriverOptions gains RawTags; the config DTO binds it (verbatim — a
bad blob is skipped per-tag at binding time, never fails the deployment).
- A new TagBinding, derived from the options and swapped with them in one write,
carries RawPath -> dataItemId, its one-to-many reverse, and the definition set
the observation index coerces against. Subscribe/Read resolve through it; the
fan-out expands each touched dataItemId into every RawPath bound to it and
publishes under THAT reference — the line the blocker turned on.
- Coercion type precedence: the blob's driverDataType/dataType (both spellings —
the AdminUI's MTConnectTagConfigModel writes the latter), else a Tags entry for
the same DataItem, else the Agent's own /probe declaration via
MTConnectDataTypeInference (what makes a browse-committed {"address": id} blob
publish as a number), else String. A present-but-invalid type rejects that tag.
- An unmapped reference falls through unchanged, so the dataItemId-keyed CLI
surface and every existing test keep working, and an unclaimed RawPath reports
Bad rather than throwing.
15 new tests build their config through the real DriverDeviceConfigMerger; all
four mutations (publish the dataItemId, drop the RawTags binding, drop the
derived coercion type, drop the reverse-map expansion) are caught.
This commit is contained in:
@@ -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