feat(mtconnect): ITagDiscovery streams tree + SupportsOnlineDiscovery opt-in (Task 12)

This commit is contained in:
Joseph Doherty
2026-07-24 16:41:21 -04:00
parent 9a67ed918a
commit 13dd9fcd70
3 changed files with 1053 additions and 5 deletions
@@ -43,10 +43,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// </para>
/// <para>
/// <b>Scope.</b> This type currently implements <see cref="IDriver"/>,
/// <see cref="IReadable"/> and <see cref="ISubscribable"/>. <c>ITagDiscovery</c> (Task 12)
/// and <c>IHostConnectivityProbe</c>/<c>IRediscoverable</c> (Task 13) are added on top of
/// the state this class already caches — the probe model, the Agent <c>instanceId</c>, and
/// the observation index.
/// <see cref="IReadable"/>, <see cref="ISubscribable"/> and <see cref="ITagDiscovery"/>.
/// <c>IHostConnectivityProbe</c>/<c>IRediscoverable</c> (Task 13) are added on top of the
/// state this class already caches — the probe model, the Agent <c>instanceId</c>, and the
/// observation index. There is deliberately no <c>IWritable</c>: MTConnect is a read-only
/// protocol, which is also why every discovered leaf is
/// <see cref="SecurityClassification.ViewOnly"/>.
/// </para>
/// <para>
/// <b>The read path never writes the shared observation index</b> — see
@@ -54,7 +56,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// (<see cref="RunSampleStreamAsync"/>).
/// </para>
/// </remarks>
public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable
public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDiscovery
{
/// <summary>
/// Coarse per-entry byte weights behind <see cref="GetMemoryFootprint"/>. The contract asks
@@ -733,6 +735,240 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable
_driverInstanceId, ours.DiagnosticId, stopStream ? " and stopped the shared /sample stream" : string.Empty);
}
// ---- ITagDiscovery: the /probe device model, streamed as folders + variables ----
/// <inheritdoc/>
/// <remarks>
/// <b>This one member is the whole browse opt-in.</b> The Wave-0 universal
/// <c>DiscoveryDriverBrowser</c> turns any driver that answers <c>true</c> here into a live
/// address picker by capturing <see cref="DiscoverAsync"/> — so this driver ships no browser
/// code, no per-driver picker component, and no entry in the browser's browse-patch table
/// (its discovery is unconditional; there is no "enable browse" option to turn on first).
/// It is answered on an <b>un-initialized</b> instance without dialling anything, because
/// <c>CanBrowse</c> constructs a throwaway driver purely to read it — which is also why the
/// constructor opens nothing.
/// </remarks>
public bool SupportsOnlineDiscovery => true;
/// <inheritdoc/>
/// <remarks>
/// <c>Once</c>, not the default <c>UntilStable</c>: <see cref="DiscoverAsync"/> streams the
/// complete device model within the single call — <c>/probe</c> is one synchronous request
/// that either answers with the whole hierarchy or fails — so nothing fills in asynchronously
/// after connect and a settle loop would only re-fetch an identical answer. (The one thing
/// that <i>does</i> change the model is an Agent restart, and that is
/// <c>IRediscoverable</c>'s job in Task 13, not a retry cadence's.)
/// </remarks>
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
/// <inheritdoc/>
/// <remarks>
/// <para>
/// Streams the Agent's <c>/probe</c> hierarchy verbatim: one folder per <c>Device</c>,
/// one per nested <c>Component</c> to arbitrary depth, and one variable per
/// <c>DataItem</c> under whichever container declares it — <b>including the data items a
/// device declares directly</b>, outside any component (an availability item almost
/// always lives there, and a walker that only recursed <c>Components</c> would silently
/// drop it).
/// </para>
/// <para>
/// <b><see cref="DriverAttributeInfo.FullName"/> is the <c>DataItem@id</c>, never the
/// browse name.</b> The universal browser commits a browsed leaf's <c>FullName</c>
/// straight into <c>TagConfig.FullName</c>, and that is the key
/// <see cref="ReadAsync"/> and the <c>/sample</c> pump resolve an observation against
/// (<see cref="MTConnectObservationIndex"/> is keyed by <c>DataItem@id</c>). Committing
/// the <c>name</c> instead would produce a picker that looks right and authors tags that
/// can never report a value. The browse <i>name</i> — cosmetic — prefers the DataItem's
/// <c>name</c> and falls back to its <c>id</c>, which for this protocol is the common
/// case rather than an edge case.
/// </para>
/// <para>
/// <b>The type shape comes from <see cref="MTConnectDataTypeInference.Infer"/>'s returned
/// record, in full</b> — <see cref="MTConnectInferredType.IsArray"/> and
/// <see cref="MTConnectInferredType.ArrayDim"/> included. Recomputing the array shape
/// locally from <c>representation</c>/<c>sampleCount</c> is the tempting shortcut and it
/// is wrong: the inference also demotes <c>DATA_SET</c>/<c>TABLE</c> to
/// <see cref="DriverDataType.String"/> even on a <c>SAMPLE</c>, and honours
/// <c>TIME_SERIES</c> only on a <c>SAMPLE</c>. The AdminUI typed editor calls the same
/// function, so any divergence here makes the picker and the editor disagree about one
/// data item. The <c>type</c> attribute is passed <b>verbatim</b> for the same reason:
/// the probe document spells it UPPER_SNAKE (<c>PART_COUNT</c>) where the streams
/// document uses PascalCase, and the inference is separator-insensitive precisely to
/// bridge the two — pre-normalising it here would defeat that.
/// </para>
/// <para>
/// <b><see cref="DriverAttributeInfo.IsAlarm"/> is this seam's job</b>, deliberately not
/// the inference's: a <c>CONDITION</c> data item is an alarm.
/// <c>IVariableHandle.MarkAsAlarmCondition</c> is <i>not</i> called — that annotation
/// needs the driver-side refs of an alarm's sibling attributes (in-alarm flag, priority,
/// ack target), and an MTConnect condition has none: it is one text observation whose
/// value is the state word. Core's generic node manager enriches on the flag alone.
/// </para>
/// <para>
/// <b>The model comes from <see cref="GetOrFetchProbeModelAsync"/>, never from
/// <see cref="CachedProbeModel"/>.</b> <see cref="FlushOptionalCachesAsync"/> may have
/// dropped the cache under memory pressure; reading the field directly would make a
/// flushed driver browse as an Agent with no data items, permanently.
/// </para>
/// <para>
/// <b>This throws, and that is the opposite of <see cref="ReadAsync"/>'s posture on
/// purpose.</b> A read has a per-reference Bad status code to report a failure in-band; a
/// discovery stream has no such channel, so the only way to "fail quietly" would be to
/// emit an empty tree — indistinguishable from an Agent that genuinely declares nothing,
/// and read by an operator in the picker as a working connection to an empty machine
/// (#485: empty is not an answer). Both production callers handle the throw: the
/// universal browser surfaces it as a failed browse, and <c>DriverInstanceActor</c> logs
/// it and retries on its rediscover tick.
/// </para>
/// <para>
/// <b>Nothing here writes the observation index.</b> Discovery is a pure read of the
/// device model — the <c>/sample</c> pump remains its sole writer.
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <c>null</c>.</exception>
/// <exception cref="InvalidOperationException">
/// The driver is not initialized, or it was torn down while the <c>/probe</c> fetch was in
/// flight (see <see cref="GetOrFetchProbeModelAsync"/>).
/// </exception>
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
var model = await GetOrFetchProbeModelAsync(cancellationToken).ConfigureAwait(false);
// Read once: a concurrent re-initialize must not scope half this walk to one device and half
// to another.
var deviceScope = _options.DeviceName?.Trim();
var devices = 0;
var variables = 0;
foreach (var device in model.Devices)
{
if (device is null || !InDeviceScope(device, deviceScope))
{
continue;
}
devices++;
var name = FolderName(device.Name, device.Id);
variables += StreamContainer(device, builder.Folder(name, name));
}
_logger.LogDebug(
"MTConnect driver {DriverInstanceId} streamed {DeviceCount} device(s) and {VariableCount} data item(s) from {AgentUri}'s /probe model{DeviceScope}.",
_driverInstanceId,
devices,
variables,
_options.AgentUri,
deviceScope is null or "" ? string.Empty : $" (scoped to '{deviceScope}')");
}
/// <summary>
/// Streams one device/component's own data items into <paramref name="scope"/>, then recurses
/// into each nested component under a folder of its own. Returns how many variables it
/// streamed, for the diagnostic tally.
/// </summary>
/// <remarks>
/// Walks the tree rather than using <c>AllDataItems()</c>: that helper flattens, and the
/// folder structure — which item belongs to which component — is precisely what a browse
/// picker exists to show. Recursion depth is bounded by the parser, which refuses a probe
/// document nested beyond its own limit.
/// </remarks>
private static int StreamContainer(IMTConnectComponentContainer container, IAddressSpaceBuilder scope)
{
var streamed = 0;
foreach (var dataItem in container.DataItems)
{
// An id-less data item cannot be read, subscribed, or committed as a tag — there would be
// nothing to key it by. The parser already requires the attribute; this is the guard for a
// model built any other way.
if (dataItem is null || string.IsNullOrWhiteSpace(dataItem.Id))
{
continue;
}
// Every field the DataItem declares, straight through — the array shape is READ OFF the
// result, never recomputed here. See the DiscoverAsync remarks.
var inferred = MTConnectDataTypeInference.Infer(
dataItem.Category,
dataItem.Type,
dataItem.Units,
dataItem.Representation,
dataItem.SampleCount);
var browseName = string.IsNullOrWhiteSpace(dataItem.Name) ? dataItem.Id : dataItem.Name;
scope.Variable(
browseName,
browseName,
new DriverAttributeInfo(
FullName: dataItem.Id,
DriverDataType: inferred.DataType,
IsArray: inferred.IsArray,
ArrayDim: inferred.ArrayDim,
// MTConnect is read-only and this driver implements no IWritable, so the tier
// that is read-only from OPC UA is the only honest one.
SecurityClass: SecurityClassification.ViewOnly,
// Historization is authored per tag, never inferred from a device model.
IsHistorized: false,
IsAlarm: IsCondition(dataItem.Category)));
streamed++;
}
foreach (var component in container.Components)
{
if (component is null)
{
continue;
}
var name = FolderName(component.Name, component.Id);
streamed += StreamContainer(component, scope.Folder(name, name));
}
return streamed;
}
/// <summary>
/// Whether a device is in this instance's configured scope. An unset scope is agent-wide.
/// </summary>
/// <remarks>
/// <para>
/// Matched against the device's <c>name</c> <b>or</b> its <c>id</c> — a device model may
/// omit the name, and its id is then what an operator would put in the config and what a
/// scoped Agent URL would carry.
/// </para>
/// <para>
/// <b>Belt-and-braces, not dead code.</b> The production client already narrows the
/// request to <c>{AgentUri}/{DeviceName}/probe</c>, so a conforming Agent answers with the
/// one device anyway. But an Agent (or an intermediary) that ignored the path segment and
/// answered agent-wide would otherwise publish every other machine's data items into an
/// instance scoped to one, and the operator's only evidence would be a picker full of ids
/// they never configured. Matching is <b>exact</b> for the same reason the URL is: a
/// differently-cased device name is not the device the Agent would have served.
/// </para>
/// </remarks>
private static bool InDeviceScope(MTConnectDevice device, string? deviceScope) =>
string.IsNullOrEmpty(deviceScope)
|| string.Equals(device.Name, deviceScope, StringComparison.Ordinal)
|| string.Equals(device.Id, deviceScope, StringComparison.Ordinal);
/// <summary>The folder name for a device/component: its <c>name</c>, or its <c>id</c> when it declares none.</summary>
private static string FolderName(string? name, string id) => string.IsNullOrWhiteSpace(name) ? id : name;
/// <summary>
/// Whether a data item's category makes it an alarm. Lives here rather than in
/// <see cref="MTConnectDataTypeInference"/> because that function answers a <i>type</i>
/// question; this is an address-space one.
/// </summary>
private static bool IsCondition(string? category) =>
category.AsSpan().Trim().Equals("CONDITION", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Starts the shared <c>/sample</c> pump, if there is anything to pump and anything to pump
/// from. <b>Caller must hold <see cref="_subscriptionLock"/>.</b>