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;
|
||||
|
||||
/// <summary>
|
||||
@@ -69,6 +71,32 @@ public sealed class MTConnectDriverOptions
|
||||
/// </summary>
|
||||
public IReadOnlyList<MTConnectTagDefinition> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// <b>The v3 data-plane binding: the authored raw tags the deploy artifact delivers.</b> Each
|
||||
/// <see cref="RawTagEntry"/> pairs the tag's <b>RawPath</b> — the driver's wire reference for
|
||||
/// read / subscribe / publish, and the key <c>DriverHostActor</c> routes published values by —
|
||||
/// with the driver-specific <c>TagConfig</c> blob naming the Agent DataItem <c>id</c> it binds.
|
||||
/// Injected into every driver's merged config by <c>DriverDeviceConfigMerger</c>, so this is the
|
||||
/// collection a deployed instance actually serves from.
|
||||
/// <para>
|
||||
/// <b>Relationship to <see cref="Tags"/>.</b> The driver builds ONE observation index and
|
||||
/// ONE <c>RawPath → dataItemId</c> table from both collections:
|
||||
/// <list type="bullet">
|
||||
/// <item>A tag in <c>RawTags</c> is reachable by its RawPath (production). Its coercion
|
||||
/// type comes from the blob's <c>driverDataType</c>; failing that, from a
|
||||
/// <see cref="Tags"/> entry naming the same DataItem id; failing that, from the Agent's
|
||||
/// own <c>/probe</c> declaration (<see cref="MTConnectDataTypeInference"/>); failing
|
||||
/// that, <see cref="DriverDataType.String"/> — the coercion that cannot fail.</item>
|
||||
/// <item>A tag in <see cref="Tags"/> only is reachable by its DataItem <c>id</c>
|
||||
/// (the driver CLI's authoring surface, and every pre-v3 test) and still contributes
|
||||
/// its coercion type to the index.</item>
|
||||
/// </list>
|
||||
/// Both default to empty, so neither collection is required and a driver instance
|
||||
/// authored with no tags yet starts cleanly.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Background connectivity-probe settings. When <see cref="MTConnectProbeOptions.Enabled"/>
|
||||
/// is true the driver periodically issues a cheap <c>/probe</c> request and raises
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -261,6 +262,15 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
private long _announcedInstanceId;
|
||||
|
||||
private MTConnectDriverOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// The tag tables derived from <see cref="_options"/> — the <c>RawPath ↔ dataItemId</c>
|
||||
/// translation the whole data plane runs through, plus the definition set the observation
|
||||
/// index coerces against. Rebuilt (never mutated) whenever options are installed, and always
|
||||
/// published in the same breath as them, so a lock-free reader can never pair one options
|
||||
/// document's tags with another's RawPaths.
|
||||
/// </summary>
|
||||
private TagBinding _binding;
|
||||
private MTConnectObservationIndex _index;
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
private long _nextSubscriptionId;
|
||||
@@ -370,16 +380,21 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_logger = logger ?? NullLogger<MTConnectDriver>.Instance;
|
||||
_agentClientFactory = agentClientFactory ?? (o => new MTConnectAgentClient(o, logger));
|
||||
_probeScheduler = probeScheduler ?? Task.Delay;
|
||||
|
||||
// Sets _options AND the tag tables derived from it. No /probe model is available here (the
|
||||
// constructor dials nothing), so a raw tag whose blob declares no type falls back to String
|
||||
// until InitializeAsync rebuilds the binding against the Agent's own declaration.
|
||||
_options = options;
|
||||
_binding = BuildBinding(options, probeModel: null);
|
||||
|
||||
// Never null, so every consumer (Task 10's ReadAsync included) can ask it for a snapshot
|
||||
// without a null check: an un-primed index answers BadWaitingForInitialData for an authored
|
||||
// tag and BadNodeIdUnknown otherwise, which is exactly the truth before Initialize runs.
|
||||
_index = new MTConnectObservationIndex(options.Tags);
|
||||
_index = new MTConnectObservationIndex(_binding.IndexTags);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -591,7 +606,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
public long GetMemoryFootprint() =>
|
||||
((long)(Volatile.Read(ref _session)?.ProbeModelDataItemCount ?? 0) * ProbeModelBytesPerDataItem)
|
||||
+ ((long)ObservationIndex.Count * IndexBytesPerEntry)
|
||||
+ ((long)_options.Tags.Count * TagBytesPerEntry);
|
||||
+ ((long)Volatile.Read(ref _binding).IndexTags.Count * TagBytesPerEntry);
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
@@ -695,6 +710,11 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
var client = Volatile.Read(ref _session)?.Client;
|
||||
var options = _options;
|
||||
|
||||
// Read once, for the same reason the session is: every reference in this batch must be
|
||||
// resolved against ONE tag table, not half against the table a concurrent re-initialize
|
||||
// just replaced.
|
||||
var binding = Volatile.Read(ref _binding);
|
||||
|
||||
if (client is null)
|
||||
{
|
||||
// Before InitializeAsync, after a faulted one, or after ShutdownAsync. Health is left
|
||||
@@ -710,14 +730,16 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
|
||||
// Built from the SAME authored tags as the shared index, so the authored-vs-unknown
|
||||
// distinction and every coercion behave identically — it is a snapshot, not a variant.
|
||||
var snapshot = new MTConnectObservationIndex(options.Tags);
|
||||
var snapshot = new MTConnectObservationIndex(binding.IndexTags);
|
||||
snapshot.Apply(current);
|
||||
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
for (var i = 0; i < results.Length; i++)
|
||||
{
|
||||
// Each reference is a RawPath, translated to the DataItem id the index is keyed by.
|
||||
// An unmapped reference falls through unchanged and misses — Bad, never a throw.
|
||||
// Get never throws and never returns null, including for a null/blank reference.
|
||||
results[i] = snapshot.Get(fullReferences[i]);
|
||||
results[i] = snapshot.Get(binding.ToDataItemId(fullReferences[i]));
|
||||
}
|
||||
|
||||
_readDegraded = false;
|
||||
@@ -812,10 +834,13 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
}
|
||||
|
||||
// Raised OUTSIDE the lock — a handler is caller code (see the _subscriptionLock remarks).
|
||||
// The callback carries the caller's OWN reference (the RawPath); only the index lookup
|
||||
// behind it is translated to the DataItem id.
|
||||
var index = ObservationIndex;
|
||||
var binding = Volatile.Read(ref _binding);
|
||||
foreach (var reference in references)
|
||||
{
|
||||
RaiseDataChange(handle, reference, index.Get(reference));
|
||||
RaiseDataChange(handle, reference, index.Get(binding.ToDataItemId(reference)));
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
@@ -1967,6 +1992,13 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
// De-duplicated in document order: one Agent document may report a data item more than once
|
||||
// (a Condition container carries several simultaneously-active states), and the index has
|
||||
// already reconciled those into a single snapshot.
|
||||
//
|
||||
// The Agent reports DataItem ids; subscribers hold RawPaths. Each touched id is therefore
|
||||
// EXPANDED into the references it is published under — every RawPath bound to it (a DataItem
|
||||
// may legitimately back more than one authored raw tag), plus the id itself for the
|
||||
// dataItemId-keyed authoring surface (the driver CLI). Publishing the id instead of the
|
||||
// RawPath would miss DriverHostActor's NodeId table and drop the value silently.
|
||||
var binding = Volatile.Read(ref _binding);
|
||||
var touched = new List<string>(document.Observations.Count);
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var observation in document.Observations)
|
||||
@@ -1978,7 +2010,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
|
||||
if (seen.Add(observation.DataItemId))
|
||||
{
|
||||
touched.Add(observation.DataItemId);
|
||||
binding.AppendReferences(observation.DataItemId, touched);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1991,6 +2023,12 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
/// whole device, and republishing all of it would flood the server with values no client
|
||||
/// asked for.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <paramref name="references"/> are <b>subscriber</b> references — RawPaths under v3 — and
|
||||
/// each is carried through to the callback unchanged; only the index lookup behind it is
|
||||
/// translated to the DataItem id the Agent reports under. That asymmetry is the point: the
|
||||
/// value must arrive under the reference the caller subscribed, or it is routed nowhere.
|
||||
/// </remarks>
|
||||
private void FanOut(IReadOnlyList<string> references, MTConnectObservationIndex index)
|
||||
{
|
||||
if (references.Count == 0)
|
||||
@@ -2009,6 +2047,8 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
states = [.. _subscriptions.Values];
|
||||
}
|
||||
|
||||
var binding = Volatile.Read(ref _binding);
|
||||
|
||||
// The lock is released before any callback: handlers are caller code.
|
||||
foreach (var reference in references)
|
||||
{
|
||||
@@ -2022,7 +2062,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
}
|
||||
|
||||
// Read once per reference, so every handle sees the identical snapshot.
|
||||
snapshot ??= index.Get(reference);
|
||||
snapshot ??= index.Get(binding.ToDataItemId(reference));
|
||||
RaiseDataChange(state.Handle, reference, snapshot);
|
||||
}
|
||||
}
|
||||
@@ -2283,6 +2323,12 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
Tags = dto.Tags is { Count: > 0 }
|
||||
? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
|
||||
: [],
|
||||
|
||||
// v3: the deploy artifact injects the driver's authored raw tags here (see
|
||||
// DriverDeviceConfigMerger). Bound verbatim — the per-entry TagConfig blob is mapped
|
||||
// later, at binding-build time, where a bad blob can be skipped rather than failing the
|
||||
// whole deployment over one tag.
|
||||
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
|
||||
Probe = new MTConnectProbeOptions
|
||||
{
|
||||
Enabled = dto.Probe?.Enabled ?? true,
|
||||
@@ -2352,6 +2398,13 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
|
||||
// ---- commit point: everything below is non-throwing bookkeeping ----
|
||||
_options = options;
|
||||
|
||||
// Derived from the options AND the /probe model just fetched: a raw tag whose TagConfig
|
||||
// blob declares no type takes the type the Agent itself declares for that DataItem, so a
|
||||
// browse-committed tag (whose blob carries only the address) publishes as the number it
|
||||
// is rather than as text. Published before the index, which is built from it.
|
||||
var binding = BuildBinding(options, probe);
|
||||
Volatile.Write(ref _binding, binding);
|
||||
built = null;
|
||||
|
||||
// The Agent this session talks to, as of now. A restart is measured against THIS, so a
|
||||
@@ -2365,7 +2418,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
ref _session,
|
||||
new AgentSession(client, probe, CountDataItems(probe), current.InstanceId, current.NextSequence));
|
||||
|
||||
var index = new MTConnectObservationIndex(options.Tags);
|
||||
var index = new MTConnectObservationIndex(binding.IndexTags);
|
||||
index.Apply(current);
|
||||
Volatile.Write(ref _index, index);
|
||||
|
||||
@@ -2431,8 +2484,11 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
SafeDispose(session?.Client);
|
||||
|
||||
// A fresh index rather than Clear(): the tag table it coerces against belongs to the options
|
||||
// that are being torn down.
|
||||
Volatile.Write(ref _index, new MTConnectObservationIndex(_options.Tags));
|
||||
// that are being torn down. The binding is left in place — it is a pure function of the
|
||||
// options still installed, and a reference must keep resolving to the tag it names so a read
|
||||
// against a torn-down driver answers BadWaitingForInitialData ("authored, no value") rather
|
||||
// than BadNodeIdUnknown ("no such tag").
|
||||
Volatile.Write(ref _index, new MTConnectObservationIndex(Volatile.Read(ref _binding).IndexTags));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
@@ -2886,6 +2942,230 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
dto.Units);
|
||||
}
|
||||
|
||||
// ---- v3 tag binding: RawPath ↔ dataItemId, and the definitions the index coerces against ----
|
||||
|
||||
/// <summary>
|
||||
/// Derives the driver's tag tables from an options document — the <c>RawPath → dataItemId</c>
|
||||
/// translation the data plane runs through, its reverse, and the definition set the
|
||||
/// observation index coerces against.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A bad raw tag is skipped, never thrown.</b> One unreadable <c>TagConfig</c> blob
|
||||
/// must not fail a whole driver — and therefore a whole deployment — over a tag the rest
|
||||
/// of the plant does not depend on. The skipped tag's RawPath then resolves to nothing
|
||||
/// and reports <c>BadNodeIdUnknown</c>, which is the documented
|
||||
/// <see cref="EquipmentTagRefResolver{TDef}"/> posture ("a miss is a miss") and is
|
||||
/// visible to an operator, unlike a silent default.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Coercion type precedence</b> (see <see cref="MTConnectDriverOptions.RawTags"/>):
|
||||
/// the blob's own <c>driverDataType</c>, else a <see cref="MTConnectDriverOptions.Tags"/>
|
||||
/// entry naming the same DataItem id, else the Agent's <c>/probe</c> declaration for that
|
||||
/// data item, else <see cref="DriverDataType.String"/>. The third step is what makes a
|
||||
/// browse-committed tag work: <c>RawBrowseCommitMapper</c> writes only the address into
|
||||
/// the blob, so without it every value from the address picker would publish as text
|
||||
/// under a numerically-typed OPC UA node.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="options">The options document to derive from.</param>
|
||||
/// <param name="probeModel">The Agent's device model when one has been fetched; else null.</param>
|
||||
private TagBinding BuildBinding(MTConnectDriverOptions options, MTConnectProbeModel? probeModel)
|
||||
{
|
||||
// The dataItemId-keyed authoring surface first (the driver CLI, and every pre-v3 caller).
|
||||
// Last-wins on a duplicate id, matching MTConnectObservationIndex's own documented rule.
|
||||
var definitions = new Dictionary<string, MTConnectTagDefinition>(StringComparer.Ordinal);
|
||||
foreach (var tag in options.Tags)
|
||||
{
|
||||
if (tag is null || string.IsNullOrWhiteSpace(tag.FullName)) continue;
|
||||
|
||||
definitions[tag.FullName] = tag;
|
||||
}
|
||||
|
||||
if (options.RawTags.Count == 0)
|
||||
{
|
||||
return new TagBinding(
|
||||
FrozenDictionary<string, string>.Empty,
|
||||
FrozenDictionary<string, IReadOnlyList<string>>.Empty,
|
||||
[.. definitions.Values]);
|
||||
}
|
||||
|
||||
var declared = IndexProbeDataItems(probeModel);
|
||||
var idByRawPath = new Dictionary<string, string>(options.RawTags.Count, StringComparer.Ordinal);
|
||||
var rawPathsById = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var entry in options.RawTags)
|
||||
{
|
||||
if (entry is null || string.IsNullOrWhiteSpace(entry.RawPath)) continue;
|
||||
|
||||
if (!TryReadRawTag(entry, out var dataItemId, out var authored))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"MTConnect driver {DriverInstanceId} could not map the raw tag {RawPath} to an Agent DataItem; it names no readable id (expected 'fullName', 'dataItemId' or 'address') or declares an unrecognised driverDataType. The tag is skipped and reports BadNodeIdUnknown; every other tag is unaffected.",
|
||||
_driverInstanceId, entry.RawPath);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
idByRawPath[entry.RawPath] = dataItemId;
|
||||
|
||||
if (!rawPathsById.TryGetValue(dataItemId, out var paths))
|
||||
{
|
||||
rawPathsById[dataItemId] = paths = [];
|
||||
}
|
||||
|
||||
if (!paths.Contains(entry.RawPath, StringComparer.Ordinal))
|
||||
{
|
||||
// One DataItem may legitimately back several authored raw tags; a duplicate RawPath
|
||||
// (the same tag listed twice) must still publish once.
|
||||
paths.Add(entry.RawPath);
|
||||
}
|
||||
|
||||
if (authored is not null)
|
||||
{
|
||||
// The blob declared a type: it is the deploy-time authority and supersedes an older
|
||||
// Tags entry for the same DataItem.
|
||||
definitions[dataItemId] = authored;
|
||||
}
|
||||
else if (!definitions.ContainsKey(dataItemId))
|
||||
{
|
||||
definitions[dataItemId] = InferDefinition(dataItemId, declared);
|
||||
}
|
||||
}
|
||||
|
||||
return new TagBinding(
|
||||
idByRawPath.ToFrozenDictionary(StringComparer.Ordinal),
|
||||
rawPathsById.ToFrozenDictionary(
|
||||
pair => pair.Key,
|
||||
pair => (IReadOnlyList<string>)pair.Value,
|
||||
StringComparer.Ordinal),
|
||||
[.. definitions.Values]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads one authored raw tag: the DataItem id it binds, and the definition its blob declares
|
||||
/// (null when the blob declares no type and the caller must fall back).
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <see langword="false"/> when the blob is unreadable, names no DataItem id, or carries a
|
||||
/// <c>driverDataType</c> that is not a <see cref="DriverDataType"/> — a present-but-invalid
|
||||
/// type rejects the tag rather than silently defaulting, which would publish the Agent's text
|
||||
/// as a Good value of the wrong type (the sibling drivers' strict-enum posture).
|
||||
/// </returns>
|
||||
private static bool TryReadRawTag(
|
||||
RawTagEntry entry, out string dataItemId, out MTConnectTagDefinition? authored)
|
||||
{
|
||||
dataItemId = string.Empty;
|
||||
authored = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entry.TagConfig)) return false;
|
||||
|
||||
MTConnectRawTagConfigDto? blob;
|
||||
try
|
||||
{
|
||||
blob = JsonSerializer.Deserialize<MTConnectRawTagConfigDto>(entry.TagConfig, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (blob is null) return false;
|
||||
|
||||
var id = FirstNonBlank(blob.FullName, blob.DataItemId, blob.Address);
|
||||
if (id is null) return false;
|
||||
|
||||
dataItemId = id;
|
||||
|
||||
// "driverDataType" (the driver's own tags[] field) or "dataType" (what the AdminUI's typed
|
||||
// MTConnectTagConfigModel writes) — see the DTO remarks.
|
||||
var authoredType = FirstNonBlank(blob.DriverDataType, blob.DataType);
|
||||
if (authoredType is null) return true;
|
||||
|
||||
// A digits-only type is rejected outright, BEFORE the parse: Enum.TryParse accepts "8" as
|
||||
// readily as "Float64" and returns the member with that ordinal, so a numerically-serialized
|
||||
// enum (the repo-wide authoring trap) would otherwise coerce every value of that tag to a
|
||||
// type nobody chose, under Good quality. The AdminUI's MTConnectTagConfigModel.Validate
|
||||
// refuses the same shape; this is the runtime half of that guard.
|
||||
if (authoredType.All(char.IsAsciiDigit)
|
||||
|| !Enum.TryParse<DriverDataType>(authoredType, ignoreCase: true, out var parsed)
|
||||
|| !Enum.IsDefined(parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
authored = new MTConnectTagDefinition(
|
||||
dataItemId,
|
||||
parsed,
|
||||
blob.IsArray ?? false,
|
||||
blob.ArrayDim ?? 0,
|
||||
blob.MtCategory,
|
||||
blob.MtType,
|
||||
blob.MtSubType,
|
||||
blob.Units);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The definition for a raw tag whose blob declared no type: the Agent's own <c>/probe</c>
|
||||
/// declaration when the model is in hand, else <see cref="DriverDataType.String"/> — the one
|
||||
/// coercion that cannot fail, carrying the Agent's text through untouched.
|
||||
/// </summary>
|
||||
private static MTConnectTagDefinition InferDefinition(
|
||||
string dataItemId, FrozenDictionary<string, MTConnectDataItem> declared)
|
||||
{
|
||||
if (!declared.TryGetValue(dataItemId, out var dataItem))
|
||||
{
|
||||
return new MTConnectTagDefinition(dataItemId, DriverDataType.String);
|
||||
}
|
||||
|
||||
var inferred = MTConnectDataTypeInference.Infer(
|
||||
dataItem.Category, dataItem.Type, dataItem.Units, dataItem.Representation, dataItem.SampleCount);
|
||||
|
||||
return new MTConnectTagDefinition(
|
||||
dataItemId,
|
||||
inferred.DataType,
|
||||
inferred.IsArray,
|
||||
(int)(inferred.ArrayDim ?? 0),
|
||||
dataItem.Category,
|
||||
dataItem.Type,
|
||||
dataItem.SubType,
|
||||
dataItem.Units);
|
||||
}
|
||||
|
||||
/// <summary>Flattens a device model into a <c>dataItemId → DataItem</c> lookup; null ⇒ empty.</summary>
|
||||
private static FrozenDictionary<string, MTConnectDataItem> IndexProbeDataItems(MTConnectProbeModel? probeModel)
|
||||
{
|
||||
if (probeModel is null) return FrozenDictionary<string, MTConnectDataItem>.Empty;
|
||||
|
||||
var map = new Dictionary<string, MTConnectDataItem>(StringComparer.Ordinal);
|
||||
foreach (var device in probeModel.Devices)
|
||||
{
|
||||
if (device is null) continue;
|
||||
|
||||
foreach (var dataItem in device.AllDataItems())
|
||||
{
|
||||
if (dataItem is null || string.IsNullOrWhiteSpace(dataItem.Id)) continue;
|
||||
|
||||
map[dataItem.Id] = dataItem;
|
||||
}
|
||||
}
|
||||
|
||||
return map.ToFrozenDictionary(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>The first of <paramref name="candidates"/> that carries text, trimmed; else null.</summary>
|
||||
private static string? FirstNonBlank(params string?[] candidates)
|
||||
{
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(candidate)) return candidate.Trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an enum authored as a <b>name</b>. Config surfaces serialize enums as names
|
||||
/// project-wide; a numerically-serialized enum is the known trap that faults a driver at
|
||||
@@ -2930,6 +3210,70 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
long InstanceId,
|
||||
long NextSequence);
|
||||
|
||||
/// <summary>
|
||||
/// The tag tables one options document derives, as one immutable unit — so installing new
|
||||
/// options is a single reference swap and no reader can pair one document's RawPaths with
|
||||
/// another's definitions.
|
||||
/// </summary>
|
||||
/// <param name="DataItemIdByRawPath">
|
||||
/// The v3 wire translation: the RawPath the server subscribes / reads / routes by, mapped to
|
||||
/// the Agent DataItem <c>id</c> the observation index is keyed by.
|
||||
/// </param>
|
||||
/// <param name="RawPathsByDataItemId">
|
||||
/// The reverse, one-to-many: every RawPath a DataItem must be published under. A DataItem may
|
||||
/// legitimately back more than one authored raw tag, and each of those tags is a separate
|
||||
/// OPC UA node that must receive the value.
|
||||
/// </param>
|
||||
/// <param name="IndexTags">
|
||||
/// The definition set <see cref="MTConnectObservationIndex"/> coerces against — the union of
|
||||
/// the authored <see cref="MTConnectDriverOptions.Tags"/> and the definitions derived from
|
||||
/// <see cref="MTConnectDriverOptions.RawTags"/>. This is how a raw tag's declared (or
|
||||
/// probe-inferred) <c>DriverDataType</c> reaches the coercion.
|
||||
/// </param>
|
||||
private sealed record TagBinding(
|
||||
FrozenDictionary<string, string> DataItemIdByRawPath,
|
||||
FrozenDictionary<string, IReadOnlyList<string>> RawPathsByDataItemId,
|
||||
IReadOnlyList<MTConnectTagDefinition> IndexTags)
|
||||
{
|
||||
/// <summary>
|
||||
/// Translates a subscriber/read reference to the DataItem id the index is keyed by.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An unmapped reference is returned <b>unchanged</b> rather than rejected, which serves
|
||||
/// two callers at once: the dataItemId-keyed authoring surface (the driver CLI, and a
|
||||
/// driver configured with <c>Tags</c> and no <c>RawTags</c>) resolves normally, and a
|
||||
/// RawPath that no authored raw tag claims falls through to an index miss —
|
||||
/// <c>BadNodeIdUnknown</c>, never an exception. There is no third behaviour to add here:
|
||||
/// the index already distinguishes "authored but not yet reported" from "unknown".
|
||||
/// </remarks>
|
||||
/// <param name="reference">The caller's reference (a RawPath under v3).</param>
|
||||
/// <returns>The DataItem id to look up.</returns>
|
||||
public string ToDataItemId(string? reference) =>
|
||||
reference is not null && DataItemIdByRawPath.TryGetValue(reference, out var dataItemId)
|
||||
? dataItemId
|
||||
: reference ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Appends every reference <paramref name="dataItemId"/> must be published under: each
|
||||
/// RawPath bound to it, plus the id itself for the dataItemId-keyed authoring surface.
|
||||
/// </summary>
|
||||
/// <param name="dataItemId">The DataItem the Agent just reported.</param>
|
||||
/// <param name="into">The reference list being accumulated for the fan-out.</param>
|
||||
public void AppendReferences(string dataItemId, List<string> into)
|
||||
{
|
||||
if (RawPathsByDataItemId.TryGetValue(dataItemId, out var rawPaths))
|
||||
{
|
||||
into.AddRange(rawPaths);
|
||||
|
||||
// Only when a RawPath is spelled identically to the id — otherwise a subscriber
|
||||
// holding that one reference would be raised twice for one observation.
|
||||
if (rawPaths.Contains(dataItemId, StringComparer.Ordinal)) return;
|
||||
}
|
||||
|
||||
into.Add(dataItemId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One live subscription: its handle and the references it wants. Both are effectively
|
||||
/// immutable once registered, so the pump reads <see cref="References"/> without a lock —
|
||||
@@ -2982,10 +3326,62 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
|
||||
public int? SampleCount { get; init; }
|
||||
public int? HeartbeatMs { get; init; }
|
||||
public List<MTConnectTagDto>? Tags { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The v3 authored raw tags the deploy artifact injects (RawPath + driver <c>TagConfig</c>
|
||||
/// blob). Bound verbatim into <see cref="MTConnectDriverOptions.RawTags"/>; this is the
|
||||
/// collection a deployed instance's data plane is keyed by.
|
||||
/// </summary>
|
||||
public List<RawTagEntry>? RawTags { get; init; }
|
||||
public MTConnectProbeDto? Probe { get; init; }
|
||||
public MTConnectReconnectDto? Reconnect { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A raw tag's <c>TagConfig</c> blob: the driver-specific address of one authored raw tag.
|
||||
/// Every field is optional — the blob is operator-authorable and reaches the driver through
|
||||
/// several authoring surfaces that populate different subsets of it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Three accepted spellings of the DataItem id</b>, tried in order:
|
||||
/// <c>fullName</c> (the driver's own tag shape, and what the typed editor writes),
|
||||
/// <c>dataItemId</c> (the protocol's own word for it, and the spelling an operator
|
||||
/// hand-authoring the blob reaches for first), and <c>address</c> (what
|
||||
/// <c>RawBrowseCommitMapper</c> writes for a driver with no typed editor of its own — the
|
||||
/// shape every browse-committed MTConnect tag currently arrives in). Accepting all three
|
||||
/// costs two dictionary probes; rejecting two of them would make a tag committed from the
|
||||
/// address picker silently unreadable.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Two accepted spellings of the coercion type</b>, likewise: <c>dataType</c> — what
|
||||
/// the AdminUI's <c>MTConnectTagConfigModel</c> writes, and therefore what every tag
|
||||
/// authored through the typed editor carries — and <c>driverDataType</c>, matching the
|
||||
/// driver's own <c>tags[]</c> field name. Reading only one of them would make the editor
|
||||
/// and the driver disagree about the type of every tag.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Kept separate from <see cref="MTConnectTagDto"/> rather than reusing it: that DTO is
|
||||
/// the strict <c>tags[]</c> surface, where a missing <c>fullName</c> is a config error
|
||||
/// worth failing the deployment over. A raw tag's blob is authored elsewhere and its
|
||||
/// failure mode is per-tag, not per-driver.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private sealed class MTConnectRawTagConfigDto
|
||||
{
|
||||
public string? FullName { get; init; }
|
||||
public string? DataItemId { get; init; }
|
||||
public string? Address { get; init; }
|
||||
public string? DriverDataType { get; init; }
|
||||
public string? DataType { get; init; }
|
||||
public bool? IsArray { get; init; }
|
||||
public int? ArrayDim { get; init; }
|
||||
public string? MtCategory { get; init; }
|
||||
public string? MtType { get; init; }
|
||||
public string? MtSubType { get; init; }
|
||||
public string? Units { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One authored tag. <c>DriverDataType</c> stays a string — see <see cref="ParseEnum{T}"/>.</summary>
|
||||
private sealed class MTConnectTagDto
|
||||
{
|
||||
|
||||
+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