# 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`](../plans/2026-07-15-mtconnect-driver-design.md) and the build-vs-plan record in [`docs/plans/2026-07-24-mtconnect-driver.md`](../plans/2026-07-24-mtconnect-driver.md); for the fixture recipe read [`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md`](../../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`](../plans/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 `Component`s 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](#browse--free-via-the-universal-browser) 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](#deferred-not-in-this-build). | 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](#known-limitations) | | `IRediscoverable` | watches the Agent's `Header.instanceId` | No consumer wires it today — see [Known limitations](#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 ```jsonc "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** (`` → the string `"Normal"`, `` → `"Fault"`); a `` 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](#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` — see [Known limitations](#known-limitations) for why that signal currently has no consumer. ## 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) + `DiscoverAsync` into a `CapturingAddressSpaceBuilder`, then tears the throwaway instance down. Each captured leaf's NodeId is the DataItem `id`, committed directly as `TagConfig.FullName` on pick. See [`docs/plans/2026-07-15-universal-discovery-browser-design.md`](../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](#deferred-not-in-this-build). ## Known limitations These are real, not placeholders — read them before relying on the driver for anything beyond values-and-conditions. 1. **`IRediscoverable` and `IHostConnectivityProbe` have no consumer in the server.** `DriverInstanceActor` wires only `ISubscribable.OnDataChange` and `IAlarmSource.OnAlarmEvent`; nothing in the server subscribes to `OnRediscoveryNeeded` or `OnHostStatusChanged` except `GalaxyDriver` wiring its own internal `DeployWatcher` sub-component. So a restarted Agent (a changed `instanceId`) leaves a stale address space behind an otherwise-Healthy driver. **This is a pre-existing fleet-wide gap affecting every driver that implements either interface** (eight drivers besides Galaxy), not something specific to MTConnect — this build simply surfaced it again. 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](../plans/2026-07-15-mtconnect-driver-design.md#36-iwritable--not-implemented-v1): 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](../plans/2026-07-15-mtconnect-driver-design.md#9-phasing--effort)): 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`](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/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 - [`docs/plans/2026-07-15-mtconnect-driver-design.md`](../plans/2026-07-15-mtconnect-driver-design.md) — full design: capability wiring, browse reconciliation, typed-editor spec, resilience/timeout rules, phasing - [`docs/plans/2026-07-24-mtconnect-driver.md`](../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`](../plans/2026-07-24-driver-expansion-tracking.md) — Wave-2 program tracking - [Docker fixture README](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md) — the authoritative fixture recipe (seeded device model, endpoint, Mac AirPlay port-5000 gotcha)