merge: MTConnect write-back (IWritable via agent AllowPut observation writes); stale MQTT P3/browse-gap records reconciled

This commit is contained in:
Joseph Doherty
2026-08-05 07:13:34 -04:00
26 changed files with 2685 additions and 117 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.
@@ -0,0 +1,151 @@
# MTConnect write-back + MQTT write-through record reconciliation — design
**Status:** DRAFT 2026-08-05.
## 1. Scope correction: the MQTT half of this request is already shipped
The request ("MQTT P3 write-through plus its two documented browse-UI gaps, MTConnect write-back")
came from `docs/plans/2026-07-24-driver-expansion-tracking.md` §Next actions (lines 288–289) — which
is **stale**. On master today:
| Item | Status | Evidence |
|---|---|---|
| MQTT P3 write-through (`IWritable`: plain `writeTopic` publish + Sparkplug NCMD/DCMD) | **Shipped, live-gated** — #508, `72be1922`, 2026-07-31 | `MqttDriver.cs:65-67` implements `IWritable`; `Mqtt.md:16-20` status banner |
| Browse-UI gap 1 (empty tree cannot arm a rebirth) | **Fixed** — #514, `7f53bd96` | `RawBrowseModal.razor:127-157` manual `{group}/{edgeNode}` scope input |
| Browse-UI gap 2 (slashed metric name refused at browse-commit) | **Fixed** — #509, `7f53bd96` | `RawBrowseCommitMapper.ToRawSegment` (`:107`), collision refusal (`:117`) |
So the only MQTT work left is **documentation reconciliation** — three stale records that now
contradict the code and `Mqtt.md`'s own closure line (`:597-602`):
1. `docs/drivers/Mqtt.md:550-556` — still prints the #514 rebirth gap as an open "Known gap".
2. `docs/drivers/Mqtt.md:586-587` — "Read-only. No writes reach the broker; every node is ViewOnly"
contradicts §Write-through (`:407-480`) in the same file.
3. `docs/plans/2026-07-24-driver-expansion-tracking.md:288-289` §Next actions still lists MQTT P3 +
the two gaps as remaining work; the P2 section (`:239-263`) still describes the gaps as
"documented not fixed".
4. `deferment.md:132-133` — "`IWritable` is absent from MTConnect, MQTT, Sql and Calculation" was
audited pre-#508 and is wrong about MQTT (and, after this branch, about MTConnect).
## 2. MTConnect write-back — the real work
### 2.1 What was deferred, and what mechanism to build on
The driver shipped deliberately read-only (`MTConnectDriver.cs:63` — no `IWritable`;
`docs/plans/2026-07-15-mtconnect-driver-design.md` §3.6). The deferral named **MTConnect
Interfaces** (a request/response state-machine handshake between two agents) as the only write-back
shape and judged it misleading to model as `IWritable` — "revisit only on a concrete deployment
need". This request is that need, but Interfaces is still the wrong mechanism: it is rarely
deployed, requires adapter-side cooperation, and is a state machine rather than a value write.
The mechanism this design uses instead is the **Agent's HTTP PUT/POST surface** — a standard
cppagent feature, off by default, enabled by `AllowPut = true` (optionally scoped by
`AllowPutFrom = <host list>`) in `agent.cfg`. With it enabled, `PUT`/`POST
{agent}/{deviceName}?{dataItemId}={value}` sets the named observation in the agent's buffer exactly
as if an adapter had reported it. The repo's own integration fixture already carries the key —
currently `AllowPut = false` with a "must not accept writes" comment
(`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg:25-26`).
### 2.2 Honest semantics — this is observation injection, not machine command
A PUT write updates **what the agent reports**, not the machine: the value lands in the agent's
observation buffer, is visible to every agent consumer, and the next adapter/SHDR update for that
data item overwrites it. That is genuinely useful (operator-set EVENT values, external systems
feeding data items the adapter does not own, test rigs) but it must never be documented as
commanding the CNC. The docs section this branch writes states this prominently, and the feature is
**opt-in at two independent layers**: the agent (`AllowPut`) and the driver config
(`write.enabled`, default `false`). Either alone leaves today's read-only behaviour untouched.
### 2.3 Driver design
**Config.** `MTConnectDriverConfigDto` (+ typed `MTConnectDriverOptions`) gains a `Write` block:
```json
"write": { "enabled": true }
```
Default absent/`false` — nothing changes for existing deployments. No per-tag key: writability is
derived (below), matching how the driver derives everything else from the probe model.
**Capability.** `MTConnectDriver` adds `IWritable`. The factory already returns the concrete type
(`MTConnectDriverFactoryExtensions.cs:33-39` documents why), so capability dispatch picks it up
with no registry change.
**`WriteAsync` shape** (mirrors `MqttDriver.cs:670-745` — never throws, batch shape preserved,
per-slot codes):
- `write.enabled == false` → every slot `BadNotWritable` (0x803B0000).
- No live client (pre-Initialize / post-Shutdown) → `BadNotConnected`, matching `ReadAsync`'s
convention (`MTConnectDriver.cs:628-695`).
- RawPath does not resolve via `TagBinding.ToDataItemId` → `BadNodeIdUnknown`.
- Data item is CONDITION-category, or the value is null / not representable as an invariant string
→ `BadNotWritable` / `BadTypeMismatch` respectively. (Condition PUTs use a different level|text
wire format and carry alarm semantics — excluded from v1.)
- HTTP call: `POST {agentBase}/{deviceName}?{dataItemId}={value}` via the agent-client seam. The
owning `deviceName` comes from a `dataItemId → deviceName` map built at bind time from the cached
probe model (falling back to `options.DeviceName`); a multi-device agent is addressed correctly
without config.
- Agent refusal (non-2xx, or an `MTConnectError` document in a 200 body) → `BadCommunicationError`
with the agent's own error text logged (never in `LastError` — type-only discipline).
- `OperationCanceledException` fills remaining slots and returns; any other exception → log warning
+ `BadCommunicationError`.
- Values format with `CultureInfo.InvariantCulture`; booleans as the raw invariant string.
**Seam extension.** `IMTConnectAgentClient` gains
`Task<MTConnectWriteOutcome> WriteObservationAsync(string deviceName, string dataItemId, string value, CancellationToken ct)`
(outcome = accepted / agent-error-with-text / transport-error). `MTConnectAgentClient` implements
it over `_http` (the unary client with the bounded timeout) using `HttpMethod.Post`; the stub
`HttpMessageHandler` ctor seam (`MTConnectAgentClient.cs:107`) covers response-shape unit tests
with no socket. The unit-test `CannedAgentClient` records write calls and returns scripted
outcomes.
**Discovery stamping.** `DiscoverAsync` currently stamps every leaf
`SecurityClassification.ViewOnly` (`MTConnectDriver.cs:1040-1042`) with a comment saying that is
the only honest tier for a driver with no `IWritable`. That derivation becomes conditional:
`CanWrite(dataItem)` (= `write.enabled` && category is not CONDITION) → `Operate`, else `ViewOnly`
— the same "a node cannot advertise writability the driver would refuse" principle MQTT pinned
(`MqttDriver.cs:504`, `CanWrite` at `:625-627`). The implementer must verify how materialization
derives the `CurrentWrite` access bit (capability presence + SecurityClass) so a stamped-Operate
node is actually writable over OPC UA, and a ViewOnly node is not.
**Doc comments to update in the same change:** the class remarks
(`MTConnectDriver.cs:46-53` "deliberately no IWritable"), the factory remark
(`MTConnectDriverFactoryExtensions.cs:39`), and `MTConnectTagDefinition.cs:10-14` ("no
corresponding write path").
### 2.4 Out of scope
- **MTConnect Interfaces** — still deferred, same rationale as §3.6; this design does not touch it.
- Asset writes (`POST /asset`), per-tag write opt-outs, write retry (`isIdempotent` stays `false`
at `DriverInstanceActor.cs:776`), and PUT `time=` timestamps — YAGNI.
- Any AdminUI surface — the driver form gains nothing; `write.enabled` is authored in the existing
driver-config JSON. (No chrome gate needed for this branch.)
### 2.5 Testing
1. **Unit (offline, falsifiable).** New `MTConnectWriteTests`: batch contract + every refusal leg
via `CannedAgentClient` (disabled-by-default, unknown RawPath, condition category, null value,
no client, cancellation fill, agent-error outcome). Encoder-free — the write is a URL, so the
URL/verb/device-resolution assertions live in `MTConnectAgentClient` stub-handler tests
(MTConnectError-in-200 detection included). Discovery stamping test: same probe fixture browsed
with write on/off flips leaf SecurityClass, CONDITION stays ViewOnly.
2. **Integration (env-gated, docker fixture).** Flip the fixture to `AllowPut = true` (update the
comment to say writes are now in scope) and add a write leg: write via the driver → read
`/current` back → the observation carries the written value; a second leg proves an
`AllowPut = false`-style refusal maps to `BadCommunicationError` is *not* needed (we cannot run
two agent configs in one fixture) — instead a bad-dataItemId write asserts the agent-error
mapping. Remove the "(b) Writes" not-covered remark
(`MTConnectAgentIntegrationTests.cs:64-65`).
3. **Live gate (no chrome).** Against the docker-dev rig + the shared fixture agent: author an
MTConnect driver with `write.enabled`, deploy, `Client.CLI write` to a writable node, confirm
via `/current` and via OPC UA read-back. Runnable headless by the executing session.
## 3. Bookkeeping (both halves)
- `docs/drivers/MTConnect.md`: capability surface (`:71-77`), Deferred (`:295-301`), new
§Write-back section with the §2.2 semantics warning.
- `docs/plans/2026-07-15-mtconnect-driver-design.md` §3.6: superseded note pointing here.
- `docs/plans/2026-07-24-driver-expansion-tracking.md`: MQTT P2 gap bullets + §Next actions
rewritten to record #508/#509/#514 closure and this branch's MTConnect write-back.
- `docs/drivers/Mqtt.md`: delete stale blocks `:550-556` and `:586-587`.
- `deferment.md`: correct `:132-133` (post-branch: `IWritable` absent from Sql and Calculation
only) and the MTConnect row (`:126`).
@@ -0,0 +1,183 @@
# MTConnect write-back Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development
> (opus implementers per user instruction).
**Goal:** Implement `IWritable` on `MTConnectDriver` via the Agent's HTTP PUT/POST surface
(opt-in at agent `AllowPut` AND driver `write.enabled`), and reconcile the stale MQTT
write-through/browse-gap records.
**Architecture:** See `docs/plans/2026-08-05-mtconnect-writeback-design.md` (in this branch).
Write = observation injection into the agent buffer, never machine command. Batch-safe
`WriteAsync` mirroring `MqttDriver.cs:670-745`; new seam method on `IMTConnectAgentClient`;
discovery stamps `Operate` only for leaves the driver would accept.
**Tech Stack:** .NET 10, xUnit v3 + Shouldly, existing `CannedAgentClient` / stub
`HttpMessageHandler` seams. No new packages.
**Worktree:** `/Users/dohertj2/Desktop/OtOpcUa/.claude/worktrees/mqtt-mtconnect-write`
(branch `feature/mqtt-mtconnect-write`). All paths below relative to it.
---
### Task 0: Write options block (config + parse)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs`
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs` (config DTO region
~:3257-3345 + `ParseOptions` ~:2222)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverOptionsTests.cs`,
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs`
TDD: failing test first — `{"write":{"enabled":true}}` parses to `Options.Write.Enabled == true`;
absent block defaults to `false`; malformed value behaves like the sibling `Probe`/`Reconnect`
blocks (match their existing tolerance/failure semantics exactly — read them first). Add
`MTConnectWriteOptions` (sealed record, `Enabled`, default false) on `MTConnectDriverOptions`
following the `Probe`/`Reconnect` pattern, a nullable `WriteDto` on the config DTO, wire through
`ParseOptions`. Commit `feat(mtconnect): parse the write.enabled config block`.
### Task 1: Agent-client write seam
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 0
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs`
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAgentClientWriteTests.cs` (new)
Add to the seam:
```csharp
Task<MTConnectWriteOutcome> WriteObservationAsync(
string deviceName, string dataItemId, string value, CancellationToken cancellationToken);
```
with `MTConnectWriteOutcome` a small readonly record: `Accepted` bool + `AgentError` string?
(agent-produced MTConnectError text) — transport failures THROW (HttpRequestException etc.), the
driver maps them; agent refusals return `Accepted=false` with the text. Implementation in
`MTConnectAgentClient` over `_http` (the bounded-timeout unary client): `HttpMethod.Post` to
`{base}/{deviceName}?{Uri.EscapeDataString(dataItemId)}={Uri.EscapeDataString(value)}` built with
the same left-part logic as `BuildRequestUri` (:382-403). A 2xx whose body parses as an
`MTConnectError` document is a refusal (reuse the LocalName-only XML helpers in
`MTConnectXml.cs`); non-2xx is a refusal carrying the status; empty 2xx is accepted.
TDD via the internal `HttpMessageHandler` ctor seam (`MTConnectAgentClient.cs:107`), the same
pattern `MTConnectDriverProbeTests.cs:22-28` uses: assert verb, exact URI (device path + escaped
query), accepted on empty 200, refusal on MTConnectError-in-200 (agent text surfaced), refusal on
404, throw on handler-thrown transport error. Commit
`feat(mtconnect): agent-client observation write (HTTP POST, AllowPut surface)`.
### Task 2: `MTConnectDriver` implements `IWritable` + discovery stamping
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs` (class decl :63,
remarks :46-53, WriteAsync new, `TagBinding` device-name map ~:3170-3213, `DiscoverAsync`
stamping :1030-1046)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverFactoryExtensions.cs`
(remark :33-39)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectTagDefinition.cs`
(remark :10-14)
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs`
(record writes, scripted outcomes)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectWriteTests.cs` (new),
`MTConnectDiscoverTests.cs` (stamping cases)
Per design §2.3. Status-code constants: follow MQTT's named-const pattern
(`MqttDriver.cs:76-89`) — `StatusBadNodeIdUnknown 0x80340000`, `StatusBadNotWritable 0x803B0000`,
`StatusBadTypeMismatch 0x80740000`, `StatusBadCommunicationError 0x80050000`, plus
`BadNotConnected` matching `ReadAsync`'s existing constant. Refusal order per slot: disabled →
BadNotWritable; no client → BadNotConnected; unresolved RawPath → BadNodeIdUnknown; CONDITION
category → BadNotWritable; null/unrepresentable value → BadTypeMismatch; then the seam call —
`Accepted=false` or thrown transport error → BadCommunicationError (log agent text at Warning,
never into `LastError`). `OperationCanceledException` fills remaining slots with
BadCommunicationError and returns. Never throw. Success = `WriteResult(0u)`.
Device-name resolution: extend `TagBinding` (or a parallel FrozenDictionary built in
`BuildBinding` ~:2887-2980) with `dataItemId → owning deviceName` from the probe model; fall back
to `options.DeviceName`; unresolvable device → BadNodeIdUnknown. Values format via
`Convert.ToString(value, CultureInfo.InvariantCulture)`.
Discovery: `CanWrite(dataItem)` = `options.Write.Enabled && !IsCondition(category)` →
`SecurityClassification.Operate` else `ViewOnly`; update the :1040-1042 comment. **Verify** how
materialization derives `CurrentWrite` (search for how MQTT's Operate-stamped nodes get the write
bit — SecurityClass + capability) and state the finding in the commit message; if a server-side
gate keys on the driver capability alone, note that existing ViewOnly-committed raw tags keep
their authored SecurityClass.
TDD: every refusal leg observed red first where feasible; the batch-shape test (one bad slot
doesn't fail the batch) mirrors `MqttDriverWriteTests.cs:74`. The publish itself is proven by
Task 3 live legs, not by asserting a fake was called beyond the seam-call recording (same
philosophy as `MqttDriverWriteTests.cs:11-15`). Commit
`feat(mtconnect): IWritable via agent observation writes (#468-era deferral closed)`.
### Task 3: Integration fixture + write legs
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 4
**Files:**
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg`
(`AllowPut = true` + comment rewrite)
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs`
(remove the "(b) Writes" not-covered remark :64-65; add write legs)
Legs (env-gated by the existing `MTCONNECT_AGENT_ENDPOINT` fixture, `Assert.Skip` clean):
1. Driver with `write.enabled` writes a distinct value to a writable EVENT data item (pick one
from `Devices.xml` the SHDR adapter does not continuously overwrite — read `adapter.py` to
choose; if every item is pumped, add a static EVENT item to `Devices.xml`) → poll `/current`
until the observation carries the value (bounded wait, `StreamWait` pattern).
2. A write naming a nonexistent dataItemId → `BadCommunicationError` (agent refusal mapping).
3. `write.enabled` absent → same write returns `BadNotWritable` without touching the agent.
Commit `test(mtconnect): live write legs against the AllowPut fixture`.
### Task 4: Docs reconciliation (MTConnect + stale MQTT records)
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 3
**Files:**
- Modify: `docs/drivers/MTConnect.md` (capability :71-77, Deferred :295-301, new §Write-back)
- Modify: `docs/plans/2026-07-15-mtconnect-driver-design.md` (§3.6 superseded note)
- Modify: `docs/drivers/Mqtt.md` (delete stale :550-556 and :586-587)
- Modify: `docs/plans/2026-07-24-driver-expansion-tracking.md` (P2 gap bullets :239-263 → closed
#509/#510/#513/#514; §Next actions :288-289 rewritten)
- Modify: `deferment.md` (:126 MTConnect row, :132-133 four-driver IWritable line → Sql +
Calculation only)
Per design §1 + §3. The Write-back section MUST carry the §2.2 semantics warning (observation
injection, adapter overwrites, two-layer opt-in, `AllowPutFrom` pointer). Commit
`docs: MTConnect write-back recorded; stale MQTT write-through/browse-gap records reconciled`.
### Task 5: Full-suite verification
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none
`dotnet build ZB.MOM.WW.OtOpcUa.slnx` (0 errors) + full MTConnect unit suite + AdminUI suite +
`dotnet test --filter "FullyQualifiedName~Mqtt"` (docs-only but cheap insurance). Integration
suite compiles; live legs skip offline. Fix fallout, commit if changes.
### Task 6: Live gate (main session, headless — NO chrome)
**Classification:** standard
**Estimated implement time:** ~10 min (main session, after merge)
Main session, not a subagent: bring up the MTConnect fixture (`ssh dohertj2@10.100.0.35` compose
in `/opt/otopcua-mtconnect/` — `lmxopcua-fix sync` equivalent via rsync first, since agent.cfg
changed), run the Task 3 legs live (`dotnet test --filter` the new write tests with
`MTCONNECT_AGENT_ENDPOINT` set), then the docker-dev rig leg: author + deploy an MTConnect driver
with `write.enabled`, `Client.CLI write` a value, read it back via Client.CLI and `/current`.
Record results in the design doc §Outcome.
@@ -0,0 +1,13 @@
{
"planPath": "docs/plans/2026-08-05-mtconnect-writeback.md",
"tasks": [
{ "id": 0, "subject": "Task 0: Write options block (config + parse)", "status": "completed" },
{ "id": 1, "subject": "Task 1: Agent-client write seam", "status": "completed" },
{ "id": 2, "subject": "Task 2: MTConnectDriver IWritable + discovery stamping", "status": "completed", "blockedBy": [0, 1] },
{ "id": 3, "subject": "Task 3: Integration fixture + write legs", "status": "completed", "blockedBy": [2] },
{ "id": 4, "subject": "Task 4: Docs reconciliation (MTConnect + stale MQTT records)", "status": "completed", "blockedBy": [2] },
{ "id": 5, "subject": "Task 5: Full-suite verification", "status": "completed", "blockedBy": [3, 4] },
{ "id": 6, "subject": "Task 6: Live gate (main session, headless)", "status": "pending", "blockedBy": [5] }
],
"lastUpdated": "2026-08-05T00:00:00Z"
}
@@ -58,7 +58,8 @@ public static class DriverTypeNames
/// <summary>MQTT / Sparkplug B broker-subscription driver.</summary>
public const string Mqtt = "Mqtt";
/// <summary>MTConnect Agent driver — read-only HTTP/XML over an Agent's probe/current/sample surface.</summary>
/// <summary>MTConnect Agent driver — HTTP/XML over an Agent's probe/current/sample surface, plus
/// opt-in observation write-back via the Agent's AllowPut POST surface.</summary>
public const string MTConnect = "MTConnect";
/// <summary>
@@ -109,6 +109,12 @@ public sealed class MTConnectDriverOptions
/// <c>/sample</c> streaming connection.
/// </summary>
public MTConnectReconnectOptions Reconnect { get; init; } = new();
/// <summary>
/// Write-back settings. Off by default — the driver shipped read-only, and enabling this is
/// only half the gate: the Agent's own <c>AllowPut</c> must be on too.
/// </summary>
public MTConnectWriteOptions Write { get; init; } = new();
}
/// <summary>Background connectivity-probe knobs, mirroring <c>ModbusProbeOptions</c>.</summary>
@@ -132,3 +138,16 @@ public sealed class MTConnectReconnectOptions
/// <summary>Multiplier applied each retry. Default <c>2.0</c> doubles each step.</summary>
public double BackoffMultiplier { get; init; } = 2.0;
}
/// <summary>
/// Write-back knobs. A write is <b>observation injection</b> via the Agent's <c>AllowPut</c>
/// surface — it sets what the Agent reports for a DataItem, not the machine itself (design §2.2).
/// </summary>
public sealed class MTConnectWriteOptions
{
/// <summary>
/// Gets a value indicating whether write-back is enabled. Default <c>false</c> — an existing
/// deployment that never authors this block keeps today's read-only behaviour exactly.
/// </summary>
public bool Enabled { get; init; }
}
@@ -8,9 +8,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// current value from the Agent's <c>/current</c> snapshot / <c>/sample</c> stream by that id.
/// </summary>
/// <param name="FullName">
/// The MTConnect DataItem's <c>id</c> attribute (the Agent's <c>/probe</c> response). This is
/// the driver's reference for read/subscribe — MTConnect is a read-only source protocol, so
/// there is no corresponding write path.
/// The MTConnect DataItem's <c>id</c> attribute (the Agent's <c>/probe</c> response). This is the
/// driver's reference for read, subscribe <b>and write</b> — the write being an <i>observation
/// injection</i> through the Agent's <c>AllowPut</c> surface
/// (<c>POST {agent}/{device}?{id}={value}</c>), which sets what the Agent reports for the DataItem
/// rather than commanding the machine. It is off unless both the Agent's <c>AllowPut</c> and the
/// driver's <c>write.enabled</c> say otherwise, and CONDITION-category items are excluded.
/// </param>
/// <param name="DriverDataType">Logical data type the DataItem's value is coerced to.</param>
/// <param name="IsArray">
@@ -1,5 +1,27 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// The result of a <see cref="IMTConnectAgentClient.WriteObservationAsync"/> call, as the
/// <b>Agent</b> answered it. A transport failure is never represented here — it throws (see the
/// method's contract), so an outcome always means "the Agent replied, and this is what it said".
/// </summary>
/// <param name="Accepted">
/// <c>true</c> when the Agent took the observation: a 2xx response whose body is not an
/// <c>MTConnectError</c> document. <c>false</c> is a refusal, and
/// <see cref="AgentError"/> then always carries text.
/// </param>
/// <param name="AgentError">
/// Why the Agent refused, in its own words where it supplied any — the joined
/// <c>errorCode: text</c> of the <c>MTConnectError</c> document, or the HTTP status line for a
/// non-2xx answer. <c>null</c> if and only if <see cref="Accepted"/> is <c>true</c>.
/// <para>
/// This is Agent-authored text destined for a <b>log line</b>, never for a driver's
/// <c>LastError</c> (which is type-only by repo-wide discipline) and never for an OPC UA
/// status code's own message.
/// </para>
/// </param>
public sealed record MTConnectWriteOutcome(bool Accepted, string? AgentError);
/// <summary>
/// The seam between the MTConnect driver and an MTConnect Agent's HTTP surface
/// (<c>/probe</c>, <c>/current</c>, <c>/sample</c>). This interface is transport-neutral by
@@ -17,7 +39,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <c>/sample</c> long poll. (Task 0 planned to build on the TrakHound MTConnect.NET libraries;
/// Tasks 6 and 7 proved they can neither parse a document nor frame the stream socket-free, and
/// dropped the references — see the Task 0 CORRECTION block in the plan.) A fake for tests only
/// needs to implement these three instance members; <see cref="IsSequenceGap"/> is a static
/// needs to implement these four instance members; <see cref="IsSequenceGap"/> is a static
/// protocol rule that lives here, rather than on the implementation, so a consumer written
/// against this seam never has to reference the concrete client.
/// </para>
@@ -88,6 +110,67 @@ public interface IMTConnectAgentClient : IDisposable
/// <param name="ct">Cancellation token; cancelling is the contracted way to end the stream.</param>
IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, CancellationToken ct);
/// <summary>
/// Sets the Agent's observation for one DataItem, through the Agent's <c>AllowPut</c>
/// surface (<c>POST {agent}/{deviceName}?{dataItemId}={value}</c>).
/// </summary>
/// <remarks>
/// <para>
/// <b>This is observation injection, not machine command.</b> The value lands in the
/// Agent's observation buffer exactly as if an adapter had reported it — every consumer
/// of that Agent then sees it, and the next adapter/SHDR update for the same DataItem
/// overwrites it. Nothing is sent to the machine. It is also gated twice and off at both
/// gates by default: the Agent's own <c>AllowPut</c> (<c>agent.cfg</c>) and the driver's
/// <c>write.enabled</c>.
/// </para>
/// <para>
/// <b>A refusal is a return value; a transport failure is an exception.</b> The split is
/// the whole point of the outcome type, and it is what lets the driver map the two to
/// different OPC UA status codes without inspecting exception text:
/// <list type="bullet">
/// <item>
/// The Agent answered and <b>took</b> the observation (2xx, body empty or not an
/// <c>MTConnectError</c>) ⇒ <see cref="MTConnectWriteOutcome.Accepted"/>. An
/// <b>empty 2xx body is acceptance</b> — that is what a cppagent actually sends,
/// so treating a body-less answer as suspect would refuse every successful write.
/// </item>
/// <item>
/// The Agent answered and <b>refused</b> — a non-2xx status, or an
/// <c>MTConnectError</c> document served (as Agents do) under <b>HTTP 200</b> ⇒
/// not accepted, with the Agent's own text in
/// <see cref="MTConnectWriteOutcome.AgentError"/>. The 200-with-error shape is
/// why the status code alone can never decide this.
/// </item>
/// <item>
/// The Agent could not be <b>reached or did not answer in time</b> ⇒ throws
/// (<see cref="HttpRequestException"/>, or a <see cref="TimeoutException"/> for
/// the per-call deadline; <see cref="OperationCanceledException"/> when
/// <paramref name="cancellationToken"/> is cancelled or the client is disposed).
/// Folding these into a <c>false</c> outcome would make "the machine's Agent is
/// unreachable" indistinguishable from "the Agent rejected this DataItem" —
/// identical to the caller, opposite in what an operator must do about it.
/// </item>
/// </list>
/// </para>
/// <para>
/// <b>The write is a URL, so escaping is this seam's job, not the caller's.</b>
/// <paramref name="dataItemId"/> and <paramref name="value"/> are passed <i>unescaped</i>
/// and are percent-encoded by the implementation. A caller that pre-escaped would
/// double-encode and write a literal <c>%20</c> into the Agent's buffer, which the Agent
/// accepts — so the corruption is silent and shows up only as a wrong value on read-back.
/// </para>
/// </remarks>
/// <param name="deviceName">
/// The device that owns the DataItem — the path segment of the request. Required even on a
/// single-device Agent, because the Agent's PUT surface is addressed per device.
/// </param>
/// <param name="dataItemId">The DataItem's <c>id</c>, as declared by <c>/probe</c>. Unescaped.</param>
/// <param name="value">The value to report, already rendered to its invariant string form. Unescaped.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>What the Agent said — see the remarks.</returns>
Task<MTConnectWriteOutcome> WriteObservationAsync(
string deviceName, string dataItemId, string value, CancellationToken cancellationToken);
/// <summary>
/// Has the Agent's ring buffer overflowed past the sequence the caller expected next? True
/// when the chunk's oldest retained sequence is <i>strictly</i> newer than
@@ -1,6 +1,8 @@
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
using System.Xml;
using System.Xml.Linq;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
@@ -75,6 +77,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
private readonly ILogger? _logger;
private readonly Uri _probeUri;
private readonly Uri _currentUri;
private readonly string _agentRoot;
private readonly string _sampleUriBase;
private readonly string _sampleQuerySuffix;
private readonly TimeSpan _requestTimeout;
@@ -133,6 +136,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
// Agent and an idle one both emit at least one chunk per heartbeat.
_streamIdleTimeout = TimeSpan.FromMilliseconds((2L * options.HeartbeatMs) + options.RequestTimeoutMs);
_agentRoot = ResolveAgentRoot(options);
_probeUri = BuildRequestUri(options, "probe");
_currentUri = BuildRequestUri(options, "current");
_sampleUriBase = BuildRequestUri(options, "sample").AbsoluteUri;
@@ -242,6 +246,130 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
}
}
/// <inheritdoc/>
/// <remarks>
/// Issued on <c>_http</c>, the <b>unary</b> client — the write is a short request/response
/// and must be bounded by <c>RequestTimeoutMs</c> like the other unary legs. Sending it on
/// the streaming client would inherit that client's infinite timeout, and a frozen Agent
/// would park a write forever with nothing to bound it (the streaming leg's heartbeat
/// watchdog protects a multipart body, not a one-shot POST).
/// </remarks>
public async Task<MTConnectWriteOutcome> WriteObservationAsync(
string deviceName, string dataItemId, string value, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);
ArgumentException.ThrowIfNullOrWhiteSpace(dataItemId);
ArgumentNullException.ThrowIfNull(value);
var uri = BuildWriteUri(deviceName, dataItemId, value);
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposeCts.Token);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
deadline.CancelAfter(_requestTimeout);
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
HttpResponseMessage response;
try
{
response = await _http.SendAsync(request, deadline.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
{
throw TimedOut(uri);
}
using (response)
{
string body;
try
{
body = await response.Content.ReadAsStringAsync(deadline.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
{
throw TimedOut(uri);
}
// Deliberately NOT EnsureSuccessStatusCode: a refused write is an answer, and the caller
// needs it as a value it can map to a per-tag status code rather than as an exception
// indistinguishable from the Agent being unreachable.
var agentError = DescribeAgentError(body);
if (!response.IsSuccessStatusCode)
{
var status = $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".TrimEnd();
return new MTConnectWriteOutcome(
Accepted: false, agentError is null ? status : $"{status}: {agentError}");
}
// An MTConnectError document under HTTP 200 is how an Agent actually refuses; the status
// code alone would report it as a success.
return agentError is null
? new MTConnectWriteOutcome(Accepted: true, AgentError: null)
: new MTConnectWriteOutcome(Accepted: false, agentError);
}
}
/// <summary>
/// The Agent's <c>AllowPut</c> URL: the device as a path segment, the DataItem id and value
/// as a query pair. Every caller-supplied part is percent-encoded here — see the seam's
/// contract for why escaping cannot be the caller's job.
/// </summary>
private Uri BuildWriteUri(string deviceName, string dataItemId, string value)
{
var device = Uri.EscapeDataString(deviceName.Trim());
var query = $"{Uri.EscapeDataString(dataItemId)}={Uri.EscapeDataString(value)}";
return new Uri($"{_agentRoot}/{device}?{query}", UriKind.Absolute);
}
/// <summary>
/// The Agent's own refusal text when <paramref name="body"/> is an <c>MTConnectError</c>
/// document, else <c>null</c> (including for an empty body, a non-XML body, and any other
/// MTConnect document).
/// </summary>
/// <remarks>
/// Read by <see cref="XName.LocalName"/> only, like every other document this driver parses:
/// the namespace URI carries the MTConnect version, so a qualified match would silently stop
/// recognising errors the day an Agent is upgraded — and an unrecognised error reads as a
/// successful write. The error-text walk itself is
/// <see cref="MTConnectXml.ExtractErrorText"/>, shared with the read paths'
/// <see cref="MTConnectXml.DescribeUnexpectedRoot"/> so the two cannot drift on what counts
/// as an Agent objection.
/// </remarks>
private static string? DescribeAgentError(string body)
{
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
XElement? root;
try
{
root = XDocument.Parse(body).Root;
}
catch (XmlException)
{
// Not XML at all — an Agent that answered 2xx with a plain-text or HTML body has not
// refused anything, so this is not the place to invent a failure.
return null;
}
if (root is null || !MTConnectXml.IsNamed(root, "MTConnectError"))
{
return null;
}
// An error document with no readable detail is still a refusal, so the shared helper's null
// — which means "nothing readable in here", NOT "this was not an error" — must become text
// rather than being passed on. Returning it as null would turn the refusal into acceptance.
return MTConnectXml.ExtractErrorText(root)
?? "The Agent answered with an MTConnectError document carrying no error detail.";
}
/// <inheritdoc/>
public void Dispose()
{
@@ -379,7 +507,31 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
return boundary;
}
private static Uri BuildRequestUri(MTConnectDriverOptions options, string request)
/// <summary>
/// A read path's request URI, scoped to the configured device. Reads <see cref="_agentRoot"/>,
/// so it may only be called after that field is assigned — which the constructor does first.
/// </summary>
private Uri BuildRequestUri(MTConnectDriverOptions options, string request)
{
var deviceName = options.DeviceName?.Trim();
var deviceSegment = string.IsNullOrEmpty(deviceName) ? string.Empty : $"/{Uri.EscapeDataString(deviceName)}";
return new Uri($"{_agentRoot}{deviceSegment}/{request}", UriKind.Absolute);
}
/// <summary>
/// Validates <see cref="MTConnectDriverOptions.AgentUri"/> and reduces it to the base every
/// request path is appended to: its left part up to and including the path (so a proxied
/// Agent keeps its prefix), with any trailing slash removed so callers can append
/// <c>/segment</c> unconditionally.
/// </summary>
/// <remarks>
/// Extracted rather than duplicated because the write path resolves its device at call time,
/// not at construction: two copies of this rule would drift, and the drift would surface as
/// writes landing on a URL the reads never used — against an Agent that answers 200 either
/// way.
/// </remarks>
private static string ResolveAgentRoot(MTConnectDriverOptions options)
{
var agentUri = options.AgentUri?.Trim();
if (string.IsNullOrEmpty(agentUri))
@@ -395,11 +547,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
$"{nameof(MTConnectDriverOptions.AgentUri)} '{agentUri}' is not an absolute http(s) URI.", nameof(options));
}
var root = parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
var deviceName = options.DeviceName?.Trim();
var deviceSegment = string.IsNullOrEmpty(deviceName) ? string.Empty : $"/{Uri.EscapeDataString(deviceName)}";
return new Uri($"{root}{deviceSegment}/{request}", UriKind.Absolute);
return parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
}
/// <summary>
@@ -44,12 +44,23 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// </para>
/// <para>
/// <b>Scope.</b> This type implements <see cref="IDriver"/>, <see cref="IReadable"/>,
/// <see cref="ISubscribable"/>, <see cref="ITagDiscovery"/>,
/// <see cref="IWritable"/>, <see cref="ISubscribable"/>, <see cref="ITagDiscovery"/>,
/// <see cref="IHostConnectivityProbe"/> and <see cref="IRediscoverable"/> — the last two
/// built on state this class already caches (the Agent <c>instanceId</c> and the probe
/// model). There is deliberately no <c>IWritable</c>: MTConnect is a read-only protocol,
/// which is also why every discovered leaf is
/// <see cref="SecurityClassification.ViewOnly"/>.
/// model).
/// </para>
/// <para>
/// <b><see cref="IWritable"/> is <i>observation injection</i>, not machine command, and it is
/// off by default.</b> A write goes to the Agent's <c>AllowPut</c> surface
/// (<c>POST {agent}/{device}?{dataItemId}={value}</c>) and sets what the Agent <i>reports</i>
/// for that DataItem — visible to every consumer of that Agent, and overwritten by the next
/// adapter/SHDR update. Nothing reaches the CNC. It is therefore gated twice, and off at both
/// gates by default: the Agent's own <c>AllowPut</c> (<c>agent.cfg</c>) and this driver's
/// <see cref="MTConnectWriteOptions.Enabled"/>. Either gate alone leaves the shipped read-only
/// behaviour byte-for-byte unchanged. CONDITION data items are excluded — they carry alarm
/// semantics and a different <c>level|text</c> wire format. MTConnect <b>Interfaces</b>, the
/// protocol's own request/response handshake, remains deferred and is a different mechanism
/// entirely (see <c>docs/plans/2026-08-05-mtconnect-writeback-design.md</c> §2.1).
/// </para>
/// <para>
/// <b>The read path never writes the shared observation index</b> — see
@@ -60,15 +71,43 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <see cref="AgentSession.ProbeModel"/> (see <see cref="RunProbeLoopAsync"/>).
/// </para>
/// </remarks>
public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable
public sealed class MTConnectDriver
: IDriver, IReadable, IWritable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable
{
/// <summary>
/// The Agent could not be reached, answered unusably, or blew the per-call deadline. Distinct
/// from the index's <c>BadNoCommunication</c>, which means the Agent answered and said the
/// machine has no value — the two failures need different operator responses.
/// machine has no value — the two failures need different operator responses. On the write
/// path it also covers an Agent that answered and <i>refused</i> — see <see cref="WriteAsync"/>.
/// </summary>
private const uint BadCommunicationError = 0x80050000u;
// ---- Write status codes ----
//
// NAMED constants, never inline literals: StatusCodeParityTests checks each against the pinned SDK
// by reflection and can only see a named const (Gitea #497/#519). The names must match
// Opc.Ua.StatusCodes members exactly. Spelled WITHOUT the "Status" prefix MqttDriver uses, to match
// the two this file already declares (the parity guard strips either spelling).
/// <summary>
/// The reference names no DataItem this driver serves, or names one whose owning device cannot
/// be resolved — there is no Agent URL a write could be addressed to.
/// </summary>
private const uint BadNodeIdUnknown = 0x80340000u;
/// <summary>
/// Writing is switched off (<c>write.enabled</c>), or the DataItem is CONDITION-category. The
/// honest answer to "can I write this?", and deliberately not a communication failure: nothing
/// was attempted and no Agent was involved.
/// </summary>
private const uint BadNotWritable = 0x803B0000u;
/// <summary>
/// The supplied value has no invariant string form a PUT could carry — <c>null</c>, or a
/// non-scalar (see <see cref="TryFormatValue"/>).
/// </summary>
private const uint BadTypeMismatch = 0x80740000u;
/// <summary>
/// There is no Agent client at all: the driver has not been initialized, its initialize
/// faulted, or it has been shut down. Deliberately not one of the index's codes — none of
@@ -694,6 +733,220 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
}
}
// ---- IWritable: observation injection through the Agent's AllowPut surface ----
/// <summary>
/// Sets the Agent's reported observation for each requested reference, through its
/// <c>AllowPut</c> surface. Returns one status per request, in request order.
/// </summary>
/// <param name="writes">The requested writes.</param>
/// <param name="cancellationToken">Cancellation for the batch.</param>
/// <returns>One <see cref="WriteResult"/> per request, in request order.</returns>
/// <remarks>
/// <para>
/// <b>This writes the Agent, not the machine</b> — see the class remarks. Good here means
/// "the Agent took the observation", which is a stronger statement than MQTT's optimistic
/// "published" (the Agent answers) and a weaker one than a device acknowledgement (no
/// MTConnect Agent speaks for the machine). The next adapter update for that DataItem
/// overwrites what was written, and the ordinary <c>/sample</c> ingest path is what
/// corrects the node.
/// </para>
/// <para>
/// <b>Never throws, and one bad slot never fails the batch</b> — matching
/// <see cref="ReadAsync"/>. Failing the whole call would let one stale reference reject
/// every other write in the same OPC UA request. Even cancellation returns a full-length
/// batch rather than propagating, so the node-manager can tell which slots were attempted.
/// </para>
/// <para>
/// <b>Refusal order is fixed, and each step answers a different operator question:</b>
/// writing switched off ⇒ <c>BadNotWritable</c>; no live client ⇒ <c>BadNotConnected</c>
/// (this driver is not running — <see cref="ReadAsync"/>'s convention); the reference
/// resolves to no DataItem, or to one whose owning device cannot be named ⇒
/// <c>BadNodeIdUnknown</c>; CONDITION category ⇒ <c>BadNotWritable</c>; no invariant string
/// form ⇒ <c>BadTypeMismatch</c>; and only then the Agent, whose refusal <b>or</b> whose
/// unreachability ⇒ <c>BadCommunicationError</c>.
/// </para>
/// <para>
/// <b>The Agent's refusal text is logged, never stored.</b> It goes to a Warning line and
/// nowhere near <see cref="DriverHealth.LastError"/>, which is type-only by repo-wide
/// discipline — and a refused write is not evidence the read path degraded, so health is
/// left exactly as it is (unlike <see cref="DegradeAfterReadFailure"/>).
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="writes"/> is <c>null</c> — a caller bug.</exception>
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(writes);
if (writes.Count == 0)
{
return [];
}
// Read once each, for the same reason ReadAsync does: every slot in this batch is judged
// against ONE options document and ONE tag table, never half against a table a concurrent
// re-initialize just replaced.
var options = _options;
var client = Volatile.Read(ref _session)?.Client;
var binding = Volatile.Read(ref _binding);
var results = new WriteResult[writes.Count];
for (var i = 0; i < writes.Count; i++)
{
var request = writes[i];
if (!options.Write.Enabled)
{
results[i] = new WriteResult(BadNotWritable);
continue;
}
if (client is null)
{
results[i] = new WriteResult(BadNotConnected);
continue;
}
if (!binding.TryResolveWriteTarget(request.FullReference, out var dataItemId, out var target)
|| target.DeviceName is null)
{
results[i] = new WriteResult(BadNodeIdUnknown);
continue;
}
if (target.IsCondition)
{
results[i] = new WriteResult(BadNotWritable);
continue;
}
if (!TryFormatValue(request.Value, out var text))
{
results[i] = new WriteResult(BadTypeMismatch);
continue;
}
try
{
var outcome = await client
.WriteObservationAsync(target.DeviceName, dataItemId, text, cancellationToken)
.ConfigureAwait(false);
if (outcome.Accepted)
{
results[i] = new WriteResult(0u);
continue;
}
_logger.LogWarning(
"MTConnect driver {DriverInstanceId}: {AgentUri} refused the write of '{Value}' to DataItem '{DataItemId}' on device '{DeviceName}' — {AgentError}. Check the Agent's AllowPut / AllowPutFrom settings and that the DataItem exists.",
_driverInstanceId, options.AgentUri, text, dataItemId, target.DeviceName, outcome.AgentError);
results[i] = new WriteResult(BadCommunicationError);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Caller-cancelled: fill the remainder rather than throwing, so the batch shape holds.
for (var j = i; j < writes.Count; j++)
{
results[j] = new WriteResult(BadCommunicationError);
}
return results;
}
catch (Exception ex)
{
// A transport failure, a blown per-call deadline, or a disposed client. Logged with the
// exception (its TYPE is the diagnosis) and reported per slot; health is untouched.
_logger.LogWarning(
ex,
"MTConnect driver {DriverInstanceId} could not write DataItem '{DataItemId}' on device '{DeviceName}' to {AgentUri}.",
_driverInstanceId, dataItemId, target.DeviceName, options.AgentUri);
results[i] = new WriteResult(BadCommunicationError);
}
}
return results;
}
/// <summary>
/// Renders a value to the invariant string a PUT query carries, or reports that it has no such
/// form.
/// </summary>
/// <param name="value">The value the OPC UA client supplied.</param>
/// <param name="text">The rendered value; empty when this returns <see langword="false"/>.</param>
/// <returns><see langword="false"/> for a value that must be refused as a type mismatch.</returns>
/// <remarks>
/// <para>
/// <b><see cref="CultureInfo.InvariantCulture"/> is load-bearing.</b> A comma decimal
/// separator produces a value the Agent accepts and every consumer reads back as a
/// different number. Booleans render as <c>True</c>/<c>False</c> —
/// <see cref="bool.ToString()"/> is culture-independent, so that IS the invariant form, and
/// it is what an MTConnect consumer will see verbatim.
/// </para>
/// <para>
/// <b>Arrays are refused rather than rendered.</b>
/// <see cref="Convert.ToString(object, IFormatProvider)"/> falls back to
/// <see cref="object.ToString"/> for a non-<see cref="IConvertible"/> value, so an array
/// would be written as the literal text <c>System.Double[]</c> — which the Agent stores
/// happily, making the corruption invisible until someone reads the observation back.
/// TIME_SERIES / DATA_SET writes are out of scope for v1.
/// </para>
/// <para>
/// <b>Every formatting throw is caught, not just the documented conversion ones.</b> The
/// rendering runs the value's own <see cref="object.ToString"/> /
/// <see cref="IFormattable"/>, which is arbitrary code — and this runs OUTSIDE
/// <see cref="WriteAsync"/>'s per-slot <c>try</c>, so anything that escapes here escapes
/// the whole batch and breaks its never-throws contract. It costs its own slot a
/// <c>BadTypeMismatch</c> and is logged.
/// </para>
/// </remarks>
private bool TryFormatValue(object? value, out string text)
{
text = string.Empty;
// A PUT query has no null to send, and the empty string is a real observation rather than an
// absence — so "no value" is refused instead of being silently turned into one.
if (value is null or Array)
{
return false;
}
string? rendered;
try
{
rendered = Convert.ToString(value, CultureInfo.InvariantCulture);
}
catch (Exception ex)
{
// Catch-all, and deliberately so. Convert.ToString falls back to the value's OWN
// ToString/IFormattable, which is arbitrary caller code that may throw ANYTHING — and this
// call sits outside WriteAsync's per-slot try, so a narrower filter (FormatException /
// InvalidCastException / OverflowException) let every other exception escape WriteAsync
// uncaught and break the documented never-throws contract for the whole batch. One
// unformattable value must cost its own slot, nothing more.
_logger.LogWarning(
ex,
"MTConnect driver {DriverInstanceId} could not render a write value of type {ValueType} to its invariant string form; the write is refused as a type mismatch.",
_driverInstanceId,
value.GetType());
return false;
}
if (rendered is null)
{
return false;
}
text = rendered;
return true;
}
// ---- ISubscribable: ONE shared /sample long poll behind every handle ----
/// <inheritdoc/>
@@ -940,7 +1193,11 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
// Read once: a concurrent re-initialize must not scope half this walk to one device and half
// to another.
var deviceScope = _options.DeviceName?.Trim();
// Both read once, together: a concurrent re-initialize must not scope half this walk to one
// device and half to another, nor stamp half its leaves writable and half not.
var options = _options;
var deviceScope = options.DeviceName?.Trim();
var writeEnabled = options.Write.Enabled;
var devices = 0;
var variables = 0;
@@ -966,7 +1223,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
}
devices++;
variables += StreamContainer(device, builder.Folder(name, name));
variables += StreamContainer(device, builder.Folder(name, name), writeEnabled);
}
// A configured scope that matches NO device is an authoring error — almost always a typo or
@@ -1003,7 +1260,14 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// picker exists to show. Recursion depth is bounded by the parser, which refuses a probe
/// document nested beyond its own limit.
/// </remarks>
private static int StreamContainer(IMTConnectComponentContainer container, IAddressSpaceBuilder scope)
/// <param name="container">The device or component whose data items are being streamed.</param>
/// <param name="scope">The builder folder they belong under.</param>
/// <param name="writeEnabled">
/// <see cref="MTConnectWriteOptions.Enabled"/>, read once by the caller so one walk cannot
/// stamp half its leaves under an old options document and half under a new one.
/// </param>
private static int StreamContainer(
IMTConnectComponentContainer container, IAddressSpaceBuilder scope, bool writeEnabled)
{
var streamed = 0;
@@ -1037,9 +1301,21 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
IsArray: inferred.IsArray,
ArrayDim: inferred.ArrayDim,
// MTConnect is read-only and this driver implements no IWritable, so the tier
// that is read-only from OPC UA is the only honest one.
SecurityClass: SecurityClassification.ViewOnly,
// Operate exactly for the leaves WriteAsync would accept — the "a node cannot
// advertise writability the driver would refuse" principle. With write.enabled
// off (the default) every leaf stays ViewOnly, which is what this driver shipped.
//
// ⚠️ This does NOT make the committed tag writable over OPC UA, and must not be
// read as doing so. Nothing persists SecurityClass: RawBrowseCommitMapper.MapLeaf
// hardcodes TagAccessLevel.Read, Tag has no SecurityClass column, and the
// CurrentWrite bit is composed from Tag.AccessLevel alone
// (OtOpcUaNodeManager.ComposeAccessLevel). The stamp reaches the browse session's
// attribute side-channel and stops there, so an operator still has to author
// ReadWrite on the tag. It is kept honest anyway — a browse that advertised
// ViewOnly for a leaf this driver now writes would be a lie an operator reads.
SecurityClass: CanWrite(writeEnabled, dataItem)
? SecurityClassification.Operate
: SecurityClassification.ViewOnly,
// Historization is authored per tag, never inferred from a device model.
IsHistorized: false,
@@ -1067,12 +1343,26 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
continue;
}
streamed += StreamContainer(component, scope.Folder(name, name));
streamed += StreamContainer(component, scope.Folder(name, name), writeEnabled);
}
return streamed;
}
/// <summary>
/// Whether <see cref="WriteAsync"/> would accept a write to <paramref name="dataItem"/> — the
/// browse-time answer, asked of the same two facts the runtime asks of.
/// </summary>
/// <param name="writeEnabled">Whether <c>write.enabled</c> is on.</param>
/// <param name="dataItem">The declared data item.</param>
/// <remarks>
/// The device-name leg of the runtime refusal is not asked here and does not need to be: a
/// leaf reached by this walk is <i>inside</i> a device the model declares, so its owning device
/// is known by construction.
/// </remarks>
private static bool CanWrite(bool writeEnabled, MTConnectDataItem dataItem) =>
writeEnabled && !IsCondition(dataItem.Category);
/// <summary>
/// Whether a device is in this instance's configured scope. An unset scope is agent-wide.
/// </summary>
@@ -2276,6 +2566,13 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
MaxBackoffMs = dto.Reconnect?.MaxBackoffMs ?? 30_000,
BackoffMultiplier = dto.Reconnect?.BackoffMultiplier ?? 2.0,
},
// Absent block == disabled: write-back is opt-in, and the agent's own AllowPut has to be
// on as well, so neither layer can arm the other by omission.
Write = new MTConnectWriteOptions
{
Enabled = dto.Write?.Enabled ?? false,
},
};
}
@@ -2926,10 +3223,13 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
if (options.RawTags.Count == 0)
{
MTConnectTagDefinition[] only = [.. definitions.Values];
return new TagBinding(
FrozenDictionary<string, string>.Empty,
FrozenDictionary<string, IReadOnlyList<string>>.Empty,
[.. definitions.Values]);
only,
BuildWriteTargets(only, probeModel, options.DeviceName));
}
var declared = IndexProbeDataItems(probeModel);
@@ -2975,13 +3275,106 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
}
}
MTConnectTagDefinition[] indexTags = [.. definitions.Values];
return new TagBinding(
idByRawPath.ToFrozenDictionary(StringComparer.Ordinal),
rawPathsById.ToFrozenDictionary(
pair => pair.Key,
pair => (IReadOnlyList<string>)pair.Value,
StringComparer.Ordinal),
[.. definitions.Values]);
indexTags,
BuildWriteTargets(indexTags, probeModel, options.DeviceName));
}
/// <summary>
/// Derives the write-addressing table: for every DataItem this driver serves, the device the
/// Agent's <c>AllowPut</c> URL must name, and whether the item is CONDITION-category.
/// </summary>
/// <param name="definitions">Every DataItem the binding serves (the union of both authoring surfaces).</param>
/// <param name="probeModel">The Agent's device model when one has been fetched; else null.</param>
/// <param name="configuredDeviceName">
/// <see cref="MTConnectDriverOptions.DeviceName"/> — the fallback for a DataItem the
/// <c>/probe</c> model does not declare.
/// </param>
/// <remarks>
/// <para>
/// <b>The device name is derived, not authored.</b> The Agent's PUT surface is addressed
/// per device even on a single-device Agent, and the probe model already says which device
/// owns which DataItem — so a multi-device Agent is addressed correctly with no extra
/// config. Config is the fallback, for the dataItemId-keyed authoring surface (the driver
/// CLI) and for a DataItem the Agent has since stopped declaring.
/// </para>
/// <para>
/// <b>A null <see cref="WriteTarget.DeviceName"/> is a refusal, not a default.</b> Neither
/// the model nor config names a device, so there is no URL to POST to; guessing one yields
/// a 404 an operator reads as "the Agent is broken". <see cref="WriteAsync"/> reports it as
/// <c>BadNodeIdUnknown</c>.
/// </para>
/// <para>
/// <b>The category comes from the probe first, the authored blob second.</b> The Agent is
/// the authority on what a DataItem is; a blob's <c>mtCategory</c> is optional metadata
/// that may be absent (browse-commit writes only the address) or stale.
/// </para>
/// </remarks>
private static FrozenDictionary<string, WriteTarget> BuildWriteTargets(
IReadOnlyList<MTConnectTagDefinition> definitions,
MTConnectProbeModel? probeModel,
string? configuredDeviceName)
{
if (definitions.Count == 0)
{
return FrozenDictionary<string, WriteTarget>.Empty;
}
var owners = IndexProbeOwners(probeModel);
var fallback = string.IsNullOrWhiteSpace(configuredDeviceName) ? null : configuredDeviceName.Trim();
var targets = new Dictionary<string, WriteTarget>(definitions.Count, StringComparer.Ordinal);
foreach (var definition in definitions)
{
if (definition is null || string.IsNullOrWhiteSpace(definition.FullName)) continue;
var owner = owners.TryGetValue(definition.FullName, out var declared) ? declared : null;
targets[definition.FullName] = new WriteTarget(
owner?.DeviceName ?? fallback,
IsCondition(owner?.Category ?? definition.MtCategory));
}
return targets.ToFrozenDictionary(StringComparer.Ordinal);
}
/// <summary>
/// Flattens a device model into a <c>dataItemId → owning device</c> lookup; null ⇒ empty.
/// Separate from <see cref="IndexProbeDataItems"/> because this one must remember which device
/// the walk was inside, which a flat DataItem lookup has thrown away.
/// </summary>
/// <param name="probeModel">The Agent's device model, or null.</param>
private static FrozenDictionary<string, DataItemOwner> IndexProbeOwners(MTConnectProbeModel? probeModel)
{
if (probeModel is null) return FrozenDictionary<string, DataItemOwner>.Empty;
var map = new Dictionary<string, DataItemOwner>(StringComparer.Ordinal);
foreach (var device in probeModel.Devices)
{
if (device is null) continue;
// The same name/id fallback the browse tree uses: an Agent addresses a device by its
// name, and by its id when it declares no name.
var deviceName = FolderName(device.Name, device.Id);
var owner = new DataItemOwner(
string.IsNullOrWhiteSpace(deviceName) ? null : deviceName, Category: null);
foreach (var dataItem in device.AllDataItems())
{
if (dataItem is null || string.IsNullOrWhiteSpace(dataItem.Id)) continue;
map[dataItem.Id] = owner with { Category = dataItem.Category };
}
}
return map.ToFrozenDictionary(StringComparer.Ordinal);
}
/// <summary>
@@ -3167,11 +3560,45 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// <see cref="MTConnectDriverOptions.RawTags"/>. This is how a raw tag's declared (or
/// probe-inferred) <c>DriverDataType</c> reaches the coercion.
/// </param>
/// <param name="WriteTargetsByDataItemId">
/// How each DataItem is addressed on the Agent's <c>AllowPut</c> surface, and whether it is
/// CONDITION-category — see <see cref="BuildWriteTargets"/>. Keyed by DataItem id, so it
/// doubles as the write path's "is this a reference I serve at all?" test: a miss is
/// <c>BadNodeIdUnknown</c>, exactly as an index miss is on the read path.
/// </param>
private sealed record TagBinding(
FrozenDictionary<string, string> DataItemIdByRawPath,
FrozenDictionary<string, IReadOnlyList<string>> RawPathsByDataItemId,
IReadOnlyList<MTConnectTagDefinition> IndexTags)
IReadOnlyList<MTConnectTagDefinition> IndexTags,
FrozenDictionary<string, WriteTarget> WriteTargetsByDataItemId)
{
/// <summary>
/// Resolves a write reference to the DataItem id and the Agent address it writes through.
/// </summary>
/// <param name="reference">The caller's reference (a RawPath under v3, or a DataItem id).</param>
/// <param name="dataItemId">The resolved DataItem id; empty when this returns false.</param>
/// <param name="target">Where and whether it can be written; <c>default</c> when false.</param>
/// <returns><see langword="false"/> when no DataItem this driver serves answers to that reference.</returns>
/// <remarks>
/// Stricter than <see cref="ToDataItemId"/>, deliberately. A read may fall through to an
/// index miss and report itself; a write that fell through would POST an arbitrary
/// operator string at the Agent as if it were a DataItem id — the Agent's answer to which
/// is a refusal that reads like a comms fault. Unknown is answered here, before the wire.
/// </remarks>
public bool TryResolveWriteTarget(string? reference, out string dataItemId, out WriteTarget target)
{
dataItemId = ToDataItemId(reference);
if (dataItemId.Length != 0 && WriteTargetsByDataItemId.TryGetValue(dataItemId, out target))
{
return true;
}
target = default;
return false;
}
/// <summary>
/// Translates a subscriber/read reference to the DataItem id the index is keyed by.
/// </summary>
@@ -3211,6 +3638,24 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
}
}
/// <summary>
/// Everything <see cref="WriteAsync"/> needs to decide one slot before it touches the network.
/// </summary>
/// <param name="DeviceName">
/// The device path segment of the Agent PUT URL, or <c>null</c> when neither the probe model
/// nor config names one — which is a refusal, not a default (see <see cref="BuildWriteTargets"/>).
/// </param>
/// <param name="IsCondition">
/// Whether the DataItem is CONDITION-category. Excluded from v1 writes: a condition PUT carries
/// alarm semantics and a different <c>level|text</c> wire format.
/// </param>
private readonly record struct WriteTarget(string? DeviceName, bool IsCondition);
/// <summary>The device a DataItem was declared under, and the category it was declared with.</summary>
/// <param name="DeviceName">The owning device's name (or id); null when it declares neither.</param>
/// <param name="Category">The DataItem's <c>category</c> attribute as the Agent spelled it.</param>
private sealed record DataItemOwner(string? DeviceName, string? Category);
/// <summary>
/// One live subscription: its handle and the references it wants. Both are effectively
/// immutable once registered, so the pump reads <see cref="References"/> without a lock —
@@ -3272,6 +3717,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
public List<RawTagEntry>? RawTags { get; init; }
public MTConnectProbeDto? Probe { get; init; }
public MTConnectReconnectDto? Reconnect { get; init; }
public MTConnectWriteDto? Write { get; init; }
}
/// <summary>
@@ -3347,4 +3793,10 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
public int? MaxBackoffMs { get; init; }
public double? BackoffMultiplier { get; init; }
}
/// <summary>Write-back knobs — the driver-side half of the two-layer opt-in on observation injection.</summary>
private sealed class MTConnectWriteDto
{
public bool? Enabled { get; init; }
}
}
@@ -31,12 +31,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// </para>
/// <para>
/// <b>The instance is returned as its concrete type.</b> The runtime resolves every optional
/// capability (<see cref="IReadable"/>, <see cref="ISubscribable"/>,
/// capability (<see cref="IReadable"/>, <see cref="IWritable"/>, <see cref="ISubscribable"/>,
/// <see cref="ITagDiscovery"/>, <see cref="IHostConnectivityProbe"/>,
/// <see cref="IRediscoverable"/>) by pattern-matching the object
/// <see cref="DriverFactoryRegistry"/> hands back, so nothing here may narrow it — a
/// narrowed return is how a capability ends up implemented but never dispatched. There is
/// deliberately no <c>IWritable</c>: the MTConnect Agent surface is read-only.
/// narrowed return is how a capability ends up implemented but never dispatched.
/// <see cref="IWritable"/> is the standing example: it was added to
/// <see cref="MTConnectDriver"/> with no change whatsoever to this file, and would have been
/// silently undispatched had the return type ever been narrowed to <see cref="IDriver"/>.
/// </para>
/// <para>
/// <b>Registering the factory does not enable writing.</b> An MTConnect write is observation
/// injection through the Agent's <c>AllowPut</c> surface and is off at two independent gates
/// — the Agent's <c>agent.cfg</c> and the driver's own <c>write.enabled</c> (default
/// <see langword="false"/>). See <see cref="MTConnectDriver"/>'s remarks.
/// </para>
/// </remarks>
public static class MTConnectDriverFactoryExtensions
@@ -127,6 +127,40 @@ internal static class MTConnectXml
return message;
}
var errors = ExtractErrorText(root);
return errors is null ? message : $"{message} The Agent reported: {errors}";
}
/// <summary>
/// The Agent's own words from an <c>MTConnectError</c> document: every <c>&lt;Error&gt;</c>
/// beneath <paramref name="root"/>, rendered <c>errorCode: text</c> where a code is present
/// and joined with <c>"; "</c>. <c>null</c> when the document carries no readable error
/// detail at all (no <c>&lt;Error&gt;</c> elements, or only blank ones).
/// </summary>
/// <remarks>
/// <para>
/// <b>Shared because both callers must read the same document the same way.</b> An
/// Agent's objection reaches this driver through two unrelated doors — a read path that
/// got an error document where it expected a model
/// (<see cref="DescribeUnexpectedRoot"/>), and the write path deciding whether a 200
/// actually accepted the observation. Two copies of this walk would drift on the same
/// two silent rules the type-level remarks describe, and on the write side a missed
/// <c>&lt;Error&gt;</c> does not merely degrade a message: it reads as a successful
/// write.
/// </para>
/// <para>
/// <b><c>null</c> means "no readable detail", NOT "not an error document".</b> Only the
/// caller knows which document it is holding — check the root name first. A caller that
/// treats a <c>null</c> here as "no error occurred" turns a hollow
/// <c>&lt;MTConnectError/&gt;</c> into a success.
/// </para>
/// </remarks>
/// <param name="root">The <c>MTConnectError</c> document's root element.</param>
public static string? ExtractErrorText(XElement root)
{
ArgumentNullException.ThrowIfNull(root);
var errors = root.Descendants()
.Where(e => IsNamed(e, "Error"))
.Select(e =>
@@ -139,8 +173,6 @@ internal static class MTConnectXml
.Where(text => text.Length > 0)
.ToList();
return errors.Count == 0
? message
: $"{message} The Agent reported: {string.Join("; ", errors)}";
return errors.Count == 0 ? null : string.Join("; ", errors);
}
}
@@ -4,8 +4,8 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>Typed working model for an MTConnect tag's TagConfig JSON. The tag binds a single Agent
/// <c>DataItem</c> by its <c>id</c> attribute (<see cref="FullName"/> — MTConnect is read-only, so
/// there is no address encoding beyond the id); the remaining fields are probe-sourced display
/// <c>DataItem</c> by its <c>id</c> attribute (<see cref="FullName"/> — reads and writes both address
/// the DataItem by id, so there is no address encoding beyond it); the remaining fields are probe-sourced display
/// metadata plus an author-settable type override. Preserves unrecognised JSON keys across a
/// load→save.</summary>
/// <remarks>
@@ -25,12 +25,20 @@
EVENT, PART_COUNT -> Int64 fixture_partcount (the regression case)
EVENT, LINE_NUMBER -> Int64 fixture_linenumber
EVENT, controlled vocabulary -> String fixture_execution, fixture_avail, fixture_mode
EVENT, free text -> String fixture_program, fixture_block (never fed)
EVENT, free text -> String fixture_program, fixture_block (never fed),
fixture_writeback (never fed; write target)
EVENT, DATA_SET representation -> String fixture_varset
CONDITION (any type) -> String + IsAlarm fixture_x_travel, fixture_logic
fixture_block is deliberately NEVER fed by the adapter, so the Agent reports it
UNAVAILABLE forever — the live half of the UNAVAILABLE -> BadNoCommunication mapping.
fixture_writeback is likewise never fed, and exists for a different reason: it is the
target the write legs POST to (agent.cfg has AllowPut = true). It is a SEPARATE item from
fixture_block on purpose — the first successful write would take fixture_block off
UNAVAILABLE for the life of the container and quietly rob the mapping test above of its
subject. Because no adapter ever writes fixture_writeback, the value a write leg reads back
out of /current can only have come from that write.
-->
<MTConnectDevices
xmlns="urn:mtconnect.org:MTConnectDevices:2.0"
@@ -99,6 +107,12 @@
<DataItem category="EVENT" id="fixture_program" name="Pprogram" type="PROGRAM"/>
<!-- Free-text EVENT the adapter NEVER feeds -> stays UNAVAILABLE. -->
<DataItem category="EVENT" id="fixture_block" name="Pblock" type="BLOCK"/>
<!--
The write-back target (MTCONNECT_WRITE_TARGET overrides the id when the suite is
pointed at a real Agent). Free-text EVENT, so any string a write leg sends is a
legal value, and never fed by the adapter, so nothing races the assertion.
-->
<DataItem category="EVENT" id="fixture_writeback" name="Pwriteback" type="PROGRAM_COMMENT"/>
<!-- DATA_SET representation demotes to String even though the Agent sends key=value entries. -->
<DataItem category="EVENT" id="fixture_varset" name="Pvars" type="VARIABLE" representation="DATA_SET"/>
<DataItem category="CONDITION" id="fixture_logic" name="Plogic" type="LOGIC_PROGRAM"/>
@@ -22,8 +22,18 @@ BufferSize = 14
CheckpointFrequency = 1000
MaxAssets = 128
# MTConnect is a read-only source for this driver; the Agent must not accept writes.
AllowPut = false
# Writes ARE in scope for this fixture, because the driver now implements IWritable (see
# docs/plans/2026-08-05-mtconnect-writeback-design.md). The mechanism is this very key: with
# AllowPut on, POST {agent}/{device}?{dataItemId}={value} sets what the Agent REPORTS for that
# observation. It is injection into the Agent's buffer -- visible to every consumer of the
# Agent, overwritten by the next SHDR update for the same DataItem -- and never a command to a
# machine. The integration suite's write legs (round-trip, agent refusal, disabled gate) need
# it on; with the driver's own write.enabled off, the shipped read-only behaviour is unchanged.
#
# No AllowPutFrom is set, so any host that can reach the port may write. Acceptable for a
# throwaway test Agent on the fixture network; NOT acceptable for a plant Agent, where the
# allow-list is the second half of the gate.
AllowPut = true
SchemaVersion = 2.0
MonitorConfigFiles = false
@@ -55,14 +55,23 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests;
/// PART_COUNT) <c>Assert.Skip</c>s rather than passing vacuously.
/// </para>
/// <para>
/// <b>Two things this suite deliberately does NOT cover, with reasons.</b>
/// (a) <b><c>MTConnectError</c> under HTTP 200.</b> The driver handles it and canned tests
/// <b>One thing this suite deliberately does NOT cover, with a reason.</b>
/// <b><c>MTConnectError</c> under HTTP 200.</b> The driver handles it and canned tests
/// pin it, but a modern Agent cannot be made to produce it: <c>mtconnect/agent</c> 2.7
/// answers an unknown device with <b>404</b> and an out-of-range sequence with <b>400</b>,
/// each carrying a well-formed <c>MTConnectError</c> body (verified live). The under-200
/// shape belongs to older/third-party Agents and stays canned-only.
/// (b) <b>Writes.</b> MTConnect is a read-only source protocol and the driver implements no
/// <c>IWritable</c>; the fixture Agent runs <c>AllowPut = false</c> to match.
/// </para>
/// <para>
/// <b>Writes ARE covered now</b> — the driver gained <c>IWritable</c> and the fixture Agent
/// runs <c>AllowPut = true</c> to match (<c>Docker/agent.cfg</c>). Three legs: the round trip
/// (a driver write the Agent then reports back), the Agent's own refusal of an undeclared
/// DataItem mapping to <c>BadCommunicationError</c>, and the <c>write.enabled</c> gate
/// refusing with <c>BadNotWritable</c> without touching the Agent. The injected target is
/// <c>fixture_writeback</c>, a DataItem the fixture's adapter never feeds, so an observed
/// value can only have come from the write under test. A write is <b>observation
/// injection</b>, not machine command: it sets what the Agent reports, which is exactly why
/// a live Agent is the only thing that can prove it.
/// </para>
/// </remarks>
[Collection(MTConnectAgentCollection.Name)]
@@ -83,9 +92,40 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
private const uint BadNoCommunication = 0x80310000u;
/// <summary>The Agent answered and refused the write (non-2xx, or an <c>MTConnectError</c> under 200).</summary>
private const uint BadCommunicationError = 0x80050000u;
/// <summary>No DataItem this driver serves answers to the reference — decided locally, before the wire.</summary>
private const uint BadNodeIdUnknown = 0x80340000u;
/// <summary><c>write.enabled</c> is off (or the DataItem is CONDITION-category): nothing is attempted.</summary>
private const uint BadNotWritable = 0x803B0000u;
/// <summary>
/// Overrides the DataItem the write legs inject into, for a run pointed at a real Agent whose
/// model has no <c>fixture_writeback</c>.
/// </summary>
private const string WriteTargetEnvVar = "MTCONNECT_WRITE_TARGET";
/// <summary>
/// The fixture's dedicated write target: a free-text EVENT the SHDR adapter NEVER feeds, so a
/// value observed on it can only have come from the write under test. Deliberately NOT
/// <c>fixture_block</c> — the first successful write would take that item off
/// <c>UNAVAILABLE</c> for the life of the container and rob
/// <see cref="Read_maps_an_unavailable_observation_to_BadNoCommunication"/> of its subject.
/// </summary>
private const string DefaultWriteTargetId = "fixture_writeback";
/// <summary>How long a subscription test waits for the live <c>/sample</c> stream to move a value.</summary>
private static readonly TimeSpan StreamWait = TimeSpan.FromSeconds(45);
/// <summary>
/// How long a write leg waits for the Agent to report the injected observation on
/// <c>/current</c>. A PUT lands in the Agent's buffer synchronously, so this is slack for the
/// HTTP round trips rather than for any processing the Agent has to schedule.
/// </summary>
private static readonly TimeSpan WriteVisibleWait = TimeSpan.FromSeconds(15);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
// ---- reachability ----
@@ -537,6 +577,169 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
}
}
// ---- writes: the Agent's AllowPut surface ----
/// <summary>
/// <b>The write round trip, and the only thing that can prove the PUT surface at all.</b> A
/// driver write must be accepted by the Agent and must then be what the Agent
/// <i>reports</i> for that DataItem — read back off <c>/current</c> over plain HTTP (not
/// through the parser under test), and then through the driver's own read path.
/// </summary>
/// <remarks>
/// <para>
/// <b>The value is unique per run</b> (a <see cref="Environment.TickCount64"/> suffix).
/// The Agent keeps an injected observation for the life of the container, so a constant
/// would be satisfied by a previous run's leftover and the test would pass against a
/// driver whose write never left the process.
/// </para>
/// <para>
/// <b>The target is a DataItem the SHDR adapter never feeds</b>, so no observation but
/// this write can put a value on it. Ring-buffer eviction is not a hazard here even
/// though the fixture's stream is busy: <c>BufferSize = 14</c> in <c>agent.cfg</c> is an
/// exponent (2^14 = 16384 observations), and <c>/current</c> is answered from the
/// Agent's checkpoint — the newest observation per DataItem — which survives the ring
/// wrapping past it.
/// </para>
/// </remarks>
[Fact(Timeout = TestTimeoutMs)]
public async Task Write_injects_an_observation_the_agent_then_reports()
{
SkipIfDown();
var target = ResolveWriteTarget();
if (target is null)
{
Assert.Skip(NoWriteTargetReason());
}
var (dataItemId, deviceName) = target!.Value;
var value = $"OTOPCUA-WRITE-{Environment.TickCount64:X}";
await using var live = await LiveDriverAsync([Tag(dataItemId, DriverDataType.String)], writeEnabled: true);
var results = await live.Driver.WriteAsync([new WriteRequest(dataItemId, value)], Ct);
results.Count.ShouldBe(1);
results[0].StatusCode.ShouldBe(
Good,
$"the Agent at {agent.AgentUri} must accept a write to DataItem '{dataItemId}' on device " +
$"'{deviceName}'. A refusal here is most often AllowPut still false in the Agent's config.");
var observed = await WaitForObservationAsync(dataItemId, value, WriteVisibleWait);
observed.ShouldBe(
value,
$"the Agent must REPORT the injected value on /current within {WriteVisibleWait.TotalSeconds:F0}s; " +
"a Good write whose value never appears is a write that went nowhere");
var reads = await live.Driver.ReadAsync([dataItemId], Ct);
reads.Count.ShouldBe(1);
reads[0].StatusCode.ShouldBe(Good, "the injected observation must read back through the driver's own path");
reads[0].Value.ShouldBe(value);
}
/// <summary>
/// <b>The Agent's own refusal, mapped to <c>BadCommunicationError</c>.</b> A write naming a
/// DataItem the Agent does not declare reaches the wire and comes back refused.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this reaches the Agent at all, when an unknown reference is normally answered
/// locally.</b> <c>BuildWriteTargets</c> derives a write target for every DataItem the
/// <i>binding</i> serves — i.e. every authored tag — resolving the device from the probe
/// model and falling back to the configured <c>deviceName</c>. So an authored tag naming
/// an id the Agent never declared, on a driver whose <c>deviceName</c> IS configured,
/// resolves to a real URL and is POSTed. That is precisely the shape a stale tag has after
/// a machine's DataItem is renamed, and the Agent's answer to it is the refusal this leg
/// pins.
/// </para>
/// <para>
/// <b>The second half is the falsifiability control</b>, and without it the leg proves
/// nothing: the SAME unknown id on a driver with NO configured device name has no URL to
/// POST to and must be refused locally as <c>BadNodeIdUnknown</c>. Two different codes for
/// the same id is what shows the first write actually went to the Agent rather than being
/// short-circuited into a look-alike failure.
/// </para>
/// </remarks>
[Fact(Timeout = TestTimeoutMs)]
public async Task Write_to_a_data_item_the_agent_does_not_declare_reports_the_agents_refusal()
{
SkipIfDown();
var target = ResolveWriteTarget();
if (target is null)
{
Assert.Skip(NoWriteTargetReason());
}
var deviceName = target!.Value.DeviceName;
var unknownId = $"otopcua_no_such_data_item_{Environment.TickCount64:X}";
await using (var live = await LiveDriverAsync(
[Tag(unknownId, DriverDataType.String)], writeEnabled: true, deviceName: deviceName))
{
var refused = await live.Driver.WriteAsync([new WriteRequest(unknownId, "refuse-me")], Ct);
refused.Count.ShouldBe(1);
refused[0].StatusCode.ShouldBe(
BadCommunicationError,
$"the Agent at {agent.AgentUri} must refuse a write to DataItem '{unknownId}', which device " +
$"'{deviceName}' does not declare, and the driver must report that refusal as an Agent " +
"failure rather than as success");
}
await using var noDevice = await LiveDriverAsync([Tag(unknownId, DriverDataType.String)], writeEnabled: true);
var local = await noDevice.Driver.WriteAsync([new WriteRequest(unknownId, "refuse-me")], Ct);
local.Count.ShouldBe(1);
local[0].StatusCode.ShouldBe(
BadNodeIdUnknown,
"with no device resolvable there is no URL to POST to, so the refusal must be decided locally — " +
"and a run where BOTH writes report the same code proves nothing about the Agent");
}
/// <summary>
/// <b>The driver-side gate, which is the half an operator controls.</b> With
/// <c>write.enabled</c> absent the same write is refused <c>BadNotWritable</c> — and the
/// Agent's <c>/current</c> is untouched, which is the assertion that distinguishes "refused"
/// from "reported as refused and sent anyway".
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Write_is_refused_without_touching_the_agent_when_write_is_not_enabled()
{
SkipIfDown();
var target = ResolveWriteTarget();
if (target is null)
{
Assert.Skip(NoWriteTargetReason());
}
var dataItemId = target!.Value.DataItemId;
var value = $"OTOPCUA-DISABLED-{Environment.TickCount64:X}";
var before = await ObservationValueAsync(dataItemId);
// No writeEnabled: the driver's own gate, with the Agent's AllowPut left on.
await using var live = await LiveDriverAsync([Tag(dataItemId, DriverDataType.String)]);
var results = await live.Driver.WriteAsync([new WriteRequest(dataItemId, value)], Ct);
results.Count.ShouldBe(1);
results[0].StatusCode.ShouldBe(
BadNotWritable,
"write.enabled is absent, so the honest answer is 'this is not writable' — not a comms failure");
// Long enough that a write which HAD been dispatched would be visible: the round-trip leg
// observes one inside WriteVisibleWait, and this waits for the same value to appear.
var after = await WaitForObservationAsync(dataItemId, value, TimeSpan.FromSeconds(3));
after.ShouldNotBe(value, "a gated write must never reach the Agent's observation buffer");
after.ShouldBe(
before,
$"nothing else feeds DataItem '{dataItemId}', so its reported value must be exactly what it was " +
"before the gated write");
}
// ---- helpers ----
private void SkipIfDown()
@@ -569,13 +772,17 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
return port;
}
private MTConnectDriverOptions LiveOptions(string? deviceName = null) =>
private MTConnectDriverOptions LiveOptions(string? deviceName = null, bool writeEnabled = false) =>
new()
{
AgentUri = agent.AgentUri,
DeviceName = deviceName,
RequestTimeoutMs = 15_000,
// Off unless a write leg asks for it: every other test in this suite must run against the
// shipped read-only driver, whatever the fixture Agent's AllowPut says.
Write = new MTConnectWriteOptions { Enabled = writeEnabled },
// Faster than the 1000 ms default so a subscription sees several distinct chunks inside
// StreamWait; the heartbeat stays well under it so a silent Agent is caught, not waited on.
SampleIntervalMs = 200,
@@ -597,6 +804,10 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
sampleCount = options.SampleCount,
heartbeatMs = options.HeartbeatMs,
probe = new { enabled = false },
// Sourced from the options rather than a separate parameter, so the config the driver
// parses can never disagree with the options object a test thinks it built.
write = new { enabled = options.Write.Enabled },
tags = (tags ?? []).Select(t => new
{
fullName = t.FullName,
@@ -607,9 +818,16 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
private static MTConnectTagDefinition Tag(string dataItemId, DriverDataType type) => new(dataItemId, type);
/// <summary>Initializes a driver against the live Agent with the supplied authored tags.</summary>
private async Task<LiveDriver> LiveDriverAsync(IEnumerable<MTConnectTagDefinition> tags)
/// <param name="tags">The authored tags, by DataItem id.</param>
/// <param name="writeEnabled">The driver's own <c>write.enabled</c> gate; off by default.</param>
/// <param name="deviceName">
/// The configured device scope, and the write path's device fallback for a DataItem the probe
/// model does not declare. Null (the default) leaves the driver unscoped.
/// </param>
private async Task<LiveDriver> LiveDriverAsync(
IEnumerable<MTConnectTagDefinition> tags, bool writeEnabled = false, string? deviceName = null)
{
var options = LiveOptions();
var options = LiveOptions(deviceName, writeEnabled);
var driver = new MTConnectDriver(options, "mtconnect-live");
await driver.InitializeAsync(ConfigJson(options, tags), Ct);
@@ -650,6 +868,112 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
return XDocument.Parse(body);
}
/// <summary>
/// The DataItem the write legs inject into, and the device that owns it — or <c>null</c> when
/// this Agent declares nothing suitable, which is a skip rather than a failure.
/// </summary>
/// <remarks>
/// Resolved from the Agent's own <c>/probe</c> like every other expectation in this suite, and
/// overridable via <see cref="WriteTargetEnvVar"/> so a run pointed at a real machine tool can
/// name a DataItem its operator is willing to have injected. CONDITION-category items and the
/// TIME_SERIES / DATA_SET / TABLE representations are excluded because <c>WriteAsync</c>
/// itself refuses them — pointing a write leg at one would assert the wrong refusal.
/// </remarks>
private (string DataItemId, string DeviceName)? ResolveWriteTarget()
{
var id = (Environment.GetEnvironmentVariable(WriteTargetEnvVar) ?? DefaultWriteTargetId).Trim();
var declared = agent.DeclaredDataItems
.FirstOrDefault(d => string.Equals(d.Id, id, StringComparison.Ordinal));
if (declared is null ||
Is(declared.Category, "CONDITION") ||
Is(declared.Representation, "TIME_SERIES") ||
Is(declared.Representation, "DATA_SET") ||
Is(declared.Representation, "TABLE"))
{
return null;
}
var device = OwningDeviceName(id);
return device is null ? null : (id, device);
}
/// <summary>The skip message for a run whose Agent declares no usable write target.</summary>
private string NoWriteTargetReason() =>
$"The Agent at {agent.AgentUri} declares no writable DataItem " +
$"'{Environment.GetEnvironmentVariable(WriteTargetEnvVar) ?? DefaultWriteTargetId}' " +
$"(a non-CONDITION, scalar item under a Device). Set {WriteTargetEnvVar} to a DataItem this " +
"Agent declares and is safe to inject an observation into, or run against the fixture's own " +
"Devices.xml, which carries a never-fed one for exactly this purpose.";
/// <summary>
/// The device a DataItem is declared under, addressed the way the Agent's PUT URL addresses
/// it: by <c>name</c>, falling back to <c>id</c> for a nameless device.
/// </summary>
private string? OwningDeviceName(string dataItemId)
{
var element = agent.ProbeDocument?
.Descendants()
.FirstOrDefault(e =>
e.Name.LocalName == "DataItem" &&
string.Equals((string?)e.Attribute("id"), dataItemId, StringComparison.Ordinal));
var device = element?.Ancestors().FirstOrDefault(a => a.Name.LocalName == "Device");
var name = (string?)device?.Attribute("name") ?? (string?)device?.Attribute("id");
return string.IsNullOrWhiteSpace(name) ? null : name;
}
/// <summary>
/// What the Agent currently reports for one DataItem, straight off <c>/current</c> — the raw
/// observation text (<c>UNAVAILABLE</c> included), or <c>null</c> when the snapshot carries no
/// observation for it at all.
/// </summary>
private async Task<string?> ObservationValueAsync(string dataItemId) =>
(await CurrentAsync())
.Descendants()
.Where(e => !e.HasElements)
.Where(e => string.Equals((string?)e.Attribute("dataItemId"), dataItemId, StringComparison.Ordinal))
.Select(e => e.Value.Trim())
.LastOrDefault();
/// <summary>
/// Polls <c>/current</c> until the DataItem reports <paramref name="expected"/> or the wait
/// runs out, returning whatever it last observed either way.
/// </summary>
/// <remarks>
/// Returning the last observation rather than a bool is deliberate: a failing assertion can
/// then print what the Agent WAS reporting, which is the difference between "the write never
/// landed" and "the write landed as something else" (a formatting defect).
/// </remarks>
private async Task<string?> WaitForObservationAsync(string dataItemId, string expected, TimeSpan timeout)
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(Ct);
deadline.CancelAfter(timeout);
string? observed;
do
{
observed = await ObservationValueAsync(dataItemId);
if (string.Equals(observed, expected, StringComparison.Ordinal))
{
return observed;
}
try
{
await Task.Delay(TimeSpan.FromMilliseconds(500), deadline.Token);
}
catch (OperationCanceledException) when (!Ct.IsCancellationRequested)
{
return observed;
}
}
while (true);
}
/// <summary>
/// The DataItem ids the Agent is currently reporting a parseable number for: the live subset
/// a read or subscription can make a non-vacuous statement about.
@@ -355,6 +355,32 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
}
}
/// <summary>
/// Every write this Agent was asked to take, in order — the record a driver-level write test
/// asserts against (which DataItem, on which device, carrying what).
/// </summary>
public ConcurrentQueue<(string DeviceName, string DataItemId, string Value)> Writes { get; } = new();
/// <summary>
/// What <see cref="WriteObservationAsync"/> answers with. Defaults to acceptance; set it to a
/// refusal to script the Agent's <c>MTConnectError</c> leg.
/// </summary>
public MTConnectWriteOutcome WriteOutcome { get; set; } = new(Accepted: true, AgentError: null);
/// <summary>When set, a write throws this instead of answering — the transport-failure leg.</summary>
public Exception? WriteFailure { get; set; }
/// <inheritdoc/>
public Task<MTConnectWriteOutcome> WriteObservationAsync(
string deviceName, string dataItemId, string value, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
cancellationToken.ThrowIfCancellationRequested();
Writes.Enqueue((deviceName, dataItemId, value));
return WriteFailure is null ? Task.FromResult(WriteOutcome) : throw WriteFailure;
}
/// <summary>
/// Queues a chunk onto the scripted <c>/sample</c> stream and waits until the consumer has
/// processed it. This is the deterministic replacement for "wait a bit and hope the pump
@@ -0,0 +1,356 @@
using System.Net;
using System.Text;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// The wire contract of <see cref="MTConnectAgentClient.WriteObservationAsync"/> — the Agent's
/// <c>AllowPut</c> surface. Everything here runs through a stubbed
/// <see cref="HttpMessageHandler"/> via the internal test-seam constructor, so the verb, the
/// exact request URI, and each response shape are asserted with no socket and no Agent.
/// </summary>
/// <remarks>
/// <para>
/// <b>The URI assertions are the point of this class, not incidental detail.</b> A write is
/// nothing but a URL: get the escaping or the device segment wrong and the Agent still
/// answers 200, having stored a value under a DataItem id nobody asked about — a corruption
/// with no error anywhere, visible only on a later read-back. There is no encoder to unit
/// test, so this is the only place that shape is pinned.
/// </para>
/// <para>
/// <b>The refusal legs pin the split the seam contract rests on:</b> an Agent that answered
/// and said no is a returned outcome, while an Agent that could not be reached throws. The
/// 200-carrying-an-<c>MTConnectError</c> case is why the status code alone can never decide
/// it — that is the shape a real cppagent uses to report every objection.
/// </para>
/// </remarks>
[Trait("Category", "Unit")]
public sealed class MTConnectAgentClientWriteTests
{
// ── stub handler ─────────────────────────────────────────────────────────
/// <summary>Answers every request with a canned response (or throws) via a delegate, with no socket.</summary>
private sealed class StubHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> respond)
: HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct) =>
respond(request, ct);
}
/// <summary>Records the requests it is handed, then answers each with <paramref name="respond"/>.</summary>
private sealed class RecordingHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
{
public List<(HttpMethod Method, Uri Uri)> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
Requests.Add((request.Method, request.RequestUri!));
return Task.FromResult(respond(request));
}
}
private static MTConnectDriverOptions Options(string agentUri = "http://stub-agent:5000") =>
new() { AgentUri = agentUri };
private static HttpResponseMessage Ok(string body = "") => new(HttpStatusCode.OK)
{
Content = new StringContent(body, Encoding.UTF8, "text/plain"),
};
private static HttpResponseMessage XmlOk(string xml) => new(HttpStatusCode.OK)
{
Content = new StringContent(xml, Encoding.UTF8, "text/xml"),
};
/// <summary>Exactly the shape a cppagent uses to refuse: an error document served under HTTP 200.</summary>
private const string MTConnectErrorXml =
"""
<?xml version="1.0" encoding="UTF-8"?>
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
<Header creationTime="2026-08-05T12:00:00Z" sender="stub-agent" instanceId="1" version="1.3.0.12" bufferSize="1"/>
<Errors>
<Error errorCode="UNSUPPORTED">Cannot find device data item: nosuchitem</Error>
</Errors>
</MTConnectError>
""";
// ── verb + URI ───────────────────────────────────────────────────────────
[Fact]
public async Task A_write_is_issued_as_a_POST()
{
var handler = new RecordingHandler(_ => Ok());
using var client = new MTConnectAgentClient(Options(), handler);
await client.WriteObservationAsync("StubDevice", "dev1_msg", "hello", CancellationToken.None);
handler.Requests.ShouldHaveSingleItem().Method.ShouldBe(HttpMethod.Post);
}
/// <summary>
/// The device is a <b>path segment</b> and the DataItem id/value are a <b>query pair</b> —
/// the Agent's documented AllowPut shape. Asserted as the whole URI rather than in parts,
/// because a fragment-wise assertion passes on a URI that is assembled in the wrong order.
/// </summary>
[Fact]
public async Task The_request_uri_is_the_agents_AllowPut_shape()
{
var handler = new RecordingHandler(_ => Ok());
using var client = new MTConnectAgentClient(Options(), handler);
await client.WriteObservationAsync("StubDevice", "dev1_msg", "hello", CancellationToken.None);
handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri
.ShouldBe("http://stub-agent:5000/StubDevice?dev1_msg=hello");
}
/// <summary>
/// A value carrying a space, an <c>&amp;</c> and a <c>=</c> — each of which silently ends the
/// query pair early if it reaches the wire raw — plus a DataItem id needing the same
/// treatment. Unescaped, the Agent would take a 200 and store the truncated value.
/// </summary>
[Fact]
public async Task The_dataItemId_and_value_are_percent_encoded()
{
var handler = new RecordingHandler(_ => Ok());
using var client = new MTConnectAgentClient(Options(), handler);
await client.WriteObservationAsync(
"Stub Device", "dev1 msg&x", "Coolant low = 20% & rising", CancellationToken.None);
handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri.ShouldBe(
"http://stub-agent:5000/Stub%20Device?dev1%20msg%26x=Coolant%20low%20%3D%2020%25%20%26%20rising");
}
/// <summary>
/// The base URI's own path is preserved (an Agent behind a reverse-proxy prefix) and a
/// trailing slash does not double up — the same left-part rule the read paths use.
/// </summary>
[Fact]
public async Task A_based_agent_uri_keeps_its_path_prefix_and_does_not_double_its_slash()
{
var handler = new RecordingHandler(_ => Ok());
using var client = new MTConnectAgentClient(Options("http://stub-agent:5000/mtconnect/"), handler);
await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri
.ShouldBe("http://stub-agent:5000/mtconnect/StubDevice?dev1_msg=hi");
}
/// <summary>
/// The <c>deviceName</c> argument decides the path — <b>not</b> the options' device scope. A
/// multi-device Agent is addressed per write, so an implementation that reused the read
/// paths' scoped URI would write every DataItem onto one device.
/// </summary>
[Fact]
public async Task The_device_segment_comes_from_the_argument_not_the_configured_device_scope()
{
var handler = new RecordingHandler(_ => Ok());
using var client = new MTConnectAgentClient(
new MTConnectDriverOptions { AgentUri = "http://stub-agent:5000", DeviceName = "ScopedDevice" },
handler);
await client.WriteObservationAsync("OtherDevice", "dev2_msg", "hi", CancellationToken.None);
handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri
.ShouldBe("http://stub-agent:5000/OtherDevice?dev2_msg=hi");
}
// ── acceptance ───────────────────────────────────────────────────────────
/// <summary>
/// An empty 2xx body is what a real cppagent sends on success. Treating a body-less answer
/// as anything but acceptance would refuse every write that actually worked.
/// </summary>
[Fact]
public async Task An_empty_200_is_accepted()
{
using var client = new MTConnectAgentClient(Options(), new StubHandler((_, _) => Task.FromResult(Ok())));
var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
outcome.Accepted.ShouldBeTrue();
outcome.AgentError.ShouldBeNull();
}
/// <summary>A 200 carrying a non-MTConnect body is still acceptance — only an error document refuses.</summary>
[Fact]
public async Task A_200_whose_body_is_not_an_error_document_is_accepted()
{
using var client = new MTConnectAgentClient(
Options(), new StubHandler((_, _) => Task.FromResult(Ok("<html><body>OK</body></html>"))));
var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
outcome.Accepted.ShouldBeTrue();
outcome.AgentError.ShouldBeNull();
}
// ── refusal ──────────────────────────────────────────────────────────────
/// <summary>
/// The load-bearing refusal: HTTP 200 with an <c>MTConnectError</c> body.
/// <c>EnsureSuccessStatusCode</c> never fires here, so an implementation that trusted the
/// status code would report every rejected write as a success.
/// </summary>
[Fact]
public async Task An_MTConnectError_under_200_is_a_refusal_carrying_the_agents_own_text()
{
using var client = new MTConnectAgentClient(
Options(), new StubHandler((_, _) => Task.FromResult(XmlOk(MTConnectErrorXml))));
var outcome = await client.WriteObservationAsync(
"StubDevice", "nosuchitem", "hi", CancellationToken.None);
outcome.Accepted.ShouldBeFalse();
outcome.AgentError.ShouldNotBeNull();
outcome.AgentError.ShouldContain("UNSUPPORTED");
outcome.AgentError.ShouldContain("Cannot find device data item: nosuchitem");
}
/// <summary>
/// An <c>MTConnectError</c> document carrying no readable <c>&lt;Error&gt;</c> detail is
/// still a refusal. The Agent said no; that it said so unhelpfully changes nothing. The trap
/// this pins is that the natural implementation — "return the joined error text, or null if
/// there is none" — hands back the same <c>null</c> that means "this body was not an error
/// document at all", silently converting the refusal into an acceptance.
/// </summary>
[Fact]
public async Task An_error_document_with_no_readable_detail_is_still_a_refusal()
{
const string HollowError =
"""
<?xml version="1.0" encoding="UTF-8"?>
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
<Header creationTime="2026-08-05T12:00:00Z" sender="stub-agent" instanceId="1" version="1.3.0.12" bufferSize="1"/>
<Errors/>
</MTConnectError>
""";
using var client = new MTConnectAgentClient(
Options(), new StubHandler((_, _) => Task.FromResult(XmlOk(HollowError))));
var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
outcome.Accepted.ShouldBeFalse();
outcome.AgentError.ShouldNotBeNullOrWhiteSpace();
}
/// <summary>The same, for an <c>&lt;Error&gt;</c> element that is present but blank.</summary>
[Fact]
public async Task An_error_document_whose_error_element_is_blank_is_still_a_refusal()
{
const string BlankError =
"""
<?xml version="1.0" encoding="UTF-8"?>
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
<Errors><Error> </Error></Errors>
</MTConnectError>
""";
using var client = new MTConnectAgentClient(
Options(), new StubHandler((_, _) => Task.FromResult(XmlOk(BlankError))));
var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
outcome.Accepted.ShouldBeFalse();
outcome.AgentError.ShouldNotBeNullOrWhiteSpace();
}
[Fact]
public async Task A_non_success_status_is_a_refusal_naming_the_status()
{
using var client = new MTConnectAgentClient(
Options(),
new StubHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent(string.Empty),
})));
var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
outcome.Accepted.ShouldBeFalse();
outcome.AgentError.ShouldNotBeNull();
outcome.AgentError.ShouldContain("404");
}
// ── transport failure throws, never a false outcome ──────────────────────
/// <summary>
/// "Unreachable" must stay distinguishable from "rejected". Returning
/// <c>Accepted = false</c> here would collapse a comms fault into a per-tag refusal, which
/// the driver maps to a different status code and an operator resolves differently.
/// </summary>
[Fact]
public async Task A_transport_failure_propagates_rather_than_becoming_a_refusal()
{
var boom = new HttpRequestException("dial failed");
using var client = new MTConnectAgentClient(
Options(), new StubHandler((_, _) => Task.FromException<HttpResponseMessage>(boom)));
var thrown = await Should.ThrowAsync<HttpRequestException>(
() => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None));
thrown.ShouldBeSameAs(boom);
}
/// <summary>
/// A frozen Agent must surface as a bounded <see cref="TimeoutException"/> naming the URI,
/// not park the caller — the same per-call deadline discipline the read legs carry
/// (arch-review R2-01).
/// </summary>
[Fact(Timeout = 10000)]
public async Task A_frozen_agent_times_out_within_the_per_call_deadline()
{
using var client = new MTConnectAgentClient(
new MTConnectDriverOptions { AgentUri = "http://stub-agent:5000", RequestTimeoutMs = 300 },
new StubHandler(async (_, ct) =>
{
await Task.Delay(Timeout.Infinite, ct);
throw new UnreachableException();
}));
await Should.ThrowAsync<TimeoutException>(
() => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None));
}
/// <summary>Local stand-in so the stub handler's "never returns" branch has a throw statement to satisfy flow analysis.</summary>
private sealed class UnreachableException : Exception;
/// <summary>Cancelling is cancellation, never a refusal — the caller's own token wins over the deadline.</summary>
[Fact]
public async Task A_cancelled_token_surfaces_as_cancellation_not_as_a_refusal()
{
using var cts = new CancellationTokenSource();
using var client = new MTConnectAgentClient(
Options(),
new StubHandler(async (_, ct) =>
{
await Task.Delay(Timeout.Infinite, ct);
throw new UnreachableException();
}));
var write = client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", cts.Token);
await cts.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(() => write);
}
// ── disposal ─────────────────────────────────────────────────────────────
[Fact]
public async Task Writing_through_a_disposed_client_throws_ObjectDisposedException()
{
var client = new MTConnectAgentClient(Options(), new StubHandler((_, _) => Task.FromResult(Ok())));
client.Dispose();
await Should.ThrowAsync<ObjectDisposedException>(
() => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None));
}
}
@@ -45,12 +45,16 @@ public sealed class MTConnectDiscoverTests
"dev1_system_cond",
];
private static MTConnectDriverOptions Opts(string? deviceName = null) =>
private static MTConnectDriverOptions Opts(string? deviceName = null, bool writeEnabled = false) =>
new()
{
AgentUri = AgentUri,
DeviceName = deviceName,
// Off unless a test says otherwise — the shipped default, and what every pre-write-back
// expectation in this file was written against.
Write = new MTConnectWriteOptions { Enabled = writeEnabled },
// Discovery is a pure read of the /probe device model: it must enumerate what the Agent
// declares, NOT what an operator has already authored. Left empty on purpose so a driver
// that streamed its authored tag table instead could not pass.
@@ -324,10 +328,10 @@ public sealed class MTConnectDiscoverTests
}
/// <summary>
/// MTConnect is a read-only protocol and this driver implements no <c>IWritable</c>, so every
/// browsed leaf is <see cref="SecurityClassification.ViewOnly"/> — the tier that is read-only
/// from OPC UA. Nothing is historized by discovery either: historization is authored per tag,
/// never inferred from a device model.
/// Write-back is off by default, so every browsed leaf is
/// <see cref="SecurityClassification.ViewOnly"/> — the tier that is read-only from OPC UA, and
/// exactly what this driver shipped. Nothing is historized by discovery either: historization
/// is authored per tag, never inferred from a device model.
/// </summary>
[Fact]
public async Task Discover_streams_every_leaf_view_only_and_not_historized()
@@ -341,6 +345,47 @@ public sealed class MTConnectDiscoverTests
cap.Variables.ShouldAllBe(v => v.Attr.Source == NodeSourceKind.Driver);
}
/// <summary>
/// With <c>write.enabled</c> on, the same probe model browses <see cref="SecurityClassification.Operate"/>
/// — but only for the leaves <c>WriteAsync</c> would actually accept. A browse that advertised a
/// tier the write path refuses is the "silent no-op in a new costume" failure MQTT's
/// <c>CanWrite</c> pins against; the flag is the ONLY difference between this test and the one
/// above, so a driver that ignored it could not pass both.
/// </summary>
[Fact]
public async Task Discover_streams_writable_leaves_as_operate_when_write_is_enabled()
{
var (driver, _) = await InitializedDriverAsync(Opts(writeEnabled: true));
var cap = await DiscoverAsync(driver);
var writable = cap.Variables.Where(v => !v.Attr.IsAlarm).ToList();
// Counted, not just ShouldAllBe: an empty sequence satisfies ShouldAllBe vacuously, so a
// discovery that streamed nothing at all would otherwise pass this test.
writable.Count.ShouldBe(FixtureDataItemIds.Length - 1);
writable.ShouldAllBe(v => v.Attr.SecurityClass == SecurityClassification.Operate);
}
/// <summary>
/// A CONDITION data item stays <see cref="SecurityClassification.ViewOnly"/> in <b>both</b>
/// modes: a condition PUT carries alarm semantics and a different <c>level|text</c> wire
/// format, so the write path refuses it and the browse must say so.
/// </summary>
/// <param name="writeEnabled">Whether the driver's write-back block is switched on.</param>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Discover_streams_a_condition_leaf_view_only_whether_or_not_write_is_enabled(bool writeEnabled)
{
var (driver, _) = await InitializedDriverAsync(Opts(writeEnabled: writeEnabled));
var cap = await DiscoverAsync(driver);
cap.Variables.Single(v => v.Attr.FullName == "dev1_system_cond")
.Attr.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
// ---- browse names ----
/// <summary>
@@ -56,6 +56,20 @@ public sealed class MTConnectDriverOptionsTests
reconnect.BackoffMultiplier.ShouldBeGreaterThan(1.0);
}
/// <summary>
/// Write-back is <b>off unless authored</b>: the driver shipped read-only, and the agent's
/// PUT surface is itself off by default, so a deployment that never mentions <c>write</c>
/// must keep exactly today's behaviour.
/// </summary>
[Fact]
public void Write_defaults_to_disabled()
{
var write = new MTConnectDriverOptions { AgentUri = "http://agent:5000" }.Write;
write.ShouldNotBeNull();
write.Enabled.ShouldBeFalse();
}
/// <summary>
/// <see cref="MTConnectDriverOptions.Tags"/> defaults to an <i>empty</i> collection, never
/// <c>null</c> — a null collection here is an operator-authorable NullReferenceException at
@@ -207,19 +207,25 @@ public sealed class MTConnectFactoryTests
}
/// <summary>
/// The other half of the capability pin: MTConnect's Agent surface is read-only, so the
/// instance must NOT be <see cref="IWritable"/>. If it ever became writable, the write gate
/// would start offering an operation the protocol cannot honour.
/// The other half of the capability pin: the instance IS <see cref="IWritable"/>, so the
/// runtime's write gate dispatches to it. This is the seam the concrete return type exists for
/// — the capability was added to the driver with no change to the factory, and a narrowed
/// return would have left it implemented but never dispatched.
/// </summary>
/// <remarks>
/// <b>Dispatchable is not enabled.</b> The config below authors no <c>write</c> block, so this
/// instance refuses every write with <c>BadNotWritable</c> — and the Agent's own
/// <c>AllowPut</c> is a second, independent gate. See <c>MTConnectWriteTests</c>.
/// </remarks>
[Fact]
public void Registered_delegate_returns_an_instance_that_is_not_writable()
public void Registered_delegate_returns_an_instance_that_dispatches_writes()
{
var registry = new DriverFactoryRegistry();
MTConnectDriverFactoryExtensions.Register(registry);
IDriver driver = registry.TryGet("MTConnect")!("mt1", "{\"agentUri\":\"http://a:5000\"}");
(driver is IWritable).ShouldBeFalse("the MTConnect Agent surface is read-only");
(driver is IWritable).ShouldBeTrue("MTConnect must dispatch writes (the Agent AllowPut surface)");
}
/// <summary>Registering into a null registry is a wiring bug, not a silent no-op.</summary>
@@ -349,4 +355,51 @@ public sealed class MTConnectFactoryTests
// above) hold for both entry points.
MTConnectDriverFactoryExtensions.CreateInstance("mt1", json).DriverInstanceId.ShouldBe("mt1");
}
/// <summary>
/// The <c>write</c> block reaches the options — the opt-in half of the two-layer gate on
/// write-back (the agent's own <c>AllowPut</c> is the other). Authored <c>true</c> arms it;
/// an authored <c>false</c> and an omitted block are the same thing, and both leave the
/// driver read-only.
/// </summary>
[Fact]
public void Write_section_round_trips_and_defaults_to_disabled_when_absent()
{
MTConnectDriver.ParseOptions(
"mt1", """{ "agentUri": "http://x:5000", "write": { "enabled": true } }""")
.Write.Enabled.ShouldBeTrue();
MTConnectDriver.ParseOptions(
"mt1", """{ "agentUri": "http://x:5000", "write": { "enabled": false } }""")
.Write.Enabled.ShouldBeFalse();
MTConnectDriver.ParseOptions("mt1", """{ "agentUri": "http://x:5000" }""")
.Write.Enabled.ShouldBeFalse();
// The factory shares that parse, so an armed instance is authorable through it too.
MTConnectDriverFactoryExtensions.CreateInstance(
"mt1", """{ "agentUri": "http://x:5000", "write": { "enabled": true } }""")
.DriverInstanceId.ShouldBe("mt1");
}
/// <summary>
/// A malformed <c>write</c> block fails <b>exactly as its siblings do</b> — the whole
/// document is one <c>JsonSerializer.Deserialize</c>, so a non-boolean <c>enabled</c> faults
/// the parse rather than silently defaulting to off. The <c>probe</c> leg is the control: it
/// pins that this is the surrounding convention and not stricter behaviour invented here.
/// </summary>
[Fact]
public void Malformed_write_block_faults_exactly_like_the_sibling_probe_block()
{
Should.Throw<InvalidOperationException>(() => MTConnectDriver.ParseOptions(
"mt1", """{ "agentUri": "http://x:5000", "probe": { "enabled": "yes" } }"""));
Should.Throw<InvalidOperationException>(() => MTConnectDriver.ParseOptions(
"mt1", """{ "agentUri": "http://x:5000", "write": { "enabled": "yes" } }"""));
// A null block is not malformed — the same "omitted means default" reading the sibling
// blocks get, since every DTO property is nullable.
MTConnectDriver.ParseOptions("mt1", """{ "agentUri": "http://x:5000", "write": null }""")
.Write.Enabled.ShouldBeFalse();
}
}
@@ -0,0 +1,425 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// <see cref="MTConnectDriver"/>'s <see cref="IWritable"/> leg — the batch contract, the per-slot
/// refusals, and the one thing the driver has to get right before the Agent is even dialled: which
/// device a DataItem belongs to.
/// </summary>
/// <remarks>
/// <para>
/// <b>The write itself is the live gate's job.</b> Everything below stops at the
/// <see cref="IMTConnectAgentClient"/> seam and asserts what the driver <i>asked</i> the Agent
/// for (<see cref="CannedAgentClient.Writes"/>), never that a fake was called for its own sake —
/// the same philosophy as <c>MqttDriverWriteTests</c>. What IS worth pinning offline is
/// everything that decides whether a write should reach an Agent at all, because that is where
/// a silent no-op would hide.
/// </para>
/// <para>
/// <b>A write is observation injection, not machine command</b> (design §2.2): it sets what the
/// Agent reports for a DataItem, and the next adapter update overwrites it. That is why the
/// feature is off at two independent gates — the Agent's <c>AllowPut</c>, and this driver's
/// <c>write.enabled</c> — and why the disabled leg below is the first test in the file.
/// </para>
/// </remarks>
public sealed class MTConnectWriteTests
{
private const string AgentUri = "http://fixture-agent:5000";
/// <summary>The device the canned <c>Fixtures/probe.xml</c> declares — <c>id="dev1" name="VMC-3Axis"</c>.</summary>
private const string FixtureDeviceName = "VMC-3Axis";
private const string PositionDataItemId = "dev1_pos";
private const string ConditionDataItemId = "dev1_system_cond";
private const string PositionRawPath = "Plant/MTConnect/dev1/Position";
private const string ConditionRawPath = "Plant/MTConnect/dev1/SystemCondition";
private const string UnknownRawPath = "Plant/MTConnect/dev1/NotATag";
private const uint Good = 0x00000000u;
private const uint BadNodeIdUnknown = 0x80340000u;
private const uint BadNotWritable = 0x803B0000u;
private const uint BadTypeMismatch = 0x80740000u;
private const uint BadCommunicationError = 0x80050000u;
private const uint BadNotConnected = 0x808A0000u;
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>One authored raw tag bound to a DataItem id, as the browse-commit flow writes it.</summary>
private static RawTagEntry Entry(string rawPath, string dataItemId) =>
new(rawPath, $$"""{"fullName":"{{dataItemId}}"}""", WriteIdempotent: false, DeviceName: "dev1");
private static MTConnectDriverOptions Opts(
bool writeEnabled = true, string? deviceName = null, params RawTagEntry[] rawTags) =>
new()
{
AgentUri = AgentUri,
DeviceName = deviceName,
Tags = [],
RawTags = rawTags,
Write = new MTConnectWriteOptions { Enabled = writeEnabled },
};
private static (MTConnectDriver Driver, CannedAgentClient Client) NewDriver(
MTConnectDriverOptions options, RecordingDriverLogger? logger = null)
{
var client = CannedAgentClient.FromFixtures();
return (new MTConnectDriver(options, "mt1", _ => client, logger), client);
}
private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedAsync(
MTConnectDriverOptions options, RecordingDriverLogger? logger = null)
{
var (driver, client) = NewDriver(options, logger);
await driver.InitializeAsync("{}", Ct);
return (driver, client);
}
/// <summary>The ordinary rig: write on, one writable position tag and the fixture's CONDITION item.</summary>
private static Task<(MTConnectDriver Driver, CannedAgentClient Client)> WritableDriverAsync(
RecordingDriverLogger? logger = null) =>
InitializedAsync(
Opts(
writeEnabled: true,
deviceName: null,
Entry(PositionRawPath, PositionDataItemId),
Entry(ConditionRawPath, ConditionDataItemId)),
logger);
// ---- the capability itself ----
[Fact]
public void MTConnectDriver_implements_IWritable()
// The interface's presence IS the feature: without it the runtime's capability dispatch never
// offers this driver a write, whatever a node advertises.
=> NewDriver(Opts()).Driver.ShouldBeAssignableTo<IWritable>();
// ---- the two independent gates ----
[Fact]
public async Task A_driver_with_write_disabled_refuses_every_slot_without_dialling_the_agent()
{
// The default. An existing deployment that never authors the block keeps today's read-only
// behaviour EXACTLY — including making no request an Agent could log or accept.
var (driver, client) = await InitializedAsync(
Opts(writeEnabled: false, deviceName: null, Entry(PositionRawPath, PositionDataItemId)));
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct);
results.Count.ShouldBe(1);
results[0].StatusCode.ShouldBe(BadNotWritable);
client.Writes.ShouldBeEmpty();
}
[Fact]
public async Task A_write_before_initialize_reports_not_connected()
{
// Matches ReadAsync: "this driver is not running" is the only true statement available, and it
// is NOT the same operator problem as an Agent that answered and refused.
var (driver, _) = NewDriver(Opts(true, null, Entry(PositionRawPath, PositionDataItemId)));
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct);
results[0].StatusCode.ShouldBe(BadNotConnected);
}
[Fact]
public async Task A_write_after_shutdown_reports_not_connected()
{
var (driver, _) = await WritableDriverAsync();
await driver.ShutdownAsync(Ct);
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct);
results[0].StatusCode.ShouldBe(BadNotConnected);
}
// ---- per-slot refusals ----
[Fact]
public async Task An_unknown_reference_is_refused_in_its_own_slot()
{
var (driver, client) = await WritableDriverAsync();
var results = await driver.WriteAsync([new WriteRequest(UnknownRawPath, 1.0)], Ct);
results[0].StatusCode.ShouldBe(BadNodeIdUnknown);
client.Writes.ShouldBeEmpty();
}
[Fact]
public async Task A_condition_data_item_is_refused_as_not_writable()
{
// CONDITION PUTs carry alarm semantics and a different level|text wire format — excluded from
// v1. BadNotWritable, not BadTypeMismatch: the value was never the problem.
var (driver, client) = await WritableDriverAsync();
var results = await driver.WriteAsync([new WriteRequest(ConditionRawPath, "NORMAL")], Ct);
results[0].StatusCode.ShouldBe(BadNotWritable);
client.Writes.ShouldBeEmpty();
}
[Fact]
public async Task A_null_value_is_refused_as_a_type_mismatch()
{
// A write IS a URL query value; there is no null to send. Refusing beats writing the empty
// string, which the Agent would accept as a real observation.
var (driver, client) = await WritableDriverAsync();
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, null)], Ct);
results[0].StatusCode.ShouldBe(BadTypeMismatch);
client.Writes.ShouldBeEmpty();
}
[Fact]
public async Task An_array_value_is_refused_as_a_type_mismatch()
{
// Convert.ToString would happily render "System.Double[]" and the Agent would happily store it:
// a silent corruption visible only on read-back. Array/TIME_SERIES writes are out of scope.
var (driver, client) = await WritableDriverAsync();
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, new[] { 1.0, 2.0 })], Ct);
results[0].StatusCode.ShouldBe(BadTypeMismatch);
client.Writes.ShouldBeEmpty();
}
[Fact]
public async Task A_value_whose_own_formatter_throws_costs_its_slot_and_nothing_more()
{
// Convert.ToString runs the value's OWN ToString — arbitrary code that may throw anything —
// and it runs OUTSIDE WriteAsync's per-slot try. A narrow catch filter let every exception
// other than Format/InvalidCast/Overflow escape WriteAsync and fail the WHOLE batch, breaking
// the never-throws contract. The load-bearing half of this test is the second slot: the good
// write still happens.
var (driver, client) = await WritableDriverAsync();
var results = await driver.WriteAsync(
[
new WriteRequest(PositionRawPath, new ThrowingFormatter()),
new WriteRequest(PositionRawPath, 42.5),
],
Ct);
results.Count.ShouldBe(2);
results[0].StatusCode.ShouldBe(BadTypeMismatch);
results[1].StatusCode.ShouldBe(Good);
client.Writes.ShouldHaveSingleItem().Value.ShouldBe("42.5");
}
/// <summary>A value whose <see cref="ToString"/> throws something no conversion filter would name.</summary>
private sealed class ThrowingFormatter
{
public override string ToString() =>
throw new InvalidOperationException("this formatter is broken");
}
// ---- what actually reaches the seam ----
[Fact]
public async Task An_accepted_write_reports_good_and_names_the_owning_device_and_data_item()
{
// The device NAME is resolved from the /probe model, not from config: the Agent's PUT surface
// is addressed per device, and a multi-device Agent must be addressed correctly with no extra
// authoring. The RawPath is translated to the DataItem id, exactly as the read path does.
var (driver, client) = await WritableDriverAsync();
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct);
results[0].StatusCode.ShouldBe(Good);
client.Writes.ShouldHaveSingleItem()
.ShouldBe((FixtureDeviceName, PositionDataItemId, "42.5"));
}
[Fact]
public async Task A_value_is_rendered_with_the_invariant_culture()
{
// A comma decimal separator would be accepted by the Agent and read back as a different number.
var (driver, client) = await WritableDriverAsync();
await driver.WriteAsync([new WriteRequest(PositionRawPath, 1234.5678d)], Ct);
client.Writes.ShouldHaveSingleItem().Value.ShouldBe("1234.5678");
}
[Fact]
public async Task A_boolean_is_rendered_as_its_invariant_true_false_string()
{
// Boolean.ToString is culture-independent, so the invariant rendering is "True"/"False" — NOT
// "1"/"0". Documented because it is the value an MTConnect consumer will see verbatim.
var (driver, client) = await WritableDriverAsync();
await driver.WriteAsync([new WriteRequest(PositionRawPath, true)], Ct);
client.Writes.ShouldHaveSingleItem().Value.ShouldBe("True");
}
// ---- device-name resolution ----
[Fact]
public async Task A_data_item_the_probe_does_not_declare_falls_back_to_the_configured_device()
{
// The dataItemId-keyed authoring surface (the driver CLI) and any tag whose DataItem the Agent
// has since dropped: config is the only device name available, and it is a real one.
var (driver, client) = await InitializedAsync(
Opts(writeEnabled: true, deviceName: "ConfiguredDevice", Entry(PositionRawPath, "not_in_probe")));
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 7)], Ct);
results[0].StatusCode.ShouldBe(Good);
client.Writes.ShouldHaveSingleItem().ShouldBe(("ConfiguredDevice", "not_in_probe", "7"));
}
[Fact]
public async Task A_data_item_with_no_resolvable_device_is_refused_as_an_unknown_node()
{
// Neither the probe model nor config names a device, so there is no URL to POST to. Refused
// rather than guessed: a wrong device path is a 404 the operator reads as "the Agent is broken".
var (driver, client) = await InitializedAsync(
Opts(writeEnabled: true, deviceName: null, Entry(PositionRawPath, "not_in_probe")));
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 7)], Ct);
results[0].StatusCode.ShouldBe(BadNodeIdUnknown);
client.Writes.ShouldBeEmpty();
}
// ---- how the Agent's own answers map ----
[Fact]
public async Task An_agent_refusal_maps_to_a_communication_error_and_logs_the_agents_own_text()
{
var logger = new RecordingDriverLogger();
var (driver, client) = await WritableDriverAsync(logger);
client.WriteOutcome = new MTConnectWriteOutcome(Accepted: false, AgentError: "UNSUPPORTED: no such device");
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct);
results[0].StatusCode.ShouldBe(BadCommunicationError);
logger.WarningsSnapshot().ShouldContain(w => w.Contains("UNSUPPORTED: no such device", StringComparison.Ordinal));
}
[Fact]
public async Task An_agent_refusal_never_reaches_the_health_LastError()
{
// Type-only discipline: LastError carries an exception TYPE, never Agent-authored text, and a
// refused write is not evidence the driver's read path is degraded either.
var (driver, client) = await WritableDriverAsync();
var before = driver.GetHealth();
client.WriteOutcome = new MTConnectWriteOutcome(Accepted: false, AgentError: "UNSUPPORTED: no such device");
await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct);
var after = driver.GetHealth();
after.LastError.ShouldBe(before.LastError);
after.State.ShouldBe(before.State);
}
[Fact]
public async Task A_transport_failure_maps_to_a_communication_error_without_throwing()
{
var (driver, client) = await WritableDriverAsync();
client.WriteFailure = new HttpRequestException("connection refused");
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct);
results[0].StatusCode.ShouldBe(BadCommunicationError);
}
// ---- batch contract ----
[Fact]
public async Task Cancellation_fills_the_remaining_slots_and_returns_the_batch()
{
// Never throws, even cancelled: the caller gets one status per request or the node-manager
// cannot tell which writes were attempted.
var (driver, client) = await WritableDriverAsync();
using var cts = new CancellationTokenSource();
await cts.CancelAsync();
var results = await driver.WriteAsync(
[new WriteRequest(PositionRawPath, 1.0), new WriteRequest(PositionRawPath, 2.0)], cts.Token);
results.Count.ShouldBe(2);
results.ShouldAllBe(r => r.StatusCode == BadCommunicationError);
client.Writes.ShouldBeEmpty();
}
[Fact]
public async Task Cancellation_mid_batch_leaves_the_slots_already_written_with_their_real_outcomes()
{
// The fill must start at the cancelled slot, not overwrite the batch. Slot 0 completed against
// the Agent before the token was cancelled, and reporting it BadCommunicationError would tell
// an operator a write failed that actually landed — the one thing worse than reporting a
// failure. Cancellation is injected from inside slot 1's own formatting, so the ordering is a
// fact of the run rather than a timing hope.
var (driver, client) = await WritableDriverAsync();
using var cts = new CancellationTokenSource();
var results = await driver.WriteAsync(
[
new WriteRequest(PositionRawPath, 42.5),
new WriteRequest(PositionRawPath, new CancellingFormatter(cts)),
new WriteRequest(PositionRawPath, 7.5),
],
cts.Token);
results.Count.ShouldBe(3);
results[0].StatusCode.ShouldBe(Good);
results[1].StatusCode.ShouldBe(BadCommunicationError);
results[2].StatusCode.ShouldBe(BadCommunicationError);
// Exactly the pre-cancellation slot reached the Agent.
client.Writes.ShouldHaveSingleItem().Value.ShouldBe("42.5");
}
/// <summary>Cancels the batch from inside its own rendering, then renders normally.</summary>
private sealed class CancellingFormatter(CancellationTokenSource cts)
{
public override string ToString()
{
cts.Cancel();
return "99";
}
}
[Fact]
public async Task One_bad_request_does_not_fail_the_others_in_the_batch()
{
// Mirrors ReadAsync: failing the whole call would let one stale reference reject every other
// write in the same OPC UA request.
var (driver, client) = await WritableDriverAsync();
var results = await driver.WriteAsync(
[
new WriteRequest(UnknownRawPath, 1.0),
new WriteRequest(ConditionRawPath, "NORMAL"),
new WriteRequest(PositionRawPath, 42.5),
],
Ct);
// Order-preserving, one status per request, each naming its own distinct cause.
results.Count.ShouldBe(3);
results[0].StatusCode.ShouldBe(BadNodeIdUnknown);
results[1].StatusCode.ShouldBe(BadNotWritable);
results[2].StatusCode.ShouldBe(Good);
client.Writes.ShouldHaveSingleItem().DataItemId.ShouldBe(PositionDataItemId);
}
[Fact]
public async Task An_empty_batch_is_answered_with_an_empty_result()
=> (await NewDriver(Opts()).Driver.WriteAsync([], Ct)).ShouldBeEmpty();
[Fact]
public async Task WriteAsync_rejects_a_null_batch()
=> await Should.ThrowAsync<ArgumentNullException>(
async () => await NewDriver(Opts()).Driver.WriteAsync(null!, Ct));
}