# 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 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 Those three are the whole data plane: **agent-first** — Discover + Read + Subscribe. There is a fourth path, and it is not part of the data plane: the Agent's optional **`AllowPut`** surface, off by default in `agent.cfg`, which this driver's `IWritable` uses (`b29623cf`, 2026-08-05). It sets what the Agent *reports* for a DataItem; nothing reaches the machine. Read [Write-back](#write-back--observation-injection-off-by-default) before enabling it. ## 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, IWritable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`). `IWritable` landed 2026-08-05 (`b29623cf`), against the Agent's `AllowPut` surface rather than MTConnect *Interfaces*. It is **observation injection, not machine command**, and is off at two independent gates by default — read [Write-back](#write-back--observation-injection-off-by-default) before authoring a writable MTConnect tag. *Interfaces* itself — the protocol's own request/response handshake — is a different mechanism and remains deferred; 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 | | `IWritable` | `WriteAsync` → `POST {agent}/{device}?{dataItemId}={value}` on the Agent's `AllowPut` surface | **Off by default** — needs `Write.Enabled` **and** the Agent's `AllowPut`; CONDITION items are refused | | `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) | | `IAlarmSource` | CONDITION observations off the same `/sample` pump → Part 9 transitions keyed by the alarm tag's RawPath | Opt-in per raw tag (`alarm` object) — see [CONDITION alarms](#condition-alarms-626) | **`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 }, "Write": { "Enabled": false }, "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` | **Double duty.** (1) Scopes requests to `{AgentUri}/{DeviceName}/...`; `null` = whole Agent. (2) The write path's fallback device — the device a write is addressed to when the probe model does not say which device declared the DataItem (see [Write-back](#write-back--observation-injection-off-by-default)) | | `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 | | `Write.Enabled` | `false` | The driver-side half of the write-back opt-in. An absent block means disabled — see [Write-back](#write-back--observation-injection-off-by-default) | | `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 is the field name `RawBrowseCommitMapper` writes for a driver with no typed-editor branch of its own — MTConnect has had such a branch since Gitea #536 and now commits under `fullName`, but blobs committed before it, and hand-authored/CSV/API blobs, still carry `address` and must stay readable. ### 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`). **`driverDataType` wins when it is non-blank**, and a present-but-invalid one rejects the tag rather than falling through to `dataType`. The AdminUI's typed editor mirrors that precedence on load and **normalises onto `dataType`, dropping the alias, on save** (Gitea #550) — so a tag edited through the editor keeps the type the driver read for it, and an unreadable spelling is refused at authoring instead of dying at the driver. 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. > **A browse-committed tag resolves at step 1, carrying the step-3 answer.** `RawBrowseCommitMapper`'s > MTConnect branch emits a whole `MTConnectTagConfigModel`, whose `ToJson()` always writes `dataType` > — so a committed blob always states a non-blank type and step 1 always wins. Since Gitea **#560** > that stated type is the **browsed leaf's own inferred `DriverDataType`**, the very value step 3 > would compute, forwarded by `MapLeaf` into `BuildTagConfig` exactly as the AbCip / TwinCAT / FOCAS > branches forward their browsed native type (Gitea #536). It therefore agrees by construction with > the raw tag row's OPC UA `DataType`, which `MapLeaf` → `MapDataType` takes from the same inference. > > Before #560 the branch stamped the model's `String` default instead, so a browsed `SAMPLE` landed > as a `Double` node whose driver coerced the observation to text — recorded as `Driver.MTConnect-003` > in [`code-reviews/Driver.MTConnect/findings.md`](../../code-reviews/Driver.MTConnect/findings.md). > A leaf the browser reports **no** type for still commits `String`, and a leaf legitimately inferred > as `String` (an `EVENT` item) commits `String` too — in both cases the type the driver would have > reached anyway. > > **Step 3 remains the rescue for a blob that states no type at all** — hand-authored, CSV-imported, > or API-posted `RawTags[]` entries carrying only an id, which is the shape it was written for. ### 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 | | `BadDecodingError` (`0x80070000`) | A `TIME_SERIES` vector whose element count disagrees with the observation's own `sampleCount` (or the tag's fixed `ArrayDim`) — see [TIME_SERIES](#time_series-arrays-627) | | `BadNotSupported` (`0x803D0000`) | A shape this build doesn't materialize — a structured `DATA_SET`/`TABLE` observation (decided: stays unsupported, #627), or an array tag declared as anything other than Float64 | | `BadWaitingForInitialData` | Tag is authored but the Agent hasn't reported it yet | | `BadNodeIdUnknown` | DataItem id is neither authored nor ever observed | ### TIME_SERIES arrays (#627) A `SAMPLE` DataItem with `representation="TIME_SERIES"` is served as a **variable-length `double[]`** (`ValueRank = 1`, no fixed `ArrayDimensions` — a live Agent never declares `sampleCount` on the DataItem, see Known limitations 4; a probe that does declare one yields a fixed `ArrayDim`). Parsing: the observation text is split on any whitespace, each token parsed invariant-culture. The result is **all-or-nothing** — one non-numeric / non-finite token is `BadTypeMismatch`, a literal `UNAVAILABLE` token or an empty body is `BadNoCommunication`, and a count that disagrees with the observation's own `sampleCount` attribute (or a fixed `ArrayDim`) is `BadDecodingError`. A partial vector is never served as Good. Only a **Float64** array tag is served; any other declared element type stays `BadNotSupported`. An authored tag needs `"isArray": true` and `"dataType": "Float64"` in its TagConfig (the AdminUI editor shows an advisory for any other array type; a browse-commit does not stamp `isArray`, so set it in the Tag modal's array section). **Sample rate is not exposed on the served node.** The observation's `sampleRate` and `sampleCount` attributes are parsed (`MTConnectObservation`) but the OPC UA node has no description/property slot through `DriverAttributeInfo`, and a sibling property node would need a new address-space mechanism. Decision: document it — the rate is a property of the Agent's DataItem (`sampleRate` in `/probe`), read it there. **`DATA_SET` / `TABLE` stay `BadNotSupported`** (decision, #627): they are key/value maps with no scalar or array OPC UA shape that preserves the keys. ### 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](#known-limitations) for what that does and does not do. ## Write-back — observation injection, off by default > ⚠️ **A write sets what the Agent *reports*, not what the machine does.** The value > lands in the Agent's observation buffer exactly as if an adapter had reported it — > visible to **every** consumer of that Agent, and **overwritten by the next > adapter/SHDR update for that DataItem**. Nothing reaches the CNC. Never document, > script, or operate this as a machine command. It is genuinely useful for > operator-set EVENT values, for external systems feeding DataItems the adapter does > not own, and for test rigs — and nothing else. The mechanism is the Agent's **`AllowPut`** surface (`POST {agent}/{device}?{dataItemId}={value}`), a standard cppagent feature that is **off by default**. It is not MTConnect *Interfaces* — see [Deferred](#deferred-not-in-this-build) for why Interfaces was rejected as the write-back shape. Design record: [`2026-08-05-mtconnect-writeback-design.md`](../plans/2026-08-05-mtconnect-writeback-design.md) §2.2; implementation `b29623cf`. ### Two independent gates, both off by default 1. **The Agent** — `AllowPut = true` in `agent.cfg`. Optionally scope it with `AllowPutFrom = ` so only named hosts may write; that list is the Agent's own access control and this driver neither reads nor influences it. 2. **The driver** — `"Write": { "Enabled": true }` in the driver config (default `false`; an absent block is disabled). Authored in the AdminUI on the `/raw` driver config modal's **Write-back** section (`MTConnectDriverForm`), which writes the block explicitly in both states — so turning the switch back off clears a previously authored `true` rather than leaving it standing. Either gate alone leaves the shipped read-only behaviour unchanged: with `Write.Enabled` off every slot is refused `BadNotWritable` without an Agent being touched, and with `AllowPut` off the Agent refuses and the driver reports `BadCommunicationError`. ### The OPC UA write bit is authored, not derived — read this before filing a bug **Enabling write-back does not make any existing node writable over OPC UA.** The `CurrentWrite` access bit is composed from the **authored `Tag.AccessLevel`** alone (`AddressSpaceComposer` → `AddressSpaceApplier`, which additionally requires a non-array tag → `OtOpcUaNodeManager.ComposeAccessLevel`); driver capability is never consulted at compose time. An operator must set the raw tag to **ReadWrite** — a browse-committed tag is created `Read` (`RawBrowseCommitMapper.MapLeaf`) whatever the browse said. Browse *does* stamp writable leaves `SecurityClassification.Operate` when `Write.Enabled` is on (and `ViewOnly` otherwise, CONDITION always `ViewOnly`), on the same "a node cannot advertise writability the driver would refuse" principle MQTT pinned. But that stamp is **honest labelling, not the access bit**: nothing persists `SecurityClass` — `Tag` has no such column — so it reaches the browse session's attribute side-panel and stops there. ### What a write does, slot by slot `WriteAsync` never throws and one bad slot never fails the batch (matching `ReadAsync`); even cancellation returns a full-length result set. The refusal order is fixed, and each step answers a different operator question: | Condition | Status | Meaning | |---|---|---| | `Write.Enabled` off, **or** the DataItem is CONDITION-category | `BadNotWritable` (`0x803B0000`) | Nothing was attempted; no Agent was involved | | No live Agent client (pre-`InitializeAsync`, faulted initialize, post-shutdown) | `BadNotConnected` (`0x808A0000`) | This driver is not running | | The reference resolves to no DataItem, or to one whose owning device cannot be named | `BadNodeIdUnknown` (`0x80340000`) | There is no Agent URL the write could be addressed to | | The value has no invariant string form — `null`, or non-scalar | `BadTypeMismatch` (`0x80740000`) | Arrays are refused rather than rendered (`Convert.ToString` would emit the literal `System.Double[]`, which the Agent stores happily) | | The Agent refused, or could not be reached | `BadCommunicationError` (`0x80050000`) | Includes an `MTConnectError` document returned under HTTP 200 | **CONDITION DataItems are excluded deliberately** — they carry alarm semantics and a different `level|text` wire format, and are out of scope for v1. **The Agent's refusal text is logged at Warning and never stored.** It stays out of `DriverHealth.LastError`, which is type-only by repo-wide discipline, and a refused write is not evidence the read path degraded — so a refusal leaves driver health untouched. **Device resolution needs no extra config.** `TagBinding.WriteTargetsByDataItemId`, built at bind time from a walk of the cached probe model, remembers which device declared each DataItem, so a multi-device Agent is addressed correctly; the fallback is the configured `DeviceName`, and an unresolvable device is `BadNodeIdUnknown` rather than a guess. **Values are rendered with `CultureInfo.InvariantCulture`.** A Boolean therefore writes the invariant string `"True"`/`"False"`, **not** `"1"`/`"0"` — if the consuming adapter or Agent vocabulary expects something else, author the tag as a `String` and write the exact token. ## 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 alarms (#626) A `CONDITION` DataItem can be served two ways, and the choice is **per raw tag**: - **As a String value** (the default — every existing CONDITION tag, and every browse-committed one). The value is the current state word — `Normal` / `Warning` / `Fault` / `UNAVAILABLE` (`UNAVAILABLE` → `BadNoCommunication`). Unchanged by #626. - **As a native OPC UA Part 9 alarm**, by adding the cross-driver `alarm` object to the raw tag's `TagConfig` — exactly how a Galaxy alarm tag is made alarm-bearing (the `/raw` tag modal's native alarm section, or JSON): ```json { "address": "dev1_system_cond", "alarm": { "alarmType": "AlarmCondition", "severity": 800 } } ``` The server then materialises **one Part 9 condition at the tag's RawPath** (ConditionId = RawPath), fanned to the raw device folder and every referencing equipment folder, and the driver feeds it through `IAlarmSource`. An alarm-bearing tag is a condition, not a value variable (the host excludes alarm tags from the value subscription), so to keep the String as well, author a second raw tag on the same DataItem without the `alarm` object — one DataItem may back several raw tags. It is deliberately **not automatic** for `category=CONDITION`: that would silently turn every existing CONDITION String tag into a condition node on upgrade. **State mapping.** | Agent level | Part 9 | Severity | Quality | |---|---|---|---| | `Normal` | inactive (return to normal) | — | Good | | `Warning` | active, unacknowledged | 500 | Good | | `Fault` | active, unacknowledged | 800 | Good | | `Unavailable` | **unchanged** (an active condition stays active and acknowledged) | — | `BadNoCommunication` | **`Unavailable` is comms loss one hop further out, not a return to normal.** A cppagent marks every DataItem of an adapter UNAVAILABLE when the adapter socket drops, so it is treated exactly like the host's own transport loss (#477/#481): the condition keeps its Active/Acked state and only its `Quality` goes `BadNoCommunication`, as a **quality-only annotation** (no `/alerts` row, written through the host's Quality-only node path — it can never rewrite message/severity or acknowledge anything). When data returns: the **same** statement ⇒ quality-only Good (an ack survives an adapter blip); `Normal` ⇒ Clear; a **different** active statement ⇒ Retrigger. An inactive condition going Unavailable is likewise a quality-only Bad. Severity is the exact Part 9 value (`AlarmEventArgs.OpcUaSeverity`, #626), not one of the four buckets. A clear keeps the severity of the alarm it clears. **Message** = the condition's text content; without text, `"{type} {level}"` (e.g. `SYSTEM Fault`). Whichever of `nativeCode` / `nativeSeverity` / `qualifier` the Agent supplied is appended — `Spindle overtemperature [nativeCode=E17, nativeSeverity=2, qualifier=HIGH]` — because the Part 9 event model has no slot for them. `AlarmType` is the condition's `type`; `SourceNodeId` the DataItem id. **Several simultaneous conditions.** MTConnect can hold several active conditions on one DataItem (distinct `nativeCode`s). The OPC UA condition shows **the most severe active one**; equal levels tie-break deterministically (ordinal `nativeCode`, then `type`), never by Agent document order, so a re-baseline that lists them differently cannot re-raise. The driver tracks them with MTConnect's own rules (`MTConnectConditionTracker`): `Warning`/`Fault` activates its `nativeCode`; `Normal` with a `nativeCode` clears only that code, `Normal` without one clears all; `Unavailable` clears all; a `/current` re-baseline replaces the DataItem's set. So a `/sample` chunk clearing code A while code B is active is **not** a return to normal. An escalation (Warning → Fault) or a different winning code is a **Retrigger** — active and unacknowledged again with the new severity/message. By design that includes **clearing the worst of several codes**: the next one down is re-raised un-acked, because it is a statement the operator has not seen. A **text-only change** on the same level + `nativeCode` (an adapter embedding a live value in the text) updates nothing — no retrigger, no un-ack — and the message shown stays the statement's first text. (The String value surface still reconciles per document, so on a delta it shows the state word of that chunk; the alarm is the authoritative view.) **Acknowledge.** MTConnect has no acknowledgement upstream. Native conditions support Acknowledge and AddComment only; the ack is applied server-side and the driver's `AcknowledgeAsync` is a no-op success (nothing is sent to the Agent). Off-Primary acks are refused by `NativeAlarmAckPrimaryGate` as for every native source. **Restart / reconnect / comms loss.** The driver diffs each condition against what the server was last **told** — dedup on level + `nativeCode` + `nativeSeverity` + `qualifier` (not text, never the value) — and records a state only when an alarm handler was attached to receive it. Consequences: - A replayed `/current` (reconnect, re-initialize, Agent restart, ring-buffer re-baseline) re-raises nothing that is unchanged — re-raising would un-acknowledge an operator's ack. - A transition that happened while the host was detached (it detaches while it reconnects) is delivered on the re-subscribe that follows; a fault cleared during an outage is cleared on recovery. - Comms loss emits nothing from the driver, matching every native source: the host annotates every condition the driver owns `BadNoCommunication` on the connectivity edge (#477) and keeps its Active state; the reconnect edge re-annotates Good, after which the driver re-asserts any still-`Unavailable` condition's Bad quality on its re-subscribe. - A re-subscribe with a **changed** alarm set clears the whole told record: only an alarm-bearing raw-tag delta changes the set, the server applies that as a full rebuild, and a rebuild recreates every condition node inactive — so every active condition is re-asserted onto its new node. The **same** set (a reconnect) keeps the record. (A rebuild that leaves this driver's alarm set unchanged triggers no re-subscribe; that is a host-wide gap shared with every native source.) - An alarm-only driver (no value tags) still runs the `/sample` pump. **EVENT controlled vocabularies as OPC UA enumerations — won't fix.** EVENT values (`Execution`, `ControllerMode`, …) stay Strings carrying the Agent's own word; an enumeration would need a per-type vocabulary table that vendors extend freely. ## 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 consumed** (Gitea #521, closed) — `DriverInstanceActor` subscribes `OnHostStatusChanged`, pulls `GetHostStatuses()` fresh, and the result rides `DriverHealthChanged` to the AdminUI `/hosts` page (no SQL; the `DriverHostStatus` table was dropped). See `docs/Telemetry.md`. 2. **One MTConnect driver instance = one Agent client = at most one `/raw` Device (#628, decision (b)).** The driver never reads `RawTagEntry.DeviceName`; every raw tag binds through the single Agent client scoped by `MTConnectDriverOptions.DeviceName`. The v3 raw tree always has a Device level (a new driver auto-creates `Device1`), and that single-Device layout is the normal, valid shape — its `DeviceConfig` is merged up into the driver config by `DriverDeviceConfigMerger` (this is how the endpoint lands). With **two or more** Devices under one MTConnect driver there is no merge-up and no routing, so which Device supplies the endpoint is ambiguous. That is now a **deploy-time `DraftValidator` error** (`MTConnectMultipleDevices`), and the AdminUI refuses to *create* the second Device (`RawTreeService.CreateDeviceAsync`, same message): "MTConnect binds all tags through one agent client per driver instance; author one MTConnect driver per agent device." Option (a), a client per Device, was rejected as a design change with no demand. A multi-device Agent is served by several driver instances, each with its own `DeviceName` scope. The driver's init-time Warning for differing `RawTagEntry.DeviceName` values stays as defence in depth for configs that predate the rule. ⚠️ **Upgrade note:** an MTConnect driver that *already* has two or more Device rows fails its next deploy with `MTConnectMultipleDevices` (the rule counts every Device row, enabled or not, because nothing at runtime reads `Device.Enabled`). Before upgrading, move each extra Device's tags under its own MTConnect driver and delete the extra Device rows. **The write path is the one place a device name is resolved per DataItem**, and it does not use `RawTagEntry.DeviceName` either: it reads the owning device out of the cached probe model and falls back to the top-level `DeviceName` (see [Write-back](#write-back--observation-injection-off-by-default)). So `MTConnectDriverOptions.DeviceName` does double duty — request scope for reads/browse, fallback write target for writes. 3. **Authoring only *warns* when a declared coercion type cannot hold the inferred one (#613).** The AdminUI tag editor (`MTConnectTagConfigModel.Warnings()`) compares the declared type with `MTConnectDataTypeInference` over the `/probe` metadata a browse-commit stamped (`mtCategory`/`mtType`), and shows an advisory line — it never blocks a save (the deploy gate does not refuse it) and is a no-op for hand-authored blobs with no stamped metadata. `representation` is not on the model; TIME_SERIES arrays get their own `isArray` advisory (declare Float64). Beyond that, 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. **A CONDITION alarm shows one condition per DataItem** — the most severe active `nativeCode`, not one Part 9 condition per code (see [CONDITION alarms](#condition-alarms-626)). 6. **Structured `DATA_SET`/`TABLE` observations surface as `BadNotSupported`**, not an approximation — see the Quality mapping table. `TIME_SERIES` is served as `double[]` (#627, see [TIME_SERIES arrays](#time_series-arrays-627)); the sample rate is not surfaced on the node. ## Deferred (not in this build) - **MTConnect *Interfaces*.** Still deferred, and for the reason design [§3.6](../plans/2026-07-15-mtconnect-driver-design.md#36-iwritable--not-implemented-v1) gave: it is a rare, optional request/response **state-machine handshake** requiring adapter-side cooperation, not a "set value" operation, and would mislead if modelled as `IWritable`. **Write-back itself is no longer deferred** — `b29623cf` shipped `IWritable` over the Agent's `AllowPut` observation-injection surface instead, a different mechanism entirely. See [Write-back](#write-back--observation-injection-off-by-default) and [`2026-08-05-mtconnect-writeback-design.md`](../plans/2026-08-05-mtconnect-writeback-design.md) §2.1. - **P1.5 fast-follow** (design [§9](../plans/2026-07-15-mtconnect-driver-design.md#9-phasing--effort)): CONDITION → native Part 9 alarms **shipped** (#626, see [CONDITION alarms](#condition-alarms-626)); EVENT controlled-vocabulary values → OPC UA enumerations is **won't-fix**. - **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). The write path adds `MTConnectAgentClientWriteTests` (`9f17d7ef` — verb, URL, agent-error-in-200 detection) and `MTConnectWriteTests` (`b29623cf` — every refusal leg, the batch shape, the browse-stamping flip). - **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`. - **CONDITION alarms (#626)** — `MTConnectConditionAlarmMappingTests` (tracker semantics + state mapping/sequencing), `MTConnectAlarmSourceTests` (driver wiring: Normal→Fault→Normal, escalation, several `nativeCode`s, Unavailable, replay after re-subscribe/re-initialize/Agent restart with no duplicate, the missed-while-detached delivery, comms loss), `MTConnectConditionAlarmWireTests` in `OpcUaServer.IntegrationTests` (a scripted Agent's Fault/Normal reaching a client's event subscription on the raw device folder). **Live check:** the fixture's adapter only ever writes `normal` for its two CONDITION items (`fixture_x_travel` / SHDR key `Xtravel`, `fixture_logic` / `Plogic`, see `Docker/Devices.xml`). To see a transition, make `send_conditions()` in `Docker/adapter.py` alternate `Xtravel|fault|1001|2|HIGH|X overtravel` with `Xtravel|normal||||` every few ticks, sync + restart the adapter, author a raw tag `{"address":"fixture_x_travel","alarm":{"alarmType":"AlarmCondition"}}` on an MTConnect driver, deploy, and subscribe events on its raw device folder (Client CLI `alarms`): expect a Fault event (severity 800, message `X overtravel [nativeCode=1001, nativeSeverity=2, qualifier=HIGH]`), then a return-to-normal event, repeating. ## 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)