# 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`. **Hardened against the peer (#804).** The Agent is unauthenticated, so every parse refuses a `DOCTYPE` outright (`DtdProcessing.Prohibit` — the `/probe` and write-answer parses used to go through `XDocument.Parse`, which *parses* DTDs and expands internal entities), the streams walk refuses nesting deeper than 64 levels (it recurses per level, and an unbounded depth is a `StackOverflowException` .NET cannot catch — the whole server process, not the driver, would die), and a structural element (`Description`/`Configuration`/`References`/`Compositions`/`DataItems`) sitting directly under `` or `` is skipped rather than failing the whole probe on its missing `id`. ## 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, IDriverDiagnostics` (`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` → the live `/sample` pump's index while it is delivering and healthy, else 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 headers-only `/probe` ping under `Probe.*` — the status is read, the device model is neither downloaded in full nor parsed (review 2026-10-21 T-7; it used to parse the whole model every tick). Trade-off: a 200 from something that is not an Agent (a proxy's HTML page) now reads as reachable; the data paths still fail on it | Consumed — feeds `/hosts` (see [Known limitations](#known-limitations)) | | `IRediscoverable` | watches the Agent's `Header.instanceId` | No consumer wires it today — see [Known limitations](#known-limitations) | | `IDriverDiagnostics` | `GetDiagnostics()` — `/sample` effective count, lag, saturated chunks, count raises | Shown on `/hosts` (review 2026-10-21 T-5) | | `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) | ### Feature status at a glance (#613) | Feature | Status | Detail | |---------|--------|--------| | SAMPLE / EVENT / CONDITION values | **Supported** | EVENT values are Strings carrying the Agent's own word | | CONDITION as native Part 9 alarms (#626) | **Supported, opt-in** per raw tag via the `alarm` object | [CONDITION alarms](#condition-alarms-626) | | `TIME_SERIES` arrays (#627) | **Supported** as variable-length `double[]` (Float64 tags only) | [TIME_SERIES arrays](#time_series-arrays-627) | | One Agent client per driver, one `/raw` Device (#628) | **Enforced** (`MTConnectMultipleDevices` deploy error); multi-device Agents use several driver instances | [Known limitations](#known-limitations) | | Write-back (`IWritable`) | **Supported, off by default** (`Write.Enabled` + Agent `AllowPut`) | [Write-back](#write-back--observation-injection-off-by-default) | | EVENT controlled vocabularies as OPC UA enumerations | **Parked / won't fix** (stay String) - see rationale | [EVENT enumerations](#event-enumerations-650-parked--wont-fix) | | `DATA_SET` / `TABLE` representations (#650) | **Supported** as a String carrying compact JSON | [DATA_SET / TABLE](#data_set--table-650) | | MTConnect Interfaces, SHDR ingest | Not built | [Deferred](#deferred-not-in-this-build) | **`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. **Read path (review 2026-10-21 T-4 / T-2).** While the current-generation `/sample` pump is running, has delivered since it started (a chunk, heartbeat or re-baseline) and its stream is not degraded, a read is answered from the pump's observation index — **read only**; the pump stays its single writer — with no Agent round trip. Otherwise (no subscription, a pump still catching up after a resubscribe, a degraded stream) it falls back to one `/current`, whose per-batch index is now built over a tag map frozen once per binding. **Stale window:** a stream that stalls without failing (no chunk, no heartbeat) still serves Good reads from the index until the client's idle watchdog fires (2 × `HeartbeatMs` + `RequestTimeoutMs`) — the same bounded window subscriptions already have. During an Agent-restart re-baseline reads fall back to `/current`. A served read also clears a read-path degradation an earlier failed `/current` left (the read path is answering). The `/current` fallback now checks the answer's `instanceId`: a changed one (an Agent restart seen by a browse-only driver with no pump) drops the cached `/probe` model and raises `OnRediscoveryNeeded` once per distinct id — it used to be detected only by the pump. **An Agent refusal is a reply (health contract).** An `MTConnectError` document answering `/current` (e.g. `NO_DEVICE` for a wrong `DeviceName` scope — a 2.x Agent serves it under **HTTP 404**, an older one under 200; both are lifted since #804) still fails the batch (`BadCommunicationError` per ref), but it is the Agent answering, not a comms fault: the read path is not degraded by it (that would let the comms-loss watchdog reconnect-loop the driver over a configuration problem), `LastSuccessfulRead` advances, a prior read degradation clears, and the Agent's text is logged once per distinct refusal. An answer that is not an MTConnect document at all (a proxy's HTML page, truncated XML) stays a comms fault. ## 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, "CommsLossConfirmMs": 2000, "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`. Any `user:pass@` (and query) is **redacted** from `HostName` (the `/hosts` key), every log line and every exception / health message (review 2026-10-21 T-8); a URI without credentials is shown unchanged | | `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. **Caps subscription throughput** at `SampleCount / SampleIntervalMs` — see [Subscribe](#subscribe--the-sample-pump) for the adaptive raise | | `HeartbeatMs` | `10000` | The Agent's `/sample?heartbeat=` query param — also the watchdog's liveness window | | `CommsLossConfirmMs` | `2000` | #846: how long the Agent must keep **refusing** connections before every subscribed value goes `BadNoCommunication` (see [Comms loss](#comms-loss-refused-vs-ambiguous-846)). `0` = on the first refusal; negative = fast path off (watchdog only). JSON-only — the AdminUI form preserves it but has no field | | `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. **Integer-valued decimals are served on integral tags (#804).** Agents format integer-valued items as `42.000000`; an `Int*`/`UInt*` tag accepts any decimal whose value *is* an integer in range (`42.000000` → `42`, `1.5e3` → `1500`) losslessly. A real fraction stays `BadOutOfRange` — deliberately stricter than the Java reference driver, which truncates toward zero. 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 — including a `NaN`/`Infinity` spelling on a float tag (#804; it used to be served Good) | | `BadOutOfRange` (`0x803C0000`) | The Agent reported a number the coerced type can't represent — an integer overflow, a real fraction on an integral tag (`3.5` is refused, never truncated), or a float literal that overflows to ±Infinity (`1e400`; #804, it used to be served as a Good Infinity) | | `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 `DATA_SET`/`TABLE` observation whose children are not `` elements (a well-formed one is served as JSON since #650), 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 (#650) `DATA_SET` and `TABLE` observations carry their content in `` children (a `TABLE` entry holds `` children). They are served as a **String carrying compact, deterministic JSON** (OPC UA type String, never an array or structure - no new node shape, history/ACL/alarm paths unchanged; discovery and type inference already type these items String): - `DATA_SET` -> `{"offsetX":1.25,"toolNumber":7}`; `TABLE` -> `{"G54":{"X":1.0,"Y":2},"G55":{"X":3}}`. - Keys are ordinal-sorted at every level, no whitespace - the same content always serialises to the same bytes, so value-dedup works. Values that are *strict JSON numbers* are emitted as numbers with their original token (lossless: `1.0` stays `1.0`); anything else (`007`, `+5`, `on`) is a string. - **Merge semantics follow MTConnect 2.x.** A `/current` document states the *whole* set and **replaces** it; a `/sample` chunk carries *deltas* that **merge** by key (a `TABLE` row is replaced as a unit by a later entry for the same row key). An observation with `resetTriggered` replaces the accumulated set, and a `resetTriggered` observation with no entries is the empty set `{}`. Several observations of one item in one document fold in document order. The accumulated set is dropped on an Agent restart (`instanceId` change). - `removed="true"` deletes the key (or row): it is **absent** from the JSON, never `null`. - **Empty set is Good `{}`**: a data-set-shaped observation (it carries the `count` attribute, which only DATA_SET/TABLE have) with no entries - `count="0"` in a `/current`, or `resetTriggered` - is the empty set, `{}`, never an absence; an all-removed set renders the same. `resetTriggered` on any other observation (e.g. an accumulating `PartCount`) is ignored. - `UNAVAILABLE` is `BadNoCommunication` as for any observation, and also drops the accumulated set, so a later delta cannot resurrect pre-outage entries. - An observation whose children are not `` elements stays `BadNotSupported`. Writes to these items stay refused (`WriteAsync` excludes them). ### 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` — older Agents serve it under HTTP 200; a 2.x Agent (the fixture's `mtconnect/agent:2.7`) serves it under **HTTP 400**. ⚠️ **Until #804 only the 200 shape was handled**: the client threw a bare `HttpRequestException` for the 400, the pump read it as transport loss and reconnected at the same evicted cursor for ever, so this leg never ran against a real 2.x Agent (the comms-loss watchdog's re-initialize was the only escape). The client now reads up to 64 KiB of any non-2xx body and lifts an `MTConnectError` into the same refusal-marked `InvalidDataException` the 200 shape produces; a non-MTConnect body (a proxy page) stays a status-only `HttpRequestException`. **Re-baselines without progress are bounded *and visible* (#804).** The reconnect backoff already paced a stream that only ever re-baselines (`A_rebaseline_that_delivers_nothing…`), but every `/current` also marked the stream delivered, so the driver read **Healthy** for ever while no `/sample` chunk arrived — the live case is a `SampleCount` above the Agent's `bufferSize`, refused `OUT_OF_RANGE` (`'count' must be less than N`) on every open. After **3** consecutive re-baselines with no chunk in between (`MaxRebaselinesWithoutProgress`, the Java driver's `MAX_REBASELINES_WITHOUT_PROGRESS`) the stream is **Degraded** with `LastError = "repeated /sample re-baselines with no observations in between"` and one Warning naming the `bufferSize` cause; it stays Degraded across further re-baselines until a chunk is delivered. Values keep flowing off the re-baselines and `LastSuccessfulRead` keeps advancing — each `/current` is the Agent answering — so the comms-loss watchdog does not reconnect-loop a configuration problem. Size `SampleCount` at most the Agent's `bufferSize` (in the `/probe` header). **It is caught before it happens (#825):** the `/probe` header's `bufferSize` is parsed (`MTConnectProbeModel.BufferSize`, advisory — missing/malformed is null, never a parse failure); **Test Connect** reads `sampleCount` (default 1000 when absent) and answers Ok **with a warning** (an amber "OK with warning" chip showing the text) when it is **above** the buffer (live, cppagent 2.7: `count == bufferSize` is accepted, `bufferSize + 1` refused `'count' must be less than {bufferSize+1}`), and the driver's **initialize** (every deploy) logs the same Warning and puts `agentBufferSize` on the `/hosts` diagnostics bag. **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`. **The test also runs on every re-baseline's `/current` (#804)** — on a 2.x Agent that is usually where a restart is first seen, because the restarted process refuses the dead one's cursor with HTTP 400 `OUT_OF_RANGE` and no chunk ever reaches the in-stream check; the re-baseline used to adopt the new id silently (live-confirmed on the fixture). Either way the driver drops every held observation (subscribers are told) and raises the rediscovery signal, which surfaces a **re-browse prompt** on the AdminUI `/hosts` page — see [Known limitations](#known-limitations) for what that does and does not do. **Lag measurement and the adaptive `count` (review 2026-10-21 T-5).** Throughput is capped at `SampleCount` observations per chunk, one chunk per `SampleIntervalMs`; a machine producing more used to fall behind the ring buffer forever, re-baselining every buffer-length (dropping intermediate transitions) with nothing surfacing it. Each chunk is now measured: `lag = lastSequence + 1 − nextSequence` (unknown when the Agent omits `lastSequence`) and whether it was **saturated** (carried the full `count`). Three consecutive saturated chunks with a positive (or unknown) lag reopen the stream at the cursor reached with `count` doubled, up to 16× `SampleCount` (max 65 536) — **and never above the Agent's `/probe` `bufferSize`** (#846; a count above it is refused `OUT_OF_RANGE`, so a raise past it only bought a refused stream and a step-back; a configured `SampleCount` already above the buffer is not raised and is the #825 warning's business). A raised stream that delivers nothing (an Agent refusing the larger count) steps back to the last count that delivered and stops raising; at the ceiling a still-behind stream logs one Warning per pump. Every new pump starts from the configured `SampleCount`. An Agent that refuses the raised count with an `MTConnectError` under HTTP 200 goes through the ordinary unusable-answer path first — one transient Degraded blip and a backoff — before the step-back. The driver now implements `IDriverDiagnostics`: `sampleCount` (effective), `sampleLag`, `sampleSaturatedChunks` and `sampleCountRaises` ride the `/hosts` diagnostics bag. **A pump carries the session generation it was started against (review 2026-10-21 T-1).** A lifecycle restart retires the running pump (or abandons it after its bounded teardown wait); if that pump finishes after the new session is installed, its cursor publish, a late chunk and a late re-baseline are all dropped — checked under the same lock the new session is installed and the generation bumped under. It used to land the OLD Agent's `instanceId` + sequence on the new session, so the next pump read its first chunk as an Agent restart (index cleared, one Bad flicker on every subscribed tag, a needless re-baseline). ## 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. A refused write is not evidence the read path degraded, but it **is** the Agent answering: any answer — accepted or refused — advances `LastSuccessfulRead`, with the state following the read / `/sample` flags (a write cannot clear either of those paths' degradation). A write that got **no** answer (transport failure, deadline) sets **Degraded** with the stamp preserved and a type-only `LastError` (review 2026-10-21 I-3, coordinator ruling). One success writer is shared: that degradation is cleared by the next answered **write**, the next answered **`/current` read**, or the next **`/sample` chunk** the current session's pump delivers (review r2 N-5: a delivered chunk proves the Agent is alive — the analogue of OpcUaClient's good keep-alive — so it cannot stick on a subscription-only driver). A read served from the live pump index does not clear it by itself; the chunks behind that index do. **One request per device (review 2026-10-21 T-6).** The slots that survive the refusals above go out as one `POST {agent}/{device}?id1=v1&id2=v2…` per device (the Agent's PUT surface takes several pairs) instead of one POST per slot. A batch closes at a slot naming another device or repeating a DataItem already in it, so two writes to one DataItem still land in request order. An Agent refusal of a multi-slot request is retried slot by slot, so a refused DataItem costs only its own slot (`BadCommunicationError`); a transport failure fails that request's slots. An Agent may apply some pairs of a multi-pair PUT before refusing it, so the per-slot retry can re-send a pair that already landed — harmless for value-setting (the Agent drops an identical repeat). **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. Sibling containers that resolve to the **same folder name** (two components named alike, or one's `name` equal to another's `id`) share **one** folder (#804): the picker keys folders by path, so two same-named siblings used to list both but expand only the first one's children. A DataItem named like a sibling folder is not a collision — leaves are keyed by DataItem id. 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 drops the told record of only the conditions that **left** the set. The server carries every native condition's state (acknowledgement included) across a full rebuild itself (Gitea #641 — see `docs/AlarmTracking.md`), so re-raising a surviving condition would only un-acknowledge it; a condition that left and later comes back was materialised fresh and is told again. The **same** set (a reconnect) keeps the whole record. A rebuild that leaves the alarm set unchanged triggers no re-subscribe, and needs none — the server-side carry-over covers it. - An alarm-only driver (no value tags) still runs the `/sample` pump. ### EVENT enumerations (#650): parked / won't fix EVENT values (`Execution`, `ControllerMode`, …) stay Strings carrying the Agent's own word. Modelling them as OPC UA enumerations was considered and **declined**: the vocabulary is vendor-extensible, so it needs a per-type table plus an unknown-value fallback, a per-type enum node shape through discovery and raw-tag type inference, and it would break every consumer that reads these items as String (ScadaBridge) for little gain. Reopen only with an opt-in, tag-level `enumValues` design. ## Java reference parity (#804) Gitea #804 reviewed this driver against the read-only Java MTConnect driver in the `ignitionoee` repo (`modules/drivers/gateway/src/{main,test}/java/com/zb/mom/driver/mtconnect/`, live-validated on Ignition 2026-08-10). That Java driver is largely a port of this one, so most rows are **covered**; the review found nine concrete gaps, all closed on `feat/804-mtconnect-parity`. Code paths below are relative to `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/`, tests to `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/` (unit) and `…MTConnect.IntegrationTests/` (live); Java classes are `MTConnect*.java` in the package above. The Java driver's live evidence does not validate this one — this driver's own live results are at the end. **Status key:** **covered** — .NET already did it (test named); **gap → closed** — verified missing, fixed in #804 with a test that failed first; **.NET exceeds** — beyond the Java baseline, kept; **divergence** — deliberately different, kept. ### Transport, protocol and configuration | Java capability (class) | .NET status | .NET code · tests | |---|---|---| | HTTP/HTTPS Agent base URI, http/https only (`MTConnectConfigValidator`) | covered | `MTConnectAgentClient.ResolveAgentRoot` · `MTConnectFactoryTests.Malformed_or_non_http_agentUri_returns_not_ok` | | Optional device scope `{agentUri}/{deviceName}/…` (`MTConnectAgentClient`) | covered (+ a belt-and-braces scope match in discovery) | `BuildRequestUri`, `MTConnectDriver.InDeviceScope` · `MTConnectDiscoverTests.Discover_with_a_device_scope_*` | | `/probe` at connect, fault-fast (`MTConnectDevice.connect`) | covered | `StartCoreAsync` (initialize fails → Faulted) · `MTConnectDriverLifecycleTests` | | `/current` snapshot; `/sample` multipart long poll with `from`/`interval`/`count`/`heartbeat` | covered | `CurrentAsync`/`SampleAsync` · `MTConnectStreamsParseTests.SampleAsync_composes_the_from_interval_count_and_heartbeat_query` | | Strictly-positive timing knobs (`requirePositive`) | covered | `MTConnectDriver.RequirePositive` · `MTConnectDriverOptionsTests` | | One HTTP client per device, not per reconnect (`SharedHttp`) | covered — two `HttpClient`s per agent client, rebuilt only on a request-shaping config change | `RequiresClientRebuild` · `MTConnectDriverLifecycleTests.Reinitialize_with_a_changed_device_scope_rebuilds_the_client` | | Agent error document under a **non-2xx** status lifted (`probeAgentError`/`streamsAgentError`) — a 2.x Agent serves `OUT_OF_RANGE` as **400** and `NO_DEVICE` as **404** | **gap → closed** | `EnsureAgentAnsweredAsync`, `MTConnectXml.AgentRefusalFromErrorBody` · `MTConnectJavaParityTests.Sample_answered_400_…`, `Current_answered_404_…`, `Probe_answered_404_…`; live `A_sample_cursor_outside_the_buffer_is_the_agents_out_of_range_refusal` | | Failure body read only to a cap (`MAX_ERROR_BODY_BYTES`, 64 KiB) | **gap → closed** (with the row above) | `MaxErrorBodyBytes` · `An_error_document_beyond_the_body_cap_is_not_read_in_full`, `A_non_2xx_proxy_page_is_still_a_plain_http_failure` | | Demand-read TTL cache for un-monitored reads (`MTConnectDemandSource`, `demandReadTtlMs`) | covered, different model | reads answer from the live pump index (no round trip) or one `/current` per **batch**; the server never calls `ReadAsync` on its hot path · `MTConnectReadPathTests` | | No credentials / no `SecretConfig` | divergence | a `user:pass@` URI is accepted and **redacted** everywhere (`MTConnectUriRedaction`) · `MTConnectUriRedactionTests` | ### Parsing, browse and values | Java capability (class) | .NET status | .NET code · tests | |---|---|---| | Match on LocalName, read attributes unqualified (`MTConnectXml` rules 1–2) | covered | `MTConnectXml` · `Probe_parse_is_agnostic_to_the_document_namespace_version`, `Probe_parse_keeps_a_vendor_extension_component_in_its_own_namespace`, `Parse_reads_only_unqualified_attributes` | | DOCTYPE refused on **every** parse (`disallow-doctype-decl`) | **gap → closed** for `/probe` and write answers (the streams parser already prohibited it) | `MTConnectXml.ParseHardened`/`LoadHardened` · `Probe_parse_refuses_a_doctype_from_a_string`/`…_stream`, `A_write_answered_with_a_doctype_document_is_not_confirmed` | | Component nesting bound 64 (`MAX_COMPONENT_DEPTH`) | covered | `MTConnectProbeParser.MaxComponentDepth` · `MTConnectProbeParseTests` | | Streams element nesting bound 64 (`MAX_ELEMENT_DEPTH`) | **gap → closed** — the walk recursed without bound: an uncatchable `StackOverflowException`, i.e. a server-process crash | `MTConnectStreamsParser.MaxElementDepth` · `Streams_parse_refuses_absurd_nesting_…`, controls `Streams_parse_accepts_ordinary_device_nesting`, `An_absurdly_deep_error_document_still_yields_the_agents_words` | | Structural elements under ``/`` skipped (`NOT_A_COMPONENT`) | **gap → closed** (one stray `` failed the whole probe) | `MTConnectProbeParser.ComponentLikeChildren` · `A_structural_element_under_components_or_devices_is_skipped_not_fatal` | | Malformed XML, missing header, missing `dataItemId` throw | covered | `Probe_parse_throws_on_malformed_xml`, `Parse_throws_on_malformed_xml`, `Parse_throws_on_a_malformed_observation` | | BOM / framing whitespace tolerated on a chunk | covered | `Parse_from_bytes_tolerates_the_multipart_frames_trailing_line_break` | | Multipart framing: Content-length authoritative, boundary-scan fallback, preamble, LF-only, split reads, truncation throws, 32 MiB part cap (`MTConnectMultipartReader`) | covered | `MultipartStreamReader` · `SampleAsync_frames_*`, `A_part_without_content_length_warns_once…`; Java cases ported as `A_declared_content_length_is_authoritative…`, `A_preamble_is_skipped_and_line_feed_only_framing_is_accepted`, `A_part_shorter_than_its_declared_content_length_is_a_lost_stream`, `A_part_larger_than_the_buffer_cap_is_refused` | | Typed Device → Component → DataItem tree; `name` else `id`; leaf ref = DataItem id (`MTConnectBrowseTree`) | covered | `DiscoverAsync`/`StreamContainer` · `MTConnectDiscoverTests` | | Sibling containers resolving to one name share **one** folder | **gap → closed** (the picker listed both, expanded only the first) | `MTConnectDriver.SiblingFolders` · `Sibling_components_resolving_to_one_name_share_one_folder_and_keep_every_data_item` | | DataItem named like a sibling component (leaf-before-folder ordering) | covered — not a collision here: leaves are keyed by DataItem id, folders by path | `Commons/Browsing/CapturingAddressSpaceBuilder` | | Bounded tree (`maxBrowseNodes` per-folder cap + truncation flag) | covered, different mechanism | the browse capture caps the whole tree at 50 000 nodes and flags `Truncated`; the *served* address space is authored raw tags, not the browse | | Display-name trimming (`segmentsAreTrimmed`) | divergence (not closed) | names are used verbatim — cosmetic, never seen on a real Agent | | Type inference SAMPLE→Double, numeric EVENT→Int64, else String (`MTConnectTypes`) | covered, **.NET exceeds** (TIME_SERIES → `double[]`, separator-insensitive types) | `MTConnectDataTypeInference` · `MTConnectDataTypeInferenceTests` | | UNAVAILABLE → `Bad_NoCommunication`, null value, source timestamp kept (`MTConnectValues`) | covered | `MTConnectObservationIndex.BuildSnapshot` · `Unavailable_observation_maps_to_BadNoCommunication_with_null_value`, `Unavailable_observation_keeps_the_agent_source_timestamp` | | UNAVAILABLE matched case-insensitively | divergence (kept) | ordinal match on the parser-normalised token, so a free-text EVENT reading "Unavailable" stays a value · `Unavailable_sentinel_is_matched_on_the_exact_literal` | | Blank text on a String node is a Good empty string | divergence (kept) | empty text is `BadNoCommunication` (an empty element is not an empty-string value) | | No fabricated numbers: narrow-integer range checks, float overflow ≠ Infinity, NaN/Infinity refused (`MTConnectValues.coerce`) | covered for integers; **gap → closed** for floats (`1e400`, `NaN`, `Infinity`, `1e39` on Float32 were served Good) | `Coerce`, `NonFiniteStatus` · `A_non_finite_float_is_never_served_as_good` | | Integer-valued decimal (`42.000000`) on an integral node | **gap → closed** (was `BadOutOfRange`) — lossless only; a real fraction is still refused, never truncated (Java truncates) | `TryIntegral` · `An_integer_valued_decimal_converts_losslessly`, control `A_fraction_or_an_out_of_range_decimal_stays_out_of_range` | | Source timestamps preserved; a non-Zulu offset normalised | covered | `ReadTimestampUtc` · `A_timestamp_with_a_non_zulu_offset_is_normalized_to_the_same_utc_instant` | | An unparseable timestamp falls back to *now* | divergence (kept) | the chunk is refused (`InvalidDataException` → re-baseline): a fabricated SourceTimestamp is never served | | Transport failure distinct from UNAVAILABLE | covered | `BadCommunicationError` per ref on a failed read; the host fans `BadNoCommunication` on comms loss (#481) | ### Stream lifecycle | Java capability (class) | .NET status | .NET code · tests | |---|---|---| | Running cursor; gap = `firstSequence > expected`, strictly (`MTConnectPump.onDocument`) | covered | `IsSequenceGap` · `Sequence_gap_is_strictly_firstSequence_greater_than_expected_from`, `Sequence_gap_triggers_exactly_one_current_rebaseline_then_resumes` | | `instanceId` checked before the gap; a restart drops held values | covered | `ConsumeStreamAsync` · `Agent_restart_clears_the_index_and_is_detected_before_the_gap_path` | | The restart test also runs on the re-baseline snapshot | **gap → closed** — on a 2.x Agent a restart is usually seen ONLY here (the old cursor gets a 400) | `RebaselineAsync`, `TryDropForAgentRestart` · `An_agent_restart_seen_only_by_the_rebaseline_…`, control `An_out_of_range_rebaseline_against_the_same_agent_raises_no_rediscovery` | | Restart → reconnect + re-probe + rebuild the tree | divergence (v3 design) | re-baseline in place + an `IRediscoverable` re-browse prompt; the served tree is authored, never grafted (Known limitations 1) | | `OUT_OF_RANGE` (200 **or** 400) → re-baseline (`onError`) | covered for 200; **gap → closed** for 400 (Transport table) | the `InvalidDataException` arm of `ConsumeStreamAsync` · `Out_of_range_stream_error_also_triggers_a_rebaseline` | | Any other Agent error → reconnect, not re-baseline | divergence | every unusable answer re-baselines; the backoff ladder and the no-progress cap below bound it | | Re-baselines without progress bounded (`MAX_REBASELINES_WITHOUT_PROGRESS` = 3) | paced already (backoff); **gap → closed** for visibility — it read Healthy for ever | `MaxRebaselinesWithoutProgress`, `TryHoldStreamStalled` · `Repeated_rebaselines_without_a_delivered_chunk_report_the_stream_degraded`, control `A_rebaseline_short_of_the_cap_stays_healthy`; live `A_sample_count_the_agent_refuses_is_reported_degraded_while_values_keep_flowing` | | Heartbeat watchdog; any part resets it (`watchdogExpired`, 2 × heartbeat) | covered (2 × `HeartbeatMs` + `RequestTimeoutMs`) | `MultipartStreamReader` idle timeout · `SampleAsync_faults_on_the_heartbeat_watchdog_rather_than_hanging_on_a_silent_agent` | | Bounded reconnect backoff (fixed 2 s → 30 s, jitter) | covered, configurable (`Reconnect.*`; ladder reset after a delivering stream) | `BackoffFor` · `MTConnectSubscribeTests.Reconnect_backoff_*` | | A superseded generation speaks for nothing (`PumpState`, `Host.running`) | covered | session generation · `MTConnectPumpGenerationTests` | | Deterministic cancellation and disposal | covered | `ShutdownAsync`, bounded teardown waits · `Shutdown_stops_the_pump_before_disposing_the_client_and_leaks_nothing`, `Shutdown_while_the_pump_is_mid_rebaseline_does_not_deadlock` | ### Representations and writes | Java capability | .NET status | Notes | |---|---|---| | TIME_SERIES / DATA_SET / TABLE **excluded** from the tree (one WARN) | **.NET exceeds** — kept | TIME_SERIES → `double[]` (#627); DATA_SET/TABLE → compact JSON String (#650) · `MTConnectDataSetTests`; live `Discovery_types_samples_as_Float64_and_time_series_as_a_float_array`, `Read_serves_data_set_and_table_as_json_strings` | | CONDITION → String state word | covered, **.NET exceeds** (opt-in native Part 9 alarms, #626) | [CONDITION alarms](#condition-alarms-626) | | **No write path** (read-only, no `writeTag()`) | **divergence — flagged, not removed** | `IWritable` (`b29623cf`, 2026-08-05) is the Agent's `AllowPut` **observation injection**: it sets what the Agent *reports* for a non-CONDITION, non-array DataItem, and nothing reaches the controller. It is off at two independent gates (`Write.Enabled`, default false; the Agent's `AllowPut`, default off) and a node is only writable once its raw tag is authored `ReadWrite`. So there is no *controller* write path — but there is an Agent write path the Java driver does not have. See [Write-back](#write-back--observation-injection-off-by-default); rejection is live-tested by `Write_is_refused_without_touching_the_agent_when_write_is_not_enabled` | ### Live validation (2026-11-10, `mtconnect/agent:2.7.0.12` at `http://10.100.0.35:5000`) - **Integration suite: 20/20** (`MTCONNECT_AGENT_ENDPOINT=http://10.100.0.35:5000`): browse, typed discovery, read, UNAVAILABLE, DATA_SET/TABLE, subscribe, RawPath binding, the write round trip, the Agent's write refusal and the `write.enabled` rejection — plus the two #804 legs, both of which failed before the fix. - **Lifecycle harness** — the real `MTConnectDriver` subscribed to `fixture_c_speed`/`fixture_mode`/`fixture_execution` with `HeartbeatMs` 5000, while the fixture containers were stopped/restarted/paused (all restored and healthy after): - *Adapter stop:* the Agent marked its items UNAVAILABLE ~12 s later (its own adapter timeout); all three went `BadNoCommunication` while the driver stayed **Healthy** (the Agent kept answering). *Adapter start:* Good ~3 s later. - *Agent restart:* one `Connection refused` Degraded tick, the restart seen on the re-baseline, **rediscovery raised**, Healthy and Good ~2 s after the Agent was back. Before the re-baseline restart fix the same leg recovered its values but raised **no** rediscovery — that run is what found the gap. - *Frozen Agent* (`docker pause`): the watchdog fired at 15 s (2 × 5000 + 5000); the driver went Degraded with `LastSuccessfulRead` frozen (the comms-loss watchdog's evidence) and held its last values (the host stamps them `BadNoCommunication` on the comms-loss edge, #481); reconnects timed out until *unpause*, then Healthy and Good within ~2 s. - *Teardown:* unsubscribe + shutdown returned in 1 ms. - **Deferred live checks:** these legs ran through the driver, **not** through a deployed OtOpcUa server and an OPC UA client (the docker-dev rig was not used); the no-progress cap was observed via a `SampleCount` above the Agent's `bufferSize`, not a genuinely lapped ring buffer; no real machine tool or non-cppagent Agent was exercised. **Closed by the #823 gate below**, except the real-machine / non-cppagent caveat. ### Comms loss: refused vs ambiguous (#846) The runtime's comms-loss watchdog (Degraded + `LastSuccessfulRead` frozen 90 s ⇒ re-initialize, host fans `BadNoCommunication`) is the contract for every **ambiguous** failure, and the #823 gate below measured what it costs: values Good-but-stale for ~94–118 s. A **refused** connection is not ambiguous — nothing is listening at `AgentUri` — so the `/sample` pump now handles it itself: - **Definite = `SocketError.ConnectionRefused`** anywhere in the failure's exception chain (what `HttpClient` throws for a port nobody listens on), on a `/sample` open or a re-baseline `/current`. A request or stream-idle timeout, a reset or closed stream, DNS failure, a paused Agent, an `MTConnectError` refusal — all stay with the watchdog, unchanged. - **Confirmed:** the refusals must continue, unbroken, for `CommsLossConfirmMs` (default 2 s); any answer from the Agent or any ambiguous failure ends the run. While confirming, the reconnect delay is capped at the time remaining. - **Then every subscribed value is published `BadNoCommunication`** (value null) **once per outage**, with one Warning. Health was already Degraded with `LastSuccessfulRead` frozen by the stream failure, so the watchdog still re-initializes at 90 s exactly as before — this only stops stale values reading Good in between. Reads already fall back to `/current` on a degraded stream and fail on their own. Native CONDITIONS are untouched (the host annotates them on the connectivity edge, as before). - **Recovery republishes everything:** the first delivered chunk (a heartbeat counts) or re-baseline after a marked outage republishes every subscribed value from the index — a resumed `/sample` carries only what changed, so an unchanged item would otherwise stay Bad (the (value, status) recovery rule). - Consequence for an Agent **restart** that refuses for longer than the window (the #823 restart refused for ~1 s, inside it): values go Bad for the restart and come back on its first chunk / re-baseline. That is the honest answer — the Agent was down. Tests: `MTConnectCommsLossTests` (incl. an ambiguous-failure falsification control and the confirmation window). ### End-to-end gate through a deployed server (#823, 2026-11-11, docker-dev) An MTConnect driver (`SampleCount` 1000, `HeartbeatMs` 10000, `RequestTimeoutMs` 5000) against the shared fixture, two raw tags (`fixture_x_pos` Double, `fixture_execution` String) referenced into a UNS equipment, deployed to the central pair, and two Client.CLI subscriptions on central-1 (`ns=3` equipment folder, `ns=2` raw device). Raw and UNS NodeIds moved together in every leg. Fixture containers were restored healthy afterwards. | Leg | What happened on the wire | |---|---| | Adapter stop | Both NodeIds `BadNoCommunication` within 1 s of the adapter dying (Agent marks items UNAVAILABLE); driver stayed Healthy. Adapter start ⇒ Good 1–2 s later. | | Agent restart (`docker restart`, ~12 s) | Stream lost and reconnected ~1 s; the stale cursor was refused `OUT_OF_RANGE`, the re-baseline saw the new `instanceId`, **rediscovery raised** — `/hosts` shows the **RE-BROWSE** chip. Values stayed Good (outage shorter than the comms-loss window). | | Agent stopped ~2 min | Values held Good-but-stale; **Bad at 118 s** (comms-loss watchdog, connection refused). Agent start ⇒ re-init on the 10 s retry, Good ~8 s after the Agent was back. **Since #846** a refused connection marks the values `BadNoCommunication` after `CommsLossConfirmMs` (2 s) instead — unit-tested, not yet re-gated live. | | Agent paused (`docker pause`) | Stream idle watchdog at 24 s (2 × 10000 + 5000); **Bad at 94 s** (watchdog); unpause ⇒ Good within ~3 s. | | Genuine buffer overrun | A stalling TCP proxy froze the driver's transport while 32 000 observations were injected (`AllowPut`) — the Agent's `firstSequence` passed the driver's cursor (75 856 → 77 083). On release the resume was refused `OUT_OF_RANGE`, the driver re-baselined from `/current` within 1 s and resumed; values Good, no restart misdiagnosed. A raised-count stream (count 16, interval 5 s) was **not** lapped by a 266 obs/s burst: chunks are sent back-to-back while data is pending, so `interval` does not cap throughput. | | `SampleCount` vs `bufferSize` (#825) | Test Connect rendered the amber "OK with warning" chip and a deploy logged the Warning (`/hosts` shows `agentBufferSize`). The same gate measured the Agent's real edge — `count = 16384` streams, `16385` is `OUT_OF_RANGE` — so the first build's "at or above" threshold was corrected to "above" (pinned live by `Test_Connect_warns_when_SampleCount_exceeds_the_live_agents_buffer_size`). | ## 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. **`DATA_SET`/`TABLE` observations are JSON Strings** (#650, see [DATA_SET / TABLE](#data_set--table-650)); `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 **parked / won't fix** (EVENTs stay Strings, #650); `DATA_SET`/`TABLE` are served as JSON Strings (#650). - **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) — 714/714 at #804 (2026-11-10). `MTConnectJavaParityTests` holds the #804 gap tests and the ported Java regression cases (see [Java reference parity](#java-reference-parity-804)). 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 when the fixture is unreachable, 20/20 against the real fixture Agent at #804. 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)