Files
lmxopcua/docs/drivers/MTConnect.md
T
Joseph Doherty adce27e7fa refactor(drivers): delete the dead discovered-node injection path (§8.2, #507)
#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.
2026-07-27 18:57:09 -04:00

20 KiB
Raw Blame History

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 /run verification 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 — a multipart/x-mixed-replace long-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:

  1. The RawTags[] blob's driverDataType or dataType field (both spellings accepted — the typed editor writes dataType, the driver's own tags[] shape uses driverDataType).
  2. A matching Tags[] entry naming the same DataItem id.
  3. The Agent's own /probe declaration, via MTConnectDataTypeInference.Infer (category/type/units/representation → DriverDataType).
  4. 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:

  1. IMTConnectAgentClient.IsSequenceGap — the next chunk's firstSequence is newer than the driver's expected cursor.
  2. An MTConnectError document reporting OUT_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)

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.

  1. A restarted Agent prompts an operator; it does not re-shape the address space. DriverInstanceActor now consumes OnRediscoveryNeeded (2026-07-27), so a changed instanceId raises a "re-browse" chip against this driver on /hosts carrying the reason. It is advisory: v3 authors raw tags through the /raw browse-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.

    IHostConnectivityProbe is still unconsumed — GetHostStatuses() has no production call site and nothing writes a DriverHostStatus row. That half remains a fleet-wide gap affecting every driver that implements it (Gitea #521).

  2. RawTagEntry.DeviceName is accepted on the wire shape but ignored for routing. One driver instance owns exactly one Agent client scoped by MTConnectDriverOptions.DeviceName (the top-level config key); the per-tag RawTagEntry.DeviceName field the v3 raw-tag identity carries is never read by the driver. Two /raw Devices 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.

  3. Nothing validates a raw tag's declared OPC UA DataType against 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 authored DataType mismatched against the actual Agent observation still surfaces as BadTypeMismatch /BadOutOfRange at runtime rather than at authoring time.

  4. sampleCount is not a legal DataItem attribute. It belongs on a TIME_SERIES observation, not the device-model declaration. A real Agent that meets sampleCount on a DataItem declaration logs The following keys were present and not expected: sampleCount and drops the entire data item from the served model. A live TIME_SERIES tag therefore always resolves to ArrayDim = null (variable-length array) in practice — the fixture's canned probe.xml/Devices.xml, which does declare a sampleCount, is unrealistic on this point and exists only to pin the parsing rule itself.

  5. CONDITION is a plain String, not a native Part 9 alarm (see above).

  6. TIME_SERIES arrays and structured DATA_SET/TABLE observations surface as BadNotSupported, 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_SERIES SAMPLE 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, so SupportsOnlineDiscovery would have to report false and 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 the OUT_OF_RANGE legs).
  • Integration tests — tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/ — env-gated on MTCONNECT_AGENT_ENDPOINT (default http://10.100.0.35:5000), skips cleanly (12 Skipped) when the fixture is unreachable, 12/12 against a real Agent. Read the fixture's own Docker/README.md for the full recipe — summary only, here:
    • Image is mtconnect/agent:2.7.0.12 — not mtconnect/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, on python:3.13-alpine). An Agent with no adapter reports every observation UNAVAILABLE forever, which proves nothing.
    • Deployed at /opt/otopcua-mtconnect/ on the shared docker host (10.100.0.35, project=lmxopcua label). Endpoint http://10.100.0.35:5000.
    • agent.cfg must be pure ASCII — one non-ASCII byte anywhere, including a comment, makes the config parser reject the whole file with a bare Failed / Stopped at line: N.
    • Bring-up: lmxopcua-fix sync mtconnect then lmxopcua-fix up mtconnect (PowerShell helper, Windows-only) — or, directly on the docker host, rsync the repo's Docker/ dir to /opt/otopcua-mtconnect/ and run docker compose up -d --wait.

Further reading