Files
lmxopcua/docs/drivers/MTConnect.md
T
Joseph Doherty bdbc47f5fc fix(mtconnect): serve an empty string-typed value as Good "" (#824)
Owner decision 2026-11-11 (Java parity): a genuinely empty observation on a
String/Reference or unauthored item is Good "" instead of BadNoCommunication.
Non-string items (numeric, bool, DateTime, arrays) keep BadNoCommunication -
the Agent supplied no value; BadTypeMismatch would wrongly blame the authored
type. The UNAVAILABLE sentinel is unchanged, and a structured observation with
no text stays BadNotSupported (structured check now precedes the empty check).
Parity matrix, quality-mapping section and drivers rule updated.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
2026-11-11 11:56:30 -05:00

82 KiB
Raw Blame History

MTConnect Driver

Getting-started guide for the MTConnect Agent driver (P1 Agent MVP). This is the short path — for the full design rationale read docs/plans/2026-07-15-mtconnect-driver-design.md and the build-vs-plan record in docs/plans/2026-07-24-mtconnect-driver.md; for the fixture recipe read tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md (not duplicated here).

Live gate: see the LIVE-GATE RESULT note in the plan's Task 21 (docs/plans/2026-07-24-mtconnect-driver.md) for the docker-dev /run verification outcome (browse picker / typed editor / deploy / read / subscribe against the real Agent fixture).

What it talks to

A MTConnect Agent — the vendor-neutral 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 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) picked the TrakHound MTConnect.NET-Common / -HTTP NuGet packages (MIT, netstandard2.0) as the primary path, with a hand-rolled fallback. The hand-rolled fallback is what shipped. Task 6/7 of the implementation plan found by reflection + live invocation that the pinned TrakHound packages ship no XML formatter (Document Formatter Not found for "xml" — the formatter lives in a separate MTConnect.NET-XML package the design didn't pin) and that MTConnectHttpClientStream exposes no injectable HttpClient handler, so it cannot be unit-tested behind the driver's IMTConnectAgentClient seam. Both package references were dropped. There is no TrakHound dependency anywhere in the shipped driver — probe/current/sample parsing is hand-rolled System.Xml.Linq plus a multipart boundary reader, entirely behind IMTConnectAgentClient. See the CORRECTION block under Task 0 of the implementation plan for the full record.

Namespace-agnostic parsing. Every parser matches XML elements on LocalName only, ignoring the document's XML namespace entirely, so MTConnect 1.3 through 2.x documents all parse without a schema-version branch. This is deliberate, not sloppy: a real Agent injects vendor-extension Components in a foreign namespace whose child DataItems elements inherit the default namespace, and namespace-strict matching would silently drop the whole vendor component (and every DataItem beneath it) rather than fail loudly. Attributes are read unqualified for the same reason — so an xsi:type attribute is never mistaken for a DataItem's own type.

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 <Components> or <Devices> 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 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 before authoring a writable MTConnect tag. Interfaces itself — the protocol's own request/response handshake — is a different mechanism and remains deferred; see Deferred.

Capability Path Notes
ITagDiscovery DiscoverAsync — streams /probe's Device→Component→DataItem tree into the address-space builder SupportsOnlineDiscovery = true, RediscoverPolicy = Once
IReadable ReadAsync → 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)
IRediscoverable watches the Agent's Header.instanceId No consumer wires it today — see 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

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
TIME_SERIES arrays (#627) Supported as variable-length double[] (Float64 tags only) TIME_SERIES arrays
One Agent client per driver, one /raw Device (#628) Enforced (MTConnectMultipleDevices deploy error); multi-device Agents use several driver instances Known limitations
Write-back (IWritable) Supported, off by default (Write.Enabled + Agent AllowPut) Write-back
EVENT controlled vocabularies as OPC UA enumerations Parked / won't fix (stay String) - see rationale EVENT enumerations
DATA_SET / TABLE representations (#650) Supported as a String carrying compact JSON DATA_SET / TABLE
MTConnect Interfaces, SHDR ingest Not built Deferred

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

"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)
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 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 — and, since #852, every native CONDITION (quality only) — goes BadNoCommunication (see Comms loss). 0 = on the first refusal; negative = fast path off (watchdog only). AdminUI: "Comms-loss confirm (ms)" in the driver modal's Reconnect section (#852; any integer is saved — a negative is never dropped, which would re-enable the 2000 default; ≥ 90000 shows an advisory, as the watchdog always fires first)
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
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. 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 (<Normal/> → the string "Normal", <Fault/> → "Fault"); a <Unavailable/> condition element normalises onto the same UNAVAILABLE sentinel as every other category so one comparison covers all three.

An empty value is not UNAVAILABLE (#824, owner decision 2026-11-11 — Java parity). A genuinely empty observation (<Program …></Program>, <Program …/>, or whitespace-only text, which the parser trims to "") on a string-typed item (String/Reference, or an unauthored item, which coerces as String) is served Good "" — an Agent can legitimately report a blank Program, Block or Message. On any non-string item (numeric, Boolean, DateTime, a TIME_SERIES array) "" cannot be a value, so it stays BadNoCommunication with a null value — deliberately not BadTypeMismatch, which would tell the operator the Agent sent text of the wrong type and point them at the tag's authored data type; the Agent sent no value at all. Only the literal UNAVAILABLE sentinel is the "no value" statement on a string item. Before #824 every empty value was BadNoCommunication. A DATA_SET/TABLE-shaped observation is unaffected (its entry-less form is still Good {}; non-<Entry> children are still BadNotSupported).

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
BadNotSupported (0x803D0000) A shape this build doesn't materialize — a DATA_SET/TABLE observation whose children are not <Entry> 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 (an array is non-string, so #824's Good "" never applies), 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 <Entry key="…"> children (a TABLE entry holds <Cell key="…"> 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 <Entry> 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 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 for why Interfaces was rejected as the write-back shape. Design record: 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 = <host list> 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.

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):

    { "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 nativeCodes). 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 HttpClients 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 <Components>/<Devices> skipped (NOT_A_COMPONENT) gap → closed (one stray <Description> 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 covered (was a divergence until #824, owner decision 2026-11-11) BuildSnapshot serves Good "" on a string-typed or unauthored item; a non-string item stays BadNoCommunication (Java has no typed node to refuse "" on); UNAVAILABLE unchanged · Empty_observation_value_on_a_string_item_is_a_Good_empty_string, A_parsed_empty_event_on_a_string_item_is_a_Good_empty_string, controls Empty_observation_value_on_a_non_string_item_stays_BadNoCommunication, Unavailable_on_a_string_item_is_still_BadNoCommunication
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
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; 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 too (#852): at the same moment, every subscribed condition the server holds a statement for gets a quality-only BadNoCommunication annotation — exactly what the host's connectivity edge does at the watchdog: Active state, severity, message and acknowledgement untouched, no /alerts row, once per outage (a condition already Bad because the Agent reported it UNAVAILABLE is skipped). Recorded in the driver's told record, so recovery lifts it.
  • 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). The same delivery re-diffs every subscribed condition: an unchanged one gets a quality-only Good (never a re-raise — that would un-acknowledge it); one the Agent still reports UNAVAILABLE stays Bad; one that changed during the outage gets its ordinary Raise/Retrigger/Clear.
  • 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), MTConnectConditionCommsLossTests (#852 — conditions, incl. the UNAVAILABLE and ambiguous-failure controls).

Live gate: refused-connection fast path (#846/#852) — recipe

Ran 2026-11-11 on 03e51457 — PASS (see the gate table below). Uses a dedicated, throwaway Agent on the local docker-dev rig — never the shared fixture on 10.100.0.35. docker-dev/docker-compose.mtconnect-gate.yml adds two services to the rig's network: mtconnect-gate (the fixture's SHDR adapter, which also owns the network namespace and publishes host :5155) and mtconnect-gate-agent (mtconnect/agent:2.7.0.12 with the fixture's agent.cfg/Devices.xml, running inside mtconnect-gate's namespace).

⚠️ Why not just docker stop an ordinary Agent container: a stopped compose container also drops out of Docker's embedded DNS, so the driver sees a DNS failure — ambiguous by design, watchdog only — and the gate fails for the wrong reason. With the shared namespace the name mtconnect-gate keeps resolving while the Agent is down, and the connect is genuinely refused (smoke-checked: Agent stopped ⇒ Connection refused from inside the network; both stopped ⇒ bad address).

dcg() { docker compose -f docker-dev/docker-compose.yml -f docker-dev/docker-compose.mtconnect-gate.yml "$@"; }   # bash + zsh
dcg up -d --no-deps mtconnect-gate mtconnect-gate-agent
curl -s http://127.0.0.1:5155/probe | head -3                    # Agent answering (Mac side)
  1. Driver. In AdminUI /raw (http://localhost:9200, MAIN cluster) add an MTConnect driver, Agent base URI http://mtconnect-gate:5000, Comms-loss confirm (ms) = 2000 (the default — the new field in the Reconnect section), one Device. Browse-commit fixture_x_pos (Double) and fixture_execution (String), and the CONDITION fixture_x_travel with an alarm object (optional, for the #852 leg). Optionally reference the two value tags into a UNS equipment. Deploy.
  2. Watch. Client CLI against central-1 (opc.tcp://localhost:4840): dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=<RawPath of fixture_x_pos>" -i 250 (and a second one on the UNS NodeId, ns=3;s=<Area>/<Line>/<Equipment>/<name>, if referenced). Values Good and moving. For the condition leg, alarms -u opc.tcp://localhost:4840 --refresh in another shell (or read the condition's Quality child found with browse).
  3. Refuse. Note the wall clock, then dcg stop mtconnect-gate-agent. Expect: both NodeIds BadNoCommunication (value null) within ~2–3 s (2000 ms confirmation + the first reconnect attempt), not ~90–118 s; host log has refused every connection for … ms (definite loss); marking N subscribed value(s) BadNoCommunication, and with a condition authored annotated 1 native condition(s) BadNoCommunication; the condition stays in its Active/acked state. /hosts shows the driver Degraded. If you wait past 90 s the #527 watchdog also re-initializes the driver as before — so run step 4 within ~60 s to keep the legs separate.
  4. Recover. dcg start mtconnect-gate-agent. Expect: values Good again within a few seconds of the Agent answering (first chunk/heartbeat or re-baseline republishes every value, unchanged ones included); the condition's quality Good with no re-raise / no new /alerts row. The restarted Agent has a new instanceId, so a RE-BROWSE chip on /hosts is expected and unrelated.
  5. Control (optional): dcg stop mtconnect-gate-agent mtconnect-gate — now the name no longer resolves (ambiguous): values must stay Good-but-stale until the ~90 s watchdog. Start mtconnect-gate FIRST, then the agent.
  6. Clean up: remove the driver from /raw and deploy, then dcg rm -sf mtconnect-gate-agent mtconnect-gate.

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 — live-gated 2026-11-11 (#852): Agent killed 13:27:15.19 ⇒ both values BadNoCommunication and the x_travel condition's quality-only event at 13:27:17 (2.0 s; condition kept Inactive/Acknowledged). On restart the condition got one more event (Inactive/Acknowledged, no re-raise) and values were Good again ~23 s later — the delay is the /sample reconnect backoff (attempt 9 ≈ 26 s), not the fast path. Note mtconnect/agent ignores SIGTERM, so compose stop kills it at the 10 s grace — time the leg from the container's FinishedAt.
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). 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).

  6. DATA_SET/TABLE observations are JSON Strings (#650, see DATA_SET / TABLE); TIME_SERIES is served as double[] (#627, see TIME_SERIES arrays); 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 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 and 2026-08-05-mtconnect-writeback-design.md §2.1.
  • P1.5 fast-follow (design §9): CONDITION → native Part 9 alarms shipped (#626, see CONDITION alarms); 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). Covers discovery tree shape, MTConnectDataTypeInference, observation indexing, UNAVAILABLE → BadNoCommunication (an empty string-typed value is Good "", #824), 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 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 nativeCodes, 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