docs: MTConnect write-back recorded; stale MQTT write-through/browse-gap records reconciled

This commit is contained in:
Joseph Doherty
2026-08-05 06:55:14 -04:00
parent b833dda673
commit 203000a80a
5 changed files with 270 additions and 62 deletions
+59 -12
View File
@@ -123,14 +123,21 @@ reflection in `tests/Core/…Core.Abstractions.Tests/DriverTypeNamesGuardTests.c
| Driver | Verdict | Merged | Capabilities | Notes |
|---|---|---|---|---|
| **MTConnect** | Complete-with-gaps | `90bdaa44` (#506) | `IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable` (`MTConnectDriver.cs:63`) | No `IWritable` **by design** (`:50`). No device form. |
| **MQTT / Sparkplug B** | **Complete** | `c3a2b0f7` | `+ IRediscoverable, IAsyncDisposable` (`MqttDriver.cs:65-66`) | Cleanest of the five: picker + driver form + device form + tag editor all present. 8 open follow-up issues. |
| **MTConnect** | Complete-with-gaps | `90bdaa44` (#506) | `IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable` (`MTConnectDriver.cs:63`) | ~~No `IWritable` **by design** (`:50`).~~ ✅ **`IWritable` added 2026-08-05 (`b29623cf`)** — observation injection via the Agent's `AllowPut` surface, off by default at two gates (`Write.Enabled` + the Agent's own `AllowPut`); MTConnect *Interfaces* stays deferred. No device form. |
| **MQTT / Sparkplug B** | **Complete** | `c3a2b0f7` | `+ IRediscoverable, IAsyncDisposable` (`MqttDriver.cs:65-66`) — **and `IWritable` since `72be1922`** | Cleanest of the five: picker + driver form + device form + tag editor all present. ~~8 open follow-up issues.~~ ✅ All eight (#508–#515) **closed 2026-07-31**. |
| **Sql** | Complete-with-gaps | `4ad54037`, follow-ups `28c28667` | `IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe` (`SqlDriver.cs:28-29`) | No device form; no browse-commit branch; stale comments. |
| **Modbus RTU-over-TCP** | **Complete** | `0f38f486` (#495) | transport mode inside Modbus (`ModbusTransportMode.cs`, `ModbusRtuOverTcpTransport.cs`) | Parses transport **by name** (`ModbusDriverFactoryExtensions.cs:77-78`) — dodges the systemic numeric-enum authoring bug. |
| **Calculation** | **Partial (authoring gap)** | pre-existing | `IDriver, IDependencyConsumer, ISubscribable, IReadable` (`CalculationDriver.cs:38`) | **Registered + offered in the picker, but has no config form.** See §1.2. |
`IWritable` is absent from MTConnect, MQTT, Sql and Calculation. In all four this is **structural and
documented**, not a stub (`MTConnectDriver.cs:50`, `MqttDriver.cs:483`, `SqlDriver.cs:15`).
~~`IWritable` is absent from MTConnect, MQTT, Sql and Calculation. In all four this is **structural and
documented**, not a stub (`MTConnectDriver.cs:50`, `MqttDriver.cs:483`, `SqlDriver.cs:15`).~~
✅ **Two of the four gained it since the audit. As of 2026-08-05 `IWritable` is absent from Sql and
Calculation only** — and in both that absence is still structural and documented (`SqlDriver.cs:15`).
**MQTT** gained it 2026-07-31 (`72be1922`,
[#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508), live-gated — plain `writeTopic`
publish + Sparkplug NCMD/DCMD); **MTConnect** gained it 2026-08-05 (`b29623cf` — observation injection
through the Agent's `AllowPut` surface, off by default at two independent gates).
**Zero `NotImplementedException`** across all five drivers. Every `NotSupportedException` is either a
`catch` filter or a deliberate domain error.
@@ -195,14 +202,14 @@ incomplete), 2 CANNOT VERIFY (correctly filed as hardware/fixture-gated).
| **519** | No Bad/Uncertain **sub-code** reaches a client | **CONFIRMED** | Collapse at `DriverInstanceActor.cs:1033-1041` (`statusCode >> 30`), re-expand at `OtOpcUaNodeManager.cs:3318-3322`. Enum is 3-state at `IOpcUaAddressSpaceSink.cs:165`. **Wider than filed** — `StatusFromQuality` also used at `:429/:506/:576/:761`, so alarm-condition Quality collapses too. | Design consequence |
| **517** | Health published AFTER teardown | **CONFIRMED, fleet-wide** | `ModbusDriver.cs:244-250`, `MTConnectDriver.cs:579-590`, `S7Driver.cs:292-326` — all `Teardown…` then `WriteHealth(Unknown)`; `GetHealth()` is a plain field read | Defect (race) |
| **516** ✅ | Driver config edits silently discarded — **RESOLVED `d32d89c3` + `5184a2e1`: seam respawn + in-place re-parse; all 12 drivers now honour a changed config. Stale-open on tracker.** | **CONFIRMED — survey now complete** | `DriverInstanceActor.cs:316` (only `_driver` assignment) + `DriverHostActor.cs:2565` (`Tell` existing child, no respawn) + `:653/:660` (green seal). **Affected: Modbus, FOCAS, OpcUaClient, + AbLegacy (issue missed it), + Sql (deliberate/documented).** AbCip/TwinCAT re-parse ✅; Galaxy fails **loud** (throws, `GalaxyDriver.cs:600-641`) | Defect |
| **515** | Sparkplug `DataSet`/`Template`/`PropertySet` | **CONFIRMED** | `SparkplugCodec.cs:207-210` decode to `Unsupported` — visible refusal, not silent drop. *(`PropertySet` isn't a `ValueOneofCase`; it rides `metric.Properties`)* | Deferred feature |
| **514** | Rebirth unarmable on a plant that hasn't birthed | **CONFIRMED** | `RawBrowseModal.razor:119-120` disabled; `_rebirthTarget` set only by tree-click handlers (`:432`, `:437`, `:439-451`). Empty tree ⇒ permanently disabled | Defect (chicken-and-egg) |
| **513** | Meaningless `payloadFormat` in Sparkplug blob | **CONFIRMED** | `MqttTagConfigModel.cs:210` unconditional `Set`, vs. mode-aware `:216` | Defect (cosmetic) |
| **512** | `.Browser` → `.Driver` layering | **CONFIRMED** | `…Mqtt.Browser.csproj:43` (**not :44**), boxed "KNOWN, DELIBERATE EXCEPTION" at `:16-42` | Deferred refactor |
| **511** | `ActAsPrimaryHost` does nothing | **CONFIRMED** | Option at `MqttDriverOptions.cs:193`, round-tripped in the UI, handled only by a warning at `MqttDriver.cs:933-948`. No STATE publish, no Last Will | Deferred feature |
| **510** | `_canRebirth` captured at browse-open | **CONFIRMED** | `RawBrowseModal.razor:377` sole `true` assignment; failure path `:516-539` never re-evaluates | Defect (stale affordance) |
| **509** | Metric name with `/` unbrowse-committable | **CONFIRMED** | `RawBrowseCommitMapper.cs:63` uses browse name verbatim → `RawPaths.cs:39` rejects `/` → `RawTreeService.cs:976` **all-or-nothing** kills the whole batch | Defect |
| **508** | MQTT write-through | **CONFIRMED** | `MqttDriver.cs:66` — no `IWritable`; consequence at `DriverInstanceActor.cs:673-677` | Deferred feature |
| **515** ✅ | Sparkplug `DataSet`/`Template`/`PropertySet` — **RESOLVED `c74ccc55`: structured metrics flattened to sibling tags; live-gated.** | **CONFIRMED** | `SparkplugCodec.cs:207-210` decode to `Unsupported` — visible refusal, not silent drop. *(`PropertySet` isn't a `ValueOneofCase`; it rides `metric.Properties`)* | Deferred feature |
| **514** ✅ | Rebirth unarmable on a plant that hasn't birthed — **RESOLVED `7f53bd96`: the browse modal takes a typed `group/edgeNode` scope when no tree scope is armed (edge node only, never a group broadcast).** | **CONFIRMED** | `RawBrowseModal.razor:119-120` disabled; `_rebirthTarget` set only by tree-click handlers (`:432`, `:437`, `:439-451`). Empty tree ⇒ permanently disabled | Defect (chicken-and-egg) |
| **513** ✅ | Meaningless `payloadFormat` in Sparkplug blob — **RESOLVED `7f53bd96`.** | **CONFIRMED** | `MqttTagConfigModel.cs:210` unconditional `Set`, vs. mode-aware `:216` | Defect (cosmetic) |
| **512** ✅ | `.Browser` → `.Driver` layering — **RESOLVED `7f53bd96`.** | **CONFIRMED** | `…Mqtt.Browser.csproj:43` (**not :44**), boxed "KNOWN, DELIBERATE EXCEPTION" at `:16-42` | Deferred refactor |
| **511** ✅ | `ActAsPrimaryHost` does nothing — **RESOLVED `7f2108dc`: Sparkplug primary-host STATE publishing + Will, Primary-gated; live-gated.** | **CONFIRMED** | Option at `MqttDriverOptions.cs:193`, round-tripped in the UI, handled only by a warning at `MqttDriver.cs:933-948`. No STATE publish, no Last Will | Deferred feature |
| **510** ✅ | `_canRebirth` captured at browse-open — **RESOLVED `7f53bd96`.** | **CONFIRMED** | `RawBrowseModal.razor:377` sole `true` assignment; failure path `:516-539` never re-evaluates | Defect (stale affordance) |
| **509** ✅ | Metric name with `/` unbrowse-committable — **RESOLVED `7f53bd96`: `RawBrowseCommitMapper.ToRawSegment` sanitises the segment and refuses a collision in words.** | **CONFIRMED** | `RawBrowseCommitMapper.cs:63` uses browse name verbatim → `RawPaths.cs:39` rejects `/` → `RawTreeService.cs:976` **all-or-nothing** kills the whole batch | Defect |
| **508** ✅ | MQTT write-through — **RESOLVED `72be1922`: `IWritable` shipped (plain `writeTopic` publish + Sparkplug NCMD/DCMD), live-gated 2026-07-31. The evidence column is the audit snapshot; `MqttDriver.cs:66` now names `IWritable`.** | **CONFIRMED** | `MqttDriver.cs:66` — no `IWritable`; consequence at `DriverInstanceActor.cs:673-677` | Deferred feature |
| **507**→ see above | | | | |
| **491** | Continuous-historization live gate | **CANNOT VERIFY** (needs VPN'd gateway) | Code **is** complete: `AddressSpaceApplier.cs:238`→`:540`→`ActorHistorizedTagSubscriptionSink.cs:27-38`→`ContinuousHistorizationRecorder.cs:84`, wired `ServiceCollectionExtensions.cs:435` | Deferred test |
| **489** | Hot-rebind renamed raw tag | **CONFIRMED** | No implementation exists | Deferred feature |
@@ -1250,6 +1257,46 @@ drivers), 3 carried-open archreview findings led by 01/S-1 (`AddressSpaceApplier
---
### 2026-08-05 — MTConnect write-back; the MQTT closure records reconciled
Recorded from branch `feature/mqtt-mtconnect-write`. This is exactly the drift shape the 2026-08-04
entry named — **work that closes an item elsewhere leaves the register asserting the item is open** —
so it is written back here in the same change as the work.
**§1.1 corrected on two rows.** `IWritable` is no longer absent from four drivers; **after this branch
it is absent from Sql and Calculation only**, and in both that is still structural.
| Driver | Gained `IWritable` | Commit | Shape |
|---|---|---|---|
| **MQTT / Sparkplug B** | 2026-07-31 ([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508), live-gated) | `72be1922` | Plain `writeTopic` publish + Sparkplug NCMD/DCMD |
| **MTConnect** | 2026-08-05 | `b29623cf` | **Observation injection** through the Agent's optional `AllowPut` surface — off by default at two independent gates (`Write.Enabled` and the Agent's own `AllowPut`), CONDITION items refused |
⚠️ **MTConnect write-back is not a machine command, and the register should not be read as saying the
driver can now command a CNC.** A write sets what the Agent *reports* for a DataItem; the next
adapter/SHDR update overwrites it. MTConnect *Interfaces* — the mechanism the original deferral
(`2026-07-15-mtconnect-driver-design.md` §3.6) assumed write-back would need — **remains deferred**, and
§3.6 now carries a superseded note saying so. Design:
`docs/plans/2026-08-05-mtconnect-writeback-design.md`.
⚠️ **It also does not make any existing node writable.** The OPC UA `CurrentWrite` bit is composed from
the authored `Tag.AccessLevel` alone; driver capability is never consulted at compose time. An operator
must author the raw tag **ReadWrite**. Browse's new `Operate` stamp on writable leaves is honest
labelling only — nothing persists `SecurityClass`.
**§2's eight MQTT rows (#508–#515) marked ✅ in place** with the commit that resolved each (`72be1922`,
`7f53bd96`, `7f2108dc`, `c74ccc55`), following the register's existing convention. Their evidence
columns are left as the audit snapshot — #508's `MqttDriver.cs:66 — no IWritable` in particular is now
false of the tree, and is annotated as such rather than rewritten.
**Docs reconciled in the same change** (they contradicted the code, and each other):
`docs/drivers/MTConnect.md` (capability surface, new §Write-back, Deferred, the `DeviceName`
double-duty note), `docs/drivers/Mqtt.md` (deleted the #514 "empty tree cannot arm a rebirth" known-gap
block and the "Read-only. No writes reach the broker" operational note — both contradicted by that
file's own closure line), and `docs/plans/2026-07-24-driver-expansion-tracking.md` (P2 gap bullets +
§Next actions, which still listed MQTT P3 and both browse gaps as remaining work).
---
---
*§1–§8 generated 2026-07-27 against `master` @ `90bdaa44` by five parallel source-verification agents.
+120 -15
View File
@@ -15,7 +15,7 @@ fixture recipe read
## What it talks to
A **MTConnect Agent** — the vendor-neutral read-only telemetry endpoint that
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:
@@ -25,8 +25,13 @@ three standard REST paths:
- `/sample` — a `multipart/x-mixed-replace` long-poll stream of observation
deltas, keyed by a monotonic sequence number
v1 is **agent-first and read-only** — Discover + Read + Subscribe, no Write —
because the mainstream Agent surface has no "set value" operation.
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
@@ -68,18 +73,22 @@ below).
## Capability surface
`MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable`
`MTConnectDriver : IDriver, IReadable, IWritable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable`
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`).
Deliberately **not** `IWritable` — the Agent surface is read-only by design.
Write-back exists only via optional, rarely-deployed MTConnect *Interfaces*
(a request/response handshake, not a "set value" operation), and is out of
scope for this build — see [Deferred](#deferred-not-in-this-build).
`IWritable` landed 2026-08-05 (`b29623cf`), against the Agent's `AllowPut`
surface rather than MTConnect *Interfaces*. It is **observation injection, not
machine command**, and is off at two independent gates by default — read
[Write-back](#write-back--observation-injection-off-by-default) before
authoring a writable MTConnect tag. *Interfaces* itself — the protocol's own
request/response handshake — is a different mechanism and remains deferred; see
[Deferred](#deferred-not-in-this-build).
| Capability | Path | Notes |
|------------|------|-------|
| `ITagDiscovery` | `DiscoverAsync` — streams `/probe`'s Device→Component→DataItem tree into the address-space builder | `SupportsOnlineDiscovery = true`, `RediscoverPolicy = Once` |
| `IReadable` | `ReadAsync` → one `/current` per call | **Not the production data path** — see below |
| `IWritable` | `WriteAsync` → `POST {agent}/{device}?{dataItemId}={value}` on the Agent's `AllowPut` surface | **Off by default** — needs `Write.Enabled` **and** the Agent's `AllowPut`; CONDITION items are refused |
| `ISubscribable` | `SubscribeAsync`/`OnDataChange` — the shared `/sample` long-poll pump | **The production data path** |
| `IHostConnectivityProbe` | periodic `/probe` under `Probe.*` | No consumer wires it today — see [Known limitations](#known-limitations) |
| `IRediscoverable` | watches the Agent's `Header.instanceId` | No consumer wires it today — see [Known limitations](#known-limitations) |
@@ -105,6 +114,7 @@ correct and tested, just not on the hot path.
"HeartbeatMs": 10000,
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000 },
"Reconnect": { "MinBackoffMs": 0, "MaxBackoffMs": 30000, "BackoffMultiplier": 2.0 },
"Write": { "Enabled": false },
"Tags": []
}
}
@@ -123,13 +133,14 @@ Read off `MTConnectDriverOptions` / `MTConnectDriverConfigDto`
| Key | Default | Notes |
|---|---|---|
| `AgentUri` | — (required) | Agent base URI; the driver appends `/probe`, `/current`, `/sample` |
| `DeviceName` | `null` | Scopes requests to `{AgentUri}/{DeviceName}/...`; `null` = whole Agent |
| `DeviceName` | `null` | **Double duty.** (1) Scopes requests to `{AgentUri}/{DeviceName}/...`; `null` = whole Agent. (2) The write path's fallback device — the device a write is addressed to when the probe model does not say which device declared the DataItem (see [Write-back](#write-back--observation-injection-off-by-default)) |
| `RequestTimeoutMs` | `5000` | Per-call deadline for `/probe` and `/current` |
| `SampleIntervalMs` | `1000` | The Agent's `/sample?interval=` query param |
| `SampleCount` | `1000` | The Agent's `/sample?count=` query param |
| `HeartbeatMs` | `10000` | The Agent's `/sample?heartbeat=` query param — also the watchdog's liveness window |
| `Probe.Enabled` / `Probe.IntervalMs` / `Probe.TimeoutMs` | `true` / `5000` / `2000` | Background connectivity-probe knobs (mirrors `ModbusProbeOptions`) |
| `Reconnect.MinBackoffMs` / `MaxBackoffMs` / `BackoffMultiplier` | `0` / `30000` / `2.0` | Geometric backoff after a failed request or a dropped `/sample` stream |
| `Write.Enabled` | `false` | The driver-side half of the write-back opt-in. An absent block means disabled — see [Write-back](#write-back--observation-injection-off-by-default) |
| `Tags[]` | `[]` | Pre-v3 / CLI authoring surface — one `MTConnectTagDefinition` per DataItem `id` |
| `RawTags[]` | `[]` (deploy-injected) | The v3 data-plane binding — see below |
@@ -224,6 +235,84 @@ the two need disambiguating, not just OR-ing together). On a changed
`/hosts` page — see [Known limitations](#known-limitations) for what that does
and does not do.
## Write-back — observation injection, off by default
> ⚠️ **A write sets what the Agent *reports*, not what the machine does.** The value
> lands in the Agent's observation buffer exactly as if an adapter had reported it —
> visible to **every** consumer of that Agent, and **overwritten by the next
> adapter/SHDR update for that DataItem**. Nothing reaches the CNC. Never document,
> script, or operate this as a machine command. It is genuinely useful for
> operator-set EVENT values, for external systems feeding DataItems the adapter does
> not own, and for test rigs — and nothing else.
The mechanism is the Agent's **`AllowPut`** surface (`POST
{agent}/{device}?{dataItemId}={value}`), a standard cppagent feature that is **off by
default**. It is not MTConnect *Interfaces* — see [Deferred](#deferred-not-in-this-build)
for why Interfaces was rejected as the write-back shape. Design record:
[`2026-08-05-mtconnect-writeback-design.md`](../plans/2026-08-05-mtconnect-writeback-design.md)
§2.2; implementation `b29623cf`.
### Two independent gates, both off by default
1. **The Agent** — `AllowPut = true` in `agent.cfg`. Optionally scope it with
`AllowPutFrom = <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).
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
@@ -272,6 +361,13 @@ for anything beyond values-and-conditions.
driver instance both resolve to the same one Agent connection. Real
per-device routing (a client per device) would be a design change, not a
bug fix.
**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. **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
@@ -294,11 +390,17 @@ for anything beyond values-and-conditions.
## Deferred (not in this build)
- **Write-back (MTConnect Interfaces).** Design
[§3.6](../plans/2026-07-15-mtconnect-driver-design.md#36-iwritable--not-implemented-v1):
the mainstream Agent surface is read-only by design; Interfaces is a rare,
optional request/response handshake that would mislead if modelled as
`IWritable`. Revisit only on a concrete deployment need.
- **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 OPC UA Part 9 alarms via `IAlarmSource` (the Galaxy
@@ -317,7 +419,10 @@ for anything beyond values-and-conditions.
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).
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
+5 -10
View File
@@ -521,6 +521,11 @@ and only** browse action that publishes anything.
- **Scope** is the last node clicked in the tree. A device or metric resolves **up to its owning edge
node** (Sparkplug has no device-scoped rebirth); a group fans out to every observed edge node
beneath it and is refused **whole** past 32, publishing nothing.
- **An empty tree can still arm a rebirth** ([#514](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/514),
fixed `7f53bd96`): when no scope is selected the panel offers a `group/edgeNode` input, so a plant
whose edge nodes all birthed before the window opened — the exact case the affordance exists for —
is reachable without a tree click. Deliberately an **edge node**, never a group: a typed group
broadcast is a self-inflicted broker storm on a large plant.
- **Two clicks, never one** — `Request rebirth…` arms a confirm panel naming the resolved scope and
its consequence; changing the selection disarms it.
- **Gated on `DriverOperator`**, enforced server-side in `BrowserSessionService.RequestRebirthAsync`
@@ -547,14 +552,6 @@ in the rebuilt tree.
> affected **every** driver's picker since 2026-05-28; it surfaced here because choosing a rebirth
> scope is the first flow that requires clicking a label rather than the ▶ toggle.
> ⚠️ **Known gap — a completely empty tree cannot arm a rebirth.** The rebirth scope is taken from the
> last node clicked *in the tree*, so on a plant that has not birthed since the window opened there is
> nothing to click and `Request rebirth…` stays disabled — the exact case the affordance exists for.
> `MqttBrowseSession.ResolveRebirthTargets` already accepts a bare `{group}/{edgeNode}` for a node the
> window has never seen (it calls that "the prime rebirth target"); what is missing is a UI path to
> enter one, or to default the scope to the driver's own configured `GroupId`. Until then: use
> **Refresh** after any birth, or author the tags by **Manual entry**, which is always available.
## Test Connect
`MqttDriverProbe` performs **one CONNECT** (no SUBSCRIBE, no publish) under a `-probe-` client id and
@@ -583,8 +580,6 @@ identical words. See [TestConnectProbes.md](TestConnectProbes.md).
## Operational Notes
- **Read-only.** No writes reach the broker; every node is ViewOnly. Sparkplug NCMD is used for
**rebirth requests only** — never to command a device.
- **No alarms, no driver-side history.** Historization is a server-side concern (`isHistorized`).
- **Both pair nodes subscribe independently** — MQTT fan-out is per-connection, and the Primary gate
applies downstream, not to ingest. This is also why `clientId` must stay unset (above).
@@ -114,6 +114,21 @@ Pure function in `.Contracts` (shared by driver, browser-commit, and the typed e
### 3.6 `IWritable` — **not implemented (v1)**
> **SUPERSEDED 2026-08-05 — write-back shipped, by a different mechanism.**
> `MTConnectDriver` now implements `IWritable` (`b29623cf`) over the Agent's optional
> **`AllowPut`** surface (`POST {agent}/{device}?{dataItemId}={value}`), gated off by
> default at both the Agent (`AllowPut`) and the driver (`Write.Enabled`). That is
> **observation injection into the Agent's buffer, not machine command** — see
> [`2026-08-05-mtconnect-writeback-design.md`](2026-08-05-mtconnect-writeback-design.md)
> §2.2 and `docs/drivers/MTConnect.md` §Write-back.
> **The paragraph below is retained because its rationale still holds for *Interfaces*,
> which remains deferred** (design §2.1 of the write-back doc re-affirms it: rarely
> deployed, needs adapter-side cooperation, a state machine rather than a value write).
> Only its two incidental claims about this build are out of date — the driver is no
> longer `IWritable`-less, and nodes no longer materialize without `CurrentWrite` *because
> of a missing capability*; the `CurrentWrite` bit was in fact always composed from the
> authored `Tag.AccessLevel` alone, never from driver capability.
Justified per research §2.1: the MTConnect Agent surface is read-only by design; write-back exists only via optional, rarely-deployed MTConnect *Interfaces* (a request/response state-machine handshake, not a "set value" — modelling it as `IWritable` would mislead). Capability interfaces are composable, so omitting `IWritable` is idiomatic (Galaxy's write path is even fire-and-forget). Nodes materialize **without** the `AccessLevels.CurrentWrite` bit automatically. Revisit Interfaces only if a concrete deployment needs it.
### 3.7 `IHostConnectivityProbe` + `IRediscoverable`
@@ -179,7 +179,8 @@ Both CI-simulatable on the shared docker host, no hardware.
surface, data-plane precedence rules, quality-code mapping, and (most load-bearing) the **Known
limitations** section.
- **Scope shipped:** `IDriver`+`ITagDiscovery`+`IReadable`+`ISubscribable`+`IHostConnectivityProbe`+`IRediscoverable`
(deliberately no `IWritable` — read-only Agent surface), browse **free via the Wave-0 universal
(P1 shipped deliberately without `IWritable`; **`IWritable` added 2026-08-05, `b29623cf`** — see the
Deferred line below), browse **free via the Wave-0 universal
browser** (`SupportsOnlineDiscovery=true`, no browser project), typed AdminUI tag editor,
`UNAVAILABLE→BadNoCommunication` mapping, CONDITION-as-`String`, ring-buffer re-baseline paging
(both the sequence-gap and `OUT_OF_RANGE` legs).
@@ -191,7 +192,18 @@ Both CI-simulatable on the shared docker host, no hardware.
- **Test results:** MTConnect unit suite 491/491; integration suite 12/12 against a real Agent
(`mtconnect/agent:2.7.0.12`, **not** `mtconnect/cppagent` — that Docker Hub repo doesn't exist);
skips cleanly (12 Skipped) offline. AdminUI 749/749; full solution 0 build errors.
- **Deferred (documented, not built):** write-back via MTConnect Interfaces (design §3.6); P1.5
- **Write-back — SHIPPED 2026-08-05, not via Interfaces** (`b29623cf`, branch
`feature/mqtt-mtconnect-write`; design
[`2026-08-05-mtconnect-writeback-design.md`](2026-08-05-mtconnect-writeback-design.md)).
`IWritable` over the Agent's optional **`AllowPut`** surface
(`POST {agent}/{device}?{dataItemId}={value}`) — **observation injection into the Agent's
buffer, never a machine command**, gated off by default at both the Agent (`AllowPut`) and the
driver (`Write.Enabled`), CONDITION items refused. **MTConnect *Interfaces* remains deferred**
— design §3.6's rationale still holds for it (rare, adapter-side cooperation, a state machine
rather than a value write), and it is a different mechanism, not this one. Caveat recorded in
the driver guide: the OPC UA `CurrentWrite` bit still comes from the authored
`Tag.AccessLevel` alone, so an operator must author **ReadWrite**.
- **Deferred (documented, not built):** MTConnect *Interfaces* (design §3.6, above); P1.5
CONDITION→native Part-9 alarms, `TIME_SERIES` array materialization, EVENT→enum vocab (design §9);
P2 SHDR adapter ingest (design §9). Also two pre-existing fleet-wide gaps this build surfaced
rather than caused: `IRediscoverable`/`IHostConnectivityProbe` have no server-side consumer (eight
@@ -210,7 +222,11 @@ Both CI-simulatable on the shared docker host, no hardware.
`OnDataChange`, retained last-value read, `#`-observation browser); P2 Sparkplug B ingest
(vendored Tahu proto + `Grpc.Tools` codegen, birth/alias/seq-gap/rebirth state machine,
death→STALE, birth-driven browse tree, scoped Request-rebirth NCMD, typed tag editor).
**Write-through (`IWritable`) is deferred to P3** — every MQTT node materializes read-only.
**P3 write-through (`IWritable`) shipped 2026-07-31** — `72be1922`
([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508), live-gated): plain
`writeTopic` publish + Sparkplug NCMD/DCMD, with a node discovered `ViewOnly` when the encoder
would refuse the write. Superseded the "every MQTT node materializes read-only" line this entry
carried; see `docs/drivers/Mqtt.md` §Write-through.
- **Fixture:** Mosquitto TLS+auth broker + JSON publisher sidecar, **live** at `10.100.0.35:8883`
(`:1883` plaintext-but-authenticated); stack `/opt/otopcua-mqtt`, compose in
`tests/Drivers/…​.Driver.Mqtt.IntegrationTests/Docker/`. Env-gated live suite
@@ -236,13 +252,31 @@ Both CI-simulatable on the shared docker host, no hardware.
Sparkplug keys, merge-not-replace on save, group-id segment validation) + 8 falsifiable
`MqttDriverFormModelTests` cases. **Three gates, three instances of the same defect class: a
hand-maintained AdminUI surface left behind by a driver-side feature.**
- **P2 live gate — two open UI gaps, documented not fixed** (see `docs/drivers/Mqtt.md` §Known gaps):
- **A completely empty browse tree cannot arm a rebirth.** The scope comes from a tree click, so on
a plant that has not birthed since the window opened `Request rebirth…` stays disabled — the very
case the affordance exists for. `MqttBrowseSession.ResolveRebirthTargets` already accepts a bare
`{group}/{edgeNode}` ("the prime rebirth target"); only a UI path to enter one is missing.
Mitigation shipped: a **Refresh** button on the browse tree (it re-reads the same session, which
previously rendered exactly once at open and never again).
- **P2 live gate — four UI gaps recorded, ALL FOUR NOW CLOSED 2026-07-31** (`7f53bd96`; see
`docs/drivers/Mqtt.md` §Known gaps, which lists the closures). Recorded as found, with what
actually fixed each:
- **A completely empty browse tree cannot arm a rebirth**
([#514](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/514)). The scope comes from a tree
click, so on a plant that has not birthed since the window opened `Request rebirth…` stays
disabled — the very case the affordance exists for. `MqttBrowseSession.ResolveRebirthTargets`
already accepts a bare `{group}/{edgeNode}` ("the prime rebirth target"); only a UI path to
enter one was missing. Mitigation shipped at the time: a **Refresh** button on the browse tree
(it re-reads the same session, which previously rendered exactly once at open and never again).
**FIXED —** `RawBrowseModal` now offers a `group/edgeNode` scope input whenever no tree scope is
armed; edge-node only, never a typed group broadcast.
- **A metric name containing `/` cannot be browse-committed**
([#509](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/509)). The derived raw **tag Name**
is a RawPath segment and may not contain `/`, while `metricName` legitimately may — so the
spec-mandatory `Node Control/Rebirth` was refused at commit (`Row N: Name must not contain '/'`).
The refusal was loud and all-or-nothing, never a silently mis-bound tag, and **Manual entry** was
the working path. Fixing it needed a name-sanitisation policy with a collision answer, so it was
recorded rather than guessed at. **FIXED —** `RawBrowseCommitMapper.ToRawSegment` sanitises the
segment and refuses a collision explicitly.
- **`_canRebirth` is captured at browse-open**, so a reaped session still drew the button
([#510](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/510)). **FIXED.**
- **The tag editor injects the Plain-only `payloadFormat` key into a Sparkplug blob**
([#513](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/513)) — cosmetic; the factory
routes on the Sparkplug tuple and ignored it. **FIXED.**
- **P2 live gate also found a PRE-EXISTING, cross-driver AdminUI defect — FIXED:** every node label in
the shared `DriverBrowseTree` was an `<a href="#">`. In a Blazor Web App, `blazor.web.js`'s
enhanced-navigation click interceptor resolves a bare `#` against `<base href="/">`, so **clicking
@@ -252,19 +286,14 @@ Both CI-simulatable on the shared docker host, no hardware.
now `<button type="button">`. It dates to the component's introduction (2026-05-28) and affects
**every** driver's picker; it only surfaced now because Sparkplug's Request-rebirth is the first
feature that requires clicking a *label* rather than the ▶ toggle (always a `<button>`, always fine).
- **A metric name containing `/` cannot be browse-committed.** The derived raw **tag Name** is a
RawPath segment and may not contain `/`, while `metricName` legitimately may — so the
spec-mandatory `Node Control/Rebirth` is refused at commit (`Row N: Name must not contain '/'`).
The refusal is loud and all-or-nothing, never a silently mis-bound tag, and **Manual entry** is
the working path (the Sparkplug tag editor accepts a slashed metric name). Fixing it needs a
name-sanitisation policy with a collision answer, so it was recorded rather than guessed at.
- `_canRebirth` is captured at browse-open, so a reaped session still draws the button.
- The tag editor injects the Plain-only `payloadFormat` key into a Sparkplug blob — cosmetic; the
factory routes on the Sparkplug tuple and ignores it.
- **Rediscovery is inert in v3 (platform gap, not MQTT's).** The Sparkplug driver raises
`OnRediscoveryNeeded` on a changed birth metric-set, but **nothing subscribes to it** and
`DriverHostActor.HandleDiscoveredNodes` hard-returns. A DBIRTH introducing a metric does **not**
change the OPC UA tree — redeploy. Driver-side behaviour is log-observable only.
- **Rediscovery was inert in v3 (platform gap, not MQTT's) — HALF-CLOSED since 2026-07-27.** The
Sparkplug driver raises `OnRediscoveryNeeded` on a changed birth metric-set; at the time nothing
subscribed to it and `DriverHostActor.HandleDiscoveredNodes` hard-returned. `DriverInstanceActor`
now **consumes** the raise (`09a401b8`) and it rides the driver-health snapshot to the AdminUI
`/hosts` page as a **re-browse chip**, while the injection path was **deleted** rather than
revived (#507, `adce27e7` — its two inputs are structurally empty in v3). So the standing part is
unchanged and deliberate: a DBIRTH introducing a metric still does **not** change the served OPC
UA tree; an operator re-browses and commits through `/raw`.
- **Redundant-pair hazard (documented, not a code change):** both nodes of a pair run the driver, so a
**fixed `clientId` makes them evict each other forever** — the broker logs
`already connected, closing old connection` and both nodes reconnect every ~2 s while still
@@ -285,7 +314,24 @@ Both CI-simulatable on the shared docker host, no hardware.
`0f38f486` / #495) and **SQL poll** (merged `4ad54037`; follow-ups #496/#497/#498 closed in
`28c28667`). Remaining SQL work is optional follow-on — Postgres/ODBC dialects behind
`ISqlDialect`, and `SqlTagModel.Query` (P3, currently rejected end-to-end by design).
3. **Wave 2 is done.** Both deliverables shipped and live-gated: **MQTT / Sparkplug B** (P1 + P2) and **MTConnect Agent** (P1 Agent MVP, PR #506). Remaining work on both is optional follow-on — MQTT P3 write-through (`IWritable`: NCMD/DCMD + plain publish) plus its two browse-UI
gaps, and MTConnect write-back (deliberately deferred; the Agent surface is read-only).
3. **Wave 2 is done, and so is the follow-on this entry used to list.** Both deliverables shipped and
live-gated: **MQTT / Sparkplug B** (P1 + P2) and **MTConnect Agent** (P1 Agent MVP, PR #506).
Since then:
- **MQTT P3 write-through shipped and was live-gated 2026-07-31** — `72be1922`
([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508)): plain `writeTopic` publish
+ Sparkplug NCMD/DCMD.
- **Both browse-UI gaps are closed**, with the other two P2 records, in `7f53bd96`
([#509](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/509),
[#510](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/510),
[#513](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/513),
[#514](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/514)) — see the P2 section above.
- **MTConnect write-back is landing on `feature/mqtt-mtconnect-write`** (`b29623cf`), by
**observation injection** through the Agent's `AllowPut` surface rather than the Interfaces
handshake the original deferral assumed — design
[`2026-08-05-mtconnect-writeback-design.md`](2026-08-05-mtconnect-writeback-design.md), which
is also the record for this reconciliation. MTConnect *Interfaces* stays deferred.
Remaining optional follow-on for the pair: MTConnect P1.5 (CONDITION→Part-9 alarms,
`TIME_SERIES` arrays, EVENT→enum vocab) and P2 SHDR ingest.
Update this file's Summary table and per-wave status whenever a deliverable changes state.