#507 was filed as "injection inert in v3, re-migrate onto the raw subtree". Reading it, the retained code is not revivable: it resolves equipment from EquipmentNode.DriverInstanceId UNION EquipmentTags, and BOTH are structurally empty in v3 (AddressSpaceComposer always constructs EquipmentNode with a null DriverInstanceId; DeploymentArtifact hard-codes an empty EquipmentTags set). Removing the guard would have changed a log line and injected nothing. So it is deleted rather than fixed, and #507 closes as superseded by /raw browse-commit. Removed: HandleDiscoveredNodes, PartitionDiscoveredByDeviceHost, ShouldWarnPartition, PlansRoutingEqual, ApplyDiscoveredPlansForDriver, _discoveredByDriver, the redeploy re-inject tail, DiscoveredNodeMapper, DiscoveredInjection, AddressSpaceApplier.MaterialiseDiscoveredNodes, OpcUaPublishActor.MaterialiseDiscoveredNodes, and the Runtime-local copies of CapturingAddressSpaceBuilder/DiscoveredNode. The connect-time discovery loop goes too (StartDiscovery, RediscoverTick, HandleRediscoverAsync, DiscoveredNodesReady, TriggerRediscovery). With injection gone it had no consumer, and leaving it would either dead-letter or keep browsing real devices up to ~15x per connect to drop the result. ITagDiscovery itself stays — the /raw browse picker drives it through Commons/Browsing/DiscoveryDriverBrowser, which never went through the actor, so the picker is unaffected. deferment.md §4.4 called the 18 DiscoveryInjectionDormantV3 tests "Real — blocked on #507". They are not: they assert an equipment-rooted graft (EquipmentRootNodeId == "EQ-1") that v3 cannot produce, so they could never have unskipped as written. Deleted along with DiscoveredNodeMapperTests, the CapturingAddressSpaceBuilder tests, and the MaterialiseDiscoveredNodes tests in AddressSpaceApplierTests/OpcUaPublishActorTests. Runtime.Tests skipped: 31 -> 13. IHostConnectivityProbe is deliberately NOT resolved here. GetHostStatuses() has no production call site and nothing writes a DriverHostStatus row, but the table was re-created in the v3 initial migration, so deleting on inference would be wrong. Split to Gitea #521 with both options costed. CLAUDE.md, docs/drivers/{Galaxy,TwinCAT,MTConnect}.md updated — they described the seam as dead. Build clean; Runtime.Tests 476 passed / 13 skipped; OpcUaServer.Tests 362 passed / 4 skipped.
20 KiB
MTConnect Driver
Getting-started guide for the MTConnect Agent driver (P1 Agent MVP). This is
the short path — for the full design rationale read
docs/plans/2026-07-15-mtconnect-driver-design.md
and the build-vs-plan record in
docs/plans/2026-07-24-mtconnect-driver.md; for the
fixture recipe read
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md
(not duplicated here).
Live gate: see the LIVE-GATE RESULT note in the plan's Task 21 (
docs/plans/2026-07-24-mtconnect-driver.md) for the docker-dev/runverification outcome (browse picker / typed editor / deploy / read / subscribe against the real Agent fixture).
What it talks to
A MTConnect Agent — the vendor-neutral read-only telemetry endpoint that front-ends a machine tool (or fronts an adapter that itself talks to the machine over SHDR). The driver speaks plain HTTP + XML against the Agent's three standard REST paths:
/probe— the static device model (Device → Component → DataItem tree)/current— a snapshot of every DataItem's latest observation/sample— amultipart/x-mixed-replacelong-poll stream of observation deltas, keyed by a monotonic sequence number
v1 is agent-first and read-only — Discover + Read + Subscribe, no Write — because the mainstream Agent surface has no "set value" operation.
Built vs. planned — read this before trusting the design doc's §2
The design doc (2026-07-15-mtconnect-driver-design.md)
picked the TrakHound MTConnect.NET-Common / -HTTP NuGet packages (MIT,
netstandard2.0) as the primary path, with a hand-rolled fallback. The
hand-rolled fallback is what shipped. Task 6/7 of the implementation plan
found by reflection + live invocation that the pinned TrakHound packages
ship no XML formatter (Document Formatter Not found for "xml" — the
formatter lives in a separate MTConnect.NET-XML package the design didn't
pin) and that MTConnectHttpClientStream exposes no injectable HttpClient
handler, so it cannot be unit-tested behind the driver's IMTConnectAgentClient
seam. Both package references were dropped. There is no TrakHound
dependency anywhere in the shipped driver — probe/current/sample parsing is
hand-rolled System.Xml.Linq plus a multipart boundary reader, entirely
behind IMTConnectAgentClient. See the CORRECTION block under Task 0 of the
implementation plan for the full record.
Namespace-agnostic parsing. Every parser matches XML elements on
LocalName only, ignoring the document's XML namespace entirely, so
MTConnect 1.3 through 2.x documents all parse without a schema-version
branch. This is deliberate, not sloppy: a real Agent injects vendor-extension
Components in a foreign namespace whose child DataItems elements inherit
the default namespace, and namespace-strict matching would silently drop
the whole vendor component (and every DataItem beneath it) rather than fail
loudly. Attributes are read unqualified for the same reason — so an
xsi:type attribute is never mistaken for a DataItem's own type.
Project split
| Project | Target | Role |
|---|---|---|
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/ |
net10.0 | In-process driver — MTConnectDriver, the hand-rolled IMTConnectAgentClient (probe/current/sample), the observation index, the factory |
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/ |
net10.0 | MTConnectDriverOptions, MTConnectTagDefinition, and the pure MTConnectDataTypeInference table shared by the driver, browse-commit, and the AdminUI typed editor |
No .Browser project — browse comes free from the Wave-0 universal
discovery browser (see Browse
below).
Capability surface
MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable
(src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs).
Deliberately not IWritable — the Agent surface is read-only by design.
Write-back exists only via optional, rarely-deployed MTConnect Interfaces
(a request/response handshake, not a "set value" operation), and is out of
scope for this build — see Deferred.
| Capability | Path | Notes |
|---|---|---|
ITagDiscovery |
DiscoverAsync — streams /probe's Device→Component→DataItem tree into the address-space builder |
SupportsOnlineDiscovery = true, RediscoverPolicy = Once |
IReadable |
ReadAsync → one /current per call |
Not the production data path — see below |
ISubscribable |
SubscribeAsync/OnDataChange — the shared /sample long-poll pump |
The production data path |
IHostConnectivityProbe |
periodic /probe under Probe.* |
No consumer wires it today — see Known limitations |
IRediscoverable |
watches the Agent's Header.instanceId |
No consumer wires it today — see Known limitations |
IReadable.ReadAsync is never called by the running server. The
production data plane is entirely ISubscribable — DriverInstanceActor
subscribes once and lives off OnDataChange. ReadAsync exists for the
Client CLI (... read -n ...) and for the unit/integration suites; it is
correct and tested, just not on the hot path.
Minimum deployment
"Drivers": {
"mtconnect-1": {
"Type": "MTConnect",
"Config": {
"AgentUri": "http://10.100.0.35:5000",
"DeviceName": null,
"RequestTimeoutMs": 5000,
"SampleIntervalMs": 1000,
"SampleCount": 1000,
"HeartbeatMs": 10000,
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000 },
"Reconnect": { "MinBackoffMs": 0, "MaxBackoffMs": 30000, "BackoffMultiplier": 2.0 },
"Tags": []
}
}
}
RawTags[] is not authored by hand — DriverDeviceConfigMerger injects it
at deploy time from the tags authored on the /raw tree.
Config keys
Read off MTConnectDriverOptions / MTConnectDriverConfigDto
(src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs,
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs):
| Key | Default | Notes |
|---|---|---|
AgentUri |
— (required) | Agent base URI; the driver appends /probe, /current, /sample |
DeviceName |
null |
Scopes requests to {AgentUri}/{DeviceName}/...; null = whole Agent |
RequestTimeoutMs |
5000 |
Per-call deadline for /probe and /current |
SampleIntervalMs |
1000 |
The Agent's /sample?interval= query param |
SampleCount |
1000 |
The Agent's /sample?count= query param |
HeartbeatMs |
10000 |
The Agent's /sample?heartbeat= query param — also the watchdog's liveness window |
Probe.Enabled / Probe.IntervalMs / Probe.TimeoutMs |
true / 5000 / 2000 |
Background connectivity-probe knobs (mirrors ModbusProbeOptions) |
Reconnect.MinBackoffMs / MaxBackoffMs / BackoffMultiplier |
0 / 30000 / 2.0 |
Geometric backoff after a failed request or a dropped /sample stream |
Tags[] |
[] |
Pre-v3 / CLI authoring surface — one MTConnectTagDefinition per DataItem id |
RawTags[] |
[] (deploy-injected) |
The v3 data-plane binding — see below |
All timing knobs are validated strictly positive at InitializeAsync
(RequirePositive, mirroring the arch-review 01/S-6 lesson: a 0 timeout
does not mean "wait forever," it faults the driver). This applies to
RequestTimeoutMs, HeartbeatMs, SampleIntervalMs, SampleCount, and
(when the probe is enabled) Probe.Interval / Probe.Timeout.
No save-time gate on a blank AgentUri in the AdminUI. The driver form
renders an inline validation notice (_form.Validate()), but
DriverConfigModal.SaveAsync has no per-form validation seam to block the
Save button on it — no sibling driver form has one either. A blank
AgentUri saves cleanly and fails at deploy with the driver's own error
message, not at authoring time.
Data plane
FullName == DataItem id
FullName (both MTConnectTagDefinition.FullName and every RawTags[]
blob's identifier) is the MTConnect DataItem@id attribute — the value the
universal browser commits and the key the driver resolves reads/subscribes
against. MTConnectTagConfigModel.FromJson (the AdminUI typed editor) tries
three spellings in order — fullName → dataItemId → address — and takes
the first non-blank one, normalising onto fullName on save. address is
accepted because that's the field name RawBrowseCommitMapper writes for a
driver with no typed editor path, so a browse-committed tag stays readable
even before the editor round-trips it.
Coercion-type precedence
The driver keys its data plane by RawPath (the v3 raw-tag identity), not
by DataItem id directly, resolving RawPath → dataItemId internally. Each raw
tag's coercion type (the DriverDataType its Agent observation is parsed
into) is resolved in this order, first non-null wins:
- The
RawTags[]blob'sdriverDataTypeordataTypefield (both spellings accepted — the typed editor writesdataType, the driver's owntags[]shape usesdriverDataType). - A matching
Tags[]entry naming the same DataItem id. - The Agent's own
/probedeclaration, viaMTConnectDataTypeInference.Infer(category/type/units/representation →DriverDataType). DriverDataType.String— the coercion that cannot fail.
Quality mapping
UNAVAILABLE — MTConnect's one explicit "I have no value" sentinel — maps to
BadNoCommunication (0x80310000). This is deliberately distinct from
the fleet-standard BadCommunicationError (0x80050000, used elsewhere for
the driver's own transport failure to the Agent): UNAVAILABLE means the
Agent is reachable and answered, it just has no device-backed value for that
item. A CONDITION observation's value is taken from its element name
(<Normal/> → the string "Normal", <Fault/> → "Fault"); a <Unavailable/>
condition element normalises onto the same UNAVAILABLE sentinel as every
other category so one comparison covers all three.
Other status codes an observation can surface:
| Code | Meaning |
|---|---|
BadTypeMismatch (0x80740000) |
The Agent's text isn't a value of the tag's coerced type at all |
BadOutOfRange (0x803C0000) |
The Agent reported a number the coerced type can't represent |
BadNotSupported (0x803D0000) |
A shape this build doesn't materialize — a TIME_SERIES vector, or a structured DATA_SET/TABLE observation (deferred to P1.5; see Known limitations) |
BadWaitingForInitialData |
Tag is authored but the Agent hasn't reported it yet |
BadNodeIdUnknown |
DataItem id is neither authored nor ever observed |
Subscribe — the /sample pump
One shared /sample long-poll stream per driver instance (the Agent streams
the whole device model regardless of which subset is subscribed, so
per-tag streams would be wasted round-trips), run under a heartbeat
watchdog — HttpClient.Timeout cannot bound a long-lived stream, so a
missing chunk and missing keep-alive heartbeat within
HeartbeatMs × N is what detects a frozen peer.
Ring-buffer overflow (the Agent's circular observation buffer wrapped past
what the driver's cursor expects) is detected two ways, both triggering a
/current re-baseline before the stream resumes:
IMTConnectAgentClient.IsSequenceGap— the next chunk'sfirstSequenceis newer than the driver's expected cursor.- An
MTConnectErrordocument reportingOUT_OF_RANGE— real Agents return this both under HTTP 200 (a normal MTConnect error document) and as a bare HTTP 400, and the client handles both.
An Agent instanceId change means the Agent restarted, and is checked
before the sequence-gap check (a restart also usually trips the gap, so
the two need disambiguating, not just OR-ing together). On a changed
instanceId the driver clears its cached probe model and raises
OnRediscoveryNeeded, which surfaces a re-browse prompt on the AdminUI
/hosts page — see Known limitations for what that does
and does not do.
Browse — free via the universal browser
MTConnect ships no bespoke browser project. Setting
ITagDiscovery.SupportsOnlineDiscovery => true is the entire integration:
the Wave-0 DiscoveryDriverBrowser sees a driver whose TryCreate succeeds
and whose instance reports online discovery, renders the AdminUI Browse
button, constructs the driver, runs InitializeAsync (the /probe connect)
DiscoverAsyncinto aCapturingAddressSpaceBuilder, then tears the throwaway instance down. Each captured leaf's NodeId is the DataItemid, committed directly asTagConfig.FullNameon pick. Seedocs/plans/2026-07-15-universal-discovery-browser-design.md.
CONDITION modelling (v1: plain String)
Each CONDITION DataItem materializes as a String variable whose value is
the current state word — Normal / Warning / Fault / UNAVAILABLE.
There is no native OPC UA Part 9 alarm plumbing in this build;
DriverAttributeInfo.IsAlarm = true is still stamped on a CONDITION leaf so
the browse side-panel flags it and a future upgrade to native alarms doesn't
need re-authoring. See Deferred.
Known limitations
These are real, not placeholders — read them before relying on the driver for anything beyond values-and-conditions.
-
A restarted Agent prompts an operator; it does not re-shape the address space.
DriverInstanceActornow consumesOnRediscoveryNeeded(2026-07-27), so a changedinstanceIdraises a "re-browse" chip against this driver on/hostscarrying the reason. It is advisory: v3 authors raw tags through the/rawbrowse-commit flow, so until an operator re-browses the Agent and commits, any DataItem that appeared or vanished is not reflected in the served tree. A runtime graft was deliberately rejected — it would materialise nodes nobody approved and no deployment artifact records.IHostConnectivityProbeis still unconsumed —GetHostStatuses()has no production call site and nothing writes aDriverHostStatusrow. That half remains a fleet-wide gap affecting every driver that implements it (Gitea #521). -
RawTagEntry.DeviceNameis accepted on the wire shape but ignored for routing. One driver instance owns exactly one Agent client scoped byMTConnectDriverOptions.DeviceName(the top-level config key); the per-tagRawTagEntry.DeviceNamefield the v3 raw-tag identity carries is never read by the driver. Two/rawDevices authored under one MTConnect driver instance both resolve to the same one Agent connection. Real per-device routing (a client per device) would be a design change, not a bug fix. -
Nothing validates a raw tag's declared OPC UA
DataTypeagainst the blob's coercion type. The/probe-sourced inference (MTConnectDataTypeInference) narrows how often an author picks a wrong type, but it doesn't close the gap — an authoredDataTypemismatched against the actual Agent observation still surfaces asBadTypeMismatch/BadOutOfRangeat runtime rather than at authoring time. -
sampleCountis not a legalDataItemattribute. It belongs on aTIME_SERIESobservation, not the device-model declaration. A real Agent that meetssampleCounton aDataItemdeclaration logsThe following keys were present and not expected: sampleCountand drops the entire data item from the served model. A live TIME_SERIES tag therefore always resolves toArrayDim = null(variable-length array) in practice — the fixture's cannedprobe.xml/Devices.xml, which does declare asampleCount, is unrealistic on this point and exists only to pin the parsing rule itself. -
CONDITION is a plain
String, not a native Part 9 alarm (see above). -
TIME_SERIESarrays and structuredDATA_SET/TABLEobservations surface asBadNotSupported, not an approximation — see the Quality mapping table.
Deferred (not in this build)
- Write-back (MTConnect Interfaces). Design
§3.6:
the mainstream Agent surface is read-only by design; Interfaces is a rare,
optional request/response handshake that would mislead if modelled as
IWritable. Revisit only on a concrete deployment need. - P1.5 fast-follow (design
§9):
CONDITION → native OPC UA Part 9 alarms via
IAlarmSource(the Galaxy native-alarm pattern);TIME_SERIESSAMPLE arrays materialized as real OPC UA arrays; EVENT controlled-vocabulary values → OPC UA enumerations. - P2 (on demand): SHDR adapter ingest. A
SourceMode: "Agent" | "Shdr"switch that opens the raw pipe-delimited SHDR TCP socket directly. Loses auto-discovery (no device model without an Agent in front, soSupportsOnlineDiscoverywould have to reportfalseand tags would be authored by hand, like Modbus). Niche; not built.
Testing
- Unit tests —
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/(canned-XML fixtures, no network) — 491/491 at time of writing. Covers discovery tree shape,MTConnectDataTypeInference, observation indexing,UNAVAILABLE→BadNoCommunication, CONDITION state-word mapping, multipart chunk framing, and ring-buffer re-baseline paging (both the sequence-gap and theOUT_OF_RANGElegs). - Integration tests —
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/— env-gated onMTCONNECT_AGENT_ENDPOINT(defaulthttp://10.100.0.35:5000), skips cleanly (12 Skipped) when the fixture is unreachable, 12/12 against a real Agent. Read the fixture's ownDocker/README.mdfor the full recipe — summary only, here:- Image is
mtconnect/agent:2.7.0.12— notmtconnect/cppagent, which does not exist on Docker Hub (the design's original assumption). - The stack is two services: the Agent plus a stdlib-only SHDR adapter
(
Docker/adapter.py, onpython:3.13-alpine). An Agent with no adapter reports every observationUNAVAILABLEforever, which proves nothing. - Deployed at
/opt/otopcua-mtconnect/on the shared docker host (10.100.0.35,project=lmxopcualabel). Endpointhttp://10.100.0.35:5000. agent.cfgmust be pure ASCII — one non-ASCII byte anywhere, including a comment, makes the config parser reject the whole file with a bareFailed / Stopped at line: N.- Bring-up:
lmxopcua-fix sync mtconnectthenlmxopcua-fix up mtconnect(PowerShell helper, Windows-only) — or, directly on the docker host,rsyncthe repo'sDocker/dir to/opt/otopcua-mtconnect/and rundocker compose up -d --wait.
- Image is
Further reading
docs/plans/2026-07-15-mtconnect-driver-design.md— full design: capability wiring, browse reconciliation, typed-editor spec, resilience/timeout rules, phasingdocs/plans/2026-07-24-mtconnect-driver.md— the executable implementation plan and the task-by-task build record, including where it diverged from the design (TrakHound → hand-rolled)docs/plans/2026-07-24-driver-expansion-tracking.md— Wave-2 program tracking- Docker fixture README — the authoritative fixture recipe (seeded device model, endpoint, Mac AirPlay port-5000 gotcha)