feat(mtconnect): ITagDiscovery streams tree + SupportsOnlineDiscovery opt-in (Task 12)
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user