Files
lmxopcua/docs/plans/2026-08-05-mtconnect-writeback-design.md
T

11 KiB
Raw Blame History

MTConnect write-back + MQTT write-through record reconciliation — design

Status: DONE 2026-08-05 — merged to master (0e3381d5); unit suites green (MTConnect 534/534, MQTT 658/658, AdminUI 1064/1064); live gate PASSED (§4): the full integration suite ran live against the shared fixture (10.100.0.35:5000, AllowPut now on) — 15/15, 0 skipped — covering the write round-trip (value read back from /current), the real agent-refusal → BadCommunicationError mapping (with the no-deviceName → BadNodeIdUnknown falsifiability control), and the disabled gate (BadNotWritable, agent untouched). The same legs also ran against a local OrbStack fixture during development with agent-log corroboration. One leg is deliberately deferred to the operator's chrome session: the docker-dev rig OPC UA leg (author an MTConnect driver with write.enabled + a ReadWrite tag in AdminUI, deploy, Client.CLI write, read back) — authoring requires the AdminUI, and per the operator's instruction chrome-driven gates run separately. Remember the CurrentWrite caveat when running it: the tag must be authored ReadWrite (browse-commit defaults to Read).

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:

"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).