Every value verified against Opc.Ua.StatusCodes in the pinned SDK (1.5.378.106) by reflection, not by copying siblings. All are client-visible: OPC UA clients branch on status. The three named in #497: - Historian.Gateway SampleMapper "BadNoData" 0x800E0000 -> 0x809B0000 (0x800E0000 is BadServerHalted) - Galaxy "BadTimeout" 0x800B0000 -> 0x800A0000 (0x800B0000 is BadServiceUnsupported) - TwinCAT BadTypeMismatch 0x80730000 -> 0x80740000 (0x80730000 is BadWriteNotSupported) BadTypeMismatch was wrong in three MORE drivers the issue did not name — FOCAS, AbLegacy and AbCip carried the same 0x80730000. Every call site is a FormatException/InvalidCastException conversion failure, so the name was right and the value was wrong in all four. Adding the reflection guard then surfaced nine further defects offline tests had never checked: - Galaxy GoodLocalOverride 0x00D80000 -> 0x00960000 (not a UA code at all) - Galaxy UncertainLastUsableValue 0x40A40000 -> 0x40900000 - Galaxy UncertainSensorNotAccurate 0x408D0000 -> 0x40930000 - Galaxy UncertainEngineeringUnitsExceeded 0x408E0000 -> 0x40940000 - Galaxy UncertainSubNormal 0x408F0000 -> 0x40950000 - TwinCAT BadOutOfService 0x80BE0000 -> 0x808D0000 (was BadProtocolVersionUnsupported) - TwinCAT BadInvalidState 0x80350000 -> 0x80AF0000 (was BadAttributeIdInvalid) - AbCip/AbLegacy GoodMoreData 0x00A70000 -> 0x00A60000 (was GoodCommunicationEvent) - Historian.Gateway GatewayQualityMapper Good_LocalOverride 0x00D80000 -> 0x00960000 Galaxy's whole Uncertain block read as though the OPC DA quality byte could be shifted into the UA substatus position; it cannot — the substatuses are an unrelated enumeration. GatewayQualityMapper already held the correct table, which is what the Galaxy values are now reconciled against. Guard: StatusCodeParityTests (Core.Abstractions.Tests, which already project- references every driver) reflects over every `const uint` in the deployed ZB.MOM.WW.OtOpcUa.Driver.*.dll whose name reads as a status code, and asserts it equals Opc.Ua.StatusCodes.<name>. Discovery is by convention, so a new driver assembly referenced by that project is covered with no edit here. It went red on all nine defects above before the fixes and now checks 109 constants green. Because reflection can only see a NAMED constant, two inline literals were hoisted so the guard can reach them: Galaxy's BadTimeout/Bad into StatusCodeMap, and GatewayQualityMapper's 15-entry table into a new HistorianStatusCodes. An inline `0x800B0000u, // BadTimeout` at a call site is exactly how the Galaxy defect survived. The drivers stay SDK-free by design; only the test project references Opc.Ua.Core, as the oracle. Also corrected: tests that pinned the wrong values (Galaxy StatusCodeMapTests, SampleMapperTests, GatewayQualityMapperTests — all written from the same bad source), a mislabelled comment in DriverInstanceActorTests, and stale constants in two design docs, one of them the unexecuted MTConnect driver design that would have propagated BadNoData's wrong value into a new driver. The CLI's SnapshotFormatter value->name table was checked against the SDK and is correct; no change needed.
26 KiB
MTConnect (Agent-first) driver — executable implementation design
Status: design, build-ready. 2026-07-15.
Research input: docs/research/drivers/mtconnect-agent.md — this doc turns that research into a build spec; it does not re-argue the protocol findings.
House-style references: 2026-06-12-galaxy-standard-driver-design.md, 2026-07-15-universal-discovery-browser-design.md.
1. Motivation + scope
MTConnect is the dominant open read-only telemetry standard for machine tools; adding a DriverType = "MTConnect" Equipment-kind driver lets OtOpcUa surface a machine's self-describing device model (positions, spindle speed, execution state, availability, conditions) alongside — and complementing — the lower-level FOCAS driver (FOCAS reaches the Fanuc CNC directly; MTConnect reaches the vendor-neutral Agent that often front-ends that same machine). v1 is agent-first, read-only (Discover + Read + Subscribe, no Write) because the mainstream MTConnect surface (/probe, /current, /sample) is read-only by design. Full rationale, protocol details, library survey, and risk register live in the research report §1–§8; this doc assumes them.
2. Project layout
Two new projects, mirroring the Modbus split (contracts DTOs isolated from runtime so the AdminUI probe/editor can reference config shapes without the driver's NuGet deps). No .Browser project — see §4.
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/
MTConnectDriverOptions.cs // strongly-typed runtime options (like ModbusDriverOptions)
MTConnectTagDefinition.cs // one record per authored tag (FullName=dataItemId + mt* metadata)
MTConnectDataTypeInference.cs // pure category/type/units → DriverDataType table (§3.3) — shared by driver, factory, editor, browser-commit
ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj // refs Core.Abstractions only
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/
MTConnectDriver.cs // IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable
MTConnectAgentClient.cs // thin seam over MTConnectHttpClient (probe/current/sample); testable via IMTConnectAgentClient
IMTConnectAgentClient.cs // seam interface — canned-XML fake in unit tests
MTConnectObservationIndex.cs // dataItemId → latest DataValueSnapshot, updated by /current + /sample
MTConnectDriverFactoryExtensions.cs // Register(registry, loggerFactory) + CreateInstance
MTConnectDriverProbe.cs // IDriverProbe: one-shot /probe reachability
ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj // refs Core, Core.Abstractions, .Contracts + TrakHound pkgs
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/
Fixtures/*.xml // canned probe/current/sample docs (§8)
...Tests.cs
.csproj boilerplate copies Modbus verbatim: net10.0, Nullable=enable, TreatWarningsAsErrors=true, GenerateDocumentationFile, InternalsVisibleTo the Tests project.
NuGet dependency
Consume TrakHound MTConnect.NET client packages (MIT) — pin an exact version:
<PackageReference Include="MTConnect.NET-Common" Version="6.9.0.2" />
<PackageReference Include="MTConnect.NET-HTTP" Version="6.9.0.2" />
<!-- MTConnect.NET-XML / -JSON pulled transitively for serialization -->
These target netstandard2.0 (load cleanly on .NET 10) and give MTConnectHttpClient (probe/current/sample with polling and the multipart long-poll stream, gzip, XML+JSON) plus the model types (IDevice/IComponent/IDataItem/IObservation) — removing the multipart-framing / version-negotiation / buffer-overflow-rebaseline grind.
Library verification checklist (from research §1.4 — research-sourced facts, unverifiable offline). Older TrakHound releases carried mixed MIT / Apache-2.0 / "all rights reserved" strings, and the version/TFM facts above came from research, not from a restore. At implementation start, verify against the actual NuGet packages: (1) the exact package version pin — confirm
6.9.0.2(or the then-current pin) exists on nuget.org and restores; (2) its TFM set — confirm the packages shipnetstandard2.0(or a net-10-compatible) target and load cleanly on .NET 10; (3) the license of the pinned version — confirm the embeddedLICENSEis MIT (the before-merge legal-review checkbox, not a blocker). Hand-roll fallback if review fails:HttpClient+System.Xml.Linqfor probe/current, amultipart/x-mixed-replaceboundary reader for sample (~300–500 LoC behind the sameIMTConnectAgentClientseam — the driver above the seam is unchanged, so the fallback is a drop-in of one class).
3. Capability mapping (concrete seam wiring)
MTConnectDriver implements IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe, IRediscoverable — not IWritable (§3.5). One MTConnectAgentClient + one MTConnectObservationIndex per driver instance; one shared sample stream (the Agent streams the whole device model, so per-tag streams would be wasteful).
3.1 IDriver
InitializeAsync(json, ct): deserializeMTConnectDriverOptions(via factory), constructMTConnectAgentClientfromAgentUri, run one/probeunder a per-call deadline (§7). Cache the parsedIDevice[]model +Header.instanceId. Prime theMTConnectObservationIndexwith one/current. SetDriverState.Healthyon success;Faulted(rethrow) on failure — the actor marks the instance Faulted, nodes go Bad, process stays up.ReinitializeAsync: tear down the sample stream, re-run Initialize. Config-only change (no address-space rebuild) unlessAgentUri/DeviceNamechanged.ShutdownAsync: stop the sample stream, dispose the HTTP client.GetHealth:DriverHealth(State, LastSuccessfulRead, LastError)—LastSuccessfulRead= last/currentor/samplechunk time.GetMemoryFootprint/FlushOptionalCachesAsync: footprint ≈ cached probe model + observation index; flush drops the browse/probe-model cache (re-fetchable), keeps the observation index (correctness state).
3.2 ITagDiscovery — plugs into the universal browser (§4)
public bool SupportsOnlineDiscovery => true; // /probe enumerates from the device
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once; // probe is synchronous + complete
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
DiscoverAsync fetches (or reuses the Initialize-cached) /probe model and streams the full Device→Component→DataItem tree into the builder:
- each
Device→builder.Folder(device.Name, device.Name)→ child builder; - each nested
Component→ recursiveFolder(component.Name, component.DisplayName)on the parent's child builder (component nesting becomes folder nesting — the builder-graph is the tree); - each
DataItem→child.Variable(browseName, displayName, attr)whereattr = new DriverAttributeInfo(FullName: dataItem.Id, DriverDataType: MTConnectDataTypeInference.Infer(category, type, units), IsArray: representation==TIME_SERIES, ArrayDim: representation==TIME_SERIES && dataItem.SampleCount is int n and > 0 ? n : null, SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false, IsAlarm: category==CONDITION). (ForTIME_SERIESitems the probe-declaredsampleCountattribute flows intoArrayDim— the record's doc-comment definesArrayDimas the declared array length whenIsArrayis true.nullonly when the device model doesn't declaresampleCount; many agents omit it for variable-length series.) (ViewOnlyis the read-only-from-OPC-UA tier —SecurityClassificationhas noReadOnlymember.)browseName=dataItem.Name ?? dataItem.Id(dataItemnameis optional in MTConnect; fall back toid, which is guaranteed unique).
Critical: DriverAttributeInfo.FullName = dataItem.Id. This is (a) the value the universal browser commits as TagConfig.FullName, and (b) the key the read/subscribe paths resolve against — the two align by construction (research §3). If DeviceName scopes to one device, only that device's subtree is streamed.
DeviceName scoping and the whole-model-in-one-call nature make this a natural fit for eager one-shot discovery — exactly what the universal browser assumes.
3.3 Data-type mapping (MTConnectDataTypeInference.Infer)
Pure function in .Contracts (shared by driver, browser-commit, and the typed editor so all three agree). Weak wire typing means this is a heuristic, stored per-tag and author-overridable (§5):
| MTConnect | DriverDataType |
|---|---|
SAMPLE numeric (has units) |
Float64 |
SAMPLE representation=TIME_SERIES |
Float64 array (IsArray=true; ArrayDim = probe-declared sampleCount when present, else null — §3.2) |
EVENT numeric type (PartCount, Line, …) |
Int64 |
EVENT controlled-vocab (Execution, ControllerMode, Availability, …) |
String |
EVENT free text (Program, Block, Message) |
String |
CONDITION |
String (state word; §3.6) |
Quality mapping (DataValueSnapshot.StatusCode): an observation value of UNAVAILABLE (MTConnect's explicit no-data sentinel) → BadNoCommunication (0x80310000u) with a null value, not the literal string — this is the UNAVAILABLE code, used everywhere in this design (read, subscribe, and the §8 fixtures). Rationale + fleet fit: semantically, UNAVAILABLE means the Agent is reachable but has no device-backed value (the adapter↔device link is down or the item has never reported) — exactly OPC UA's BadNoCommunication ("communication to the data source has failed"). The fleet grep shows no competing convention for this case: every existing driver's BadCommunicationError (0x80050000u, Modbus/FOCAS/TwinCAT/OpcUaClient/S7/AbCip/AbLegacy/Galaxy) marks the driver's own transport failure to its peer — a different failure (and the code this driver also uses when the Agent HTTP call fails, per §7) — while BadNoData (0x809B0000u) is historian-domain only (Historian.Gateway/Mapping/SampleMapper.cs). BadNoCommunication is already in the CLI's SnapshotFormatter name table (src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/SnapshotFormatter.cs), so it renders by name, not hex. Declare it as a private const uint in MTConnectDriver (the Modbus StatusBadCommunicationError pattern). Missing dataItem / empty condition → Bad. observation.timestamp → SourceTimestamp (not a separate node).
3.4 IReadable — /current
ReadAsync(fullReferences, ct): issue one /current under a per-call deadline, index the returned MTConnectStreams observations by dataItemId into MTConnectObservationIndex, then return one DataValueSnapshot per requested ref in order. A ref absent from the response → a Bad-coded snapshot (not a throw). Reads are idempotent (matches IReadable contract; DriverCapability.Read auto-retries). /current is the whole-device snapshot regardless of how many refs are requested, so batch reads cost one round-trip.
3.5 ISubscribable — /sample multipart long-poll
SubscribeAsync(fullReferences, publishingInterval, ct): on the first subscription, start the sharedMTConnectAgentClientsample stream (/sample?from=<nextSequence>&interval=<SampleIntervalMs>&count=<SampleCount>). Record the subscribed ref set. Immediately fireOnDataChangefor each subscribed ref from the primed/currentvalues (OPC UA initial-data convention). Return anISubscriptionHandle(monotonic id +DiagnosticId).- Stream pump: each received
MTConnectStreamschunk → for each observation whosedataItemId∈ subscribed set, update the index and raiseOnDataChange(handle, dataItemId, snapshot). Advancefrom = Header.nextSequencefor the next request (contiguous, gap-free). - Ring-buffer overflow: if the Agent reports a sequence gap /
fromolder thanfirstSequence, re-/currentto re-baseline, then resume the stream from the newnextSequence. (TrakHound handles this internally; the hand-roll fallback must replicate it — unit-tested via a fixture with a forced gap.) UnsubscribeAsync(handle, ct): drop that handle's refs from the subscribed set; stop the shared stream when the set empties.publishingIntervalmaps to the/sampleintervalwhen it is the only/first subscription; the driver polls at the finest requested interval and fans out (one stream per instance, not per tag).
3.6 IWritable — not implemented (v1)
Justified per research §2.1: the MTConnect Agent surface is read-only by design; write-back exists only via optional, rarely-deployed MTConnect Interfaces (a request/response state-machine handshake, not a "set value" — modelling it as IWritable would mislead). Capability interfaces are composable, so omitting IWritable is idiomatic (Galaxy's write path is even fire-and-forget). Nodes materialize without the AccessLevels.CurrentWrite bit automatically. Revisit Interfaces only if a concrete deployment needs it.
3.7 IHostConnectivityProbe + IRediscoverable
IHostConnectivityProbe: expose oneHostConnectivityStatusper Agent (HostName =AgentUri). A cheap periodic/probe(or reuse the sample-stream heartbeat) flipsRunning ↔ Stopped; raiseOnHostStatusChangedon transition. Enable/interval fromMTConnectDriverOptions.Probe(mirrorsModbusProbeOptions).IRediscoverable: watch the AgentHeader.instanceIdon every/current//samplechunk. A change means the Agent restarted / its model changed → raiseOnRediscoveryNeededsoDriverHostrebuilds the address space (mirrors Galaxy'sDeployWatcher). This is whyRediscoverPolicy = Onceis safe: instanceId change, not polling, drives re-discovery.
3.8 CONDITION modelling
v1 simple (this design): each CONDITION DataItem is a String variable whose value is the current state word (Normal/Warning/Fault/Unavailable), optionally suffixed with nativeCode. Zero alarm plumbing. IsAlarm=true is still set on the DriverAttributeInfo so the browser side-panel flags it and a future upgrade needn't re-author tags.
v1.5 (fast-follow, not in this build): implement IAlarmSource + emit a TagConfig alarm object so Fault/Warning become native OPC UA Part 9 alarms, routing on the authored dotted ConditionId per the Galaxy/Phase-B native-alarm pattern.
4. Browse — plugs into the universal browser (reconciliation)
The research report (§4.2) proposed a bespoke ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Browser project with a hand-written MTConnectBrowseSession. This design supersedes that: build NO bespoke browser. With the universal discovery browser in place (the Wave-0 gate, which lands before this Wave-2 driver), MTConnect browse is covered for free by the generic DiscoveryDriverBrowser, because MTConnect browse comes from discovery (/probe → the device model → the same DiscoverAsync stream). Concretely, the universal browser already does exactly what the proposed bespoke session would have done:
DiscoveryDriverBrowser.CanBrowse("MTConnect", cfg)returns true becauseTryCreatesucceeds and the instance isITagDiscovery { SupportsOnlineDiscovery: true }(§3.2) → the AdminUI renders the Browse button.OpenAsyncconstructs the driver,InitializeAsync(the/probeconnect), runsDiscoverAsyncinto aCapturingAddressSpaceBuilder, thenShutdownAsync— the captured tree (Device folders → Component folders → DataItem leaves) is served byCapturedTreeBrowseSession. Each leaf'sNodeId == DriverAttributeInfo.FullName == dataItemId, committed directly asTagConfig.FullName;IsAlarm/DriverDataTypeflow to the side-panel and pre-fill the typed editor.
No PatchForBrowse entry is needed — MTConnect discovery is unconditional (not config-gated like AbCip/TwinCAT's EnableControllerBrowse); it belongs in the "no patch" set alongside OpcUaClient/BACnet. No IUniversalDriverBrowser/BrowserSessionService change is needed — MTConnect lights up purely by setting SupportsOnlineDiscovery => true. The single required action to enable browse is that one default-member override in §3.2.
The only limit (universal-browser §8) is that discovery is eager one-shot; /probe is already a whole-model single call, so eager is the right fit and graduating to bespoke is unlikely.
5. Typed tag editor + validator (AdminUI)
Avoid the raw-JSON fallback by adding a typed editor (mirrors the Modbus template exactly).
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs— pureFromJson/ToJson/Validate, preserves unknown keys via theTagConfigJsonbag helper (copyModbusTagConfigModel). Fields:FullName(dataItemId, required),MtCategory/MtType/MtSubType(enums / strings, from probe — read-only in UI),DataType(DriverDataTypeoverride dropdown),Units,MtDevice/MtComponent(author context).Validate()returns an error ifFullNameis blank..../Components/Shared/Uns/TagEditors/MTConnectTagConfigEditor.razor— thin shell:FullNametext, read-onlymtCategory/mtType, aDataTypeoverride<select>. Because most tags arrive via the browse picker (which fills the model), the editor is mostly a confirm/override surface.- Register
["MTConnect"] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor)inUns/TagEditors/TagConfigEditorMap.cs, and["MTConnect"] = j => MTConnectTagConfigModel.FromJson(j).Validate()inTagConfigValidator.cs.
JsonStringEnumConverterenum-serialization trap (project-wide gotcha — MEMORY). Every driver's AdminUI serialization path must round-trip enums as name strings, never numerics. The factory/probe DTOs are string-typed (ParseEnum<T>), so a numerically-serialized enum FAULTS the driver at deploy. Two concrete requirements: (1)MTConnectTagConfigModel.ToJsonwritesMtCategory/DataTypeviaTagConfigJson.Set(bag, "dataType", DataType)which emits the enum name (the Modbus helper already does this — do not hand-rollJsonSerializerwithoutJsonStringEnumConverter); (2)MTConnectDriverProbeparses withnew JsonStringEnumConverter()in itsJsonSerializerOptions(copyModbusDriverProbe._opts); the factory keeps enum-carrying DTO fieldsstring?and parses them viaParseEnum<T>— the Modbus factory pattern (itsJsonOptionscarries no enum converter). The.ContractsMTConnectDataTypeInferencereturns aDriverDataTypeenum; when it's written to JSON it must be the string.
6. Factory + registration
MTConnectDriverFactoryExtensions(copy Modbus):public const string DriverTypeName = "MTConnect";Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)→registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory)).CreateInstancedeserializes aMTConnectDriverConfigDto(nullable-init DTO withTags,Probe), validatesAgentUripresent, buildsMTConnectDriverOptions, returnsnew MTConnectDriver(options, id, agentClientFactory: null, logger). Copy the Modbus factoryJsonOptions(PropertyNameCaseInsensitive,ReadCommentHandling=Skip,AllowTrailingCommas— no enum converter: enum-carrying DTO fields staystring?and go throughParseEnum<T>, per §5's gotcha).- Host wiring — 3 one-line edits in
src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs:Register(...)body: addDriver.MTConnect.MTConnectDriverFactoryExtensions.Register(registry, loggerFactory);- add
using MTConnectProbe = Driver.MTConnect.MTConnectDriverProbe; AddOtOpcUaDriverProbes: addservices.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, MTConnectProbe>());The probe MUST be inAddOtOpcUaDriverProbes(not only the factory path) so it reaches admin-only nodes — theadmin-operationssingleton that backs Test-Connect is admin-role-pinned (the MEMORY "driver probes on admin nodes" gotcha).TryAddEnumerablekeeps a fused admin,driver node from double-registering (a dup makes the singleton'sToDictionary(p=>p.DriverType)throw).
MTConnectDriverProbe : IDriverProbe,DriverType => "MTConnect": parse the config DTO, one-shotGET {AgentUri}/probeundertimeout,Ok=true+latency on any validMTConnectDevicesresponse,Ok=false+message on TCP/HTTP/timeout failure. Never throw (perIDriverProbecontract).- No AdminUI
IDriverBrowserDI line — browse is the universal browser (§4), already registered.
7. Resilience / timeout (the R2-01 frozen-peer lesson)
Every agent HTTP request MUST carry a per-call deadline — never an unbounded wait (the R2-01 S7 finding: an async read that ignores its socket timeout wedges the poll loop against a frozen peer). Concretely:
/probeand/current: wrap each call in aCancellationTokenSource(RequestTimeoutMs)linked to the caller'sct; a timeout surfaces as aBadCommunicationError-coded snapshot (read — the fleet-standard driver-transport-failure code, distinct from the §3.3BadNoCommunicationUNAVAILABLE mapping) or a Faulted init (probe), never a hang. SetHttpClient.Timeoutand a linked CTS (belt-and-suspenders —HttpClient.Timeoutdoesn't cover the response-body read of a streamed multipart)./samplelong-poll stream watchdog: the stream is intentionally long-lived, soHttpClient.Timeoutcannot bound it. Instead run a watchdog: if no chunk and no keep-alive heartbeat arrives withinHeartbeatMs × N(e.g. 3× the Agent heartbeat), treat the stream as dead → cancel it, transitionHostState.Stopped, and reconnect. The Agent's ownheartbeaton the multipart boundary is the liveness signal; absence past the watchdog window is the frozen-peer detector.- Reconnect / backoff: geometric backoff (
MinBackoffMs→MaxBackoffMs) on stream drop or connect failure, mirroringModbusReconnectOptions. On reconnect, re-baseline via/currentthen resume/samplefrom the freshnextSequence. - All capability calls flow through the existing Phase-6.1
IDriverCapabilityInvokerresilience pipeline (Read/Discover/Subscribe/Probe auto-retry); the per-call deadlines above are the driver-internal floor beneath that pipeline. Subscribe-timeout interplay: the pipeline's Subscribe timeout bounds only theSubscribeAsynccall itself — the/samplestream-start handshake must complete within it, andSubscribeAsyncreturns once the stream is established; the long-lived pump then runs outside that timeout, guarded by the heartbeat watchdog above instead.
8. Test fixtures
- Canned XML unit fixtures (bulk of coverage, no network). Capture one
MTConnectDevices(probe) + a couple ofMTConnectStreams(current + sample, incl. one with a forcednextSequencegap) from a demo agent intotests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/. Drive through theIMTConnectAgentClientfake to pin:DiscoverAsynctree shape + leafFullName==dataItemId,MTConnectDataTypeInferencetable, observation indexing,UNAVAILABLE→BadNoCommunication(§3.3), CONDITION→state-word, multipart chunk framing, and ring-buffer re-baseline paging. - Dockerized
mtconnect/cppagent(reproducible integration fixture). Addtests/.../Docker/docker-compose.ymlwith theproject: lmxopcualabel on every service, seeded with a cannedDevices.xml(+ an SHDR simulator or the built-in adapter), exposed on the shared docker host10.100.0.35; drive it vialmxopcua-fix up mtconnect+sync. Env-gated integration suite (*.IntegrationTests), skips cleanly when the fixture is down — the analog of the Modbus/S7 sims. - Public demo agent (manual live smoke only).
https://demo.mtconnect.org/(or NISTsmstestbedDevices.xml) for a real-shape browse/read/stream smoke. Internet-dependent, not in CI. Live-verify the browse picker on docker-dev per the universal-browser discipline: open the/unsTagModal picker for an MTConnect driver, confirm the Device→Component→DataItem tree renders and a picked leaf commitsTagConfig.FullName = <dataItemId>(Razor binding bugs pass unit tests — always/runit).
9. Phasing + effort
- P1 (this design) — Agent MVP, ~1–1.5 wk with TrakHound (~2.5–3 wk hand-rolled):
.Contracts+Driver.MTConnect(IDriver+ITagDiscovery+IReadable+ISubscribable+IHostConnectivityProbe+IRediscoverable),MTConnectDriverProbe,SupportsOnlineDiscovery=true(browse is free via universal browser — no browser code), typed editor + map/validator entries, 3-line Host registration, canned-XML unit suite + cppagent fixture. CONDITION asString. - P1.5 fast-follow: CONDITION → native Part-9 alarms via
IAlarmSource(Galaxy pattern);TIME_SERIESSAMPLE arrays; EVENT controlled-vocab → OPC UA enumerations. - P2 (on demand) — SHDR adapter ingest: add
SourceMode: "Agent" | "Shdr"; SHDR opens the raw pipe-delimited TCP socket (:7878). Loses auto-discovery (no device model →SupportsOnlineDiscoverymust returnfalsein SHDR mode → picker falls back to manual entry; tags authored like Modbus). Niche.
Detailed risk register (weak typing, CONDITION analog, TrakHound license, demo-agent CI flakiness, ring-buffer overflow, netstandard2.0-on-.NET10) is in research §7 — not repeated here.