From 203000a80aca269072bfae443fcc247c0a9cb8cf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 5 Aug 2026 06:55:14 -0400 Subject: [PATCH] docs: MTConnect write-back recorded; stale MQTT write-through/browse-gap records reconciled --- deferment.md | 71 +++++++-- docs/drivers/MTConnect.md | 135 ++++++++++++++++-- docs/drivers/Mqtt.md | 15 +- .../2026-07-15-mtconnect-driver-design.md | 15 ++ .../2026-07-24-driver-expansion-tracking.md | 96 +++++++++---- 5 files changed, 270 insertions(+), 62 deletions(-) diff --git a/deferment.md b/deferment.md index d0499579..7d9a8bd7 100644 --- a/deferment.md +++ b/deferment.md @@ -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. diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md index e694b5eb..4756c00a 100644 --- a/docs/drivers/MTConnect.md +++ b/docs/drivers/MTConnect.md @@ -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 = ` 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 diff --git a/docs/drivers/Mqtt.md b/docs/drivers/Mqtt.md index c01aeb83..1b08ba9c 100644 --- a/docs/drivers/Mqtt.md +++ b/docs/drivers/Mqtt.md @@ -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). diff --git a/docs/plans/2026-07-15-mtconnect-driver-design.md b/docs/plans/2026-07-15-mtconnect-driver-design.md index f7c0e5ae..e993f6b2 100644 --- a/docs/plans/2026-07-15-mtconnect-driver-design.md +++ b/docs/plans/2026-07-15-mtconnect-driver-design.md @@ -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` diff --git a/docs/plans/2026-07-24-driver-expansion-tracking.md b/docs/plans/2026-07-24-driver-expansion-tracking.md index f9df628f..f15a8ac4 100644 --- a/docs/plans/2026-07-24-driver-expansion-tracking.md +++ b/docs/plans/2026-07-24-driver-expansion-tracking.md @@ -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 ``. In a Blazor Web App, `blazor.web.js`'s enhanced-navigation click interceptor resolves a bare `#` against ``, so **clicking @@ -252,19 +286,14 @@ Both CI-simulatable on the shared docker host, no hardware. now `