diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
index 36214f5a..f3839fd1 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
@@ -43,10 +43,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
///
///
/// Scope. This type currently implements ,
-/// and . ITagDiscovery (Task 12)
-/// and IHostConnectivityProbe/IRediscoverable (Task 13) are added on top of
-/// the state this class already caches — the probe model, the Agent instanceId, and
-/// the observation index.
+/// , and .
+/// IHostConnectivityProbe/IRediscoverable (Task 13) are added on top of the
+/// state this class already caches — the probe model, the Agent instanceId, and the
+/// observation index. There is deliberately no IWritable: MTConnect is a read-only
+/// protocol, which is also why every discovered leaf is
+/// .
///
///
/// The read path never writes the shared observation index — see
@@ -54,7 +56,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// ().
///
///
-public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable
+public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDiscovery
{
///
/// Coarse per-entry byte weights behind . 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 ----
+
+ ///
+ ///
+ /// This one member is the whole browse opt-in. The Wave-0 universal
+ /// DiscoveryDriverBrowser turns any driver that answers true here into a live
+ /// address picker by capturing — 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 un-initialized instance without dialling anything, because
+ /// CanBrowse constructs a throwaway driver purely to read it — which is also why the
+ /// constructor opens nothing.
+ ///
+ public bool SupportsOnlineDiscovery => true;
+
+ ///
+ ///
+ /// Once, not the default UntilStable: streams the
+ /// complete device model within the single call — /probe 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 does change the model is an Agent restart, and that is
+ /// IRediscoverable's job in Task 13, not a retry cadence's.)
+ ///
+ public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
+
+ ///
+ ///
+ ///
+ /// Streams the Agent's /probe hierarchy verbatim: one folder per Device,
+ /// one per nested Component to arbitrary depth, and one variable per
+ /// DataItem under whichever container declares it — including the data items a
+ /// device declares directly, outside any component (an availability item almost
+ /// always lives there, and a walker that only recursed Components would silently
+ /// drop it).
+ ///
+ ///
+ /// is the DataItem@id, never the
+ /// browse name. The universal browser commits a browsed leaf's FullName
+ /// straight into TagConfig.FullName, and that is the key
+ /// and the /sample pump resolve an observation against
+ /// ( is keyed by DataItem@id). Committing
+ /// the name instead would produce a picker that looks right and authors tags that
+ /// can never report a value. The browse name — cosmetic — prefers the DataItem's
+ /// name and falls back to its id, which for this protocol is the common
+ /// case rather than an edge case.
+ ///
+ ///
+ /// The type shape comes from 's returned
+ /// record, in full — and
+ /// included. Recomputing the array shape
+ /// locally from representation/sampleCount is the tempting shortcut and it
+ /// is wrong: the inference also demotes DATA_SET/TABLE to
+ /// even on a SAMPLE, and honours
+ /// TIME_SERIES only on a SAMPLE. The AdminUI typed editor calls the same
+ /// function, so any divergence here makes the picker and the editor disagree about one
+ /// data item. The type attribute is passed verbatim for the same reason:
+ /// the probe document spells it UPPER_SNAKE (PART_COUNT) where the streams
+ /// document uses PascalCase, and the inference is separator-insensitive precisely to
+ /// bridge the two — pre-normalising it here would defeat that.
+ ///
+ ///
+ /// is this seam's job, deliberately not
+ /// the inference's: a CONDITION data item is an alarm.
+ /// IVariableHandle.MarkAsAlarmCondition is not 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.
+ ///
+ ///
+ /// The model comes from , never from
+ /// . 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.
+ ///
+ ///
+ /// This throws, and that is the opposite of 's posture on
+ /// purpose. 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 DriverInstanceActor logs
+ /// it and retries on its rediscover tick.
+ ///
+ ///
+ /// Nothing here writes the observation index. Discovery is a pure read of the
+ /// device model — the /sample pump remains its sole writer.
+ ///
+ ///
+ /// is null.
+ ///
+ /// The driver is not initialized, or it was torn down while the /probe fetch was in
+ /// flight (see ).
+ ///
+ 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}')");
+ }
+
+ ///
+ /// Streams one device/component's own data items into , then recurses
+ /// into each nested component under a folder of its own. Returns how many variables it
+ /// streamed, for the diagnostic tally.
+ ///
+ ///
+ /// Walks the tree rather than using AllDataItems(): 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// Whether a device is in this instance's configured scope. An unset scope is agent-wide.
+ ///
+ ///
+ ///
+ /// Matched against the device's name or its id — 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.
+ ///
+ ///
+ /// Belt-and-braces, not dead code. The production client already narrows the
+ /// request to {AgentUri}/{DeviceName}/probe, 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 exact for the same reason the URL is: a
+ /// differently-cased device name is not the device the Agent would have served.
+ ///
+ ///
+ 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);
+
+ /// The folder name for a device/component: its name, or its id when it declares none.
+ private static string FolderName(string? name, string id) => string.IsNullOrWhiteSpace(name) ? id : name;
+
+ ///
+ /// Whether a data item's category makes it an alarm. Lives here rather than in
+ /// because that function answers a type
+ /// question; this is an address-space one.
+ ///
+ private static bool IsCondition(string? category) =>
+ category.AsSpan().Trim().Equals("CONDITION", StringComparison.OrdinalIgnoreCase);
+
///
/// Starts the shared /sample pump, if there is anything to pump and anything to pump
/// from. Caller must hold .
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CapturingBuilder.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CapturingBuilder.cs
new file mode 100644
index 00000000..eed2defd
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CapturingBuilder.cs
@@ -0,0 +1,132 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// An that records the tree a driver streams into it, so
+/// DiscoverAsync can be asserted on shape (which node landed under which folder)
+/// and not merely on a flat list of leaves.
+///
+///
+///
+/// Why this exists rather than a reference to the shipped capturing builder. Two
+/// production capturing builders exist — Commons.Browsing.CapturingAddressSpaceBuilder
+/// (universal browser) and Runtime.Drivers.CapturingAddressSpaceBuilder (deploy-time
+/// discovery) — but this suite deliberately references only the driver and its
+/// .Contracts, so neither is reachable without pulling the whole server stack into a
+/// driver unit-test project. Both also flatten differently from what these tests must prove:
+/// the browser's node id for a folder is a synthetic path while a leaf's id is its
+/// FullName, which is exactly the conflation a "did the device-level data item land in
+/// the device folder?" assertion needs to see through.
+///
+///
+/// Deliberately not thread-safe. A driver streams discovery on one caller; recording
+/// behind a lock would hide a driver that fanned out onto other threads.
+///
+///
+internal sealed class CapturingBuilder : IAddressSpaceBuilder
+{
+ private readonly State _state;
+ private readonly string _path;
+
+ /// Creates a root builder — the scope a driver's DiscoverAsync is handed.
+ public CapturingBuilder()
+ {
+ _state = new State();
+ _path = string.Empty;
+ }
+
+ private CapturingBuilder(State state, string path)
+ {
+ _state = state;
+ _path = path;
+ }
+
+ /// Every folder streamed, in call order, with the slash-joined path it landed at.
+ public IReadOnlyList Folders => _state.Folders;
+
+ /// Every variable streamed, in call order, with the folder path it landed under.
+ public IReadOnlyList Variables => _state.Variables;
+
+ /// Every property streamed, with the scope path it was added to.
+ public IReadOnlyList Properties => _state.Properties;
+
+ ///
+ public IAddressSpaceBuilder Folder(string browseName, string displayName)
+ {
+ var path = _path.Length == 0 ? browseName : $"{_path}/{browseName}";
+ _state.Folders.Add(new CapturedFolder(path, _path, browseName, displayName));
+
+ return new CapturingBuilder(_state, path);
+ }
+
+ ///
+ public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
+ {
+ var captured = new CapturedVariable(_path, browseName, displayName, attributeInfo);
+ _state.Variables.Add(captured);
+
+ return new CapturedVariableHandle(captured);
+ }
+
+ ///
+ public void AddProperty(string browseName, DriverDataType dataType, object? value) =>
+ _state.Properties.Add(new CapturedProperty(_path, browseName, dataType, value));
+
+ private sealed class State
+ {
+ public List Folders { get; } = [];
+
+ public List Variables { get; } = [];
+
+ public List Properties { get; } = [];
+ }
+
+ private sealed class CapturedVariableHandle(CapturedVariable variable) : IVariableHandle
+ {
+ public string FullReference => variable.Attr.FullName;
+
+ public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
+ {
+ variable.AlarmConditions.Add(info);
+
+ return new NullSink();
+ }
+
+ private sealed class NullSink : IAlarmConditionSink
+ {
+ public void OnTransition(AlarmEventArgs args)
+ {
+ // Nothing to record: no test in this suite drives an alarm transition through
+ // discovery, and a sink that threw would fail a driver that legitimately never
+ // pushes one.
+ }
+ }
+ }
+}
+
+/// One folder captured from a discovery stream.
+/// Slash-joined path of the folder itself.
+/// Slash-joined path of the scope it was added to (empty at the root).
+/// The browse name the driver supplied.
+/// The display name the driver supplied.
+internal sealed record CapturedFolder(string Path, string ParentPath, string BrowseName, string DisplayName);
+
+/// One variable captured from a discovery stream.
+/// Slash-joined path of the folder it landed under (empty at the root).
+/// The browse name the driver supplied.
+/// The display name the driver supplied.
+/// The driver-side attribute metadata the driver stamped on it.
+internal sealed record CapturedVariable(
+ string ParentPath, string BrowseName, string DisplayName, DriverAttributeInfo Attr)
+{
+ /// Alarm conditions the driver marked this variable with, if any.
+ public List AlarmConditions { get; } = [];
+}
+
+/// One property captured from a discovery stream.
+/// Slash-joined path of the node it was added to.
+/// The property browse name.
+/// The property data type.
+/// The property value.
+internal sealed record CapturedProperty(string ScopePath, string BrowseName, DriverDataType DataType, object? Value);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDiscoverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDiscoverTests.cs
new file mode 100644
index 00000000..369e366b
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDiscoverTests.cs
@@ -0,0 +1,680 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// Task 12 — 's half: the
+/// /probe device model streamed into an as
+/// Device → Component → DataItem, and the one-member browse opt-in
+/// () the Wave-0 universal browser keys off.
+///
+///
+///
+/// The load-bearing assertion is FullName == DataItem@id. The universal
+/// browser commits a browsed leaf's straight into
+/// TagConfig.FullName, and that is the key ReadAsync / the /sample pump
+/// resolve an observation against ( is keyed by
+/// DataItem@id). Streaming the browse name instead would produce a picker that
+/// looks perfect and commits tags that can never report a value — every one of them stuck at
+/// BadWaitingForInitialData behind a Healthy driver.
+///
+///
+/// The second load-bearing assertion is the type shape. Discovery must stamp exactly
+/// what returns, including the array shape — the
+/// AdminUI typed editor calls the same function, so any local recomputation here makes the
+/// picker and the editor disagree about the same data item. PART_COUNT → Int64 is
+/// called out on its own because the probe document's UPPER_SNAKE spelling is the case a
+/// PascalCase-only match silently types as String.
+///
+///
+public sealed class MTConnectDiscoverTests
+{
+ private const string AgentUri = "http://fixture-agent:5000";
+
+ /// Every DataItem id the canned Fixtures/probe.xml declares.
+ private static readonly string[] FixtureDataItemIds =
+ [
+ "dev1_avail",
+ "dev1_pos",
+ "dev1_vibration_ts",
+ "dev1_partcount",
+ "dev1_execution",
+ "dev1_program",
+ "dev1_system_cond",
+ ];
+
+ private static MTConnectDriverOptions Opts(string? deviceName = null) =>
+ new()
+ {
+ AgentUri = AgentUri,
+ DeviceName = deviceName,
+
+ // Discovery is a pure read of the /probe device model: it must enumerate what the Agent
+ // declares, NOT what an operator has already authored. Left empty on purpose so a driver
+ // that streamed its authored tag table instead could not pass.
+ Tags = [],
+ };
+
+ private static (MTConnectDriver Driver, CannedAgentClient Client) NewDriver(
+ MTConnectDriverOptions? options = null, MTConnectProbeModel? probe = null)
+ {
+ var client = CannedAgentClient.FromFixtures();
+ if (probe is not null)
+ {
+ client.Probe = probe;
+ }
+
+ return (new MTConnectDriver(options ?? Opts(), "mt1", _ => client), client);
+ }
+
+ private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedDriverAsync(
+ MTConnectDriverOptions? options = null, MTConnectProbeModel? probe = null)
+ {
+ var (driver, client) = NewDriver(options, probe);
+ await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ return (driver, client);
+ }
+
+ private static async Task DiscoverAsync(MTConnectDriver driver)
+ {
+ var builder = new CapturingBuilder();
+ await driver.DiscoverAsync(builder, TestContext.Current.CancellationToken);
+
+ return builder;
+ }
+
+ // ---- the plan's headline test ----
+
+ ///
+ /// The whole contract in one place: the fixture's Device → Controller → Path tree reaches the
+ /// builder, the CONDITION data item is present under its own id, it types as
+ /// , and the driver declares the browse opt-in the
+ /// universal browser gates on.
+ ///
+ [Fact]
+ public async Task Discover_streams_device_component_dataitem_tree_leaf_fullname_is_dataitemid()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Variables.Select(v => v.Attr.FullName).ShouldContain("dev1_system_cond");
+ cap.Variables.Single(v => v.Attr.IsAlarm).Attr.DriverDataType.ShouldBe(DriverDataType.String);
+ driver.SupportsOnlineDiscovery.ShouldBeTrue();
+ driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
+ }
+
+ // ---- tree shape ----
+
+ ///
+ /// The folder tree mirrors the probe document exactly: one folder per Device (named by its
+ /// name), then one per nested Component to arbitrary depth, each under its own parent.
+ ///
+ [Fact]
+ public async Task Discover_materialises_a_folder_per_device_and_nested_component()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Folders.Select(f => f.Path).ShouldBe(
+ ["VMC-3Axis", "VMC-3Axis/controller", "VMC-3Axis/controller/path"], ignoreOrder: true);
+
+ // Parentage, not just membership: a flat "three folders exist" assertion passes for a walker
+ // that streamed all three onto the root builder.
+ cap.Folders.Single(f => f.Path == "VMC-3Axis").ParentPath.ShouldBe(string.Empty);
+ cap.Folders.Single(f => f.Path == "VMC-3Axis/controller").ParentPath.ShouldBe("VMC-3Axis");
+ cap.Folders.Single(f => f.Path == "VMC-3Axis/controller/path").ParentPath.ShouldBe("VMC-3Axis/controller");
+ }
+
+ ///
+ /// dev1_avail hangs directly off <Device><DataItems>, outside every
+ /// Component. A walker that only recursed <Components> would drop it silently, so
+ /// it is asserted to land in the device folder while the Path-owned items land in the
+ /// path folder.
+ ///
+ [Fact]
+ public async Task Discover_places_device_level_data_items_in_the_device_folder()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Variables.Single(v => v.Attr.FullName == "dev1_avail").ParentPath.ShouldBe("VMC-3Axis");
+ cap.Variables
+ .Where(v => v.Attr.FullName != "dev1_avail")
+ .ShouldAllBe(v => v.ParentPath == "VMC-3Axis/controller/path");
+ }
+
+ ///
+ /// Every DataItem the fixture declares is streamed exactly once, and every leaf's
+ /// is its DataItem@id — the read/subscribe
+ /// key, aligned with the browser's commit by construction.
+ ///
+ [Fact]
+ public async Task Discover_streams_every_data_item_once_with_fullname_equal_to_its_id()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Variables.Count.ShouldBe(FixtureDataItemIds.Length);
+ cap.Variables.Select(v => v.Attr.FullName).ShouldBe(FixtureDataItemIds, ignoreOrder: true);
+ }
+
+ ///
+ /// The FullName-is-the-id rule, asserted where the two can actually differ.
+ ///
+ ///
+ /// The fixture cannot prove this on its own — its data items declare no name, so
+ /// browseName and id coincide and a driver that streamed the browse name as the
+ /// FullName would pass every fixture-based assertion in this file. This model gives
+ /// every data item a name that is disjoint from its id, so the two sets cannot be confused:
+ /// the commit key must be the id set, and the presentation must be the name set.
+ ///
+ [Fact]
+ public async Task Discover_fullname_is_the_id_even_when_the_data_item_declares_a_different_name()
+ {
+ var probe = ModelWith(
+ new MTConnectDataItem("id_pos", "Xpos", "SAMPLE", "POSITION", null, "MILLIMETER", null, null),
+ new MTConnectDataItem("id_count", "PartsMade", "EVENT", "PART_COUNT", null, null, null, null),
+ new MTConnectDataItem("id_cond", "SystemHealth", "CONDITION", "SYSTEM", null, null, null, null),
+ new MTConnectDataItem(
+ "id_series", "Feed", "SAMPLE", "PATH_FEEDRATE", null, null, "TIME_SERIES", 10));
+
+ var (driver, _) = await InitializedDriverAsync(probe: probe);
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Variables.Select(v => v.Attr.FullName)
+ .ShouldBe(["id_pos", "id_count", "id_cond", "id_series"], ignoreOrder: true);
+ cap.Variables.Select(v => v.BrowseName)
+ .ShouldBe(["Xpos", "PartsMade", "SystemHealth", "Feed"], ignoreOrder: true);
+
+ // …and the id-keyed metadata still lands on the right leaf once the two differ.
+ cap.Variables.Single(v => v.Attr.FullName == "id_count").Attr.DriverDataType
+ .ShouldBe(DriverDataType.Int64);
+ cap.Variables.Single(v => v.Attr.IsAlarm).Attr.FullName.ShouldBe("id_cond");
+ cap.Variables.Single(v => v.Attr.IsArray).Attr.FullName.ShouldBe("id_series");
+ }
+
+ // ---- type shape (must equal MTConnectDataTypeInference, not a local recomputation) ----
+
+ ///
+ /// The per-category defaults reach the builder unchanged: SAMPLE ⇒ Float64, controlled
+ /// vocabulary / free-text EVENT ⇒ String, CONDITION ⇒ String.
+ ///
+ [Theory]
+ [InlineData("dev1_pos", DriverDataType.Float64)]
+ [InlineData("dev1_avail", DriverDataType.String)]
+ [InlineData("dev1_execution", DriverDataType.String)]
+ [InlineData("dev1_program", DriverDataType.String)]
+ [InlineData("dev1_system_cond", DriverDataType.String)]
+ public async Task Discover_stamps_the_inferred_data_type(string dataItemId, DriverDataType expected)
+ {
+ var (driver, _) = await InitializedDriverAsync();
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Variables.Single(v => v.Attr.FullName == dataItemId).Attr.DriverDataType.ShouldBe(expected);
+ }
+
+ ///
+ /// PART_COUNT is the live-only bug this driver's inference was already fixed for once:
+ /// the probe document spells it UPPER_SNAKE, so a match written against the Streams
+ /// document's PartCount types every real agent's part counter as
+ /// while a PascalCase unit test stays green. Asserted at
+ /// the discovery seam because that is where the probe spelling is actually read.
+ ///
+ [Fact]
+ public async Task Discover_types_the_upper_snake_PART_COUNT_event_as_Int64()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Variables.Single(v => v.Attr.FullName == "dev1_partcount").Attr.DriverDataType
+ .ShouldBe(DriverDataType.Int64);
+ }
+
+ ///
+ /// A TIME_SERIES SAMPLE is an array whose length is the probe-declared
+ /// sampleCount. The shape comes from 's
+ /// returned record — recomputing it inline at this seam is what makes discovery and the
+ /// AdminUI editor disagree.
+ ///
+ [Fact]
+ public async Task Discover_carries_the_time_series_array_shape_from_the_inference()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+
+ var cap = await DiscoverAsync(driver);
+
+ var series = cap.Variables.Single(v => v.Attr.FullName == "dev1_vibration_ts").Attr;
+ series.DriverDataType.ShouldBe(DriverDataType.Float64);
+ series.IsArray.ShouldBeTrue();
+ series.ArrayDim.ShouldBe(10u);
+ }
+
+ ///
+ /// A DATA_SET SAMPLE demotes to and stays scalar —
+ /// a rule that lives only inside the inference. An inline
+ /// "IsArray = representation == TIME_SERIES, DataType = category == SAMPLE ? Float64 : String"
+ /// at this seam would type it Float64 and go Bad on every parse, and no fixture-only test
+ /// would notice.
+ ///
+ [Fact]
+ public async Task Discover_demotes_a_data_set_sample_to_string_per_the_inference()
+ {
+ var probe = ModelWith(
+ new MTConnectDataItem(
+ "dev9_toolset", null, "SAMPLE", "POSITION", null, "MILLIMETER", "DATA_SET", 4));
+
+ var (driver, _) = await InitializedDriverAsync(probe: probe);
+
+ var cap = await DiscoverAsync(driver);
+
+ var attr = cap.Variables.Single(v => v.Attr.FullName == "dev9_toolset").Attr;
+ attr.DriverDataType.ShouldBe(DriverDataType.String);
+ attr.IsArray.ShouldBeFalse();
+ attr.ArrayDim.ShouldBeNull();
+ }
+
+ ///
+ /// TIME_SERIES is defined for SAMPLE only; on an EVENT the inference ignores it. Pins
+ /// the other half of "use the inference's answer, do not recompute the array shape".
+ ///
+ [Fact]
+ public async Task Discover_ignores_time_series_on_a_non_sample_per_the_inference()
+ {
+ var probe = ModelWith(
+ new MTConnectDataItem("dev9_msg", null, "EVENT", "MESSAGE", null, null, "TIME_SERIES", 8));
+
+ var (driver, _) = await InitializedDriverAsync(probe: probe);
+
+ var cap = await DiscoverAsync(driver);
+
+ var attr = cap.Variables.Single(v => v.Attr.FullName == "dev9_msg").Attr;
+ attr.DriverDataType.ShouldBe(DriverDataType.String);
+ attr.IsArray.ShouldBeFalse();
+ attr.ArrayDim.ShouldBeNull();
+ }
+
+ // ---- the flags this seam owns ----
+
+ ///
+ /// is the caller's job — the inference deliberately
+ /// does not compute it. Exactly the CONDITION data item carries it.
+ ///
+ [Fact]
+ public async Task Discover_marks_only_the_condition_data_item_as_an_alarm()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Variables.Where(v => v.Attr.IsAlarm).Select(v => v.Attr.FullName).ShouldBe(["dev1_system_cond"]);
+ }
+
+ ///
+ /// MTConnect is a read-only protocol and this driver implements no IWritable, so every
+ /// browsed leaf is — the tier that is read-only
+ /// from OPC UA. Nothing is historized by discovery either: historization is authored per tag,
+ /// never inferred from a device model.
+ ///
+ [Fact]
+ public async Task Discover_streams_every_leaf_view_only_and_not_historized()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Variables.ShouldAllBe(v => v.Attr.SecurityClass == SecurityClassification.ViewOnly);
+ cap.Variables.ShouldAllBe(v => !v.Attr.IsHistorized);
+ cap.Variables.ShouldAllBe(v => v.Attr.Source == NodeSourceKind.Driver);
+ }
+
+ // ---- browse names ----
+
+ ///
+ /// The browse name is the DataItem's name when it declares one and its id
+ /// otherwise — the fixture's data items declare none, so the fallback is the normal case for
+ /// this protocol, not an edge case.
+ ///
+ [Fact]
+ public async Task Discover_browse_name_prefers_the_data_item_name_and_falls_back_to_its_id()
+ {
+ var probe = ModelWith(
+ new MTConnectDataItem("dev9_named", "Xpos", "SAMPLE", "POSITION", null, "MILLIMETER", null, null),
+ new MTConnectDataItem("dev9_unnamed", null, "EVENT", "PROGRAM", null, null, null, null));
+
+ var (driver, _) = await InitializedDriverAsync(probe: probe);
+
+ var cap = await DiscoverAsync(driver);
+
+ var named = cap.Variables.Single(v => v.Attr.FullName == "dev9_named");
+ named.BrowseName.ShouldBe("Xpos");
+ named.DisplayName.ShouldBe("Xpos");
+
+ var unnamed = cap.Variables.Single(v => v.Attr.FullName == "dev9_unnamed");
+ unnamed.BrowseName.ShouldBe("dev9_unnamed");
+
+ // The browse name is presentation; the read key is not. Restated here because this is the
+ // one test where the two legitimately differ.
+ named.Attr.FullName.ShouldBe("dev9_named");
+ }
+
+ ///
+ /// A Device or Component with no name attribute falls back to its id rather
+ /// than materialising a blank folder.
+ ///
+ [Fact]
+ public async Task Discover_folder_names_fall_back_to_the_id_when_no_name_is_declared()
+ {
+ var probe = new MTConnectProbeModel(
+ [
+ new MTConnectDevice(
+ "dev9",
+ null,
+ [
+ new MTConnectComponent(
+ "dev9_axes",
+ null,
+ [],
+ [new MTConnectDataItem("dev9_x", null, "SAMPLE", "POSITION", null, null, null, null)]),
+ ],
+ []),
+ ]);
+
+ var (driver, _) = await InitializedDriverAsync(probe: probe);
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Folders.Select(f => f.Path).ShouldBe(["dev9", "dev9/dev9_axes"], ignoreOrder: true);
+ cap.Variables.Single().ParentPath.ShouldBe("dev9/dev9_axes");
+ }
+
+ // ---- DeviceName scoping ----
+
+ ///
+ /// With no DeviceName the driver is agent-wide and every device's subtree is streamed.
+ /// The control for the scoping test below — without it, a discovery that dropped the second
+ /// device for an unrelated reason would look like correct scoping.
+ ///
+ [Fact]
+ public async Task Discover_without_a_device_scope_streams_every_device()
+ {
+ var (driver, _) = await InitializedDriverAsync(probe: TwoDeviceModel());
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Folders.Where(f => f.ParentPath.Length == 0).Select(f => f.BrowseName)
+ .ShouldBe(["VMC-3Axis", "Lathe-2Axis"], ignoreOrder: true);
+ cap.Variables.Select(v => v.Attr.FullName).ShouldContain("dev2_avail");
+ }
+
+ ///
+ /// With DeviceName set, only that device's subtree is streamed.
+ ///
+ ///
+ /// Belt-and-braces rather than dead code: the production client already narrows the request
+ /// to {AgentUri}/{DeviceName}/probe, so a conforming Agent answers with one device
+ /// anyway. But a non-conforming Agent (or a proxy that drops the path segment) that 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.
+ ///
+ [Fact]
+ public async Task Discover_with_a_device_scope_streams_only_that_device_subtree()
+ {
+ var (driver, _) = await InitializedDriverAsync(Opts(deviceName: "Lathe-2Axis"), TwoDeviceModel());
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Folders.Select(f => f.Path).ShouldBe(["Lathe-2Axis"]);
+ cap.Variables.Select(v => v.Attr.FullName).ShouldBe(["dev2_avail"]);
+ }
+
+ ///
+ /// A device scope naming a device the Agent does not declare streams nothing. Kept explicit
+ /// because the alternative reading ("scope did not match, so stream everything") would hand a
+ /// mis-typed device name a picker showing the whole plant.
+ ///
+ [Fact]
+ public async Task Discover_with_an_unmatched_device_scope_streams_nothing()
+ {
+ var (driver, _) = await InitializedDriverAsync(Opts(deviceName: "not-a-device"), TwoDeviceModel());
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Folders.ShouldBeEmpty();
+ cap.Variables.ShouldBeEmpty();
+ }
+
+ ///
+ /// A device with no name is addressable by its id — which is what a scoped
+ /// Agent URL would carry for it too.
+ ///
+ [Fact]
+ public async Task Discover_device_scope_matches_a_nameless_device_by_id()
+ {
+ var probe = new MTConnectProbeModel(
+ [
+ new MTConnectDevice(
+ "dev9", null, [], [new MTConnectDataItem("dev9_a", null, "EVENT", "PROGRAM", null, null, null, null)]),
+ new MTConnectDevice(
+ "dev8", "Other", [], [new MTConnectDataItem("dev8_a", null, "EVENT", "PROGRAM", null, null, null, null)]),
+ ]);
+
+ var (driver, _) = await InitializedDriverAsync(Opts(deviceName: "dev9"), probe);
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Variables.Select(v => v.Attr.FullName).ShouldBe(["dev9_a"]);
+ }
+
+ // ---- the probe-cache contract (anti-wedge) ----
+
+ ///
+ /// Discovery is served from the cache InitializeAsync already populated — a browse
+ /// does not re-hit the Agent for a model it is holding.
+ ///
+ [Fact]
+ public async Task Discover_serves_from_the_cached_probe_model()
+ {
+ var (driver, client) = await InitializedDriverAsync();
+ client.ProbeCallCount.ShouldBe(1);
+
+ await DiscoverAsync(driver);
+
+ client.ProbeCallCount.ShouldBe(1);
+ }
+
+ ///
+ /// …and after has dropped that cache,
+ /// discovery re-fetches and still streams the full tree. This is the whole reason discovery
+ /// must go through GetOrFetchProbeModelAsync rather than reading the cached field: a
+ /// memory-budget flush must never leave browse permanently answering "this agent has no data
+ /// items".
+ ///
+ [Fact]
+ public async Task Discover_refetches_the_probe_model_after_a_cache_flush()
+ {
+ var (driver, client) = await InitializedDriverAsync();
+ await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
+ driver.CachedProbeModel.ShouldBeNull();
+
+ var cap = await DiscoverAsync(driver);
+
+ client.ProbeCallCount.ShouldBe(2);
+ cap.Variables.Select(v => v.Attr.FullName).ShouldBe(FixtureDataItemIds, ignoreOrder: true);
+ driver.CachedProbeModel.ShouldNotBeNull();
+ }
+
+ // ---- discovery never touches the subscription surface ----
+
+ ///
+ /// Discovery is a pure read of the device model: it does not write the shared observation
+ /// index (the /sample pump is its sole writer) and it does not issue a /current.
+ /// A discovery that folded a snapshot into the index would be a second writer racing the
+ /// pump, rolling subscribed values backwards.
+ ///
+ [Fact]
+ public async Task Discover_does_not_touch_the_observation_index_or_call_current()
+ {
+ var options = new MTConnectDriverOptions
+ {
+ AgentUri = AgentUri,
+ Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
+ };
+
+ var (driver, client) = await InitializedDriverAsync(options);
+ var index = driver.ObservationIndex;
+ var indexed = index.Count;
+ var currentCalls = client.CurrentCallCount;
+
+ await DiscoverAsync(driver);
+
+ ReferenceEquals(driver.ObservationIndex, index).ShouldBeTrue();
+ driver.ObservationIndex.Count.ShouldBe(indexed);
+ client.CurrentCallCount.ShouldBe(currentCalls);
+ }
+
+ // ---- failure posture: loud, never a silently-empty tree ----
+
+ ///
+ /// Discovery before InitializeAsync throws rather than streaming an empty tree.
+ ///
+ ///
+ /// This is the deliberate opposite of 's
+ /// never-throw posture, and the difference is what the caller can do with the answer. A
+ /// read has per-reference Bad status codes to report a failure in-band; a discovery
+ /// stream has no such channel — an empty tree is indistinguishable from "this Agent declares
+ /// no data items", which is the #485 shape (failure wearing success's clothes) and would read
+ /// to an operator in the browse picker as a working connection to an empty machine. Both
+ /// production callers handle the throw: the universal browser surfaces it as a failed browse,
+ /// and DriverInstanceActor logs it and retries.
+ ///
+ [Fact]
+ public async Task Discover_before_initialize_throws_and_streams_nothing()
+ {
+ var (driver, client) = NewDriver();
+ var builder = new CapturingBuilder();
+
+ await Should.ThrowAsync(
+ () => driver.DiscoverAsync(builder, TestContext.Current.CancellationToken));
+
+ builder.Folders.ShouldBeEmpty();
+ builder.Variables.ShouldBeEmpty();
+ client.ProbeCallCount.ShouldBe(0);
+ }
+
+ /// Same posture after ShutdownAsync — a stopped driver has no model to browse.
+ [Fact]
+ public async Task Discover_after_shutdown_throws_and_streams_nothing()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+ await driver.ShutdownAsync(TestContext.Current.CancellationToken);
+
+ var builder = new CapturingBuilder();
+
+ await Should.ThrowAsync(
+ () => driver.DiscoverAsync(builder, TestContext.Current.CancellationToken));
+
+ builder.Variables.ShouldBeEmpty();
+ }
+
+ ///
+ /// An Agent that fails the /probe a discovery needs fails the discovery — it does not
+ /// degrade to an empty tree. Same #485 reasoning as the un-initialized case.
+ ///
+ [Fact]
+ public async Task Discover_propagates_an_agent_probe_failure_rather_than_streaming_an_empty_tree()
+ {
+ var (driver, client) = await InitializedDriverAsync();
+ await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
+ client.ProbeFailure = new HttpRequestException("agent down");
+
+ var builder = new CapturingBuilder();
+
+ await Should.ThrowAsync(
+ () => driver.DiscoverAsync(builder, TestContext.Current.CancellationToken));
+
+ builder.Variables.ShouldBeEmpty();
+ }
+
+ /// Caller cancellation propagates, as it does on every other seam of this driver.
+ [Fact]
+ public async Task Discover_propagates_caller_cancellation()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+ await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
+
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+
+ await Should.ThrowAsync(
+ () => driver.DiscoverAsync(new CapturingBuilder(), cts.Token));
+ }
+
+ /// A null builder is a caller bug — the one thing this method is loud about up front.
+ [Fact]
+ public async Task Discover_with_a_null_builder_throws_ArgumentNullException()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+
+ await Should.ThrowAsync(
+ () => driver.DiscoverAsync(null!, TestContext.Current.CancellationToken));
+ }
+
+ // ---- the browse opt-in ----
+
+ ///
+ /// The universal browser's CanBrowse constructs a throwaway instance purely to read
+ /// these two members, so they must answer on an un-initialized driver without
+ /// dialling anything.
+ ///
+ [Fact]
+ public void Browse_opt_in_answers_on_an_uninitialized_instance_without_connecting()
+ {
+ var (driver, client) = NewDriver();
+
+ driver.ShouldBeAssignableTo();
+ driver.SupportsOnlineDiscovery.ShouldBeTrue();
+ driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
+ client.ProbeCallCount.ShouldBe(0);
+ client.CurrentCallCount.ShouldBe(0);
+ }
+
+ // ---- helpers ----
+
+ /// A one-device model whose single Path component owns .
+ private static MTConnectProbeModel ModelWith(params MTConnectDataItem[] dataItems) =>
+ new(
+ [
+ new MTConnectDevice(
+ "dev9",
+ "Probe9",
+ [new MTConnectComponent("dev9_path", "path", [], dataItems)],
+ []),
+ ]);
+
+ ///
+ /// The canned fixture's device plus a second, independent one — the multi-device Agent the
+ /// DeviceName scope exists for.
+ ///
+ private static MTConnectProbeModel TwoDeviceModel()
+ {
+ var fixture = MTConnectProbeParser.Parse(File.ReadAllText("Fixtures/probe.xml"));
+ var second = new MTConnectDevice(
+ "dev2",
+ "Lathe-2Axis",
+ [],
+ [new MTConnectDataItem("dev2_avail", null, "EVENT", "AVAILABILITY", null, null, null, null)]);
+
+ return new MTConnectProbeModel([.. fixture.Devices, second]);
+ }
+}