19ffb3c902
- A /sample or re-baseline failure whose chain holds SocketError.ConnectionRefused, unbroken for CommsLossConfirmMs (new, default 2000; 0 = first refusal; negative = off), publishes every subscribed value BadNoCommunication once per outage; the first delivery afterwards republishes all of them from the index (unchanged values included). Timeouts/resets/DNS/pause keep the #527 watchdog contract. - SampleCountCeiling clamps the adaptive raise to the /probe bufferSize (never below SampleCount). - Docs: MTConnect.md comms-loss section + config key; drivers.md rule. Refs #846
931 lines
75 KiB
Markdown
931 lines
75 KiB
Markdown
# MTConnect Driver
|
||
|
||
Getting-started guide for the MTConnect Agent driver (P1 Agent MVP). This is
|
||
the short path — for the full design rationale read
|
||
[`docs/plans/2026-07-15-mtconnect-driver-design.md`](../plans/2026-07-15-mtconnect-driver-design.md)
|
||
and the build-vs-plan record in
|
||
[`docs/plans/2026-07-24-mtconnect-driver.md`](../plans/2026-07-24-mtconnect-driver.md); for the
|
||
fixture recipe read
|
||
[`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md`](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md)
|
||
(not duplicated here).
|
||
|
||
> **Live gate:** see the LIVE-GATE RESULT note in the plan's Task 21
|
||
> (`docs/plans/2026-07-24-mtconnect-driver.md`) for the docker-dev `/run` verification outcome
|
||
> (browse picker / typed editor / deploy / read / subscribe against the real Agent fixture).
|
||
|
||
## What it talks to
|
||
|
||
A **MTConnect Agent** — the vendor-neutral telemetry endpoint that
|
||
front-ends a machine tool (or fronts an adapter that itself talks to the
|
||
machine over SHDR). The driver speaks plain HTTP + XML against the Agent's
|
||
three standard REST paths:
|
||
|
||
- `/probe` — the static device model (Device → Component → DataItem tree)
|
||
- `/current` — a snapshot of every DataItem's latest observation
|
||
- `/sample` — a `multipart/x-mixed-replace` long-poll stream of observation
|
||
deltas, keyed by a monotonic sequence number
|
||
|
||
Those three are the whole data plane: **agent-first** — Discover + Read +
|
||
Subscribe. There is a fourth path, and it is not part of the data plane: the
|
||
Agent's optional **`AllowPut`** surface, off by default in `agent.cfg`, which
|
||
this driver's `IWritable` uses (`b29623cf`, 2026-08-05). It sets what the Agent
|
||
*reports* for a DataItem; nothing reaches the machine. Read
|
||
[Write-back](#write-back--observation-injection-off-by-default) before enabling
|
||
it.
|
||
|
||
## Built vs. planned — read this before trusting the design doc's §2
|
||
|
||
The design doc ([`2026-07-15-mtconnect-driver-design.md`](../plans/2026-07-15-mtconnect-driver-design.md))
|
||
picked the **TrakHound `MTConnect.NET-Common` / `-HTTP`** NuGet packages (MIT,
|
||
`netstandard2.0`) as the primary path, with a hand-rolled fallback. **The
|
||
hand-rolled fallback is what shipped.** Task 6/7 of the implementation plan
|
||
found by reflection + live invocation that the pinned TrakHound packages
|
||
ship **no XML formatter** (`Document Formatter Not found for "xml"` — the
|
||
formatter lives in a separate `MTConnect.NET-XML` package the design didn't
|
||
pin) and that `MTConnectHttpClientStream` exposes no injectable `HttpClient`
|
||
handler, so it cannot be unit-tested behind the driver's `IMTConnectAgentClient`
|
||
seam. Both package references were dropped. There is **no TrakHound
|
||
dependency anywhere in the shipped driver** — probe/current/sample parsing is
|
||
hand-rolled `System.Xml.Linq` plus a multipart boundary reader, entirely
|
||
behind `IMTConnectAgentClient`. See the CORRECTION block under Task 0 of the
|
||
implementation plan for the full record.
|
||
|
||
**Namespace-agnostic parsing.** Every parser matches XML elements on
|
||
`LocalName` only, ignoring the document's XML namespace entirely, so
|
||
MTConnect 1.3 through 2.x documents all parse without a schema-version
|
||
branch. This is deliberate, not sloppy: a real Agent injects vendor-extension
|
||
`Component`s in a foreign namespace whose child `DataItems` elements inherit
|
||
the *default* namespace, and namespace-strict matching would silently drop
|
||
the whole vendor component (and every DataItem beneath it) rather than fail
|
||
loudly. Attributes are read **unqualified** for the same reason — so an
|
||
`xsi:type` attribute is never mistaken for a DataItem's own `type`.
|
||
|
||
**Hardened against the peer (#804).** The Agent is unauthenticated, so every parse
|
||
refuses a `DOCTYPE` outright (`DtdProcessing.Prohibit` — the `/probe` and write-answer
|
||
parses used to go through `XDocument.Parse`, which *parses* DTDs and expands internal
|
||
entities), the streams walk refuses nesting deeper than 64 levels (it recurses per
|
||
level, and an unbounded depth is a `StackOverflowException` .NET cannot catch — the
|
||
whole server process, not the driver, would die), and a structural element
|
||
(`Description`/`Configuration`/`References`/`Compositions`/`DataItems`) sitting
|
||
directly under `<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](#browse--free-via-the-universal-browser)
|
||
below).
|
||
|
||
## Capability surface
|
||
|
||
`MTConnectDriver : IDriver, IReadable, IWritable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable, IDriverDiagnostics`
|
||
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`).
|
||
|
||
`IWritable` landed 2026-08-05 (`b29623cf`), against the Agent's `AllowPut`
|
||
surface rather than MTConnect *Interfaces*. It is **observation injection, not
|
||
machine command**, and is off at two independent gates by default — read
|
||
[Write-back](#write-back--observation-injection-off-by-default) before
|
||
authoring a writable MTConnect tag. *Interfaces* itself — the protocol's own
|
||
request/response handshake — is a different mechanism and remains deferred; see
|
||
[Deferred](#deferred-not-in-this-build).
|
||
|
||
| Capability | Path | Notes |
|
||
|------------|------|-------|
|
||
| `ITagDiscovery` | `DiscoverAsync` — streams `/probe`'s Device→Component→DataItem tree into the address-space builder | `SupportsOnlineDiscovery = true`, `RediscoverPolicy = Once` |
|
||
| `IReadable` | `ReadAsync` → the live `/sample` pump's index while it is delivering and healthy, else one `/current` per call | **Not the production data path** — see below |
|
||
| `IWritable` | `WriteAsync` → `POST {agent}/{device}?{dataItemId}={value}` on the Agent's `AllowPut` surface | **Off by default** — needs `Write.Enabled` **and** the Agent's `AllowPut`; CONDITION items are refused |
|
||
| `ISubscribable` | `SubscribeAsync`/`OnDataChange` — the shared `/sample` long-poll pump | **The production data path** |
|
||
| `IHostConnectivityProbe` | periodic headers-only `/probe` ping under `Probe.*` — the status is read, the device model is neither downloaded in full nor parsed (review 2026-10-21 T-7; it used to parse the whole model every tick). Trade-off: a 200 from something that is not an Agent (a proxy's HTML page) now reads as reachable; the data paths still fail on it | Consumed — feeds `/hosts` (see [Known limitations](#known-limitations)) |
|
||
| `IRediscoverable` | watches the Agent's `Header.instanceId` | No consumer wires it today — see [Known limitations](#known-limitations) |
|
||
| `IDriverDiagnostics` | `GetDiagnostics()` — `/sample` effective count, lag, saturated chunks, count raises | Shown on `/hosts` (review 2026-10-21 T-5) |
|
||
| `IAlarmSource` | CONDITION observations off the same `/sample` pump → Part 9 transitions keyed by the alarm tag's RawPath | Opt-in per raw tag (`alarm` object) — see [CONDITION alarms](#condition-alarms-626) |
|
||
|
||
### Feature status at a glance (#613)
|
||
|
||
| Feature | Status | Detail |
|
||
|---------|--------|--------|
|
||
| SAMPLE / EVENT / CONDITION values | **Supported** | EVENT values are Strings carrying the Agent's own word |
|
||
| CONDITION as native Part 9 alarms (#626) | **Supported, opt-in** per raw tag via the `alarm` object | [CONDITION alarms](#condition-alarms-626) |
|
||
| `TIME_SERIES` arrays (#627) | **Supported** as variable-length `double[]` (Float64 tags only) | [TIME_SERIES arrays](#time_series-arrays-627) |
|
||
| One Agent client per driver, one `/raw` Device (#628) | **Enforced** (`MTConnectMultipleDevices` deploy error); multi-device Agents use several driver instances | [Known limitations](#known-limitations) |
|
||
| Write-back (`IWritable`) | **Supported, off by default** (`Write.Enabled` + Agent `AllowPut`) | [Write-back](#write-back--observation-injection-off-by-default) |
|
||
| EVENT controlled vocabularies as OPC UA enumerations | **Parked / won't fix** (stay String) - see rationale | [EVENT enumerations](#event-enumerations-650-parked--wont-fix) |
|
||
| `DATA_SET` / `TABLE` representations (#650) | **Supported** as a String carrying compact JSON | [DATA_SET / TABLE](#data_set--table-650) |
|
||
| MTConnect Interfaces, SHDR ingest | Not built | [Deferred](#deferred-not-in-this-build) |
|
||
|
||
**`IReadable.ReadAsync` is never called by the running server.** The
|
||
production data plane is entirely `ISubscribable` — `DriverInstanceActor`
|
||
subscribes once and lives off `OnDataChange`. `ReadAsync` exists for the
|
||
Client CLI (`... read -n ...`) and for the unit/integration suites; it is
|
||
correct and tested, just not on the hot path.
|
||
|
||
**Read path (review 2026-10-21 T-4 / T-2).** While the current-generation `/sample` pump is running, has
|
||
delivered since it started (a chunk, heartbeat or re-baseline) and its stream is not degraded, a read is answered
|
||
from the pump's observation index — **read only**; the pump stays its single writer — with no Agent round trip.
|
||
Otherwise (no subscription, a pump still catching up after a resubscribe, a degraded stream) it falls back to one
|
||
`/current`, whose per-batch index is now built over a tag map frozen once per binding. **Stale window:** a
|
||
stream that stalls without failing (no chunk, no heartbeat) still serves Good reads from the index until the client's
|
||
idle watchdog fires (2 × `HeartbeatMs` + `RequestTimeoutMs`) — the same bounded window subscriptions already have. During
|
||
an Agent-restart re-baseline reads fall back to `/current`. A served read also clears a
|
||
read-path degradation an earlier failed `/current` left (the read path is answering). The `/current` fallback now
|
||
checks the answer's `instanceId`: a changed one (an Agent restart seen by a browse-only driver with no pump) drops the
|
||
cached `/probe` model and raises `OnRediscoveryNeeded` once per distinct id — it used to be detected only by the pump.
|
||
|
||
**An Agent refusal is a reply (health contract).** An `MTConnectError` document answering `/current` (e.g.
|
||
`NO_DEVICE` for a wrong `DeviceName` scope — a 2.x Agent serves it under **HTTP 404**, an older one under 200;
|
||
both are lifted since #804) still fails the batch (`BadCommunicationError` per ref), but it is the
|
||
Agent answering, not a comms fault: the read path is not degraded by it (that would let the comms-loss watchdog
|
||
reconnect-loop the driver over a configuration problem), `LastSuccessfulRead` advances, a prior read degradation
|
||
clears, and the Agent's text is logged once per distinct refusal. An answer that is not an MTConnect document at all
|
||
(a proxy's HTML page, truncated XML) stays a comms fault.
|
||
|
||
## Minimum deployment
|
||
|
||
```jsonc
|
||
"Drivers": {
|
||
"mtconnect-1": {
|
||
"Type": "MTConnect",
|
||
"Config": {
|
||
"AgentUri": "http://10.100.0.35:5000",
|
||
"DeviceName": null,
|
||
"RequestTimeoutMs": 5000,
|
||
"SampleIntervalMs": 1000,
|
||
"SampleCount": 1000,
|
||
"HeartbeatMs": 10000,
|
||
"CommsLossConfirmMs": 2000,
|
||
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000 },
|
||
"Reconnect": { "MinBackoffMs": 0, "MaxBackoffMs": 30000, "BackoffMultiplier": 2.0 },
|
||
"Write": { "Enabled": false },
|
||
"Tags": []
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`RawTags[]` is not authored by hand — `DriverDeviceConfigMerger` injects it
|
||
at deploy time from the tags authored on the `/raw` tree.
|
||
|
||
### Config keys
|
||
|
||
Read off `MTConnectDriverOptions` / `MTConnectDriverConfigDto`
|
||
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs`,
|
||
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`):
|
||
|
||
| Key | Default | Notes |
|
||
|---|---|---|
|
||
| `AgentUri` | — (required) | Agent base URI; the driver appends `/probe`, `/current`, `/sample`. Any `user:pass@` (and query) is **redacted** from `HostName` (the `/hosts` key), every log line and every exception / health message (review 2026-10-21 T-8); a URI without credentials is shown unchanged |
|
||
| `DeviceName` | `null` | **Double duty.** (1) Scopes requests to `{AgentUri}/{DeviceName}/...`; `null` = whole Agent. (2) The write path's fallback device — the device a write is addressed to when the probe model does not say which device declared the DataItem (see [Write-back](#write-back--observation-injection-off-by-default)) |
|
||
| `RequestTimeoutMs` | `5000` | Per-call deadline for `/probe` and `/current` |
|
||
| `SampleIntervalMs` | `1000` | The Agent's `/sample?interval=` query param |
|
||
| `SampleCount` | `1000` | The Agent's `/sample?count=` query param. **Caps subscription throughput** at `SampleCount / SampleIntervalMs` — see [Subscribe](#subscribe--the-sample-pump) for the adaptive raise |
|
||
| `HeartbeatMs` | `10000` | The Agent's `/sample?heartbeat=` query param — also the watchdog's liveness window |
|
||
| `CommsLossConfirmMs` | `2000` | #846: how long the Agent must keep **refusing** connections before every subscribed value goes `BadNoCommunication` (see [Comms loss](#comms-loss-refused-vs-ambiguous-846)). `0` = on the first refusal; negative = fast path off (watchdog only). JSON-only — the AdminUI form preserves it but has no field |
|
||
| `Probe.Enabled` / `Probe.IntervalMs` / `Probe.TimeoutMs` | `true` / `5000` / `2000` | Background connectivity-probe knobs (mirrors `ModbusProbeOptions`) |
|
||
| `Reconnect.MinBackoffMs` / `MaxBackoffMs` / `BackoffMultiplier` | `0` / `30000` / `2.0` | Geometric backoff after a failed request or a dropped `/sample` stream |
|
||
| `Write.Enabled` | `false` | The driver-side half of the write-back opt-in. An absent block means disabled — see [Write-back](#write-back--observation-injection-off-by-default) |
|
||
| `Tags[]` | `[]` | Pre-v3 / CLI authoring surface — one `MTConnectTagDefinition` per DataItem `id` |
|
||
| `RawTags[]` | `[]` (deploy-injected) | The v3 data-plane binding — see below |
|
||
|
||
**All timing knobs are validated strictly positive** at `InitializeAsync`
|
||
(`RequirePositive`, mirroring the arch-review 01/S-6 lesson: a `0` timeout
|
||
does not mean "wait forever," it faults the driver). This applies to
|
||
`RequestTimeoutMs`, `HeartbeatMs`, `SampleIntervalMs`, `SampleCount`, and
|
||
(when the probe is enabled) `Probe.Interval` / `Probe.Timeout`.
|
||
|
||
**No save-time gate on a blank `AgentUri` in the AdminUI.** The driver form
|
||
renders an inline validation notice (`_form.Validate()`), but
|
||
`DriverConfigModal.SaveAsync` has no per-form validation seam to block the
|
||
Save button on it — no sibling driver form has one either. A blank
|
||
`AgentUri` saves cleanly and fails at deploy with the driver's own error
|
||
message, not at authoring time.
|
||
|
||
## Data plane
|
||
|
||
### FullName == DataItem `id`
|
||
|
||
`FullName` (both `MTConnectTagDefinition.FullName` and every `RawTags[]`
|
||
blob's identifier) is the MTConnect `DataItem@id` attribute — the value the
|
||
universal browser commits and the key the driver resolves reads/subscribes
|
||
against. `MTConnectTagConfigModel.FromJson` (the AdminUI typed editor) tries
|
||
three spellings in order — `fullName` → `dataItemId` → `address` — and takes
|
||
the first non-blank one, normalising onto `fullName` on save. `address` is
|
||
accepted because that is the field name `RawBrowseCommitMapper` writes for a
|
||
driver with no typed-editor branch of its own — MTConnect has had such a
|
||
branch since Gitea #536 and now commits under `fullName`, but blobs committed
|
||
before it, and hand-authored/CSV/API blobs, still carry `address` and must
|
||
stay readable.
|
||
|
||
### Coercion-type precedence
|
||
|
||
The driver keys its data plane by **RawPath** (the v3 raw-tag identity), not
|
||
by DataItem id directly, resolving RawPath → dataItemId internally. Each raw
|
||
tag's coercion type (the `DriverDataType` its Agent observation is parsed
|
||
into) is resolved in this order, first non-null wins:
|
||
|
||
1. The `RawTags[]` blob's `driverDataType` or `dataType` field (both
|
||
spellings accepted — the typed editor writes `dataType`, the driver's own
|
||
`tags[]` shape uses `driverDataType`). **`driverDataType` wins when it is
|
||
non-blank**, and a present-but-invalid one rejects the tag rather than
|
||
falling through to `dataType`. The AdminUI's typed editor mirrors that
|
||
precedence on load and **normalises onto `dataType`, dropping the alias, on
|
||
save** (Gitea #550) — so a tag edited through the editor keeps the type the
|
||
driver read for it, and an unreadable spelling is refused at authoring
|
||
instead of dying at the driver.
|
||
2. A matching `Tags[]` entry naming the same DataItem id.
|
||
3. The Agent's own `/probe` declaration, via `MTConnectDataTypeInference.Infer`
|
||
(category/type/units/representation → `DriverDataType`).
|
||
4. `DriverDataType.String` — the coercion that cannot fail.
|
||
|
||
> **A browse-committed tag resolves at step 1, carrying the step-3 answer.** `RawBrowseCommitMapper`'s
|
||
> MTConnect branch emits a whole `MTConnectTagConfigModel`, whose `ToJson()` always writes `dataType`
|
||
> — so a committed blob always states a non-blank type and step 1 always wins. Since Gitea **#560**
|
||
> that stated type is the **browsed leaf's own inferred `DriverDataType`**, the very value step 3
|
||
> would compute, forwarded by `MapLeaf` into `BuildTagConfig` exactly as the AbCip / TwinCAT / FOCAS
|
||
> branches forward their browsed native type (Gitea #536). It therefore agrees by construction with
|
||
> the raw tag row's OPC UA `DataType`, which `MapLeaf` → `MapDataType` takes from the same inference.
|
||
>
|
||
> Before #560 the branch stamped the model's `String` default instead, so a browsed `SAMPLE` landed
|
||
> as a `Double` node whose driver coerced the observation to text — recorded as `Driver.MTConnect-003`
|
||
> in [`code-reviews/Driver.MTConnect/findings.md`](../../code-reviews/Driver.MTConnect/findings.md).
|
||
> A leaf the browser reports **no** type for still commits `String`, and a leaf legitimately inferred
|
||
> as `String` (an `EVENT` item) commits `String` too — in both cases the type the driver would have
|
||
> reached anyway.
|
||
>
|
||
> **Step 3 remains the rescue for a blob that states no type at all** — hand-authored, CSV-imported,
|
||
> or API-posted `RawTags[]` entries carrying only an id, which is the shape it was written for.
|
||
|
||
### Quality mapping
|
||
|
||
`UNAVAILABLE` — MTConnect's one explicit "I have no value" sentinel — maps to
|
||
**`BadNoCommunication`** (`0x80310000`). This is deliberately distinct from
|
||
the fleet-standard `BadCommunicationError` (`0x80050000`, used elsewhere for
|
||
the driver's *own* transport failure to the Agent): `UNAVAILABLE` means the
|
||
Agent is reachable and answered, it just has no device-backed value for that
|
||
item. A `CONDITION` observation's value is taken from its **element name**
|
||
(`<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.
|
||
|
||
**Integer-valued decimals are served on integral tags (#804).** Agents format integer-valued items as
|
||
`42.000000`; an `Int*`/`UInt*` tag accepts any decimal whose value *is* an integer in range (`42.000000` → `42`,
|
||
`1.5e3` → `1500`) losslessly. A real fraction stays `BadOutOfRange` — deliberately stricter than the Java
|
||
reference driver, which truncates toward zero.
|
||
|
||
Other status codes an observation can surface:
|
||
|
||
| Code | Meaning |
|
||
|---|---|
|
||
| `BadTypeMismatch` (`0x80740000`) | The Agent's text isn't a value of the tag's coerced type at all — including a `NaN`/`Infinity` spelling on a float tag (#804; it used to be served Good) |
|
||
| `BadOutOfRange` (`0x803C0000`) | The Agent reported a number the coerced type can't represent — an integer overflow, a real fraction on an integral tag (`3.5` is refused, never truncated), or a float literal that overflows to ±Infinity (`1e400`; #804, it used to be served as a Good Infinity) |
|
||
| `BadDecodingError` (`0x80070000`) | A `TIME_SERIES` vector whose element count disagrees with the observation's own `sampleCount` (or the tag's fixed `ArrayDim`) — see [TIME_SERIES](#time_series-arrays-627) |
|
||
| `BadNotSupported` (`0x803D0000`) | A shape this build doesn't materialize — a `DATA_SET`/`TABLE` observation whose children are not `<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`, 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](#known-limitations) for what that does
|
||
and does not do.
|
||
|
||
**Lag measurement and the adaptive `count` (review 2026-10-21 T-5).** Throughput is capped at `SampleCount`
|
||
observations per chunk, one chunk per `SampleIntervalMs`; a machine producing more used to fall behind the ring
|
||
buffer forever, re-baselining every buffer-length (dropping intermediate transitions) with nothing surfacing it.
|
||
Each chunk is now measured: `lag = lastSequence + 1 − nextSequence` (unknown when the Agent omits `lastSequence`)
|
||
and whether it was **saturated** (carried the full `count`). Three consecutive saturated chunks with a positive (or
|
||
unknown) lag reopen the stream at the cursor reached with `count` doubled, up to 16× `SampleCount` (max 65 536) —
|
||
**and never above the Agent's `/probe` `bufferSize`** (#846; a count above it is refused `OUT_OF_RANGE`, so a raise past
|
||
it only bought a refused stream and a step-back; a configured `SampleCount` already above the buffer is not raised and
|
||
is the #825 warning's business). A
|
||
raised stream that delivers nothing (an Agent refusing the larger count) steps back to the last count that delivered
|
||
and stops raising; at the ceiling a still-behind stream logs one Warning per pump. Every new pump starts from the
|
||
configured `SampleCount`. An Agent that refuses the raised count with an `MTConnectError` under HTTP 200 goes through
|
||
the ordinary unusable-answer path first — one transient Degraded blip and a backoff — before the step-back. The
|
||
driver now implements `IDriverDiagnostics`: `sampleCount` (effective), `sampleLag`,
|
||
`sampleSaturatedChunks` and `sampleCountRaises` ride the `/hosts` diagnostics bag.
|
||
|
||
**A pump carries the session generation it was started against (review 2026-10-21 T-1).** A lifecycle restart
|
||
retires the running pump (or abandons it after its bounded teardown wait); if that pump finishes after the new
|
||
session is installed, its cursor publish, a late chunk and a late re-baseline are all dropped — checked under the
|
||
same lock the new session is installed and the generation bumped under. It used to land the OLD Agent's
|
||
`instanceId` + sequence on the new session, so the next pump read its first chunk as an Agent restart (index
|
||
cleared, one Bad flicker on every subscribed tag, a needless re-baseline).
|
||
|
||
## Write-back — observation injection, off by default
|
||
|
||
> ⚠️ **A write sets what the Agent *reports*, not what the machine does.** The value
|
||
> lands in the Agent's observation buffer exactly as if an adapter had reported it —
|
||
> visible to **every** consumer of that Agent, and **overwritten by the next
|
||
> adapter/SHDR update for that DataItem**. Nothing reaches the CNC. Never document,
|
||
> script, or operate this as a machine command. It is genuinely useful for
|
||
> operator-set EVENT values, for external systems feeding DataItems the adapter does
|
||
> not own, and for test rigs — and nothing else.
|
||
|
||
The mechanism is the Agent's **`AllowPut`** surface (`POST
|
||
{agent}/{device}?{dataItemId}={value}`), a standard cppagent feature that is **off by
|
||
default**. It is not MTConnect *Interfaces* — see [Deferred](#deferred-not-in-this-build)
|
||
for why Interfaces was rejected as the write-back shape. Design record:
|
||
[`2026-08-05-mtconnect-writeback-design.md`](../plans/2026-08-05-mtconnect-writeback-design.md)
|
||
§2.2; implementation `b29623cf`.
|
||
|
||
### Two independent gates, both off by default
|
||
|
||
1. **The Agent** — `AllowPut = true` in `agent.cfg`. Optionally scope it with
|
||
`AllowPutFrom = <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`](../plans/2026-07-15-universal-discovery-browser-design.md).
|
||
|
||
## CONDITION alarms (#626)
|
||
|
||
A `CONDITION` DataItem can be served two ways, and the choice is **per raw tag**:
|
||
|
||
- **As a String value** (the default — every existing CONDITION tag, and every browse-committed
|
||
one). The value is the current state word — `Normal` / `Warning` / `Fault` / `UNAVAILABLE`
|
||
(`UNAVAILABLE` → `BadNoCommunication`). Unchanged by #626.
|
||
- **As a native OPC UA Part 9 alarm**, by adding the cross-driver `alarm` object to the raw tag's
|
||
`TagConfig` — exactly how a Galaxy alarm tag is made alarm-bearing (the `/raw` tag modal's native
|
||
alarm section, or JSON):
|
||
|
||
```json
|
||
{ "address": "dev1_system_cond", "alarm": { "alarmType": "AlarmCondition", "severity": 800 } }
|
||
```
|
||
|
||
The server then materialises **one Part 9 condition at the tag's RawPath** (ConditionId = RawPath),
|
||
fanned to the raw device folder and every referencing equipment folder, and the driver feeds it
|
||
through `IAlarmSource`. An alarm-bearing tag is a condition, not a value variable (the host excludes
|
||
alarm tags from the value subscription), so to keep the String as well, author a second raw tag on
|
||
the same DataItem without the `alarm` object — one DataItem may back several raw tags.
|
||
|
||
It is deliberately **not automatic** for `category=CONDITION`: that would silently turn every existing
|
||
CONDITION String tag into a condition node on upgrade.
|
||
|
||
**State mapping.**
|
||
|
||
| Agent level | Part 9 | Severity | Quality |
|
||
|---|---|---|---|
|
||
| `Normal` | inactive (return to normal) | — | Good |
|
||
| `Warning` | active, unacknowledged | 500 | Good |
|
||
| `Fault` | active, unacknowledged | 800 | Good |
|
||
| `Unavailable` | **unchanged** (an active condition stays active and acknowledged) | — | `BadNoCommunication` |
|
||
|
||
**`Unavailable` is comms loss one hop further out, not a return to normal.** A cppagent marks every
|
||
DataItem of an adapter UNAVAILABLE when the adapter socket drops, so it is treated exactly like the
|
||
host's own transport loss (#477/#481): the condition keeps its Active/Acked state and only its `Quality`
|
||
goes `BadNoCommunication`, as a **quality-only annotation** (no `/alerts` row, written through the host's
|
||
Quality-only node path — it can never rewrite message/severity or acknowledge anything). When data
|
||
returns: the **same** statement ⇒ quality-only Good (an ack survives an adapter blip); `Normal` ⇒ Clear;
|
||
a **different** active statement ⇒ Retrigger. An inactive condition going Unavailable is likewise a
|
||
quality-only Bad. Severity is the exact Part 9 value (`AlarmEventArgs.OpcUaSeverity`, #626), not one of
|
||
the four buckets. A clear keeps the severity of the alarm it clears.
|
||
|
||
**Message** = the condition's text content; without text, `"{type} {level}"` (e.g. `SYSTEM Fault`).
|
||
Whichever of `nativeCode` / `nativeSeverity` / `qualifier` the Agent supplied is appended —
|
||
`Spindle overtemperature [nativeCode=E17, nativeSeverity=2, qualifier=HIGH]` — because the Part 9
|
||
event model has no slot for them. `AlarmType` is the condition's `type`; `SourceNodeId` the DataItem id.
|
||
|
||
**Several simultaneous conditions.** MTConnect can hold several active conditions on one DataItem
|
||
(distinct `nativeCode`s). The OPC UA condition shows **the most severe active one**; equal levels tie-break deterministically
|
||
(ordinal `nativeCode`, then `type`), never by Agent document order, so a re-baseline that lists them
|
||
differently cannot re-raise.
|
||
The driver tracks them with MTConnect's own rules (`MTConnectConditionTracker`): `Warning`/`Fault`
|
||
activates its `nativeCode`; `Normal` with a `nativeCode` clears only that code, `Normal` without one
|
||
clears all; `Unavailable` clears all; a `/current` re-baseline replaces the DataItem's set. So a
|
||
`/sample` chunk clearing code A while code B is active is **not** a return to normal. An escalation
|
||
(Warning → Fault) or a different winning code is a **Retrigger** — active and unacknowledged again with
|
||
the new severity/message. By design that includes **clearing the worst of several codes**: the next one
|
||
down is re-raised un-acked, because it is a statement the operator has not seen. A **text-only change**
|
||
on the same level + `nativeCode` (an adapter embedding a live value in the text) updates nothing — no
|
||
retrigger, no un-ack — and the message shown stays the statement's first text. (The String value surface still reconciles per
|
||
document, so on a delta it shows the state word of that chunk; the alarm is the authoritative view.)
|
||
|
||
**Acknowledge.** MTConnect has no acknowledgement upstream. Native conditions support Acknowledge and
|
||
AddComment only; the ack is applied server-side and the driver's `AcknowledgeAsync` is a no-op success
|
||
(nothing is sent to the Agent). Off-Primary acks are refused by `NativeAlarmAckPrimaryGate` as for every
|
||
native source.
|
||
|
||
**Restart / reconnect / comms loss.** The driver diffs each condition against what the server was last
|
||
**told** — dedup on level + `nativeCode` + `nativeSeverity` + `qualifier` (not text, never the value) —
|
||
and records a state only when an alarm handler was attached to receive it. Consequences:
|
||
|
||
- A replayed `/current` (reconnect, re-initialize, Agent restart, ring-buffer re-baseline) re-raises
|
||
nothing that is unchanged — re-raising would un-acknowledge an operator's ack.
|
||
- A transition that happened while the host was detached (it detaches while it reconnects) is delivered
|
||
on the re-subscribe that follows; a fault cleared during an outage is cleared on recovery.
|
||
- Comms loss emits nothing from the driver, matching every native source: the host annotates every
|
||
condition the driver owns `BadNoCommunication` on the connectivity edge (#477) and keeps its Active
|
||
state; the reconnect edge re-annotates Good, after which the driver re-asserts any still-`Unavailable`
|
||
condition's Bad quality on its re-subscribe.
|
||
- A re-subscribe with a **changed** alarm set drops the told record of only the conditions that **left**
|
||
the set. The server carries every native condition's state (acknowledgement included) across a full
|
||
rebuild itself (Gitea #641 — see `docs/AlarmTracking.md`), so re-raising a surviving condition would
|
||
only un-acknowledge it; a condition that left and later comes back was materialised fresh and is told
|
||
again. The **same** set (a reconnect) keeps the whole record. A rebuild that leaves the alarm set
|
||
unchanged triggers no re-subscribe, and needs none — the server-side carry-over covers it.
|
||
- An alarm-only driver (no value tags) still runs the `/sample` pump.
|
||
|
||
### EVENT enumerations (#650): parked / won't fix
|
||
|
||
EVENT values (`Execution`, `ControllerMode`, …) stay Strings carrying the Agent's own word. Modelling them as
|
||
OPC UA enumerations was considered and **declined**: the vocabulary is vendor-extensible, so it needs a per-type
|
||
table plus an unknown-value fallback, a per-type enum node shape through discovery and raw-tag type inference,
|
||
and it would break every consumer that reads these items as String (ScadaBridge) for little gain. Reopen only
|
||
with an opt-in, tag-level `enumValues` design.
|
||
|
||
## Java reference parity (#804)
|
||
|
||
Gitea #804 reviewed this driver against the read-only Java MTConnect driver in the `ignitionoee` repo
|
||
(`modules/drivers/gateway/src/{main,test}/java/com/zb/mom/driver/mtconnect/`, live-validated on Ignition
|
||
2026-08-10). That Java driver is largely a port of this one, so most rows are **covered**; the review found
|
||
nine concrete gaps, all closed on `feat/804-mtconnect-parity`. Code paths below are relative to
|
||
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/`, tests to `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/`
|
||
(unit) and `…MTConnect.IntegrationTests/` (live); Java classes are `MTConnect*.java` in the package above. The
|
||
Java driver's live evidence does not validate this one — this driver's own live results are at the end.
|
||
|
||
**Status key:** **covered** — .NET already did it (test named); **gap → closed** — verified missing, fixed in
|
||
#804 with a test that failed first; **.NET exceeds** — beyond the Java baseline, kept; **divergence** —
|
||
deliberately different, kept.
|
||
|
||
### Transport, protocol and configuration
|
||
|
||
| Java capability (class) | .NET status | .NET code · tests |
|
||
|---|---|---|
|
||
| HTTP/HTTPS Agent base URI, http/https only (`MTConnectConfigValidator`) | covered | `MTConnectAgentClient.ResolveAgentRoot` · `MTConnectFactoryTests.Malformed_or_non_http_agentUri_returns_not_ok` |
|
||
| Optional device scope `{agentUri}/{deviceName}/…` (`MTConnectAgentClient`) | covered (+ a belt-and-braces scope match in discovery) | `BuildRequestUri`, `MTConnectDriver.InDeviceScope` · `MTConnectDiscoverTests.Discover_with_a_device_scope_*` |
|
||
| `/probe` at connect, fault-fast (`MTConnectDevice.connect`) | covered | `StartCoreAsync` (initialize fails → Faulted) · `MTConnectDriverLifecycleTests` |
|
||
| `/current` snapshot; `/sample` multipart long poll with `from`/`interval`/`count`/`heartbeat` | covered | `CurrentAsync`/`SampleAsync` · `MTConnectStreamsParseTests.SampleAsync_composes_the_from_interval_count_and_heartbeat_query` |
|
||
| Strictly-positive timing knobs (`requirePositive`) | covered | `MTConnectDriver.RequirePositive` · `MTConnectDriverOptionsTests` |
|
||
| One HTTP client per device, not per reconnect (`SharedHttp`) | covered — two `HttpClient`s per agent client, rebuilt only on a request-shaping config change | `RequiresClientRebuild` · `MTConnectDriverLifecycleTests.Reinitialize_with_a_changed_device_scope_rebuilds_the_client` |
|
||
| Agent error document under a **non-2xx** status lifted (`probeAgentError`/`streamsAgentError`) — a 2.x Agent serves `OUT_OF_RANGE` as **400** and `NO_DEVICE` as **404** | **gap → closed** | `EnsureAgentAnsweredAsync`, `MTConnectXml.AgentRefusalFromErrorBody` · `MTConnectJavaParityTests.Sample_answered_400_…`, `Current_answered_404_…`, `Probe_answered_404_…`; live `A_sample_cursor_outside_the_buffer_is_the_agents_out_of_range_refusal` |
|
||
| Failure body read only to a cap (`MAX_ERROR_BODY_BYTES`, 64 KiB) | **gap → closed** (with the row above) | `MaxErrorBodyBytes` · `An_error_document_beyond_the_body_cap_is_not_read_in_full`, `A_non_2xx_proxy_page_is_still_a_plain_http_failure` |
|
||
| Demand-read TTL cache for un-monitored reads (`MTConnectDemandSource`, `demandReadTtlMs`) | covered, different model | reads answer from the live pump index (no round trip) or one `/current` per **batch**; the server never calls `ReadAsync` on its hot path · `MTConnectReadPathTests` |
|
||
| No credentials / no `SecretConfig` | divergence | a `user:pass@` URI is accepted and **redacted** everywhere (`MTConnectUriRedaction`) · `MTConnectUriRedactionTests` |
|
||
|
||
### Parsing, browse and values
|
||
|
||
| Java capability (class) | .NET status | .NET code · tests |
|
||
|---|---|---|
|
||
| Match on LocalName, read attributes unqualified (`MTConnectXml` rules 1–2) | covered | `MTConnectXml` · `Probe_parse_is_agnostic_to_the_document_namespace_version`, `Probe_parse_keeps_a_vendor_extension_component_in_its_own_namespace`, `Parse_reads_only_unqualified_attributes` |
|
||
| DOCTYPE refused on **every** parse (`disallow-doctype-decl`) | **gap → closed** for `/probe` and write answers (the streams parser already prohibited it) | `MTConnectXml.ParseHardened`/`LoadHardened` · `Probe_parse_refuses_a_doctype_from_a_string`/`…_stream`, `A_write_answered_with_a_doctype_document_is_not_confirmed` |
|
||
| Component nesting bound 64 (`MAX_COMPONENT_DEPTH`) | covered | `MTConnectProbeParser.MaxComponentDepth` · `MTConnectProbeParseTests` |
|
||
| Streams element nesting bound 64 (`MAX_ELEMENT_DEPTH`) | **gap → closed** — the walk recursed without bound: an uncatchable `StackOverflowException`, i.e. a server-process crash | `MTConnectStreamsParser.MaxElementDepth` · `Streams_parse_refuses_absurd_nesting_…`, controls `Streams_parse_accepts_ordinary_device_nesting`, `An_absurdly_deep_error_document_still_yields_the_agents_words` |
|
||
| Structural elements under `<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 | divergence (kept) | empty text is `BadNoCommunication` (an empty element is not an empty-string value) |
|
||
| No fabricated numbers: narrow-integer range checks, float overflow ≠ Infinity, NaN/Infinity refused (`MTConnectValues.coerce`) | covered for integers; **gap → closed** for floats (`1e400`, `NaN`, `Infinity`, `1e39` on Float32 were served Good) | `Coerce`, `NonFiniteStatus` · `A_non_finite_float_is_never_served_as_good` |
|
||
| Integer-valued decimal (`42.000000`) on an integral node | **gap → closed** (was `BadOutOfRange`) — lossless only; a real fraction is still refused, never truncated (Java truncates) | `TryIntegral` · `An_integer_valued_decimal_converts_losslessly`, control `A_fraction_or_an_out_of_range_decimal_stays_out_of_range` |
|
||
| Source timestamps preserved; a non-Zulu offset normalised | covered | `ReadTimestampUtc` · `A_timestamp_with_a_non_zulu_offset_is_normalized_to_the_same_utc_instant` |
|
||
| An unparseable timestamp falls back to *now* | divergence (kept) | the chunk is refused (`InvalidDataException` → re-baseline): a fabricated SourceTimestamp is never served |
|
||
| Transport failure distinct from UNAVAILABLE | covered | `BadCommunicationError` per ref on a failed read; the host fans `BadNoCommunication` on comms loss (#481) |
|
||
|
||
### Stream lifecycle
|
||
|
||
| Java capability (class) | .NET status | .NET code · tests |
|
||
|---|---|---|
|
||
| Running cursor; gap = `firstSequence > expected`, strictly (`MTConnectPump.onDocument`) | covered | `IsSequenceGap` · `Sequence_gap_is_strictly_firstSequence_greater_than_expected_from`, `Sequence_gap_triggers_exactly_one_current_rebaseline_then_resumes` |
|
||
| `instanceId` checked before the gap; a restart drops held values | covered | `ConsumeStreamAsync` · `Agent_restart_clears_the_index_and_is_detected_before_the_gap_path` |
|
||
| The restart test also runs on the re-baseline snapshot | **gap → closed** — on a 2.x Agent a restart is usually seen ONLY here (the old cursor gets a 400) | `RebaselineAsync`, `TryDropForAgentRestart` · `An_agent_restart_seen_only_by_the_rebaseline_…`, control `An_out_of_range_rebaseline_against_the_same_agent_raises_no_rediscovery` |
|
||
| Restart → reconnect + re-probe + rebuild the tree | divergence (v3 design) | re-baseline in place + an `IRediscoverable` re-browse prompt; the served tree is authored, never grafted (Known limitations 1) |
|
||
| `OUT_OF_RANGE` (200 **or** 400) → re-baseline (`onError`) | covered for 200; **gap → closed** for 400 (Transport table) | the `InvalidDataException` arm of `ConsumeStreamAsync` · `Out_of_range_stream_error_also_triggers_a_rebaseline` |
|
||
| Any other Agent error → reconnect, not re-baseline | divergence | every unusable answer re-baselines; the backoff ladder and the no-progress cap below bound it |
|
||
| Re-baselines without progress bounded (`MAX_REBASELINES_WITHOUT_PROGRESS` = 3) | paced already (backoff); **gap → closed** for visibility — it read Healthy for ever | `MaxRebaselinesWithoutProgress`, `TryHoldStreamStalled` · `Repeated_rebaselines_without_a_delivered_chunk_report_the_stream_degraded`, control `A_rebaseline_short_of_the_cap_stays_healthy`; live `A_sample_count_the_agent_refuses_is_reported_degraded_while_values_keep_flowing` |
|
||
| Heartbeat watchdog; any part resets it (`watchdogExpired`, 2 × heartbeat) | covered (2 × `HeartbeatMs` + `RequestTimeoutMs`) | `MultipartStreamReader` idle timeout · `SampleAsync_faults_on_the_heartbeat_watchdog_rather_than_hanging_on_a_silent_agent` |
|
||
| Bounded reconnect backoff (fixed 2 s → 30 s, jitter) | covered, configurable (`Reconnect.*`; ladder reset after a delivering stream) | `BackoffFor` · `MTConnectSubscribeTests.Reconnect_backoff_*` |
|
||
| A superseded generation speaks for nothing (`PumpState`, `Host.running`) | covered | session generation · `MTConnectPumpGenerationTests` |
|
||
| Deterministic cancellation and disposal | covered | `ShutdownAsync`, bounded teardown waits · `Shutdown_stops_the_pump_before_disposing_the_client_and_leaks_nothing`, `Shutdown_while_the_pump_is_mid_rebaseline_does_not_deadlock` |
|
||
|
||
### Representations and writes
|
||
|
||
| Java capability | .NET status | Notes |
|
||
|---|---|---|
|
||
| TIME_SERIES / DATA_SET / TABLE **excluded** from the tree (one WARN) | **.NET exceeds** — kept | TIME_SERIES → `double[]` (#627); DATA_SET/TABLE → compact JSON String (#650) · `MTConnectDataSetTests`; live `Discovery_types_samples_as_Float64_and_time_series_as_a_float_array`, `Read_serves_data_set_and_table_as_json_strings` |
|
||
| CONDITION → String state word | covered, **.NET exceeds** (opt-in native Part 9 alarms, #626) | [CONDITION alarms](#condition-alarms-626) |
|
||
| **No write path** (read-only, no `writeTag()`) | **divergence — flagged, not removed** | `IWritable` (`b29623cf`, 2026-08-05) is the Agent's `AllowPut` **observation injection**: it sets what the Agent *reports* for a non-CONDITION, non-array DataItem, and nothing reaches the controller. It is off at two independent gates (`Write.Enabled`, default false; the Agent's `AllowPut`, default off) and a node is only writable once its raw tag is authored `ReadWrite`. So there is no *controller* write path — but there is an Agent write path the Java driver does not have. See [Write-back](#write-back--observation-injection-off-by-default); rejection is live-tested by `Write_is_refused_without_touching_the_agent_when_write_is_not_enabled` |
|
||
|
||
### Live validation (2026-11-10, `mtconnect/agent:2.7.0.12` at `http://10.100.0.35:5000`)
|
||
|
||
- **Integration suite: 20/20** (`MTCONNECT_AGENT_ENDPOINT=http://10.100.0.35:5000`): browse, typed discovery,
|
||
read, UNAVAILABLE, DATA_SET/TABLE, subscribe, RawPath binding, the write round trip, the Agent's write refusal and
|
||
the `write.enabled` rejection — plus the two #804 legs, both of which failed before the fix.
|
||
- **Lifecycle harness** — the real `MTConnectDriver` subscribed to `fixture_c_speed`/`fixture_mode`/`fixture_execution`
|
||
with `HeartbeatMs` 5000, while the fixture containers were stopped/restarted/paused (all restored and healthy after):
|
||
- *Adapter stop:* the Agent marked its items UNAVAILABLE ~12 s later (its own adapter timeout); all three went
|
||
`BadNoCommunication` while the driver stayed **Healthy** (the Agent kept answering). *Adapter start:* Good ~3 s later.
|
||
- *Agent restart:* one `Connection refused` Degraded tick, the restart seen on the re-baseline, **rediscovery
|
||
raised**, Healthy and Good ~2 s after the Agent was back. Before the re-baseline restart fix the same leg recovered
|
||
its values but raised **no** rediscovery — that run is what found the gap.
|
||
- *Frozen Agent* (`docker pause`): the watchdog fired at 15 s (2 × 5000 + 5000); the driver went Degraded with
|
||
`LastSuccessfulRead` frozen (the comms-loss watchdog's evidence) and held its last values (the host stamps them
|
||
`BadNoCommunication` on the comms-loss edge, #481); reconnects timed out until *unpause*, then Healthy and Good within ~2 s.
|
||
- *Teardown:* unsubscribe + shutdown returned in 1 ms.
|
||
- **Deferred live checks:** these legs ran through the driver, **not** through a deployed OtOpcUa server and an
|
||
OPC UA client (the docker-dev rig was not used); the no-progress cap was observed via a `SampleCount` above the
|
||
Agent's `bufferSize`, not a genuinely lapped ring buffer; no real machine tool or non-cppagent Agent was exercised.
|
||
**Closed by the #823 gate below**, except the real-machine / non-cppagent caveat.
|
||
|
||
### Comms loss: refused vs ambiguous (#846)
|
||
|
||
The runtime's comms-loss watchdog (Degraded + `LastSuccessfulRead` frozen 90 s ⇒ re-initialize, host fans
|
||
`BadNoCommunication`) is the contract for every **ambiguous** failure, and the #823 gate below measured what it costs:
|
||
values Good-but-stale for ~94–118 s. A **refused** connection is not ambiguous — nothing is listening at `AgentUri` — so
|
||
the `/sample` pump now handles it itself:
|
||
|
||
- **Definite = `SocketError.ConnectionRefused`** anywhere in the failure's exception chain (what `HttpClient` throws for
|
||
a port nobody listens on), on a `/sample` open or a re-baseline `/current`. A request or stream-idle timeout, a reset or
|
||
closed stream, DNS failure, a paused Agent, an `MTConnectError` refusal — all stay with the watchdog, unchanged.
|
||
- **Confirmed:** the refusals must continue, unbroken, for `CommsLossConfirmMs` (default 2 s); any answer from the Agent or
|
||
any ambiguous failure ends the run. While confirming, the reconnect delay is capped at the time remaining.
|
||
- **Then every subscribed value is published `BadNoCommunication`** (value null) **once per outage**, with one Warning.
|
||
Health was already Degraded with `LastSuccessfulRead` frozen by the stream failure, so the watchdog still re-initializes
|
||
at 90 s exactly as before — this only stops stale values reading Good in between. Reads already fall back to `/current`
|
||
on a degraded stream and fail on their own. Native CONDITIONS are untouched (the host annotates them on the
|
||
connectivity edge, as before).
|
||
- **Recovery republishes everything:** the first delivered chunk (a heartbeat counts) or re-baseline after a marked outage
|
||
republishes every subscribed value from the index — a resumed `/sample` carries only what changed, so an unchanged item
|
||
would otherwise stay Bad (the (value, status) recovery rule).
|
||
- Consequence for an Agent **restart** that refuses for longer than the window (the #823 restart refused for ~1 s, inside
|
||
it): values go Bad for the restart and come back on its first chunk / re-baseline. That is the honest answer — the
|
||
Agent was down.
|
||
|
||
Tests: `MTConnectCommsLossTests` (incl. an ambiguous-failure falsification control and the confirmation window).
|
||
|
||
### End-to-end gate through a deployed server (#823, 2026-11-11, docker-dev)
|
||
|
||
An MTConnect driver (`SampleCount` 1000, `HeartbeatMs` 10000, `RequestTimeoutMs` 5000) against the shared fixture,
|
||
two raw tags (`fixture_x_pos` Double, `fixture_execution` String) referenced into a UNS equipment, deployed to the
|
||
central pair, and two Client.CLI subscriptions on central-1 (`ns=3` equipment folder, `ns=2` raw device). Raw and UNS
|
||
NodeIds moved together in every leg. Fixture containers were restored healthy afterwards.
|
||
|
||
| Leg | What happened on the wire |
|
||
|---|---|
|
||
| Adapter stop | Both NodeIds `BadNoCommunication` within 1 s of the adapter dying (Agent marks items UNAVAILABLE); driver stayed Healthy. Adapter start ⇒ Good 1–2 s later. |
|
||
| Agent restart (`docker restart`, ~12 s) | Stream lost and reconnected ~1 s; the stale cursor was refused `OUT_OF_RANGE`, the re-baseline saw the new `instanceId`, **rediscovery raised** — `/hosts` shows the **RE-BROWSE** chip. Values stayed Good (outage shorter than the comms-loss window). |
|
||
| Agent stopped ~2 min | Values held Good-but-stale; **Bad at 118 s** (comms-loss watchdog, connection refused). Agent start ⇒ re-init on the 10 s retry, Good ~8 s after the Agent was back. **Since #846** a refused connection marks the values `BadNoCommunication` after `CommsLossConfirmMs` (2 s) instead — unit-tested, not yet re-gated live. |
|
||
| Agent paused (`docker pause`) | Stream idle watchdog at 24 s (2 × 10000 + 5000); **Bad at 94 s** (watchdog); unpause ⇒ Good within ~3 s. |
|
||
| Genuine buffer overrun | A stalling TCP proxy froze the driver's transport while 32 000 observations were injected (`AllowPut`) — the Agent's `firstSequence` passed the driver's cursor (75 856 → 77 083). On release the resume was refused `OUT_OF_RANGE`, the driver re-baselined from `/current` within 1 s and resumed; values Good, no restart misdiagnosed. A raised-count stream (count 16, interval 5 s) was **not** lapped by a 266 obs/s burst: chunks are sent back-to-back while data is pending, so `interval` does not cap throughput. |
|
||
| `SampleCount` vs `bufferSize` (#825) | Test Connect rendered the amber "OK with warning" chip and a deploy logged the Warning (`/hosts` shows `agentBufferSize`). The same gate measured the Agent's real edge — `count = 16384` streams, `16385` is `OUT_OF_RANGE` — so the first build's "at or above" threshold was corrected to "above" (pinned live by `Test_Connect_warns_when_SampleCount_exceeds_the_live_agents_buffer_size`). |
|
||
|
||
## Known limitations
|
||
|
||
These are real, not placeholders — read them before relying on the driver
|
||
for anything beyond values-and-conditions.
|
||
|
||
1. **A restarted Agent prompts an operator; it does not re-shape the address
|
||
space.** `DriverInstanceActor` now consumes `OnRediscoveryNeeded`
|
||
(2026-07-27), so a changed `instanceId` raises a "re-browse" chip against
|
||
this driver on `/hosts` carrying the reason. It is **advisory**: v3 authors
|
||
raw tags through the `/raw` browse-commit flow, so until an operator
|
||
re-browses the Agent and commits, any DataItem that appeared or vanished is
|
||
not reflected in the served tree. A runtime graft was deliberately rejected
|
||
— it would materialise nodes nobody approved and no deployment artifact
|
||
records.
|
||
|
||
**`IHostConnectivityProbe` is consumed** (Gitea #521, closed) —
|
||
`DriverInstanceActor` subscribes `OnHostStatusChanged`, pulls
|
||
`GetHostStatuses()` fresh, and the result rides `DriverHealthChanged` to
|
||
the AdminUI `/hosts` page (no SQL; the `DriverHostStatus` table was
|
||
dropped). See `docs/Telemetry.md`.
|
||
2. **One MTConnect driver instance = one Agent client = at most one `/raw`
|
||
Device (#628, decision (b)).** The driver never reads
|
||
`RawTagEntry.DeviceName`; every raw tag binds through the single Agent client
|
||
scoped by `MTConnectDriverOptions.DeviceName`. The v3 raw tree always has a
|
||
Device level (a new driver auto-creates `Device1`), and that single-Device
|
||
layout is the normal, valid shape — its `DeviceConfig` is merged up into the
|
||
driver config by `DriverDeviceConfigMerger` (this is how the endpoint lands).
|
||
With **two or more** Devices under one MTConnect driver there is no merge-up
|
||
and no routing, so which Device supplies the endpoint is ambiguous. That is
|
||
now a **deploy-time `DraftValidator` error** (`MTConnectMultipleDevices`),
|
||
and the AdminUI refuses to *create* the second Device
|
||
(`RawTreeService.CreateDeviceAsync`, same message): "MTConnect binds all tags
|
||
through one agent client per driver instance; author one MTConnect driver per
|
||
agent device." Option (a), a client per Device, was rejected as a design
|
||
change with no demand. A multi-device Agent is served by several driver
|
||
instances, each with its own `DeviceName` scope. The driver's init-time
|
||
Warning for differing `RawTagEntry.DeviceName` values stays as defence in
|
||
depth for configs that predate the rule.
|
||
|
||
⚠️ **Upgrade note:** an MTConnect driver that *already* has two or more Device
|
||
rows fails its next deploy with `MTConnectMultipleDevices` (the rule counts every
|
||
Device row, enabled or not, because nothing at runtime reads `Device.Enabled`).
|
||
Before upgrading, move each extra Device's tags under its own MTConnect driver
|
||
and delete the extra Device rows.
|
||
|
||
**The write path is the one place a device name is resolved per DataItem**,
|
||
and it does not use `RawTagEntry.DeviceName` either: it reads the owning
|
||
device out of the cached probe model and falls back to the top-level
|
||
`DeviceName` (see [Write-back](#write-back--observation-injection-off-by-default)).
|
||
So `MTConnectDriverOptions.DeviceName` does double duty — request scope for
|
||
reads/browse, fallback write target for writes.
|
||
|
||
3. **Authoring only *warns* when a declared coercion type cannot hold the
|
||
inferred one (#613).** The AdminUI tag editor (`MTConnectTagConfigModel.Warnings()`)
|
||
compares the declared type with `MTConnectDataTypeInference` over the
|
||
`/probe` metadata a browse-commit stamped (`mtCategory`/`mtType`), and shows an
|
||
advisory line — it never blocks a save (the deploy gate does not refuse it) and
|
||
is a no-op for hand-authored blobs with no stamped metadata. `representation`
|
||
is not on the model; TIME_SERIES arrays get their own `isArray` advisory (declare Float64). Beyond that,
|
||
nothing validates a raw tag's declared OPC UA `DataType` against the
|
||
blob's coercion type. The `/probe`-sourced inference
|
||
(`MTConnectDataTypeInference`) narrows how often an author picks a wrong
|
||
type, but it doesn't close the gap — an authored `DataType` mismatched
|
||
against the actual Agent observation still surfaces as `BadTypeMismatch`
|
||
/`BadOutOfRange` at runtime rather than at authoring time.
|
||
4. **`sampleCount` is not a legal `DataItem` attribute.** It belongs on a
|
||
`TIME_SERIES` *observation*, not the device-model declaration. A real
|
||
Agent that meets `sampleCount` on a `DataItem` declaration logs `The
|
||
following keys were present and not expected: sampleCount` and **drops
|
||
the entire data item** from the served model. A live TIME_SERIES tag
|
||
therefore always resolves to `ArrayDim = null` (variable-length array) in
|
||
practice — the fixture's canned `probe.xml`/`Devices.xml`, which does
|
||
declare a `sampleCount`, is unrealistic on this point and exists only to
|
||
pin the parsing rule itself.
|
||
5. **A CONDITION alarm shows one condition per DataItem** — the most severe active `nativeCode`, not one
|
||
Part 9 condition per code (see [CONDITION alarms](#condition-alarms-626)).
|
||
6. **`DATA_SET`/`TABLE` observations are JSON Strings** (#650, see
|
||
[DATA_SET / TABLE](#data_set--table-650)); `TIME_SERIES` is served as `double[]` (#627, see
|
||
[TIME_SERIES arrays](#time_series-arrays-627)); the sample rate is not
|
||
surfaced on the node.
|
||
|
||
## Deferred (not in this build)
|
||
|
||
- **MTConnect *Interfaces*.** Still deferred, and for the reason design
|
||
[§3.6](../plans/2026-07-15-mtconnect-driver-design.md#36-iwritable--not-implemented-v1)
|
||
gave: it is a rare, optional request/response **state-machine handshake**
|
||
requiring adapter-side cooperation, not a "set value" operation, and would
|
||
mislead if modelled as `IWritable`.
|
||
**Write-back itself is no longer deferred** — `b29623cf` shipped `IWritable`
|
||
over the Agent's `AllowPut` observation-injection surface instead, a different
|
||
mechanism entirely. See [Write-back](#write-back--observation-injection-off-by-default)
|
||
and
|
||
[`2026-08-05-mtconnect-writeback-design.md`](../plans/2026-08-05-mtconnect-writeback-design.md)
|
||
§2.1.
|
||
- **P1.5 fast-follow** (design
|
||
[§9](../plans/2026-07-15-mtconnect-driver-design.md#9-phasing--effort)): CONDITION → native Part 9
|
||
alarms **shipped** (#626, see [CONDITION alarms](#condition-alarms-626)); EVENT
|
||
controlled-vocabulary values → OPC UA enumerations is **parked / won't fix** (EVENTs stay Strings, #650);
|
||
`DATA_SET`/`TABLE` are served as JSON Strings (#650).
|
||
- **P2 (on demand): SHDR adapter ingest.** A `SourceMode: "Agent" | "Shdr"`
|
||
switch that opens the raw pipe-delimited SHDR TCP socket directly. Loses
|
||
auto-discovery (no device model without an Agent in front, so
|
||
`SupportsOnlineDiscovery` would have to report `false` and tags would be
|
||
authored by hand, like Modbus). Niche; not built.
|
||
|
||
## Testing
|
||
|
||
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/`
|
||
(canned-XML fixtures, no network) — 714/714 at #804 (2026-11-10). `MTConnectJavaParityTests` holds the #804 gap tests and the ported Java regression cases (see [Java reference parity](#java-reference-parity-804)). Covers
|
||
discovery tree shape, `MTConnectDataTypeInference`, observation indexing,
|
||
`UNAVAILABLE` → `BadNoCommunication`, CONDITION state-word mapping,
|
||
multipart chunk framing, and ring-buffer re-baseline paging (both the
|
||
sequence-gap and the `OUT_OF_RANGE` legs). The write path adds
|
||
`MTConnectAgentClientWriteTests` (`9f17d7ef` — verb, URL, agent-error-in-200
|
||
detection) and `MTConnectWriteTests` (`b29623cf` — every refusal leg, the
|
||
batch shape, the browse-stamping flip).
|
||
- **Integration tests** —
|
||
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/` — env-gated
|
||
on `MTCONNECT_AGENT_ENDPOINT` (default `http://10.100.0.35:5000`), skips
|
||
cleanly when the fixture is unreachable, 20/20 against the real fixture Agent at #804. Read the fixture's own
|
||
[`Docker/README.md`](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md)
|
||
for the full recipe — summary only, here:
|
||
- Image is **`mtconnect/agent:2.7.0.12`** — not `mtconnect/cppagent`, which
|
||
does not exist on Docker Hub (the design's original assumption).
|
||
- The stack is **two services**: the Agent plus a stdlib-only SHDR adapter
|
||
(`Docker/adapter.py`, on `python:3.13-alpine`). An Agent with no adapter
|
||
reports every observation `UNAVAILABLE` forever, which proves nothing.
|
||
- Deployed at `/opt/otopcua-mtconnect/` on the shared docker host
|
||
(`10.100.0.35`, `project=lmxopcua` label). Endpoint
|
||
`http://10.100.0.35:5000`.
|
||
- `agent.cfg` must be **pure ASCII** — one non-ASCII byte anywhere,
|
||
including a comment, makes the config parser reject the whole file with
|
||
a bare `Failed / Stopped at line: N`.
|
||
- Bring-up: `lmxopcua-fix sync mtconnect` then `lmxopcua-fix up mtconnect`
|
||
(PowerShell helper, Windows-only) — or, directly on the docker host,
|
||
`rsync` the repo's `Docker/` dir to `/opt/otopcua-mtconnect/` and run
|
||
`docker compose up -d --wait`.
|
||
- **CONDITION alarms (#626)** — `MTConnectConditionAlarmMappingTests` (tracker semantics + state
|
||
mapping/sequencing), `MTConnectAlarmSourceTests` (driver wiring: Normal→Fault→Normal, escalation,
|
||
several `nativeCode`s, Unavailable, replay after re-subscribe/re-initialize/Agent restart with no
|
||
duplicate, the missed-while-detached delivery, comms loss), `MTConnectConditionAlarmWireTests` in
|
||
`OpcUaServer.IntegrationTests` (a scripted Agent's Fault/Normal reaching a client's event subscription on
|
||
the raw device folder). **Live check:** the fixture's adapter only ever writes `normal` for its two
|
||
CONDITION items (`fixture_x_travel` / SHDR key `Xtravel`, `fixture_logic` / `Plogic`, see
|
||
`Docker/Devices.xml`). To see a transition, make `send_conditions()` in `Docker/adapter.py` alternate
|
||
`Xtravel|fault|1001|2|HIGH|X overtravel` with `Xtravel|normal||||` every few ticks, sync + restart the
|
||
adapter, author a raw tag `{"address":"fixture_x_travel","alarm":{"alarmType":"AlarmCondition"}}` on an
|
||
MTConnect driver, deploy, and subscribe events on its raw device folder (Client CLI `alarms`): expect a
|
||
Fault event (severity 800, message `X overtravel [nativeCode=1001, nativeSeverity=2, qualifier=HIGH]`),
|
||
then a return-to-normal event, repeating.
|
||
|
||
## Further reading
|
||
|
||
- [`docs/plans/2026-07-15-mtconnect-driver-design.md`](../plans/2026-07-15-mtconnect-driver-design.md) — full design: capability wiring, browse reconciliation, typed-editor spec, resilience/timeout rules, phasing
|
||
- [`docs/plans/2026-07-24-mtconnect-driver.md`](../plans/2026-07-24-mtconnect-driver.md) — the executable implementation plan and the task-by-task build record, including where it diverged from the design (TrakHound → hand-rolled)
|
||
- [`docs/plans/2026-07-24-driver-expansion-tracking.md`](../plans/2026-07-24-driver-expansion-tracking.md) — Wave-2 program tracking
|
||
- [Docker fixture README](../../tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md) — the authoritative fixture recipe (seeded device model, endpoint, Mac AirPlay port-5000 gotcha)
|