feat(mtconnect): IWritable via agent observation writes

MTConnectDriver now implements IWritable over the Task-1 agent-client seam
(WriteObservationAsync -> the Agent's AllowPut POST surface). A write is
OBSERVATION INJECTION, not machine command: it sets what the Agent reports for
a DataItem, is visible to every consumer of that Agent, and is overwritten by
the next adapter/SHDR update. Off at two independent gates, both off by default
- the Agent's own AllowPut and the driver's write.enabled (Task 0).

Per-slot refusal order (never throws, strict N-in/N-out batch shape):
  write disabled          -> BadNotWritable
  no live client          -> BadNotConnected  (ReadAsync's existing convention)
  reference unresolved    -> BadNodeIdUnknown
  no resolvable device    -> BadNodeIdUnknown
  CONDITION category      -> BadNotWritable
  null / non-scalar value -> BadTypeMismatch
  agent refusal OR throw  -> BadCommunicationError
OperationCanceledException fills the remaining slots and returns. The Agent's
refusal text is logged at Warning and never enters DriverHealth.LastError
(type-only discipline); a refused write leaves health untouched.

Device-name resolution: TagBinding gains WriteTargetsByDataItemId, built in
BuildBinding from a probe-model walk that remembers which device each DataItem
was declared under (name, or id when nameless) plus its category. Falls back to
options.DeviceName; unresolvable -> BadNodeIdUnknown rather than a guess.
Values render via Convert.ToString(..., InvariantCulture) - so a Boolean writes
the invariant string "True"/"False", NOT "1"/"0". Arrays are refused rather
than rendered: Convert.ToString would emit the literal "System.Double[]", which
the Agent stores happily and which is invisible until read-back.

FINDING - the CurrentWrite bit does NOT key on SecurityClassification, and the
discovery stamping below is therefore cosmetic. Traced end to end: a browsed
leaf's SecurityClass survives into the browse session's AttributeInfo
(CapturedTreeBrowseSession.cs:53) and stops there - RawBrowseModal never reads
it, RawBrowseCommitMapper.MapLeaf hardcodes TagAccessLevel.Read, and Tag has no
SecurityClass column. AddressSpaceComposer:746/:809 sets Writable purely from
Tag.AccessLevel == ReadWrite; AddressSpaceApplier ANDs !IsArray;
OtOpcUaNodeManager.ComposeAccessLevel:2002 composes CurrentWrite from that
alone. Driver capability is never consulted at compose time either - IWritable
is checked only at runtime in DriverInstanceActor:755. Consequences: (a) raw
tags already committed keep their authored AccessLevel, so nothing this commit
does makes an existing node writable; (b) an operator must still set the tag to
ReadWrite for an MTConnect write to be reachable over OPC UA; (c) conversely a
ReadWrite tag on a non-IWritable driver has always advertised CurrentWrite and
failed only at the actor - pre-existing, not touched here.

Discovery stamps CanWrite(writeEnabled, dataItem) = write.enabled && !CONDITION
-> Operate, else ViewOnly - kept honest so the browse never advertises a tier
the write path would refuse (MQTT's CanWrite principle), with the caveat above
recorded verbatim at the call site.

Status-code constants are spelled WITHOUT MqttDriver's "Status" prefix, to
match the two this file already declares (BadCommunicationError,
BadNotConnected). StatusCodeParityTests strips either spelling and passes.

TDD: WriteAsync landed first as a NotImplementedException scaffold - 19 of 20
new tests red on behaviour, 1 (the capability assertion) green on the scaffold
- then implemented. MTConnectFactoryTests' "not_writable" characterization test
is rewritten to pin the new truth, and is the guard for the concrete-return
contract the factory remark explains.

Tests: 532 passed / 0 failed (was ~509). Full solution build: 0 errors,
0 warnings; OTOPCUA0001 clean.
This commit is contained in:
Joseph Doherty
2026-08-05 06:36:08 -04:00
parent 3b8b5a77e1
commit b29623cf99
6 changed files with 868 additions and 32 deletions
@@ -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">