Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
42 KiB
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/runverification 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— amultipart/x-mixed-replacelong-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.
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
(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 → one /current per call |
Not the production data path — see below |
IWritable |
WriteAsync → POST {agent}/{device}?{dataItemId}={value} on the Agent's AllowPut surface |
Off by default — needs Write.Enabled and the Agent's AllowPut; CONDITION items are refused |
ISubscribable |
SubscribeAsync/OnDataChange — the shared /sample long-poll pump |
The production data path |
IHostConnectivityProbe |
periodic /probe under Probe.* |
No consumer wires it today — see Known limitations |
IRediscoverable |
watches the Agent's Header.instanceId |
No consumer wires it today — see Known limitations |
IAlarmSource |
CONDITION observations off the same /sample pump → Part 9 transitions keyed by the alarm tag's RawPath |
Opt-in per raw tag (alarm object) — see CONDITION alarms |
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 | Not supported (stay String); not scheduled | See Deferred |
DATA_SET / TABLE representations |
Not supported - read as BadNotSupported |
Quality mapping |
| 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.
Minimum deployment
"Drivers": {
"mtconnect-1": {
"Type": "MTConnect",
"Config": {
"AgentUri": "http://10.100.0.35:5000",
"DeviceName": null,
"RequestTimeoutMs": 5000,
"SampleIntervalMs": 1000,
"SampleCount": 1000,
"HeartbeatMs": 10000,
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000 },
"Reconnect": { "MinBackoffMs": 0, "MaxBackoffMs": 30000, "BackoffMultiplier": 2.0 },
"Write": { "Enabled": false },
"Tags": []
}
}
}
RawTags[] is not authored by hand — DriverDeviceConfigMerger injects it
at deploy time from the tags authored on the /raw tree.
Config keys
Read off MTConnectDriverOptions / MTConnectDriverConfigDto
(src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs,
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs):
| Key | Default | Notes |
|---|---|---|
AgentUri |
— (required) | Agent base URI; the driver appends /probe, /current, /sample |
DeviceName |
null |
Double duty. (1) Scopes requests to {AgentUri}/{DeviceName}/...; null = whole Agent. (2) The write path's fallback device — the device a write is addressed to when the probe model does not say which device declared the DataItem (see Write-back) |
RequestTimeoutMs |
5000 |
Per-call deadline for /probe and /current |
SampleIntervalMs |
1000 |
The Agent's /sample?interval= query param |
SampleCount |
1000 |
The Agent's /sample?count= query param |
HeartbeatMs |
10000 |
The Agent's /sample?heartbeat= query param — also the watchdog's liveness window |
Probe.Enabled / Probe.IntervalMs / Probe.TimeoutMs |
true / 5000 / 2000 |
Background connectivity-probe knobs (mirrors ModbusProbeOptions) |
Reconnect.MinBackoffMs / MaxBackoffMs / BackoffMultiplier |
0 / 30000 / 2.0 |
Geometric backoff after a failed request or a dropped /sample stream |
Write.Enabled |
false |
The driver-side half of the write-back opt-in. An absent block means disabled — see Write-back |
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:
- The
RawTags[]blob'sdriverDataTypeordataTypefield (both spellings accepted — the typed editor writesdataType, the driver's owntags[]shape usesdriverDataType).driverDataTypewins when it is non-blank, and a present-but-invalid one rejects the tag rather than falling through todataType. The AdminUI's typed editor mirrors that precedence on load and normalises ontodataType, 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. - A matching
Tags[]entry naming the same DataItem id. - The Agent's own
/probedeclaration, viaMTConnectDataTypeInference.Infer(category/type/units/representation →DriverDataType). 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 wholeMTConnectTagConfigModel, whoseToJson()always writesdataType— 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 inferredDriverDataType, the very value step 3 would compute, forwarded byMapLeafintoBuildTagConfigexactly 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 UADataType, whichMapLeaf→MapDataTypetakes from the same inference.Before #560 the branch stamped the model's
Stringdefault instead, so a browsedSAMPLElanded as aDoublenode whose driver coerced the observation to text — recorded asDriver.MTConnect-003incode-reviews/Driver.MTConnect/findings.md. A leaf the browser reports no type for still commitsString, and a leaf legitimately inferred asString(anEVENTitem) commitsStringtoo — 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.
Other status codes an observation can surface:
| Code | Meaning |
|---|---|
BadTypeMismatch (0x80740000) |
The Agent's text isn't a value of the tag's coerced type at all |
BadOutOfRange (0x803C0000) |
The Agent reported a number the coerced type can't represent |
BadDecodingError (0x80070000) |
A TIME_SERIES vector whose element count disagrees with the observation's own sampleCount (or the tag's fixed ArrayDim) — see TIME_SERIES |
BadNotSupported (0x803D0000) |
A shape this build doesn't materialize — a structured DATA_SET/TABLE observation (decided: stays unsupported, #627), or an array tag declared as anything other than Float64 |
BadWaitingForInitialData |
Tag is authored but the Agent hasn't reported it yet |
BadNodeIdUnknown |
DataItem id is neither authored nor ever observed |
TIME_SERIES arrays (#627)
A SAMPLE DataItem with representation="TIME_SERIES" is served as a
variable-length double[] (ValueRank = 1, no fixed ArrayDimensions —
a live Agent never declares sampleCount on the DataItem, see Known
limitations 4; a probe that does declare one yields a fixed ArrayDim).
Parsing: the observation text is split on any whitespace, each token parsed
invariant-culture. The result is all-or-nothing — one non-numeric /
non-finite token is BadTypeMismatch, a literal UNAVAILABLE token or an
empty body is BadNoCommunication, and a count that disagrees with the
observation's own sampleCount attribute (or a fixed ArrayDim) is
BadDecodingError. A partial vector is never served as Good. Only a
Float64 array tag is served; any other declared element type stays
BadNotSupported. An authored tag needs "isArray": true and
"dataType": "Float64" in its TagConfig (the AdminUI editor shows an advisory
for any other array type; a browse-commit does not stamp isArray, so set it
in the Tag modal's array section).
Sample rate is not exposed on the served node. The observation's
sampleRate and sampleCount attributes are parsed (MTConnectObservation)
but the OPC UA node has no description/property slot through DriverAttributeInfo,
and a sibling property node would need a new address-space mechanism. Decision:
document it — the rate is a property of the Agent's DataItem (sampleRate in
/probe), read it there. DATA_SET / TABLE stay BadNotSupported (decision, #627): they are
key/value maps with no scalar or array OPC UA shape that preserves the keys.
Subscribe — the /sample pump
One shared /sample long-poll stream per driver instance (the Agent streams
the whole device model regardless of which subset is subscribed, so
per-tag streams would be wasted round-trips), run under a heartbeat
watchdog — HttpClient.Timeout cannot bound a long-lived stream, so a
missing chunk and missing keep-alive heartbeat within
HeartbeatMs × N is what detects a frozen peer.
Ring-buffer overflow (the Agent's circular observation buffer wrapped past
what the driver's cursor expects) is detected two ways, both triggering a
/current re-baseline before the stream resumes:
IMTConnectAgentClient.IsSequenceGap— the next chunk'sfirstSequenceis newer than the driver's expected cursor.- An
MTConnectErrordocument reportingOUT_OF_RANGE— real Agents return this both under HTTP 200 (a normal MTConnect error document) and as a bare HTTP 400, and the client handles both.
An Agent instanceId change means the Agent restarted, and is checked
before the sequence-gap check (a restart also usually trips the gap, so
the two need disambiguating, not just OR-ing together). On a changed
instanceId the driver clears its cached probe model and raises
OnRediscoveryNeeded, which surfaces a re-browse prompt on the AdminUI
/hosts page — see Known limitations for what that does
and does not do.
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
- The Agent —
AllowPut = trueinagent.cfg. Optionally scope it withAllowPutFrom = <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. - The driver —
"Write": { "Enabled": true }in the driver config (defaultfalse; an absent block is disabled). Authored in the AdminUI on the/rawdriver config modal's Write-back section (MTConnectDriverForm), which writes the block explicitly in both states — so turning the switch back off clears a previously authoredtruerather than leaving it standing.
Either gate alone leaves the shipped read-only behaviour unchanged: with Write.Enabled
off every slot is refused BadNotWritable without an Agent being touched, and with
AllowPut off the Agent refuses and the driver reports BadCommunicationError.
The OPC UA write bit is authored, not derived — read this before filing a bug
Enabling write-back does not make any existing node writable over OPC UA. The
CurrentWrite access bit is composed from the authored Tag.AccessLevel alone
(AddressSpaceComposer → AddressSpaceApplier, which additionally requires a
non-array tag → OtOpcUaNodeManager.ComposeAccessLevel); driver capability is never
consulted at compose time. An operator must set the raw tag to ReadWrite — a
browse-committed tag is created Read (RawBrowseCommitMapper.MapLeaf) whatever the
browse said.
Browse does stamp writable leaves SecurityClassification.Operate when
Write.Enabled is on (and ViewOnly otherwise, CONDITION always ViewOnly), on the
same "a node cannot advertise writability the driver would refuse" principle MQTT
pinned. But that stamp is honest labelling, not the access bit: nothing persists
SecurityClass — Tag has no such column — so it reaches the browse session's
attribute side-panel and stops there.
What a write does, slot by slot
WriteAsync never throws and one bad slot never fails the batch (matching ReadAsync);
even cancellation returns a full-length result set. The refusal order is fixed, and each
step answers a different operator question:
| Condition | Status | Meaning |
|---|---|---|
Write.Enabled off, or the DataItem is CONDITION-category |
BadNotWritable (0x803B0000) |
Nothing was attempted; no Agent was involved |
No live Agent client (pre-InitializeAsync, faulted initialize, post-shutdown) |
BadNotConnected (0x808A0000) |
This driver is not running |
| The reference resolves to no DataItem, or to one whose owning device cannot be named | BadNodeIdUnknown (0x80340000) |
There is no Agent URL the write could be addressed to |
The value has no invariant string form — null, or non-scalar |
BadTypeMismatch (0x80740000) |
Arrays are refused rather than rendered (Convert.ToString would emit the literal System.Double[], which the Agent stores happily) |
| The Agent refused, or could not be reached | BadCommunicationError (0x80050000) |
Includes an MTConnectError document returned under HTTP 200 |
CONDITION DataItems are excluded deliberately — they carry alarm semantics and a
different level|text wire format, and are out of scope for v1.
The Agent's refusal text is logged at Warning and never stored. It stays out of
DriverHealth.LastError, which is type-only by repo-wide discipline, and a refused write
is not evidence the read path degraded — so a refusal leaves driver health untouched.
Device resolution needs no extra config. TagBinding.WriteTargetsByDataItemId,
built at bind time from a walk of the cached probe model, remembers which device declared
each DataItem, so a multi-device Agent is addressed correctly; the fallback is the
configured DeviceName, and an unresolvable device is BadNodeIdUnknown rather than a
guess.
Values are rendered with CultureInfo.InvariantCulture. A Boolean therefore writes
the invariant string "True"/"False", not "1"/"0" — if the consuming adapter or
Agent vocabulary expects something else, author the tag as a String and write the exact
token.
Browse — free via the universal browser
MTConnect ships no bespoke browser project. Setting
ITagDiscovery.SupportsOnlineDiscovery => true is the entire integration:
the Wave-0 DiscoveryDriverBrowser sees a driver whose TryCreate succeeds
and whose instance reports online discovery, renders the AdminUI Browse
button, constructs the driver, runs InitializeAsync (the /probe connect)
DiscoverAsyncinto aCapturingAddressSpaceBuilder, then tears the throwaway instance down. Each captured leaf's NodeId is the DataItemid, committed directly asTagConfig.FullNameon pick. Seedocs/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
alarmobject to the raw tag'sTagConfig— exactly how a Galaxy alarm tag is made alarm-bearing (the/rawtag 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 thealarmobject — 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
BadNoCommunicationon the connectivity edge (#477) and keeps its Active state; the reconnect edge re-annotates Good, after which the driver re-asserts any still-Unavailablecondition's Bad quality on its re-subscribe. - A re-subscribe with a changed alarm set clears the whole told record: only an alarm-bearing raw-tag delta changes the set, the server applies that as a full rebuild, and a rebuild recreates every condition node inactive — so every active condition is re-asserted onto its new node. The same set (a reconnect) keeps the record. (A rebuild that leaves this driver's alarm set unchanged triggers no re-subscribe; that is a host-wide gap shared with every native source.)
- An alarm-only driver (no value tags) still runs the
/samplepump.
EVENT controlled vocabularies as OPC UA enumerations — not built (parked, low priority). EVENT values (Execution,
ControllerMode, …) stay Strings carrying the Agent's own word; an enumeration would need a per-type
vocabulary table that vendors extend freely.
Known limitations
These are real, not placeholders — read them before relying on the driver for anything beyond values-and-conditions.
-
A restarted Agent prompts an operator; it does not re-shape the address space.
DriverInstanceActornow consumesOnRediscoveryNeeded(2026-07-27), so a changedinstanceIdraises a "re-browse" chip against this driver on/hostscarrying the reason. It is advisory: v3 authors raw tags through the/rawbrowse-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.IHostConnectivityProbeis consumed (Gitea #521, closed) —DriverInstanceActorsubscribesOnHostStatusChanged, pullsGetHostStatuses()fresh, and the result ridesDriverHealthChangedto the AdminUI/hostspage (no SQL; theDriverHostStatustable was dropped). Seedocs/Telemetry.md. -
One MTConnect driver instance = one Agent client = at most one
/rawDevice (#628, decision (b)). The driver never readsRawTagEntry.DeviceName; every raw tag binds through the single Agent client scoped byMTConnectDriverOptions.DeviceName. The v3 raw tree always has a Device level (a new driver auto-createsDevice1), and that single-Device layout is the normal, valid shape — itsDeviceConfigis merged up into the driver config byDriverDeviceConfigMerger(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-timeDraftValidatorerror (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 ownDeviceNamescope. The driver's init-time Warning for differingRawTagEntry.DeviceNamevalues 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 readsDevice.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.DeviceNameeither: it reads the owning device out of the cached probe model and falls back to the top-levelDeviceName(see Write-back). SoMTConnectDriverOptions.DeviceNamedoes double duty — request scope for reads/browse, fallback write target for writes. -
Authoring only warns when a declared coercion type cannot hold the inferred one (#613). The AdminUI tag editor (
MTConnectTagConfigModel.Warnings()) compares the declared type withMTConnectDataTypeInferenceover the/probemetadata 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.representationis not on the model; TIME_SERIES arrays get their ownisArrayadvisory (declare Float64). Beyond that, nothing validates a raw tag's declared OPC UADataTypeagainst 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 authoredDataTypemismatched against the actual Agent observation still surfaces asBadTypeMismatch/BadOutOfRangeat runtime rather than at authoring time. -
sampleCountis not a legalDataItemattribute. It belongs on aTIME_SERIESobservation, not the device-model declaration. A real Agent that meetssampleCounton aDataItemdeclaration logsThe following keys were present and not expected: sampleCountand drops the entire data item from the served model. A live TIME_SERIES tag therefore always resolves toArrayDim = null(variable-length array) in practice — the fixture's cannedprobe.xml/Devices.xml, which does declare asampleCount, is unrealistic on this point and exists only to pin the parsing rule itself. -
A CONDITION alarm shows one condition per DataItem — the most severe active
nativeCode, not one Part 9 condition per code (see CONDITION alarms). -
Structured
DATA_SET/TABLEobservations surface asBadNotSupported, not an approximation — see the Quality mapping table.TIME_SERIESis served asdouble[](#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 —b29623cfshippedIWritableover the Agent'sAllowPutobservation-injection surface instead, a different mechanism entirely. See Write-back and2026-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 not built (EVENTs stay Strings), and
structured
DATA_SET/TABLEobservations stayBadNotSupported; both are tracked as a low-priority follow-up, not part of P1.5. - 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, soSupportsOnlineDiscoverywould have to reportfalseand tags would be authored by hand, like Modbus). Niche; not built.
Testing
- Unit tests —
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/(canned-XML fixtures, no network) — 491/491 at time of writing. Covers discovery tree shape,MTConnectDataTypeInference, observation indexing,UNAVAILABLE→BadNoCommunication, CONDITION state-word mapping, multipart chunk framing, and ring-buffer re-baseline paging (both the sequence-gap and theOUT_OF_RANGElegs). The write path addsMTConnectAgentClientWriteTests(9f17d7ef— verb, URL, agent-error-in-200 detection) andMTConnectWriteTests(b29623cf— every refusal leg, the batch shape, the browse-stamping flip). - Integration tests —
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/— env-gated onMTCONNECT_AGENT_ENDPOINT(defaulthttp://10.100.0.35:5000), skips cleanly (12 Skipped) when the fixture is unreachable, 12/12 against a real Agent. Read the fixture's ownDocker/README.mdfor the full recipe — summary only, here:- Image is
mtconnect/agent:2.7.0.12— notmtconnect/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, onpython:3.13-alpine). An Agent with no adapter reports every observationUNAVAILABLEforever, which proves nothing. - Deployed at
/opt/otopcua-mtconnect/on the shared docker host (10.100.0.35,project=lmxopcualabel). Endpointhttp://10.100.0.35:5000. agent.cfgmust be pure ASCII — one non-ASCII byte anywhere, including a comment, makes the config parser reject the whole file with a bareFailed / Stopped at line: N.- Bring-up:
lmxopcua-fix sync mtconnectthenlmxopcua-fix up mtconnect(PowerShell helper, Windows-only) — or, directly on the docker host,rsyncthe repo'sDocker/dir to/opt/otopcua-mtconnect/and rundocker compose up -d --wait.
- Image is
- CONDITION alarms (#626) —
MTConnectConditionAlarmMappingTests(tracker semantics + state mapping/sequencing),MTConnectAlarmSourceTests(driver wiring: Normal→Fault→Normal, escalation, severalnativeCodes, Unavailable, replay after re-subscribe/re-initialize/Agent restart with no duplicate, the missed-while-detached delivery, comms loss),MTConnectConditionAlarmWireTestsinOpcUaServer.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 writesnormalfor its two CONDITION items (fixture_x_travel/ SHDR keyXtravel,fixture_logic/Plogic, seeDocker/Devices.xml). To see a transition, makesend_conditions()inDocker/adapter.pyalternateXtravel|fault|1001|2|HIGH|X overtravelwithXtravel|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 CLIalarms): expect a Fault event (severity 800, messageX 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— full design: capability wiring, browse reconciliation, typed-editor spec, resilience/timeout rules, phasingdocs/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— Wave-2 program tracking- Docker fixture README — the authoritative fixture recipe (seeded device model, endpoint, Mac AirPlay port-5000 gotcha)