Compare commits

..

158 Commits

Author SHA1 Message Date
Joseph Doherty cc7cd2db11 docs(claude): MTConnect CONDITION alarms (#626)
v2-ci / build (push) Successful in 4m44s
v2-ci / unit-tests (push) Failing after 20m37s
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 15:48:45 -04:00
Joseph Doherty b82c09d2fe Merge b626-626 (batch #626+): MTConnect CONDITION data items as native Part 9 alarms (opt-in TagConfig.alarm)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 15:48:04 -04:00
Joseph Doherty 95b389497a test+docs(mtconnect): wire test for the Unavailable blip + ack survival; docs for the review rulings (#626)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 15:44:50 -04:00
Joseph Doherty 9c8e3da5da fix(mtconnect): review rulings for CONDITION alarms (#626)
- Unavailable keeps an ACTIVE condition active: quality-only BadNoCommunication; on data
  return same statement => quality-only Good, Normal => Clear, different => Retrigger.
- A changed alarm reference set (alarm-bearing delta = full rebuild) clears the whole
  last-told record so every active condition is re-asserted; the same set keeps it.
- Quality-only events go through the host's AlarmQualityUpdate path, never a projected
  snapshot (which could ack a retained unacked condition or rewrite message/severity).
- Deterministic tie-break between equal-level conditions (ordinal nativeCode, then type).
- Dedup key is level+nativeCode+nativeSeverity+qualifier; a text-only change updates nothing.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 15:38:52 -04:00
Joseph Doherty 6a5f5c5a28 Merge branch 'master' into b626-626 2026-10-08 15:32:51 -04:00
Joseph Doherty bbf5f85c95 docs(claude): batch #626+ facts — re-init release, S7/TwinCAT/AbLegacy/OpcUaClient/MTConnect ports, condition child NodeIds (#629)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 15:25:19 -04:00
Joseph Doherty f4d513e111 test+docs(mtconnect): CONDITION alarm wire test, reinit barrier, docs (#626)
MTConnectConditionAlarmWireTests drives a scripted Agent's Fault/Normal through the real
driver, NativeAlarmProjector and sink to a client event subscription on the raw device
folder. docs/drivers/MTConnect.md replaces 'CONDITION v1: plain String' with the alarm
mapping, opt-in authoring, multi-condition, ack, replay and comms-loss behaviour, and a
live-check recipe; EVENT vocabularies as enumerations marked won't-fix. AlarmTracking.md
lists the MTConnect source and the two new AlarmEventArgs fields.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 15:13:30 -04:00
Joseph Doherty 5051275b78 Merge b626-629 (batch #626+): Part 9 condition children get instance-scoped NodeIds ({ConditionId}/{browse path}); shelve methods keep resolving; subtree forgotten on teardown
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 15:02:07 -04:00
Joseph Doherty 66c47c3ddd fix(opcua): shelve declaration ids from a fixed set, not an address-space lookup; pin instance-id + state-machine-id shelve calls (#629)
New() ran FindNodeInAddressSpace under Lock, taking other node managers' locks (lock-order
risk); the three AlarmConditionType_ShelvingState_* ids are now a static set. Adds wire tests:
TimedShelve/Unshelve by the condition's own instance method ids (object = condition or its
ShelvingState) succeed and report, and a ShelvedStateMachineType_* id call is pinned as
BadMethodInvalid with a positive control.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 15:01:54 -04:00
Joseph Doherty 653d138637 feat(mtconnect): CONDITION data items as native Part 9 alarms via IAlarmSource (#626)
The parser keeps a condition's text, nativeCode, nativeSeverity, qualifier and type. A new
MTConnectConditionTracker applies MTConnect's multi-condition semantics (per-nativeCode
activation/clearing across /sample deltas, /current baseline reset, Unavailable), and
MTConnectConditionAlarmMapper turns tracker state into Part 9 transitions: Warning=500,
Fault=800, Normal=clear, Unavailable=inactive + BadNoCommunication; escalation or a new
code retriggers. The driver implements IAlarmSource keyed by the alarm tag's RawPath,
diffs against what the server was actually told (only when a handler is attached), so a
reconnect/re-baseline never re-raises an unchanged condition and a missed transition is
delivered on the next subscribe. Ack is a no-op success. Existing CONDITION String tags
are unchanged; a tag becomes an alarm only via the standard TagConfig 'alarm' object.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 15:01:39 -04:00
Joseph Doherty a60279eb92 Merge b626-opcuaclient (batch #626+): #635 subscription tuning, OperationLimits via SDK batching, OTel event counters, CRL options, discovery URL, full aggregate set
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:58:41 -04:00
Joseph Doherty 1219b7b13e fix(opcuaclient): review fixes - drop driver-side OperationLimits chunking (SDK batches), diagnostics as OTel counter, safe discovery selection, options validation at load (#273 #275 #276 #277 #278)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:55:53 -04:00
Joseph Doherty cb85a455c9 feat(alarms): exact OPC UA severity + per-condition quality on native alarm events (#626)
AlarmEventArgs gains optional OpcUaSeverity (supersedes the four-bucket mapping, clamped
1..1000) and QualityStatusCode (projected onto the condition's Quality). An Unspecified-kind
event carrying a quality is a quality-only annotation: the host updates the condition but
publishes no alerts row. Both default null, so every existing native source is unchanged.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:55:46 -04:00
Joseph Doherty 1de8357d13 Merge commit 'e6028678' into b626-629 2026-10-08 14:55:30 -04:00
Joseph Doherty 935ba8d1d4 Merge branch 'master' into b626-opcuaclient 2026-10-08 14:47:49 -04:00
Joseph Doherty d0052d9fed fix(opcua): Part 9 condition children get instance-scoped NodeIds (#629)
Create(assignNodeIds: true) asked CustomNodeManager2.New, whose default returns the
node's current id, so every condition child kept the type's ns=0 instance-declaration id
(or an SDK-generated ns=0 number): translate of a child path returned BadNoMatch and a
Read of a browsed child id was BadNodeIdUnknown or served from the TYPE (UnshelveTime 0
on a condition timed-shelved for a minute), bypassing the data-plane ACL.

OtOpcUaNodeManager.New now derives {conditionId}/{browsePath} in the condition's
namespace, so the ACL scope of a child is its condition's scope plus the path. The
ShelvingState methods adopt their original AlarmConditionType_ShelvingState_* id as
MethodDeclarationId so clients calling them by that id still resolve. Condition removal
and rebuild forget the whole PredefinedNodes subtree so a removed condition's children
stop answering.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:45:46 -04:00
Joseph Doherty e6028678b3 docs(mtconnect): upgrade note for existing multi-Device MTConnect drivers (#628)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:44:44 -04:00
Joseph Doherty 5c6e2d1b59 Merge b626-631 (batch #626+)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:43:45 -04:00
Joseph Doherty 723392e3da Merge b626-mtconnect (batch #626+)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:43:45 -04:00
Joseph Doherty 7a9b51aeca Merge b626-ablegacy-focas (batch #626+)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:43:44 -04:00
Joseph Doherty 2377da3cb4 Merge b626-630 (batch #626+): release prior device/session state on re-Initialize in six drivers
Conflict in S7Driver.ReleaseConnectionAsync resolved: kept the gated Plc swap from #630 and
carried the negotiated-PDU reset from #632 #294 into it.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:43:44 -04:00
Joseph Doherty 2a41b46b17 Merge b626-s7 (batch #626+)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:43:07 -04:00
Joseph Doherty b7b9a57b84 Merge b626-twincat (batch #626+)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:43:07 -04:00
Joseph Doherty c69a27318d fix(drivers): #630 re-review - S7 warns on gate-wait timeout; AbCip publishes fresh tag tables by reference swap
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:42:51 -04:00
Joseph Doherty ba62022e88 feat(opcuaclient): subscription tuning, OperationLimits chunking, diagnostics counters, revocation options, DiscoveryUrl (#273 #275 #276 #277 #278)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:40:20 -04:00
Joseph Doherty 18a20981a5 fix(drivers): #630 review - AbCip disposes alarm projection + clears tag tables, join not abortable by init token; S7 swaps Plc under _gate; doc rationale
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:39:05 -04:00
Joseph Doherty ff055ebd79 fix(s7): numeric TSAP loads in form, shared TSAP conflict error, Test Connect read detects PUT/GET disabled, pin Int32 ms rounding (#632 #638)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:38:49 -04:00
Joseph Doherty d8f3dd7394 fix(ablegacy): review fixes - one family-aware address authority in Contracts, deploy gate + editor check, malformed address -> BadNodeIdUnknown warned once, probe normalised, octal/migration docs (#244 #245 #247)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:38:25 -04:00
Joseph Doherty 34c52defb3 feat(s7): STRING/WSTRING/CHAR/WCHAR and DTL/DATE/TOD/TIME/S5TIME codecs, TSAP/connection-type override, negotiated PDU surfaced (#632 #288 #289 #294 #295)
New S7DataType members WString/Char/WChar/Dtl/Date/Tod/Time/S5Time mapped onto existing
DriverDataType (String / DateTime / Int32 ms); writes refuse out-of-range with BadOutOfRange.
Optional localTsap/remoteTsap/connectionType through options, factory JSON, probe and AdminUI form.
NegotiatedPduSize snapshotted at connect and exposed via GetDiagnostics (DriverHealth has no bag).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:31:44 -04:00
Joseph Doherty dcc827ca8c feat(mtconnect): one agent client per driver — a second /raw Device is a deploy error (#628)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:29:50 -04:00
Joseph Doherty 461133235a fix(drivers): InitializeAsync releases previous device/session state before overwrite (#630)
FOCAS, AbLegacy, AbCip, TwinCAT, S7 and OpcUaClient orphaned the previous
probe/recycle/fixed-tree loops and clients when re-initialised on a live driver.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:28:55 -04:00
Joseph Doherty 0dc09329b7 feat(ablegacy,focas): PLC-5 octal I/O, MicroLogix function files, indirect-address parse, 16i PMC F/G (#244 #245 #247 #265)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:28:22 -04:00
Joseph Doherty 05ed039c73 fix(twincat): TOD write bound, historian note, driver-level refused-write test (#306)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:26:32 -04:00
Joseph Doherty da33f27544 feat(mtconnect): serve TIME_SERIES observations as variable-length double[] (#627)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:25:45 -04:00
Joseph Doherty 55eac5f68b feat(history): full Part 13 aggregate set; unsupported-by-backend aggregates answer BadAggregateNotSupported (#285)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:24:25 -04:00
Joseph Doherty 90e3fe1cf7 feat(twincat): TIME/TOD as Double ms, DATE/DT as UTC DateTime with read decode + write encode (#306)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:24:12 -04:00
Joseph Doherty da203d5d7e fix(adminui): modal backdrop offset came from .rise's retained transform, not 100vw (#631)
Live-measured on docker-dev: the backdrop was viewport-wide (1911px) but started at the
panel's left edge (241px) because the theme's .rise animation (fill-mode both) leaves an
identity transform on section.panel, re-basing position:fixed descendants. Override the
fill mode to backwards; drops the speculative backdrop/dialog sizing overrides.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:22:32 -04:00
Joseph Doherty c9fdcc3ea6 fix(adminui): clear stale one-time key banner on delete; modal backdrop no longer overruns viewport (#631)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 14:21:20 -04:00
Joseph Doherty d898262c4a docs(claude): #609 removal tombstones tracker + hub against late writers
v2-ci / build (push) Successful in 5m39s
v2-ci / unit-tests (push) Failing after 20m16s
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 09:08:07 -04:00
Joseph Doherty b9e6802d27 Merge #609 race fix: a removed driver's late in-flight invoker calls / health emits can no longer resurrect its resilience-tracker or telemetry-hub state (Retire/Reinstate, Evict/Readmit); removal test made deterministic
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 09:07:57 -04:00
Joseph Doherty 4a10ea1e1c fix(runtime): a removed driver's late writes no longer resurrect its resilience/telemetry state (#609)
DriverHostActor.StopChild released the tracker (Invalidate) and evicted the hub synchronously, but
Context.Stop is asynchronous: the stopping child's in-flight SubscribeBulk completed through its
capability invoker AFTER the release, and its RecordCallStart/RecordSuccess/RecordCallComplete
re-created the tracker entry, which DriverResilienceStatusPublisherService then re-emitted every tick
forever. Confirmed with a temporary stack capture in the tracker (removed): every post-Release write
came from DriverInstanceActor.HandleSubscribeAsync -> CapabilityInvoker.ExecuteAsync; the test failed
whenever it landed before the second publisher tick.

- DriverResilienceStatusTracker.Retire (Release) drops the id's snapshots and ignores every later
  Record* for it; one guarded write path re-checks after the upsert so a write racing Retire cannot
  survive it. Reinstate (called by DriverCapabilityInvokerFactory.Create) lifts it, so the same id
  re-added in a later deployment records again.
- TelemetryLocalHub.Evict now also holds the id (late Health/Resilience dropped, neither cached nor
  fanned out); ITelemetryLocalHub.Readmit (default no-op), called at the top of SpawnChild, lifts it.
- The removal test is now deterministic: an invoker decorator holds the child's subscribe inside the
  real invoker's bracket across the removal and signals when its late writes are done (positive
  control) — red 5/5 without the fix, green 20/20 with it. New unit tests for the tracker guard
  (incl. a Retire race), the factory Release/Create pair, the hub hold/readmit, and a re-add-same-id
  host test.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 09:07:11 -04:00
Joseph Doherty 5c0623ad2b docs(claude): array-write element conversion + Modbus/S7-only array-to-scalar refusal; passive LocalDb half drain gate and pre-join verdict window (R6)
v2-ci / build (push) Successful in 4m50s
v2-ci / unit-tests (push) Failing after 23m15s
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 08:45:28 -04:00
Joseph Doherty 31346f1ecd Merge review R6 fixes: numeric array writes converted element-wise (checked) to the node's element type, refusing out-of-range/fractional/NaN; array-to-scalar refusal scoped to Modbus/S7; Primary-verdict pre-join window documented + real MemberUp subscription test; passive LocalDb pair half drain-gated on its mesh partner
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 08:43:59 -04:00
Joseph Doherty 3be8a1217e fix(runtime): the passive half of a LocalDb pair stands its alarm drain down for its mesh partner (review R6 MINOR-4)
The drain gate's 'is my queue-sharing peer Primary?' used only the host of
LocalDb:Replication:PeerAddress, which the passive half (ApiKey +
SyncListenPort) does not set - so as Secondary it kept draining beside its
Primary partner and every alarm event reached the historian twice.
DriverHostActor takes a sharesAlarmQueueWithLocal predicate used when no
peer host is known; WithOtOpcUaRuntimeActors builds it
(BuildAlarmQueuePeerPredicate) for a SharesRowsWithPeer node with no
PeerAddress as 'a member of my own cluster-{ClusterId} mesh other than
me'. A node sharing no rows, or with no mesh role, gets none and keeps
draining. AlarmHistorian.md / Redundancy.md / pair-replication runbook
updated.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 08:43:06 -04:00
Joseph Doherty e93d115cbe fix(runtime): pin the Primary-verdict member-event subscription with a test; document the pre-join allow window (review R6 MINOR-2/3)
MINOR-3: new DriverHostActorPrimaryRoleSourceTests case drives the
deny flip with a real ClusterEvent.MemberUp on the EventStream (how the
cluster's domain-event publisher reaches a Cluster.Subscribe subscriber)
instead of a direct RepublishPrimaryVerdict tell; it fails when the
Cluster.Subscribe(IMemberEvent) call is deleted.

MINOR-2: the published verdict MIRRORS the live gate; before this node has
joined its member count is 0 and both allow (seconds; up to the bootstrap
guard's 25 s probe), so the Sparkplug primary-host claim can be held on
both nodes in that window (native ack is unaffected - view and route
agree). Behaviour deliberately unchanged: denying until self is Up would
change the live gate too and delay writes/acks on a single-node cluster's
own join. Comments + AlarmTracking.md / Redundancy.md / Mqtt.md corrected.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 08:33:52 -04:00
Joseph Doherty 52708bff52 fix(address-space): numeric array writes convert (checked) to the node element type; array-to-scalar refusal only for Modbus/S7 (#610 review R6)
MAJOR-1: R4's exact element-type match refused ScadaBridge's CLR-list
writes (its element types are only Boolean/Int32/Float/Double/String/
DateTime) to every Int16/UInt16/Byte/UInt32/Int64/UInt64 array node and a
Double list to a Float array. EvaluateEquipmentWriteStructure now converts
a numeric array of another numeric element type element-wise, CHECKED, into
the node's element type and hands the converted array back through
OnEquipmentTagWrite's ref value, so the node stores and the driver receives
the node's own type. Any out-of-range, fractional-to-integer, NaN/Inf-to-
integer or Float-overflowing element refuses the whole write BadTypeMismatch
(nothing dispatched) - so the UInt32 -> Int32 bit reinterpretation stays
closed. Non-numeric mismatches stay refused. The wrong 'no client relies on
a looser rule' comment is corrected.

MINOR-1: the array-value-to-scalar-node refusal broke writes back to
browse-committed array tags (committed scalar, isArray never set) on
pass-through drivers. It now applies only when the owning driver's scalar
encoder cannot take an array (DriverArrayWriteSupport.
RefusesArrayValueOnScalarTag: Modbus, S7), carried RawTagPlan.
ScalarRefusesArrayValue / UnsReferenceVariable.BackingScalarRefusesArrayValue
-> EnsureVariable(refuseArrayOnScalar) -> a per-node set in the node
manager. OpcUaClient/Galaxy/others keep pre-#610 dispatch. Not on the
classifier whitelist (driver-type edit rebuilds); a mirror whose flag
changes is recreated, not updated in place.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 08:31:46 -04:00
Joseph Doherty 42700eb298 Merge docs pass: #623 stale docs rewritten (IncrementalSync, AddressSpace, driver-specs banner, Secrets runbook replication), #624 stale claims + perf-followups-r3 tasks.json valid, CLAUDE.md updated with this batch's facts, #619 compose comment
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 08:20:05 -04:00
Joseph Doherty 0a5d03da95 docs: apply CLAUDE-FACTS to CLAUDE.md, fix AlarmAck LDAP wording, retitle KNOWN LIMITATION 2 (#624)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 08:19:30 -04:00
Joseph Doherty 2befe9f9ba docs: correct stale LDAP-role, host-probe, resilience-table, Bad-value claims; fix tasks.json (#624)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 08:16:43 -04:00
Joseph Doherty f383996127 docs: soften shared-volume remark in secrets runbook (#623)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 08:13:18 -04:00
Joseph Doherty 90bfcbe47a docs: rewrite IncrementalSync + AddressSpace for v3, fix driver-specs/driver-stability tier claims, update Secrets runbook for pair replication + KEK rotation (#623)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 08:13:14 -04:00
Joseph Doherty a702875d41 Merge review R4 fixes (#610): array writes require an exact element-type match (BadTypeMismatch, nothing sent); Galaxy encoder refuses uint[]/ulong[]; Modbus array writability bounded by maxRegistersPerWrite/coil cap; arrayLength >= 1 required; Galaxy arrays without a declared length stay read-only; arrays to scalar nodes refused
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 08:08:03 -04:00
Joseph Doherty dabd8eb955 fix(address-space): exact element-type check for array writes + array-capability shape edges (#610 review R4)
MAJOR-1: the SDK skips TypeInfo.IsInstanceOfDataType on any node with an
OnWriteValue handler (every writable equipment node), and the node manager's
fail-fast treated numeric<->numeric as compatible, so a UInt32[] to a Galaxy
Int32 array matched MxValueEncoder's int[] arm by CLR covariance and was
written bit-reinterpreted with a Good result. EvaluateEquipmentWriteStructure
now matches an ARRAY write's element type exactly (Byte[]/ByteString the only
equivalence; Variant[]/object[] refused) -> BadTypeMismatch, nothing
dispatched. MxValueEncoder's int[]/long[] arms are exact-type guarded too, and
the Galaxy parity test's false "the SDK refuses" premise is corrected (the
UInt32/UInt64 exclusion is now the encoder's own answer). Scalar writes keep
numeric widening by decision: consumers write CLR-typed values (Double to a
Float node) and every driver's scalar encoder range-checks.

MINOR: Modbus row honours maxRegistersPerWrite (smallest of DriverConfig /
the tag's DeviceConfig, default 123) and FC15's 1968-coil ceiling, and
requires region + numeric address; Modbus/S7/Galaxy require isArray + integer
arrayLength >= 1 (Galaxy: an unbounded node would let a short write resize the
attribute); an array value to a scalar node is refused inline. Docs: Uns.md
array-write rules + upgrade note (ReadWrite array tags become client-writable
on first boot), Modbus.md, Galaxy.md.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 08:07:36 -04:00
Joseph Doherty 3eefbc7371 Merge review R5 fixes: MTConnect session publish + generation bump atomic, superseded pumps joined on stop; MQTT writes encode the birth's own Sparkplug datatype (unsupported birth types refused BadNotSupported); AbLegacy dead init join removed + Dispose join test; MQTT QoS1 and Postgres key-normalisation documented
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:53:18 -04:00
Joseph Doherty 7628402893 fix(drivers): R5 minors - MTConnect session publish+generation atomic and superseded pump joined; MQTT birth-typed writes use the birth's own datatype; AbLegacy dead init join removed + Dispose join test; docs (#602)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:52:59 -04:00
Joseph Doherty b99aea46b2 Merge review R1 fixes: removed driver's resilience tracker released; SDK timed-unshelve no-op (engine owns expiry); commented re-ack stored as AddComment; native-ack Primary verdict published from start; drain gate SharesRowsWithPeer; malformed cache bytes don't adopt the revision; HostAbortedException passthrough; ClusterRoleInfo seed filtered
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:47:57 -04:00
Joseph Doherty e3dcb180c3 fix(runtime): Primary verdict published from host start, cache boot never adopts malformed bytes, strict drain-gate predicate, HostAbortedException passthrough, seed only Up members
Review R1:
- M1 (#609): DriverHostActor.StopChild releases the instance's resilience state before evicting the hub.
- m2 (#597): DriverHostActor publishes ShouldServiceAsPrimary() to IDriverPrimaryRoleSource at start and on
  cluster membership changes (deduped), so the native-ack gate denies in a pair's boot window.
- m4 (#601): ApplyCachedArtifact returns false on empty/malformed bytes; the cached revision is not adopted,
  Direct mode goes Stale (no RUNNING FROM CACHE), FetchAndCache lands Steady-with-no-revision and a
  re-dispatch of the same revision really applies.
- m3 (#603): LocalDbReplicationPolicy splits into CapturesChanges (PeerAddress||ApiKey, registration) and
  SharesRowsWithPeer (ApiKey && (PeerAddress||SyncListenPort>0), alarm drain gate).
- m5 (#618): Program.cs catch-all filters HostAbortedException (design-time tooling).
- m6 (Cluster-002): ClusterRoleInfo seeds only Up/WeaklyUp members.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:47:34 -04:00
Joseph Doherty 83e9ffd27a fix(alarms): SDK timed-unshelve timer is a no-op; commented re-ack comment is durable (#597)
Review R1 M2: since #597 the SDK timer fires at real expiry, and OnTimedUnshelve routed an operator-style
Unshelve by 'system' from both pair nodes, pre-empting the engine's AutoUnshelve. The engine owns expiry,
so the callback now routes nothing. Integration test: an engine stand-in that acts on a routed Unshelve and
expires late, plus a wrapped OnTimedUnshelve (positive control: the SDK timer fired).
Review R1 m1: Part9StateMachine.ApplyAcknowledge records a commented re-ack as AddComment (persisted and
replicated) instead of a NoOp; an uncommented re-ack is still a no-op.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:47:28 -04:00
Joseph Doherty 8a9bf23048 fix(telemetry): release a removed driver's resilience tracker state so the publisher stops re-emitting it (#609)
Review R1 M1: StopChild evicted the hub cache, but DriverResilienceStatusPublisherService re-emits the
tracker every tick. IDriverCapabilityInvokerFactory.Release (default no-op) now invalidates the pipeline
builder + status tracker; the host calls it before Evict. Test runs a publisher tick after removal, with a
neighbour instance as positive control.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:47:13 -04:00
Joseph Doherty b7a48e2b93 Merge #610: server-side array writes gated per driver capability (DriverArrayWriteSupport); ArrayWritable on the surgical whitelist + UNS Backing; shape/IndexRange refusal; ValueRank>1 out of scope
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe

# Conflicts:
#	docs/drivers/S7.md
2026-10-08 07:39:40 -04:00
Joseph Doherty 18f8eb5d2f feat(address-space): server-side array writes gated per driver capability (#610)
Array nodes were forced read-only for every driver. A new single authority,
Core.Abstractions DriverArrayWriteSupport, says which (driver, element type)
pairs encode a whole-array write: Modbus (Coils / numeric HoldingRegisters),
S7 (Byte/Int16/UInt16/Int32/UInt32/Float32), OpcUaClient (pass-through) and
Galaxy (Boolean/Int32/Int64/Float/Double/String/DateTime). Everything else
stays read-only, so the server never advertises a write the driver refuses.

- Composer derives RawTagPlan.ArrayWritable + UnsReferenceVariable
  .BackingArrayWritable at compose time (both seams, byte-parity); the
  applier's one writability rule is Writable && (!IsArray || ArrayWritable)
  for materialise and surgical paths, raw and UNS alike.
- ArrayWritable is on the classifier's surgical raw-tag whitelist and
  mirrored on the UNS record (UnsReferenceBackingParityTests updated).
- Node manager refuses a wrong-length / non-array value on an array node
  (BadTypeMismatch) and any IndexRange write (BadWriteNotSupported) inline,
  before dispatch; WriteOperate/ACL gates and the revert are unchanged.
- Per-driver parity tests pin the table against the real Modbus write path,
  S7 EncodeArrayBlock and Galaxy MxValueEncoder (UInt32/UInt64 excluded:
  array covariance reinterprets their bits).
- Over-the-wire proof in OpcUaServer.IntegrationTests (ArrayWriteWireTests).
- ValueRank > 1 documented as out of scope by decision (docs/Uns.md).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:39:05 -04:00
Joseph Doherty b29642c5da Merge #602 stability: FOCAS/AbLegacy loop joins on shutdown, Postgres untyped string binds, MTConnect pump generation, MQTT birth-typed writes + QoS1 plain writes + claim backoff
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:33:03 -04:00
Joseph Doherty babc452740 docs(drivers): MQTT write QoS/claim backoff and Sql Postgres untyped binds (#602)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:32:42 -04:00
Joseph Doherty 85491f1ebc Merge review R3 fixes: per-page interactive ErrorBoundary, DisposeBridge cancels on timeout, CSV commit refreshes after close, SecurityMode=None documented, browsePageDelayMs omit-by-default
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:31:42 -04:00
Joseph Doherty c120794958 Merge review R2 fixes: Galaxy subscribe-once deadline returns BadTimeout, S7 probe honours backoff, write success clears only write-side Faulted, null array elements rejected, S7_SIM_ALLOW_WRITES opt-in
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:31:36 -04:00
Joseph Doherty e77bca385a fix(galaxy,s7): R2 review minors — subscribe-once deadline, probe vs backoff, write-fault ownership, null array elements, write opt-in
- Galaxy: deadline bounds the wait, not the subscribe RPC; BadTimeout for all tags and late handles unsubscribed; doc comment relocated
- S7: probe connect runs on the connect deadline so a timeout opens the backoff window
- S7: a successful write only retires a Faulted the write side owns
- S7: null array elements rejected (BadTypeMismatch); multi-PDU array writes documented non-atomic
- S7 integration: array write test gated on S7_SIM_ALLOW_WRITES=1

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:31:22 -04:00
Joseph Doherty 1475991fd6 fix(adminui,client): error boundary inside the interactive scope, stale-commit OnSaved, AbCip browse delay emit, DisposeBridge cancel (review R3)
M1: PageErrorBoundary wraps the body of every InteractiveServer page; the inert layout boundary is removed.
m1: DisposeBridge cancels its CTS when the 5 s wait times out.
m2: a CSV import that commits after the modal closed still raises OnSaved.
m3: document SecurityMode=None no-fallback in Client.CLI.md / Client.UI.md.
m4: browsePageDelayMs written only when loaded or changed; negative flagged.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:31:19 -04:00
Joseph Doherty 36343c1277 Merge #593 follow-up: AbLegacy native write refusals decided before any wire traffic (status-bit sub-elements of any type, PLC-5)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:29:18 -04:00
Joseph Doherty 275b240ebd fix(mqtt): back off the Sparkplug primary-host claim loop after failures (#602 STAB-27)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:29:08 -04:00
Joseph Doherty e863e9f0e2 fix(ablegacy): refuse native status-bit and PLC-5 writes before creating the runtime (#593)
A first-touch native write to a T/C/R status-bit sub-element (T4:0.DN) created
and initialised the tag's runtime before the runtime's write refusal could fire;
the init is one READ, so against a device lacking the file (the ab_server
fixture has no T/C/R) it surfaced as BadCommunicationError + Degraded health
instead of BadNotSupported. The bit-RMW path was not involved: AbLegacyAddress
rejects sub-element + /N, so a status bit's BitIndex is always null.

NativeAbLegacyTagRuntime.WriteRefusal is now the single predicate (status bit
by address whatever the authored type; any PLC-5 write), asked by the driver via
IAbLegacyTagFactory.WriteRefusal (default null, so libplctag is unchanged)
before EnsureTagRuntimeAsync and ahead of the bit-RMW branch.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:28:52 -04:00
Joseph Doherty 1f4f4fca8f fix(mqtt): birth-typed Sparkplug writes and QoS 1 for Plain writes (#602 STAB-25/26)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:27:42 -04:00
Joseph Doherty e76df0741a fix(mtconnect): stamp the /sample pump with its session generation and supersede a stale one (#602 STAB-20)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:24:43 -04:00
Joseph Doherty b176469b43 Merge #621(node manager): Utils.Log* → injected ILogger; last CS0618 suppressions removed
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:20:00 -04:00
Joseph Doherty 8d5e456ce1 refactor(opcua): node manager logs through injected ILogger, drop CS0618 suppressions (#621)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:19:47 -04:00
Joseph Doherty ca0f1e7735 Merge #597/#599: real UnshelveTime, commented re-ack reports, native ack refused off-Primary, /alerts ack-path test; surgically-added raw nodes announced, equipment-with-references removal keeps references severed; Backing* parity guard
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:12:59 -04:00
Joseph Doherty bd63810645 fix(alarms): native ack refused off-Primary, commented re-ack reported, real UnshelveTime (#597)
- Native-condition Acknowledge on a non-Primary node returns BadInvalidState (nothing
  routed/applied/reported) via NativeAlarmAckPrimaryGate, fed from
  IDriverPrimaryRoleSource by OtOpcUaServerHostedService.
- A re-ack carrying a comment reports one comment event (Acked/Confirmed unchanged).
- TimedShelve applies its requested duration and the scripted projection carries
  UnshelveAtUtc, so ShelvingState/UnshelveTime counts down instead of ~24.8 days.
- Native re-ack still routed to the driver (deliberate: documented retry path), pinned.
- AdminUI /alerts client ack/shelve path test; docs/AlarmTracking.md decisions incl.
  accepted SDK EventsMonitored residual (item 6).
- Also #599/03/S20: RemoveEquipmentSubtree severs inverse Organizes edges.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:12:29 -04:00
Joseph Doherty 0bd3ec00e4 fix(address-space): announce surgically-added raw nodes; equipment-with-references removal stays surgical (#599)
- AnnounceAddedNodes raises NodeAdded in the Raw realm for the existing parents of
  added raw containers/tags (ComputeRawAddAnnouncements); Uns side unchanged.
- ApplyPureRemove removes UNS reference variables BEFORE the equipment subtree, so a
  reference the subtree's id-prefix scope reaches no longer ratchets to a full rebuild
  (03/P5). A NotSubsumed filter was not used: in production a reference NodeId is
  Area/Line/Equipment/Name, outside the subtree scope, so filtering would leak it.
  The matching RemoveEquipmentSubtree severing (03/S20) is in the node-manager commit.
- UnsReferenceBackingParityTests: reflection guard discovering the classifier's surgical
  raw-tag whitelist and requiring a same-typed mirror field (03/U11).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:12:29 -04:00
Joseph Doherty 5a5f9b22c9 Merge #622/#621/#606: OpcUaClient driver wire tests against the in-process server; SDK telemetry ctors in driver/browser; TreatWarningsAsErrors on the last 4 src projects
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:10:55 -04:00
Joseph Doherty cb838d4ae1 test(abcip): scale the lease-soak budget with AB_LEASE_SOAK_CYCLES (#593)
200 cycles against ab_server pass (3m19s); a fixed 120s budget timed out above ~100 cycles.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:10:55 -04:00
Joseph Doherty 2b8b5fbdb2 test(opcua-client): wire-level driver tests against the in-process server; retire scaffold headers (#622)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:07:55 -04:00
Joseph Doherty 71881163df fix(sql): bind string keys untyped on Postgres so integer key columns compare (#602 STAB-24)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:03:00 -04:00
Joseph Doherty e9e35baa70 Merge #600.1/#603: provision changed-to-historized tags; retire dormant VirtualTagEngine; cache ScriptSandbox references; one LocalDb replication predicate for registration and drain gate
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:01:41 -04:00
Joseph Doherty 93e381561b Merge #601(malformed)/#609: malformed artifact bytes are no answer on boot/restore; telemetry snapshot eviction on driver removal
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:01:32 -04:00
Joseph Doherty 75c66c16ef Merge #625: deferred code-review findings resolved or ruled (S7.Cli verdict, browser CP release, Cluster member events, Sql Detach hoist, docs)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:01:32 -04:00
Joseph Doherty 80ed056d41 Merge #602(S7)/#610.2: S7 connect backoff, write-path worst-state-wins, Volatile health, factory logging, driver-side array writes
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 07:01:32 -04:00
Joseph Doherty 3fb6549209 fix(review): resolve deferred code-review findings; S7.Cli verdict, browser CP release, MemberLeft/Exited/Downed, Sql Detach hoist (#625)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:59:53 -04:00
Joseph Doherty 5a5c80ee57 fix(drivers): join FOCAS and AbLegacy background loops on shutdown (#602 STAB-23/6)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:59:38 -04:00
Joseph Doherty 82372f1672 fix(s7): connect backoff, worst-state write health, Volatile health, array writes (#602, #610)
STAB-21 ConnectionBackoff in EnsureConnectedAsync; STAB-22 write-pass accumulator with a
write-side Faulted that a clean read does not erase; _health on the Volatile idiom;
S7-016 contiguous array write (Bool arrays stay refused).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:59:29 -04:00
Joseph Doherty 439752d3b8 feat(adminui): S7 tag editor rejects wide-type and writable-Bool arrays at author time (#610)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:59:29 -04:00
Joseph Doherty e3cb2dd591 fix(s7): thread ILoggerFactory into the S7 driver factory (#602 CONV-3)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:59:29 -04:00
Joseph Doherty b007d450d8 chore(build): OpcUaClient driver/browser on ITelemetryContext ctors, TreatWarningsAsErrors on remaining src projects (#621 #606)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:59:25 -04:00
Joseph Doherty 6a5e804eca Merge #593/#594: Ab.Transport offline/simulator test gaps, libplctag BadParam per-tag path, fixture suites skip when absent
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:58:44 -04:00
Joseph Doherty 7e36842556 test(abcip): ab_server soak of ConnectionLease.ReleaseInBackground; fixture docs (#579, #593, #594)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:58:25 -04:00
Joseph Doherty 4f6231fa40 refactor(localdb): one replication predicate shared by table registration and the alarm drain gate (#603)
The drain gate counted SyncListenPort; a port with no ApiKey shares nothing (fail-closed
passive endpoint), and an ApiKey-only passive node shares its queue but was never gated.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:56:00 -04:00
Joseph Doherty db10e9d2e3 Merge #604: AdminUI stale-open guards on 5 modals, ValidationSummary, API-key delete confirm, ErrorBoundary
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:55:40 -04:00
Joseph Doherty d3bca7afd4 fix(adminui): stale-open guards, validation summaries, key-delete confirm, error boundary (#604)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:55:23 -04:00
Joseph Doherty 9b159025f7 Merge #598/#621(client): AddComment API + CLI comment, Galaxy-era fallback reads removed, CLI test serialization, Client.Shared on async SDK APIs
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:54:22 -04:00
Joseph Doherty 48bd266e19 feat(client): AddComment + comment CLI, drop Galaxy-era alarm fallback, serialize CLI tests (#598); Client.Shared off obsolete sync SDK APIs (#621)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:54:01 -04:00
Joseph Doherty 0fb194a096 Merge #608/#602/#596: stale WaveC TODOs resolved, FOCAS/TwinCAT factory logging, Modbus health asymmetry documented, AbCip browsePageDelayMs field, Device1 by-design
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:53:59 -04:00
Joseph Doherty 53247a056a fix(runtime): malformed artifact bytes on boot/restore are no answer (#601); evict telemetry snapshots on driver removal (#609)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:53:45 -04:00
Joseph Doherty 9c39f2b6c4 fix(drivers): ILoggerFactory on FOCAS+TwinCAT factories; replace stale TODO(v3 WaveC); AbCip browsePageDelayMs form field; document Device1 + Modbus health (#608 #602 #596)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:53:38 -04:00
Joseph Doherty a6192159fe Merge #618/#617/#620: fatal-log + exit on fail-start; ns=0 boot warning corrected; Health.Akka role match documented as accepted residual
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:53:12 -04:00
Joseph Doherty 413bc5c04f fix(host): fatal-log, flush and exit non-zero when the host fails to start (#618)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:52:54 -04:00
Joseph Doherty ba3d882897 Merge #605: Galaxy gateway seam hardening (advise-failure unmark, derived historian health, duplicate attributeRef surfaced, bounded subscribe-once read)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:52:49 -04:00
Joseph Doherty 6a6ac9a11f fix(abcip): libplctag ErrorBadParam at tag create is a per-tag config error, no device backoff (#570, #593)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:52:47 -04:00
Joseph Doherty 0544287f26 docs(plans): auto/driver-gaps branch ledger — 92 issues classified (#607)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:52:41 -04:00
Joseph Doherty 1b37b2f2b0 perf(scripting): cache ScriptSandbox BCL + pinned metadata references (#603)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:52:35 -04:00
Joseph Doherty 7c4c7f9653 fix(galaxy): seam hardening - advise unmark on throw, dup attributeRef surfaced, bounded subscribe-once read; delete dormant historian connection probe (#605)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:52:21 -04:00
Joseph Doherty 33d72edcd0 Merge #613 slice: MTConnect authoring-time DataType advisory + ignored-DeviceName warning
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:52:08 -04:00
Joseph Doherty b78c7ff9db feat(mtconnect): authoring DataType warning + loud ignored-DeviceName warning (#613 slice)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:51:40 -04:00
Joseph Doherty a8703c6afe refactor(virtualtags): retire dormant VirtualTagEngine/Source/Definition + TimerTriggerScheduler (#603)
TimerTriggerScheduler was welded to the engine and has no production consumer.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:51:22 -04:00
Joseph Doherty 21267750c7 test(ab-transport): blocked-write timeout fault path via a stream seam (#565, #593)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:50:40 -04:00
Joseph Doherty 7e91c37a52 test(opcuaclient-browser): skip live browse tests when opc-plc is unreachable; move out of unit project (#594)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:48:34 -04:00
Joseph Doherty 792510c0eb Merge #595/#606: OTOPCUA0001 error under src/, scoped NoWarn in driver test projects, global.json
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:48:15 -04:00
Joseph Doherty 7aa7758864 build: scoped OTOPCUA0001 NoWarn in 13 test projects, error under src/, pin SDK via global.json (#595 #606)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:47:40 -04:00
Joseph Doherty b37e9613f7 test(ab): default AB matrices to one profile when unset; PLC-5/status-bit refusal fixture tests (#594, #593)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:45:51 -04:00
Joseph Doherty dc54b883eb fix(historian): provision tags whose historization flips on or historian name changes (#600)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 06:45:48 -04:00
Joseph Doherty 3baa332d6b Merge: over-the-wire test of the shared client's alarm method calls against the in-process server
v2-ci / build (push) Successful in 4m36s
v2-ci / unit-tests (push) Failing after 19m50s
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 01:49:44 -04:00
Joseph Doherty d66269a99b test(client): over-the-wire alarm method calls against the in-process server
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 01:49:32 -04:00
Joseph Doherty d978573fb2 Merge #591: FOCAS duplicate-sibling tests wait for the first axis pass, not the bootstrap stamp
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 01:35:41 -04:00
Joseph Doherty 159f30dcd0 test(focas): wait for the first full poll, not the bootstrap stamp, before reading (#591)
LastFixedPollUtc is stamped by the fixed-tree bootstrap before any axis snapshot
is published, so under load Each_duplicate_axis_reads_its_own_position could read
inside that window and get BadNodeIdUnknown. The helper now waits for the last
axis slot plus the servo/spindle caches. 45/45 green under 14-core yes load.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 01:35:25 -04:00
Joseph Doherty 3803df0433 Merge #587/#588: one condition event per method-driven transition (native: Ack/AddComment only); model-change + audit events gated per session on i=2253 when ACL is on
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 01:23:20 -04:00
Joseph Doherty e2663643dd fix(alarms): native conditions support Acknowledge/AddComment only; report only real method changes (#587 review)
Review of 167a48bf (FIX-FIRST): making native Confirmed/Shelving method-owned produced over-the-wire
regressions — a native TimedShelve never expired, a native OneShotShelve was silently undone by
SetActiveState, an OPC UA ack left a native alarm Unconfirmed forever (ScadaBridge maps ConfirmedState),
and any local state was per-node, never reaching the redundant peer.

- Reverted: the driver projection owns native Confirmed/Shelving again.
- Native Confirm / OneShotShelve / TimedShelve / Unshelve return BadNotSupported with no event, as native
  Enable/Disable already did. Scripted alarms keep every method.
- A native Acknowledge re-asserts ConfirmedState=true and the projection's Retain rule (Active || !Acked)
  before its single report. The native condition starts Confirmed.
- The method path reports only when the state actually moved (before/after). A re-ack is silent and
  restores the Confirmed/Retain that the SDK flips, so a scripted node never drifts from its engine.
  AddComment always reports.
- Docs: native alarms support Acknowledge/AddComment only; the Secondary-node native-ack flap is documented.

Tests: refused native methods (5), native ack stays Confirmed through echo/clear/reactivate, scripted
TimedShelve expires with exactly one unshelve event, re-ack silent (native + scripted). Each fix falsified.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 01:19:38 -04:00
Joseph Doherty 85f003272c Merge: Client ack/confirm/enable/disable/shelve call Part 9 methods on the condition node (no .Condition/.ShelvingState string ids)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:57:23 -04:00
Joseph Doherty fa7efb3b62 fix(client-cli): call Part 9 ack/confirm/enable/shelve methods on the condition node itself
The client appended .Condition / .ShelvingState to the condition id; neither child exists on the
OtOpcUa server (conditions are their own SourceNode, ShelvingState is an SDK numeric ns0 node), so
every CLI ack/confirm/shelve/enable/disable returned BadNodeIdUnknown.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:57:06 -04:00
Joseph Doherty 167a48bffa fix(alarms,acl): one condition event per Part 9 method call; gate Server-object model-change/audit events (#587, #588)
#587 — a client Acknowledge/Confirm/AddComment/Shelve/Enable/Disable emitted no condition event: the SDK's
ReportStateChange is gated on AutoReportStateChanges (never set), and the engine re-projection found the node
already in the new state and was suppressed by the delta-gate. WireMethodTransitionReporting now wraps each
method's SDK handler and, on Good, reports exactly once via ReportConditionEvent, under Lock so the routed
re-projection cannot race in ahead of the SDK applying the state. Independent of the SDK's imprecise
AreEventsMonitored bookkeeping. The shelve methods' state is applied by the wrapper (the SDK never applies
it). Native conditions' Confirmed/Shelving are method-owned (NativeAlarmProjector's constants would re-report
every ack and undo every shelve). MaxTimeShelved set to int.MaxValue ms at materialise — Create() left it 0
and every TimedShelve failed BadShelvingTimeOutOfRange. Wrong "SDK auto-fires (E2)" comments fixed.

#588 — with ACL on, GeneralModelChange and AuditWriteUpdate events no longer go to Server.ReportEvent
(ungated to every i=2253 item): ReportToServerObject delivers them per item, a model change narrowed to the
Affected nodes the session may Browse (dropped when none), an audit write only with Read on its source plus
the SDK audit/encryption guard. Flag off: unchanged.

#586 review: materialise raises only the new condition's monitored unit (was a full O(N) reconcile per
condition ⇒ O(N²) deploys under Lock).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:54:08 -04:00
Joseph Doherty 15dd75379d Merge #590: ClusterRoleInfo records the RoleLeaderChanged address directly (lagging State snapshot could pin a role leader to null)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:26:16 -04:00
Joseph Doherty 129b84fd69 fix(cluster): record RoleLeaderChanged leader from the event, not the lagging state snapshot (#590)
ClusterRoleInfo resolved the leader address through Cluster.State.Members, which is
published asynchronously; a miss stored null permanently, so RoleLeader(role) stayed null
(flaky test timed out under load: 6/10 failing with CPU contention, 0/20 after). Only the
address is ever read, so store it directly. Adds a deterministic unit test (fails without
the fix) and InternalsVisibleTo for the Cluster.Tests project.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:25:57 -04:00
Joseph Doherty e8453eef6c Merge #589: S7 span-read tests wait out the clock tick before asserting LastSuccessfulRead advanced
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:22:25 -04:00
Joseph Doherty b36f5644d3 test(s7): wait out the clock tick before asserting LastSuccessfulRead advanced (#589)
The driver stamps DateTime.UtcNow at init and again per read pass; under CPU
contention both land in the same tick, so the strict greater-than assertions
flaked (reproduced 1/40 under load, sibling test A_cancelled_pass_still_flushes...
failed identically with equal timestamps). Test-only race; no production defect.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:22:11 -04:00
Joseph Doherty 390c8269a0 Merge #586: Server-object (i=2253) event items gated by the data-plane ACL; per-condition delivery with SDK audit guard + monitored bookkeeping
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:09:03 -04:00
Joseph Doherty fe92ff4c34 fix(acl): keep the SDK's audit guard and EventsMonitored bookkeeping on Server-object delivery (#586)
Review of 75e559f6:
- OnConditionReportEvent now mirrors MonitoredNode2's AuditEventState guard (only while Server.Auditing,
  over SignAndEncrypt / https-uabinary). Conditions report their Part 9 method audit events through
  themselves, so the first cut queued AuditConditionAcknowledge (i=8944) etc. to every i=2253 item with
  auditing off -- the removed folder->Server forward used to drop them.
- ReconcileAllEventsMonitoring keeps one AreEventsMonitored unit on every materialised condition while any
  i=2253 item exists (called from SubscribeToAllEvents, materialise, removal, rebuild), so
  ConditionState.EventsMonitored() is true on a server whose only subscribers are Server-object items.
  A protected SubscribeToEvents override no-ops all-events items, which stops the SDK's AddRootNotifier
  (unsubscribe: true bug) taking that unit back from conditions rebuilt under a live item.

Flag-off over-the-wire coverage: Acknowledge/AddComment with and without a folder co-subscriber (no audit
event, transitions once each), EventsMonitored on/after-rebuild/off, rebuild survival with one copy per
event, native + scripted conditions once each, refresh once per condition with RefreshStart/End; plus an
ACL-on native-vs-scripted notifier-folder decision. Falsified: removing the audit guard reds both audit
cases; removing the reconcile reds the monitored test.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 00:05:12 -04:00
Joseph Doherty 277cd6ee3e Merge: deterministic AbCip transport-switch probe tests (libplctag timeout races the probe cancel under load)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:51:34 -04:00
Joseph Doherty fe2b0614c8 test(abcip): deterministic transport-switch probe tests
The libplctag-path positive control asserted 'timed out', but libplctag's internal
timeout equals the probe timeout and races the probe's own cancellation; under CPU
load it surfaces as 'ErrorTimeout' instead. Accept either libplctag stall outcome
(native never produces either: it fails at once with 'Connection lost').

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:51:11 -04:00
Joseph Doherty 75e559f6d8 fix(acl): filter Server-object (i=2253) event subscriptions per event through the data-plane ACL (#586)
Server-object (all-events) items bypassed the ACL entirely: the SDK attached them to every root
notifier's fan-out via SubscribeToAllEvents, and a root notifier with no direct subscriber forwarded
to the core Server object, which this node manager never sees.

- SubscribeToAllEvents override records the item and attaches it to nothing.
- Each condition's own OnReportEvent (fires once per event) queues the event to each recorded item
  whose session holds Subscribe on the condition (RawPath) or any notifier folder it reports through
  -- the per-folder path's question; otherwise mirrors MonitoredNode2.OnReportEvent.
- The root-notifier forward to the core Server object (OnReportEvent override) is dropped.
- ConditionRefresh on an i=2253 item is filtered the same way (once per condition).
- Revalidation is implicit: every event re-asks the authorizer (per-generation cache, key expiry and
  revocation re-checked per call). Inert with Security:Acl:Enabled=false.

Over-the-wire tests: no-grant vs granted (positive control), created-before-deploy, revoke/restore by
deployment, key expiry with no tick, refresh, and flag-off. Falsified: disabling the filter, or
restoring the forward, turns the enforcement tests red.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:37:47 -04:00
Joseph Doherty e5fe2e5547 Merge #566/#581/#583: per-pool transport diagnostics + per-device open-attempt limit, CIP-level close recycle, positive-control absence tests
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:31:17 -04:00
Joseph Doherty fe94a3bdb8 test(ab): replace fixed-delay absence assertions with positive controls / processed counts (#583)
Ab.Transport.Tests: predecessor-wait counter instead of 300 ms (DeviceSessionPoolTests x2), throttle
queue/interval-park hooks instead of 50/100 ms (ForwardOpenThrottleTests x2), UnobservedTaskProbe with a
must-report control fault instead of N GC cycles (EncapsulationClientTests), re-armed keep-alive timer as
the control for the never-opened keep-alive test.
AbCip.Tests: still-polling sibling / control driver read counts instead of 300-500 ms (subscriptions x4,
alarm projection x5), structural ProbeTask==null + enabled-probe control (host probe x2), predecessor
wait (NativeAbCipLeaseReleaseTests), processed waits in the shutdown/logging tests.
AbLegacy.Tests: sibling-subscription control (Unsubscribe_halts_polling), ProbeCts==null + control.
AbCip.IntegrationTests differential safety: a post-phase control open through the same throttle instead
of 1.5-2.5 s sleeps (via a native tag init, keeping the harness read-only guard green).
Fake tag ReadCount made interlocked; CapturingLogger entries snapshotted under a lock.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:28:33 -04:00
Joseph Doherty 3edff3304b fix(ab-transport): per-pool diagnostics + per-device open-attempt limit, CIP-level close recycle, CipConnection/pool test gaps (#566, #581)
- Process-wide CipConnection statics (ForwardOpenCount, Forward Close counts, RequestsSent, exchange
  stats, OpenAttempts, OpenAttemptBudget) move to an instance ConnectionDiagnostics owned by each
  DeviceSessionPool (and by a stand-alone CipConnection). The open-attempt budget becomes a disposable
  per-DeviceKey limit (LimitOpenAttempts); the differential harness scopes one to the press under test.
- LibplctagOrphanRelease.ForceRootingForTest is gone: ReleaseIfOrphaned(tag, canReadHandles) overload.
- A connected reply with general 0x01 / extended 0x0107 or 0x0203 (target no longer holds the class-3
  connection, TCP still up) retires the session at once; the next send reopens through the throttle.
- New tests: dispose during RegisterSession, abandoned-open observer (falsified), scheduler-dispose-throws
  guard (DeviceSessionPool.CloseAsync), release-in-background with a shared open in flight, per-device
  limit scoping, CIP-close recycle (request + keep-alive + negative control).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:21:02 -04:00
Joseph Doherty 35152fcaf7 Merge #562/#563: event/alarm items revalidated by both ACL triggers (reversible stand-down); key usability on WriteOperate/AlarmAck gates regardless of Security:Acl:Enabled
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:11:22 -04:00
Joseph Doherty 2b945672e9 fix(acl): revalidate event/alarm subscriptions on both triggers; key-usability check on role gates (#562, #563)
#562: direct event (alarm) monitored items are now tracked in a second map
(_eventItems, fed by the SubscribeToEvents override) and re-authorized by
RevalidateMonitoredItems on both triggers (DataPlaneAuthorityState.Changed and
AclRevalidationTimer). A denied item is detached via the SDK's own protected
SubscribeToEvents(unsubscribe: true) - never MonitoringMode.Disabled - and
re-attached on restore; a client delete of a stood-down item is absorbed.
Over-the-wire tests: revoke -> stop (positive control on a still-granted
notifier) -> restore -> resume; clock-driven expiry via the real timer with a
control session; delete-while-stood-down is not resurrected; unexpired
negative control. Server-object (all-events) subscriptions are documented as
outside the ACL (observed over the wire).

#563: DataPlaneAuthorizer.AuthorizeKeyUsable runs the FindUsableKey probe on
the WriteOperate and AlarmAck role gates regardless of Security:Acl:Enabled,
so an expired or deployment-disabled key loses its activation-time roles on
its open session. Anonymous (no key id) is unaffected; reads/subscriptions
stay ungated with the flag off. No new flag.

Docs: docs/security.md and the CLAUDE.md API-key/ACL section.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:10:35 -04:00
Joseph Doherty 501f6ea4e5 Merge #570/#579: native AbCip per-tag config errors, no mistype churn, STRING 82, LWORD type checks; non-blocking lease release (AbCip + AbLegacy)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe

# Conflicts:
#	src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs
2026-10-07 23:06:52 -04:00
Joseph Doherty 007174d28a Merge #573: AbLegacy fake tag factory is thread-safe (probe raced the test's tag create)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:06:07 -04:00
Joseph Doherty 4e6eed7454 fix(abcip): native runtime STRING capacity, mistype churn, malformed-name config error, LWORD type check; non-blocking lease release (#570, #579)
#570
- STRING: the built-in 88-byte Logix STRING holds 82 characters (LEN + DATA[82] + 2 pad), not 84; the
  write still sends the whole 88-byte structure and a read never decodes the pad as characters.
- Mistype churn: a type refusal (CIP 0xFF/0x2107) on the existence read no longer throws from
  InitializeAsync, so the runtime is not disposed/re-created (and re-Forward-Opened) every poll; the driver
  also keeps a runtime that throws TypeMismatch from init and records the device as reachable.
- Malformed tag name: a TagPath that does not parse, or a name the CIP symbolic encoder refuses (now also
  non-ASCII, empty member, >255-char member, unterminated subscript), is AbCipTagConfigurationException ->
  per-tag BadConfigurationError (named const in AbCipStatusMapper), warned once, never feeding the device
  backoff or health; siblings keep reading. A malformed probe tag no longer feeds the backoff either.
- Micro800 BYTE/WORD/LWORD (0xD1/0xD2/0xD4) are type-checked as integers of their width: REAL/LREAL or a
  narrower integer over LWORD is TypeMismatch, not decoded garbage.

#579
- ConnectionLease.ReleaseInBackground: one non-blocking release shape for both native AB runtimes. The
  AbCip runtime's Dispose no longer waits up to 2 s under the per-tag lock; the pool still registers the
  close synchronously so a re-create cannot overlap it. Offline soak tests (25 cycles, AbCip + AbLegacy)
  prove no leaked connection; FakeEipServer gains OpenConnections.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:05:49 -04:00
Joseph Doherty 8c685bcbba test(ablegacy): make FakeAbLegacyTagFactory.Tags thread-safe — probe loop raced the test thread (#573)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:05:34 -04:00
Joseph Doherty 1ab961c226 docs(abcip): browsePageDelayMs survives an AdminUI save now that the form preserves unknown keys (#576)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:04:01 -04:00
Joseph Doherty 7105ef3eb5 Merge #574/#576/#577/#584: AB forms preserve unknown keys + write factory ms keys, /raw device-name + prerender fixes, CLI read prints Bad status, docs dot-form
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:03:48 -04:00
Joseph Doherty bafbcbee7e fix(adminui): AB forms read/write the factory-bound *Ms duration keys, migrate legacy TimeSpan strings (#576)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 23:03:38 -04:00
Joseph Doherty 4ba6cbab3a Merge #572/#575: native browse paging/lease/pacing, differential equal-refusal + budget isolation
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe

# Conflicts:
#	docs/drivers/AbCip.md
2026-10-07 23:01:26 -04:00
Joseph Doherty be421a4cb0 fix(abcip): native browse paging/pacing/template lease (#572); differential equal-refusal + budget isolation (#575)
#572: listing continues at max(instance in page)+1 (no ascending-order assumption, no-progress guard kept);
browsePageDelayMs (default 5 ms) paces pages; DiscoverAsync reads templates through one batch reader
(IAbCipTemplateReaderFactory.CreateBatch) holding one lease.
#575: differential harness reports a same-status template refusal as an equal refusal; offline test pins the
open-attempt budget without relying on the abort ending the phase.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:59:42 -04:00
Joseph Doherty 29cd25dec8 Merge #568/#569/#580/#585: shared AB status map + orphan release, confinement guard tokeniser, adapter + DefaultTransport tests
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:59:30 -04:00
Joseph Doherty 341417f193 fix(ab): share status map + libplctag orphan release, harden confinement guard, pin width-mismatch + DefaultTransport wiring (#568 #569 #580 #585)
- Ab.Transport: AbOpcUaStatusMap is the single AbStatus->OPC UA table; AbCip/AbLegacy mappers delegate.
- Remove dead MapCipGeneralStatus / MapPcccStatus / AbLegacy MapException (+ tests).
- LibplctagOrphanRelease is one source file (Ab.LibplctagShared) compiled into both drivers.
- LibplctagConfinementGuardTests: tokenising comment/literal stripper, block comments, csproj PackageReference guard.
- LibplctagStatusTranslationTests: real LibPlcTagException round trip, WrapSync/Wrap OCE pass-through, adapter routes decode via WrapSync.
- AbCipTypeMismatchTests: libplctag width-mismatch path (BadTypeMismatch, handle kept, no Degraded, warn once).
- Host: AddAbCipDriverDefaults extension + Program.cs source pin for Drivers:AbCip:DefaultTransport.
- docs/drivers/AbCip.md: layout rows + benign Degraded-after-outage note.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:59:06 -04:00
Joseph Doherty 3d081ad29c fix(adminui,cli): /raw device-name placeholder + no-prerender tree, CLI read prints Bad status (#577)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:56:47 -04:00
Joseph Doherty 13a6727033 Merge #564/#565: CIP codec hardening, EncapsulationClient gate/timeout bounds
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:55:02 -04:00
Joseph Doherty 7892eb13a8 fix(abcip): harden Ab.Transport CIP codec and EncapsulationClient (#564, #565)
CIP codec (#564): PatchU16 bounds guard; CipReply content equality; CipPath
port/length/ASCII/odd/oversize validation with ArgumentException for malformed
tag names and routes; CipPath.MessageRouter/ConnectionManager are now
ReadOnlySpan<byte>; refused Forward Open surfaces ForwardOpenRefusedException
with structured general/extended status; test CaptureFile uses BinaryPrimitives.

EncapsulationClient (#565): frame write bounded by the exchange timeout, which
faults (never cancels) the connection; finite positive timeouts enforced at
every entry point; gate wait bounded by the timeout; deterministic reorder of
the 300 ms test; new dispose-during-drain, gate-wait and RegisterSession
zero-context tests.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:54:32 -04:00
Joseph Doherty bd2bebeda6 Merge #567/#582: RequestScheduler reply-size budget, private lock, fragment struct-handle check
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:53:42 -04:00
Joseph Doherty 9641cefe2e fix(abcip): budget expected reply size in RequestScheduler.TakeBatch (#582); private lock + struct-handle check across read fragments (#567)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:53:10 -04:00
Joseph Doherty e588443ced fix(abcip,adminui): ParseEnum device wording + libplctag probe assertion (#574); AB forms preserve unknown keys + MaxConcurrentReads (#576); probe off for new AbCip driver (#577); docs T4:0.DN (#584)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:53:04 -04:00
Joseph Doherty 04b6563286 Merge native .NET Allen-Bradley transport (AbCip native default; AbLegacy native opt-in; real-press gated)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:40:06 -04:00
480 changed files with 25846 additions and 4729 deletions
+209 -29
View File
@@ -186,10 +186,13 @@ is still default-closed; what changed is which deltas it whitelists.
bounded resync). A **rebuild** still advances even with failures.
- **Any surgical `false`/throw still ratchets to `SafeRebuild()`** — that one-way fallback is
what makes every whitelist above safe.
- ⚠️ **Known gap, no longer neutral:** `AnnounceAddedNodes` is Uns-only, so surgically-added
**raw** containers/tags are materialised but never announced — previously invisible (the
rebuild forced a re-browse anyway), now genuinely undiscoverable until a client re-browses.
Added UNS reference variables ARE announced.
- **Announcements:** `AnnounceAddedNodes` announces surgically-added **Raw** containers/tags
(`ComputeRawAddAnnouncements`, #599 — the former "raw adds are undiscoverable until a re-browse" gap is
closed) as well as added UNS reference variables.
- **Array writability rides the same whitelist:** `ArrayWritable` joins the surgical raw-tag attributes and
`UnsReferenceVariable` carries `BackingArrayWritable` (see "Equipment-node writes" under Authentication). Removing
an equipment that has references removes the references before the equipment subtree, and
`RemoveEquipmentSubtree` severs the inverse `Organizes` edges.
See `docs/plans/2026-08-15-surgical-deploys.md`, `docs/Uns.md` and `docs/Raw.md`.
@@ -268,10 +271,12 @@ deploy) — not a wall-clock timer. Removed with the feature: `TagSetDrift`/`Tag
`DriverInstanceActor`'s drift timer, and `ITagDiscovery.AuthoredDiscoveryRefs` /
`DiscoveryStreamIncludesAuthoredTags`.
⚠️ **`IHostConnectivityProbe` is still unconsumed.** `GetHostStatuses()` has no production call site and
nothing ever writes a `DriverHostStatus` row (the table was re-created in the v3 initial migration, so
confirm intent before deleting). The separable `OnHostStatusChanged` event is the useful half. Tracked
as Gitea **#521**.
**`IHostConnectivityProbe` is consumed (Gitea #521, closed).** `DriverInstanceActor` subscribes
`OnHostStatusChanged`, pulls `GetHostStatuses()` fresh, and the host set rides `DriverHealthChanged` to the
AdminUI `/hosts` page — no SQL: the `DriverHostStatus` table is dropped, and so is
`DriverInstanceResilienceStatus` (#524; the live data goes over the `driver-resilience-status` telemetry
channel only). **Multi-host dispatch is wired too:** `DriverInstanceActor` resolves the breaker/host key
per call via `IPerCallHostResolver` (Modbus, FOCAS, AbLegacy, TwinCAT, AbCip).
### Comms-loss detection: `GetHealth()` is load-bearing (Gitea #527, closed)
@@ -315,9 +320,12 @@ non-`Healthy` state **and** its `LastSuccessfulRead` has been frozen for 90 s. T
quality contract" and implemented by `Core.Abstractions.InputQuality` (`WorstOf`/`IsUsable`). Three
things changed with it, all client-visible:
- ⚠️ **Value nodes of a comms-lost driver now go Bad.** `DriverHostActor.FanCommsLossToValueNodes`
stamps `BadNoCommunication` on the raw NodeId **and every referencing UNS NodeId**, **value retained**,
via the new status-only sink primitive `IOpcUaAddressSpaceSink.WriteStatuses` (the host holds no value
cache, so only the node can keep the value). #481 had wired comms loss to the mux and to Part 9
stamps `BadNoCommunication` on the raw NodeId **and every referencing UNS NodeId**, **value retained
server-side**, via the new status-only sink primitive `IOpcUaAddressSpaceSink.WriteStatuses` (the host
holds no value cache, so only the node can keep the value). ⚠️ Retention is **not client-observable**:
the SDK's `NodeState.ReadAttribute` nulls the value of any Bad-status read/sample, so clients see
`Value = null` + the Bad status; the retained value is what the next Good re-publish and the
`(value, status)` dedup compare against. #481 had wired comms loss to the mux and to Part 9
conditions and stopped one seam short of the external data plane. A consumer that treats Bad as "drop
the tag" (ScadaBridge) will see more Bads — that is the fix working.
- **Nothing Good is written on reconnect** — recovery is the driver's own re-publish (the poll engine's
@@ -500,13 +508,16 @@ incident (`docs/deployments/wonder-app-vd03-makino-z-34184.md`).
- **`RoleParser.GossipedRolesHave`** (case-insensitive) is defence in depth at the handful of
production sites that read LIVE GOSSIP directly (`Member.Roles`) rather than through
`EffectiveRoles` — a peer running an older, pre-R3-04 build can still gossip a miscased plain role
during a rolling upgrade. ⚠️ **Known residual, not closed by R3-04:**
`RedundancyStateActor.SelectOldestUpMemberOfRole`/`SelectDriverPrimary` delegate to
`ZB.MOM.WW.Health.Akka`'s `ClusterActiveNode.OldestUpMember`, which matches via Akka's OWN ordinal
`Member.HasRole` — not this repo's to patch, the same class of "not ours to change" carve-out as
`ClusterSingletonOptions.Role`. A pair where every member is on a fixed build can no longer gossip
a miscased role at all (normalization happens before gossip), so this only bites a genuinely mixed
fixed/pre-fix rolling-upgrade window.
during a rolling upgrade. ⚠️ **Accepted residual (#620), not closed by R3-04:**
`RedundancyStateActor`'s election delegates to `ZB.MOM.WW.Health.Akka`'s `ClusterActiveNode.OldestUpMember`,
which matches via Akka's OWN ordinal `Member.HasRole`. Deliberately **not** patched — neither in the
library nor with a local case-insensitive pre-filter, which would split the Primary election from
`ClusterSingletonManager` placement (also ordinal). It only bites a mixed fixed/pre-fix rolling-upgrade
window; finish the upgrade. Full rationale: `docs/Redundancy.md` ("Accepted residual (#620)").
- **`Program.cs` wraps the composition root** (#618): a start-up failure logs Fatal, writes `FATAL:` to
stderr (the bootstrap logger is still silent before `AddZbSerilog`), flushes, and `Environment.Exit(1)`s
— leftover Akka/Kestrel threads would otherwise keep the process alive. `HostAbortedException`
(design-time tooling) passes through untouched.
- **`RoleAuthorityGuardTests`** (`tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/`) is the enforcement:
a source scan failing on any production file outside `EffectiveRoles.cs`/`RoleParser.cs` that reads
`OTOPCUA_ROLES`, `Cluster:Roles` as code, or declares a bare `"admin"`/`"driver"`/`"dev"` literal —
@@ -621,6 +632,9 @@ fail-closed). Things worth knowing before touching it:
and the node keeps serving last-known-good. It never tears down to an empty address space. At boot a
`FetchAndCache` node restores served state from the LocalDb pointer with **no** central-SQL read; an
empty/unreadable cache lands Steady-with-no-revision (the first dispatch fetches), **never Stale**.
**Malformed non-empty bytes are "no answer" too** — on cache boot, the `FetchAndCache` boot reconcile and
the subscription push alike (the lenient swallow leg is closed). A cache boot from malformed bytes does
**not** adopt the revision (`Direct` ⇒ Stale; `FetchAndCache` ⇒ no revision, the re-dispatch fetches).
- **Both pair nodes fetch independently** — no Primary gating of the fetch (same shape as both reading
SQL today; `StoreAsync` is idempotent and the cache replicates).
- **The dedicated h2c listener coexists with the LocalDb-sync listener.** A fused admin+driver node with
@@ -681,7 +695,8 @@ touching it:
- **Reconnect story:** central runs one reconnecting dialer per driver node (discovered from
`ClusterNode.Host`/`GrpcPort`, refreshed every `TelemetryDial:ContactRefreshSeconds`), immediate
first retry then ~5s fixed backoff, generation-stamped so a superseded stream's late error/event
is ignored, and it never permanently gives up (observability, not data plane). The node-local hub
is ignored, and it never permanently gives up (observability, not data plane). `TelemetryLocalHub.Evict` drops a removed driver's snapshots (`StopChild`, via `plan.ToStop`; central-side
stores are not evicted). The node-local hub
replays last-value snapshots for `driver-health`/`driver-resilience-status` on every (re)subscribe
so those stores re-prime immediately after a reconnect; `alerts`/`script-logs` are append logs and
tolerate the gap. The `/alerts`/`/script-log` pill reflects aggregate stream health (live when ≥1
@@ -751,7 +766,8 @@ things are worth knowing:
message size. Every comment and doc row that called it "row-count-only against gRPC's 4 MB cap"
was corrected with the bump; do not restore that rationale.
- **Registration FOLLOWS the replication config — it is no longer unconditional (2026-08-18).**
`LocalDbSetup.OnReady` reads one predicate, `LocalDbSetup.ReplicationIsConfigured`:
`LocalDbSetup.OnReady` reads one predicate, `LocalDbReplicationPolicy.CapturesChanges`
(via `LocalDbSetup.ReplicationIsConfigured`):
**`LocalDb:Replication:PeerAddress` OR `LocalDb:Replication:ApiKey` is non-blank.** The **OR is
not belt-and-braces** — only the dialling half of a pair sets `PeerAddress`, so keying on it
alone would strip capture from every *passive* node (site-a-2 carries `ApiKey` +
@@ -759,6 +775,16 @@ things are worth knowing:
silently. ⚠️ **`SyncListenPort` deliberately does NOT join the OR:** the passive endpoint is
fail-closed on the key, so a port without an `ApiKey` rejects every inbound stream and has
nothing to capture for.
- ⚠️ **The alarm DRAIN gate uses a different predicate** — `LocalDbReplicationPolicy.SharesRowsWithPeer`:
`ApiKey` **and** (`PeerAddress` **or** `LocalDb:SyncListenPort > 0`), i.e. "a peer actually holds these
rows". Capture must be wider than the drain gate (a dialling-only node still captures); a node with no
peer must still drain, so the two predicates are deliberately not the same.
The **passive** half (no `PeerAddress`) cannot name its peer, so it stands down for its **mesh partner**
(the other member of its `cluster-{ClusterId}` role); a legacy passive half with no mesh role still
drains alongside its partner (duplicate historian rows). ⚠️ Before a node has joined its mesh the
published Primary verdict (and the live data-plane gate, which must stay the same decision) **allows** —
for seconds, up to ~25 s under the bootstrap guard — so a cold-starting node can briefly co-hold the
Sparkplug primary-host claim with its Primary peer.
- **Configured ⇒ `RegisterReplicated(table, baselineExistingRows: true)`**, per table, in the
same load-bearing DDL→register order. Baselining is what makes enabling replication on a node
that has been running *without* it actually converge: capture is CDC and the snapshot streamer
@@ -859,6 +885,15 @@ Two things to know before relying on any of this:
browse, subscribe and HistoryRead are ungated for any admitted session including Anonymous**. With the
flag **on** (`DataPlaneAuthorizer`), scoped grants decide **every** data-plane operation: write and
alarm commands and HistoryRead (stage 5), read and browse and subscription creation (stage 6).
- **The two role gates re-check the session's KEY on every call, flag off or on (Gitea #563).** Roles
are resolved once at activation, so an expired / deployment-disabled key used to keep `WriteOperate` /
`AlarmAck` on its open session until the client disconnected. `DataPlaneAuthorizer.AuthorizeKeyUsable`
runs the same `FindUsableKey` probe against the same snapshot + clock as the ACL path (one dictionary
lookup, no I/O) and denies `BadUserAccessDenied` before the role check. **Only those two gates** —
reads/subscriptions stay ungated with the flag off, by design. Anonymous carries no key and is
unaffected. No flag: a dead key keeping write authority is a defect, not a rollout choice. ⚠️ On a
fused node a key minted after the last deploy authenticates (ConfigDb) but cannot write/ack until a
deployment carries it — the flag-on path always behaved this way.
- ⚠️ **The ACL decision REPLACES the role check — the two are never chained.** Chained, a grant would
have to confer the server-wide role *as well as* pass the scope check, and only `Cluster`-scoped
grants confer a role, so every narrow grant would stay inert and the flag would appear to do
@@ -910,6 +945,60 @@ Two things to know before relying on any of this:
`Security:Acl:Enabled` is false), runs the same pass on the clock — because `ExpiresUtc` passes with
the clock, not with a deployment. The pass's verdict memo is **pass-local and must stay that way**;
hoisting it across ticks would serve an expiry that landed between two ticks the earlier tick's allow.
**Event/alarm items ride both triggers too (Gitea #562)** — a second map (`_eventItems`), fed by the
`SubscribeToEvents` override, holds every *direct* notifier subscription; a denied one is detached via
the SDK's own protected `SubscribeToEvents(…, unsubscribe: true)` (what a client delete does) and
re-attached with `unsubscribe: false` on restore — again never `Disabled`. Unlike a data item it is
**not told** (no value slot for a status; the feed goes quiet), and a client delete of a stood-down
item is absorbed by the override (the SDK would otherwise remove an item that is not attached).
**Server-object (`i=2253`) event subscriptions are filtered per event at delivery (Gitea #586).** The
`SubscribeToAllEvents` override attaches them to **nothing** (just records them in `_allEventsItems`);
each condition's own `OnReportEvent` (`OnConditionReportEvent`, fires once per event ⇒ still one
Server-object copy) queues the event to each recorded item whose session holds `Subscribe` on the
condition (RawPath) **or** any notifier folder it reports through — the per-folder path's question. The
root-notifier forward to the core Server object (`OnReportEvent` override) is **dropped**: it was the
ungated path, and it is the only one that served an item created before the alarm was deployed (the
SDK's `AddRootNotifier` subscribes existing all-events items with `unsubscribe: true`). Revalidation is
**implicit** — every event re-asks the authorizer, so a deploy or expiry lands on the next event with no
pass. `ConditionRefresh` on an `i=2253` item is filtered the same way. The dispatcher **must** keep
the SDK's audit guard (`AuditEventState` only while `Server.Auditing` over SignAndEncrypt/https),
because conditions report their Part 9 method audit events through themselves.
`ReconcileAllEventsMonitoring` must keep `AreEventsMonitored` raised on every condition while any
`i=2253` item exists, because `ConditionState` gates its method **audit** reports on
`EventsMonitored()` (method-driven state-change events no longer depend on it — #587, below). The
protected `SubscribeToEvents` override that no-ops all-events items is what
stops the SDK's `AddRootNotifier` (`unsubscribe: true` bug) taking that unit back. Both review
findings (review of `75e559f6`). ⚠️ The materialise path raises only the **new** condition's unit
(`RaiseAllEventsMonitoring`); a full reconcile there made a deploy of N alarms O(N²) under `Lock` with
ScadaBridge connected — keep the full reconcile to remove/rebuild/(un)subscribe.
**Model-change and audit events are gated too (Gitea #588).** The manager's own server-wide events
(`GeneralModelChangeEvent`, the rejected-write `AuditWriteUpdateEvent`) go through
`ReportToServerObject`: flag **off** ⇒ `Server.ReportEvent` as before; flag **on** ⇒ delivered per item to
`_allEventsItems` (same audience), a model change narrowed to the `Affected` nodes the session may
**Browse** (dropped when none — the builders' Messages name the node, so a narrowed copy gets a neutral
one), an audit write only with `Read` on its source node plus the SDK's audit/encryption guard. Any other
event type fails closed. Auditing is off by default (never configured in this repo).
- **ns=0 standard nodes stay ungated by design, even with `Security:Acl:Enabled`** (#617) — `ServerStatus`,
`NamespaceArray`, `ServiceLevel` etc. are served by the SDK's core node manager outside our authorizer and
clients read them to connect and to judge redundancy health; they carry no plant data. Every raw/UNS node,
event subscription and HistoryRead is still gated. `docs/security.md`.
- **Equipment-node writes: `EvaluateEquipmentWriteStructure` is the ONLY type check.** `OnWriteValue` bypasses
the SDK's own type check on every writable equipment node, so `OtOpcUaNodeManager.EvaluateEquipmentWriteStructure`
(plus `EvaluateEquipmentWriteRange`) is the whole gate. Scalars are deliberately lenient (drivers
range-check; ScadaBridge sends Double to a Float node). **Array writes** (#610): a numeric array of another
element type is **converted, checked, element by element** to the node's element type (ScadaBridge writes
CLR `int[]`/`double[]` lists) — any out-of-range, fractional-to-integer or NaN/∞ element refuses the whole
write `BadTypeMismatch` with nothing sent, so a `UInt32` > `Int32.MaxValue` can never reach a Galaxy Int32
attribute as reinterpreted bits; non-numeric mismatches are refused (`Byte[]` ≈ ByteString); wrong length ⇒
`BadTypeMismatch`; an array to a **scalar** node is refused only on Modbus/S7 (`EnsureVariable`'s
`refuseArrayOnScalar` — pass-through drivers keep their browse-committed scalar-shaped array tags writable;
a new sink wrapper must forward that argument, and the reflection guard checks only that the call arrives);
`IndexRange` writes ⇒ `BadWriteNotSupported`, `ValueRank > 1` out of scope. An
array node is writable only when the driver can write the array (`DriverArrayWriteSupport`: Modbus bounded
by `maxRegistersPerWrite`/coil cap, S7 non-Bool/non-wide types, OpcUaClient, Galaxy signed types **with a
declared `arrayLength`** — an undeclared length stays read-only because a short whole-array write would
resize the Galaxy attribute); `arrayLength >= 1` is required, and the node's `Writable && (!IsArray ||
ArrayWritable)` is projected onto the raw node and every UNS mirror.
- **Anonymous is a normal grantable principal**, not a bypass. `OpcUa:AllowAnonymous` (default `true`,
preserving today's behaviour) controls *admission*; when admitted the session authenticates as the
reserved principal `anonymous` and gets roles from `NodeAcl` rows naming it, arriving at the gates as
@@ -932,8 +1021,9 @@ Two things to know before relying on any of this:
**Expiry is the exception**: `FindUsableKey` compares `ExpiresUtc` against the authorizer's clock on every
call, so it is immediate everywhere — and since arch-review 03/S19 it reaches live subscriptions too, on
the host's `Security:Acl:RevalidationIntervalSeconds` timer (default 60 s; the session is **silenced with
`BadUserAccessDenied`, not closed**, and recovers on re-activation). ⚠️ Event/alarm monitored items are
revalidated by neither trigger — pre-existing, and a named residual.
`BadUserAccessDenied`, not closed**, and recovers on re-activation). Direct event/alarm subscriptions
are revalidated on both triggers as well (#562); with the flag **off**, expiry and revocation reach the
two role gates (#563) but not reads or subscriptions.
- ⚠️ **The activation lookup is BOUNDED (arch-review 03/S18).** The SDK raises impersonation synchronously
under a `SessionManager`-wide lock, so a ConfigDb that is *slow* rather than *refusing* stalled **every**
session activation on the server, Anonymous and X.509 included. `BoundedApiKeyStore` wraps the ConfigDb
@@ -1000,11 +1090,64 @@ Refusals are not comms faults: an Unsupported refusal (`BadNotSupported`) and an
mismatch** (`BadTypeMismatch`, where libplctag returned Good garbage for a same-width mis-authored type) both
keep the tag handle, leave driver health alone (no `Degraded`, nothing for the comms-loss watchdog) and warn
once per tag — only genuine transport failures evict and degrade. `DeviceSessionPool.Shared` logs through the
host logger factory (wired in `DriverFactoryBootstrap`). libplctag is confined to `Libplctag*.cs` adapters by
host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT factories' `Register(registry, ILoggerFactory?)` take one too, #602 CONV-3). The AbCip `browsePageDelayMs` knob has an AdminUI field (`AbCipDriverForm` "Browse page delay (ms)"). libplctag is confined to `Libplctag*.cs` adapters by
`LibplctagConfinementGuardTests`; do not reference it elsewhere. See `docs/drivers/AbCip.md` §Transport,
`docs/drivers/AbLegacy.md` §Transport + §Wire evidence, `docs/Configuration.md` §`Drivers:AbCip`, and
`docs/plans/2026-10-07-native-ab-transport-gate-results.md`.
## Other driver notes
- **S7:** connect attempts back off 1 s doubling to 30 s (one attempt per window, not one per call/poll/probe
tick); a write-side access-denied (PUT/GET disabled) latches a **sticky Faulted** that outranks later read
passes; driver-side array writes cover non-Bool narrow/`Int32`/`Float32` types only — Bool arrays are
refused (a one-byte-per-element encode would clobber neighbour bits).
- **MQTT:** Plain `writeTopic` writes publish at QoS 1 (Sparkplug NCMD/DCMD stay QoS 0); the Sparkplug
primary-host claim backs off 2 s → 30 s; writes encode the birth's own datatype (unsupported birth types
`BadNotSupported`).
- **FOCAS / AbLegacy:** background loops are joined (≤ 5 s) on shutdown.
- **Re-Initialize without teardown (#630):** FOCAS, AbLegacy, AbCip, TwinCAT, S7 and OpcUaClient `InitializeAsync`
first release the prior device/session state (cancel, ≤ 5 s join, dispose — incl. the AbCip/FOCAS alarm
projections) — the device-state half of `ShutdownAsync` only; the poll engine is kept and the actor re-subscribes
after every successful init. AbCip publishes fresh tag tables by reference swap (a second init used to throw
"tag name collision" on `AdoptConfigDuringInit`); S7 swaps `Plc` under `_gate`.
- **S7 (#632/#638):** STRING/WSTRING/CHAR/WCHAR and DTL/DATE/TOD/TIME/S5TIME codecs (`S7ValueCodecs`; TOD/TIME/S5TIME
surface as Int32 ms); an undecodable buffer or unrepresentable write is `BadOutOfRange` with no health impact.
Optional `localTsap`/`remoteTsap`/`connectionType` override the slot TSAP (`remoteTsap` ⊕ `connectionType`). Test
Connect reads 2 bytes of `MB0` so a PUT/GET-disabled CPU fails the probe. The negotiated PDU size is logged and on
`S7Driver.GetDiagnostics()` only — there is no driver-diagnostics surface on `/hosts`.
- **TwinCAT (#637):** TIME/TOD publish Float64 ms, DATE/DT a UTC DateTime; a tag authored `UDInt` keeps the raw counter
(and keeps historizing — DateTime is not historized).
- **AbLegacy (#636):** `AbLegacyAddress` (in `AbLegacy.Contracts`) is family-aware: PLC-5 `I:`/`O:` indices are OCTAL
and are emitted to libplctag as decimal (libplctag's `pccc.c` parses decimal only). MicroLogix function files and
indirect `N7:[N7:0]` addresses parse but read `BadNotSupported`. One check (`AbLegacyAddress.Validate`) backs the
editor (family-independent) and the deploy gate (family from the tag's `DeviceConfig.PlcFamily`); an unparseable
address reads `BadNodeIdUnknown` for that tag only.
- **OpcUaClient (#635):** OperationLimits are honoured by the SDK session (`SessionClientBatched`) — do not re-add
driver-side chunking. Subscription tuning keys are range-checked at `ParseOptions`. `discoveryUrl` endpoints go
AFTER configured ones; more than one discovered server without `discoveryServerUri` fails the connect. Session
events are the OTel counter `otopcua.driver.opcuaclient.events`. `HistoryAggregateType` carries the full Part 13
set; an aggregate the gateway can't serve is `BadAggregateNotSupported` per node.
- **MTConnect (#627/#628):** TIME_SERIES reads as a variable-length `double[]` (Float64 only); a count disagreeing
with `sampleCount` is `BadDecodingError`. One MTConnect driver = one agent client = at most one `/raw` Device
(`MTConnectMultipleDevices` deploy error; `RawTreeService.CreateDeviceAsync` refuses a second).
- **MTConnect CONDITION alarms (#626):** a CONDITION raw tag is a native Part 9 condition only when its `TagConfig`
carries the standard `alarm` object (opt-in — alarm tags leave the value subscription, so auto-promotion would break
existing String tags). Normal→inactive, Warning→500, Fault→800; one condition per DataItem showing the most severe
active nativeCode (ties: ordinal nativeCode, then type); ack is server-side only. ⚠️ UNAVAILABLE never clears: an
active condition stays active+acked with a quality-only `BadNoCommunication` (same as #481 transport loss). The
driver dedups against what the server was last TOLD (level+nativeCode+nativeSeverity+qualifier — not text), records
only while a handler is attached, and clears that record when the subscribed alarm SET changes (a rebuild recreates
the nodes inactive) but not on a reconnect of the same set — a replayed `/current` must never re-raise (un-ack).
`AlarmEventArgs` gained optional `OpcUaSeverity` (exact Part 9 severity) and `QualityStatusCode`; a quality-only
event goes out as `AlarmQualityUpdate` per node and never through the snapshot projection.
- **Driver removal (#609):** removing a driver releases its capability-invoker pipelines and resilience
tracker, *then* evicts its telemetry snapshots. ⚠️ Both are **tombstoned**, not just cleared: the removed
child stops asynchronously and its in-flight subscribe/connect still completes through the invoker (and
emits Health) on the thread pool *after* `StopChild`, which used to resurrect the state — so a removed
driver's counters were republished forever. `Release` → tracker `Retire` and hub `Evict` ignore every later
write for that id until it is spawned again (`Create` → `Reinstate`, `SpawnChild` → `Readmit`). A
Watch/`Terminated` release would not work — the late writes land after the actor terminates.
## Library Preferences
- **Logging**: Serilog with rolling daily file sink
@@ -1027,6 +1170,8 @@ dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp:
dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- subscribe -u opc.tcp://localhost:4840 -n "ns=2;s=SomeNode" -i 500
```
**AdminUI interactive pages must wrap their body in `<PageErrorBoundary>`** — `MainLayout` is static, so it cannot catch an interactive page's render failure.
Address pickers in AdminUI support live browse for OpcUaClient and Galaxy drivers — see `docs/plans/2026-05-28-driver-browsers-design.md`.
The AdminUI's global **UNS** page (`/uns`) is the single surface for managing the unified namespace fleet-wide (Area → Line → Equipment → Tag/VirtualTag), replacing the old per-cluster UNS/Equipment/Tags tabs. See `docs/Uns.md`.
@@ -1052,11 +1197,45 @@ exactly. The backend lives in
endpoints under `/api/script-analysis/*`, gated by the `ConfigEditor` policy
(Administrator or Designer) via `RequireAuthorization` in
`ScriptAnalysisEndpoints.MapScriptAnalysisEndpoints`).
`Core.VirtualTags` is now just `DependencyGraph`, `ITagUpstreamSource` and `IHistoryWriter` — the dormant `VirtualTagEngine`/Source/Definition and `TimerTriggerScheduler` were deleted.
See `docs/ScriptEditor.md` for the full guide. Scripts may use the `{{equip}}` token for equipment-relative tag paths that resolve per-equipment at deploy time — see the "Equipment-relative tag paths" section in `docs/ScriptEditor.md`.
## Scripted Alarm Ack/Shelve
Inbound operator acknowledge/shelve for scripted alarms is fully implemented. Two surfaces converge on the `alarm-commands` DPS topic: (1) OPC UA Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) wired in `OtOpcUaNodeManager`, gated on the `AlarmAck` LDAP role via `RoleCarryingUserIdentity`; (2) AdminUI `/alerts` per-row buttons routed through the `AdminOperationsActor` singleton (gated by `DriverOperator` policy). `ScriptedAlarmHostActor` dispatches from the topic to the engine. Client.CLI supports `ack`, `confirm`, `shelve` commands. See `docs/ScriptedAlarms.md` §"Inbound operator ack/shelve" and `docs/AlarmTracking.md`.
Inbound operator acknowledge/shelve for scripted alarms is fully implemented. Two surfaces converge on the `alarm-commands` DPS topic: (1) OPC UA Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) wired in `OtOpcUaNodeManager`, gated on the `AlarmAck` role — conferred by the session's API-key principal's `NodeAcl` grants (or the `anonymous` principal's), carried by `RoleCarryingUserIdentity`; LDAP is AdminUI-only; (2) AdminUI `/alerts` per-row buttons routed through the `AdminOperationsActor` singleton (gated by `DriverOperator` policy). `ScriptedAlarmHostActor` dispatches from the topic to the engine. Client.CLI supports `ack`, `confirm`, `shelve` commands. See `docs/ScriptedAlarms.md` §"Inbound operator ack/shelve" and `docs/AlarmTracking.md`.
⚠️ **One condition event per accepted method call is OURS, not the SDK's (Gitea #587).** The SDK's own
report is gated on `AutoReportStateChanges` (left `false`), so before #587 a client Acknowledge emitted **no**
condition event — the old "SDK auto-fires its own event (E2)" comments were wrong. `WireMethodTransitionReporting`
wraps every method's SDK handler (Ack/Confirm/AddComment/Enable/Disable/OneShotShelve/TimedShelve/Unshelve) and,
on Good, reports once via `ReportConditionEvent` — **under `Lock`**, so the engine's/driver's re-projection
(routed by a fire-and-forget Tell *before* the SDK applies state) waits and then hits the delta-gate with no
delta. Things to keep: the SDK's shelve handlers never apply shelving, so the wrapper does; only a real
before/after change is reported (a re-ack is silent and restores the Confirmed/Retain the SDK flips);
`MaxTimeShelved` is set to `int.MaxValue` ms at materialise because `Create()` left it 0 and every TimedShelve
failed. ⚠️ **Native alarms support Acknowledge and AddComment only** — Confirm, the shelve methods and
Enable/Disable return `BadNotSupported` (no upstream path, no pair replication, and `NativeAlarmProjector`'s
Confirmed/Unshelved constants own those fields); a native ack re-asserts `ConfirmedState = true` because the
SDK's ack unconfirms any confirm-capable condition and ScadaBridge maps that field. Pinned over the wire by
`ConditionMethodEventTests` (exactly-once after a sentinel, folder + `i=2253`).
Behaviours that were wrong or missing and are now fixed — keep them: the SDK's `OnTimedUnshelve` is a
**no-op** (the engine's `AutoUnshelve` owns timed-shelve expiry, and `UnshelveTime` reports the real
remaining time from `AlarmConditionSnapshot.UnshelveAtUtc`); a **commented** re-ack is stored as an
`AddComment` (durable, replicated) and reports one event, an uncommented re-ack stays silent; a **native
ack is refused `BadInvalidState` off-Primary** via `NativeAlarmAckPrimaryGate` (fed by
`IDriverPrimaryRoleSource`; `DriverHostActor` publishes its Primary verdict at host start and on member
changes, so the boot window before the first redundancy snapshot is closed — the same fix covers the MQTT
Sparkplug primary-host claim). A native re-ack is still re-sent to the driver (the retry path, by design).
**Condition children are instance-scoped (#629).** Every child of a materialised condition carries
`{ConditionId}/{browse path}` in the condition's namespace (e.g. `…/temp_hi/ShelvingState/UnshelveTime`), assigned by
the `OtOpcUaNodeManager.New` override — the SDK default kept each child on the TYPE's ns=0 id, so a direct Read hit
the type (or `BadNodeIdUnknown`), translate failed, and children sat outside the ACL (the slash join now puts them
under their condition's scope). ⚠️ The shelve methods resolve by the `AlarmConditionType_ShelvingState_*` ids only
because `New` adopts those (a fixed set) as their `MethodDeclarationId`; calls by the `ShelvedStateMachineType_*` id
are no longer supported (pinned by a test). Condition teardown must forget the whole `PredefinedNodes` subtree
(`ForgetPredefinedSubtree`) or a removed condition's children keep answering.
## Historian / HistoryRead
@@ -1138,7 +1317,7 @@ only the recorder + outbox knobs:
### Tag auto-provisioning (`IHistorianProvisioning` EnsureTags hook)
`AddressSpaceApplier.Apply()` fires a **non-blocking, fire-and-forget** `IHistorianProvisioning.EnsureTagsAsync`
hook for added historized value tags — the gateway-backed `GatewayTagProvisioner` calls the gateway's
hook for added historized value tags — and, since #600.1, for **changed** raw tags that flip to historized or change their resolved historian name or DataType — the gateway-backed `GatewayTagProvisioner` calls the gateway's
`EnsureTags` so a brand-new historized tag exists in the historian before the recorder's `WriteLiveValues`
land. The hook is wrapped so a faulted/synchronously-throwing provisioner can **never** block or fail a
deploy. Non-numeric (`String`/`DateTime`/`Reference`) data types are skipped (not provisioned); the
@@ -1205,7 +1384,7 @@ dotnet test --filter "Category=LiveIntegration"
The live suite **skips cleanly** when these env vars are absent (safe to run offline on macOS). It is the
gate the operator runs on the VPN before trusting the cutover.
### KNOWN LIMITATION 2 — continuous-historization value-capture wired; live end-to-end verification still pending (issue #491)
### Continuous-historization value capture — wired and live-gated (was "KNOWN LIMITATION 2"; #491 CLOSED 2026-08-04)
The `ContinuousHistorizationRecorder` is fully wired (actor + FasterLog outbox + gateway value-writer +
meters). It is spawned with an **empty *initial* ref set** (`Array.Empty<string>()` in
@@ -1273,6 +1452,7 @@ tag); evidence in the same plan doc. Two things to know before touching either.*
(NOT alongside the alarm `IRedundancyRoleView`, which only exists when that section is enabled — the
#511 registration accident).
Also re-confirmed live: **`EnsureTags` provisioning fires only for newly-ADDED historized tags** —
flipping an existing tag's `historianTagname` produces no provisioning at all
(`2026-06-27-historian-gateway-integration-issues.md` Issue 5).
Re-confirmed live in 2026-06 and **since fixed (#600.1)**: `EnsureTags` provisioning used to fire only for
newly-ADDED historized tags, so flipping an existing tag's `historianTagname` (or historization, or DataType)
provisioned nothing (`2026-06-27-historian-gateway-integration-issues.md` Issue 5). It now also fires for those
changed raw tags.
+18 -6
View File
@@ -2,12 +2,10 @@
<!--
Defaults inherited by every csproj. Individual projects may override.
Deviation from the original v2 plan: TreatWarningsAsErrors is NOT set globally because the
pre-v2 test projects (e.g. Admin.Tests) carry 240+ xUnit1051 analyzer warnings that would
fail the build. New v2 projects (Commons, Cluster, ControlPlane, Runtime, OpcUaServer, AdminUI,
Host, Security) MUST opt in to <TreatWarningsAsErrors>true</TreatWarningsAsErrors> in their
own csproj. Once the legacy Admin/Server projects are deleted (Phase 10, Task 56), this can
be promoted back to a global default.
TreatWarningsAsErrors is NOT set globally because test projects carry analyzer warnings
(e.g. xUnit1051) that would fail the build. src/ projects opt in to
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> in their own csproj (a few still pending;
see #606). Once every project is clean this can be promoted back to a global default.
-->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -16,6 +14,20 @@
<LangVersion>latest</LangVersion>
</PropertyGroup>
<!--
OTOPCUA0001 is an ERROR under src/ (#595): production code must route capability calls through
CapabilityInvoker. src/ is currently clean; test projects keep it a Warning and suppress it
per-csproj where they call drivers at the wire level. The path test is relative to this file
so it does not depend on where the repo is checked out; backslashes normalised for Windows.
-->
<PropertyGroup>
<_OtRepoRoot>$([System.String]::Copy('$(MSBuildThisFileDirectory)').Replace('\','/'))</_OtRepoRoot>
<_OtProjPath>$([System.String]::Copy('$(MSBuildProjectFullPath)').Replace('\','/'))</_OtProjPath>
</PropertyGroup>
<PropertyGroup Condition="$(_OtProjPath.StartsWith('$(_OtRepoRoot)src/'))">
<WarningsAsErrors>$(WarningsAsErrors);OTOPCUA0001</WarningsAsErrors>
</PropertyGroup>
<!--
Wire the custom OTOPCUA0001 analyzer (UnwrappedCapabilityCallAnalyzer) into every project so
the CapabilityInvoker-wrapping rule is enforced tree-wide, not just against the analyzer's own
+2 -3
View File
@@ -60,7 +60,7 @@ Cross-cluster regression coverage lives in `Host.IntegrationTests`.
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterRoleInfo.cs:110-133` |
| Status | Deferred |
| Status | Resolved |
**Description:** `HandleMemberEvent` only adds members on `MemberUp` and removes on
`MemberRemoved`. `MemberLeft`, `MemberExited`, `MemberWeaklyUp`, and `MemberDowned` (all
@@ -73,8 +73,7 @@ data.
**Recommendation:** Add cases for `MemberLeft`, `MemberExited`, and `MemberDowned` that remove the
member from `_membersByRole` immediately.
**Resolution:** Deferred — `MembersWithRole` has no production callers today; stale window is
bounded and self-corrects on `MemberRemoved`. Revisit when a caller is added.
**Resolution:** Resolved 2026-10-08 (#625). `MembersWithRole` gained a production caller (the historization scope-peer check in `Runtime/ServiceCollectionExtensions.cs`), so the stale window mattered. `HandleMemberEvent` now removes a member on `MemberLeft`, `MemberExited` and `MemberDowned` as well as `MemberRemoved` (shared `RemoveMember`). Pinned by `ClusterRoleInfoMemberEventTests` (Up add is the positive control; each departing transition empties the role).
---
+2 -2
View File
@@ -59,7 +59,7 @@ apply", referencing the old `Phase7*` naming that was renamed to `AddressSpace*`
| Severity | Low |
| Category | Code organization & conventions |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/RestartDriver.cs:27,36`, `ReconnectDriver.cs:16,25`, `TestDriverConnect.cs:16,27` |
| Status | Deferred |
| Status | Won't Fix |
**Description:** `RestartDriver`, `ReconnectDriver`, and `TestDriverConnect` (plus their result
records) carry `Guid CorrelationId` parameters using the raw `System.Guid` type, while every
@@ -73,7 +73,7 @@ predate the `CorrelationId` struct.
cross-cutting public-API change that touches callers in at least AdminUI, Runtime, and Cluster
projects; defer to a coordinated cleanup pass.
**Resolution:** Deferred — cross-cutting public-API change requiring coordinated update across at least three other modules (AdminUI, Runtime, Cluster). Safe to defer.
**Resolution:** Won't Fix (2026-10-08, #625) — accepted divergence. The three messages (`RestartDriver`, `ReconnectDriver`, `TestDriverConnect`) are admin-command round-trips whose `Guid` is generated and matched at the sender; migrating to the `CorrelationId` wrapper is a public-API change across AdminUI, Runtime and Cluster for no behavioural gain. Revisit only if one of those senders is being rewritten anyway.
---
+2 -14
View File
@@ -381,25 +381,13 @@ corrected package set.
| Severity | Low |
| Category | Design-document adherence |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/` (no source change), gateway proto contract |
| Status | Deferred |
| Status | Won't Fix |
**Description:** The vendored `MxGateway.Contracts.dll` only carries the OLD `MxGateway.Contracts.Proto[.Galaxy]` namespace (PE-namespace dump confirms — `MxGateway.Client`, `MxGateway.Contracts`, `MxGateway.Contracts.Proto`, `MxGateway.Contracts.Proto.Galaxy` only). The sibling `mxaccessgw` repo's live `Protos/mxaccess_gateway.proto`, `mxaccess_worker.proto`, and `galaxy_repository.proto` files now generate into `ZB.MOM.WW.MxGateway.Contracts.Proto.*`. The proto wire format itself can still evolve (new RPCs, renamed fields, removed fields) and the driver has no contract-version handshake (a repo-wide search for `ContractVersion|ProtocolVersion|ApiVersion|WireVersion` in the driver returns nothing) — so a gateway service that evolves its proto past what the vendored client knows will fail silently at runtime: gRPC `UNIMPLEMENTED` for a renamed RPC, default-value reads for a removed scalar field, or worse, a wire-tag collision if a field number is reused. The risk surface grew with vendoring: previously the `ProjectReference` would have hard-failed at build time if the proto changed shape; now the driver builds green against a frozen contract that may not match the running gateway.
**Recommendation:** (a) Add a single `Ping`/`GetVersion` RPC call at gateway-session open, comparing the gateway's reported contract version against a string baked into `libs/README.md` (or a `GatewayContractVersion` const) and refusing the session on mismatch with a clear log. (b) Document in `libs/README.md` the exact mxaccessgw commit SHA (and proto-file SHA-256s) the vendored DLLs were built from, so a parity-rig operator can grep the live gateway for the matching commit. (c) Add a soak/parity test that asserts the live gateway's proto descriptor still matches what the vendored DLL expects — fail loud rather than degrade.
**Resolution:** Deferred 2026-05-23 — the recommendation's part (b)
(record the mxaccessgw source-commit SHA in `libs/README.md`) is satisfied
by the Driver.Galaxy-015 resolution, which records both DLLs were built
from mxaccessgw commit `dd7ca1634e2d2b8a866c81f0009bf87ee9427750`. Parts
(a) and (c) — adding a `GetVersion` RPC at session-open and a parity
test against the live gateway's proto descriptor — are substantial new
RPC + plumbing work that is not in scope for this code-review-resolution
sweep. The risk surface is bounded because either of the two unwinding
paths in `libs/README.md` (sibling repo restores `MxGateway.Client.csproj`,
or this driver migrates to the new namespace) will move the codebase
past the vendoring + close this concern naturally. Re-open if neither
unwinding path is taken within the next quarter and the live gateway
service does evolve its proto under the driver.
**Resolution:** Won't Fix (2026-10-08, #625) — part (b) is done (Driver.Galaxy-015 records the mxaccessgw commit SHA); parts (a)/(c) need a new `GetVersion` RPC on the gateway and a live-gateway parity gate, which belongs to the mxaccessgw contract owner, not this driver. Contract drift surfaces as gRPC `UNIMPLEMENTED` at first use.
### Driver.Galaxy-018
@@ -61,7 +61,7 @@ Although the OpcUaClient browser never issues a server round-trip in `Attributes
| Severity | Low |
| Category | Performance & resource management |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs:112-125` |
| Status | Deferred |
| Status | Resolved |
**Description:** If the caller's `CancellationToken` fires during the `BrowseNextAsync` pagination loop, `OperationCanceledException` propagates out of the loop and any in-progress server-side continuation point is never released via `BrowseNext(releaseContinuationPoints: true)`. The server holds the cursor until the session is closed. For the transient AdminUI picker session this is low-risk (the session is short-lived and `DisposeAsync` → `CloseAsync` eventually cleans it up server-side), but it represents a server-side resource leak during cancellation.
@@ -69,7 +69,7 @@ The identical pattern exists in the runtime driver (`OpcUaClientDriver.cs`), mak
**Recommendation:** Catch `OperationCanceledException` in the pagination loop, issue a fire-and-forget `BrowseNext(releaseContinuationPoints: true, continuationPoints: [cp])` before re-throwing, and apply the same fix to `Driver.OpcUaClient`.
**Resolution:** Deferred — awaiting a cross-cutting fix that also updates `Driver.OpcUaClient`. Short session lifetime of the AdminUI picker limits practical impact.
**Resolution:** Resolved 2026-10-08 (#625). The runtime driver was fixed first (Driver.OpcUaClient-016); `OpcUaClientBrowseSession.BrowseOneLevelAsync` now catches `OperationCanceledException` in its `BrowseNextAsync` loop and issues a best-effort `releaseContinuationPoints: true` call on `CancellationToken.None` before rethrowing. Pinned by `OpcUaClientBrowseSessionPaginationTests` (mock `ISession`; an uncancelled pagination is the negative control).
---
@@ -50,13 +50,13 @@
| Severity | Low |
| Category | OtOpcUa conventions / Design-document adherence |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj:9` |
| Status | Deferred |
| Status | Resolved |
**Description:** The Contracts project carries a direct `PackageReference` to `OPCFoundation.NetStandard.Opc.Ua.Client` solely to support `NamespaceMap`. This makes the Contracts project not a lightweight DTO/enum assembly: every consumer (AdminUI serializers, future test helpers, config-only tooling) that references Contracts for `OpcUaClientDriverOptions` and its enums also transitively receives the full OPC UA client SDK. Both `OpcUaClientDriver` and `OpcUaClientDriverBrowser` already reference the SDK directly and could host `NamespaceMap` without any new dependency. Moving `NamespaceMap` to the runtime driver (or to a dedicated shared helper project referenced by both driver and browser) would restore the Contracts project to its intended SDK-free role.
**Recommendation:** Move `NamespaceMap` out of Contracts into `ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient` (or an internal shared helper). Remove the `OPCFoundation.NetStandard.Opc.Ua.Client` reference from the Contracts `.csproj`. `NamespaceMap` is not part of the public wire contract; it is an implementation detail of the driver's namespace-stability mechanism.
**Resolution:** Deferred 2026-06-20. Investigated the relocation during the open-findings resolution pass. `NamespaceMap` is consumed by the runtime driver (`Driver.OpcUaClient`) AND the Browser (`Driver.OpcUaClient.Browser`), but the Browser references **only** `Driver.OpcUaClient.Contracts` — it has no ProjectReference to the runtime driver. So the recommended "move it into `Driver.OpcUaClient`" would break the Browser's compile. The only relocation that keeps both consumers building without the Browser taking a runtime-driver dependency is a brand-new dedicated shared-helper project referenced by both — a structural change (new `.csproj`, solution-file edit, two ProjectReference rewrites) disproportionate to a Low architectural-cleanliness finding whose only cost is the OPC UA SDK being transitively visible to Contracts consumers (AdminUI already references the SDK directly via the driver anyway). Left Deferred until an OpcUaClient project-layout refactor is undertaken for an independent reason; revisit then.
**Resolution:** Resolved (verified 2026-10-08, #625). `NamespaceMap` has since moved to the dedicated `Driver.OpcUaClient.Session` leaf project (Gitea #512), referenced by both the runtime driver and the Browser; the Contracts project no longer references the OPC UA client SDK (its only reference is `Core.Abstractions`). This is the shared-helper-project shape the 2026-06-20 deferral said was the only workable one.
---
+2 -2
View File
@@ -292,10 +292,10 @@ the S7 copy explains why the event handler writes via `console.Output` synchrono
| Severity | Low |
| Category | Design-document adherence |
| Location | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ProbeCommand.cs` |
| Status | Deferred |
| Status | Resolved |
**Description:** The Modbus CLI `ProbeCommand` was enhanced (Driver.Modbus.Cli-006) with a `ComputeVerdict` method that derives a single `OK / DEGRADED / FAIL` headline from both the driver state and the probe-read snapshot StatusCode. This prevents the contradictory output `Health: Healthy` when the probe snapshot carries a Bad quality code. The S7 `ProbeCommand` has the same contradiction risk — it prints `Health: {health.State}` without cross-checking the snapshot quality.
**Recommendation:** Port the `ComputeVerdict` logic from the Modbus CLI to the S7 CLI: derive a verdict from `health.State` + `snapshot[0].StatusCode` and print it as the headline `Verdict:` line, keeping `Health:` for the raw driver state.
**Resolution:** Deferred — enhancement rather than bug; requires mirroring the Modbus CLI's `ComputeVerdict` pattern which is a design change not a correctness fix. No test regression risk from deferring.
**Resolution:** Resolved 2026-10-08 (#625). Ported `ComputeVerdict` to `S7.Cli`'s `ProbeCommand`; the probe now prints a `Verdict:` headline (OK / DEGRADED / FAIL) from driver state + snapshot StatusCode, keeping `Health:` as the raw driver state. Covered by `ProbeCommandTests` in `Driver.S7.Cli.Tests`.
+2 -2
View File
@@ -74,10 +74,10 @@ a category produced nothing rather than leaving it blank.
| Severity | Low |
| Category | Design-document adherence |
| Location | `docs/v2/driver-specs.md:369` |
| Status | Deferred |
| Status | Resolved |
**Description:** The `CpuType` table in `docs/v2/driver-specs.md` (§5, "Connection Settings") lists only four values: `S7300`, `S7400`, `S71200`, `S71500`. The `S7CpuType` enum in this module defines seven values — `S7200 = 0`, `Logo0BA8 = 1`, `S7200Smart = 2`, `S7300 = 10`, `S7400 = 20`, `S71200 = 30`, `S71500 = 40` — all mapped through `S7CpuTypeMap.ToS7Net` in the driver. `docs/drivers/S7.md` mentions S7-200 / S7-200 Smart / LOGO! 0BA8 in prose but gives no enum string names or rack/slot conventions. An operator targeting a LOGO! 0BA8 has no documentation reference for the `CpuType` JSON string to use.
**Recommendation:** Add `S7200`, `S7200Smart`, `Logo0BA8` rows to the `driver-specs.md` CpuType table (or note limited S7comm support for these legacy families) and add their enum names to `docs/drivers/S7.md`'s rack/slot table.
**Resolution:** Deferred — the fix is in `docs/v2/driver-specs.md` and `docs/drivers/S7.md`, outside this module's source files. No code impact. Tracked as a documentation-only follow-up.
**Resolution:** Resolved 2026-10-08 (#625). `docs/v2/driver-specs.md` CpuType row and `docs/drivers/S7.md` rack/slot section now list `S7200`, `S7200Smart` and `Logo0BA8` with their rack/slot behaviour and the limited-support caveat.
+2
View File
@@ -495,3 +495,5 @@ open-ended deferral.
**Recommendation:** Add `int? ArrayCount` and `bool? IsArray` to `S7TagDto` and propagate them through `BuildTag`. Mirror the `S7EquipmentTagParser` semantics (honour `arrayLength` only when `isArray` is true and the value is positive).
**Resolution:** Deferred — requires an AdminUI page/DTO change and a corresponding config-form field addition. Tracked as a follow-up to the array-read Phase 4d work. No code change in this review; the existing init guard-(e) correctly rejects any programmatically-constructed writable array reaching the driver until the DTO is wired.
**Update (2026-10-08, #610 item 2):** the DTO half was already stale — `S7TagDto` is gone and `S7TagDefinitionFactory` reads `isArray`/`arrayLength` from TagConfig. Remaining pieces now done: the driver write path (`EncodeArrayBlock` + one contiguous `WriteBytesAsync`; guard-(e) narrowed to writable **Bool** arrays, which would clobber neighbouring bits), and AdminUI author-time validation in `S7TagConfigModel` (the isArray/arrayLength controls live in the TagModal's generic `TagArrayConfig`, not the S7 editor). Server-side exposure of array writes stays with #610 item 1.
+2 -9
View File
@@ -162,7 +162,7 @@ out of scope; option (a) here removes the silent-truncation defect for the commo
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `OtOpcUaNodeManager.cs:1978` (`ServeRawPaged`), `HistoryPaging.cs` (whole), `HistoryPaging.cs:213` (`SliceTieCluster` `next <= endUtc`) |
| Status | Deferred |
| Status | Won't Fix |
**Description:** The Raw paging chain treats `endUtc` as an **inclusive** upper bound throughout —
the `HistoryContinuationState`/`HistoryPaging` XML docs all say "the original (inclusive) end of
@@ -186,14 +186,7 @@ the inclusive/exclusive question requires confirming the Wonderware backend's ac
semantics (cross-module / infra), and changing a comparison without that confirmation risks the
opposite off-by-one.
**Resolution:** Deferred — 2026-06-20: infra-gated. Resolving the `endUtc` inclusive-vs-exclusive
disagreement requires confirming the actual Wonderware historian backend's boundary semantics, which is
hardware/infra-gated and not reachable from this macOS dev host. The impact is benign and bounded — because
the backend is the authority on which samples exist (a sample at exactly `endUtc` never appears in an
exclusive-end read), the disagreement only ever yields ONE extra empty resume page (`[endUtc, endUtc)` →
GoodNoData, no continuation point) rather than any duplicated or dropped data. Changing the
`SliceTieCluster` comparison / paging XML docs without confirming the live backend boundary risks
introducing the opposite off-by-one, so no code is changed here pending that live confirmation.
**Resolution:** Won't Fix (2026-10-08, #625) — accepted by design. The paging chain treating `endUtc` as inclusive is the conservative side of the exclusive backend contract: worst case is one extra empty resume page, never a dropped or duplicated sample, and the live backend boundary cannot be confirmed from a dev host. The XML doc on `HistoryPaging.SliceTieCluster` now records the ruling. The backward-read (`StartTime == MinValue`) caveat is unchanged: the backend is assumed to return chronological results.
### OpcUaServer-004
+9 -9
View File
@@ -321,7 +321,7 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
| OpcUaServer-001 | Medium | Resolved | Correctness & logic bugs | `AddressSpacePlan.cs:56` (`AddressSpacePlan.IsEmpty`), `AddressSpacePlan.cs:80` (`AddressSpacePlanner.Compute`) |
| OpcUaServer-002 | Medium | Resolved | Correctness & logic bugs | `OtOpcUaNodeManager.cs:1748` (`HistoryReadEvents`), `OtOpcUaNodeManager.cs:1814` (`ClampToInt`) |
| OpcUaServer-007 | Medium | Resolved | Correctness & logic bugs | `OtOpcUaNodeManager.cs:674` (`MaterialiseAlarmCondition` → `alarm.OnTimedUnshelve`) |
| Runtime-001 | Medium | Deferred | Correctness & logic bugs | `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs:311-318`; cross-module `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs:56-61` + `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs:55` |
| Runtime-001 | Medium | Resolved | Correctness & logic bugs | `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs:311-318`; cross-module `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs:56-61` + `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs:55` |
| Runtime-002 | Medium | Resolved | Error handling & resilience | `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs:148-169, 782-828` |
| Server-003 | Medium | Resolved | Correctness & logic bugs | `src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/RingBufferHistoryWriter.cs:96-119` |
| Server-005 | Medium | Resolved | Concurrency & thread safety | `src/Server/ZB.MOM.WW.OtOpcUa.Server/Alarms/AlarmConditionService.cs:166`, `src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs:303-311` |
@@ -371,12 +371,12 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
| Client.UI-011 | Low | Resolved | Documentation & comments | `Views/MainWindow.axaml:81`, `Services/JsonSettingsService.cs:11-15` |
| Client.UI-012 | Low | Resolved | Testing coverage | `ViewModels/AlarmsViewModel.cs:60-93` |
| Client.UI-013 | Low | Resolved | Correctness & logic bugs | `ViewModels/AlarmsViewModel.cs:71-82` |
| Cluster-002 | Low | Deferred | Correctness & logic bugs | `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterRoleInfo.cs:110-133` |
| Cluster-002 | Low | Resolved | Correctness & logic bugs | `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterRoleInfo.cs:110-133` |
| Cluster-003 | Low | Resolved | Performance & resource management | `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/HoconLoader.cs:12-16` |
| Cluster-004 | Low | Resolved | Documentation & comments | `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/Resources/akka.conf:4,7` |
| Cluster-005 | Low | Resolved | Testing coverage | `tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ServiceLevelCalculatorTests.cs` |
| Commons-001 | Low | Resolved | Documentation & comments | `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Observability/OtOpcUaTelemetry.cs:79` |
| Commons-002 | Low | Deferred | Code organization & conventions | `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/RestartDriver.cs:27,36`, `ReconnectDriver.cs:16,25`, `TestDriverConnect.cs:16,27` |
| Commons-002 | Low | Won't Fix | Code organization & conventions | `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Messages/Admin/RestartDriver.cs:27,36`, `ReconnectDriver.cs:16,25`, `TestDriverConnect.cs:16,27` |
| Commons-003 | Low | Resolved | Testing coverage | `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs`, `DeferredServiceLevelPublisher.cs` |
| Commons-004 | Low | Resolved | Testing coverage | `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/OpcUa/EquipmentNodeIdsTests.cs` |
| Configuration-004 | Low | Resolved | OtOpcUa conventions | `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs:8`, `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs:417` |
@@ -498,7 +498,7 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
| Driver.Galaxy-010 | Low | Resolved | Security | `GalaxyDriver.cs:311-341` |
| Driver.Galaxy-012 | Low | Resolved | Performance & resource management | `Runtime/SubscriptionRegistry.cs:65-67`, `GalaxyDriver.cs:538`, `GalaxyDriver.cs:675` |
| Driver.Galaxy-013 | Low | Resolved | Design-document adherence | `GalaxyDriver.cs:14-27`, `GalaxyDriver.cs:374-382`, `Config/GalaxyDriverOptions.cs:84-86` |
| Driver.Galaxy-017 | Low | Deferred | Design-document adherence | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/` (no source change), gateway proto contract |
| Driver.Galaxy-017 | Low | Won't Fix | Design-document adherence | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/` (no source change), gateway proto contract |
| Driver.Galaxy-018 | Low | Resolved | Documentation & comments | `libs/README.md:32-37`, `ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj:40-47` |
| Driver.Galaxy-019 | Low | Resolved | Error handling & resilience | `Runtime/GatewayGalaxySubscriber.cs:89-100` |
| Driver.Galaxy.Browser-003 | Low | Resolved | Code organization & conventions | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs:149` |
@@ -572,8 +572,8 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
| Driver.OpcUaClient-014 | Low | Resolved | Performance & resource management | `OpcUaClientDriver.cs:1138`, `:1314` |
| Driver.OpcUaClient-016 | Low | Resolved | Performance & resource management / Error handling & resilience | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs:934-948` |
| Driver.OpcUaClient.Browser-001 | Low | Resolved | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs:66` |
| Driver.OpcUaClient.Browser-002 | Low | Deferred | Performance & resource management | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs:112-125` |
| Driver.OpcUaClient.Contracts-002 | Low | Deferred | OtOpcUa conventions / Design-document adherence | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj:9` |
| Driver.OpcUaClient.Browser-002 | Low | Resolved | Performance & resource management | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs:112-125` |
| Driver.OpcUaClient.Contracts-002 | Low | Resolved | OtOpcUa conventions / Design-document adherence | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj:9` |
| Driver.OpcUaClient.Contracts-003 | Low | Resolved | Code organization & conventions | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/OpcUaClientDriverOptions.cs:131-138`, `:174-176` |
| Driver.S7-003 | Low | Resolved | Correctness & logic bugs | `S7Driver.cs:172`, `S7Driver.cs:255` |
| Driver.S7-005 | Low | Resolved | OtOpcUa conventions | `S7Driver.cs:33`, `S7Driver.cs:433` |
@@ -588,10 +588,10 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
| Driver.S7.Cli-009 | Low | Resolved | Correctness & logic bugs | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ProbeCommand.cs:61`, `Commands/ReadCommand.cs`, `Commands/WriteCommand.cs` |
| Driver.S7.Cli-010 | Low | Resolved | Code organization & conventions | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/SubscribeCommand.cs` |
| Driver.S7.Cli-011 | Low | Resolved | Concurrency & thread safety | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/SubscribeCommand.cs:61-67` |
| Driver.S7.Cli-012 | Low | Deferred | Design-document adherence | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ProbeCommand.cs` |
| Driver.S7.Cli-012 | Low | Resolved | Design-document adherence | `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/Commands/ProbeCommand.cs` |
| Driver.S7.Contracts-001 | Low | Resolved | Documentation & comments | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs:107` |
| Driver.S7.Contracts-002 | Low | Resolved | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7EquipmentTagParser.cs:38` |
| Driver.S7.Contracts-003 | Low | Deferred | Design-document adherence | `docs/v2/driver-specs.md:369` |
| Driver.S7.Contracts-003 | Low | Resolved | Design-document adherence | `docs/v2/driver-specs.md:369` |
| Driver.Sql-004 | Low | Resolved | Error handling & resilience | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs:651` |
| Driver.Sql-005 | Low | Resolved | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs:641` |
| Driver.Sql-006 | Low | Resolved | Correctness & logic bugs | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriver.cs:599` |
@@ -625,7 +625,7 @@ Findings with status `Resolved`, `Won't Fix`, or `Deferred`.
| Driver.TwinCAT.Contracts-002 | Low | Resolved | Documentation & comments | `TwinCATDataType.cs:33` (`Structure` member) |
| Host-003 | Low | Resolved | Design-document adherence | `docs/ServiceHosting.md` (section "Per-role configuration overlays") |
| Host-004 | Low | Resolved | Documentation & comments | `src/Server/ZB.MOM.WW.OtOpcUa.Host/Observability/ObservabilityExtensions.cs:41–42` |
| OpcUaServer-003 | Low | Deferred | Correctness & logic bugs | `OtOpcUaNodeManager.cs:1978` (`ServeRawPaged`), `HistoryPaging.cs` (whole), `HistoryPaging.cs:213` (`SliceTieCluster` `next <= endUtc`) |
| OpcUaServer-003 | Low | Won't Fix | Correctness & logic bugs | `OtOpcUaNodeManager.cs:1978` (`ServeRawPaged`), `HistoryPaging.cs` (whole), `HistoryPaging.cs:213` (`SliceTieCluster` `next <= endUtc`) |
| OpcUaServer-004 | Low | Resolved | Error handling & resilience | `OtOpcUaNodeManager.cs:1597` (`ResolveParentFolder`), and every public sink mutator that calls it (`EnsureFolder` 1278, `EnsureVariable` 1335, `MaterialiseAlarmCondition` 597, plus `WriteValue`/`WriteAlarmCondition` `CreateVariable`) |
| OpcUaServer-005 | Low | Won't Fix | Testing coverage | `OtOpcUaNodeManager.cs:2049` (`ServeRawPaged` tie-cluster stall path), `OtOpcUaNodeManager.cs:2068` (absurd-burst backstop) |
| OpcUaServer-006 | Low | Resolved | Documentation & comments | `OtOpcUaNodeManager.cs:11-30` (class XML doc), `OpcUaApplicationHost.cs:88-93` / `OpcUaApplicationHost.cs:421-423` (F13/F13c follow-up notes) |
+2 -2
View File
@@ -62,7 +62,7 @@ alarms (`ScriptedAlarmHostActor.cs`, `EfAlarmConditionStateStore.cs`,
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs:311-318`; cross-module `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs:56-61` + `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs:55` |
| Status | Deferred |
| Status | Resolved |
**Description:** This is the Runtime side of the orchestrator-handed cross-module item **OpcUaServer-001**:
a UNS Area/Line *rename* (display-name-only edit, no Equipment/Tag/Driver/Alarm change) produces an
@@ -90,7 +90,7 @@ is necessary but not sufficient.
`HandleRebuild` runs `MaterialiseHierarchy` for a non-empty UNS-only plan (it already does, since the
short-circuit only fires for a truly-empty plan). No Runtime-only change can close it safely.
**Resolution:** _(deferred — needs OpcUaServer `AddressSpacePlan` rename signal + Commons `EnsureFolder`/`RenameFolder` rename semantics; both outside the Runtime edit boundary. Tracked with OpcUaServer-001.)_
**Resolution:** Resolved (verified 2026-10-08, #625) — closed by OpcUaServer-001: `AddressSpacePlanner` now diffs `UnsAreas`/`UnsLines` renames (`DiffRenames`), so a display-name-only rename is no longer `IsEmpty`, and the sink renames the existing folder in place. The surgical-deploys work did not change this path.
### Runtime-002
+5 -3
View File
@@ -41,6 +41,8 @@ Nothing here is a data-loss or outage defect. The sharpest edge is §3 — silen
### 0.1 What is actually left (re-verified at `4bdce910`, 2026-08-04)
> **Refreshed 2026-10-08.** The re-verification below is as of `4bdce910` (2026-08-04) and is *not* current. Since then the #562–#591 issue sweep closed on master (`3baa332d`), and the work it deferred was filed as **#592–#625** — those are the live tracker, not "0 open". Re-derive counts from the Gitea API before relying on any figure in this subsection; the §4.1 rows for telemetry eviction, the `TODO(v3 WaveC)` set and the `/raw` Browse-device placeholder are resolved (struck through below).
> **Read this subsection, not the table below it.** The 2026-07-30 table that follows is preserved as the
> record of what the remediation left behind; every tracker item in it has since closed. The live picture
> is here.
@@ -362,11 +364,11 @@ commented-out DI registrations.** Real deferred work lives in prose doc-comments
| Item | Evidence |
|---|---|
| **Telemetry snapshot cache never evicted** — a decommissioned driver replays last-known Health/Resilience to new subscribers forever | `TelemetryLocalHub.cs:40` `TODO(mesh-phase5+)` — self-declared, bounded, "pre-production, accepted" |
| **Device host recovered by string-matching instead of `DeviceConfig`** — 7 of the repo's 9 TODOs are this one item, across 4 drivers | `FocasDriver.cs:137,287`; `TwinCATDriver.cs:775`; `AbLegacyDriver.cs:557`; `AbCipDriver.cs:89,1272` + `AbCipDriverOptions.cs:130` — all `TODO(v3 WaveC)` |
| ~~**Telemetry snapshot cache never evicted** — a decommissioned driver replays last-known Health/Resilience to new subscribers forever~~ ✅ **FIXED** — `TelemetryLocalHub.Evict` drops a removed driver's snapshots (`DriverHostActor.StopChild` via `plan.ToStop`, #609); central-side stores are not evicted | `TelemetryLocalHub.cs:40` `TODO(mesh-phase5+)` — self-declared, bounded, "pre-production, accepted" |
| ~~**Device host recovered by string-matching instead of `DeviceConfig`** — 7 of the repo's 9 TODOs are this one item, across 4 drivers~~ ✅ **RESOLVED (#608)** — no `TODO(v3 WaveC)` remains in `src/` (re-verified 2026-10-08) | `FocasDriver.cs:137,287`; `TwinCATDriver.cs:775`; `AbLegacyDriver.cs:557`; `AbCipDriver.cs:89,1272` + `AbCipDriverOptions.cs:130` — all `TODO(v3 WaveC)` |
| **Galaxy writes can never confirm a COM commit** — empty status array ⇒ provisional Good, metered `galaxy.writes.unconfirmed` | `GatewayGalaxyDataWriter.cs:44,328,354` — **cross-repo**, blocked on mxaccessgw |
| **Subscription liveness undetectable** — `ISubscriptionHandle` is opaque | `DriverInstanceActor.cs:527` (#488) — well-reasoned, deferred "until a real occurrence" |
| **`/raw` "Browse device" is still a placeholder** despite the universal discovery browser landing | `RawTree.razor:15,426` — **verify**; may be reachable now via `DiscoveryDriverBrowser` |
| ~~**`/raw` "Browse device" is still a placeholder** despite the universal discovery browser landing~~ ✅ **RESOLVED** — `RawTree.razor` wires `OnBrowseDevice` to the discovery modal | `RawTree.razor:15,426` — **verify**; may be reachable now via `DiscoveryDriverBrowser` |
### 4.2 Per-driver
+5 -3
View File
@@ -104,9 +104,11 @@
# mesh gRPC call on a login-enabled admin node) survived seven phases of prior live gates. To drive
# this rig production-faithfully:
# OTOPCUA_DISABLE_LOGIN=false docker compose -f docker-dev/docker-compose.yml up -d central-1 central-2
# Sign in as `alice` / `password` (or `bob`) against the shared GLAuth (10.100.0.35:3893,
# dc=zb,dc=local) — the same LDAP users every other node in this file already authenticates OPC UA
# sessions against. Restore the default with `docker compose ... up -d central-1 central-2` (no env
# Sign in as `admin` / `password` (OtOpcUa-Admins, AdminUI Administrator) or `multi-role` / `password`
# (member of every group) against the shared GLAuth (10.100.0.35:3893, dc=zb,dc=local). The old
# `alice`/`bob` users no longer exist there. NOTE: the serviceaccount bind password below is the
# pre-rotation value (rotated out-of-band, SEC-36, scadaproj/infra/glauth/config.toml); a real-login run
# needs the current one supplied via the environment. The data plane no longer uses LDAP at all (API keys). Restore the default with `docker compose ... up -d central-1 central-2` (no env
# var). See docs/security.md "Mesh gRPC ingress is AllowAnonymous by design" for what this actually
# exercises.
#
+65 -77
View File
@@ -1,96 +1,84 @@
# Address Space
> ⚠️ **Accuracy warning (audited 2026-07-27) — this page is v2-era and contradicts the shipped v3
> address space.** Corrections, in order of how badly they mislead:
>
> - **There is no "single custom namespace".** v3 exposes **two**:
> `https://zb.com/otopcua/raw` (`s=<RawPath>`) and `https://zb.com/otopcua/uns`
> (`s=<Area>/<Line>/<Equipment>/<EffectiveName>`), registered together at
> `OtOpcUaNodeManager.cs:45`. Identity authority is `V3NodeIds` + `AddressSpaceRealm`. The retired
> `https://zb.com/otopcua/ns` survives only in a comment marking it retired (`V3NodeIds.cs:17`).
> - **`Phase7Applier` / `Phase7Composer` / `GalaxyTagPlan` do not exist**, nor do the file paths cited
> for them. Renamed by `40e8a23e` to `AddressSpaceApplier` / `AddressSpaceComposer`
> (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/`); the composition result type is
> `AddressSpaceComposition` (`AddressSpaceComposer.cs:13`).
> - **The `SystemPlatform` namespace kind is retired** — Galaxy is a standard Equipment-kind driver.
> - **`GenericDriverNodeManager` is test scaffolding**, not a production dispatch path
> (`GenericDriverNodeManager.cs:71`).
> - **The §Rediscovery section is wrong**: nothing consumes `OnRediscoveryNeeded`
> ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518)), and the driver list is stale
> (MQTT and MTConnect now raise it too).
>
> For the current design see the "v3 OPC UA Address Space (Batch 4)" section of `CLAUDE.md`,
> `docs/Raw.md`, and `docs/Uns.md`.
The server serves **two OPC UA namespaces** under one shared root folder. Identity is owned by
`V3NodeIds` (`src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/V3NodeIds.cs`) and `AddressSpaceRealm`; the tree is
built from the deploy artifact (see `docs/IncrementalSync.md`), not by driver discovery at runtime.
Address-space construction is a two-layer system. The **driver-facing layer** is the streaming builder: a driver implements `ITagDiscovery.DiscoverAsync` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs`) and emits `Folder` / `Variable` / `AddProperty` calls into an `IAddressSpaceBuilder` as it walks its backend — no buffering of the whole tree. `GenericDriverNodeManager` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs`) wraps that builder to capture alarm-condition sinks and routes alarm events from the driver to them. The **SDK materialization layer** turns the resulting node descriptions into live OPC UA nodes: `OpcUaPublishActor` drives the write-only `IOpcUaAddressSpaceSink`, whose production binding `SdkAddressSpaceSink` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs`) forwards to `OtOpcUaNodeManager` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`), a `CustomNodeManager2` subclass that owns the `FolderState` / `BaseDataVariableState` instances. The same code path serves Galaxy object hierarchies, Modbus PLC registers, AB CIP tags, TwinCAT symbols, FOCAS CNC parameters, and OPC UA Client aggregations — Galaxy is one driver of seven, not the driver.
| Realm | Namespace URI | Subtree | NodeId `s=` identifier |
|---|---|---|---|
| `Raw` | `https://zb.com/otopcua/raw` | `/raw` device tree: Folder → Driver → Device → TagGroup → Tag | the node's **RawPath** (e.g. `Plant/Modbus/dev1/Speed`) |
| `Uns` | `https://zb.com/otopcua/uns` | UNS tree: Area → Line → Equipment → signal | slash-joined `Area/Line/Equipment[/EffectiveName]` |
`OtOpcUaNodeManager` registers both URIs (raw is index `NamespaceIndexes[0]`, UNS `[1]`). The retired
single namespace `https://zb.com/otopcua/ns` and the `EquipmentNodeIds` scheme no longer exist. The realm
travels beside the identifier at every sink seam (`IOpcUaAddressSpaceSink`); it is never parsed out of the
id string. Authoring and semantics: `docs/Raw.md`, `docs/Uns.md`.
## Root folder
`OtOpcUaNodeManager.CreateAddressSpace` creates a single shared root `FolderState` (`NodeId = OtOpcUa`, `BrowseName = OtOpcUa`, `EventNotifier = None`) under the standard OPC UA `Objects` folder, wired with an `Organizes` reference. Every driver's folders and variables hang beneath this one root; the server is published under a single `ApplicationUri = urn:OtOpcUa` (the `OpcUaApplicationHostOptions.ApplicationUri` default) and all nodes live in the server's single custom namespace, not a per-driver `urn:OtOpcUa:{DriverInstanceId}`. The UNS Area → Line → Equipment folder skeleton under the root is materialised by `Phase7Applier.MaterialiseHierarchy` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs`); SystemPlatform (Galaxy) tags are materialised by `Phase7Applier.MaterialiseGalaxyTags`.
## IAddressSpaceBuilder surface
`IAddressSpaceBuilder` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs`) offers three calls:
- `Folder(browseName, displayName)` — creates a child `FolderState` and returns a child builder scoped to it.
- `Variable(browseName, displayName, DriverAttributeInfo attributeInfo)` — creates a `BaseDataVariableState` and returns an `IVariableHandle` the driver keeps for alarm wiring.
- `AddProperty(browseName, DriverDataType, value)` — attaches a `PropertyState` for static metadata (e.g. equipment identification fields).
Drivers drive ordering. Typical pattern: root → folder per equipment → variables per tag. `GenericDriverNodeManager.BuildAddressSpaceAsync` calls `DiscoverAsync` once on startup and once per rediscovery cycle, tearing down the previous alarm subscription and clearing its sink registry before each re-walk so a redeploy doesn't double-fire alarm events.
## DriverAttributeInfo → OPC UA variable
Each variable carries a `DriverAttributeInfo` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs`):
| Field | Role |
|---|---|
| `FullName` | driver-side full reference used as the lookup key for Read/Write/Subscribe; also seeds the variable's string `NodeId` |
| `DriverDataType` | resolved to a built-in `DataTypeIds.*` NodeId at materialization time — `OtOpcUaNodeManager.ResolveBuiltInDataType` maps the data-type name string; each driver first maps its native type into `DriverDataType` (e.g. Galaxy via `Browse/DataTypeMap.Map`) |
| `IsArray` / `ArrayDim` | declared 1-D-array length carried as metadata; the Galaxy discoverer sets `ArrayDim` only when the gateway reports a positive dimension |
| `SecurityClass` | write-authorization tier (`SecurityClassification`); enforced server-side by the `NodePermissions` ACL evaluator (`TriePermissionEvaluator`) mapping each `OpcUaOperation` to a required permission bit. The Galaxy driver also caches it per full reference (`_securityByFullRef`) to answer `GetSecurityClassification` |
| `IsHistorized` | marks the attribute as feeding historian / HistoryRead |
| `IsAlarm` | drives the `MarkAsAlarmCondition` pass (see below) |
| `WriteIdempotent` | when true the attribute's writes are safe to replay, so the capability invoker may apply Polly retry; defaults false so pulses / acks / counters aren't auto-retried |
| `Source` | `NodeSourceKind` discriminator (`Driver` / `Virtual` / `ScriptedAlarm`) that decides which subsystem dispatches the node's Read/Write/Subscribe |
The variable is created with `StatusCode = BadWaitingForInitialData` and a null value until the first Read or `ISubscribable.OnDataChange` push lands. Note the production SDK sink (`OtOpcUaNodeManager.EnsureVariable`) currently materialises every variable as `ValueRank = Scalar`, read-only `AccessLevel`, and `Historizing = false` — the `IsArray`/`IsHistorized` intent lives in `DriverAttributeInfo` but is not yet projected onto the SDK node.
## CapturingBuilder + alarm sink registration
`GenericDriverNodeManager.BuildAddressSpaceAsync` wraps the supplied builder in a `CapturingBuilder` before calling `DiscoverAsync`. The wrapper observes every `Variable()` call: when a returned `IVariableHandle.MarkAsAlarmCondition(AlarmConditionInfo)` fires, the sink is registered in the manager's `_alarmSinks` dictionary keyed by the variable's `FullReference`. Subsequent `IAlarmSource.OnAlarmEvent` pushes are routed to the matching sink by `SourceNodeId`. This keeps the alarm-wiring protocol declarative — drivers just flag `DriverAttributeInfo.IsAlarm = true` and the materialization of the OPC UA `AlarmConditionState` node is handled by the server layer. See `docs/AlarmTracking.md`.
`OtOpcUaNodeManager.CreateAddressSpace` creates **one** shared root `FolderState` — identifier and browse
name `OtOpcUa` (`V3NodeIds.SharedRootFolderId`), minted in the **raw** namespace — under the standard
`Objects` folder with an `Organizes` reference. Top-level Raw folders and top-level UNS Areas both parent
under it. The identifier is a display name, not a path segment; the data-plane authorizer maps it onto the
trie's zero-segment realm-root scope (`docs/security.md`). The application URI is
`OpcUaApplicationHostOptions.ApplicationUri` (default `urn:OtOpcUa`); there is no per-driver namespace.
## NodeId scheme
All nodes share the server's single custom namespace (`NamespaceIndex`); NodeIds are string identifiers, not numeric. The string values come from the source rows / driver references — there is no per-driver namespace prefix:
Segments are validated by `RawPaths.ValidateSegment` (no embedded `/`, no leading/trailing whitespace;
ordinal, case-sensitive). `V3NodeIds.Raw(rawPath)` is a pass-through; `V3NodeIds.Uns(...)` /
`UnsChild(parent, segment)` build UNS ids.
| Node type | NodeId (string identifier) | Example |
|---|---|---|
| Shared root | `OtOpcUa` | `OtOpcUa` |
| UNS Area / Line / Equipment folder | the Config-DB `UnsAreaId` / `UnsLineId` / `EquipmentId` | `EQ_Press_07` |
| Galaxy tag variable | the MXAccess reference (`Phase7Applier` uses `GalaxyTagPlan.MxAccessRef`) | `DelmiaReceiver_001.DownloadPath` |
| Equipment tag variable | the driver full reference from `DriverAttributeInfo.FullName` | driver-specific |
| Node | Realm | `s=` identifier | Example |
|---|---|---|---|
| Shared root | Raw | `OtOpcUa` | `OtOpcUa` |
| Raw folder / driver / device / tag group / tag | Raw | its own RawPath | `Plant/Modbus/dev1/Speed` |
| UNS Area / Line / Equipment folder | Uns | `Area`, `Area/Line`, `Area/Line/Equipment` | `packaging/line-1/press-07` |
| UNS reference variable (`UnsTagReference`), virtual tag, scripted alarm | Uns | `Area/Line/Equipment/EffectiveName` | `packaging/line-1/press-07/speed` |
For Galaxy the variable `FullName` is the `tag_name.AttributeName` MXAccess reference; AB CIP uses `tag.Name` or `tag.Name.member` for UDT members; the shape is the driver's choice. Browse-path resolution (OPC UA `TranslateBrowsePathsToNodeIds`) is the canonical way clients map a browse path to one of these flat NodeIds.
Area, Line and Equipment names must match `^[a-z0-9-]{1,32}$` (`docs/Uns.md`). EffectiveNames are unique per
equipment across references, virtual tags and scripted alarms.
## Per-driver hierarchy examples
## One source, fanned to both namespaces
- **Galaxy**: `GalaxyDriver.DiscoverAsync` delegates to `GalaxyDiscoverer` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GalaxyDiscoverer.cs`), which walks the hierarchy from `IGalaxyHierarchySource` — one folder per Galaxy object (browse name = `contained_name`, falling back to `tag_name`), one variable per dynamic attribute (full reference = `tag_name.AttributeName`). It copies the gateway-supplied `IsAlarm` flag through to `DriverAttributeInfo` and, for alarm-bearing attributes, calls `MarkAsAlarmCondition` with the five sub-attribute refs built by `AlarmRefBuilder`.
- **Modbus**: streams one folder per device, one variable per register range from `ModbusDriverOptions`. No alarm surface.
- **AB CIP**: `AbCipDriver.DiscoverAsync` emits an `AbCip` root, then a folder per configured device. Pre-declared tags become variables under the device folder; UDT (`Structure`) tags fan out into a sub-folder with one variable per member; when controller browse is enabled, `IAbCipTagEnumerator` adds discovered tags under a `Discovered/` sub-folder. (`AbCipTemplateCache` caches UDT layouts for the libplctag enumerator.)
- **OPC UA Client**: re-exposes a remote server's address space — `OpcUaClientDriver.DiscoverAsync` browses the upstream from `BrowseRoot` into a `Remote` folder (pass 1), then batch-reads DataType/AccessLevel/ValueRank/Historizing per variable before registering them (pass 2).
Every device value has exactly one source: the raw tag's node, keyed by RawPath. A `UnsTagReference`
projects that raw tag into an equipment as a UNS variable that carries an `Organizes` reference to its raw
node and mirrors it. `DriverHostActor` fans one driver publish for a RawPath to the raw NodeId **and** every
referencing UNS NodeId with identical value, quality and timestamp. Writes via either NodeId route to the
same driver reference under the same realm-qualified `WriteOperate` gate. Native alarms materialise once at
the raw tag (`ConditionId = RawPath`) and fan through notifiers to the raw device folder and every
referencing equipment folder. Both NodeIds register the same historian tagname.
See `docs/v2/driver-specs.md` for the per-driver discovery contracts.
## Materialisation
## Rediscovery
`AddressSpaceComposer` turns the deployment artifact into an `AddressSpaceComposition`;
`AddressSpaceApplier` (both in `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/`) walks it (`MaterialiseHierarchy`,
`MaterialiseRawSubtree`, `MaterialiseUnsReferences`, `MaterialiseEquipmentVirtualTags`,
`MaterialiseScriptedAlarms`, ...) into the write-only `IOpcUaAddressSpaceSink`, whose production binding
`SdkAddressSpaceSink` forwards to `OtOpcUaNodeManager`, the `CustomNodeManager2` that owns the
`FolderState` / `BaseDataVariableState` instances. Variables are created through `EnsureVariable`:
`ValueRank` is `OneDimension` with `ArrayDimensions=[arrayLength]` for array tags and `Scalar` otherwise;
`AccessLevel` carries the write bit only for writable nodes (array nodes only when the driver can write the
array); `Historizing` plus the `HistoryRead` bit are set when a historian tagname is supplied.
Drivers that implement `IRediscoverable` fire `OnRediscoveryNeeded` when their backend signals a change. Galaxy's `DeployWatcher` raises it when the observed `time_of_last_deploy` advances; TwinCAT raises it on the ADS symbol-version-changed signal (`DeviceSymbolVersionInvalid`, error 1809). Core re-runs `DiscoverAsync` and diffs — see `docs/IncrementalSync.md`. Drivers that don't implement `IRediscoverable` (Modbus, S7, OPC UA Client) only change their address space when a new generation is published from the Config DB.
The nodes start `BadWaitingForInitialData` until the first read or subscription push. Datatype names resolve
through `OtOpcUaNodeManager.ResolveBuiltInDataType`; each driver first maps its native type into
`DriverDataType` (Galaxy: `Driver.Galaxy/Browse/DataTypeMap.cs`).
## Driver discovery is the browse picker, not the address space
`ITagDiscovery.DiscoverAsync` (streaming into `IAddressSpaceBuilder`: `Folder` / `Variable` /
`AddProperty`, each variable described by a `DriverAttributeInfo`) feeds the `/raw` browse-commit flow via
`Commons/Browsing/DiscoveryDriverBrowser`: the operator browses a device, picks leaves and commits them as
raw tags, which then reach the server as an ordinary deployment. Drivers that signal backend change via
`IRediscoverable` only raise an operator "re-browse" prompt — the served tree never changes on its own.
See `docs/IncrementalSync.md`. `GenericDriverNodeManager` (`Core/OpcUa/`) is leftover scaffolding with no
production caller.
## Key source files
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — orchestration + `CapturingBuilder`
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`, `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs` — OPC UA materialization (write-only sink fed by the actor system)
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs` — materialises the UNS folder hierarchy + Galaxy tags into the sink
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs` — walks Config-DB Equipment-namespace rows into the builder
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs` — builder contract
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs` — driver discovery capability
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs` — per-attribute descriptor
- `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/V3NodeIds.cs`, `AddressSpaceRealm.cs` — identity authority
- `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/RawPaths.cs` — RawPath/segment rules
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`, `SdkAddressSpaceSink.cs` — SDK materialisation
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs`, `AddressSpaceApplier.cs` — compose and apply
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs`, `IAddressSpaceBuilder.cs`, `DriverAttributeInfo.cs` — discovery-side contracts
+21
View File
@@ -93,6 +93,27 @@ Two postures are deliberate:
- **Unset (the default) means drain.** So does an unpublished view. A deployment
that runs no redundancy never publishes a role, and defaulting closed would
silently stop its alarm history forever.
- **Only a shared queue is gated.** Standing down is safe only because a peer
holds the same rows, so the gate applies only when
`LocalDbReplicationPolicy.SharesRowsWithPeer` holds:
`LocalDb:Replication:ApiKey` **AND** (`LocalDb:Replication:PeerAddress` **OR**
`LocalDb:SyncListenPort > 0`) — an authenticated sync path in either
direction. Any other node always drains its own queue. This is deliberately
stricter than the table-registration predicate (`PeerAddress` OR `ApiKey`):
a key with no dial and no listener (e.g. a fleet-wide secret on an unpaired
node) captures changes but shares them with nobody, and gating it would park
its events behind a Primary that does not hold them (review R1 m3).
- **A gated node stands down only for the peer that holds its rows — and BOTH
halves of a pair know it.** The dialling half identifies its peer by the host
of `LocalDb:Replication:PeerAddress`. The passive half (`ApiKey` +
`SyncListenPort`, no `PeerAddress`) identifies it as its **per-cluster mesh
partner** (the other member of its `cluster-{ClusterId}` role). Before review
R6 MINOR-4 the passive half knew no peer at all, so as Secondary it kept
draining beside its Primary partner and the historian received every alarm
event twice. ⚠️ A passive half with **no** cluster-scoped role (a legacy,
pre-Phase-6 node) still cannot identify its partner and keeps draining — it
cannot prove which Primary holds its rows, and deferring to the wrong one
would strand them.
- **A gate that throws means "not now", never permission.** Draining on a gate
fault would put a pair into dual delivery exactly when its redundancy signal is
sick.
+209 -21
View File
@@ -12,6 +12,7 @@ historical reference.
| Source | Driver capability | Path |
|----------------------------------|--------------------------|------|
| **Galaxy MxAccess (driver-native)** | `GalaxyDriver : IAlarmSource` | gateway → worker → MxAccess alarm sink → `MX_EVENT_FAMILY_ON_ALARM_TRANSITION` → `EventPump` → driver `OnAlarmEvent` → `AlarmConditionService` |
| **MTConnect CONDITION (driver-native, #626)** | `MTConnectDriver : IAlarmSource` | Agent `/sample` pump → `MTConnectConditionTracker` → `MTConnectConditionAlarmMapper` → driver `OnAlarmEvent` (ConditionId = RawPath) → `DriverHostActor.ForwardNativeAlarm` |
| **Galaxy sub-attribute fallback** | `IWritable` writes to `$Alarm*` sub-attributes | gateway data subscription → driver `OnDataChange` → `DriverNodeManager` ConditionSink → `AlarmConditionService` |
| **Scripted alarms** | `Phase7Composer` | server-side script evaluator → `ScriptedAlarmActor` transitions → `HistorianAdapterActor` → `IAlarmHistorianSink` |
@@ -36,9 +37,12 @@ same condition is wired as an **event notifier of every referencing equipment fo
`EnsureFolderIsEventNotifier(equipFolder)`.
- A **single** `ReportEvent` fans through the SDK notifier graph to the raw device folder,
every referencing equipment folder, and up to the Server object. A subscriber at any one
every referencing equipment folder, and to the Server object. A subscriber at any one
root receives **exactly one** copy of the transition — the Part 9 shared-`InstanceStateSnapshot`
dedup (`MonitoredItem.QueueEvent` → `IsEventContainedInQueue`), **never** one copy per root.
The Server-object (`i=2253`) copy is delivered by the condition itself, not by the folders'
forward to the core Server object, so it can be filtered per subscriber by the data-plane ACL
(Gitea #586 — see `docs/security.md`).
Duplicating the `ReportEvent` per root is rejected by design (distinct EventIds would break
Server-object dedup + Part 9 ack correlation).
- **Teardown is symmetric:** the node manager tracks the wired notifier pairs and calls
@@ -112,6 +116,40 @@ that `ConditionClassId` / `ConditionClassName` do too. The two class fields are
> a live↔history join key. Correlate on `ConditionId` / the RawPath instead. (Pre-existing; the
> live-path fix above does not change the history path.)
### Condition child nodes are instance-scoped (#629)
Every child of a materialised condition — `ActiveState`, `ActiveState/Id`, `AckedState/Id`,
`ShelvingState/UnshelveTime`, `Message`, `Severity`, `Retain`, the method nodes, … — carries the NodeId
**`{ConditionId}/{browse path}`** in the condition's own namespace: e.g.
`ns=<raw>;s=Plant/Modbus/dev1/temp_hi/ShelvingState/UnshelveTime`, or
`ns=<uns>;s=<ScriptedAlarmId>/ActiveState/Id`. So `TranslateBrowsePathsToNodeIds` from the condition
resolves, and a direct Read or data-change subscription on a child returns **this condition's** value.
Before #629 the children kept ns=0 ids: `Create(…, assignNodeIds: true)` asks the node manager's
`INodeIdFactory.New`, and the SDK default returns the node's current id — the condition type's
instance-declaration id (`i=9189` for UnshelveTime) or an SDK-generated ns=0 number. Every translate of
a child path returned `BadNoMatch`; a Read of a browsed child id was either `BadNodeIdUnknown` or served
from the **type** (UnshelveTime read `0` on a condition timed-shelved for a minute). `OtOpcUaNodeManager.New`
now derives the id. Event delivery was never affected (events copy the instance's fields; clients select
them by type + browse path), and neither were the Part 9 methods — calling them by the type's method id
(`MethodIds.AcknowledgeableConditionType_Acknowledge`, …) still works, because the SDK matches a method on
its declaration id. ⚠️ The ShelvingState methods needed help here: their SDK declaration id is the
`ShelvedStateMachineType`'s (`i=2948`…), and they were only ever found by the `AlarmConditionType_ShelvingState_*`
id (`i=9212`…) because that WAS their NodeId. `New` adopts that original id as the method's
`MethodDeclarationId` (a fixed set of those three ids — no address-space lookup, which would take other
node managers' locks under ours), so OneShotShelve/TimedShelve/Unshelve by the
`AlarmConditionType_ShelvingState_*` id keep working, as do calls by the condition's own instance method
ids (`{ConditionId}/ShelvingState/TimedShelve`, on the condition or its ShelvingState). Calling them by the
`ShelvedStateMachineType_*` id returns `BadMethodInvalid` — a pinned trade-off.
- **ACL:** the slash-joined id makes a child's ACL scope its condition's scope plus the browse path, so
with `Security:Acl:Enabled` a child is governed exactly like its condition (a grant covering the
condition covers its children; one that does not, denies them). Before #629 a child was in ns=0 and
bypassed the data-plane gate entirely.
- **Removal:** removing or rebuilding a condition forgets its whole subtree, so a removed condition's
children answer `BadNodeIdUnknown`, not their last value.
- Guarded over the wire by `ConditionChildNodeIdTests`.
### Condition source-data Quality (#477)
`ConditionType.Quality` reports the quality of the condition's source data. It was never assigned, and
@@ -260,6 +298,23 @@ the optional properties added in PR E.7 (`OperatorComment`,
need them are unaffected; consumers that do (Client.UI, Client.CLI
verbose mode) read the new fields when present.
## MTConnect CONDITION path (driver-native, #626)
An MTConnect raw tag bound to a `CONDITION` DataItem becomes a native condition when its `TagConfig`
carries the standard `alarm` object (opt-in; without it the tag stays the String state word). Normal →
inactive, Warning → active 500, Fault → active 800, Unavailable → state unchanged (an active condition stays
active and acked — it is comms loss one hop out, the #477/#481 posture) with `BadNoCommunication`
quality. Two optional `AlarmEventArgs` fields added for it are generic: **`OpcUaSeverity`** (an exact
1..1000 severity that supersedes the four-bucket mapping in `NativeAlarmProjector`) and
**`QualityStatusCode`** (projected onto the condition's `Quality` on a transition; with an `Unspecified`
kind it is a **quality-only annotation** — `DriverHostActor` routes it through the same
`AlarmQualityUpdate` → `WriteAlarmQuality` path as the connectivity annotation, which touches Quality and
nothing else, and publishes no `/alerts` row).
Acknowledge is server-side only (MTConnect has no ack; the driver's `AcknowledgeAsync` is a no-op).
The driver diffs against what the server was last told, so a reconnect's replayed `/current` never
re-raises (and so un-acknowledges) an unchanged condition. Full mapping:
[`drivers/MTConnect.md` §CONDITION alarms](drivers/MTConnect.md#condition-alarms-626).
## Galaxy sub-attribute fallback
For Galaxy templates without `$Alarm*` extensions, the value-driven
@@ -289,8 +344,9 @@ branches on native-ness and routes through a dedicated path — separate
from the scripted `AlarmCommandRouter`:
1. **`OtOpcUaNodeManager.HandleNativeAlarmAck`** — gates on the caller's
`AlarmAck` role (fails closed: no role → `BadUserAccessDenied`), then
dispatches a `NativeAlarmAck(ConditionNodeId, Comment, OperatorUser)`
`AlarmAck` role (fails closed: no role → `BadUserAccessDenied`), then on the
node's redundancy role (not the Primary → `BadInvalidState`, see
§"Acknowledge on a non-Primary node" below), then dispatches a `NativeAlarmAck(ConditionNodeId, Comment, OperatorUser)`
to the `NativeAlarmAckRouter` seam (fire-and-forget, non-blocking under
the node-manager Lock). `OperatorUser` carries the authenticated
session principal's display name.
@@ -311,16 +367,61 @@ from the scripted `AlarmCommandRouter`:
OPC UA client (mirrors the Galaxy write-outcome limitation; the local
`AlarmConditionState` SDK update already committed at step 1).
Only the **Acknowledge** is routed to the driver. `Confirm` / `AddComment`
/ `Shelve` operations on a native condition stay on the scripted
`AlarmCommandRouter` path (Phase 3 scope is Acknowledge → AVEVA only).
Only the **Acknowledge** is routed to the driver. **A native condition
supports Acknowledge and AddComment only** (Gitea #587 review): `Confirm`,
`OneShotShelve` / `TimedShelve` / `Unshelve` and `Enable` / `Disable` return
`BadNotSupported` and report no event. There is no upstream path for them and
no pair replication of a local state, and the driver projection
(`NativeAlarmProjector`: always `Confirmed`, `Unshelved`) would silently undo
one at the next transition. `AddComment` stays on the scripted
`AlarmCommandRouter` path (Phase 3 scope is Acknowledge → AVEVA only) and is
applied locally.
A native condition is **never Unconfirmed**: it starts Confirmed, and because
the SDK's own Acknowledge sets `ConfirmedState = false` on any condition type
that supports Confirm, the node manager re-asserts `Confirmed = true` (and the
projection's Retain rule, `Active || !Acked`) after the SDK handler, before the
one ack event. ScadaBridge maps `ConfirmedState` into its alarm state, so this
is client-visible.
> **Acknowledge on a non-Primary node is refused** (Gitea #597). On a node
> that is not the redundancy Primary, an OPC UA Acknowledge of a **native**
> condition returns **`BadInvalidState`** ("not the redundancy Primary;
> acknowledge on the Primary") — nothing is routed, applied or reported. The
> verdict is the same data-plane one the device-write gate and
> `DriverHostActor`'s ack route use (`PrimaryGatePolicy.ShouldServiceAsPrimary`,
> published to `IDriverPrimaryRoleSource` on every redundancy snapshot and
> wired onto the node manager by `OtOpcUaServerHostedService`). It is checked
> after authorization, so a caller who may not ack learns nothing about the
> node's role.
>
> This replaces a documented flap: the Secondary used to apply and report the
> ack locally while `DriverHostActor` dropped the route upstream, so the driver
> never acknowledged, its next transition projected `Acked = false`, and the
> client saw acked → unacked. An honest refusal is client-visible — a client
> (ScadaBridge included) that acks against the Secondary now gets a Bad result
> instead of a Good that later reverts — so ack against the Primary (the higher
> ServiceLevel). Scripted conditions are **not** gated: their engine runs on
> both nodes, so an ack on the Secondary is real. The published verdict
> mirrors `DriverHostActor`'s live check from the moment the host starts, not
> only from the first redundancy snapshot: the host publishes it once at start
> and again whenever cluster membership changes, so in the boot window of a
> pair (role still unknown, a driver peer Up) the gate already denies, exactly
> as the route does (review R1 m2 — previously the view sat on its single-node
> "allow" seed until the first snapshot, reopening the flap for that window).
> It mirrors, it does not deny from start: until this node has **joined** and
> sees its peer Up, its member count is 0 and both the published verdict and the
> live route **allow** (seconds normally; up to the bootstrap guard's 25 s
> partner probe). The two agree there, so the flap cannot occur, but a native
> ack in that pre-join window is serviced (review R6 MINOR-2).
> **Best-effort during reconnect.** If a native ack arrives while the owning
> driver is `Connecting`/`Reconnecting` (or on a non-Primary node), it is
> dropped with a debug log and **not** retried upstream — the OPC UA Part 9
> `AlarmConditionState` was already committed locally at step 1, so the rare
> outcome is "acked locally but not propagated to AVEVA." Re-issue the ack once
> the driver is reconnected if upstream propagation is required.
> driver is `Connecting`/`Reconnecting`, it is dropped with a debug log and **not**
> retried upstream — the OPC UA Part 9 `AlarmConditionState` was already
> committed locally at step 1, so the rare outcome is "acked locally but not
> propagated to AVEVA." Re-issue the ack once the driver is reconnected if
> upstream propagation is required — a re-ack of a node already showing Acked
> is still routed to the driver for exactly this reason.
### Legacy sub-attribute path
@@ -354,9 +455,11 @@ Scripted alarms use a separate inbound path that converges on the
`OtOpcUaNodeManager` wires the Part 9 condition methods (Acknowledge /
Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) on each
scripted-alarm `AlarmConditionState` node. Every call is **gated on the
`AlarmAck` LDAP role** — fail-closed: sessions with no role or without
`AlarmAck` group membership receive `BadUserAccessDenied` immediately.
The LDAP-resolved role set is carried past `OpcUaApplicationHost` by
`AlarmAck` role** — fail-closed: sessions with no role or without
an `AlarmAck`-conferring grant receive `BadUserAccessDenied` immediately.
The session's roles come from its API-key principal's `NodeAcl` grants (or the
reserved `anonymous` principal's; LDAP is AdminUI-only — `docs/security.md`) and are carried past
`OpcUaApplicationHost` by
`RoleCarryingUserIdentity` (a `UserIdentity` subclass), making it
readable inside the method handler at dispatch time.
@@ -365,14 +468,99 @@ On allow, the handler publishes a `Commons.OpcUa.AlarmCommand` onto the
action is a settable `Action<AlarmCommand>` injected at boot by the
hosted service.
`OnTimedUnshelve` (the SDK's automatic unshelve timer) bypasses the
operator gate — it is system-initiated.
`OnTimedUnshelve` (the SDK's automatic unshelve timer) is a **no-op** — it
routes nothing (review R1 M2). The engine owns timed-shelve expiry (its 5 s
shelving check records `AutoUnshelve` by `system`), and its re-projection
unshelves the node; see §"`UnshelveTime` is the real remaining time" below.
`WriteAlarmCondition` fires the Part 9 condition event only when the
incoming state differs from the node's current live state (delta-gate),
preventing the double-emit that would otherwise occur when the SDK
auto-applies the acked state and the engine re-projection fires a
duplicate event immediately after.
### One condition event per method call (Gitea #587)
Every accepted Part 9 method call — Acknowledge, Confirm, AddComment,
OneShotShelve, TimedShelve, Unshelve, Enable, Disable, on a native or a
scripted condition — reports **exactly one** condition event carrying the
new state, to folder subscribers and (through the ACL) Server-object
subscribers alike. A refused call (bad status, e.g. a denied gate, an
already-acknowledged condition, Enable/Disable on a native condition)
reports nothing.
- **The SDK does not report it.** `ConditionState.ReportStateChange` is
gated on `AutoReportStateChanges`, which this repo leaves `false` — so
before #587 a client Acknowledge produced no condition event at all (an
older comment claimed "the SDK auto-fires its own event"; it never did).
Instead the node manager wraps each method's SDK handler
(`WireMethodTransitionReporting`) and, once the handler returns Good,
reports the condition itself. That report does not depend on the SDK's
`AreEventsMonitored` bookkeeping, which is imprecise for conditions
materialised under an already-subscribed folder.
- **The SDK's shelve methods never apply shelving state** (they only call
`OnShelve`), so the wrapper applies it — with the same `(shelved, oneShot)`
mapping the projection uses, and a TimedShelve with its requested duration —
before reporting.
- **The re-projection does not duplicate it.** The engine's (or driver's)
answer to the routed command reaches `WriteAlarmCondition`'s delta-gate,
which compares against the node's current state — already the new state —
and suppresses. The whole method call runs under the node-manager lock, so
a fast re-projection cannot slip in between the SDK applying the state and
the report.
- **Only a real change is reported.** The condition's state is compared
before and after the SDK handler; a call that changed nothing reports
nothing. The SDK accepts acknowledging an acknowledged condition and sets
`Confirmed = false` again, so a re-ack also restores the pre-call Confirmed
and Retain — silent, and a scripted node never drifts from its engine.
AddComment always reports (the comment is the change), and so does a re-ack
**carrying a comment** (Gitea #597): the SDK writes that comment to the node,
so it is reported once, with Acked/Confirmed unchanged. Before #597 the
comment landed on the node but no subscriber ever saw it. For a **scripted**
condition the comment is also durable (review R1 m1): the routed Acknowledge
reaches an engine that is already acknowledged, and
`Part9StateMachine.ApplyAcknowledge` records a commented re-ack exactly as an
AddComment (kind `AddComment`, the caller as user) — persisted and replicated
with the condition state, the original ack's `LastAck*` fields untouched. An
uncommented re-ack is still a no-op in the engine. The same applies to a
commented re-ack from the AdminUI `/alerts` page.
- **A native re-ack is still routed to the driver** (deliberate, #597). The
route is fire-and-forget, so a node showing Acked does not prove the
upstream system acknowledged — an ack dropped during a reconnect leaves
exactly that state — and re-acking is the documented retry (§"Best-effort
during reconnect"). The cost is a duplicate ack upstream for an alarm that
was already acknowledged there.
- **Native conditions: Acknowledge and AddComment only** — see §"Native alarm
acknowledge → AVEVA" above. The other methods are refused
(`BadNotSupported`, no event), and a native ack keeps the condition
Confirmed.
- **TimedShelve works (scripted).** `Create()` left `MaxTimeShelved` at 0, and
the SDK rejects any shelving time above it, so every client TimedShelve
failed with `BadShelvingTimeOutOfRange`. It is now `int.MaxValue` ms (≈24.8
days). The engine owns the expiry and its Unshelved re-projection reports
the one expiry event.
- **`UnshelveTime` is the real remaining time** (Gitea #597). Both the method
path and the projection used to apply a timed shelve with `shelvingTime 0`,
which the SDK reads as `MaxTimeShelved` — so every timed shelve advertised
≈24.8 days remaining. The method now applies the requested duration, and the
engine's projection carries its expiry (`AlarmConditionSnapshot.UnshelveAtUtc`),
so `ShelvingState/UnshelveTime` counts down to the engine's expiry whichever
path shelved it (the AdminUI `/alerts` timed shelve included). This arms the
SDK's own unshelve timer at the real instant, and `OnTimedUnshelve` — the
timer's callback — is deliberately a **no-op** (review R1 M2): it used to
route an operator-style `Unshelve` by `system`, which, firing before the
engine's 5 s expiry sweep, pre-empted the engine's `AutoUnshelve` on every
expiry, from both pair nodes, rewriting the audit trail as a manual unshelve.
The SDK's timer changes no shelving state itself, so the node stays
TimedShelved (with `UnshelveTime` at zero) for up to one sweep after expiry,
until the engine's re-projection reports the one expiry event.
- **Accepted, not fixed — the SDK's `EventsMonitored` unit for late conditions**
(Gitea #597 item 6, from the #586 review). The SDK's `AreEventsMonitored`
counter never reaches a condition materialised under a folder that already had
an event subscriber. Our own reporting does not depend on it (above); the one
thing it still gates is the SDK's method **audit** event, and only while the
server is auditing — which no configuration in this repo enables. Closed as
accepted.
- Method **audit** events (`AuditConditionAcknowledgeEventType`, …) are still
the SDK's, delivered only while the server is auditing over an encrypted
channel.
Engine-driven transitions are unaffected: `WriteAlarmCondition` still fires
whenever the incoming state differs from the node's current state.
### AdminUI path
+26 -3
View File
@@ -4,7 +4,7 @@
`ZB.MOM.WW.OtOpcUa.Client.CLI` is a cross-platform command-line client for the OtOpcUa OPC UA server. It targets .NET 10 and uses the shared `IOpcUaClientService` from `Client.Shared` for all OPC UA operations. Commands are routed and parsed by [CliFx](https://github.com/Tyrrrz/CliFx).
The CLI is the primary tool for operators and developers to test and interact with the server from a terminal. It supports all core operations: connectivity testing, browsing, reading, writing, subscriptions, alarm monitoring, alarm operator actions (acknowledge / confirm / shelve / enable / disable), history reads, and redundancy queries. Any driver surface exposed by the server (Galaxy, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, OPC UA Client) is reachable through these commands — the CLI is driver-agnostic because everything below the OPC UA endpoint is.
The CLI is the primary tool for operators and developers to test and interact with the server from a terminal. It supports all core operations: connectivity testing, browsing, reading, writing, subscriptions, alarm monitoring, alarm operator actions (acknowledge / confirm / comment / shelve / enable / disable), history reads, and redundancy queries. Any driver surface exposed by the server (Galaxy, Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS, OPC UA Client) is reachable through these commands — the CLI is driver-agnostic because everything below the OPC UA endpoint is.
## Build and Run
@@ -41,6 +41,12 @@ All commands accept these options:
| `-F` / `--failover-urls` | Comma-separated failover endpoint URLs for redundancy |
| `--verbose` | Enable debug-level Serilog console logging (default: warning) |
**`--security none` does not fall back.** Endpoint selection picks the server's `None`-policy endpoint and
fails with an `InvalidOperationException` naming the endpoints the server does offer when it has none. The
older stack helper silently fell back to the first endpoint (often a secured one), so a command that
defaulted to `none` against a secured-only server used to connect and now errors. Pass `-S sign`,
`-S encrypt` or `-S signandencrypt` to talk to such a server.
### Authentication
When `-U` and `-P` are provided, the shared service passes a `UserIdentity(username, password)` to the OPC UA session. Without credentials, anonymous identity is used.
@@ -116,6 +122,8 @@ Source Time: 2026-03-30T19:58:38.0961252Z
Server Time: 2026-03-30T19:58:38.0971257Z
```
A Bad node status (unknown node, access denied, ...) is reported, not thrown: the command prints `Node:`, an empty `Value:` and `Status: 0x80340000 (BadNodeIdUnknown)`. Session-level failures (closed connection / channel) still fail the command.
### write
Writes a value to a node. The shared service reads the current value first to determine the target data type, then converts the supplied string value using `ValueConverter.ConvertValue()`.
@@ -242,7 +250,7 @@ otopcua-cli alarms -u opc.tcp://localhost:4840/OtOpcUa \
### ack
Acknowledges an active alarm condition via the OPC UA Part 9 `Acknowledge` method. The server gates this on the **`AlarmAck`** LDAP role (fail-closed — a session without `AlarmAck` group membership gets `BadUserAccessDenied`; see [ScriptedAlarms.md](ScriptedAlarms.md) §"AlarmAck gate"). The `--event-id` is the hex-encoded `EventId` taken from a prior `alarms` subscription notification for the same condition.
Acknowledges an active alarm condition via the OPC UA Part 9 `Acknowledge` method. The server gates this on the **`AlarmAck`** role (fail-closed — a session whose API-key principal holds no `AlarmAck`-conferring `NodeAcl` grant gets `BadUserAccessDenied`; the data plane does not use LDAP; see [ScriptedAlarms.md](ScriptedAlarms.md) §"AlarmAck gate"). The `--event-id` is the hex-encoded `EventId` taken from a prior `alarms` subscription notification for the same condition.
```bash
otopcua-cli ack -u opc.tcp://localhost:4840/OtOpcUa \
@@ -282,9 +290,24 @@ Example output:
Confirm successful: ns=1;s=TestMachine_001
```
### comment
Attaches an operator comment to an alarm condition (OPC UA Part 9 `AddComment`) without changing its acknowledge/confirm state. Works on scripted and native conditions alike. The `--node` and `--event-id` are the same values `ack`/`confirm` take.
```bash
otopcua-cli comment -u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=2;s=Plant/Line1/Pump1/HighTemp" -e 0A1B2C -c "Checked on site"
```
| Flag | Description |
|---|---|
| `-n` / `--node` | Condition node ID of the alarm to comment on (**required**) |
| `-e` / `--event-id` | Hex-encoded EventId from the alarm notification (**required**) |
| `-c` / `--comment` | Operator comment to attach (**required**) |
### shelve
Shelves or unshelves an active alarm condition via the OPC UA Part 9 `ShelvedStateMachine` (`OneShotShelve` / `TimedShelve` / `Unshelve`). Gated on the `AlarmAck` role. `--duration` is given in **seconds** (converted to milliseconds for the OPC UA call) and is required only for `--kind Timed`.
Shelves or unshelves an active alarm condition via the OPC UA Part 9 `ShelvedStateMachine` (`OneShotShelve` / `TimedShelve` / `Unshelve`). Gated on the `AlarmAck` role. `--duration` is given in **seconds** (converted to milliseconds for the OPC UA call) and is required only for `--kind Timed`. The `--node` is the condition node itself (the `SourceNode`/condition id an `alarms` notification reports): `ack`, `confirm`, `shelve`, `enable` and `disable` call the Part 9 method ids directly on that node — no `.Condition` / `.ShelvingState` suffix is appended (those child nodes do not exist on the OtOpcUa server).
```bash
# One-shot shelve until the next clear
+1 -1
View File
@@ -57,7 +57,7 @@ The top bar provides the endpoint URL, Connect, and Disconnect buttons. The **Co
|---------|-------------|
| Endpoint URL | OPC UA server endpoint (e.g., `opc.tcp://localhost:4840/OtOpcUa`) |
| Username / Password | Credentials for `UserName` token authentication |
| Security Mode | Transport security: None, Sign, SignAndEncrypt |
| Security Mode | Transport security: None, Sign, SignAndEncrypt. `None` requires the server to offer a `None` endpoint — there is no fallback to a secured one; connecting fails naming the endpoints offered |
| Failover URLs | Comma-separated backup endpoints for redundancy failover |
| Session Timeout | Session timeout in seconds (1–3600, default 60) |
| Certificate Store Path | Path to the client certificate store (folder chooser) |
+5 -2
View File
@@ -218,7 +218,7 @@ See the [ClusterClient→gRPC migration design](plans/2026-08-06-clusterclient-t
| `GrpcListenPort` | int | `0` | Dedicated **h2c-only** Kestrel port for the artifact gRPC service. `0` = disabled (nothing bound). Must differ from the main HTTP port and from `LocalDb:SyncListenPort` (h2c cannot share a cleartext HTTP/1 port). Mapped only on admin-role nodes. |
| `ApiKey` | string | `""` | Shared bearer key the serve-side interceptor checks (constant-time, **fail-closed**: an unset key rejects every call). **Supply via env `ConfigServe__ApiKey` — never commit.** |
**It is a dark switch; the serve side is always wired.** Central maps the `DeploymentArtifactService` behind the `ConfigServeAuthInterceptor` (path-scoped, `FixedTimeEquals`, fail-closed) whenever `ConfigServe:GrpcListenPort > 0`, in both modes — so flipping a node to `FetchAndCache` is a config change, not a redeploy. A `FetchAndCache` node that cannot fetch (all endpoints down, `NotFound`, SHA mismatch) **fails the apply and keeps last-known-good** — a null fetch is "no answer," never an empty configuration (the [#485](plans/2026-07-15-v3-batch4-address-space-plan.md) contract). At boot it restores served state from the LocalDb pointer with no SQL read; an empty or unreadable cache lands Steady-with-no-revision (the first dispatch fetches), never Stale.
**It is a dark switch; the serve side is always wired.** Central maps the `DeploymentArtifactService` behind the `ConfigServeAuthInterceptor` (path-scoped, `FixedTimeEquals`, fail-closed) whenever `ConfigServe:GrpcListenPort > 0`, in both modes — so flipping a node to `FetchAndCache` is a config change, not a redeploy. A `FetchAndCache` node that cannot fetch (all endpoints down, `NotFound`, SHA mismatch) **fails the apply and keeps last-known-good** — a null fetch is "no answer," never an empty configuration (the [#485](plans/2026-07-15-v3-batch4-address-space-plan.md) contract). At boot it restores served state from the LocalDb pointer with no SQL read; an empty or unreadable cache — including a cached row whose bytes are malformed (review R1 m4) — lands Steady-with-no-revision (the first dispatch fetches, even of the same revision), never Stale.
**The listener is dedicated h2c and coexists with the LocalDb-sync listener.** A fused admin+driver node with **both** `ConfigServe:GrpcListenPort` and `LocalDb:SyncListenPort` set re-binds its existing HTTP surface exactly once and adds both dedicated HTTP/2-only ports on top — see the same Kestrel-takeover caveat under [`LocalDb`](#localdb-pair-local-config-cache--driver-role-only).
@@ -431,7 +431,10 @@ replication stays inert until the sync port + peer are set.
`PeerAddress` **OR** `ApiKey` non-blank — the OR because only the *dialling* half sets `PeerAddress`, so
keying on it alone would strip change capture from every passive node and leave the pair converging in one
direction only. `SyncListenPort` is deliberately **not** part of it (a port with no `ApiKey` is fail-closed
and syncs with nobody). A configured node registers all four tables with `baselineExistingRows: true`, so
and syncs with nobody). (The alarm-historian **drain gate** uses a stricter predicate on purpose —
`ApiKey` AND (`PeerAddress` OR `SyncListenPort > 0`), `LocalDbReplicationPolicy.SharesRowsWithPeer` — because
gating a node that shares its queue with nobody strands its alarm history; see `docs/AlarmHistorian.md`
§"The drain gate".) A configured node registers all four tables with `baselineExistingRows: true`, so
turning replication on converges the rows **already in the file** and not merely subsequent writes; an
unconfigured node **deregisters** them at boot, dropping capture triggers an earlier build installed. ⚠️ The
sync handshake compares registered-table digests **fail-closed**, so flipping either key is a
+17 -2
View File
@@ -311,12 +311,18 @@ shutdown paths deliberately keep the single trailing write.
> **A value consumer never reports a quality better than its source's.** A node derived from a driver
> value — the raw node, every UNS mirror, a Calculation output, a virtual tag, a scripted condition's
> quality — carries, at worst, the worst status among its inputs: a Bad input makes the derived node Bad
> **with that input's sub-code and the last-known value**; an Uncertain input makes it Uncertain; a source
> **with that input's sub-code** (the last-known value is kept server-side, but see the note below: a client never sees it beside a Bad status); an Uncertain input makes it Uncertain; a source
> that has stopped communicating is a Bad input (`BadNoCommunication`) for everything downstream of it,
> **including the external OPC UA data plane**. Recovery is a *publish*, never an assumption: the first
> post-recovery value must land even when it equals the value held before the outage, so every dedup on
> the path — driver caches, the node's own status, a consumer's last-published memory — must be keyed on
> **(value, status)**, not value alone.
>
> **"Value retained" is server-side state, not wire-observable.** The node keeps its last value under the
> Bad status (that is what lets the next Good re-publish carry it and what the `(value, status)` dedup
> compares against), but the OPC UA SDK's `NodeState.ReadAttribute` nulls the value of any Bad-status read
> and monitored-item sample, so a client sees `Value = null` with the Bad status and never the last-known
> value. Pinned server-side by `OtOpcUaNodeManagerWriteStatusesTests`; observation: `archreview/plans/R3-02-stale-good-family-plan.md`.
This is one rule with one implementation of its comparison,
`ZB.MOM.WW.OtOpcUa.Core.Abstractions.InputQuality` (`WorstOf` / `IsUsable`), used by every derived-value
@@ -325,7 +331,7 @@ a fifth (archreview round 3, theme "the stale-Good family"). The seams that impl
| Seam | What it does | Where |
|---|---|---|
| Driver comms loss → **value nodes** | one status-only batch stamps `BadNoCommunication` on the driver's raw NodeIds and every referencing UNS NodeId, **value retained** | `DriverHostActor.FanCommsLossToValueNodes` (below) |
| Driver comms loss → **value nodes** | one status-only batch stamps `BadNoCommunication` on the driver's raw NodeIds and every referencing UNS NodeId, **value retained server-side** (clients see a null value with the Bad status) | `DriverHostActor.FanCommsLossToValueNodes` (below) |
| Driver comms loss → dependency mux | synthetic Bad per ref, so scripts and alarms see the source die | `DriverHostActor.FanCommsLossToDependencyMux` |
| Driver comms loss → Part 9 conditions | condition quality annotated out of band | `DriverHostActor.OnDriverConnectivityChanged` |
| MQTT / Sparkplug | connection loss stales observed refs; every (re)subscribe pushes the cached snapshot | [`docs/drivers/Mqtt.md`](drivers/Mqtt.md#quality-on-connection-loss-and-re-subscribe) |
@@ -419,6 +425,15 @@ re-entered this path; the watchdog makes it automatic and unbounded, so a device
have leaked ~360 transports at the 10 s retry interval. The shutdown is best-effort: a driver that throws
on it still gets its initialise attempt.
**`InitializeAsync` itself releases the previous device/connection state first (#630).** The retry above is not
the only way in: `DriverInstanceActor.AdoptConfigDuringInit` calls `InitializeAsync` on a live driver with *no*
teardown, so a driver that does `_devices[host] = new DeviceState(...)` (or `Session = ...`, `Plc = ...`) orphans
the previous state's probe / recycle / fixed-tree loops and its client. FOCAS, AbLegacy, AbCip, TwinCAT, S7 and
OpcUaClient now cancel, bounded-join (5 s) and dispose that state at the top of `InitializeAsync`
(`ReleaseDeviceStatesAsync` / `ReleaseConnectionAsync` / `ReleaseSessionAsync`), alongside Modbus and MQTT.
It is deliberately the device-state half of `ShutdownAsync` only: the poll engine and subscriptions are *not*
dropped: the release is scoped to device state so it cannot take state the shutdown path owns. The actor re-subscribes values and alarms after every successful init, so driver-side subscriptions kept across a no-teardown re-init are retained leftovers, not needed for data flow. A new driver must do the same.
#### The fan-out that watchdog triggers: indexed per driver, and one batch
`DisconnectObserved` raises `ConnectivityChanged`, and `DriverHostActor` answers it by pushing
+11
View File
@@ -431,6 +431,17 @@ RetrievalMode.Integral`) — no client-side scaling. (This replaces the retired
likewise a native gateway mode. Bucket status codes and timestamps are preserved unchanged; empty / null
buckets surface as `BadNoData`.
### Aggregate set and `BadAggregateNotSupported` (#285)
`HistoryAggregateType` carries the wider Part 13 set (Interpolative, TimeAverage, Start, End, Delta, Range,
Min/MaxActualTime, Start/EndBound, DeltaBounds, DurationGood/Bad, PercentGood/Bad, WorstQuality,
NumberOfTransitions, AnnotationCount, StandardDeviationPopulation, VariancePopulation); `OtOpcUaNodeManager.MapAggregate`
maps each standard aggregate NodeId. The **HistorianGateway** backend only has retrieval modes for the original
five (Average, Minimum, Maximum, Total, Count): for any other member `AggregateModeMapper` throws
`HistoryAggregateNotSupportedException`, which the HistoryRead path turns into `BadAggregateNotSupported` for that
node only (not a generic failure, and not counted as a backend fault). The OPC UA Client driver forwards every
member upstream. A NodeId outside the mapped set (e.g. `StandardDeviationSample`) is still `BadAggregateNotSupported`.
### Known limitations
- **Processed and AtTime are single-shot** (no continuation points). Unlike Raw, neither
+58 -77
View File
@@ -1,90 +1,71 @@
# Incremental Sync
> ⚠️ **Accuracy warning (audited 2026-07-27) — most of this page describes machinery that is retired
> or was never wired.** Read it as v2-era design history, not as current behaviour.
>
> - **"Rebuild flow" (below) is not a production path.** It is built on `GenericDriverNodeManager`,
> which is Core test scaffolding with **zero production references**
> (`GenericDriverNodeManager.cs:71`). Its `_variablesByFullRef` / `_sourceByFullRef` fields do not
> exist in `src/`.
> - **Driver-backend rediscovery has no consumer.** Nothing in `src/Server/` or `src/Core/`
> subscribes to `IRediscoverable.OnRediscoveryNeeded`; `DriverHostActor.HandleDiscoveredNodes`
> (`DriverHostActor.cs:986-1002`) short-circuits unconditionally. The `ScopeHint` is read by
> nothing. Drivers that raise it today: Galaxy, TwinCAT, MQTT/Sparkplug, MTConnect
> ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518),
> [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507)).
> - **The generation-publish path is retired.** `sp_PublishGeneration` and
> `sp_ComputeGenerationDiff` are stubs that `RAISERROR`
> (`20260715230637_V3Initial.StoredProcedures.cs:194`); `DraftRevisionToken` does not exist.
>
> **The one live rebuild path in v3** is the deploy artifact: `ConfigComposer` →
> `Deployment.ArtifactBlob` → `DriverHostActor` apply → `AddressSpaceComposer` /
> `AddressSpaceApplier` diff-apply.
How a change reaches the running server's address space in v3. There is exactly one path that
changes the served address space — the **deploy artifact** — and one advisory signal
(`IRediscoverable`) that never changes it.
Two distinct change-detection paths feed the running server: driver-backend rediscovery (Galaxy's `time_of_last_deploy`, TwinCAT's symbol-version-changed) and generation-level config publishes from the Admin UI. Both flow into re-runs of `ITagDiscovery.DiscoverAsync`, but they originate differently.
## The deploy-artifact path
## Driver-backend rediscovery — IRediscoverable
1. **Compose.** `ConfigComposer` (`src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs`)
snapshots the whole Config DB into one gzip-compressed JSON blob (`Deployment.ArtifactBlob`) whose
`RevisionHash` is the SHA-256 of those exact bytes. A deployment is fleet-wide; each node
self-scopes the artifact (`DeploymentArtifact.ResolveClusterScope`).
2. **Dispatch.** `ConfigPublishCoordinator` (`.../ControlPlane/Coordinators/`) sends each enabled
`ClusterNode` a payload-free dispatch and waits for acks until the apply deadline. A node obtains the
bytes either straight from central SQL (`ConfigSource:Mode=Direct`) or over gRPC with a SHA-256
check (`FetchAndCache`) — see `docs/Configuration.md` §`ConfigSource`.
3. **Apply.** `DriverHostActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/`) opens the artifact and
reconciles in two halves:
- **Drivers** — `DriverSpawnPlanner.Compute` yields `ToStop` / `ToApplyDelta` / `ToSpawn`. A changed
driver config reaches the live driver as `IDriver.ReinitializeAsync` on its `DriverInstanceActor`;
a removed driver is stopped. An unreadable or empty blob is "no answer": the apply fails and the
running children are left alone (never an empty configuration).
- **Address space** — `AddressSpaceComposer` builds an `AddressSpaceComposition` from the artifact;
`AddressSpacePlan` diffs it against the last applied composition; `AddressSpaceChangeClassifier`
picks the delta class; `AddressSpaceApplier`
(`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/`) mutates `OtOpcUaNodeManager` through the sink.
4. **Classes** (`AddressSpaceChangeKind`): `Empty`, `AttributeOnly`, `PureAdd`, `PureRemove`,
`AddRemoveMix` — all applied surgically, so unrelated MonitoredItems survive — and `Rebuild`, the
default-closed fallback for anything the classifier does not whitelist. Any surgical `false`/throw
also ratchets to a safe rebuild, and a degraded non-rebuild apply does not advance the last-applied
composition (the next deploy re-diffs and re-ensures). The whitelist, the UNS-mirror repair rule and
the add/remove announcements are described in the "surgical deploys" paragraph of `CLAUDE.md`
and `docs/plans/2026-08-15-surgical-deploys.md`.
Drivers whose backend has a native change signal implement `IRediscoverable` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IRediscoverable.cs`):
Virtual tags and scripted alarms ride the same artifact and the same plan; a script edit is just another
changed record in the composition (see `docs/VirtualTags.md`, `docs/ScriptedAlarms.md`).
```csharp
public interface IRediscoverable
{
event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;
}
public sealed record RediscoveryEventArgs(string Reason, string? ScopeHint);
```
## Driver-backend rediscovery is advisory
The driver fires the event with a reason string (for the diagnostic log) and an optional scope hint — a non-null hint lets Core scope the rebuild surgically to that subtree; null means "the whole address space may have changed".
Drivers whose backend signals a tag-set change implement `IRediscoverable`
(`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IRediscoverable.cs`; `OnRediscoveryNeeded` carries a
`Reason` and an optional `ScopeHint`). Raisers today: **Galaxy** (`DeployWatcher`, new
`time_of_last_deploy`), **TwinCAT** (ADS symbol-version-changed), **MQTT/Sparkplug** and **MTConnect**.
AbCip, AbLegacy, FOCAS, Modbus, S7 and the rest raise nothing.
Drivers that implement the capability today:
`DriverInstanceActor` is the only production consumer: it subscribes in `PreStart`, detaches in
`PostStop`, and folds the raise into the driver-health snapshot
(`DriverHealthChanged.RediscoveryNeededUtc` / `.RediscoveryReason`), which the AdminUI `/hosts` page shows
as a **re-browse** chip. **It never rebuilds or edits the served address space** — v3 raw tags are authored
explicitly in `/raw`, so an operator re-browses the device and commits, and the result reaches the
server as an ordinary deployment. `ScopeHint` is not read by the actor. (The older runtime
injection path was removed, #507 closed as superseded.)
- **Galaxy** — `DeployWatcher` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DeployWatcher.cs`) subscribes to the mxaccessgw gRPC stream (`IGalaxyDeployWatchSource.WatchAsync`) and fires on a new `time_of_last_deploy` value. The gateway polls the Galaxy repository DB internally; the driver side is event-driven.
- **TwinCAT** — observes ADS symbol-version-changed notifications (ADS error `DeviceSymbolVersionInvalid`, decimal 1809 / `0x0711`). Note: legacy Beckhoff documentation sometimes cites `0x0702` (`DeviceInvalidGroup`) — that is a transcription error; the correct code is `0x0711` per `TwinCATStatusMapper.AdsSymbolVersionChanged` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStatusMapper.cs:35`).
## Not part of the v3 path
Static drivers (Modbus, S7, AB CIP, AB Legacy, FOCAS) do not implement `IRediscoverable` — their tags only change when a new generation is published from the Config DB. Core sees absence of the interface and skips change-detection wiring for those drivers (decision #54).
## Config-DB generation publishes
Tag-set changes authored in the Admin UI (UNS edits, CSV imports, driver-config edits) accumulate in a draft generation and commit via `sp_PublishGeneration`. The delta between the currently-published generation and the proposed next one is computed by `sp_ComputeGenerationDiff`, which drives:
- The publish-preview surface in the Admin UI (`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Deployments.razor`, backed by `AdminOperationsClient`) so operators can preview what will change before clicking Publish.
- The 409-on-stale-draft flow (decision #161) — a UNS drag-reorder preview carries a `DraftRevisionToken` so Confirm returns `409 Conflict / refresh-required` if the draft advanced between preview and commit.
After publish, the server's generation applier invokes `IDriver.ReinitializeAsync(driverConfigJson, ct)` on every driver whose `DriverInstance.DriverConfig` row changed in the new generation. Reinitialize is the in-process recovery path for Tier A/B drivers; if it fails the driver is marked `DriverState.Faulted` and its nodes go Bad quality — but the server process stays running. See `docs/v2/driver-stability.md`.
Drivers whose discovery depends on Config DB state (Modbus register maps, S7 DBs, AB CIP tag lists) re-run their discovery inside `ReinitializeAsync`; Core then diffs the new node set against the current address space.
## Rebuild flow
When a rediscovery is triggered (by either source), `GenericDriverNodeManager` re-runs `ITagDiscovery.DiscoverAsync` into the same `CapturingBuilder` it used at first build. The new node set is diffed against the current:
1. **Diff** — full-name comparison of the new `DriverAttributeInfo` set against the existing `_variablesByFullRef` map. Added / removed / modified references are partitioned.
2. **Snapshot subscriptions** — before teardown, Core captures the current monitored-item ref-counts for every affected reference so subscriptions can be replayed after rebuild.
3. **Teardown** — removed / modified variable nodes are deleted via `CustomNodeManager2.DeleteNode`. Driver-side subscriptions for those references are unwound via `ISubscribable.UnsubscribeAsync`.
4. **Rebuild** — added / modified references get fresh `BaseDataVariableState` nodes via the standard `IAddressSpaceBuilder.Variable(...)` path. Alarm-flagged references re-register their `IAlarmConditionSink` through `CapturingBuilder`.
5. **Restore subscriptions** — for every captured reference that still exists after rebuild, Core re-opens the driver subscription and restores the original ref-count.
Exceptions during teardown are swallowed per decision #12 — a driver throw must not leave the node tree half-deleted.
## Scope hint
When `RediscoveryEventArgs.ScopeHint` is non-null (e.g. a folder path), Core restricts the diff to that subtree. This matters for Galaxy Platform-scoped deployments where a `time_of_last_deploy` advance may only affect one platform's subtree. Null scope falls back to a full-tree diff.
## Virtual tags in the rebuild
Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md), virtual (scripted) tags live in the same address space as driver tags and flow through the same rebuild. `EquipmentNodeWalker` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs`) emits virtual-tag children alongside driver-tag children with `DriverAttributeInfo.Source = NodeSourceKind.Virtual`, and `DriverNodeManager` registers each variable's source in `_sourceByFullRef` so the dispatch branches correctly after rebuild. Virtual-tag script changes published from the Admin UI land through the same generation-publish path — the `VirtualTagEngine` recompiles its script bundle when its config row changes and `DriverNodeManager` re-registers any added/removed virtual variables through the standard diff path. Subscription restoration after rebuild runs through each source's `ISubscribable` — either the driver's or `VirtualTagSource` — without special-casing.
## Active subscriptions survive rebuild
Subscriptions for unchanged references stay live across rebuilds — their ref-count map is not disturbed. Clients monitoring a stable tag never see a data-change gap during a deploy, only clients monitoring a tag that was genuinely removed see the subscription drop.
- `sp_PublishGeneration` / `sp_ComputeGenerationDiff` are stubs that `RAISERROR` in the v3 baseline
migration; there is no generation-publish flow and no `DraftRevisionToken`.
- `GenericDriverNodeManager` (`Core/OpcUa/`) is retained scaffolding with no production caller; the
diff / teardown / subscription-replay "rebuild flow" described by earlier versions of this page does not
exist in `src/`.
## Key source files
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IRediscoverable.cs` — backend-change capability
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — discovery orchestration
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs` — `ReinitializeAsync` contract
- `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs` — publish-flow driver
- `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs` — cluster singleton invoked by the Admin UI's `AdminOperationsClient`
- `docs/v2/config-db-schema.md` — `sp_PublishGeneration` + `sp_ComputeGenerationDiff`
- `docs/v2/admin-ui.md` — DiffViewer + draft-revision-token flow
- `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs` — artifact composition + hash
- `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs` — dispatch + ack set
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` — apply + driver reconcile
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` — node-side artifact parsing
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs`, `AddressSpacePlan.cs`,
`AddressSpaceChangeClassifier.cs`, `AddressSpaceApplier.cs` — compose, diff, classify, apply
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs` — `IRediscoverable` consumer
- `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs` — in-place update seam
+3 -2
View File
@@ -56,8 +56,9 @@ The server binds a TCP endpoint at `opc.tcp://{PublicHostname}:{OpcUaPort}/OtOpc
`OpcUaApplicationHost` subscribes to `SessionManager.ImpersonateUser` after `ApplicationInstance.Start`. The handler (`HandleImpersonation`) deals with the token types as follows:
- `UserNameIdentityToken` → the password is decrypted, then `IOpcUaUserAuthenticator.AuthenticateUserNameAsync` validates the credential (`LdapOpcUaUserAuthenticator` in production, a stub in tests). On success a `UserIdentity` carrying the token is attached and the LDAP-derived roles are logged; on failure `ImpersonateEventArgs.IdentityValidationError` is set to `BadIdentityTokenRejected`.
- `AnonymousIdentityToken` and X.509 tokens → the handler returns without intervening, so the SDK's default validation stands.
- `UserNameIdentityToken` → the password is decrypted, then `IOpcUaUserAuthenticator.AuthenticateUserNameAsync` validates the credential (`ApiKeyOpcUaUserAuthenticator` in production — fixed username `apikey`, `otua_<keyId>_<secret>` password, no network I/O — and a stub in tests). On success a `RoleCarryingUserIdentity` carrying the token and the key's resolved roles is attached; on failure `ImpersonateEventArgs.IdentityValidationError` is set to `BadIdentityTokenRejected`.
- `AnonymousIdentityToken` → admitted only when `OpcUa:AllowAnonymous` (default `true`); the session then authenticates as the reserved `anonymous` principal and carries whatever roles `NodeAcl` rows naming it grant (`HandleAnonymousImpersonation`). A failure to read those grants denies.
- X.509 tokens → the handler returns without intervening, so the SDK's default validation stands.
Decryption failures and authenticator exceptions also map to `BadIdentityTokenRejected`.
+21 -5
View File
@@ -48,6 +48,12 @@ settings; the device's `DeviceConfig` holds host/port/endpoint. At deploy/probe
connect (inside the **Device** modal) probes that merged config. Driver forms no longer
serialize the endpoint keys, so `DeviceConfig` is the single source of truth.
**Creating a driver auto-creates an empty `Device1` (by design, #596).** Every driver needs at least one
device to hang browse and tags off, and for a single-device driver the merger treats that device's
`DeviceConfig` as the endpoint, so an empty, renamable, deletable `Device1` is the lowest-friction start
(`RawTreeService.CreateDriverAsync`; pinned by `RawTreeServiceWaveBTests`). It is not a placeholder to be
removed later.
## Authoring tags
**Add tags ▸** on a Device or TagGroup offers:
@@ -74,8 +80,9 @@ serialize the endpoint keys, so `DeviceConfig` is the single source of truth.
Editing a raw **tag's** attributes is no longer a fleet-wide event. A **non-alarm**
raw-tag delta whose only differences are the in-place-updatable attributes —
`Writable`, `IsHistorized`, `HistorianTagname`, `DataType`, `IsArray`, `ArrayLength` —
and/or its set of referencing equipment is applied **in place** on the existing Raw-realm
`Writable`, `IsHistorized`, `HistorianTagname`, `DataType`, `IsArray`, `ArrayLength`,
`ArrayWritable` (the driver's array-write capability, #610 — see
[Uns.md §Array writes](Uns.md#array-writes-610)) — and/or its set of referencing equipment is applied **in place** on the existing Raw-realm
node (`ISurgicalAddressSpaceSink.UpdateTagAttributes`), so every client subscription on
that tag and on the rest of the server survives. The same is true of **every** UNS
tag-reference change: adding, removing or re-pointing a reference used to flip the
@@ -99,9 +106,18 @@ mirror **in place** (its MonitoredItems survive); a re-point or effective-name c
removes and recreates **that one mirror node**. See [`Uns.md`](Uns.md) and
[`plans/2026-08-15-surgical-deploys.md`](plans/2026-08-15-surgical-deploys.md).
⚠️ A surgically added raw container/tag is materialised but **not** announced —
`AnnounceAddedNodes` is UNS-only, so model-aware clients see new raw nodes on their next
browse. This mattered less when every add forced a rebuild (and therefore a re-browse).
A surgically added raw container/tag is **announced** (Gitea #599): `AnnounceAddedNodes`
raises a `GeneralModelChangeEvent(NodeAdded)` in the **Raw** realm for each distinct
existing parent of the added nodes (a parent that is itself new in the same deploy is
skipped — its own parent is announced, and a re-browse there finds the whole new subtree;
a new top-level folder announces itself). UNS-side adds announce in the Uns realm as
before. Until #599 raw adds were materialised but never announced, so model-aware clients
only saw them on their next browse — invisible while every add still forced a rebuild.
Removing an equipment that holds UNS references is also surgical: the references are
removed (terminal Bad, then `RemoveVariableNode`, which severs the backing raw node's
inverse `Organizes` edge) **before** the equipment subtree, and the subtree removal severs
the same edge for anything it reaches itself (archreview 03/P5 + 03/S20).
## The `Calculation` driver
+16 -3
View File
@@ -108,6 +108,16 @@ plane (writes, alarm acks, the alerts emit, the alarm-history drain) on the wron
*higher* address so the two orderings genuinely disagree. `NodeRedundancyState.IsRoleLeaderForDriver`
was renamed `IsDriverPrimary` to stop the name asserting the old derivation.
**Accepted residual (#620): the election's role match is case-sensitive.** `RedundancyStateActor`
delegates to `ZB.MOM.WW.Health.Akka`'s `ClusterActiveNode.OldestUpMember`, which matches roles with
Akka's own ordinal `Member.HasRole`. A peer gossiping a miscased plain role (`Driver`) is therefore
invisible to the election — which only happens in a mixed rolling-upgrade window with a pre-R3-04
build (fixed builds normalize roles before gossip). Deliberately **not** patched: the library is a
family-shared package, and making either it or a local role pre-filter case-insensitive would split the
Primary election from `ClusterSingletonManager` placement (also ordinal) — recreating the "Primary not
on the singleton node" defect above. Case-sensitive on both sides is the consistent behaviour; finish
the rolling upgrade rather than working around it.
> **RESOLVED by Phase 6 — the election is now per application `Cluster`, not per Akka mesh.**
> Phase 6 of the per-cluster mesh program split the single fleet-wide Akka mesh into one independent
> 2-node mesh per application `Cluster` (see [Per-cluster meshes (Phase 6)](#per-cluster-meshes-phase-6)
@@ -516,13 +526,13 @@ Three data-plane surfaces are **Primary-gated** so a warm standby never touches
Before archreview 03/S4 the unknown-role case defaulted to *allow* unconditionally, opening a dual-primary window: a freshly-booted secondary on a multi-node cluster serviced shared-device writes and emitted duplicate fleet-wide alerts for up to the ~10 s `RedundancyStateActor` heartbeat (and indefinitely if the snapshot's NodeId never matched this node). Membership is authoritative much earlier than DPS delivery, so an unknown role on a **multi-driver** cluster now default-**denies**. The driver member count is read live from `Cluster.State.Members` (Up members with the `driver` role); a non-cluster ActorRefProvider yields 0 ⇒ single-node posture. If a snapshot arrives that never mentions this node while a driver peer exists (the 03/S5 identity-mismatch shape), `DriverHostActor` logs a one-time Warning so the silent mismatch is diagnosable.
**What a client sees during the boot window (multi-node, role unknown):** an inbound write is **rejected** (not queued — OT actuation seconds late is a surprise). The reject rides the sanctioned optimistic-write self-correction surface: the node reverts to its prior value with a transient Bad-quality blip and a Part 8 `AuditWriteUpdateEvent`; the client's synchronous write call still returned Good (exactly as any failed device write behaves since the #5 write-outcome self-correction). The rejection reason distinguishes the boot window (`"not primary (role unknown)"`) from a steady-state Secondary (`"not primary"`). Acks are dropped (Warning-logged when role-unknown). The alerts emit is skipped (the Primary peer publishes the single fleet copy). The window closes on the first delivered snapshot.
**What a client sees during the boot window (multi-node, role unknown):** an inbound write is **rejected** (not queued — OT actuation seconds late is a surprise). The reject rides the sanctioned optimistic-write self-correction surface: the node reverts to its prior value with a transient Bad-quality blip and a Part 8 `AuditWriteUpdateEvent`; the client's synchronous write call still returned Good (exactly as any failed device write behaves since the #5 write-outcome self-correction). The rejection reason distinguishes the boot window (`"not primary (role unknown)"`) from a steady-state Secondary (`"not primary"`). Acks are dropped (Warning-logged when role-unknown) — and since review R1 m2 a native OPC UA Acknowledge is refused up front (`BadInvalidState`) in the same window: `DriverHostActor` publishes its Primary verdict to `IDriverPrimaryRoleSource` (the native-ack gate, and the MQTT Sparkplug primary-host claim) once at start and again on every cluster membership change, not only on redundancy snapshots, so the published verdict denies as soon as a driver peer is Up rather than sitting on the single-node "allow" seed until the first snapshot. ⚠️ "As soon as a driver peer is Up" means **in this node's own cluster view**, i.e. after it has joined: the host is spawned before the join, when the view holds no members, and a member count of 0 takes the single-node *allow* posture — for the published verdict and the live gate alike. So a node cold-starting into a pair whose peer is already Primary allows until its own join completes (seconds normally; up to `Cluster:BootstrapGuard:PartnerProbeSeconds`, 25 s, under the bootstrap guard), and in that window the MQTT Sparkplug primary-host claim can be held on both nodes. Native ack is not exposed (the view and the route agree, so there is no acked → unacked revert). Denying until self is Up was not adopted: it would also deny writes/acks during a single-node cluster's own join (review R6 MINOR-2). The alerts emit is skipped (the Primary peer publishes the single fleet copy). The window closes on the first delivered snapshot.
Under warm/hot redundancy both cluster nodes run `ScriptedAlarmHostActor` and evaluate scripted alarms, keeping each node's address space and engine state warm for instant failover. To avoid duplicate rows on `/alerts` and duplicate historian writes, only the Primary node publishes externally:
- **`alerts` topic emission** — `ScriptedAlarmHostActor` and `DriverHostActor.ForwardNativeAlarm` subscribe to the `redundancy-state` DPS topic and cache the local node's `RedundancyRole`, then gate the cluster `alerts` publish through `PrimaryGatePolicy` (table above). The OPC UA condition-node write and inbound ack/shelve command processing remain **ungated** on both nodes so the secondary is always ready to serve clients after a failover.
- **`HistorianAdapterActor` historization** — likewise Primary-gated so alarm historization is exactly-once across all alarm sources. The actor subscribes to the `alerts` DPS topic and translates each `AlarmTransitionEvent` → `AlarmHistorianEvent` before enqueuing it on the sink; scripted alarms therefore historize exactly once regardless of cluster size.
- **The alarm sink's DRAIN** — a second, independent gate on the same decision. The enqueue gate above is not sufficient any more: since Phase 2 the store-and-forward buffer lives in the replicated LocalDb, so the Secondary holds a full copy of the Primary's queued rows and an ungated drain there would re-deliver every event continuously. `LocalDbStoreAndForwardSink` takes a `drainGate` fed from `IRedundancyRoleView` — a singleton `DriverHostActor` publishes its `PrimaryGatePolicy` verdict to, because the drain runs on a timer and cannot receive `RedundancyStateChanged` itself. A gated-off node reports `HistorianDrainState.NotPrimary`. Delivery is **at-least-once across a failover** by design: rows the old Primary delivered just before dying may not have replicated their deletes yet. See [AlarmHistorian.md](AlarmHistorian.md).
- **The alarm sink's DRAIN** — a second, independent gate on the same decision. The enqueue gate above is not sufficient any more: since Phase 2 the store-and-forward buffer lives in the replicated LocalDb, so the Secondary holds a full copy of the Primary's queued rows and an ungated drain there would re-deliver every event continuously. `LocalDbStoreAndForwardSink` takes a `drainGate` fed from `IRedundancyRoleView` — a singleton `DriverHostActor` publishes its `PrimaryGatePolicy` verdict to, because the drain runs on a timer and cannot receive `RedundancyStateChanged` itself. A gated-off node reports `HistorianDrainState.NotPrimary`. The gate applies only to a node whose queue a peer actually holds — `LocalDbReplicationPolicy.SharesRowsWithPeer`: `LocalDb:Replication:ApiKey` AND (`PeerAddress` OR `LocalDb:SyncListenPort` > 0); any other node always drains (review R1 m3). Within a pair BOTH halves stand down for their partner: the dialling half knows it by the `PeerAddress` host, the passive half (no `PeerAddress`) as its per-cluster mesh partner (review R6 MINOR-4 — before that the passive half never stood down, so both halves drained and every event reached the historian twice; a passive half with no `cluster-{ClusterId}` role still cannot identify its partner and keeps draining). Delivery is **at-least-once across a failover** by design: rows the old Primary delivered just before dying may not have replicated their deletes yet. See [AlarmHistorian.md](AlarmHistorian.md).
- **Continuous-historization value CAPTURE** (Gitea #537) — a third gate on the same role rule, and the
one that gates the *opposite* half of its pipeline. Both pair nodes serve the same address space and see
@@ -612,7 +622,10 @@ and [AlarmHistorian.md](AlarmHistorian.md). Phase 1 caches the **deployed-config
artifact** (chunked). Its job is a single failure mode redundancy does not otherwise cover: a driver
node restarting into a **central-SQL-Server outage** would come up with no configuration at all. With
the cache, it **boots from the last artifact it applied** instead, and logs a running-from-cache
signal.
signal. A cached artifact whose bytes are empty or malformed is **no answer** (#601): nothing is applied,
the node does **not** adopt that revision or claim running-from-cache, and it goes **Stale** (retrying the
ConfigDb) — so a later dispatch of that same revision really applies instead of being acked as already
current (review R1 m4). A `FetchAndCache` node lands Steady-with-no-revision instead, as for an empty cache.
- **What replicates.** The cache can *optionally* replicate to the node's redundant pair peer over
the LocalDb library's gRPC sync (HLC-stamped, last-writer-wins). Two tables replicate:
+5 -5
View File
@@ -203,9 +203,9 @@ prefix. The concrete implementation `ScriptTagCatalog` reads `Tag` +
literal against `DriverInstanceActor.AttributeValuePublished.FullReference`,
which is the `FullName` field from `Tag.TagConfig`
(see `ScriptTagCatalog.ExtractFullNameFromTagConfig` + `EquipmentNodeWalker.ExtractFullName`).
The UNS-path engine (`Core.VirtualTags.VirtualTagEngine`, keyed by the
slash-joined `Enterprise/Site/Area/Line/Equipment/TagName` browse path) is
dormant — it is not wired into the host — so UNS browse paths never resolve at
The former UNS-path cascade engine (`Core.VirtualTags.VirtualTagEngine`, keyed by the
slash-joined `Enterprise/Site/Area/Line/Equipment/TagName` browse path) was
retired (#603) — it was never wired into the host — so UNS browse paths never resolve at
runtime and are intentionally NOT offered as completions.
The catalog is scoped per Blazor circuit; each call creates and disposes its own
@@ -233,8 +233,8 @@ deploy time. An unrelated `anything.GetTag("…")` no longer triggers it.
**`SetVirtualTag` is a no-op in production.** The live single-tag evaluator
(`RoslynVirtualTagEvaluator`) drops cross-tag `ctx.SetVirtualTag(...)` writes
(logged); the cascade `VirtualTagEngine` that honours them is dormant (not wired
into the host). So hover on a `ctx.SetVirtualTag("…")` path literal appends a
(logged); the cascade engine that would have honoured them (`VirtualTagEngine`) was retired
unwired (#603). So hover on a `ctx.SetVirtualTag("…")` path literal appends a
note that the write is dropped in single-tag mode and will not take effect at
runtime — the path completions still help author the literal, but the author is
not misled into relying on a no-op. Making `SetVirtualTag` functional would
+6 -5
View File
@@ -118,7 +118,8 @@ rationale is recorded in the engine's class doc so it is not re-proposed:
- `ApplyOneShotShelve` / `ApplyTimedShelve(unshelveAtUtc)` / `ApplyUnshelve` — shelving transitions. `Timed` requires `unshelveAtUtc > nowUtc`.
- `ApplyEnable` / `ApplyDisable` — operator enable/disable. Disabled alarms ignore predicate results until re-enabled; on enable, `ActiveState` is re-derived from the next evaluation.
- `ApplyAddComment(text)` — append-only audit entry, no state change.
- `ApplyShelvingCheck(nowUtc)` — called by the 5s timer; promotes expired `Timed` shelving to `Unshelved` with a `system / AutoUnshelve` audit entry.
- `ApplyShelvingCheck(nowUtc)` — called by the 5s timer; promotes expired `Timed` shelving to `Unshelved` with a `system / AutoUnshelve` audit entry. The sole owner of timed-shelve expiry — the OPC UA node's own SDK timer does nothing.
- `ApplyAcknowledge` on an already-acknowledged alarm is a no-op **unless it carries a comment**, which is recorded as an `AddComment` (emission `CommentAdded`; `LastAck*` untouched) — the OPC UA node reports a commented re-ack, so the system of record must hold the comment too.
Two invariants the machine enforces:
@@ -346,19 +347,19 @@ Operators interact with active scripted alarms through two surfaces — both con
### AlarmAck gate (OPC UA method path)
`OtOpcUaNodeManager` wires the OPC UA Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve / Enable / Disable) on each `AlarmConditionState` node. Every method call is gated on the `AlarmAck` LDAP role — fail-closed: a session with no resolved roles or no `AlarmAck` group membership receives `BadUserAccessDenied` immediately without reaching the engine. The role is carried on the session by `RoleCarryingUserIdentity` (a `UserIdentity` subclass that preserves the LDAP-resolved role set past `OpcUaApplicationHost`).
`OtOpcUaNodeManager` wires the OPC UA Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve / Enable / Disable) on each `AlarmConditionState` node. Every method call is gated on the `AlarmAck` role — fail-closed: a session with no resolved roles or no `AlarmAck`-conferring grant on its API-key principal (`NodeAcl`; LDAP is AdminUI-only, see `docs/security.md`) receives `BadUserAccessDenied` immediately without reaching the engine. The role is carried on the session by `RoleCarryingUserIdentity` (a `UserIdentity` subclass that preserves the API-key-resolved role set past `OpcUaApplicationHost`).
On allow, the handler publishes a `Commons.OpcUa.AlarmCommand` (containing command kind, condition id, comment, and operator principal) onto the `alarm-commands` DPS topic. The node manager itself stays Akka-free: the dispatch action is a settable `Action<AlarmCommand>` injected at boot by the hosted service.
**Scripted vs native conditions — Enable/Disable and Acknowledge:**
- **Scripted conditions** — all eight Part 9 operations (including Enable/Disable) route through `AlarmCommandRouter` onto the `alarm-commands` topic, which `ScriptedAlarmHostActor` dispatches to the engine (`EnableAsync` / `DisableAsync` / `AcknowledgeAsync` / …). On enable, `ActiveState` is re-derived from the next predicate evaluation.
- **Native (driver-fed) conditions** — `OnAcknowledge` branches to `NativeAlarmAckRouter` and routes the ack to the owning driver rather than the scripted engine (see §[Native-alarm OPC UA operator operations](#native-alarm-opc-ua-operator-operations) above). `OnEnableDisable` returns `BadNotSupported` immediately — native conditions have no engine-side enable/disable surface.
- **Native (driver-fed) conditions** — `OnAcknowledge` branches to `NativeAlarmAckRouter` and routes the ack to the owning driver rather than the scripted engine (see §[Native-alarm OPC UA operator operations](#native-alarm-opc-ua-operator-operations) above). `OnEnableDisable`, `OnConfirm` and `OnShelve` return `BadNotSupported` immediately — a native condition supports Acknowledge and AddComment only (no engine-side surface, no upstream path, and the driver projection owns its Confirmed/Shelving; Gitea #587 review).
`OnTimedUnshelve` (the SDK's internal auto-unshelve timer) bypasses the client gate — it is system-initiated and not subject to operator role checks.
`OnTimedUnshelve` (the SDK's internal auto-unshelve timer, armed for the real remaining time since Gitea #597) is a **no-op**: the engine owns timed-shelve expiry via `ApplyShelvingCheck` (`system / AutoUnshelve`), and routing an `Unshelve` from the timer pre-empted that on every expiry as an operator-style unshelve from both pair nodes (review R1 M2).
### Delta-gate de-duplication
`WriteAlarmCondition` fires a Part 9 condition event only when the incoming state differs from the node's current live state. This suppresses the double-emit that would otherwise occur when the OPC UA SDK auto-applies the acked state on the node and the engine's re-projection then attempts to fire a duplicate event.
`WriteAlarmCondition` fires a Part 9 condition event only when the incoming state differs from the node's current live state. For an OPC UA method call (Acknowledge, Confirm, AddComment, the shelve methods, Enable/Disable) the SDK applies the new state to the node, and the node manager then reports **the one** condition event for it from the method path (Gitea #587 — the SDK reports none of its own, since `AutoReportStateChanges` stays false; the shelve methods' state is applied by the node manager, because the SDK's never applies it; a call that changed nothing — e.g. an uncommented re-ack — reports nothing; a re-ack carrying a comment reports that comment once, Gitea #597, and the engine records it as an `AddComment` so it is persisted and replicated, review R1 m1). Native (driver-fed) conditions support only Acknowledge and AddComment; their Confirm, shelve and Enable/Disable methods return `BadNotSupported`, and their Acknowledge is refused with `BadInvalidState` on a node that is not the redundancy Primary (Gitea #597). A timed shelve's `UnshelveTime` counts down to the engine's expiry (the projection carries `UnshelveAtUtc`). The engine's later re-projection of the same transition finds the node already in that state and is suppressed, so each accepted call yields exactly one event. See `docs/AlarmTracking.md` §"One condition event per method call".
### AdminUI path
+17 -3
View File
@@ -140,9 +140,9 @@ Three things to know before touching it:
The digest is a flattened, host-name-ordered **string** for the converse reason: a tuple holding an
`IReadOnlyList` compares by reference and would re-publish on every 30 s heartbeat forever.
⚠️ **`DriverInstanceResilienceStatus` is the same defect, still open.** That table also has no writer
and no reader — the only reference in the repo is its DbSet declaration — while the live data reaches
the AdminUI over the `driver-resilience-status` channel above. Keep-or-delete is Gitea **#524**.
**`DriverInstanceResilienceStatus` was dropped (Gitea #524, migration
`DropDriverInstanceResilienceStatusTable`).** The table had no writer and no reader; the live data
reaches the AdminUI over the `driver-resilience-status` channel above, which is its only home.
## The three non-migrated channels (NOT migrated in Phase 5 — do not read this as "seven done")
@@ -253,6 +253,20 @@ driver node**:
`IDriverStatusSnapshotStore` / `IDriverResilienceStatusStore` re-prime immediately rather than
sitting stale until the next natural event. `alerts`/`script-logs` simply tolerate the gap — a
reconnect loses whatever transitions happened while disconnected, same as a DPS resubscribe would.
The cache is evicted per instance (`ITelemetryLocalHub.Evict`) when `DriverHostActor.StopChild` removes a
driver from the plan (#609), so a decommissioned instance's last state is not replayed to new subscribers.
`StopChild` first releases the instance's resilience state (`IDriverCapabilityInvokerFactory.Release` →
pipeline-builder + `DriverResilienceStatusTracker.Retire`): `DriverResilienceStatusPublisherService`
re-emits the whole tracker every 5 s, so evicting only the hub was undone within one tick (review R1 M1).
⚠️ **Both releases HOLD the id, not just clear it.** `Context.Stop` is asynchronous, so the stopping child
can still finish a capability call it had in flight — observed: its `SubscribeBulk`, whose
`RecordCallStart`/`RecordSuccess`/`RecordCallComplete` ran after the release and re-created the tracker
entry, which the publisher then re-emitted every tick for the life of the process — and can still publish a
Health snapshot. So the tracker ignores every `Record*` for a retired id, and the hub drops every later
Health/Resilience item for an evicted id (neither cached nor fanned out), until the same id is spawned
again: `IDriverCapabilityInvokerFactory.Create` reinstates it in the tracker and `SpawnChild` calls
`ITelemetryLocalHub.Readmit`. Residual: a late write from the *old* child that lands after a re-add of the
same id is indistinguishable from the new child's and is kept — the same window a respawn always had.
- **Connection indicator** — the `/alerts` and `/script-log` live/disconnected pill is driven by
**aggregate** stream health across all dialers: connected when **at least one** node stream is
up, disconnected when all are down. This matches today's DPS `SubscribeAck`/`PostStop` pill
+91 -4
View File
@@ -312,10 +312,96 @@ Combined with historization (values are arrays — history of the whole array sn
| **AB Legacy** | PCCC multi-element file read via libplctag (cap 256 elements) | Unit-proven (sim fixture down) |
| **TwinCAT** | Raw-bytes read of the declared `SymbolPath` at the resolved array type's layout + per-element decode; native ADS notifications carry the whole array (same decode). Multi-dim PLC arrays publish flattened; a shorter `arrayLength` reads the leading sub-range | Live-verified 2026-10-07 on two TwinCAT 2.11 PLCs (see [TwinCAT.md §Array tags](drivers/TwinCAT.md#array-tags)) |
### Array writes (#610)
An authored-`ReadWrite` array tag is **writable only when its driver can write that array** —
the server never advertises a write the driver will refuse. The capability is one table,
`DriverArrayWriteSupport` (`Core.Abstractions`), consulted by the composer at compose time and
carried on the plan records (`RawTagPlan.ArrayWritable`, mirrored onto every UNS reference as
`UnsReferenceVariable.BackingArrayWritable`); the node is writable iff
`Writable && (!IsArray || ArrayWritable)`, on the raw node and every UNS mirror alike.
| Driver | Array writes | Element types |
|---|---|---|
| **Modbus** | Yes | `region: Coils` (FC15, any element, ≤ 1968 coils); `region: HoldingRegisters` with `Int16`/`UInt16`/`Bcd16`/`Int32`/`UInt32`/`Bcd32`/`Float32`/`Int64`/`UInt64`/`Float64` (FC16) whose `arrayLength × registers-per-element` fits `maxRegistersPerWrite` (default 123; the smallest value set on the driver or the tag's device wins) — the driver refuses rather than splits an over-cap FC16, so e.g. a 62-element `Int32` array (124 registers) stays read-only. Not BitInRegister / String / Bool-in-register; input regions are read-only |
| **S7** | Yes | `Byte`/`Int16`/`UInt16`/`Int32`/`UInt32`/`Float32` (one contiguous block). Not `Bool` (would clobber neighbouring bits) nor the wide / String / DateTime types |
| **OpcUaClient** | Yes | All — forwarded upstream unchanged; the upstream server decides (shape included) |
| **Galaxy** | Yes | OPC UA `Boolean`/`Int32`/`Int64`/`Float`/`Double`/`String`/`DateTime` (the `MxValueEncoder` array variants) — **only with a declared `arrayLength`**: an undeclared length materialises an unbounded node, and a short whole-array write would *resize* the Galaxy attribute (mxaccessgw contract), so such a node stays read-only |
| AB CIP, AB Legacy, TwinCAT, FOCAS, MQTT, MTConnect, Sql, Calculation | **No** — scalar-only write encoders; array nodes stay read-only and a client write is `BadNotWritable` | — |
Modbus, S7 and Galaxy additionally need a **declared length** — `isArray: true` *and* an integer
`arrayLength >= 1`. The Modbus and S7 tag factories read anything else as a **scalar** tag (whose
scalar encoder cannot take an array), even though the composer still materialises an array node;
that node stays read-only.
Each "Yes" row is pinned against the driver's real encoder by a parity test in that driver's
test project (`*ArrayWriteSupportParityTests`), so a driver gaining or losing an array encoder
fails a test until the table follows.
What a client sees on a writable array node:
- **Whole-array writes only.** The value must be a 1-D array of exactly `arrayLength` elements
(an unbounded `arrayLength`/`ArrayDimensions = [0]` takes any length — reachable only on
OpcUaClient); anything else is `BadTypeMismatch`, refused inline before dispatch.
- **The element type must match the node's DataType, or be a numeric type that converts to it
exactly.** A matching element type (or a `Byte` array, which arrives as a ByteString) is dispatched
as written. A **numeric** array of another numeric element type is **converted, checked, element by
element** into the node's element type, and the converted array is what the node stores and the
driver receives — an `Int32[]` written to an `Int16` array reaches the driver as `short[]`. The whole
write is refused `BadTypeMismatch` inline, nothing dispatched, if **any** element is out of the
target's range, fractional for an integer node (`1.5` → `Int16` refused; `2.0` → `2`), NaN/±∞ for an
integer node, or finite but beyond `Float` range for a `Float` node. Every **non-numeric** mismatch
(`Boolean[]` → `Int32`, `Int32[]` → `String`, a `Variant[]`) is refused. This is the check the OPC UA
SDK would normally make itself: it skips its own type check on any node with an `OnWriteValue`
handler, which every writable equipment node has, so the node manager makes it instead. Without it a
`UInt32[]` reached Galaxy's `int[]` encoder by CLR array covariance and was written bit-reinterpreted
(4,000,000,000 → −294,967,296) with a Good result — that write is still refused (4,000,000,000
overflows `Int32`), while an in-range `UInt32[]` converts. The conversion is required, not a
nicety: ScadaBridge writes the CLR list it decoded from a `List` attribute, and its element types are
only Boolean/Int32/Float/Double/String/DateTime, so an exact-match rule left every `Int16`/`UInt16`/
`Byte`/`UInt32`/`Int64`/`UInt64` array node (Modbus's default `Int16` included) and a `Double` list to
a `Float` array unwritable from it. **Scalar** writes keep their looser rule — any numeric to any
numeric node, unconverted — because every driver's scalar encoder converts with a range check, so an
out-of-range scalar fails the device write and is reverted.
- An **array value to a scalar node** is refused `BadTypeMismatch` inline **only on Modbus and S7**,
whose scalar encoders cannot take one (`DriverArrayWriteSupport.RefusesArrayValueOnScalarTag`). Every
other driver keeps the pre-#610 behaviour — the write is dispatched and the driver decides. That
matters for browse-committed tags: the `/raw` commit sets the DataType but never `isArray`, so an
upstream array variable (OpcUaClient) or a Galaxy array attribute committed from the browser is a
**scalar** node into which the driver publishes an array, and writing that array back still works
(OpcUaClient forwards it to the upstream server, which arbitrates the shape; Galaxy encodes it). Set
`isArray` + `arrayLength` on such a tag to get the array-node checks above.
- A partial **IndexRange** write is
`BadWriteNotSupported` — every array-capable driver writes the whole block, and the server
does not hold the device array authoritatively enough to read-modify-write it.
- The same realm-qualified `WriteOperate` / ACL gate as a scalar applies to both NodeIds, and a
device-refused array write is reverted on the NodeId it was written through (the scalar
write-outcome self-correction, unchanged).
- Flipping a tag's array-write capability (a driver-type or element-type edit on an existing
array tag) is a **surgical** in-place writability update, not a rebuild: `ArrayWritable` is on
the classifier's raw-tag whitelist, and the mirrors repair through `BackingArrayWritable`. A
change to the array-on-scalar refusal (`ScalarRefusesArrayValue` — only a driver-type edit can flip
it) is deliberately NOT whitelisted and rebuilds.
> ⚠️ **Upgrade note — array tags become client-writable without a deploy.** Before #610 every
> array node was read-only regardless of its authored `AccessLevel`. On the first boot after
> upgrading, every array tag **authored `ReadWrite`** on Modbus, S7, OpcUaClient or Galaxy whose
> shape the table above accepts is materialised **writable** — no deployment needed, since the
> capability is derived at compose time (a `FetchAndCache` node re-composes from its cached
> artifact). Any client holding `WriteOperate` (or, with `Security:Acl:Enabled`, a `WriteOperate`
> grant covering the tag) can then write whole PLC arrays. Before upgrading, audit array tags
> authored `ReadWrite` that were never meant to be written and set them `Read`; array tags on the
> other drivers are unaffected.
### Out of scope (named deferrals)
- **Array writes** (inbound client→device write of an array value) — tagged for a follow-up phase.
- **Multi-dimensional arrays** (`ValueRank > 1`) — not supported; all arrays are 1-D.
- **Multi-dimensional arrays** (`ValueRank > 1`) — **out of scope by decision** (#610 item 3),
not merely deferred. Every driver's array read decodes a flat 1-D block (TwinCAT already
publishes multi-dim PLC arrays flattened), and the node manager materialises every array
`ValueRank = OneDimension` with a single `ArrayDimensions` entry. Supporting rank > 1 would
need per-rank dimensions in TagConfig, the plan records, the classifier whitelist, the node
manager and every driver's decode/encode, and no consumer has asked for it. Author a
multi-dimensional PLC array as a flat 1-D tag (row-major), or as several 1-D tags.
- **Array historization** — a historized array tag materialises with the correct `Historizing` flag, but the historian backend treats the value as an opaque blob; per-element history is out of scope. (Continuous historization is numeric-analog only — array / non-numeric values are not recorded.)
See the individual driver docs under `docs/drivers/` for per-driver implementation details.
@@ -324,7 +410,8 @@ See the individual driver docs under `docs/drivers/` for per-driver implementati
An equipment tag is **writable by default** — the materialised OPC UA node is created
`CurrentReadWrite` and inbound client writes are routed to the device (subject to the
`WriteOperate` LDAP role gate). Setting the optional `"writable": false` key in a tag's
`WriteOperate` gate — a role the session's API-key principal earns from its `NodeAcl` grants, see
[security.md](security.md)). Setting the optional `"writable": false` key in a tag's
`TagConfig` makes the node **read-only** at the driver seam. The six typed tag editors
(Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS) expose this as a **Writable** checkbox
(checked by default); it can also be hand-authored in the raw-JSON textarea.
@@ -338,7 +425,7 @@ An equipment tag is **writable by default** — the materialised OPC UA node is
The key is honoured by Modbus, S7, AB CIP, AB Legacy, and TwinCAT. **FOCAS forces
read-only regardless** — FOCAS PMC writes are unsupported in the wire client, so its
editor shows the checkbox disabled/unchecked and any authored `writable:true` is ignored
(and flagged at the deploy gate below). Node-level authz (LDAP `WriteOperate`) remains the
(and flagged at the deploy gate below). Node-level authz (`WriteOperate` via the API-key principal's grants, not LDAP) remains the
effective write gate above this flag.
## Deploy-time TagConfig validation (`Deployment:TagConfigValidationMode`)
+8 -40
View File
@@ -60,9 +60,7 @@ Parses the script source with `CSharpSyntaxTree.ParseText` (script kind), walks
## Virtual tag engine (`Core.VirtualTags`)
### `VirtualTagDefinition`
One row per operator-authored tag. Fields: `Path` (UNS browse path; also the engine's internal id), `DataType` (`DriverDataType` enum; the evaluator coerces the script's return value to this and mismatch surfaces as `BadTypeMismatch`), `ScriptSource` (Roslyn C# script text), `ChangeTriggered` (re-evaluate on any input delta), `TimerInterval` (optional periodic cadence; null disables), `Historize` (route every evaluation result to `IHistoryWriter`). Change-trigger and timer are independent — a tag can be either, both, or neither.
> **Retired (#603):** the standalone cascade engine (`VirtualTagEngine`, `VirtualTagSource`, `VirtualTagDefinition`, `TimerTriggerScheduler`) had no production consumer and was deleted; virtual tags run through `VirtualTagHostActor` / `VirtualTagActor` + `RoslynVirtualTagEvaluator`. `Core.VirtualTags` now holds `DependencyGraph` (deploy-gate cycle check), `ITagUpstreamSource` and `IHistoryWriter`. Descriptions below of the engine's `Load`/cascade/timer behaviour are historical.
### `VirtualTagContext`
@@ -77,56 +75,30 @@ Directed graph of tag paths. Edges run from a virtual tag to each path it reads.
Cycle detection uses an **iterative** Tarjan's SCC implementation (no recursion, deep graphs cannot stack-overflow). Cycles of length > 1 and self-loops both reject; leaf references cannot form cycles with internal nodes.
### `VirtualTagEngine` lifecycle
- **`Load(definitions)`** — clears prior state, compiles every script through `DependencyExtractor.Extract` + `ScriptEvaluator.Compile` (wrapped in `TimedScriptEvaluator`), registers each in `_tags` + `_graph`, runs `TopologicalSort` (cycle check), then for every upstream (non-virtual) path subscribes via `ITagUpstreamSource.SubscribeTag` and seeds `_valueCache` with `ReadTag`. Throws `InvalidOperationException` aggregating every compile failure at once so operators see the whole set; throws `DependencyCycleException` on cycles. Re-entrant — supports config-publish reloads by disposing the prior upstream subscriptions first.
- **`EvaluateAllAsync(ct)`** — evaluates every tag once in topological order. Called at startup so virtual tags have a defined initial value before subscriptions start.
- **`EvaluateOneAsync(path, ct)`** — single-tag evaluation. Entry point for `TimerTriggerScheduler` + tests.
- **`Read(path)`** — synchronous last-known-value lookup from `_valueCache`. Returns `BadNodeIdUnknown`-quality for unregistered paths.
- **`Subscribe(path, observer)`** — register a change observer; returns `IDisposable`. Does **not** emit a seed value.
- **`OnUpstreamChange(path, value)`** (internal, wired from the upstream subscription) — updates cache, notifies observers, launches `CascadeAsync` fire-and-forget so the driver's dispatcher isn't blocked.
Evaluations are **serial across all tags** — `_evalGate` is a `SemaphoreSlim(1, 1)` held around every `EvaluateInternalAsync`. Parallelism is deferred (Phase 7 plan decision #19). Rationale: serial execution preserves the "earlier topological nodes computed before later dependents" invariant when two cascades race. Per-tag error isolation: a script exception or timeout sets that tag's quality to `BadInternalError` and logs a structured error; other tags keep evaluating. `OperationCanceledException` is re-thrown (shutdown path).
Result coercion: `CoerceResult` maps the script's return value to the declared `DriverDataType` via `Convert.ToXxx`. Coercion failure returns null which the outer pipeline maps to `BadInternalError`; `BadTypeMismatch` is documented in the definition shape (`VirtualTagDefinition.DataType` doc) rather than emitted distinctly today.
`IHistoryWriter.Record` fires per evaluation when `Historize = true`. The default `NullHistoryWriter` drops silently.
### `TimerTriggerScheduler`
Groups `VirtualTagDefinition`s by `TimerInterval`, one `System.Threading.Timer` per unique interval. Each tick evaluates the group's paths serially via `VirtualTagEngine.EvaluateOneAsync`. Errors per-tag log and continue. `Dispose()` cancels an internal `CancellationTokenSource` and disposes every timer. Independent of the change-trigger path — a tag with both triggers fires from both scheduling sources.
### `ITagUpstreamSource`
What the engine pulls driver-tag values from. Reads are **synchronous** because user scripts call `ctx.GetTag(path)` inline — a blocking wire call per evaluation would kill throughput. Implementations are expected to serve from a last-known-value cache populated by subscription callbacks. The server's production implementation is `CachedTagUpstreamSource` (see Composition below).
What a value consumer (scripted alarms) pulls driver-tag values from. Reads are **synchronous** because user scripts call `ctx.GetTag(path)` inline — a blocking wire call per evaluation would kill throughput. Implementations are expected to serve from a last-known-value cache populated by subscription callbacks. The server's production implementation is `CachedTagUpstreamSource` (see Composition below).
### `IHistoryWriter`
Fire-and-forget sink for evaluation results when `VirtualTagDefinition.Historize = true`. Implementations must queue internally and drain on their own cadence — a slow historian must not block script evaluation. `NullHistoryWriter.Instance` is the no-op default. Scripted-alarm emissions flow through `Core.AlarmHistorian` via `HistorianAdapterActor` → the registered `IAlarmHistorianSink` (a separate concern; see [AlarmTracking.md](AlarmTracking.md)). *(The `Phase7EngineComposer.RouteToHistorianAsync` this used to name is gone — not renamed; the routing moved into the actor.)*
Fire-and-forget sink for evaluation results when a virtual tag is `Historize`d. Implementations must queue internally and drain on their own cadence — a slow historian must not block script evaluation. `NullHistoryWriter.Instance` is the no-op default. Scripted-alarm emissions flow through `Core.AlarmHistorian` via `HistorianAdapterActor` → the registered `IAlarmHistorianSink` (a separate concern; see [AlarmTracking.md](AlarmTracking.md)). *(The `Phase7EngineComposer.RouteToHistorianAsync` this used to name is gone — not renamed; the routing moved into the actor.)*
**Equipment-namespace path (H5).** The `Historize` flag is threaded end-to-end on the equipment path: `VirtualTag.Historize` → composer + artifact-decode (byte-parity) → `EquipmentVirtualTagPlan.Historize` → `VirtualTagHostActor`, which calls `IHistoryWriter.Record(nodeId, snapshot)` for every historized result (in addition to publishing the live value). The writer is injectable via DI — `DriverHostActor` resolves `IHistoryWriter` (`TryAddSingleton`, `NullHistoryWriter` default) and threads it into `VirtualTagHostActor`. **This `IHistoryWriter` seam still ships no durable binding** (`NullHistoryWriter` default). Durable continuous historization of driver/virtual values is now handled by the separate `ContinuousHistorizationRecorder` (it taps the dependency-mux value fan-out → a crash-safe FasterLog outbox → the HistorianGateway's `WriteLiveValues` path; see [Historian.md](Historian.md)), not through this seam. A deployment can still bind a custom `IHistoryWriter` via DI.
## Dispatch integration
Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md) Option B, there is a single `GenericDriverNodeManager`. `VirtualTagSource` implements `IReadable` + `ISubscribable` over a `VirtualTagEngine`:
- `ReadAsync` fans each path through `engine.Read(...)`.
- `SubscribeAsync` calls `engine.Subscribe` per path and forwards each engine observer callback as an `OnDataChange` event; emits an initial-data callback per OPC UA convention.
- `UnsubscribeAsync` disposes every per-path engine subscription it holds.
- **`IWritable` is deliberately not implemented.** Virtual-tag nodes are not client-writable because `OtOpcUaNodeManager.EnsureVariable` materialises every SDK variable with `AccessLevel = AccessLevels.CurrentRead`; the SDK base `CustomNodeManager2.Write` returns `BadNotWritable` for read-only nodes and v2 has no client-write dispatch path. Scripts are the only write path via `ctx.SetVirtualTag`.
`NodeSourceKind` on each `DriverAttributeInfo` (set by `EquipmentNodeWalker` at address-space build time) drives which backend handles a read. See [ReadWriteOperations.md](ReadWriteOperations.md) and [v1/Subscriptions.md](v1/Subscriptions.md) for the broader dispatch framing.
Virtual-tag nodes are materialised by `AddressSpaceApplier` and fed by `VirtualTagActor` publishes; they are not client-writable (`AccessLevel = CurrentRead`), scripts being the only write path via `ctx.SetVirtualTag`. See [ReadWriteOperations.md](ReadWriteOperations.md).
## Upstream reads + history
`ITagUpstreamSource` and `IHistoryWriter` are the two ports the engine requires from its host. Both live in `Core.VirtualTags`. In the v2 actor system:
`ITagUpstreamSource` and `IHistoryWriter` are the two ports value consumers require from their host. Both live in `Core.VirtualTags`. In the v2 actor system:
- **Upstream-tag feed.** `DependencyMuxActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs`) routes `DriverInstanceActor.AttributeValuePublished` events to the `VirtualTagActor` instances that declared interest in those tag refs. Each `VirtualTagActor` holds the in-memory per-tag dependency map; the `IVirtualTagEvaluator` (`RoslynVirtualTagEvaluator`) receives the dependency snapshot synchronously on the actor message thread. Reads of never-pushed dependency refs return `null` values in the dependency snapshot.
- **`IHistoryWriter`** — the equipment-namespace path threads `Historize` end-to-end and `VirtualTagHostActor` invokes the injected writer on historized results (H5); the writer is resolved through `DriverHostActor` DI with a `NullHistoryWriter` default. The standalone `VirtualTagEngine` likewise receives `NullHistoryWriter` by default. No *durable* writer ships on this seam — durable continuous historization now flows through the separate `ContinuousHistorizationRecorder` → HistorianGateway `WriteLiveValues` path (see the `IHistoryWriter` section above and [Historian.md](Historian.md)).
- **`IHistoryWriter`** — the equipment-namespace path threads `Historize` end-to-end and `VirtualTagHostActor` invokes the injected writer on historized results (H5); the writer is resolved through `DriverHostActor` DI with a `NullHistoryWriter` default. No *durable* writer ships on this seam — durable continuous historization now flows through the separate `ContinuousHistorizationRecorder` → HistorianGateway `WriteLiveValues` path (see the `IHistoryWriter` section above and [Historian.md](Historian.md)).
## Composition
`AddressSpaceComposer` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs`) is a pure static function that flattens config-DB entities into an `AddressSpaceComposition` value (UNS topology + raw subtree + driver-instance plans + scripted-alarm plans). `AddressSpaceApplier` applies that result into the OPC UA SDK node manager. Neither class has knowledge of `VirtualTagEngine` or `ScriptedAlarmEngine`.
`AddressSpaceComposer` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs`) is a pure static function that flattens config-DB entities into an `AddressSpaceComposition` value (UNS topology + raw subtree + driver-instance plans + scripted-alarm plans). `AddressSpaceApplier` applies that result into the OPC UA SDK node manager. Neither class has knowledge of `ScriptedAlarmEngine`.
In the v2 actor system, virtual-tag engine composition is owned by the driver-role host actor tree:
@@ -134,7 +106,7 @@ In the v2 actor system, virtual-tag engine composition is owned by the driver-ro
- `RoslynVirtualTagEvaluator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs`) is injected into each `VirtualTagActor` as its `IVirtualTagEvaluator`. It holds a per-source `CompiledScriptCache` keyed by script source and compiles on first use.
- `DependencyMuxActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs`) receives every `DriverInstanceActor.AttributeValuePublished` event and routes it to the `VirtualTagActor` instances that registered interest in that tag ref.
`VirtualTagEngine`, `VirtualTagSource`, `TimerTriggerScheduler`, and `ITagUpstreamSource` are available as standalone Core.VirtualTags primitives and remain the correct composition path for non-actor deployments (integration tests, future standalone runtimes).
`ITagUpstreamSource` and `IHistoryWriter` remain available as `Core.VirtualTags` ports.
## Key source files
@@ -148,14 +120,10 @@ In the v2 actor system, virtual-tag engine composition is owned by the driver-ro
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs` — static `ctx.GetTag` / `ctx.SetVirtualTag` inference
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLoggerFactory.cs` — per-script Serilog logger
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLogCompanionSink.cs` — error mirror to main log
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagDefinition.cs` — per-tag config record
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagContext.cs` — evaluation-scoped `ctx`
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/DependencyGraph.cs` — Kahn topo-sort + iterative Tarjan SCC
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs` — load / evaluate / cascade pipeline
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/TimerTriggerScheduler.cs` — periodic re-evaluation
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ITagUpstreamSource.cs` — driver-tag read + subscribe port
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/IHistoryWriter.cs` — historize sink port + `NullHistoryWriter`
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs` — `IReadable` + `ISubscribable` adapter
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs` — actor that receives `DependencyValueChanged` from the mux and invokes `IVirtualTagEvaluator` per expression
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs` — routes `DriverInstanceActor.AttributeValuePublished` to interested `VirtualTagActor` subscribers
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs` — production `IVirtualTagEvaluator` binding; holds a per-source `CompiledScriptCache`
+80 -8
View File
@@ -20,6 +20,7 @@ coverage map, see [AbServer-Test-Fixture.md](AbServer-Test-Fixture.md).
| Project | Role |
|---------|------|
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/` | The driver — `AbCipDriver`, the libplctag runtime/enumerator/template-reader wrappers, the UDT read planner + template decoders, the host-address parser, and the ALMD alarm projection. |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared/` | Project-less shared source (`LibplctagOrphanRelease.cs`, #578/#580) compiled into both `Driver.AbCip` and `Driver.AbLegacy`. The `AbStatus` → OPC UA table is shared in `Driver.Ab.Transport` (`AbOpcUaStatusMap`). |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/` | `AbCipDriverOptions`, `AbCipDeviceOptions`, `AbCipTagDefinition` / `AbCipStructureMember`, and the `AbCipDataType` / `AbCipPlcFamily` enums bound from the driver's `DriverConfig` JSON. |
Per family the `AbCipPlcFamilyProfile` (`PlcFamilies/AbCipPlcFamilyProfile.cs`)
@@ -75,9 +76,10 @@ the driver's `DriverConfig` JSON. Key fields:
- **`Devices`** — one `AbCipDeviceOptions` per PLC (`HostAddress`, `PlcFamily`, optional `DeviceName`, per-device `AllowPacking` / `ConnectionSize` overrides).
- **`Tags`** — pre-declared `AbCipTagDefinition` list; `Members` for UDT fan-out, `SafetyTag` for GuardLogix safety-partition tags.
- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeTagPath`.
- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeTagPath`. A driver created from the AdminUI starts with the probe **off** (an enabled probe with no tag path only logs a warning); an absent `probe` key in a stored config still means enabled to the driver.
- AdminUI driver forms (AbCip/AbLegacy) overlay only the keys they edit onto the stored JSON, so unmodelled keys survive a save (#576). They also write durations as the integer-millisecond keys the factory binds (`timeoutMs`, `alarmPollIntervalMs`, `probe.intervalMs`, `probe.timeoutMs`); a legacy TimeSpan-string key (`timeout`: "00:00:05") is loaded and migrated on save.
- **`Transport`** — absent (default ⇒ `Native`), `Native`, or `Libplctag` (fallback); see [Transport](#transport-transport-option).
- **Discovery** — `EnableControllerBrowse` (`@tags` walk) and `EnableDeclarationOnlyUdtGrouping` (whole-UDT read fast path).
- **Discovery** — `EnableControllerBrowse` (`@tags` walk), `EnableDeclarationOnlyUdtGrouping` (whole-UDT read fast path) and `browsePageDelayMs` (native browse pacing, see [Native browse](#native-browse-paging-pacing-and-template-leases)).
- **Alarms** — `EnableAlarmProjection` + `AlarmPollInterval` for the ALMD projection.
Full per-field descriptions live in `AbCipDriverOptions.cs`. The JSON skeleton is
@@ -157,8 +159,29 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
- **One CIP connection and one request scheduler per device** (`DeviceSessionPool`), shared by reads,
writes, browse and template reads. The Multiple Service Packet scheduler coalesces requests with one
request in flight; large reads (listing pages, Read Template) are sent unpacked and exclusive.
**Packing budgets the expected reply as well as the request (#582):** each queued request declares an
upper bound on its reply (`expectedReplyBytes`; reads: elements x element width, STRING = 88 bytes per
element, BOOL counted as 4) and a batch is closed when the summed MSP reply (`6 + 2n + sum`) would exceed
the connection size. A read whose size cannot be bounded (structure / unknown authored type) is sent
alone; writes and status-only replies count their 4-byte header. Pinned by
`RequestSchedulerTests.Mixed_scalar_string_and_array_replies_are_packed_within_the_reply_budget`.
⚠️ RESIDUAL: mixed STRING/array packing has not been exercised against a real controller (the real-press
gate predates the budget); the budget is unit-tested against the scheduler and fake channel only.
- **Fragmented-read integrity (#567):** `Read Tag Fragmented` replies are compared against the first reply;
a changed type code *or* structure handle mid-read fails the read with `BadReply` rather than stitching
two types into one value.
- **Forward Open throttle:** at most one attempt in flight, at least 1 s between starts, bounded by the
open budget.
- **A target-closed CIP connection is recycled at once (#566).** When a connected reply says the controller
no longer holds the class-3 connection while TCP stays up — general status `0x01` with extended `0x0107`
(target connection not found) or `0x0203` (connection timed out), e.g. after a controller restart or a
connection timeout — the session is closed (UnRegisterSession; no Forward Close, there is nothing to close)
and the next request reopens through the throttle. The request that got the reply fails
`BadCommunicationError` (transport `BadConnection`); a keep-alive that gets it recycles the connection
before any request fails. Every other status — tag-level errors, other `0x01` extended codes — is left to
the request. ⚠️ RESIDUAL: which of the two codes a real Logix controller returns for a forgotten
connection (and whether it answers at all rather than dropping TCP) is unobserved; the check is correct
whichever it does — a silent target still faults the session through the exchange timeout.
- `ConnectionSize` / `Timeout` / `AllowPacking` are honoured per pooled connection, and the **first**
driver/device to open a given device key wins — including a Test Connect probe on a fused `admin,driver`
node (its connection carries the probe timeout). A later opener with different options is ignored and
@@ -181,8 +204,33 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
an Unsupported refusal (final review I4): the tag's handle is kept (no re-create, no reconnect), driver
health is unchanged (no `Degraded`, nothing fed to the comms-loss watchdog), and the Warning is logged
**once per tag** per driver instance, not every poll. The same applies to the libplctag-path width check.
- Malformed tag names put the device into backoff, as under libplctag (#570). STRING capacity includes the
alignment pad (84, not 82) (#570).
The same holds when the controller refuses the **existence read itself** with a type error (CIP
`0xFF`/`0x2107`): initialization completes with the status instead of throwing, so the runtime is not
disposed and re-created (nor, for a sole lease holder, re-Forward-Opened) on every poll, and the device's
connect backoff is not opened (#570). Micro800 `BYTE`/`WORD`/`LWORD` (`0xD1`/`0xD2`/`0xD4`) are checked as
integers of their width: a `REAL`/`LREAL` or a narrower integer authored over an `LWORD` is
`BadTypeMismatch`, not decoded garbage (#570).
- **A malformed tag name is a per-tag configuration error** (#570). A `TagPath` that does not parse, or a
name the CIP symbolic encoder refuses (non-ASCII, an empty member such as `A..B`, a member longer than
255 characters, an unterminated or non-numeric subscript), reads and writes **`BadConfigurationError`**
with one Warning per tag. Nothing reaches the device, so it never opens the device's connect backoff and
never degrades driver health — healthy tags on the same device keep reading. (libplctag still reports its
own `ErrorBadParam` for a name it rejects.) A malformed **probe** tag does not feed the backoff either.
- **STRING capacity:** the built-in Logix `STRING` is 88 bytes on the wire — `LEN` + `DATA[82]` + 2 bytes of
alignment padding — so a write holds at most **82** characters (it used to allow 84) and a read never
returns the padding as characters. The write still sends the whole 88-byte structure, padding zeroed, as
libplctag does. A user string type of any other size keeps the buffer-derived capacity (`size − 4`): the
read alone cannot tell its `DATA` length from up to 3 bytes of padding, so a write may accept up to 3
characters more than the type holds (#570).
- **Releasing a tag never blocks** (#579). Disposing a native tag runtime — the driver does it under the
tag's lock on an evict, a reinitialize or a shutdown — releases its connection lease in the background
(`ConnectionLease.ReleaseInBackground`, the same shape the native AbLegacy runtime uses). It used to wait
up to 2 s for the last lease's Forward Close. Nothing is lost by not waiting: the pool records the close
synchronously, so a re-create the next instant still does not open a second session until that Forward
Close + UnRegisterSession have finished, and the close is bounded by the connection's own budgets (the
open budget for an open still in flight, 1 s for the Forward Close), so a connection is always released.
Offline soak: `NativeAbCipLeaseReleaseTests` (25 create/release cycles, no leaked connection); a soak
against `ab_server` has not been run.
- **A refused operation is not a comms fault.** Any operation a runtime refuses as Unsupported (in AbCip, e.g.
a native write whose type cannot be determined) reaches the client as `BadNotSupported`, keeps the tag
handle, and leaves driver health unchanged; only real transport failures evict and degrade.
@@ -194,9 +242,12 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
(2026-10-08) and nothing else. Results:
[native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md).
- **libplctag cannot be deleted** until a bench controller allows write verification.
- Wide-struct fragmented writes (8-byte alignment) are unit-tested only, never proven on a controller (#567).
- Native browse: listing pages are unpaced, and template reads outside an enumeration open a connection per
call (#572).
- ⚠️ RESIDUAL (#567): wide-struct **fragmented writes** (8-byte alignment) are unit-tested only. Production
presses are read-only and there is no bench controller, so this cannot be proven; it stays open until a
bench controller exists.
- Native browse (#572, resolved in code): listing paging no longer assumes ascending instance order, pages
are paced, and a browse's template reads share one lease. One residual: whether a real controller ever
lists a page out of order is unobserved — the reader is now correct either way. `browsePageDelayMs` is an AdminUI driver-form field ("Browse page delay (ms)", #596); keys the form does not model are preserved on save (#576).
- Micro800, routed and `logixpccc` paths are covered by `ab_server` only. The Micro800 profile passes
natively. The AbLegacy default excludes `logixpccc` (and PLC-5).
- Follow-up issues for this workstream: #564 to #577.
@@ -241,6 +292,27 @@ integration fixture is down during normal dev (Docker host fixture currently off
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
and the UI authoring flow.
## Native browse: paging, pacing and template leases
Native symbol browse (`transport` = `Native`; libplctag pages inside its own library and is unaffected):
- **Paging continues past the highest instance seen**, not the last entry of a page. The Symbol Object
listing is requested from `start`; the next request starts at `max(instance in the reply) + 1`, so the
order a controller lists a page in does not matter. A "more" reply whose highest instance is still below
its `start` (or equals `uint.MaxValue`) made no progress and fails with `BadReply` ("made no progress")
rather than looping against the controller.
- **Pages are paced.** `browsePageDelayMs` (driver config, default **5**, `0` = back-to-back, negative =
`0`) pauses before every page after the first. 5 ms is small against a page's own round-trip, so a
50k-symbol browse gains a few seconds, but it breaks the back-to-back burst (one request in flight, roughly
a Studio 5000 online browse) into slices the controller's message processing can interleave with. Raise it
for a controller that is tight on CPU; the pause applies per listing (the controller listing and each
program listing).
- **Template reads in a browse share one lease.** `DiscoverAsync` reads the UDT templates (and nested
templates) of a device through a single batch reader (`IAbCipTemplateReaderFactory.CreateBatch`); the
native batch reader holds one device lease from its first read until the browse of that device ends,
instead of leasing per template. `FetchUdtShapeAsync` outside a discovery run is unchanged: one
self-contained lease per call.
## Controller-discovered UDT member variables
When `EnableControllerBrowse` is set, the driver walks the controller's `@tags` symbol table and
@@ -312,7 +384,7 @@ UDT-typed tags found in the walk.
- **Native heap is invisible to the GC.** libplctag's native `Tag` heap does not show up in CLR allocation figures. Watch whole-process RSS, and use `ReinitializeAsync` (tears down + re-creates every device's libplctag handles) as the remediation for native-heap growth. (There is no per-driver footprint number to consult: `IDriver.GetMemoryFootprint()` was deleted as unconsumed in Gitea #525.)
- **Handle eviction on failure** — a non-zero libplctag status or a transport exception evicts the cached tag runtime so the next read/write re-creates a fresh handle, mirroring the probe loop's recreate-on-failure behaviour.
- **Read statuses carry the libplctag cause** — a tag the controller does not have reads `BadNodeIdUnknown` (not `BadCommunicationError`), and it does **not** open the device's connect-backoff window (the controller answered; before this, one typo throttled every sibling tag on the device). A genuine connection failure still opens it.
- **Width check on scalar reads** — libplctag never checks the CIP type the controller returns. A scalar atomic read whose buffer is not the authored type's width (e.g. a REAL authored as INT — the legacy Kepware export does exactly this for some press tags) reads `BadTypeMismatch` instead of Good garbage. Same-width mistakes (DINT vs REAL) are undetectable under libplctag; the `Native` transport reports them as `BadTypeMismatch` (see [Native transport behaviour](#native-transport-behaviour)).
- **Width check on scalar reads** — libplctag never checks the CIP type the controller returns. A scalar atomic read whose buffer is not the authored type's width (e.g. a REAL authored as INT — the legacy Kepware export does exactly this for some press tags) reads `BadTypeMismatch` instead of Good garbage. Same-width mistakes (DINT vs REAL) are undetectable under libplctag; the `Native` transport reports them as `BadTypeMismatch` (see [Native transport behaviour](#native-transport-behaviour)). The width-mismatch refusal is pinned by `AbCipTypeMismatchTests.A_width_mismatched_read_keeps_the_handle_leaves_health_alone_and_warns_once`: `BadTypeMismatch`, handle kept, no `Degraded`, one Warning per tag. *Benign edge (#585):* a driver left `Degraded` by an earlier outage whose only remaining tags are mis-typed gets one extra comms-loss-watchdog reconnect ~90 s later before settling, because a type refusal deliberately writes no health and so never clears the stale `Degraded`.
- **Module-defined I/O tags** — `Local:1:I.Data`, `ABB_OmniCore:I.ConnectionFaulted` and similar colon-bearing base names are valid tag paths (controller scope only).
- **User string arrays** — `STR0016[N]` and similar arrays stride by the actual element size (read buffer ÷ count); libplctag.NET 1.5.2 leaves `ElementSize` unset after a read.
- **Declaration-only UDT grouping is a footgun unless verified** — only enable `EnableDeclarationOnlyUdtGrouping` when every UDT's member declaration order has been hand-verified against the controller's compiled layout.
+3 -2
View File
@@ -36,8 +36,9 @@ supplies a `FakeAbLegacyTag`.
The `AbLegacy.IntegrationTests` suites now run **once per transport** (`TransportMatrix`: `Libplctag` and
`Native`). Set `AB_LEGACY_COMPOSE_PROFILE` to the running profile. On the 2026-10-07 run native passed
everything libplctag passed on `slc500`, `micrologix` and `plc5` (the PLC-5 test reads N7 only, so the PLC-5
refusal paths and status-bit writes are not exercised by it). Native PCCC is **simulator-only**. Write-capable
everything libplctag passed on `slc500`, `micrologix` and `plc5` (the smoke tests read N7 only; the PLC-5
refusal paths and status-bit write refusals are exercised by `AbLegacyUnsupportedPathTests`, native only, #593 — the
refusals are the driver's own, raised before the wire, so each test carries a positive-control read). Native PCCC is **simulator-only**. Write-capable
tests (the SLC500 N7:5 round trip) require `AB_SERVER_ALLOW_WRITES=1`. Golden frames:
`scripts/ab-capture/capture.sh`, fixtures under
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/`. Evidence:
+58 -4
View File
@@ -67,6 +67,9 @@ pieces for driver-side routing (e.g. deciding a bit needs read-modify-write):
| `ST9:0` | String file (82-byte fixed-length) |
| `T4:0.ACC` / `C5:0.PRE` | Timer / counter sub-element |
| `I:0/0` / `O:1/2` / `S:1` | Input / output / status system files (no file number) |
| `I:017/07` | **PLC-5 only:** `I:`/`O:` word and bit indices are **octal** (#244); a digit 8/9 is a parse error |
| `RTC:0.HR` / `HSC:0.ACC` | **MicroLogix only:** function files RTC, HSC, DLS, MMI, PTO, PWM, STI, EII, IOS, BHI (#245) |
| `N7:[N7:0]` / `N[N7:0]:5` | Indirect (index word read from the PLC) — parsed, **not supported at runtime** (#247) |
`AbLegacyDataType` covers the corresponding PCCC types: `Bit`, `Int` (N), `Long`
(L), `Float` (F), `AnalogInt` (A), `String` (ST), and the `TimerElement` /
@@ -85,6 +88,42 @@ this is an inherent limitation of any software-level RMW. Input-image (`I`) bit
writes are forwarded to the PLC as-is; the device drives the input image from
physical inputs and may reject the write.
### Family-specific address forms (#244 / #245 / #247)
`AbLegacyAddress.TryParse(text, family)` is family-aware; the driver passes the device's `PlcFamily`
(the one-argument overload is the family-neutral grammar: decimal, no function files).
- **PLC-5 octal I/O (#244).** RSLogix 5 shows `I:`/`O:` word and bit indices in octal, so `I:017/07` is word 15,
bit 7 and `I:017/17` is bit 15. Only `I`/`O` on PLC-5 (`OctalIoAddressing` on the family profile) — SLC,
MicroLogix and Logix-PCCC stay decimal, and PLC-5 `N`/`B`/... files stay decimal. The record stores decimal and
`ToLibplctagName()` **emits decimal**, so the libplctag `name=` string and the string the native transport
re-parses carry identical numbers. This is not a double conversion: libplctag's `pccc.c`
(`parse_pccc_elem_num` / `parse_pccc_bit_num`) parses decimal numerals only (a FIXME there says octal is
unsupported), so the driver must convert. Pinned by `Plc5_io_indices_are_octal` (`I:017/17` -> `I:15/15`).
The connectivity-probe address is normalised the same way.
- ⚠️ **Behaviour change / migration.** Before this change a PLC-5 `I:`/`O:` address was passed to libplctag as
written, i.e. read as **decimal**. An address copied from RSLogix in octal (e.g. `I:017`) therefore read the
wrong word, and an authored decimal such as `I:1/12` now means bit 10 (octal 12). A previously "working"
address containing the digits 8 or 9 (`I:018`, `O:1/9`) is no longer valid on PLC-5. Re-author any PLC-5
I/O tags in RSLogix octal. Such a tag is refused (see below), never silently misread.
- **A malformed / family-invalid address** (also: a function-file letter off MicroLogix) reads and writes
`BadNodeIdUnknown` for that tag only, creates no runtime, leaves driver health alone, and logs **one**
Warning per tag. The same check (`AbLegacyAddress.Validate`) runs in the AdminUI tag editor (family-independent:
rejects only what *no* family accepts) and the deploy gate (`EquipmentTagConfigInspector`, using the device's
`DeviceConfig.PlcFamily`, absent = SLC 500, unrecognised = unknown) — so `I:018` on a PLC-5 device warns at
deploy (an error under `TagConfigValidationMode.Error`), and the editor is never stricter than the gate.
- **MicroLogix function files (#245).** `RTC`, `HSC`, `DLS`, `MMI`, `PTO`, `PWM`, `STI`, `EII`, `IOS`, `BHI`
(single fixed instance — no file number — with a `.SUBELEMENT`; no bit index) parse on MicroLogix devices only.
`AbLegacyFunctionFile.SubElementType` carries the sub-element catalogue (flags → Boolean, words → Int32; unknown
members default to Int32). ⚠️ **Wire-unverified on both transports:** there is no capture and no simulator
coverage; the libplctag path passes the name through and the native transport has no file-type code for them, so
it refuses the tag as `Unsupported` (`BadNotSupported` to the client; see Wire evidence).
- **Indirect addressing (#247).** `N7:[N7:0]` (word from another address) and `N[N7:0]:5` (file from another
address) parse, one level deep (the inner address must be direct, and carries the family's octal rule).
**Runtime resolution is not implemented** (it needs a pre-read of the index word and per-resolved-address
runtimes): reads and writes of an indirect tag answer `BadNotSupported` before any runtime is created, on both
transports.
## Configuration
`AbLegacyDriverOptions` (`Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs`)
@@ -142,14 +181,25 @@ Native routing: SLC500/MicroLogix/PLC-5 are direct; `logixpccc` uses Unconnected
No real SLC, MicroLogix or PLC-5 has been exercised. Opt in per driver after checking it against the real
controller.
- ⚠️ **Status-bit writes are refused** — `T4:0.DN`, `T4:0.TT`, `T4:0.EN`, `C5:0.CU`, `C5:0.DN`, `R6:0.EN`
and the rest return **`BadNotSupported`**; nothing is written to the PLC; the handle is kept and health is
unchanged. libplctag performs a masked bit write here; a native read-modify-write of the control word would
race the PLC updating TT/DN. Reads of status bits are unaffected.
and the rest return **`BadNotSupported`** whatever the tag's authored data type (the refusal keys on the
address); nothing is written to the PLC; health is unchanged. The driver answers the refusal **before it
creates the tag's runtime**, so a first-touch write sends nothing at all — not even the init read, which
against a controller without that file used to surface as `BadCommunicationError` + Degraded health (#593
follow-up). PLC-5 writes are refused the same way. libplctag performs a masked bit write here; a native
read-modify-write of the control word would race the PLC updating TT/DN. Reads of status bits are unaffected.
- ⚠️ **On libplctag (the default) a status-bit write is NOT refused** — it goes to the controller as a write to the
timer/counter/control element (libplctag's bit-tag write; not exercised on a wire — `ab_server` has no T/C/R
files). Nothing in authoring prevents it: the address is free text,
any `dataType` may be chosen (a status bit is naturally authored `Bit`), and `writable` defaults to `true`.
Author status-bit tags with `"writable": false` unless writing them is genuinely intended.
- **PLC-5 native is N-file reads only**; every other PLC-5 operation is refused with `BadNotSupported`.
`LogixPccc` framing is unverified. Explicit Native on either logs a Warning at init.
- **ST (string) byte order is unverified** (no capture); string values may be byte-swapped.
- Unlike AbCip, the native AbLegacy runtime has **no element-type check**, so a mis-authored same-width
type reads as reinterpreted data (as on libplctag), not `BadTypeMismatch`.
- Releasing a native tag (evict, reinitialize, shutdown) never blocks on the connection close: the lease
is released in the background (`ConnectionLease.ReleaseInBackground`), the same shape as native AbCip
(#579). A re-create cannot overlap the close, because the pool holds the successor connection behind it.
### Wire evidence (native PCCC)
@@ -161,6 +211,7 @@ Everything below is against the `ab_server` simulator; no real PCCC controller h
| N-file write (`N7:5`) | capture `slc500-write-n7` + native `AbLegacyReadSmokeTests` write/read round trip (SLC 500) |
| F-file read (`F8`) | one capture, `slc500-read-f8` (codec parity only; no native F write on a wire) |
| S (incl. the default probe `S:0`), B, T, C, R, L, ST, A, O, I | **none** — encoded from the PCCC file-type table |
| MicroLogix function files (RTC/HSC/...), PLC-5 octal I/O, indirect addressing | **none** — parsed only; native refuses function files and indirect names as Unsupported |
| T/C/R sub-elements (`.PRE`/`.ACC`/`.LEN`/`.POS`), 6-byte T/C/R element layout | **none** |
### Native known gaps
@@ -172,7 +223,10 @@ Everything below is against the `ab_server` simulator; no real PCCC controller h
wire and reach the client as `BadNotSupported` (handle kept, health unchanged; #571 closed). Native is opt-in
for every family; an explicit Native on PLC-5 / `logixpccc` logs a Warning at init.
- Status-bit sub-element writes (e.g. `T4:0.DN`, `C5:0.DN`) are Unsupported natively (they would clobber the
control word); nothing goes on the wire. Sub-element arrays are Unsupported natively. L-file bit indexes
control word); nothing goes on the wire — the driver refuses before creating the tag's runtime, so not even an
init read is sent (`NativeAbLegacyTagRuntime.WriteRefusal` is the single predicate, asked through
`IAbLegacyTagFactory.WriteRefusal`). Pinned offline by `NativeAbLegacyRefusedWriteTests` and on `ab_server` by
`AbLegacyUnsupportedPathTests` (whose SLC500 fixture has no T/C/R files, which is what makes it discriminating). Sub-element arrays are Unsupported natively. L-file bit indexes
above 15 are allowed.
- **ST (string) file layout is uncaptured**, so the string byte order is unverified natively.
- Capture recipe for golden frames: `scripts/ab-capture/capture.sh` (see
+1 -1
View File
@@ -37,7 +37,7 @@ quirk. UDT / alarm / quirk behavior is verified only by unit tests with
Every integration test in `AbCip.IntegrationTests` now runs **once per transport** (`TransportMatrix`:
`Libplctag` and `Native`). Run one compose profile at a time and name it with `AB_SERVER_COMPOSE_PROFILE`
(all profiles bind `:44818`, so set it to the running profile). On the 2026-10-07 run native passed everything
(all profiles bind `:44818`, so set it to the running profile; **unset, only the default `controllogix` rows run** — ab_server does not self-identify its `--plc` mode, so crossing every family at one container failed the non-matching rows instead of skipping them, #594). `NativeLeaseReleaseSoakTests` is the ab_server soak of the background lease release (`AB_LEASE_SOAK_CYCLES`, default 100; read-only, skips without the fixture, #593). On the 2026-10-07 run native passed everything
libplctag passed on all seven profiles; on `micro800` libplctag failed on the empty-path defect described in
[AbCip.md](AbCip.md#known-gaps) and native passed. That defect is now fixed: a re-run of the `micro800`
smoke test passes on both transports. Evidence:
+2 -1
View File
@@ -121,7 +121,8 @@ Notes worth knowing before authoring against this:
## Error semantics
An evaluator failure publishes **Bad quality carrying the last-known value** — the value is not
zeroed, because a stale-but-flagged reading is more useful to an operator than a fabricated one. It
zeroed, so the node holds it server-side for the next Good re-publish and the `(value, status)` dedup. (Over the wire the
OPC UA SDK nulls the value of a Bad-status read, so a client sees only the Bad status — see `docs/DriverLifecycle.md`.) It
publishes:
- **once per Good→Bad transition**, not on every failed evaluation (a permanently broken script does
+4
View File
@@ -91,6 +91,10 @@ on.
| `MACRO:500` | Custom macro variable | System / user macro variable number |
| `FIXED:Identity/SeriesNumber` | Fixed-tree field | A path into the [fixed node tree](#fixed-node-tree) — read-only |
PMC letters are series-gated by `FocasCapabilityMatrix.PmcLetters`; the 16i accepts `X Y F G R D` (F/G are
the CNC-to-PMC / PMC-to-CNC handshake groups its ladders use, #265 — reads only, FOCAS stays read-only), while
`M C E A K T` remain 0i-F / 30i-family.
The first three name a location the driver asks the CNC for on demand.
`FIXED:` does not: it names a field of the per-device fixed-tree snapshot the
driver's own poll loop already maintains, so a `FIXED:` read issues no wire
+4
View File
@@ -113,3 +113,7 @@ The session-less alarm feed (`GatewayGalaxyAlarmFeed`) and alarm acknowledger (`
- **Replay gaps** (`galaxy.events.replay_gaps`): the gateway signals that events were evicted from its replay ring before we could read them. The driver responds by re-issuing `SubscribeBulk` for every tracked subscription — MXAccess delivers an initial value per item on subscribe, so re-subscribing *is* the re-snapshot the contract asks for. It does **not** restart the event pump (the stream carrying the sentinel is healthy) and it honours `Reconnect:ReplayOnSessionLost=false` by logging and standing down, on the grounds that an operator who delegated recovery to the gateway did not ask for a re-subscribe on a different trigger.
⚠️ This cannot currently fire: the sentinel is only emitted on a stream opened with `after_worker_sequence > 0`, and the driver opens with `0`, which the gateway treats as "fresh stream, not a resume". Enabling resume is a bigger change than it looks — the sequence is only meaningful **within one gateway session**, and the reconnect path recreates the session, so a remembered sequence replayed against a fresh session would set the server's live watermark *above* every event it is about to send, silencing the stream rather than recovering it.
- **Undecodable values** (`galaxy.events.undecodable`): a value kind this driver has no CLR representation for is published as **`BadTypeMismatch` with a null value**, and the first occurrence of each distinct kind is logged. It is *not* published as a null carrying the event's own quality: an MXAccess event can be Good-quality while the payload is one we cannot map, and a good null is indistinguishable from a genuine null reading — a client would consume a value it never received as a real one, with the failure visible only in a log. Non-zero here means the gateway is sending a wire type `MxValueDecoder` has no mapping for and those readings are being lost; the fix is a decoder mapping, not a quality change.
- **Duplicate `attributeRef` (#605 / 06-S-13)**: two authored RawPaths dialling the same Galaxy attribute are both dialled, but the reverse map is first-wins, so value fan-out and alarm `ConditionId` surface under the first RawPath only. The loser is logged at Warning and recorded in the skipped-tag ledger with the winner path in the reason.
- **Subscribe-once reads are bounded (#605 / 06-S-3)**: `ReadAsync` on the production path (SubscribeBulk, first event, Unsubscribe) carries an internal 30 s deadline in addition to the caller token; slots that never publish come back `BadTimeout`, so `CancellationToken.None` can no longer hang on a silent tag.
- **Supervisory advise is retried after a thrown failure (#605 / 06-S-12)**: the writer marks an item handle advised before the call; a thrown advise (not just a non-OK status) now unmarks it, fails the write closed and re-advises on the next write.
- **Array writes (#610)**: a Galaxy array tag is client-writable only for the `MxValueEncoder` array types (OPC UA `Boolean`/`Int32`/`Int64`/`Float`/`Double`/`String`/`DateTime`) **and only with a declared `arrayLength`** — with none the node is unbounded, and a short whole-array write would *resize* the attribute. The element type must match the node or be a numeric type that converts to it exactly — a numeric array of another element type is converted, checked, to the node's type in the node manager (an `Int32` list to an `Int64` array arrives as `long[]`), and any unrepresentable element refuses the write `BadTypeMismatch` inline. An array written to a **scalar** Galaxy node (a browse-committed array attribute without `isArray`) is still dispatched, as before #610. Separately, `MxValueEncoder` itself refuses `uint[]`/`ulong[]`: CLR array covariance let them match the `int[]`/`long[]` arms and be written bit-reinterpreted. Encoder-level only — Galaxy array writes have **no live gate** yet. See [Uns.md §Array writes](../Uns.md#array-writes-610), including the upgrade note.
+180 -30
View File
@@ -92,6 +92,7 @@ request/response handshake — is a different mechanism and remains deferred; se
| `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) |
| `IAlarmSource` | CONDITION observations off the same `/sample` pump → Part 9 transitions keyed by the alarm tag's RawPath | Opt-in per raw tag (`alarm` object) — see [CONDITION alarms](#condition-alarms-626) |
**`IReadable.ReadAsync` is never called by the running server.** The
production data plane is entirely `ISubscribable` — `DriverInstanceActor`
@@ -230,10 +231,37 @@ Other status codes an observation can surface:
|---|---|
| `BadTypeMismatch` (`0x80740000`) | The Agent's text isn't a value of the tag's coerced type at all |
| `BadOutOfRange` (`0x803C0000`) | The Agent reported a number the coerced type can't represent |
| `BadNotSupported` (`0x803D0000`) | A shape this build doesn't materialize — a `TIME_SERIES` vector, or a structured `DATA_SET`/`TABLE` observation (deferred to P1.5; see [Known limitations](#known-limitations)) |
| `BadDecodingError` (`0x80070000`) | A `TIME_SERIES` vector whose element count disagrees with the observation's own `sampleCount` (or the tag's fixed `ArrayDim`) — see [TIME_SERIES](#time_series-arrays-627) |
| `BadNotSupported` (`0x803D0000`) | A shape this build doesn't materialize — a structured `DATA_SET`/`TABLE` observation (decided: stays unsupported, #627), or an array tag declared as anything other than Float64 |
| `BadWaitingForInitialData` | Tag is authored but the Agent hasn't reported it yet |
| `BadNodeIdUnknown` | DataItem id is neither authored nor ever observed |
### TIME_SERIES arrays (#627)
A `SAMPLE` DataItem with `representation="TIME_SERIES"` is served as a
**variable-length `double[]`** (`ValueRank = 1`, no fixed `ArrayDimensions` —
a live Agent never declares `sampleCount` on the DataItem, see Known
limitations 4; a probe that does declare one yields a fixed `ArrayDim`).
Parsing: the observation text is split on any whitespace, each token parsed
invariant-culture. The result is **all-or-nothing** — one non-numeric /
non-finite token is `BadTypeMismatch`, a literal `UNAVAILABLE` token or an
empty body is `BadNoCommunication`, and a count that disagrees with the
observation's own `sampleCount` attribute (or a fixed `ArrayDim`) is
`BadDecodingError`. A partial vector is never served as Good. Only a
**Float64** array tag is served; any other declared element type stays
`BadNotSupported`. An authored tag needs `"isArray": true` and
`"dataType": "Float64"` in its TagConfig (the AdminUI editor shows an advisory
for any other array type; a browse-commit does not stamp `isArray`, so set it
in the Tag modal's array section).
**Sample rate is not exposed on the served node.** The observation's
`sampleRate` and `sampleCount` attributes are parsed (`MTConnectObservation`)
but the OPC UA node has no description/property slot through `DriverAttributeInfo`,
and a sibling property node would need a new address-space mechanism. Decision:
document it — the rate is a property of the Agent's DataItem (`sampleRate` in
`/probe`), read it there. **`DATA_SET` / `TABLE` stay `BadNotSupported`** (decision, #627): they are
key/value maps with no scalar or array OPC UA shape that preserves the keys.
### Subscribe — the `/sample` pump
One shared `/sample` long-poll stream per driver instance (the Agent streams
@@ -354,14 +382,96 @@ throwaway instance down. Each captured leaf's NodeId is the DataItem `id`,
committed directly as `TagConfig.FullName` on pick. See
[`docs/plans/2026-07-15-universal-discovery-browser-design.md`](../plans/2026-07-15-universal-discovery-browser-design.md).
## CONDITION modelling (v1: plain String)
## CONDITION alarms (#626)
Each `CONDITION` DataItem materializes as a `String` variable whose value is
the current state word — `Normal` / `Warning` / `Fault` / `UNAVAILABLE`.
There is no native OPC UA Part 9 alarm plumbing in this build;
`DriverAttributeInfo.IsAlarm = true` is still stamped on a CONDITION leaf so
the browse side-panel flags it and a future upgrade to native alarms doesn't
need re-authoring. See [Deferred](#deferred-not-in-this-build).
A `CONDITION` DataItem can be served two ways, and the choice is **per raw tag**:
- **As a String value** (the default — every existing CONDITION tag, and every browse-committed
one). The value is the current state word — `Normal` / `Warning` / `Fault` / `UNAVAILABLE`
(`UNAVAILABLE` → `BadNoCommunication`). Unchanged by #626.
- **As a native OPC UA Part 9 alarm**, by adding the cross-driver `alarm` object to the raw tag's
`TagConfig` — exactly how a Galaxy alarm tag is made alarm-bearing (the `/raw` tag modal's native
alarm section, or JSON):
```json
{ "address": "dev1_system_cond", "alarm": { "alarmType": "AlarmCondition", "severity": 800 } }
```
The server then materialises **one Part 9 condition at the tag's RawPath** (ConditionId = RawPath),
fanned to the raw device folder and every referencing equipment folder, and the driver feeds it
through `IAlarmSource`. An alarm-bearing tag is a condition, not a value variable (the host excludes
alarm tags from the value subscription), so to keep the String as well, author a second raw tag on
the same DataItem without the `alarm` object — one DataItem may back several raw tags.
It is deliberately **not automatic** for `category=CONDITION`: that would silently turn every existing
CONDITION String tag into a condition node on upgrade.
**State mapping.**
| Agent level | Part 9 | Severity | Quality |
|---|---|---|---|
| `Normal` | inactive (return to normal) | — | Good |
| `Warning` | active, unacknowledged | 500 | Good |
| `Fault` | active, unacknowledged | 800 | Good |
| `Unavailable` | **unchanged** (an active condition stays active and acknowledged) | — | `BadNoCommunication` |
**`Unavailable` is comms loss one hop further out, not a return to normal.** A cppagent marks every
DataItem of an adapter UNAVAILABLE when the adapter socket drops, so it is treated exactly like the
host's own transport loss (#477/#481): the condition keeps its Active/Acked state and only its `Quality`
goes `BadNoCommunication`, as a **quality-only annotation** (no `/alerts` row, written through the host's
Quality-only node path — it can never rewrite message/severity or acknowledge anything). When data
returns: the **same** statement ⇒ quality-only Good (an ack survives an adapter blip); `Normal` ⇒ Clear;
a **different** active statement ⇒ Retrigger. An inactive condition going Unavailable is likewise a
quality-only Bad. Severity is the exact Part 9 value (`AlarmEventArgs.OpcUaSeverity`, #626), not one of
the four buckets. A clear keeps the severity of the alarm it clears.
**Message** = the condition's text content; without text, `"{type} {level}"` (e.g. `SYSTEM Fault`).
Whichever of `nativeCode` / `nativeSeverity` / `qualifier` the Agent supplied is appended —
`Spindle overtemperature [nativeCode=E17, nativeSeverity=2, qualifier=HIGH]` — because the Part 9
event model has no slot for them. `AlarmType` is the condition's `type`; `SourceNodeId` the DataItem id.
**Several simultaneous conditions.** MTConnect can hold several active conditions on one DataItem
(distinct `nativeCode`s). The OPC UA condition shows **the most severe active one**; equal levels tie-break deterministically
(ordinal `nativeCode`, then `type`), never by Agent document order, so a re-baseline that lists them
differently cannot re-raise.
The driver tracks them with MTConnect's own rules (`MTConnectConditionTracker`): `Warning`/`Fault`
activates its `nativeCode`; `Normal` with a `nativeCode` clears only that code, `Normal` without one
clears all; `Unavailable` clears all; a `/current` re-baseline replaces the DataItem's set. So a
`/sample` chunk clearing code A while code B is active is **not** a return to normal. An escalation
(Warning → Fault) or a different winning code is a **Retrigger** — active and unacknowledged again with
the new severity/message. By design that includes **clearing the worst of several codes**: the next one
down is re-raised un-acked, because it is a statement the operator has not seen. A **text-only change**
on the same level + `nativeCode` (an adapter embedding a live value in the text) updates nothing — no
retrigger, no un-ack — and the message shown stays the statement's first text. (The String value surface still reconciles per
document, so on a delta it shows the state word of that chunk; the alarm is the authoritative view.)
**Acknowledge.** MTConnect has no acknowledgement upstream. Native conditions support Acknowledge and
AddComment only; the ack is applied server-side and the driver's `AcknowledgeAsync` is a no-op success
(nothing is sent to the Agent). Off-Primary acks are refused by `NativeAlarmAckPrimaryGate` as for every
native source.
**Restart / reconnect / comms loss.** The driver diffs each condition against what the server was last
**told** — dedup on level + `nativeCode` + `nativeSeverity` + `qualifier` (not text, never the value) —
and records a state only when an alarm handler was attached to receive it. Consequences:
- A replayed `/current` (reconnect, re-initialize, Agent restart, ring-buffer re-baseline) re-raises
nothing that is unchanged — re-raising would un-acknowledge an operator's ack.
- A transition that happened while the host was detached (it detaches while it reconnects) is delivered
on the re-subscribe that follows; a fault cleared during an outage is cleared on recovery.
- Comms loss emits nothing from the driver, matching every native source: the host annotates every
condition the driver owns `BadNoCommunication` on the connectivity edge (#477) and keeps its Active
state; the reconnect edge re-annotates Good, after which the driver re-asserts any still-`Unavailable`
condition's Bad quality on its re-subscribe.
- A re-subscribe with a **changed** alarm set clears the whole told record: only an alarm-bearing raw-tag
delta changes the set, the server applies that as a full rebuild, and a rebuild recreates every
condition node inactive — so every active condition is re-asserted onto its new node. The **same** set
(a reconnect) keeps the record. (A rebuild that leaves this driver's alarm set unchanged triggers no
re-subscribe; that is a host-wide gap shared with every native source.)
- An alarm-only driver (no value tags) still runs the `/sample` pump.
**EVENT controlled vocabularies as OPC UA enumerations — won't fix.** EVENT values (`Execution`,
`ControllerMode`, …) stay Strings carrying the Agent's own word; an enumeration would need a per-type
vocabulary table that vendors extend freely.
## Known limitations
@@ -378,18 +488,35 @@ for anything beyond values-and-conditions.
— it would materialise nodes nobody approved and no deployment artifact
records.
**`IHostConnectivityProbe` is still unconsumed** — `GetHostStatuses()` has
no production call site and nothing writes a `DriverHostStatus` row. That
half remains a fleet-wide gap affecting every driver that implements it
(Gitea #521).
2. **`RawTagEntry.DeviceName` is accepted on the wire shape but ignored for
routing.** One driver instance owns exactly one Agent client scoped by
`MTConnectDriverOptions.DeviceName` (the top-level config key); the
per-tag `RawTagEntry.DeviceName` field the v3 raw-tag identity carries is
never read by the driver. Two `/raw` Devices authored under one MTConnect
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.
**`IHostConnectivityProbe` is consumed** (Gitea #521, closed) —
`DriverInstanceActor` subscribes `OnHostStatusChanged`, pulls
`GetHostStatuses()` fresh, and the result rides `DriverHealthChanged` to
the AdminUI `/hosts` page (no SQL; the `DriverHostStatus` table was
dropped). See `docs/Telemetry.md`.
2. **One MTConnect driver instance = one Agent client = at most one `/raw`
Device (#628, decision (b)).** The driver never reads
`RawTagEntry.DeviceName`; every raw tag binds through the single Agent client
scoped by `MTConnectDriverOptions.DeviceName`. The v3 raw tree always has a
Device level (a new driver auto-creates `Device1`), and that single-Device
layout is the normal, valid shape — its `DeviceConfig` is merged up into the
driver config by `DriverDeviceConfigMerger` (this is how the endpoint lands).
With **two or more** Devices under one MTConnect driver there is no merge-up
and no routing, so which Device supplies the endpoint is ambiguous. That is
now a **deploy-time `DraftValidator` error** (`MTConnectMultipleDevices`),
and the AdminUI refuses to *create* the second Device
(`RawTreeService.CreateDeviceAsync`, same message): "MTConnect binds all tags
through one agent client per driver instance; author one MTConnect driver per
agent device." Option (a), a client per Device, was rejected as a design
change with no demand. A multi-device Agent is served by several driver
instances, each with its own `DeviceName` scope. The driver's init-time
Warning for differing `RawTagEntry.DeviceName` values stays as defence in
depth for configs that predate the rule.
⚠️ **Upgrade note:** an MTConnect driver that *already* has two or more Device
rows fails its next deploy with `MTConnectMultipleDevices` (the rule counts every
Device row, enabled or not, because nothing at runtime reads `Device.Enabled`).
Before upgrading, move each extra Device's tags under its own MTConnect driver
and delete the extra Device rows.
**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
@@ -397,8 +524,16 @@ for anything beyond values-and-conditions.
`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
3. **Authoring only *warns* when a declared coercion type cannot hold the
inferred one (#613).** The AdminUI tag editor (`MTConnectTagConfigModel.Warnings()`)
compares the declared type with `MTConnectDataTypeInference` over the
`/probe` metadata a browse-commit stamped (`mtCategory`/`mtType`), and shows an
advisory line — it never blocks a save (the deploy gate does not refuse it) and
is a no-op for hand-authored blobs with no stamped metadata. `representation`
is not on the model; TIME_SERIES arrays get their own `isArray` advisory (declare Float64). Beyond that,
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
type, but it doesn't close the gap — an authored `DataType` mismatched
against the actual Agent observation still surfaces as `BadTypeMismatch`
@@ -412,10 +547,13 @@ for anything beyond values-and-conditions.
practice — the fixture's canned `probe.xml`/`Devices.xml`, which does
declare a `sampleCount`, is unrealistic on this point and exists only to
pin the parsing rule itself.
5. **CONDITION is a plain `String`, not a native Part 9 alarm** (see above).
6. **`TIME_SERIES` arrays and structured `DATA_SET`/`TABLE` observations
surface as `BadNotSupported`**, not an approximation — see the Quality
mapping table.
5. **A CONDITION alarm shows one condition per DataItem** — the most severe active `nativeCode`, not one
Part 9 condition per code (see [CONDITION alarms](#condition-alarms-626)).
6. **Structured `DATA_SET`/`TABLE` observations surface as
`BadNotSupported`**, not an approximation — see the Quality mapping table.
`TIME_SERIES` is served as `double[]` (#627, see
[TIME_SERIES arrays](#time_series-arrays-627)); the sample rate is not
surfaced on the node.
## Deferred (not in this build)
@@ -431,10 +569,9 @@ for anything beyond values-and-conditions.
[`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
native-alarm pattern); `TIME_SERIES` SAMPLE arrays materialized as real
OPC UA arrays; EVENT controlled-vocabulary values → OPC UA enumerations.
[§9](../plans/2026-07-15-mtconnect-driver-design.md#9-phasing--effort)): CONDITION → native Part 9
alarms **shipped** (#626, see [CONDITION alarms](#condition-alarms-626)); EVENT
controlled-vocabulary values → OPC UA enumerations is **won't-fix**.
- **P2 (on demand): SHDR adapter ingest.** A `SourceMode: "Agent" | "Shdr"`
switch that opens the raw pipe-delimited SHDR TCP socket directly. Loses
auto-discovery (no device model without an Agent in front, so
@@ -474,6 +611,19 @@ for anything beyond values-and-conditions.
(PowerShell helper, Windows-only) — or, directly on the docker host,
`rsync` the repo's `Docker/` dir to `/opt/otopcua-mtconnect/` and run
`docker compose up -d --wait`.
- **CONDITION alarms (#626)** — `MTConnectConditionAlarmMappingTests` (tracker semantics + state
mapping/sequencing), `MTConnectAlarmSourceTests` (driver wiring: Normal→Fault→Normal, escalation,
several `nativeCode`s, Unavailable, replay after re-subscribe/re-initialize/Agent restart with no
duplicate, the missed-while-detached delivery, comms loss), `MTConnectConditionAlarmWireTests` in
`OpcUaServer.IntegrationTests` (a scripted Agent's Fault/Normal reaching a client's event subscription on
the raw device folder). **Live check:** the fixture's adapter only ever writes `normal` for its two
CONDITION items (`fixture_x_travel` / SHDR key `Xtravel`, `fixture_logic` / `Plogic`, see
`Docker/Devices.xml`). To see a transition, make `send_conditions()` in `Docker/adapter.py` alternate
`Xtravel|fault|1001|2|HIGH|X overtravel` with `Xtravel|normal||||` every few ticks, sync + restart the
adapter, author a raw tag `{"address":"fixture_x_travel","alarm":{"alarmType":"AlarmCondition"}}` on an
MTConnect driver, deploy, and subscribe events on its raw device folder (Client CLI `alarms`): expect a
Fault event (severity 800, message `X overtravel [nativeCode=1001, nativeSeverity=2, qualifier=HIGH]`),
then a return-to-normal event, repeating.
## Further reading
+11 -4
View File
@@ -96,14 +96,15 @@ to the actual offending register(s). Per-tag escape hatch:
from the driver's `DriverConfig` JSON. Key fields:
- **Endpoint** — `Host`, `Port` (default 502), `UnitId`, `Timeout`. Per-tag `UnitId` overrides drive multi-slave gateway topology.
- **`Transport`** — `Tcp` (default; MBAP header + transaction id) or `RtuOverTcp` (RTU `[addr][PDU][CRC-16]` framing tunnelled over the socket to a serial-to-Ethernet gateway); string-valued, absent = `Tcp`. The Test Connect probe follows the authored mode.
- **`Tags`** — the pre-declared `ModbusTagDefinition` list; this *is* the address space.
- **`Probe`** — connectivity-probe interval / timeout / probe register (default register 0).
- **Read/write caps** — `MaxRegistersPerRead` (125), `MaxRegistersPerWrite` (123), `MaxCoilsPerRead` (2000), plus `MaxReadGap` and `AutoProhibitReprobeInterval` for coalescing.
- **Function-code overrides** — `UseFC15ForSingleCoilWrites`, `UseFC16ForSingleRegisterWrites` for PLCs that only accept multi-write codes.
- **Resilience** — `AutoReconnect`, `KeepAlive`, `IdleDisconnectTimeout`, `Reconnect` backoff, and `WriteOnChangeOnly` redundant-write suppression.
Full per-field descriptions live in `ModbusDriverOptions.cs`. The JSON skeleton
is reproduced in [docs/v2/driver-specs.md §2](../v2/driver-specs.md).
Full per-field descriptions live in `ModbusDriverOptions.cs`. The v2-era JSON skeleton
in [docs/v2/driver-specs.md §2](../v2/driver-specs.md) predates the `Transport` key and the v3 raw-tag model.
## Testing
@@ -137,8 +138,13 @@ numeric, String, and BitInRegister array reads.
**Live-verify** — Mac-verifiable against the simulator at `10.100.0.35:5020` (no fixture
bring-up required; the sim is always-on on the shared Docker host).
**Deferrals** — array *writes* (FC16 multi-register write for an array value) are a named
follow-up; multi-dimensional arrays (`arrayLength` is always 1-D); per-element historization.
**Array writes** (#610) — a whole-array write goes out as one FC15 (coils) or one FC16 (numeric
holding registers); the driver refuses rather than splits an FC16 longer than `maxRegistersPerWrite`
(default 123). The server mirrors that: an array tag is client-writable only when
`arrayLength × registers-per-element` fits the cap (and a coil array ≤ 1968), else its node stays
read-only — see [Uns.md §Array writes](../Uns.md#array-writes-610), including the upgrade note.
**Deferrals** — multi-dimensional arrays (`arrayLength` is always 1-D); per-element historization.
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
and the UI authoring flow.
@@ -149,3 +155,4 @@ and the UI authoring flow.
- **`WriteOnChangeOnly` + write-only tags** — the suppression cache is only invalidated by a read that returns a divergent value. A tag that is never subscribed/polled never refreshes its cache entry, so a re-asserted value can be suppressed indefinitely. Subscribe every tag that needs deterministic re-writes, or leave the option off.
- **Auto-prohibited ranges** are visible via `GetAutoProhibitedRanges` and logged on first occurrence / on clear — use them to find protected register holes in a device's map.
- **Int64 / UInt64 OPC UA node DataType** — tags declared with `DataType: Int64` or `DataType: UInt64` advertise the correct OPC UA scalar type (`Int64` / `UInt64`) on their node. Values outside the 32-bit range are preserved end-to-end; the wire codec (4-register read/write) was already correct before this fix.
- **Health is per-tag, last-writer-wins — a deliberate asymmetry (#602).** Unlike the S7 read/write passes, which accumulate one worst-state-wins outcome per batch, `ModbusDriver` stamps `ReadHealth`/`WriteHealth` (Volatile) per tag as the loop runs. This is intentional: the comms-loss watchdog keys on `LastSuccessfulRead`, which any success advances and `Degraded`/`Faulted` writes preserve, so a late healthy tag cannot mask a dead device, and a per-pass accumulator would add risk to the `Degraded` latch semantics (CLAUDE.md "Comms-loss detection") for no observable gain. Revisit only if a test shows a defect.
+35 -6
View File
@@ -325,6 +325,9 @@ and this driver ships all three or none of it
2. a matching **retained `OFFLINE` armed as the MQTT Last Will**, so a crash still reports the truth;
3. an explicit `OFFLINE` on a clean stand-down, so edge nodes do not wait out a keep-alive timeout.
A claim the broker keeps refusing backs off (STAB-27): 2 s, 4 s, 8 s … capped at 30 s between attempts, one
Warning per attempt, reset by a successful claim or a stand-down. The role is still sampled every second.
Set `Sparkplug.HostId` and `Sparkplug.ActAsPrimaryHost: true`. A blank host id is **refused** with a
warning — the driver does not publish to `spBv1.0/STATE/`, which is a legal topic reaching nobody.
@@ -360,9 +363,14 @@ Hence: bind the claim to a connection, and rebuild the connection on role change
| Role unknown, no driver peer (single node) | open | `ONLINE` |
The verdict comes from `PrimaryGatePolicy.ShouldServiceAsPrimary` via `IDriverPrimaryRoleSource`
(`Core.Abstractions`), published by `DriverHostActor` on every redundancy snapshot and **sampled on a
1-second timer** rather than evented — a process-singleton raising events at per-instance drivers
outlives every driver it holds a reference to.
(`Core.Abstractions`), published by `DriverHostActor` at host start, on every cluster membership change and on
every redundancy snapshot, and **sampled on a 1-second timer** rather than evented — a process-singleton
raising events at per-instance drivers outlives every driver it holds a reference to.
> ⚠️ **"Driver peer present" is this node's own cluster view.** Before the node has joined, it sees no
> members, which is the single-node row: a node cold-starting into a pair whose peer is already Primary
> can open the claim until its join completes (seconds; up to the bootstrap guard's 25 s partner probe),
> so both nodes may briefly hold it (review R6 MINOR-2).
> **Accepted trade: a brief edge-visible `OFFLINE` during failover**, while the old Primary stands down
> and the new one connects and republishes. That is a genuine state transition — for that window there
@@ -425,7 +433,7 @@ which is archreview 05/STAB-18.
- **Connection loss stales, keeping the value.** On the `Connected → Reconnecting` edge
`MqttConnection.ConnectionLost` fires, and both ingest paths mark every **already-observed**
reference `BadNoCommunication` with its **last value retained** and publish the change. A
reference `BadNoCommunication` with its **last value retained** (server-side; a client reads the Bad status with a null value) and publish the change. A
never-observed reference (still `BadWaitingForInitialData`) and an already-Bad one are left alone.
The event does **not** fire for a failed *initial* connect or during dispose — neither is an outage.
- **Every (re)subscribe pushes the cached snapshots.** `SubscribeAsync` republishes each observed
@@ -460,10 +468,31 @@ every MQTT node materialized read-only regardless of what the operator authored.
| **Plain** | the tag's `writeTopic` | the value as UTF-8 text (`Raw`/`Scalar`) or a bare JSON literal (`Json`) |
| **Sparkplug B** | `spBv1.0/{group}/DCMD/{node}/{device}` for a device metric, `spBv1.0/{group}/NCMD/{node}` for an edge-node metric | a Sparkplug `Payload` carrying the metric **by name**, with an explicit `Datatype` and no `seq` |
Both publish at **QoS 0, `retain: false`**. Retain is the one that matters and is deliberately not
configurable: a retained command would be replayed by the broker to the device on *every* future
Both publish with `retain: false`. A **Plain** write goes at **QoS 1** (STAB-26): the publish completes only
once the broker PUBACKs it, so `Good` means "the broker accepted it", and a broker refusal or a connection
that died under the send is `Bad`. **Sparkplug commands stay at QoS 0** — a redelivered NCMD/DCMD would be
re-applied. A Sparkplug metric the tag did not give a `dataType` is commanded with the type its live birth
certificate declared (STAB-25), the same authored-beats-birth precedence reads use — using the birth's **own**
Sparkplug datatype, not the lossy driver-type mapping, so Int8/UInt8/Text/Uuid/Bytes/File metrics are commanded
with their real type; a birth type the encoder cannot represent (any `*Array`, DataSet, Template, …) is refused
`BadNotSupported` rather than sent as another type. Retain is the one that
matters and is deliberately not configurable: a retained command would be replayed by the broker to the device on *every* future
subscribe — including the device's own reconnect — so one stale write would be re-applied forever.
**Plain QoS 1 is at-least-once.** Know the consequences before pointing a Plain `writeTopic` at a non-idempotent
device topic:
- A lost PUBACK can make a device subscribed at QoS ≥ 1 with a persistent session receive the command **twice**
(at QoS 0 delivery was downgraded and a duplicate could not happen).
- A lost PUBACK, or the `ConnectTimeoutSeconds` (15 s) deadline, reports `Bad` and the node is **reverted**, even
though the broker may already have delivered the write. `Good` is "the broker accepted it", `Bad` is "not
confirmed", never "not delivered".
- Each write holds `DriverInstanceActor`'s mailbox until the PUBACK arrives — up to 15 s per write, one after
another; comparable to a Modbus round trip.
- A broker advertising MQTT 5 Maximum QoS 0 will disconnect on every Plain write.
There is no per-tag QoS option; make the receiving topic idempotent (absolute setpoints, not increments).
### `writeTopic` is separate from `topic`, and its absence is meaningful
A tag commonly reads `…/state` and writes `…/set`, so the destination is its own authored field. A
+8
View File
@@ -52,6 +52,14 @@ the tests mock.
Wire-level surfaces verified: `IDriver` + `IReadable` + `ISubscribable` +
`IHostConnectivityProbe` (via the Secure Channel exchange).
### In-process wire tests (no opc-plc)
`tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/OpcUaClientDriverWireTests` drives the real driver
against the in-process OtOpcUa server (`ServerEventRig`, authenticating with the rig's API-key credential):
read (+ missing node), write through the node-write gateway (+ read-only refusal, + failed-write revert),
subscribe/unsubscribe, host-status Running -> not-Running when the server stops, and discovery. These need
no Docker. The HistoryRead wire leg is deferred (needs an `IHistorianDataSource` stub on the server).
### Unit
The surface is broad because `OpcUaClientDriver` is the richest-capability
+66 -3
View File
@@ -99,8 +99,11 @@ rather than surfacing as a runtime surprise.
### History passthrough
`IHistoryProvider` forwards `HistoryRead` to the upstream server's own historian.
Raw, processed (Average / Minimum / Maximum / Total / Count aggregates mapped to
OPC UA Part 13 standard aggregate NodeIds), and at-time reads are supported; each
Raw, processed (the full `HistoryAggregateType` set — Average / Minimum / Maximum / Total / Count plus
Interpolative, TimeAverage, Start, End, Delta, Range, Min/Max ActualTime, Start/EndBound, DeltaBounds,
DurationGood/Bad, PercentGood/Bad, WorstQuality, NumberOfTransitions, AnnotationCount and the
population StandardDeviation/Variance — each mapped to its OPC UA Part 13 standard aggregate NodeId; #285),
and at-time reads are supported; each
returned `DataValue` keeps its upstream `StatusCode` and timestamps verbatim.
Event-history (`ReadEventsAsync`) is left at the interface default — the
interface doesn't yet carry the EventFilter surface needed to forward it.
@@ -109,7 +112,67 @@ interface doesn't yet carry the EventFilter surface needed to forward it.
`AutoAcceptCertificates` accepts any self-signed / untrusted server certificate.
It is **dev-only** — leave it `false` in production so a MITM against the
opc.tcp channel fails closed.
opc.tcp channel fails closed. The SDK rejects a **revoked** certificate
(`BadCertificateRevoked` / `BadCertificateIssuerRevoked`) before the auto-accept handler runs, and the handler
also refuses them explicitly — defence in depth (#277), not a behaviour change.
`CertificateValidation` (defaults change nothing):
| Key | Default | Meaning |
|---|---|---|
| `rejectUnknownRevocationStatus` | `false` | Reject a server certificate whose revocation status cannot be determined (no CRL for its issuer, or a stale one). Leave `false` for brownfield sites without CRL infrastructure. **Only effective with `autoAcceptCertificates` off** — the auto-accept handler accepts every suppressible error, including revocation-unknown. |
| `issuerStorePath` | `null` (= `%LocalAppData%/OtOpcUa/pki/issuers`) | Directory used as the trusted-issuer store. The SDK reads issuer CRLs (`*.crl`) from its `crl/` sub-folder, so this is how an operator points the driver at a managed CRL set. |
### Subscription tuning (#273)
The per-subscription parameters that used to be hardcoded are driver options; the defaults are the old values.
| Key | Default | Notes |
|---|---|---|
| `subscriptionPublishingInterval` | `null` | A .NET `TimeSpan` string (`"00:00:02"`), not a number. `null` = the interval the runtime requests per subscribe (floored at 50 ms). When set it overrides that value (floor still applies); monitored-item sampling follows it. |
| `subscriptionKeepAliveCount` | `10` | `>= 1`. |
| `subscriptionLifetimeCount` | `1000` | Must be `>= 3 x` keep-alive count (Part 4 §5.13.2). |
| `subscriptionMaxNotificationsPerPublish` | `0` | `0` = server decides. |
| `subscriptionPriority` | `0` | `0`-`255`. |
Out-of-range values (and a non-absolute `discoveryUrl`) are rejected when the options are loaded — driver creation / `InitializeAsync` fails once with a clear message rather than the driver faulting and retrying forever. The alarm subscription uses the same counts.
*Per-tag* tuning (deadband / sampling interval / queue size, #274) is not implemented — it needs an
`ISubscribable` signature change on Core.
### Server OperationLimits (#275)
Nothing driver-side: the SDK session already honours the remote server's
`ServerCapabilities/OperationLimits`. `Opc.Ua.Client.Session` derives from `Opc.Ua.SessionClientBatched`,
fetches the limits on open (`FetchOperationLimitsAsync`) and splits Read / Write / CreateMonitoredItems / Browse /
HistoryRead requests that exceed them (a limit of `0` = unlimited). The driver issues one logical call per
operation and a unit test pins the `Session : SessionClientBatched` relationship so an SDK change that dropped
it is caught. (A driver-side splitter was tried and removed: it duplicated the SDK and, on a stalled link, held
the driver gate for chunks x timeout.)
### Discovery URL (#278)
`discoveryUrl` (default `null`, e.g. `opc.tcp://lds:4840`): at `InitializeAsync` the driver runs `FindServers` on it
and `GetEndpoints` on every returned server, keeping the endpoints matching `securityPolicy` + `securityMode`.
Discovered URLs are tried **after** `endpointUrls`/`endpointUrl` (case-insensitive de-dup) — discovery can add
fallbacks but never outranks or replaces the configured server. Because an LDS commonly registers several servers
(identical line PLCs, a gateway plus a historian) and the first to accept a connection would be bound silently:
- `discoveryServerUri` set: only endpoints of the server with that ApplicationUri are used.
- unset and exactly one distinct server discovered: that server is used.
- unset and more than one: the connect attempt **fails** with an error naming the discovered ApplicationUris and
telling the operator to set `discoveryServerUri`.
A discovery *network* failure (LDS down) is logged and non-fatal — the static list is used (note it is retried on
every `InitializeAsync`, including comms-loss reconnects). The discovery channel itself is always unsecured (spec).
### Diagnostics counters (#276)
Session/publish events are published on the shared OTel meter as the counter
`otopcua.driver.opcuaclient.events`, tagged `driver_instance` (the driver instance id) and `event` =
`publish_response`, `notification` (items), `data_change`, `bad_status_value`, `publish_error`,
`keepalive_miss`, `reconnect_attempt`, `reconnect_success`, `reconnect_exhausted`. A climbing `keepalive_miss` /
`reconnect_attempt` rate is the signature of a flapping upstream. They are not part of `DriverHealth` or the
`/hosts` panel.
## Testing
+3 -3
View File
@@ -103,7 +103,7 @@ Three properties worth knowing:
- **Historian.Gateway** (server-side historian backend, not a tag driver) is documented in the main guide:
- [../Historian.md](../Historian.md) — HistorianGateway backend: read-path registration, HistoryRead dispatch, alarm store-and-forward (`SendEvent`), continuous historization (`WriteLiveValues`), `EnsureTags` provisioning, config keys, deployment prerequisites. (The retired Wonderware sidecar backend it replaced: [Historian.Wonderware.md](Historian.Wonderware.md).)
- The full per-field spec (capability surface, config schema, addressing, data-type maps, connection settings, quirks for every driver) lives in [docs/v2/driver-specs.md](../v2/driver-specs.md). The overview pages above are the short path; that file is the authoritative per-driver reference.
- The full per-field spec (capability surface, config schema, addressing, data-type maps, connection settings, quirks for every driver) lives in [docs/v2/driver-specs.md](../v2/driver-specs.md). The overview pages above are the short path and are the current reference; that file is the v2-era per-field spec (see its status banner for which drivers it covers).
## Test-fixture coverage maps
@@ -114,7 +114,7 @@ Each driver has a dedicated fixture doc that lays out what the integration / uni
- [Siemens S7](S7-Test-Fixture.md) — Dockerized `python-snap7` server; DB/MB read + write round-trip verified end-to-end on `:1102`
- [AB Legacy](AbLegacy-Test-Fixture.md) — Dockerized `ab_server` PCCC mode across SLC500 / MicroLogix / PLC-5 profiles (task #224); N/F/L-file round-trip verified end-to-end. `/1,0` cip-path required for the Docker fixture; real hardware uses empty. Residual gap: bit-file writes (`B3:0/5`) still surface BadState — real HW / RSEmulate 500 for those
- [TwinCAT](TwinCAT-Test-Fixture.md) — XAR-VM integration scaffolding (task #221); three smoke tests skip when VM unreachable. Unit via `FakeTwinCATClient` with native-notification harness
- [FOCAS](FOCAS-Test-Fixture.md) — no integration fixture, unit-only via `FakeFocasClient`; Tier C out-of-process isolation scoped but not shipped
- [FOCAS](FOCAS-Test-Fixture.md) — no integration fixture, unit-only via `FakeFocasClient`; FOCAS runs in-process (Tier A) — the Tier C out-of-process isolation once scoped was never built
- [OPC UA Client](OpcUaClient-Test-Fixture.md) — Dockerized `opc-plc` integration suite (task #215): real Secure Channel + Session, read + subscribe verified end-to-end; write not yet exercised in the integration suite; exhaustive capability matrix (reconnect, failover, cert-auth, history, alarms) via unit suite with mocked `Session`
- [MQTT](Mqtt-Test-Fixture.md) — Dockerized `eclipse-mosquitto` with **TLS + real auth on both listeners (no anonymous fallback)** + a JSON-publisher sidecar; env-gated live suite (**15 tests**) — 7 plain (TLS/auth connect, CA pinning both ways, subscribe→value, retained seeding, wrong-password rejection) + 8 Sparkplug against a **project-owned C# edge-node simulator** on the `sparkplug` compose profile (birth→alias bind, DDATA, NDEATH→STALE, rebirth NCMD round-trip). Births are never retained, so `docker restart otopcua-sparkplug-sim` is how you force a fresh one
- [Galaxy](../v1/drivers/Galaxy-Test-Fixture.md) — richest harness: gateway E2E + ZB SQL live-smoke + MXAccess opt-in (v1 archive)
@@ -125,5 +125,5 @@ Each driver has a dedicated fixture doc that lays out what the integration / uni
- [HistoricalDataAccess.md](../v1/HistoricalDataAccess.md) — `IHistoryProvider` dispatch, aggregate mapping, continuation points. The OPC UA Client driver is the only driver that implements driver-side `IHistoryProvider` (it forwards HistoryRead to the upstream server); the AVEVA Historian path is served server-side by the HistorianGateway-backed `IHistorianDataSource` instead. Other drivers do not implement the interface and return `BadHistoryOperationUnsupported`.
- [AlarmTracking.md](../AlarmTracking.md) — `IAlarmSource` event model and filtering. Implemented by Galaxy (native MxAccess alarms, working end-to-end), OPC UA Client, AB CIP, and FOCAS; AB Legacy, Modbus, S7, TwinCAT, and MQTT have no alarm source.
- [Subscriptions.md](../v1/Subscriptions.md) — how the Server multiplexes subscriptions onto `ISubscribable.OnDataChange`.
- [docs/v2/driver-stability.md](../v2/driver-stability.md) — tier system (A / B / C), shared `CapabilityPolicy` defaults per tier × capability, `MemoryTracking` hybrid formula, and process-level recycle rules.
- [docs/v2/driver-stability.md](../v2/driver-stability.md) — historical design record of the A / B / C tier model. Only `DriverTier` + the `CapabilityPolicy` defaults per tier × capability (`DriverResilienceOptions.GetTierDefaults`) are live; its `MemoryTracking` / recycle / out-of-process isolation machinery was deleted (#522) and every driver runs Tier A.
- [docs/v2/plan.md](../v2/plan.md) — authoritative vision, architecture decisions, migration strategy.
+2
View File
@@ -20,6 +20,8 @@ python-snap7 `Server` via `Docker/docker-compose.yml --profile s7_1500`
on `10.100.0.35:1102` (the shared Docker host; override via `S7_SIM_ENDPOINT`;
pinned `python:3.12-slim-bookworm` base +
`python-snap7>=2.0`). Docker is the only supported launch path.
Tests that WRITE to the endpoint (currently `S7_1500ArrayWriteTests`) additionally require
`S7_SIM_ALLOW_WRITES=1`, because `S7_SIM_ENDPOINT` can be pointed at a real CPU; they skip without it.
`Snap7ServerFixture` probes the port at collection init + skips with a
clear message when unreachable (matches the pymodbus pattern).
`server.py` (baked into the image under `Docker/`) reads a JSON profile
+109 -10
View File
@@ -57,10 +57,38 @@ operates off a **static, per-site tag list**, not live symbol discovery.
pick the family that matches the PLC exactly. `Rack` is almost always `0`
(relevant only for distributed S7-400 racks). `Slot` conventions per family:
S7-300 = slot 2, S7-400 = slot 2 or 3, S7-1200 / S7-1500 = slot 0 (onboard PN).
A wrong slot causes a connection refusal during the handshake. See
The `CpuType` JSON strings are `S7300`, `S7400`, `S71200`, `S71500` and the legacy
families `S7200` (S7-200), `S7200Smart` (S7-200 Smart) and `Logo0BA8` (LOGO! 0BA8).
For the three legacy families S7.Net uses fixed TSAPs, so `Rack` / `Slot` are not
consulted — leave them `0` / `0`; support is limited S7comm and unverified against
hardware. A wrong slot causes a connection refusal during the handshake. See
`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7DriverOptions.cs` for the
per-field defaults.
### TSAP / connection-type override
By default S7.Net derives both ISO-TSAPs from `CpuType` / `Rack` / `Slot` (local `01.00`; remote
`03.<rack×0x20+slot>` for the S7-300/400/1200/1500 families). Three optional keys override that
for CPUs and gateways that hand out connection resources by TSAP (S7-400H, CP343, LOGO!):
| Key | Form | Effect |
|---|---|---|
| `localTsap` | `"01.00"` (also `"0100"`, `"0x0100"`, or a 16-bit number) | replaces the local TSAP |
| `remoteTsap` | `"03.01"` (same forms) | replaces the whole remote TSAP |
| `connectionType` | `"PG"` \| `"OP"` \| `"S7Basic"` (string) | writes `1` / `2` / `3` into the **first** remote-TSAP byte, keeping `rack×0x20+slot` as the second |
`remoteTsap` and `connectionType` are mutually exclusive (a config carrying both fails to load); a
malformed TSAP fails to load rather than silently falling back to the slot-derived default. With none of
the three set the connection is built exactly as before. The override applies to the driver, the AdminUI
Test Connect probe and the AdminUI S7 driver form (Connection type / Local TSAP / Remote TSAP fields).
### Test Connect and PUT/GET
After the handshake, Test Connect reads 2 bytes from `MB0`. A CPU with PUT/GET disabled (the S7-1200/1500
default) completes the handshake but denies every read, so the probe fails with an actionable "enable
PUT/GET communication" message instead of a false green tick. A CPU that merely rejects that address (no
`MB0`) still passes — the connection is fine; any other read fault fails the probe with its message.
## Address forms
Addresses use Siemens TIA-Portal / STEP 7 Classic syntax, parsed by
@@ -98,19 +126,52 @@ extra PLC round-trip.
| `UInt64` | ULINT/LWORD | 8 bytes | yes | yes |
| `Float64` | LREAL | 8 bytes | yes | yes |
| `String` | STRING | `StringLength + 2` bytes | yes | yes |
| `DateTime` | DATE_AND_TIME | 8 bytes | yes | yes |
| `WString` | WSTRING | `4 + 2 × StringLength` bytes | yes | yes |
| `Char` | CHAR | 1 byte | yes | yes |
| `WChar` | WCHAR | 2 bytes | yes | yes |
| `DateTime` | DATE_AND_TIME (DT) | 8 bytes (BCD) | yes | yes |
| `Dtl` | DTL | 12 bytes | yes | yes |
| `Date` | DATE | 2 bytes | yes | yes |
| `Tod` | TIME_OF_DAY | 4 bytes | yes | yes |
| `Time` | TIME | 4 bytes | yes | yes |
| `S5Time` | S5TIME | 2 bytes | yes | yes |
| Timer (`T{n}`) | TIME | — | yes | **read-only** |
| Counter (`C{n}`) | COUNTER | — | yes | **read-only** |
Wide types (`Int64`, `UInt64`, `Float64`, `String`, `DateTime`) and Timer/Counter require
byte-anchored addressing — see the section below.
Wide types (`Int64`, `UInt64`, `Float64`, `String`, `DateTime`, and the string / date-time
types below) and Timer/Counter require byte-anchored addressing — see the section below.
### String, character and date/time/duration types
These add no new OPC UA data types — they map onto existing ones:
| DataType | OPC UA type | Representation | Write refuses (`BadOutOfRange`, nothing sent) |
|---|---|---|---|
| `String` | String | S7 `[maxLen][curLen][chars]`, ASCII | over `StringLength` (S7.Net behaviour) |
| `WString` | String | `[maxLen UInt16][curLen UInt16][UTF-16BE]`; `stringLength` is the declared max in characters, **0–16382** (default 254) | more UTF-16 code units than `StringLength` |
| `Char` | String (1 char) | 1 byte, Latin-1 | a character above U+00FF |
| `WChar` | String (1 char) | UTF-16BE code unit | — (a string whose length is not 1 is `BadTypeMismatch`) |
| `DateTime` | DateTime | DT, 8-byte BCD (S7.Net codec; 1990–2089) | out-of-range year |
| `Dtl` | DateTime | year UInt16 + month/day/weekday/h/m/s + UInt32 nanoseconds; 100 ns resolution | year outside 1970–2262 |
| `Date` | DateTime (midnight) | UInt16 days since 1990-01-01 (max 2169-06-06) | before 1990-01-01 or after 2169-06-06 |
| `Tod` | **Int32 ms** since midnight | UInt32 ms | outside 0–86 399 999 |
| `Time` | **Int32 ms** (signed duration) | Int32 ms | outside the Int32 range |
| `S5Time` | **Int32 ms** | 2-bit time base (10 ms / 100 ms / 1 s / 10 s) + 3 BCD digits | above 9 990 000 ms, negative, or not exactly representable (resolution is 10 ms up to 9 990 ms, then coarser) — **never rounded** |
A fractional millisecond `Double` is rounded to the nearest whole millisecond (banker's rounding), exactly
as the driver does for every other Int32 tag write; only values off the S5TIME time-base grid are refused.
A write of a `TimeSpan` is also accepted for `Tod` / `Time` / `S5Time`. A PLC buffer the codec cannot
decode — an uninitialised (all-zero) `Dtl`, a non-BCD `S5Time` digit, a `WString` whose current length
exceeds its reserved length — reads `BadOutOfRange` for that tag only, without degrading driver health
or condemning the connection. `S5Time` writes pick the finest time base that holds the value. The
`Timer` area (`T{n}`) keeps its own S5TIME-to-**seconds** (`Float64`) decode.
## Wide types & Timer/Counter
### Byte-anchored addressing for wide / structured types
Wide types (`Int64`, `UInt64`, `Float64`/LReal, `String`, `DateTime`) are
**byte-anchored**: the address must use the `B` suffix pointing at the **first
Wide types (`Int64`, `UInt64`, `Float64`/LReal, `String`, `DateTime`, `WString`, `Char`,
`WChar`, `Dtl`, `Date`, `Tod`, `Time`, `S5Time`) are **byte-anchored**: the address must use the `B` suffix pointing at the **first
byte** of the value; the driver reads the correct number of contiguous bytes
based on the DataType.
@@ -121,6 +182,8 @@ based on the DataType.
| `Float64` | same | 8 | `DB1.DBB8` → LREAL at bytes 8–15 |
| `DateTime` | same | 8 | `DB3.DBB0` → DATE_AND_TIME at bytes 0–7 |
| `String` | same | `StringLength + 2` | `DB4.DBB0`, StringLength=40 → 42 bytes |
| `WString` | same | `4 + 2 × StringLength` | `DB5.DBB0`, StringLength=10 → 24 bytes |
| `Dtl` | same | 12 | `DB6.DBB0` → DTL at bytes 0–11 |
Using a `W` or `D` suffix with a wide DataType is a config error caught at
`InitializeAsync`. For array tags (`isArray: true`), wide-type element arrays
@@ -151,11 +214,18 @@ The following are explicitly deferred and will produce a config error or
`BadNotSupported` if attempted:
- **Wide-type arrays** — array tags (`isArray: true`) with element types `Int64`,
`UInt64`, `Float64`, `String`, or `DateTime`.
- **`S7WString`** (2-byte character strings; distinct from classic `STRING`).
- **`DTL` / `DateTimeLong`** (12-byte Siemens date-and-time-long).
`UInt64`, `Float64`, `String`, `DateTime`, or any of the string / date-time types above.
- **Timer / Counter writes** — surfaced as `BadNotWritable` until write support lands.
## Negotiated PDU size
The PDU size the CPU negotiates during the connection handshake (typically 240 bytes; 480 / 960 with
extended PDU) is snapshotted at every (re)connect and exposed as `S7Driver.NegotiatedPduSize` /
`S7Driver.GetDiagnostics()["S7.NegotiatedPduSize"]` (0 before the first connect and after shutdown). It is
also written to the `S7Driver connected` / `reconnected` log lines and to the AdminUI Test Connect
message (`S7 connected (CPU S71500, PDU 240 bytes)`). `DriverHealth` has no diagnostics bag in this
codebase, so the value is not yet on the `/hosts` health snapshot — adding one is a Core change.
## Capability surface
`S7Driver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe`
@@ -195,6 +265,21 @@ Portal under *Protection & Security* for the CPU.
| PUT/GET denied | `BadNotSupported` | `Faulted` (config alert) |
| CPU / hardware fault | `BadDeviceFailure` | `Degraded` |
| Socket / timeout | `BadCommunicationError` | `Degraded` |
| Reconnect suppressed (backoff window) | `BadCommunicationError` | `Degraded` |
| Array write of the wrong shape / length / element type | `BadTypeMismatch` | unchanged |
| Bool / wide-type array write | `BadNotSupported` | unchanged |
**Health is decided once per pass, worst state wins.** A read pass and a write batch each
accumulate their outcomes and write health once (`Healthy < Degraded < Faulted`), so a PUT/GET-denied
`Faulted` item is not erased by a later `Degraded` or successful sibling. The write-side `Faulted` is
**sticky against reads**: a clean read pass (reads often work against a write-protected CPU) leaves
`Faulted` in place and only advances `LastSuccessfulRead`; the next write that succeeds clears it. A
transient `Degraded` write failure is still cleared by a clean read, as before.
**Connect backoff.** A dead PLC costs one connect attempt per backoff window (1 s doubling to a 30 s
cap), not one per read / poll tick / probe tick: inside the window `EnsureConnectedAsync` fails fast
(`BadCommunicationError`, `Degraded`) without a connect. A successful connect clears the window, so a
fresh drop after a healthy period reconnects immediately; worst-case recovery lag is the 30 s cap.
## Testing
@@ -228,7 +313,21 @@ normal dev (the S7 sim is on the shared Docker host but currently offline).
**Live-verify** — integration-fixture-gated (not Mac-verifiable without the S7 sim up).
**Deferrals** — array *writes*, multi-dimensional arrays, per-element historization,
**Array writes (S7-016)** — a writable `Byte` / `Int16` / `UInt16` / `Int32` / `UInt32` / `Float32` array
is written as ONE contiguous `WriteBytesAsync` of `count x element-bytes` (the inverse of the read
decode), and the value must be an array of exactly `arrayLength` elements (else `BadTypeMismatch`, no
health impact). **Writable `Bool` arrays are refused at init** (`NotSupportedException`): the array
read is byte-granular, so a matching write would overwrite the 7 neighbouring bits of every byte it
touches, and a safe bit-packed write needs read-modify-write. Mark Bool arrays `writable: false`. A `null` array element is rejected (`BadTypeMismatch`, nothing written)
rather than coerced to 0. An array write larger than one PDU is split by S7.Net into several writes and is
**not atomic** — a mid-write failure can leave the PLC array partly updated.
The server exposes these as **writable OPC UA array nodes** (#610) for exactly the element types above —
a `Bool` / wide-type S7 array stays read-only at the node (`BadNotWritable`), see [Uns.md §Array
writes](../Uns.md#array-writes-610). The driver path is also reachable directly through the Client
CLI / driver-level callers and is proven by the unit suite plus the snap7 fixture test
(`S7_1500ArrayWriteTests`); the AdminUI tag editor rejects wide-type and writable-Bool arrays at author time.
**Deferrals** — multi-dimensional arrays (out of scope by decision, see Uns.md), per-element historization,
wide-type array elements (`Int64`, `UInt64`, `Float64`, `String`, `DateTime`), and
Timer/Counter array elements.
+11 -1
View File
@@ -92,7 +92,17 @@ Both value-bearing fields — `whereValue` here and `keyValue` on the `KeyValue`
authored as a JSON string, number or boolean, and are captured as text. They are **bound as
parameters** either way, and matched back as text (the key cell is stringified), so a key column
that holds an INTEGER station id can be authored either `"7"` or `7`. A JSON object, array or `null`
is not a value and is refused. (They were read asymmetrically until 2026-08-10, so a numerically
is not a value and is refused. On **PostgreSQL** a string bind is sent untyped (`NpgsqlDbType.Unknown`,
`PostgresDialect.ConfigureParameter`, STAB-24) so the server infers its type from the compared column;
bound as `text`, a `"7"` against an `integer` key fails with `operator does not exist: integer = text`.
⚠️ **Known limitation — key normalisation.** Row slice-back matches the returned key cell by its *text* form
(`Convert.ToString`, ordinal), while the server now matches by the column's inferred type. An authored key the
server treats as equal but whose text differs therefore selects a row that the driver then cannot find, and the
tag reads **no-row**: `"007"` against an `integer` column (returns `7`), `"1.50"` against `numeric` (returns
`1.5`), and an upper-case GUID against a `uuid` column (returned lower-case). Author keys in the column's
canonical text form. SQL Server behaves the same way.
(They were read asymmetrically until 2026-08-10, so a numerically
authored `keyValue` refused the tag while the identically-bound `whereValue` was served — and the
deploy gate reported the key as *missing* when it was right there in the blob.)
+24 -1
View File
@@ -135,6 +135,29 @@ found against a real TwinCAT 2 PLC:
registers, and the probe-tick retry re-checks it after a PLC program change.
Reads go through the Beckhoff symbol cache, which converts numeric values by
the PLC's own type, so only the string-kind rule applies there.
- **IEC temporal types publish native values (#306, behaviour change).** `TIME` and
`TOD` (TIME_OF_DAY) publish **`Double` milliseconds** (`DriverDataType.Float64`);
`DATE` and `DT` (DATE_AND_TIME) publish a **UTC `DateTime`** decoded from the
UDINT seconds-since-1970 counter (`DriverDataType.DateTime`). They used to
publish the raw counter as `UInt32`. Reads, notifications, array elements and
browse/discovery all agree (`TwinCATTemporalCodec`). No new `DriverDataType`
member was added (`Duration` would touch every switch in Core, the node
manager, historian mapper and editors); a duration is a Double ms, which is
what OPC UA `Duration` is on the wire. `LTIME`/`LDT` are not in the driver's
type surface and are still unsupported. **Writes** accept a `Double` ms /
`TimeSpan` (TIME, TOD) or a `DateTime` (DATE — truncated to a whole day — and
DT); negative, NaN, pre-1970 or > UDINT values are refused `BadOutOfRange`, and
a duration written to a date (or vice versa) `BadTypeMismatch`.
**Existing tags:** a tag authored with `dataType: "Time"` (etc.) now surfaces
the decoded type — re-browse/redeploy to pick up the new node type. A tag
authored `UDInt` against a TIME/DATE symbol is unchanged: it still reads and
writes the raw 32-bit counter (the guard tolerates the same-width divergence).
**Historian:** a historized tag authored `Date`/`DateTime` now publishes
`DriverDataType.DateTime`, which continuous historization skips as non-numeric
(it was historized as `UInt32` before) — such tags stop recording. Author them
`UDInt` to keep historizing the raw counter. `Time`/`TimeOfDay` are now
`Float64` and still historize. A `TimeOfDay` write of 86 400 000 ms or more
is refused `BadOutOfRange`.
- **Sizes come from the resolved data type, not the symbol.** On a TwinCAT 2
target the Beckhoff library reports a dotted *member* symbol's `ByteSize` as if
the PLC's byte count were a bit count (`.OPC_MACHINEDATA.PROCESSSTATUS`
@@ -315,7 +338,7 @@ works:
| `.ARRFAULTCODE` | `ARRAY [0..12] OF UINT` | UInt × 13 | `UInt16[13]` (element 3 = 32, matching `.ARRFAULTCODE[3]` scalar) |
| `.ARRUSPOWER` | `ARRAY [1..3, 1..2] OF REAL` / `[1..2, 1..2]` | Real × 6 / 4 | `Single[6]` / `Single[4]` — changing (~1060 W on Z33733); **23/23** notifications identical to an immediate poll |
| `P_INPUTVALUESFILTER.FBUSPWR1G1.SAMPLE` | `ARRAY [1..20] OF REAL` (member, symbol 10 B) | Real × 20, and × 5 (sub-range) | `Single[20]` changing, **14/14** identical; `Single[5]` = its first 5 |
| `.THROTTLE_MODE_DEFAULT` | `ARRAY [0..12] OF TIME` | Time × 13 | `UInt32[13]` `[0, 10, 20, 40, …]` ms |
| `.THROTTLE_MODE_DEFAULT` | `ARRAY [0..12] OF TIME` | Time × 13 | `Double[13]` `[0, 10, 20, 40, …]` ms (was `UInt32[13]` before #306; declare `UDInt` for the raw counter) |
| `.ARGTYPE_IS_BIT_TYPE` | `ARRAY [0..18] OF BOOL` | Bool × 19 | `Boolean[19]` |
| `.FORMAT_DEFAULT_PRECISION` / `.DWORD_DECADE_DIVIDER` / `.DIXTIMEALARM` | `INT` / `DWORD` / `UDINT` arrays | Int / UDInt | `Int16[12]` / `UInt32[10]` / `UInt32[101]` |
| `.IMQN_TABLE` / `.AMSNETID` / `.ARRMODULENO` | `LREAL` / `USINT` / `BYTE` arrays | LReal / USInt / SInt | `Double[32]` / `Byte[6]` / `SByte[13]` (changing on Z33856) |
@@ -31,35 +31,89 @@ plaintext secret on every node, no matter its role:
combination — must unwrap the store with the exact same master key. A
per-node KEK (e.g. a per-box DPAPI-protected key) would make each node
decrypt every *other* node's ciphertext rows to garbage.
2. **Identical store rows on every node.** All nodes must read the same SQLite
database (same file, or a replicated/shared copy with the same rows) — not
independently-seeded stores that happen to share a KEK.
2. **The same store rows on every node of a pair.** Nodes must read the same rows —
not independently-seeded stores that merely share a KEK. Either mount one SQLite file
(see "Alternative: shared volume" below) or, preferred, turn on Akka secret replication
so each pair converges its own rows.
`ZB.MOM.WW.Secrets` ships a SQLite-only `ISecretStore` with a `NoOpSecretReplicator`
— there is no built-in cross-node replication today. Meeting both requirements in
production is a deployment concern, covered below.
The base `ZB.MOM.WW.Secrets` store is a plain local SQLite `ISecretStore` whose default
replicator is `NoOpSecretReplicator`: **with `Secrets:Replication:Enabled=false` (the
default) nothing replicates and each node's store is private.** That is what this page
used to describe as the only option. It is no longer: pair replication exists
(`ZB.MOM.WW.Secrets.Replicator.AkkaDotNet`, lib pin `0.6.2` in `Directory.Packages.props`;
gated by `SecretsRegistration`, see below).
## Recommended interim posture (G-5)
## Recommended posture: pair-local Akka replication
Until real replication exists (G-7, below), the recommended production posture is:
Opt in with `Secrets:Replication:Enabled=true` on **every node** (default `false`, so absent
config means a plain local SQLite store). `SecretsRegistration.AddOtOpcUaSecrets`
(`src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecretsRegistration.cs`) then registers
`AddZbSecretsAkkaReplication` **instead of** `AddZbSecrets` (the two are alternatives, never
additive) and a `SecretReplicationStarter` hosted service resolves the store at boot so a
node that never reads a secret still joins anti-entropy. Keys: `Secrets:Replication:Enabled`,
`:AnnounceInterval` (default `00:00:30`; each node publishes a name/revision/timestamp/tombstone
manifest per interval, no ciphertext), `:ActorName` (`zb-secret-replication`).
- **`Secrets:MasterKey:Source = File`**, with `FilePath` pointing at a **read-only
key file that is identical on every node of every role** — a base64-encoded
32-byte key, generated out-of-band (e.g. `openssl rand -base64 32`), distributed
to each node's filesystem/secret-mount by the deployment tooling, and **never
committed** to the repo. Treat it with the same discipline as any other
production secret (restrictive file ACLs, no logging, rotated via a future
KEK-rotation runbook — not yet built).
- **`Secrets:SqlitePath`** pointing at a **single shared or replicated volume**
that every node mounts (admin, driver, and dev/fused alike), so every node's
migrator opens and reads the same rows at boot.
- **Scope is PAIR-LOCAL.** Since mesh Phase 6 each application cluster is its own 2-node Akka
mesh and replication rides that mesh's DistributedPubSub, so a secret converges **within its
pair and nowhere else**. There is no fleet-wide sync (site nodes have no SQL hub). Seed each
pair independently: central via `/admin/secrets`, driver-only site pairs (no AdminUI) via the
secrets CLI against one node's store, or a ciphertext-only bundle import. See
`docs/Redundancy.md` §"Secrets replication is pair-local".
- **Both nodes of a pair must share the same KEK** — rows replicate verbatim (ciphertext, wrapped
DEK and `kek_id` untouched), never re-encrypted.
- **Deletes replicate** as soft tombstones (last-writer-wins on `updated_utc`/`revision`).
- **Re-wraps do not replicate** (a re-wrap preserves `revision`/`updated_utc`, so it is invisible to
last-writer-wins). KEK rotation therefore runs against each node's store — see the next section.
- The store should sit on a **persistent per-node path** (`Secrets:SqlitePath`, e.g. the node's
`/app/data` volume in containers); the relative default lands inside the image layer and is lost on
recreate.
- Evidence: live-gated PASS 4/4 on the three-mesh docker-dev rig (convergence <= 7 s, no cross-pair
leakage in any direction, delete < 1 s, clean restart without resurrection) —
`docs/plans/2026-08-07-secrets-pair-local-live-gate.md`. Replication stays default-off in the product.
Writes to the store are rare and human-driven — an operator using the
`/admin/secrets` UI (admin nodes only) or the `ZB.MOM.WW.Secrets` CLI — while
reads happen on every node's boot and on the `ResolveCacheTtl` refresh cycle,
regardless of role. The access pattern is read-mostly / effectively
single-writer, which is what makes a shared SQLite volume viable as an interim
posture (see caveat below).
Whichever store layout is used, the KEK is delivered per node as before:
**`Secrets:MasterKey:Source = File`** with `FilePath` pointing at a read-only base64 32-byte key
(`openssl rand -base64 32`), distributed identically to every node of the pair by deployment tooling and
**never committed**. Writes are rare and human-driven (`/admin/secrets` on admin nodes, or the
CLI); reads happen at every node's boot and each `ResolveCacheTtl` refresh.
## Rotating the KEK
Rotation re-wraps each row's per-secret DEK under the new KEK (`wrapped_dek`, `wrap_nonce`,
`wrap_tag`, `kek_id` change; the sealed body, `revision` and `updated_utc` do not). The
authoritative operator procedure and troubleshooting table live with the library:
`scadaproj/ZB.MOM.WW.Secrets/docs/operations/kek-rotation.md`, driven by the `secret rewrap-all`
CLI verb (in that repo; not packed). The OtOpcUa-specific points:
1. **Back up every store first** (copy each node's SQLite file) — the backup is the complete undo.
2. **Stage the new KEK on every node of the pair** (same file path / env var as `Secrets:MasterKey`
resolves) but do not restart yet. Never pass key values on a command line; use
`--old-key-env VAR` / `--old-key-file PATH` and, if the new key is not the node's configured
provider, `--new-key-file`.
3. **Run `secret rewrap-all --old-key-env ZB_SECRETS_OLD_KEY` once per store, i.e. on EACH node.**
Because re-wraps do not replicate, rotating one node leaves its partner's rows on the old
`kek_id`. With replication on, do not leave the pair half-rotated: a node whose rows carry a KEK
it cannot unwrap fails closed (`Row wrapped by unknown KEK`) on resolve.
4. **Quiesce first**: stop (or drain) the node while its store is rewrapped. Rotating a pair
node-by-node while the other node keeps serving works for resolves, but do not author secrets
anywhere in the pair mid-rotation — a fresh write is wrapped under whichever KEK the writing node
currently uses and replicates verbatim to a partner that may not yet hold that KEK.
5. **Cut over**: confirm `Secrets:MasterKey` resolves the new key on both nodes, start them, and
confirm a `${secret:}` value resolves (the pre-host expander fails the boot loudly if not).
6. **Destroy the old key** only after both stores resolve cleanly. Re-running `rewrap-all` is
idempotent (`alreadyCurrent`), so an interrupted pass is resumed by running it again.
7. Rollback: restore the step-1 backups on both nodes and revert the KEK source.
Pair rotation across **separate pairs** (central, site-a, site-b) is independent per pair; there is no
fleet-wide order.
## Alternative: shared SQLite volume (no replication)
The earlier interim posture — every node of a pair mounting one `Secrets:SqlitePath` on a shared
or replicated volume — still works for a deployment that keeps `Secrets:Replication:Enabled=false`:
reads dominate and writes are effectively single-writer. See the caveat below; it is not safe with
concurrent writers, and it is impractical for site pairs whose nodes share no volume.
## How it's delivered (do NOT commit these values)
@@ -107,14 +161,14 @@ or key is missing.
SQLite's file-locking model does not tolerate concurrent multi-writer access well
over network filesystems (SMB/NFS locking is unreliable, and even on a clustered
block volume only one writer should be active at a time). The interim posture
block volume only one writer should be active at a time). The shared-volume alternative
above is acceptable because:
- Reads dominate (every node's boot + cache-refresh cycle, across every role).
- Writes are rare, human-initiated, and effectively single-writer in practice
(an operator runs the CLI/UI against one admin node at a time).
It is **not** a substitute for real replication, and it is not safe if multiple
It is **not** a substitute for real replication (Akka pair replication, above), and it is not safe if multiple
nodes attempt concurrent writes. Do not build automation that writes secrets
from more than one node simultaneously.
@@ -128,31 +182,15 @@ ConfigDb. That mechanism is unrelated to `ZB.MOM.WW.Secrets`' envelope encryptio
(KEK + SQLite store) and must **not** be reconfigured as part of secrets-adoption
work — doing so risks invalidating active sessions for an unrelated reason.
It is, however, the model for where the *next* iteration of secret storage
should go — see the G-7 hand-off below.
## The G-7 hand-off (status)
## The G-7 hand-off
The posture above is an interim, ops-only workaround. The long-term shape,
tracked as **G-7** in `scadaproj/components/secrets/GAPS.md`, is a
ConfigDb-backed `ISecretStore` that mirrors the pattern OtOpcUa already uses for
the Data Protection key ring:
```csharp
services.AddDataProtection()
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
.SetApplicationName("OtOpcUa");
```
(`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:73-75`).
`OtOpcUaConfigDbContext` already gives every node — admin, driver, and dev/fused
alike — a single MS SQL-backed source of truth for the Data Protection key ring;
the secret store is the natural next tenant of that same shared database instead
of a shared SQLite file. Building this requires new `ZB.MOM.WW.Secrets` library
code (a ConfigDb-backed `ISecretStore` implementation) that does not exist yet,
overlaps the G-7 tracking item, and is explicitly **deferred there** — it is not
built as part of this cut. This runbook's shared-SQLite-volume posture is the
bridge until G-7 lands.
G-7 (`scadaproj/components/secrets/GAPS.md`) asked for real cross-node replication. It was
built in two flavours in the library (`ZB.MOM.WW.Secrets.Replicator.SqlServer` and
`.AkkaDotNet`, plus a gRPC replicator package). **OtOpcUa production uses the Akka pair-local
option** (the decision recorded in scadaproj#3), wired by `SecretsRegistration` as described above;
the ConfigDb-backed store this page used to anticipate is not what shipped here. Driver-only nodes
have no ConfigDb since mesh Phase 4, which is a further reason a ConfigDb secret store would not
reach them.
## Dev/test/default posture (unchanged)
@@ -92,7 +92,12 @@ Replication has two knobs, both under `LocalDb`:
> 2026-08-18 `LocalDbSetup.OnReady` installs the change-capture triggers only when the node is
> configured to replicate — the predicate is **`PeerAddress` OR `ApiKey` non-blank** (the OR because
> the passive node has no `PeerAddress`; `SyncListenPort` is deliberately not part of it, since a
> port with no key is fail-closed and syncs with nobody). Consequences at enablement time:
> port with no key is fail-closed and syncs with nobody). The alarm-historian drain gate uses the
> stricter `ApiKey` AND (`PeerAddress` OR `SyncListenPort > 0`) — so set the key **and** the port/peer
> together, or the node keeps draining its own queue. Once gated, the passive half stands down for
> its **per-cluster mesh partner** (it has no `PeerAddress` to name one) — so it must carry the pair's
> `cluster-{ClusterId}` role, or as Secondary it keeps draining beside its Primary and the historian
> gets every alarm event twice (review R6 MINOR-4). Consequences at enablement time:
>
> - A configured node registers with **`baselineExistingRows: true`**, so the rows **already in the
> file** are seeded into the row-version ledger at the LWW floor (HLC `0`) and actually travel.
@@ -1,19 +1,19 @@
{
"planPath": "docs/plans/2026-08-15-perf-roadmap-remainder.md",
"tasks": [
{"id": 1, "subject": "Task 1: Outbox batch append with group commit", "status": "in_progress", "note": "6ff64ca0 + fix 73205865 (incremental bookkeeping, second-reader durability pin); re-review in flight"},
{"id": 1, "subject": "Task 1: Outbox batch append with group commit", "status": "completed", "note": "6ff64ca0 + fix 73205865 (incremental bookkeeping, second-reader durability pin); merged to master (verified 2026-10-08)"},
{"id": 2, "subject": "Task 2: Batched mux→subscriber delivery, single-eval virtual tags", "status": "completed", "note": "content inside 140bf0c1 (shared-index sweep; message belongs to Task 22 — needs an outcomes note); spec + code approved; code review's two Importants (yielding-fake batch test, mid-batch exception policy) folded into Task 4's brief"},
{"id": 3, "subject": "Task 3: Parallel drain with bounded concurrency", "status": "completed", "note": "a76b4e07; spec + code APPROVED; follow-up (inherited, pre-existing): bare catch around WriteLiveValuesAsync swallows OperationCanceledException at shutdown → spurious _tagWriteFailures increments when succeeded>0; recommend rethrowing genuine ct cancellations"},
{"id": 4, "subject": "Task 4: Recorder batched ingest — one fsync per burst", "status": "completed", "blockedBy": [1, 2, 3], "note": "384568b4 + follow-up 451273c8 (append-failure branch pinned via 3 independent mutations, loss-accounting docs corrected, recorder 64→1024 literals aligned); all reviews APPROVED; register: #491 falsification nondeterministic under load, IngestFaults not surfaced in docs/gauges, latch re-arms on any non-throwing item"},
{"id": 5, "subject": "Task 5: Historian defaults, config drift, docs", "status": "completed", "blockedBy": [3, 4], "note": "0298a0b9 (new Historian.md key table, cross-repo batch-RPC follow-up recorded); code APPROVED; recorder 64→1024 literals aligned in 451273c8; minor: pinning-test doc comment overclaims appsettings-drift coverage"},
{"id": 6, "subject": "Task 6: Per-session decision-cache shards", "status": "completed", "note": "debd44c2; follow-ups: _globalCount drift under races, concurrency stress test, bound-ratio comment"},
{"id": 7, "subject": "Task 7: Authorizer hot path — lookup first, no closures, lazy deny reasons", "status": "in_progress", "blockedBy": [6]},
{"id": 7, "subject": "Task 7: Authorizer hot path — lookup first, no closures, lazy deny reasons", "status": "completed", "blockedBy": [6], "note": "d8a17f2e; merged to master (verified 2026-10-08)"},
{"id": 8, "subject": "Task 8: Browse and TranslateBrowsePath compaction", "status": "completed", "note": "541a223b + follow-up test aad7583b (survivor-order pin, falsified red, review APPROVED); minor: FanOutTargetIdList fake couples to SDK single-Add contract — comment if pattern reused"},
{"id": 9, "subject": "Task 9: RevalidateMonitoredItems dedup", "status": "in_progress", "blockedBy": [8], "note": "fbe8aaad; realm-qualified 3-tuple key (safer than spec); spec+code reviews in flight; watch: its clock-count observable couples to DataPlaneAuthorizer reading the clock once — Task 7 edits that file"},
{"id": 9, "subject": "Task 9: RevalidateMonitoredItems dedup", "status": "completed", "blockedBy": [8], "note": "fbe8aaad; realm-qualified 3-tuple key (safer than spec); merged to master (verified 2026-10-08); watch: its clock-count observable couples to DataPlaneAuthorizer reading the clock once — Task 7 edits that file"},
{"id": 10, "subject": "Task 10: Modbus InitializeAsync transport leak", "status": "completed", "note": "c3970a2e + 409a58b6"},
{"id": 11, "subject": "Task 11: PollGroupEngine fixed-rate pacing, overrun counter, async unsubscribe", "status": "completed", "blockedBy": [10, 12, 13, 14], "note": "9cf400f1 + tolerance fix 485f4ea1 (fast-reader test: 200ms interval, <=1 blip); spec ✅ + code APPROVED; follow-ups: NoteOverrun conflates slow-success/slow-failure (doc caveat), backoffCap param doc note for future callers"},
{"id": 12, "subject": "Task 12: S7 narrow-scalar span codec", "status": "completed", "note": "8d91833f; SByte spec mention was a plan error"},
{"id": 13, "subject": "Task 13: S7 span planner + grouped ReadAsync", "status": "in_progress", "blockedBy": [12], "note": "39f67a0f; spec passed (demotion rule + factory-binding 6th file accepted); code review in flight; follow-up: live gate vs s7_1500 fixture before trusting 16/220 defaults"},
{"id": 13, "subject": "Task 13: S7 span planner + grouped ReadAsync", "status": "completed", "blockedBy": [12], "note": "39f67a0f; spec passed (demotion rule + factory-binding 6th file accepted); merged to master (verified 2026-10-08); follow-up: live gate vs s7_1500 fixture before trusting 16/220 defaults"},
{"id": 14, "subject": "Task 14: AbCip bounded-parallel reads", "status": "completed", "note": "d6a4fbd6 + fix d5f573b0 (per-device ConnectGate single-flight); re-review APPROVED; minor follow-ups: stale EvictRuntime-race comment ~L1161, test timing margin on slow CI"},
{"id": 15, "subject": "Task 15: Dirty-set coalescing in ScriptedAlarmEngine", "status": "completed", "note": "5df793e7; follow-ups: TrackBackgroundTask registration-ordering gap (pre-existing), stale line-334 comment, non-volatile _disposed"},
{"id": 16, "subject": "Task 16: Deployments list projection", "status": "completed", "note": "dbc9a132"},
@@ -8,7 +8,7 @@
{"id": 5, "subject": "Task 5: Live gate — FetchAndCache legs", "status": "completed", "note": "GATE PASSED, no commit. All 6 legs: fetch+SHA-verify on all 4 site nodes (1584 bytes from central-1:4055), Served×4 central-side, applied-FETCHED + cached on site-a-1 (acks=6), boot-from-cache after restart (restored rev matches what it cached 52s earlier), absence proof (0 Stale/ConfigDb-back/Direct-form/ERR/WRN over full lifetime), post-restart browse proves both namespaces materialised (ns=2 raw + ns=3 UNS). Method: same-revision deploys short-circuit the fetch path (immediate-ACK at HandleDispatchFromSteady) — gate forced a temp UnsArea row then reverted; rig config restored byte-identical (rev 315de861). Register: central-2 serve path unexercised (first-success endpoint walk always hit central-1)"},
{"id": 6, "subject": "Task 6: Live gate — alarm-bearing surgical ratchet", "status": "completed", "note": "GATE PASSED, no commit. Four deploys, all routings correct: Writable flip ⇒ kind=AttributeOnly surgicalRawTags=1 rebuild=False; alarm ADD ⇒ kind=Rebuild rebuild=True; alarm REMOVE ⇒ rebuild=True (previous-side disqualifies); restore ⇒ surgical again (ratchet per-deploy, not latched). All sealed 6/6 acks. Authoring: direct UPDATE on the rig's OWN sql container (Database=OtOpcUa — NOT 10.100.0.35; sqlcmd needs -I for QUOTED_IDENTIFIER). Findings: only cluster-MAIN nodes emit applier lines (site plans empty via ResolveClusterScope, apply early-returns); artifact hash can never return to baseline after any UPDATE — ConfigComposer snapshots SQL rowversion (verified by gunzip+diff: only the RowVersion differs). Rig config semantically restored; rig UP"},
{"id": 7, "subject": "Task 7: Live gate — ACL-enabled enforcement on site-a", "status": "completed", "note": "GATE PASSED, no commit. TOPOLOGY ADAPTATION: gated node = central-1 via scratchpad-only compose overlay (site-a serves NO value nodes — the rig's one tag is cluster MAIN; central-2 became the ungated A/B control). ALLOW leg genuinely load-bearing: ungated baseline captured first — same key+call DENIED 0x801F0000 with ACL off (Tag grant confers no role), SUCCEEDED with ACL on ⇒ ACL decision REPLACES the role check, live. Deny×3 (anonymous write, out-of-scope browse, ungated control reads fine); revocation split into grant-removal (gen 1→2, primed per-key shard discarded — exercises this round's CAS-loop path) and key-disable (pre-cache re-check). Authoring: DB fallback (Chrome extension not connected) — ApiKeySecret hashing reproduced from source, pepper verified live. Restore byte-identical (rev a3a9061b), zero residue. FINDINGS for register: (1) browse transit stops below the realm root — grant holder cannot browse DOWN to its own tag from ns=2;s=OtOpcUa (fails closed; file as issue); (2) ns=0 core nodes are ungated (CoreNodeManager bypasses DataPlaneAuthorizer) — boot warning overstates 'see nothing'"},
{"id": 8, "subject": "Task 8: Close-out — sweep + Outcomes + register annotations", "status": "completed", "blockedBy": [1, 2, 3, 4, 5, 6, 7, 9, 10], "note": "c4d6cba7 (Outcomes + sweep record + r2 register annotations, +383/−0) + the coordinator's flake-register append. Rig torn down BEFORE the sweep (rig-up skews skips: Host.IntegrationTests 229/0/2 up vs 226/0/5 down). Build 0 errors / 904 warnings — IDENTICAL to r2 (note: incremental builds under-report at 896). Sweep 43/43 green: 10,377 / 0 / 19 — +7 reconciled exactly (OpcUaServer +1 T1, S7 +6 T2); skips identical project-for-project; AdminUI 1540/0-skipped + Configuration 190/0-skipped ran vs 10.100.0.35,14330. Host.IntegrationTests (outside the 43-project baseline) +9 vs merge-base (LocalDbSetupTests 7→15, WiringTests 6→7). One flake isolated: Client.CLI LoggerLifecycleTests — pre-existing global-Serilog race, no r3 commit touches it, registered"}
{"id": 8, "subject": "Task 8: Close-out — sweep + Outcomes + register annotations", "status": "completed", "blockedBy": [1, 2, 3, 4, 5, 6, 7, 9, 10], "note": "c4d6cba7 (Outcomes + sweep record + r2 register annotations, +383/−0) + the coordinator's flake-register append. Rig torn down BEFORE the sweep (rig-up skews skips: Host.IntegrationTests 229/0/2 up vs 226/0/5 down). Build 0 errors / 904 warnings — IDENTICAL to r2 (note: incremental builds under-report at 896). Sweep 43/43 green: 10,377 / 0 / 19 — +7 reconciled exactly (OpcUaServer +1 T1, S7 +6 T2); skips identical project-for-project; AdminUI 1540/0-skipped + Configuration 190/0-skipped ran vs 10.100.0.35,14330. Host.IntegrationTests (outside the 43-project baseline) +9 vs merge-base (LocalDbSetupTests 7→15, WiringTests 6→7). One flake isolated: Client.CLI LoggerLifecycleTests — pre-existing global-Serilog race, no r3 commit touches it, registered"},
{"id": 9, "subject": "Task 9: Roslyn evaluator racing test — remove timing assumption (ScadaBridge row 38)", "status": "completed", "note": "34829ea0 + 837d8a17 (review round). Option (b): renamed to Evaluate_racing_ClearCompiledScripts_never_leaks_a_raw_disposed_failure; three-bucket classification (leaked=fail-first, exhausted + starved each bounded max(evals/10,8)); duration-bounded loops (10s OR 400 iters) — the REAL load failure was the test's own 45s WaitAsync budget under recompile pressure (old shape RED 4/4 at 4-way concurrency, new GREEN 4/4 + 8/8); reviewer-found starvation-timeout flake vector closed (TimedScriptEvaluator's 5s clock starts at enqueue), falsified via 12ms-budget probe (old classification RED, new GREEN at 0.11%); literals promoted to shared ScriptEvalFailureReasons in Commons (both evaluators + test). Honest disclosure: disposed race does not reproduce on this arm64 box at all. Spec ✅ + code CHANGES REQUESTED → delta re-APPROVED. Host.IntegrationTests 229/0/2, Calculation 53/53"},
{"id": 10, "subject": "Task 10: LocalDb conditional registration + baseline + deregister self-heal (ScadaBridge row 39)", "status": "completed", "note": "3e62f161 + df533916 (review round: content-based data-survival pin, mid-loop loud-fail pin via exhaustive delegating fake, Log.Logger assumption comment). Spec ✅ + code APPROVED + delta re-APPROVED. Predicate = PeerAddress OR ApiKey (SyncListenPort deliberately out — passive endpoint fail-closed on key); 0.2.1 APIs verified by reflection+probe (baseline seeds hlc=0; DeregisterReplicated returns bool) — NO pin bump; 0.3.1 now published (ScadaBridge heads-up) — bump recorded as deliberate both-nodes-together future change (bookkeeping v2→v3 one-way). Surfaced+fixed two vacuous-fixture bugs (PairHarness/AlarmSfLegacyMigrator two-config split). scadaproj index updated + pushed (1145df6). Suites: LocalDbSetupTests 15/15, Host.IntegrationTests 226/0/5"}
],
@@ -0,0 +1,112 @@
# auto/driver-gaps branch ledger (Gitea #607)
Classification of the 92 driver-gap issues (#225–#316) closed against PRs merged into `auto/driver-gaps`, which never reached master. Verdicts from symbol/file/doc greps against master `3baa332d` (2026-10-08). Counts: PRESENT 18, SUPERSEDED 9, STRANDED 61 (~190 agent-days to port), UNCLEAR 4. Per-driver porting backlogs are filed as separate issues; the branch is archived as tag `archive/auto-driver-gaps`.
Issue to commit mapping is by driver + sequence (one non-merge commit per issue; verified against titles). Verdict basis: symbol/file grep on master src plus reading master docs; no hardware.
| issue | title | driver | branch commit(s) | verdict | evidence | port effort |
|---|---|---|---|---|---|---|
| #225 | AbCip - LINT/ULINT 64-bit fidelity | abcip | 2e6228a2 (5 src lines/2 files) | PRESENT | AbCipDataTypeExtensions.cs maps LInt->Int64, ULInt->UInt64; Ab.Transport LogixTypeCode 64-bit | - |
| #226 | AbCip - Native STRING / STRINGnn variant decoding | abcip | d78a471e (27 src lines/4 files) | UNCLEAR | Master native runtime handles built-in STRING (82-char capacity, template-derived capacity; 4e6eed74/#570) but no STRINGnn custom-string-type decode found; human to check against fixtures | - |
| #227 | AbCip - Array-slice read addressing Tag[0..N] | abcip | 767ac4ae (266 src lines/5 files) | SUPERSEDED | Array tags authored via TagConfig isArray/arrayLength -> ElementCount (AbCipDriverOptions.cs, AbCipDriver.cs:901); `Tag[0..N]` slice syntax/planner absent but v3 tag authoring replaces it | - |
| #228 | AbCip - CIP multi-tag write packing | abcip | 36b29297 (266 src lines/2 files) | PRESENT | Native transport batches via Ab.Transport/Cip/MultipleServicePacket.cs + RequestScheduler.cs (#582 era); libplctagbranch planner not needed | - |
| #229 | AbCip - L5K parser + ingest | abcip | 86407e6c (612 src lines/5 files) | STRANDED | No L5K parser on master (grep L5K empty) Port risk: High: branch import ran at driver Initialize via IL5kSource; v3 wants it as a /raw import source (Commons/Csv-style staged review), not driver code | L |
| #230 | AbCip - L5X (XML) parser + ingest | abcip | cfcaf5c1 (299 src lines/3 files) | STRANDED | No L5X parser on master Port risk: High: same re-homing to /raw import; reuses L5K model from #229 | M |
| #231 | AbCip - Tag descriptions surfaced as OPC UA Description | abcip | e5299cda (69 src lines/7 files) | STRANDED | No Description plumbing on master (DriverAttributeInfo has none); only meaningful with L5K/L5X Port risk: Medium: touches Core.Abstractions + node manager | S |
| #232 | AbCip - CSV tag import / export | abcip | 08d8a104 (514 src lines/5 files) | SUPERSEDED | v3 /raw CSV import/export (Commons/Csv, RawTree) is the mechanism; branch CsvTagImporter/Exporter + CLI TagExport absent | - |
| #233 | AbCip - Online tag-DB refresh trigger | abcip | 6e244e0c (174 src lines/3 files) | SUPERSEDED | v3: re-browse is operator-driven via /raw browse-commit; DeployWatcher/IRediscoverable advisory only (CLAUDE.md). IDriverControl.RebrowseAsync absent | - |
| #234 | AbCip - AOI input/output handling | abcip | e3c0750f (162 src lines/5 files) | STRANDED | No AOI qualifier handling in ingest (AbCipDriver only has AOI alarm hidden-host handling) Port risk: Low; dependent on #229/#230 | S |
| #235 | AbCip - Configurable CIP Connection Size per device | abcip | f6c26db6 (157 src lines/6 files) | PRESENT | AbCipDriver.cs:1877-1899 Options.ConnectionSize ?? Profile.DefaultConnectionSize; IAbCipTagRuntime ConnectionSize | - |
| #236 | AbCip - Symbolic vs logical (instance-ID) addressing toggle | abcip | 0c6a0d6e (391 src lines/7 files) | STRANDED | No AddressingMode/logical instance-ID mode on master Port risk: Medium: native transport (Ab.Transport) is the default now; must be implemented there, libplctag path legacy | M |
| #237 | AbCip - Logical-blocking / non-blocking strategy selector | abcip | 01f4ee6b (470 src lines/5 files) | SUPERSEDED | Native transport MultipleServicePacket + AbCipUdtReadPlanner.cs cover read strategy; no ReadStrategy selector but intent met | - |
| #238 | AbCip - Per-tag scan rate / scan group bucketing | abcip | b4571362 (136 src lines/3 files) | STRANDED | No per-tag ScanRate/scan-group in AbCip Port risk: Medium: v3 poll/subscription engine is driver-shared (PollGroupEngine); a per-tag bucket must fit it | M |
| #239 | AbCip - Write deadband / write-on-change | abcip | da9936f7 (307 src lines/4 files) | STRANDED | No AbCipWriteCoalescer / write deadband on master Port risk: Medium: write-suppression semantics interact with v3 write-outcome self-correction; questionable value | M |
| #240 | AbCip - Diagnostic / system tags as browseable variables | abcip | 901a5b9b (351 src lines/2 files) | STRANDED | AbCipSystemTagFilter.cs on master FILTERS system tags out; no AbCipSystemTagSource Port risk: Medium: v3 raw tree requires explicit authoring; synthesize via DiscoverAsync only | M |
| #241 | AbCip - _RefreshTagDb writeable system tag | abcip | e0e5e04e (254 src lines/2 files) | STRANDED | No _RefreshTagDb handling Port risk: Low; depends on #240 and loses meaning (discovery injection deleted in v3) | S |
| #242 | AbCip - HSBY paired-IP probing | abcip | 561b0f9e (611 src lines/6 files) | STRANDED | No HSBY role prober Port risk: Medium: hardware-only (redundant ControlLogix pair); driver->host routing | L |
| #243 | AbCip - IPerCallHostResolver failover routing | abcip | 9e157fc8 (224 src lines/1 files) | STRANDED | AbCipDriver implements IPerCallHostResolver (:661) but no HSBY active-address routing (triage-E 'present' is for the interface only) Port risk: Depends on #242 | M |
| #244 | AbLegacy - PLC-5 octal I/O addressing | ablegacy | 8f726518 (72 src lines/4 files) | STRANDED | No octal I/O handling in AbLegacy/Ab.Transport (grep octal empty; Pccc parses I:/O: only) Port risk: Low (verify on ab_server) | S |
| #245 | AbLegacy - MicroLogix function-file letters | ablegacy | f2bc3634 (92 src lines/3 files) | STRANDED | No MicroLogix function-file letters (RTC/HSC/etc.) in PcccFileType/AbLegacyDataType Port risk: Low | S |
| #246 | AbLegacy - Sub-element bit semantics for Timer/Counter/Control | ablegacy | c89f5bb3 (130 src lines/3 files) | PRESENT | AbLegacyAddress.cs sub-element parsing (T4:0.DN etc.); NativeAbLegacy refuses status-bit writes (docs/drivers/AbLegacy.md:177) | - |
| #247 | AbLegacy - Indirect / indexed addressing parser | ablegacy | 4ff4cc58 (131 src lines/2 files) | STRANDED | No indirect/indexed address parser on master Port risk: Low; niche | S |
| #248 | AbLegacy - PD/MG/PLS/BT structure files | ablegacy | 286ab3ba (199 src lines/4 files) | STRANDED | No PD/MG/PLS/BT file types in PcccFileType Port risk: Low-medium; unverified on wire (see CLAUDE.md AbLegacy native evidence) | M |
| #249 | AbLegacy - ST string read/write production verification | ablegacy | 00446039 (24 src lines/2 files) | PRESENT | NativeAbLegacyTagRuntime.cs StringDataBytes=82 + AbLegacyAddress ST docs; #582 | - |
| #250 | AbLegacy - Array contiguous block addressing | ablegacy | c689ac58 (227 src lines/6 files) | PRESENT | AbLegacyDriver.cs:345 IsArrayTag/DecodeArray/EffectiveArrayLength; AbLegacyTagDefinition.ArrayLength | - |
| #251 | AbLegacy - Per-tag deadband / change filter | ablegacy | eb528614 (156 src lines/4 files) | STRANDED | No AbLegacy deadband/change filter Port risk: Low; shared subscription engine may already dedup | S |
| #252 | AbLegacy - Per-device timeout / retry overrides | ablegacy | c292dcc1 (140 src lines/3 files) | UNCLEAR | Master has per-driver and per-device Timeout (AbLegacyDriverOptions.cs:49,104) but no Retries option found; human to confirm if retries still wanted | - |
| #253 | AbLegacy - Diagnostic counters as tags | ablegacy | 42472b55 (413 src lines/2 files) | STRANDED | No AbLegacyDiagnosticTags Port risk: Medium: synthetic tags not authorable in v3 raw tree without discovery | M |
| #254 | AbLegacy - RSLogix 500/PLC-5 symbol import | ablegacy | 4e8df38b (618 src lines/7 files) | STRANDED | No RSLogix CSV importer (only generic /raw CSV) Port risk: Medium: re-home as /raw import source, not CLI command | M |
| #255 | AbLegacy - Auto-demote on comm failure | ablegacy | 1e3053c0 (401 src lines/8 files) | STRANDED | No auto-demote options (AbLegacyDemoteOptions); master has probe + IHostConnectivityProbe only Port risk: Medium; interacts with comms-loss watchdog (#527) | M |
| #256 | AbLegacy - DH+ via 1756-DHRIO bridging | ablegacy | 39925737 (116 src lines/3 files) | STRANDED | No DH+/DHRIO validation (IsDhPlusBridge) Port risk: Low; recommend DROP (niche) | S |
| #257 | FOCAS - ODBST status flags as fixed-tree nodes | focas | 551494d2 (146 src lines/3 files) | STRANDED | Master has Identity/Axes/Spindle/Program/OperationMode/Timers fixed tree only (docs/drivers/FOCAS.md:213); no ODBST status flags Port risk: High: master FOCAS is a managed wire client rewrite (Wire/*); branch used FWLIB P/Invoke so must be re-done as wire commands; hardware-only to verify | M |
| #258 | FOCAS - Parts count + cycle time | focas | 3d9697b9 (162 src lines/4 files) | STRANDED | Timers/CycleSeconds present; parts count absent (partial) Port risk: High: wire-client rewrite; needs cnc_rdparam/rdtimer decode on hardware | S |
| #259 | FOCAS - Modal G/M/T codes + override values | focas | 3c2c4f29 (332 src lines/5 files) | STRANDED | No modal/override reads Port risk: High: wire-client rewrite | M |
| #260 | FOCAS - Tool number / tool life + work coordinate offsets | focas | 9ec92a90 (383 src lines/5 files) | STRANDED | No tooling/work-offset reads Port risk: High: wire-client rewrite | L |
| #261 | FOCAS - Operator messages + currently-executing block text | focas | cc757855 (276 src lines/4 files) | STRANDED | No operator message / block text Port risk: High: wire-client rewrite | M |
| #262 | FOCAS - cnc_getfigure scaling + connection statistics | focas | 1abf743a (282 src lines/5 files) | SUPERSEDED | Master DELETED cnc_getfigure (not a real wire cmd; 69e0a622); scaling now from each cnc_rddynamic2 block | - |
| #263 | FOCAS - DIAG: address scheme | focas | 451b37a6 (138 src lines/7 files) | STRANDED | No DIAG: address area (FocasAddress has PARAM/MACRO/PMC/FIXED only) Port risk: High: wire rewrite | S |
| #264 | FOCAS - Multi-path / multi-channel CNC | focas | 2f3eeecd (192 src lines/5 files) | STRANDED | No path-number / multi-path support found in FocasWireClient/options Port risk: High: wire rewrite; hardware | M |
| #265 | FOCAS - PMC F/G letters for 16i | focas | 63099115 (4 src lines/1 files) | STRANDED | FocasCapabilityMatrix.cs:87 Sixteen_i PMC letters still {X,Y,R,D}; branch adds F,G (4-line change) Port risk: Trivial, verify against 16i wire | S |
| #266 | FOCAS - Bulk PMC range read coalescing | focas | 4d3ee472 (401 src lines/4 files) | STRANDED | No PMC range coalescing (FocasPmcCoalescer); wire client has pmc_rdpmcrng primitive Port risk: Medium: fits wire client | M |
| #267 | FOCAS - cnc_rdalmhistry alarm-history extension | focas | 7f9d6a77 (615 src lines/5 files) | STRANDED | No cnc_rdalmhistry; FocasAlarmProjection is polled cnc_rdalmmsg2 only Port risk: High: wire rewrite + history decode | L |
| #268 | FOCAS - Write infrastructure + per-tag opt-in | focas | 1bfe8fba (77 src lines/5 files) | STRANDED | Master FOCAS is intentionally READ-ONLY: WireFocasClient.WriteAsync returns BadNotWritable (Wire/WireFocasClient.cs:13-15,73) Port risk: High: safety-critical on real CNCs; policy decision needed before any port | M |
| #269 | FOCAS - cnc_wrmacro + cnc_wrparam | focas | f48f31cf (186 src lines/6 files) | STRANDED | wrmacro/wrparam deliberately not exposed in wire client Port risk: High: as #268 | M |
| #270 | FOCAS - pmc_wrpmcrng | focas | 54c09d4d (171 src lines/5 files) | STRANDED | pmc_wrpmcrng not exposed; PMC bit-RMW absent despite stale comment in FocasDriverOptions Port risk: High: as #268 | M |
| #271 | FOCAS - Password / unlock parameter | focas | 86f3fc27 (284 src lines/7 files) | STRANDED | No password/unlock Port risk: High: as #268 | M |
| #272 | FOCAS - Cycle time per part / last cycle delta | focas | e3d7c65f (199 src lines/1 files) | STRANDED | No per-part cycle delta Port risk: Depends on #258 | S |
| #273 | OpcUaClient - Per-subscription tuning | opcuaclient | 7209364c (66 src lines/2 files) | STRANDED | Subscription params hardcoded (OpcUaClientDriver.cs:1245-1250) Port risk: Low | S |
| #274 | OpcUaClient - Per-tag advanced subscription tuning incl. deadband | opcuaclient | fae00749 (225 src lines/3 files) | STRANDED | No per-tag DataChangeFilter/deadband/sampling tuning (QueueSize=1 hardcoded) Port risk: Medium: ISubscribable/DriverCapability signature change on Core | M |
| #275 | OpcUaClient - Honor server OperationLimits | opcuaclient | 494fdf23 (164 src lines/1 files) | STRANDED | No OperationLimits handling Port risk: Low-medium; pure managed, fixture testable (opc-plc) | M |
| #276 | OpcUaClient - Diagnostics counters | opcuaclient | bb1ab47b (255 src lines/3 files) | STRANDED | No OpcUaClientDiagnostics counters Port risk: Low; DriverHealth.Diagnostics change | M |
| #277 | OpcUaClient - CRL / revocation handling | opcuaclient | 4a3860ae (176 src lines/2 files) | STRANDED | No CRL/revocation options (only RejectedCertificateStore) Port risk: Low-medium | M |
| #278 | OpcUaClient - Discovery URL FindServers | opcuaclient | 0f509fbd (160 src lines/2 files) | STRANDED | No FindServers / DiscoveryUrl Port risk: Low | M |
| #279 | OpcUaClient - Selective import / namespace remap | opcuaclient | 02d1c851 (163 src lines/2 files) | SUPERSEDED | v3 authors raw tags explicitly (browse-commit); NamespaceMap exists; selective curation superseded | - |
| #280 | OpcUaClient - Type definition mirroring | opcuaclient | cc21281c (330 src lines/3 files) | SUPERSEDED | Node mirroring of type definitions is discovery injection, deleted in v3 (#507 closed superseded) | - |
| #281 | OpcUaClient - Method node mirroring + Call passthrough | opcuaclient | 07abee5f (407 src lines/3 files) | SUPERSEDED | Method node mirroring is discovery injection, deleted in v3 | - |
| #282 | OpcUaClient - Auto re-import on ModelChangeEvent | opcuaclient | ab3ed6b6 (260 src lines/3 files) | SUPERSEDED | Auto re-import on ModelChange contradicts v3 advisory-only IRediscoverable; injection path deleted | - |
| #283 | OpcUaClient - Reverse Connect | opcuaclient | 5c72deb8 (396 src lines/3 files) | STRANDED | No reverse connect Port risk: Medium: SDK ReverseConnectManager; needs listener port + fixture | M |
| #284 | OpcUaClient - IHistoryProvider.ReadEventsAsync interface fix + impl | opcuaclient | c36903d6 (310 src lines/2 files) | PRESENT | OpcUaClientDriver.ReadEventsAsync (:1887) with HistoricalEventsResult; different shape than branch EventHistoryRequest but functional | - |
| #285 | OpcUaClient - Full Aggregate function set | opcuaclient | 0adc5adb (149 src lines/2 files) | STRANDED | HistoryAggregateType has only 5 values; MapAggregateToNodeId (:1779) throws otherwise Port risk: Low: Core enum append + mapping | S |
| #286 | OpcUaClient - ServerUriArray redundant failover | opcuaclient | 705c98ad (491 src lines/3 files) | STRANDED | No ServerUriArray failover; master has static EndpointUrls ordered list with PerEndpointConnectTimeout (partial) Port risk: Medium: interacts with comms-loss watchdog reconnect | L |
| #287 | S7 - 64-bit scalar types (LInt/ULInt/LReal/LWord) | s7 | d1699af6 (123 src lines/2 files) | PRESENT | S7DataType has Int64/UInt64/Float64 (S7DriverOptions.cs:195-198) | - |
| #288 | S7 - STRING / WSTRING / CHAR / WCHAR | s7 | 316f820e (246 src lines/3 files) | STRANDED | No WString/WChar/Char in S7DataType (String only); docs/drivers/S7.md lists deferred Port risk: Low-medium; pure codec, ab-style fixture (snap7) | M |
| #289 | S7 - DTL / DT / S5TIME / TIME / TOD / DATE | s7 | 2b66cec5 (448 src lines/3 files) | STRANDED | No DTL/S5TIME/TIME/TOD/DATE codecs (S7DataType.DateTime only; S5TIME only as Timer area word) Port risk: Low; pure codec | M |
| #290 | S7 - Array tags (ValueRank=1) | s7 | ce98c2ad (253 src lines/2 files) | PRESENT | S7 tags carry ArrayCount (S7DriverOptions.cs:169) -> ValueRank=1 | - |
| #291 | S7 - LOGO! 8 + S7-200 V-memory | s7 | e5122c54 (79 src lines/2 files) | STRANDED | No LOGO!/S7-200 V-memory parser Port risk: Low; niche, verify hardware | S |
| #292 | S7 - Multi-variable PDU packing | s7 | d7633fe3 (312 src lines/2 files) | PRESENT | IS7Plc.ReadMultipleVarsAsync (S7.Net packs) used by S7Driver | - |
| #293 | S7 - Block-read coalescing for contiguous DBs | s7 | 17faf76e (442 src lines/4 files) | PRESENT | S7ReadSpanPlanner.cs coalesces contiguous byte ranges (re-implemented) | - |
| #294 | S7 - PDU size negotiation surfaced | s7 | 6540bbe1 (28 src lines/1 files) | STRANDED | No negotiated-PDU surfaced via DriverHealth.Diagnostics (only AssertPduSizeForRead) Port risk: Low | S |
| #295 | S7 - TSAP / Connection Type selector | s7 | 3b98e4d3 (197 src lines/4 files) | STRANDED | Only slot-derived TSAP; no LocalTsap/RemoteTsap/ConnectionType override Port risk: Low | M |
| #296 | S7 - Per-tag scan group / publish rate | s7 | 162c82b8 (225 src lines/3 files) | STRANDED | No S7 scan-group intervals Port risk: Medium: shared poll engine | M |
| #297 | S7 - Deadband / on-change with thresholds | s7 | 06b39a28 (164 src lines/3 files) | STRANDED | No S7 deadband Port risk: Low-medium | M |
| #298 | S7 - Pre-flight PUT/GET enablement test | s7 | 64a11ef2 (195 src lines/4 files) | UNCLEAR | S7Driver.cs:744,1156,1161 maps PUT/GET-disabled errors with an actionable message at runtime, but no explicit preflight probe at init; human decide if sufficient | - |
| #299 | S7 - Symbol-table / TIA Portal export browse | s7 | a908dff7 (1293 src lines/8 files) | STRANDED | No TIA CSV / AWL symbol importers Port risk: Medium: re-home as /raw import source rather than CLI ImportSymbolsCommand | L |
| #300 | S7 - UDT / STRUCT / nested-DB handling | s7 | 5f8d84db (511 src lines/5 files) | STRANDED | No UDT/STRUCT fan-out Port risk: Medium: v3 raw tree needs explicit tags per member; fan-out at author time | L |
| #301 | S7 - Instance-DB / FB parameter access | s7 | baf1d658 (302 src lines/3 files) | STRANDED | No instance-DB/FB resolver Port risk: Depends on #299 | M |
| #302 | S7 - CPU diagnostic buffer / SZL reads | s7 | 108f69d1 (967 src lines/8 files) | STRANDED | No SZL reads / system tags Port risk: High: S7.Net SZL via reflection; synthetic tags not authorable in v3 raw tree; hardware | L |
| #303 | S7 - PLC password / protection-level handling | s7 | 30c3b10c (366 src lines/5 files) | STRANDED | No PLC password/protection-level handling Port risk: Medium; hardware | M |
| #304 | S7 - Optimized DB / S7Plus (decision PR) | s7 | b217ca61 (0 src lines/0 files) | STRANDED | Docs-only decision (docs/v2/s7.md, featuregaps.md, S7-Test-Fixture.md) not on master Port risk: Low: cherry-pick docs or drop | S |
| #305 | TwinCAT - Int64 fidelity for LINT/ULINT | twincat | 615ab256 (2 src lines/1 files) | PRESENT | TwinCATDataTypeExtensions.cs LInt->Int64, ULInt->UInt64 | - |
| #306 | TwinCAT - TIME/DATE/DT/TOD as native UA types | twincat | 80b2d7f8 (86 src lines/4 files) | STRANDED | TIME/DATE/DT/TOD map to UInt32 (TwinCATDataTypeExtensions.cs:26); branch adds native DriverDataType/UA types Port risk: Medium: touches Core DriverDataType + node manager | S |
| #307 | TwinCAT - Bit-indexed BOOL writes (RMW) | twincat | fcf89618 (71 src lines/1 files) | PRESENT | TwinCATBitAccess.cs bit-indexed BOOL parent-word RMW | - |
| #308 | TwinCAT - Multi-dim and whole-array reads | twincat | b6990523 (80 src lines/4 files) | PRESENT | ITwinCATClient arrayCount whole-array reads + TwinCATArrayCodec | - |
| #309 | TwinCAT - ENUM and ALIAS at discovery | twincat | 0df14ab9 (39 src lines/1 files) | PRESENT | AdsTwinCATClient.cs resolves ALIAS to base type (:180) | - |
| #310 | TwinCAT - ADS Sum-read / Sum-write | twincat | 931049b5 (334 src lines/3 files) | STRANDED | No ADS sum-read/sum-write Port risk: Medium: hardware/ADS router to verify | M |
| #311 | TwinCAT - Handle-based access with caching | twincat | b67eb6c8 (205 src lines/3 files) | UNCLEAR | AdsTwinCATClient has variable-handle addressing (0xF005) for handle-addressed symbols but no confirmed handle cache; human check | - |
| #312 | TwinCAT - Symbol-version invalidation listener | twincat | 4098d72b (104 src lines/1 files) | PRESENT | AdsTwinCATClient/ITwinCATClient OnSymbolVersionChanged + TwinCATStatusMapper.AdsSymbolVersionChanged | - |
| #313 | TwinCAT - Per-tag MaxDelay tuning | twincat | fb57717f (47 src lines/5 files) | PRESENT | TwinCATDriverOptions.NotificationMaxDelayMs wired at TwinCATDriver.cs:601,980 | - |
| #314 | TwinCAT - Cycle-time / jitter / PLC-state diagnostics | twincat | 24a3cda5 (248 src lines/2 files) | STRANDED | No TwinCATDeviceDiagnostics (cycle/jitter/PLC state); only probe ReadState Port risk: Medium; synthetic tags | M |
| #315 | TwinCAT - Nested UDT browse via online type walker | twincat | 0444cb69 (364 src lines/5 files) | PRESENT | TwinCATSymbolExpander.cs walks struct/UDT members at discovery (re-implemented) | - |
| #316 | TwinCAT - IAlarmSource via TC3 EventLogger | twincat | c88e0b6b (406 src lines/4 files) | STRANDED | No TC3 EventLogger IAlarmSource (branch was gated scaffold) Port risk: Medium; recommend DROP | L |
## Counts
PRESENT: 18, UNCLEAR: 4, SUPERSEDED: 9, STRANDED: 61
## Stranded by driver
- abcip: 11 issues (S=3, M=6, L=2) ~37 effort-days; src lines 3416; issues [229, 230, 231, 234, 236, 238, 239, 240, 241, 242, 243]
- ablegacy: 9 issues (S=5, M=4, L=0) ~17 effort-days; src lines 2198; issues [244, 245, 247, 248, 251, 253, 254, 255, 256]
- focas: 15 issues (S=4, M=9, L=2) ~47 effort-days; src lines 3566; issues [257, 258, 259, 260, 261, 263, 264, 265, 266, 267, 268, 269, 270, 271, 272]
- opcuaclient: 9 issues (S=2, M=6, L=1) ~28 effort-days; src lines 2082; issues [273, 274, 275, 276, 277, 278, 283, 285, 286]
- s7: 13 issues (S=3, M=7, L=3) ~48 effort-days; src lines 4826; issues [288, 289, 291, 294, 295, 296, 297, 299, 300, 301, 302, 303, 304]
- twincat: 4 issues (S=1, M=2, L=1) ~15 effort-days; src lines 1074; issues [306, 310, 314, 316]
+107 -3
View File
@@ -406,6 +406,27 @@ Both gates fail closed — a session with no identity or no matching role gets
the constants in `OpcUaDataPlaneRoles`, derived from the principal's cluster-scoped `NodeAcl` grants
(see [Role grant source](#role-grant-source-data-plane)).
**Both gates also re-check the session's API key on every call, flag off or on** (Gitea #563). A
session's roles are resolved once, at activation, so before this an open session kept `WriteOperate` /
`AlarmAck` after its key's `ExpiresUtc` passed — or after a deployment disabled or removed the key —
until the client chose to disconnect, which on a machine-to-machine data plane can be never. Now each
write and each Part 9 method call first asks `DataPlaneAuthorizer.AuthorizeKeyUsable`: the same
`FindUsableKey` probe, against the same deployed snapshot and the same clock, that the ACL path makes
(one dictionary lookup, no I/O). An unusable key gets `BadUserAccessDenied` (logged as
`AclGate: denied … key … is no longer enabled or has expired`), checked *before* the "no gateway ⇒
`BadNotWritable`" availability answer. Things to know:
- **Only these two gates.** Read, Browse, Subscribe and HistoryRead stay ungated with the flag off —
that is the flag-off posture, and an expired key's session can still read and keep its subscriptions.
Turn `Security:Acl:Enabled` on to have expiry reach those too.
- **Anonymous is unaffected.** It carries no key id, so there is nothing to expire; its roles stay what
activation resolved from the `anonymous` principal's grants.
- **No flag.** An expired or disabled key keeping write authority is a defect, not a rollout choice.
- ⚠️ **On a fused admin+driver node a key minted after the last deployment authenticates (the ConfigDb
store is read at activation) but its writes and acks are refused until a deployment carries it** — the
check reads the deployed snapshot, which is also what the flag-on path has always done. Deploy after
minting a key.
**What has no authorization check at all:**
- **Read, Browse, TranslateBrowsePaths** — no override, no handler. Any admitted session, including
@@ -432,6 +453,14 @@ takes no realm and no node.
### Node ACL enforcement (`Security:Acl:Enabled`)
> **ns=0 standard nodes stay ungated, deliberately (#617).** A session with no grants can still connect
> and read the standard OPC UA server nodes (`ServerStatus`, `NamespaceArray`, `ServiceLevel`, ...) and
> browse ns=0. Those are served by the SDK's core node manager outside our authorizer, and gating them
> would break session setup (clients read them for connection and redundancy health). They carry no plant
> data; every raw/UNS node, event subscription and HistoryRead is still gated. The boot warning says so
> ("can still connect and read the standard server nodes ... but sees no plant data") — earlier wording
> ("can connect and see nothing") overstated it.
| Key | Default | Effect |
|---|---|---|
| `Security:Acl:Enabled` | `false` | `true` makes scoped `NodeAcl` grants decide **every** data-plane operation — read, browse, subscribe, write, alarm commands and HistoryRead |
@@ -496,9 +525,84 @@ Behaviours worth knowing before enabling it:
no reconnect** — closing the session instead would force a full reconnect-and-resubscribe on every
machine consumer in the fleet at once when a shared key expired.
⚠️ **Event/alarm monitored items are revalidated by neither trigger.** The live-item map is fed only
from data-change items, so an alarm subscription is gated at creation and never again. Pre-existing —
the deployment trigger has the same hole — and tracked as a residual of arch-review 03/S19.
**Event/alarm monitored items are revalidated by both triggers too** (Gitea #562). A direct event
subscription — one on an equipment or device folder's event notifier — is tracked in a second map
alongside the data-change items and re-asked the creation gate's question (`Subscribe` on the
notifier's scope, for the identity of the session that created it). A denied item is **detached** from
the notifier's event fan-out through the SDK's own unsubscribe — exactly what the client's delete would
do — never set `MonitoringMode.Disabled`, so restoring its authority re-attaches it and alarms resume
with no client action. Events raised while it was stood down are not replayed. Two differences from a
data-change item:
- **The client is not told.** An event item has no value slot to carry `BadUserAccessDenied`, and a
fabricated "access denied" event would land in every alarm consumer's stream, so the feed simply goes
quiet. The operator-facing signal is the `AclGate: RevalidateEventSubscriptions withheld N of M` log line.
- **A client may delete a stood-down item** normally; the delete is absorbed (the item is already off
the fan-out) and a later restoring deployment does not resurrect it.
**Server-object (`i=2253`, all-events) event subscriptions are filtered per event at delivery** (Gitea
#586). They used to be outside the ACL entirely: the SDK attached such an item to the fan-out of every
one of our root notifiers through `SubscribeToAllEvents`, and an equipment folder with no direct
subscriber forwarded its events to the core node manager's Server object, which our node manager never
sees — so a session holding no grant received every alarm in the server. Now:
- The node manager attaches a Server-object item to **nothing**. It records it, and each condition hands
its events to the recorded items itself (the condition's own `OnReportEvent`, which fires exactly once
per event, so the Server object still sees one copy). The root-notifier forward to the core Server
object is dropped.
- Each event is delivered to an item only if the item's session holds `Subscribe` on **any** of: the
condition itself (`ConditionId` = RawPath, raw realm — so a grant on the alarm's raw tag admits it), a
root-notifier folder the condition bubbles through (its raw device folder; a scripted alarm's
equipment folder), or an equipment folder wired as its extra notifier. That is the per-folder path's
question — a session that may subscribe to any notifier the alarm reports through already receives it
there — asked with the same authorizer and the same operation as the per-folder creation gate. The
folders must be asked as well as the condition because the trie inherits downward only within a realm:
an `Equipment` grant covers the UNS folder, not the condition at its RawPath.
- **Revalidation is implicit, not trigger-driven.** Every event re-asks the authorizer, whose decision
cache is keyed per generation and which re-checks key revocation and expiry on every call — so a
deployment takes effect on the next event, and an expired key's feed stops on the next event, without
waiting for the `AclRevalidationTimer` tick. Nothing is detached, so restoring a grant needs no
re-attach: the next event simply passes. Events raised while withheld are not replayed, and the client
is not told (the same reasoning as a stood-down direct item).
- Creation is **not** refused — an all-events item is legitimate for a partially-granted session; what
is withheld is each event it may not see.
- **`ConditionRefresh` on a Server-object item** replays only the conditions the session passes the same
check on (once per condition, not once per notifier it is wired to). Without that, a refresh would
replay every condition's current state to any session.
- Inert with the flag off: every Server-object item receives every condition, as before.
- The replacement delivery keeps the two behaviours of the SDK path it replaced. **Audit events** a
condition reports through itself (`AuditConditionAcknowledgeEventType`, Comment, Enable, Shelving)
reach a Server-object item only while the server is auditing, over SignAndEncrypt or https-binary,
which is exactly the SDK's own guard. And while any Server-object item exists, every condition counts as
**monitored** (`ConditionState.EventsMonitored()`), which gates the SDK's method **audit** events on a
server whose only subscribers are Server-object items. Both were caught in review: the
first cut queued `i=8944` to every Server-object item with auditing off, and left conditions
unmonitored. The second also needed the SDK's `AddRootNotifier` neutralised: its "subscribe existing
all-events items with `unsubscribe: true`" bug would otherwise take the unit back from every
condition rebuilt under a live item. (Method-driven **state-change** events no longer depend on this
bookkeeping at all — the node manager reports them itself, Gitea #587; see `docs/AlarmTracking.md`.
The SDK's counter stays imprecise in one known way — a folder subscriber's unit never reaches a
condition materialised under that folder later, yet its unsubscribe still lowers it — which can now
only cost an audit event, and only while auditing is on.)
- Materialising a condition raises only **that** condition's monitored unit; the full reconcile (every
condition) runs on removal, rebuild and Server-object subscribe/delete. A deploy of N alarms under a
live Server-object item is therefore N bookkeeping steps, not N²/2 under the node-manager lock.
**Model-change and audit events on the Server object are gated too** (Gitea #588). The node manager's
own server-wide events — `GeneralModelChangeEvent` (NodeAdded / NodeDeleted / DataTypeChanged) and the
`AuditWriteUpdateEvent` of a rejected device write — used to go straight to `Server.ReportEvent`, where
the core Server object queued them to every Server-object item ungated: a session with no grant learned
the RawPath / UNS path of every node added, deleted or retyped, and (with auditing on) the written and
prior values of a rejected write. With the flag **on**, the node manager now delivers them itself, item by
item, to the Server-object items it already holds for #586 (the same audience — only Server-object items
ever received them):
- A **model-change** event keeps only the changes whose `Affected` node the session may **Browse**
(ancestors of a granted node included, as for Browse itself). Every event the server raises today carries
one change, so in practice it is delivered whole or not at all; a multi-change event would be delivered
as a copy holding only the visible changes, with a neutral `Message` (the original names the nodes).
- An **audit write** event needs `Read` on its source node — it carries the node's prior value and the
attempted one — on top of the SDK's own guard (server auditing on, SignAndEncrypt or https-binary).
Auditing stays **off by default**: this repo never sets `ServerConfiguration.AuditingEnabled`.
- Any other server-wide event type is withheld (fail closed); the node manager raises none today.
- With the flag **off**, these events go to `Server.ReportEvent` exactly as before.
- **An ancestor of a granted node is browsable**, or no narrow grant would be reachable: a client
arrives at a deep node by browsing its ancestors, and the trie otherwise inherits downward only. Note
what this necessarily discloses — the *names* of the folders on the path to a granted node are visible
+21 -5
View File
@@ -1,6 +1,22 @@
# OtOpcUa v2 — Driver Implementation Specifications
> **Status**: DRAFT — reference document for plan.md
> **Status (2026-10)**: v2-era DRAFT, **not authoritative**. It covers 8 of the 12 shipped drivers and
> several of its architecture claims are stale. The current per-driver references are the pages in
> [`docs/drivers/`](../drivers/README.md) (the README there lists all 12 and is the accurate overview).
>
> - **Not covered here at all:** MQTT/Sparkplug ([Mqtt.md](../drivers/Mqtt.md)), MTConnect
> ([MTConnect.md](../drivers/MTConnect.md)), Sql ([Sql.md](../drivers/Sql.md)), Calculation
> ([Calculation.md](../drivers/Calculation.md)).
> - **Hosting/tier claims are stale:** every driver runs in-process as Tier A (Galaxy reaches MXAccess over
> gRPC to `mxaccessgw`; FOCAS is a pure-managed in-process `FocasWireClient`, not a `Focas.Host` pipe
> service) — see `docs/v2/driver-stability.md`'s banner. The "SystemPlatform namespace kind" is retired:
> Galaxy is a standard Equipment-kind driver, and the address space is the dual Raw/UNS namespace
> ([../AddressSpace.md](../AddressSpace.md)).
> - **Modbus:** the driver has its own socket layer (no NModbus dependency) and a driver-level
> `transport` key — `Tcp` (default, MBAP framing) or `RtuOverTcp` (RTU `[addr][PDU][CRC-16]` framing tunnelled
> to a serial-to-Ethernet gateway); see [Modbus.md](../drivers/Modbus.md).
> - **AB CIP / AB Legacy:** a second, managed `Transport` (`Native` | `Libplctag`) exists — `CLAUDE.md`
> §"AB drivers: native transport" and the driver pages.
>
> **Created**: 2026-04-16
@@ -303,9 +319,9 @@ Same library as AB CIP — libplctag handles both protocols via the `plc` attrib
| I (Input) | 1 | 2 bytes | INT16/bit | `I:0`, `I:0/3` |
| S (Status) | 2 | 2 bytes | INT16/bit | `S:0`, `S:1/0` |
| B (Binary) | 3 | 2 bytes | INT16/bit | `B3:0`, `B3:0/5` |
| T (Timer) | 4 | 6 bytes | Struct | `T4:0.ACC`, `T4:0.PRE`, `T4:0/DN` |
| C (Counter) | 5 | 6 bytes | Struct | `C5:0.ACC`, `C5:0/DN`, `C5:0/CU` |
| R (Control) | 6 | 6 bytes | Struct | `R6:0.LEN`, `R6:0.POS`, `R6:0/DN` |
| T (Timer) | 4 | 6 bytes | Struct | `T4:0.ACC`, `T4:0.PRE`, `T4:0.DN` |
| C (Counter) | 5 | 6 bytes | Struct | `C5:0.ACC`, `C5:0.DN`, `C5:0.CU` |
| R (Control) | 6 | 6 bytes | Struct | `R6:0.LEN`, `R6:0.POS`, `R6:0.DN` |
| N (Integer) | 7 | 2 bytes | INT16 | `N7:0`, `N7:0/5`, `N10:50` |
| F (Float) | 8 | 4 bytes | FLOAT32 | `F8:0`, `F20:10` |
| ST (String) | 9 | 84 bytes | STRING | `ST9:0` (82 char max) |
@@ -366,7 +382,7 @@ Pure managed .NET, MIT license. No native dependencies.
| `Host` | string | — | PLC IP address |
| `Rack` | int | 0 | Hardware rack number |
| `Slot` | int | 0 | CPU slot (S7-300=2, S7-1200/1500=0) |
| `CpuType` | enum | S71500 | `S7300`, `S7400`, `S71200`, `S71500` |
| `CpuType` | enum | S71500 | `S7300`, `S7400`, `S71200`, `S71500`; legacy families `S7200`, `S7200Smart`, `Logo0BA8` (S7.Net uses fixed TSAPs for these, so `Rack`/`Slot` are not consulted — leave `0`/`0`; limited S7comm support, unverified against hardware) |
| `TimeoutMs` | int | 5000 | Read/write timeout |
| `MaxConcurrentConnections` | int | 1 | Connections to this PLC (usually 1) |
+4
View File
@@ -49,6 +49,10 @@ Driver stability needs to be a first-class architectural concern, not a per-driv
Every driver is assigned to one of three tiers based on the trust level of its dependency stack:
> **Design intent, not the shipped assignment.** The per-driver tier tables below record the proposed
> assignment (Galaxy/FOCAS in Tier C, the Ab/S7/TwinCAT drivers in Tier B). As shipped every driver is Tier A —
> see the banner above and `docs/drivers/README.md`, which is the accurate reference.
### Tier A — Pure Managed
Drivers whose entire dependency chain is verifiable .NET. Standard exception handling and Polly are sufficient. Run in-process in the main server.
+4 -4
View File
@@ -245,8 +245,8 @@ PCCC types are file-based. Int32/Float64/DateTime are not native to SLC/MicroLog
| Int32 | — (decompose in driver from two N words) | partial |
| Float32 | `F8:n` | ✔ |
| String | `ST9:n` | ✔ |
| Timer struct | `T4:n.ACC` / `.PRE` / `/DN` | ✔ |
| Counter struct | `C5:n.ACC` / `.PRE` / `/DN` | ✔ |
| Timer struct | `T4:n.ACC` / `.PRE` / `.DN` | ✔ |
| Counter struct | `C5:n.ACC` / `.PRE` / `.DN` | ✔ |
### Standard Scenario Mapping
@@ -260,8 +260,8 @@ PCCC types are file-based. Int32/Float64/DateTime are not native to SLC/MicroLog
| Ramp (Float32) | `F8:1` sine wave |
| Write-read-back (Bool / Int16 / Float32 / String) | `B3:1/0` / `N7:100` / `F8:100` / `ST9:100` |
| Array element write (Int16[10]) | `N7:200..209` |
| Timer fidelity | `T4:0.ACC`, `T4:0.PRE`, `T4:0/DN` |
| Counter fidelity | `C5:0.ACC`, `C5:0.PRE`, `C5:0/DN` |
| Timer fidelity | `T4:0.ACC`, `T4:0.PRE`, `T4:0.DN` |
| Counter fidelity | `C5:0.ACC`, `C5:0.PRE`, `C5:0.DN` |
| Connection-limit refusal | Driver harness toggle to simulate 4-conn limit |
| Bad on demand | Connection-refused toggle |
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestFeature"
}
}
@@ -0,0 +1,85 @@
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using Opc.Ua;
using ZB.MOM.WW.OtOpcUa.Client.CLI.Helpers;
using ZB.MOM.WW.OtOpcUa.Client.Shared;
namespace ZB.MOM.WW.OtOpcUa.Client.CLI.Commands;
[Command("comment", Description = "Add an operator comment to an alarm condition (OPC UA Part 9 AddComment)")]
public class CommentCommand : CommandBase
{
/// <summary>
/// Creates the comment command used to attach an operator comment to an OPC UA condition from the terminal.
/// </summary>
/// <param name="factory">The factory that creates the shared client service for the command run.</param>
public CommentCommand(IOpcUaClientServiceFactory factory) : base(factory)
{
}
/// <summary>
/// Gets the condition node ID of the alarm event to comment on.
/// </summary>
[CommandOption("node", 'n', Description = "Condition node ID of the alarm to comment on", IsRequired = true)]
public string NodeId { get; init; } = default!;
/// <summary>
/// Gets the event ID (hex-encoded) returned by the server in the alarm notification.
/// </summary>
[CommandOption("event-id", 'e', Description = "EventId from the alarm notification (hex-encoded byte array)", IsRequired = true)]
public string EventId { get; init; } = default!;
/// <summary>
/// Gets the operator comment to attach to the condition.
/// </summary>
[CommandOption("comment", 'c', Description = "Operator comment to attach", IsRequired = true)]
public string Comment { get; init; } = string.Empty;
/// <inheritdoc />
public override async ValueTask ExecuteAsync(IConsole console)
{
ConfigureLogging();
try
{
NodeIdParser.ParseRequired(NodeId);
}
catch (Exception ex) when (ex is FormatException or ArgumentException)
{
throw new CommandException($"Invalid --node value: {ex.Message}");
}
byte[] eventId;
try
{
eventId = Convert.FromHexString(EventId);
}
catch (FormatException ex)
{
throw new CommandException($"Invalid --event-id value: {ex.Message} (expected hex-encoded bytes, e.g. 0A1B2C)");
}
IOpcUaClientService? service = null;
try
{
var ct = console.RegisterCancellationHandler();
(service, _) = await CreateServiceAndConnectAsync(ct);
var statusCode = await service.AddCommentAsync(NodeId, eventId, Comment, ct);
if (StatusCode.IsGood(statusCode))
await console.Output.WriteLineAsync($"Comment added: {NodeId}");
else
await console.Output.WriteLineAsync($"Comment failed: {statusCode}");
}
finally
{
if (service != null)
{
await service.DisconnectAsync();
service.Dispose();
}
}
}
}
@@ -45,7 +45,23 @@ public class ReadCommand : CommandBase
var ct = console.RegisterCancellationHandler();
(service, _) = await CreateServiceAndConnectAsync(ct);
var value = await service.ReadValueAsync(nodeId, ct);
DataValue value;
try
{
value = await service.ReadValueAsync(nodeId, ct);
}
catch (ServiceResultException ex) when (ex.StatusCode != StatusCodes.BadSessionIdInvalid
&& ex.StatusCode != StatusCodes.BadConnectionClosed
&& ex.StatusCode != StatusCodes.BadSecureChannelClosed)
{
// The SDK's Session.ReadValue surfaces a Bad node status (BadNodeIdUnknown,
// BadUserAccessDenied, ...) as an exception. A read command's job is to REPORT that status,
// not crash with a stack trace (#577) — session-level failures still propagate.
await console.Output.WriteLineAsync($"Node: {NodeId}");
await console.Output.WriteLineAsync($"Value: ");
await console.Output.WriteLineAsync($"Status: {(StatusCode)ex.StatusCode} ({StatusCodes.GetBrowseName(ex.StatusCode)})");
return;
}
await console.Output.WriteLineAsync($"Node: {NodeId}");
await console.Output.WriteLineAsync($"Value: {value.Value}");
@@ -4,6 +4,7 @@
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Client.CLI</RootNamespace>
</PropertyGroup>
@@ -0,0 +1,45 @@
using Opc.Ua;
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters;
/// <summary>
/// The one <see cref="ITelemetryContext" /> the shared client hands the SDK's non-obsolete (telemetry-taking)
/// overloads. An empty builder keeps SDK internals off any logging provider; the client logs through Serilog.
/// </summary>
internal static class ClientTelemetry
{
private static readonly Lazy<ITelemetryContext> Instance = new(() => DefaultTelemetry.Create(_ => { }));
/// <summary>Gets the shared telemetry context.</summary>
public static ITelemetryContext Context => Instance.Value;
}
/// <summary>
/// Bounded sync-over-async for the <c>Dispose</c> paths, which cannot await. Runs on the thread pool so a
/// caller on a synchronization context (the Avalonia UI thread) cannot deadlock, and waits at most
/// <see cref="Timeout" /> so a dead server cannot hang teardown.
/// </summary>
internal static class DisposeBridge
{
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
/// <summary>Runs <paramref name="work" /> to completion (or the timeout), swallowing every failure.</summary>
public static void Run(Func<CancellationToken, Task> work)
{
using var cts = new CancellationTokenSource(Timeout);
try
{
var task = Task.Run(() => work(cts.Token), cts.Token);
// Observe a late fault so it never surfaces as an unobserved-task exception.
_ = task.ContinueWith(t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted);
// The CTS's own timer and this Wait race: when Wait loses, `using` would dispose the CTS before
// the token ever fired, leaving the work running while the caller goes on to dispose the session.
// Cancel explicitly so the in-flight Close/Delete is told to stop.
if (!task.Wait(Timeout)) { cts.Cancel(); }
}
catch
{
// Teardown is best-effort, exactly as the synchronous Close/Delete it replaces.
}
}
}
@@ -58,14 +58,14 @@ internal sealed class DefaultApplicationConfigurationFactory : IApplicationConfi
}
};
await config.Validate(ApplicationType.Client);
await config.ValidateAsync(ApplicationType.Client, ct);
if (settings.AutoAcceptCertificates)
config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
if (settings.SecurityMode != SecurityMode.None)
{
var app = new ApplicationInstance
var app = new ApplicationInstance(ClientTelemetry.Context)
{
ApplicationName = "OtOpcUaClient",
ApplicationType = ApplicationType.Client,
@@ -12,18 +12,14 @@ internal sealed class DefaultEndpointDiscovery : IEndpointDiscovery
private static readonly ILogger Logger = Log.ForContext<DefaultEndpointDiscovery>();
/// <inheritdoc />
public EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl,
MessageSecurityMode requestedMode)
public async Task<EndpointDescription> SelectEndpointAsync(ApplicationConfiguration config, string endpointUrl,
MessageSecurityMode requestedMode, CancellationToken ct = default)
{
if (requestedMode == MessageSecurityMode.None)
{
#pragma warning disable CS0618 // Acceptable for endpoint selection
return CoreClientUtils.SelectEndpoint(config, endpointUrl, false);
#pragma warning restore CS0618
}
using var client = DiscoveryClient.Create(new Uri(endpointUrl));
var allEndpoints = client.GetEndpoints(null);
// One path for every mode: discover, filter by mode, rewrite the host. (The None branch used to call the
// obsolete synchronous CoreClientUtils.SelectEndpoint, which does the same filter + rewrite.)
using var client = await DiscoveryClient.CreateAsync(
config, new Uri(endpointUrl), DiagnosticsMasks.None, ct).ConfigureAwait(false);
var allEndpoints = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);
return EndpointSelector.SelectBest(allEndpoints, endpointUrl, requestedMode);
}
@@ -264,7 +264,7 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
{
try
{
if (_session.Connected) _session.Close();
if (_session.Connected) DisposeBridge.Run(ct => _session.CloseAsync(ct));
}
catch
{
@@ -23,14 +23,18 @@ internal sealed class DefaultSessionFactory : ISessionFactory
var endpointConfig = EndpointConfiguration.Create(config);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
var session = await Session.Create(
var created = await new Opc.Ua.Client.DefaultSessionFactory(ClientTelemetry.Context).CreateAsync(
config,
configuredEndpoint,
false,
sessionName,
sessionTimeoutMs,
identity,
null);
null,
ct);
var session = created as Session
?? throw new InvalidOperationException(
$"Session factory returned {created.GetType().Name}, expected {nameof(Session)}.");
Logger.Information("Session created: {SessionName} -> {EndpointUrl}", sessionName, endpoint.EndpointUrl);
return new DefaultSessionAdapter(session);
@@ -123,7 +123,7 @@ internal sealed class DefaultSubscriptionAdapter : ISubscriptionAdapter
{
try
{
_subscription.Delete(true);
DisposeBridge.Run(ct => _subscription.DeleteAsync(true, ct));
}
catch
{
@@ -14,7 +14,8 @@ internal interface IEndpointDiscovery
/// <param name="config">The OPC UA application configuration.</param>
/// <param name="endpointUrl">The endpoint URL to discover.</param>
/// <param name="requestedMode">The requested message security mode.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The best-matching endpoint description, with its URL hostname rewritten to match <paramref name="endpointUrl"/> when they differ.</returns>
EndpointDescription SelectEndpoint(ApplicationConfiguration config, string endpointUrl,
MessageSecurityMode requestedMode);
Task<EndpointDescription> SelectEndpointAsync(ApplicationConfiguration config, string endpointUrl,
MessageSecurityMode requestedMode, CancellationToken ct = default);
}
@@ -127,6 +127,16 @@ public interface IOpcUaClientService : IDisposable
/// </returns>
Task<StatusCode> ConfirmAlarmAsync(string conditionNodeId, byte[] eventId, string comment, CancellationToken ct = default);
/// <summary>
/// Attaches an operator comment to a condition (Part 9 <c>AddComment</c>) using the event identifier of an alarm notification.
/// </summary>
/// <param name="conditionNodeId">The condition node the comment is attached to.</param>
/// <param name="eventId">The event identifier returned by the OPC UA server for the alarm event.</param>
/// <param name="comment">The operator comment.</param>
/// <param name="ct">The cancellation token that aborts the request.</param>
/// <returns><see cref="StatusCodes.Good"/> on success, or the server's bad <see cref="StatusCode"/> when the call is refused.</returns>
Task<StatusCode> AddCommentAsync(string conditionNodeId, byte[] eventId, string comment, CancellationToken ct = default);
/// <summary>
/// Shelves or unshelves an active alarm condition (OPC UA Part 9 ShelvedStateMachine).
/// </summary>
@@ -237,10 +237,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
hasChildren));
}
if (continuationPoint != null && continuationPoint.Length > 0)
(continuationPoint, references) = await _session.BrowseNextAsync(continuationPoint, ct);
else
break;
if (continuationPoint is not { Length: > 0 } pending) break;
(continuationPoint, references) = await _session.BrowseNextAsync(pending, ct);
}
return results;
@@ -370,10 +368,9 @@ public sealed class OpcUaClientService : IOpcUaClientService
ThrowIfDisposed();
ThrowIfNotConnected();
// The Acknowledge method lives on the .Condition child node, not the source node itself
var conditionObjId = conditionNodeId.EndsWith(".Condition")
? NodeId.Parse(conditionNodeId)
: NodeId.Parse(conditionNodeId + ".Condition");
// OtOpcUa conditions are their own event source (SourceNode == ConditionId), so the Part 9 methods are
// invoked on that node directly. There is no ".Condition" child to append.
var conditionObjId = NodeId.Parse(conditionNodeId);
var acknowledgeMethodId = MethodIds.AcknowledgeableConditionType_Acknowledge;
// CallMethodAsync throws ServiceResultException on a bad call result;
@@ -406,10 +403,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
ThrowIfDisposed();
ThrowIfNotConnected();
// Confirm lives on the same .Condition child node as Acknowledge
var conditionObjId = conditionNodeId.EndsWith(".Condition")
? NodeId.Parse(conditionNodeId)
: NodeId.Parse(conditionNodeId + ".Condition");
// Confirm is invoked on the condition node itself, like Acknowledge.
var conditionObjId = NodeId.Parse(conditionNodeId);
var confirmMethodId = MethodIds.AcknowledgeableConditionType_Confirm;
try
@@ -431,6 +426,35 @@ public sealed class OpcUaClientService : IOpcUaClientService
return StatusCodes.Good;
}
/// <inheritdoc />
public async Task<StatusCode> AddCommentAsync(string conditionNodeId, byte[] eventId, string comment,
CancellationToken ct = default)
{
ThrowIfDisposed();
ThrowIfNotConnected();
// AddComment is a ConditionType method, invoked on the condition node itself.
var conditionObjId = NodeId.Parse(conditionNodeId);
try
{
await _session!.CallMethodAsync(
conditionObjId,
MethodIds.ConditionType_AddComment,
[eventId, new LocalizedText(comment)],
ct);
}
catch (ServiceResultException ex)
{
Logger.Warning(ex, "Failed to add comment on {ConditionId} (status {Status})",
conditionNodeId, ex.StatusCode);
return ex.StatusCode;
}
Logger.Debug("Added comment on {ConditionId}", conditionNodeId);
return StatusCodes.Good;
}
/// <inheritdoc />
public async Task<StatusCode> ShelveAlarmAsync(string conditionNodeId, ShelveKind kind,
double shelvingTimeSeconds = 0, CancellationToken ct = default)
@@ -438,11 +462,11 @@ public sealed class OpcUaClientService : IOpcUaClientService
ThrowIfDisposed();
ThrowIfNotConnected();
// The shelve methods live on the ShelvingState child of the condition node.
// conditionNodeId is the alarm/condition node; append .ShelvingState to get the state machine node.
var shelvingStateNodeId = conditionNodeId.EndsWith(".ShelvingState")
? NodeId.Parse(conditionNodeId)
: NodeId.Parse(conditionNodeId + ".ShelvingState");
// The server's ShelvingState child is SDK-created with a numeric namespace-0 id, so a synthesized
// "<condition>.ShelvingState" node id never exists. The shelve methods are called on the condition
// object itself with the AlarmConditionType_ShelvingState_* method ids, which the server dispatches
// to the condition's ShelvingState methods.
var shelvingStateNodeId = NodeId.Parse(conditionNodeId);
NodeId methodId;
object[] inputArgs;
@@ -495,8 +519,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
/// <summary>
/// Shared body for the H4 Enable/Disable client path. Invokes the OPC UA Part 9 ConditionType
/// <c>Enable</c>/<c>Disable</c> method (no input arguments) on the condition object node, mirroring
/// the <see cref="AcknowledgeAlarmAsync"/> convention: the methods live on the <c>.Condition</c>
/// child node, and a bad call result surfaces as the returned <see cref="StatusCode"/> rather than
/// the <see cref="AcknowledgeAlarmAsync"/> convention: the methods are invoked on the condition
/// node itself, and a bad call result surfaces as the returned <see cref="StatusCode"/> rather than
/// escaping as an uncaught <see cref="ServiceResultException"/>.
/// </summary>
/// <param name="conditionNodeId">The condition node associated with the alarm.</param>
@@ -507,10 +531,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
ThrowIfDisposed();
ThrowIfNotConnected();
// Enable/Disable live on the same .Condition child node as Acknowledge/Confirm.
var conditionObjId = conditionNodeId.EndsWith(".Condition")
? NodeId.Parse(conditionNodeId)
: NodeId.Parse(conditionNodeId + ".Condition");
// Enable/Disable are invoked on the condition node itself, like Acknowledge/Confirm.
var conditionObjId = NodeId.Parse(conditionNodeId);
var methodId = enabling ? MethodIds.ConditionType_Enable : MethodIds.ConditionType_Disable;
try
@@ -638,7 +660,7 @@ public sealed class OpcUaClientService : IOpcUaClientService
var config = await _configFactory.CreateAsync(effectiveSettings, ct);
var requestedMode = SecurityModeMapper.ToMessageSecurityMode(settings.SecurityMode);
var endpoint = _endpointDiscovery.SelectEndpoint(config, endpointUrl, requestedMode);
var endpoint = await _endpointDiscovery.SelectEndpointAsync(config, endpointUrl, requestedMode, ct);
var identity = settings.Username != null
? new UserIdentity(settings.Username, Encoding.UTF8.GetBytes(settings.Password ?? ""))
@@ -829,74 +851,8 @@ public sealed class OpcUaClientService : IOpcUaClientService
var ackedState = ackedField ?? false;
var activeState = activeField ?? false;
// Fallback: read InAlarm/Acked from condition node Galaxy attributes
// when the server doesn't populate standard event fields.
// Must run on a background thread to avoid deadlocking the notification thread.
// Capture the session reference now; skip supplemental reads if the session has
// been replaced by a concurrent failover before the Task.Run body executes.
if (ackedField == null && activeField == null && conditionNodeId != null && _session != null)
{
var session = _session;
var capturedConditionNodeId = conditionNodeId;
var capturedMessage = message;
_ = Task.Run(async () =>
{
// If the session was replaced by a failover before we started reading,
// skip the supplemental reads to avoid hitting a disposed session.
if (!ReferenceEquals(session, _session))
return;
try
{
var inAlarmValue = await session.ReadValueAsync(NodeId.Parse($"{capturedConditionNodeId}.InAlarm"));
if (inAlarmValue.Value is bool inAlarm) activeState = inAlarm;
var ackedValue = await session.ReadValueAsync(NodeId.Parse($"{capturedConditionNodeId}.Acked"));
if (ackedValue.Value is bool acked) ackedState = acked;
if (time == DateTime.MinValue && activeState)
{
var timeValue = await session.ReadValueAsync(NodeId.Parse($"{capturedConditionNodeId}.TimeAlarmOn"));
if (timeValue.Value is DateTime alarmTime && alarmTime != DateTime.MinValue)
time = alarmTime;
}
// Read alarm description to use as message
try
{
var descValue = await session.ReadValueAsync(NodeId.Parse($"{capturedConditionNodeId}.DescAttrName"));
if (descValue.Value is string desc && !string.IsNullOrEmpty(desc))
capturedMessage = desc;
}
catch { /* DescAttrName may not exist */ }
}
catch (ObjectDisposedException)
{
// Session was disposed during supplemental reads (concurrent failover);
// skip the event rather than delivering stale/default states.
Logger.Debug("Supplemental alarm read skipped — session disposed during failover for {ConditionNodeId}", capturedConditionNodeId);
return;
}
catch
{
// Other supplemental read failure; deliver event with defaults
}
// OperatorComment is intentionally left null (default) here.
// Two concrete reasons make it unrecoverable in this Galaxy sub-attribute fallback path:
// (a) Galaxy's AckMsg attribute is WRITE-ONLY — there is no readable attribute that
// exposes the last operator comment, so no supplemental read can recover it.
// (b) The OPC UA event SelectClause (CreateAlarmEventFilter) carries no comment field;
// the OPC UA event notification itself never delivers a comment string.
// The operator comment is only available via the gateway's native alarm feed
// (the Galaxy driver path, GalaxyAlarmTransition.OperatorComment), not this
// OpcUaClient sub-attribute fallback.
AlarmEvent?.Invoke(this, new AlarmEventArgs(
sourceName, conditionName, severity, capturedMessage, retain, activeState, ackedState, time,
eventId, capturedConditionNodeId));
});
return;
}
// No Galaxy-era supplemental reads (<condition>.InAlarm/.Acked/...): the server publishes the
// standard Part 9 fields. A foreign server that leaves both null reports Active=false/Acked=false.
AlarmEvent?.Invoke(this, new AlarmEventArgs(
sourceName, conditionName, severity, message, retain, activeState, ackedState, time,
@@ -3,6 +3,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Client.Shared</RootNamespace>
</PropertyGroup>
@@ -4,6 +4,7 @@
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Client.UI</RootNamespace>
</PropertyGroup>
@@ -37,7 +37,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
// RoleLeaderChanged event landed last would silently overwrite the other's leader — turning two
// real, independent facts Akka tracks separately into one, wrong, merged one. Pinned by
// ClusterRoleInfoRoleLeaderCaseTests.RoleLeaderChanged_for_driver_then_Driver_keeps_distinct_entries.
private readonly Dictionary<string, Member?> _roleLeaders = new(StringComparer.Ordinal);
private readonly Dictionary<string, Akka.Actor.Address?> _roleLeaders = new(StringComparer.Ordinal);
private readonly Dictionary<string, HashSet<Member>> _membersByRole = new(StringComparer.OrdinalIgnoreCase);
private IActorRef? _subscriber;
@@ -76,7 +76,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
}
}
SeedFromCurrentState();
SeedFrom(_cluster.State);
_subscriber = system.ActorOf(Props.Create(() => new SubscriberActor(this)), "clusterroleinfo-subscriber");
}
@@ -113,7 +113,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
lock (_lock)
{
return _roleLeaders.TryGetValue(role, out var leader) && leader is not null
? ToNodeId(leader.Address)
? ToNodeId(leader)
: (CommonsNodeId?)null;
}
}
@@ -121,12 +121,19 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
/// <summary>Occurs when the leader for a role changes.</summary>
public event EventHandler<RoleLeaderChangedEventArgs>? RoleLeaderChanged;
private void SeedFromCurrentState()
/// <summary>Seeds the member/leader maps from a cluster-state snapshot (the constructor passes the live
/// <c>Cluster.State</c>; exposed for tests).</summary>
/// <param name="snapshot">The cluster-state snapshot.</param>
internal void SeedFrom(ClusterEvent.CurrentClusterState snapshot)
{
var snapshot = _cluster.State;
lock (_lock)
{
foreach (var member in snapshot.Members)
// Cluster-002 (review R1 m6): seed only USABLE members. A member already Leaving / Exiting / Down
// (or still Joining) at seed time is not one, and a departing member never fires a later
// MemberLeft / MemberExited / MemberDowned that HandleMemberEvent would drop it on — it would
// linger until MemberRemoved, the very delay the member-event handling exists to avoid. A
// Joining member is added by its MemberUp.
foreach (var member in snapshot.Members.Where(m => m.Status is MemberStatus.Up or MemberStatus.WeaklyUp))
foreach (var role in member.Roles)
{
if (!_membersByRole.TryGetValue(role, out var set))
@@ -137,9 +144,7 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
foreach (var role in snapshot.Members.SelectMany(m => m.Roles).Distinct())
{
var leaderAddr = snapshot.RoleLeader(role);
_roleLeaders[role] = leaderAddr is not null
? snapshot.Members.FirstOrDefault(m => m.Address == leaderAddr)
: null;
_roleLeaders[role] = leaderAddr;
}
}
}
@@ -160,15 +165,35 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
set.Add(up.Member);
}
break;
// Cluster-002: a leaving / exiting / downed member is no longer a usable member, so
// drop it as soon as that transition lands rather than waiting for MemberRemoved
// (several seconds later). MembersWithRole now has a production caller (the
// historization scope-peer check), which must not defer to a departing partner.
// Member equality is by UniqueAddress, so the status change does not matter here.
case ClusterEvent.MemberLeft left:
RemoveMember(left.Member);
break;
case ClusterEvent.MemberExited exited:
RemoveMember(exited.Member);
break;
case ClusterEvent.MemberDowned downed:
RemoveMember(downed.Member);
break;
case ClusterEvent.MemberRemoved removed:
foreach (var role in removed.Member.Roles)
if (_membersByRole.TryGetValue(role, out var set))
set.Remove(removed.Member);
RemoveMember(removed.Member);
break;
}
}
}
// Caller holds _lock.
private void RemoveMember(Member member)
{
foreach (var role in member.Roles)
if (_membersByRole.TryGetValue(role, out var set))
set.Remove(member);
}
/// <summary>Handles a role leader change event.</summary>
/// <param name="evt">The role leader changed event from the cluster.</param>
internal void HandleRoleLeaderChanged(ClusterEvent.RoleLeaderChanged evt)
@@ -179,17 +204,17 @@ public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
lock (_lock)
{
_roleLeaders.TryGetValue(evt.Role, out var prevMember);
if (prevMember is not null)
previous = ToNodeId(prevMember.Address);
_roleLeaders.TryGetValue(evt.Role, out var prevLeader);
if (prevLeader is not null)
previous = ToNodeId(prevLeader);
var nextMember = evt.Leader is null
? null
: _cluster.State.Members.FirstOrDefault(m => m.Address == evt.Leader);
_roleLeaders[evt.Role] = nextMember;
if (nextMember is not null)
next = ToNodeId(nextMember.Address);
// Record the event's own address. The leader used to be resolved by looking the address up
// in Cluster.State.Members, but that snapshot is published asynchronously and can lag the
// event: a miss stored null and nothing re-ran, so RoleLeader(role) stayed null for good
// (Gitea #590). Only the address is ever read, so no Member lookup is needed.
_roleLeaders[evt.Role] = evt.Leader;
if (evt.Leader is not null)
next = ToNodeId(evt.Leader);
raise = !Nullable.Equals(previous, next);
}
@@ -5,6 +5,10 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Cluster.Tests"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Akka.Hosting"/>
<PackageReference Include="Akka.Cluster"/>
@@ -5,8 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Engines;
/// <summary>
/// Abstraction over the compiled virtual-tag expression engine. Runtime consumes this so
/// <see cref="VirtualTagActor"/> can stay free of Roslyn / scripting machinery and the
/// production wiring binds an adapter over <c>VirtualTagEngine</c> from
/// <c>Core.VirtualTags</c>.
/// production wiring binds <c>RoslynVirtualTagEvaluator</c> (Host).
/// </summary>
public interface IVirtualTagEvaluator
{
@@ -98,7 +97,7 @@ public static class ScriptEvalFailureReasons
}
/// <summary>Returns <see cref="VirtualTagEvalResult.NoChange"/> from every call. Bound by default
/// when the production <c>VirtualTagEngine</c> adapter hasn't been registered (Mac dev, tests).</summary>
/// when the production <c>RoslynVirtualTagEvaluator</c> hasn't been registered (Mac dev, tests).</summary>
public sealed class NullVirtualTagEvaluator : IVirtualTagEvaluator
{
public static readonly NullVirtualTagEvaluator Instance = new();
@@ -76,6 +76,16 @@ public static class OtOpcUaTelemetry
/// </summary>
public const string PollOverrunDriverInstanceTag = "driver_instance";
/// <summary>
/// Session/publish-level events of an OPC UA Client driver instance (#276): the operator's view of a
/// flapping upstream. Tagged <see cref="PollOverrunDriverInstanceTag"/> (<c>driver_instance</c>) +
/// <c>event</c> = <c>publish_response|notification|data_change|bad_status_value|publish_error|
/// keepalive_miss|reconnect_attempt|reconnect_success|reconnect_exhausted</c>.
/// </summary>
public static readonly Counter<long> OpcUaClientEvents =
Meter.CreateCounter<long>("otopcua.driver.opcuaclient.events", unit: "{event}",
description: "OPC UA Client driver session/publish events (event=publish_response|notification|data_change|bad_status_value|publish_error|keepalive_miss|reconnect_attempt|reconnect_success|reconnect_exhausted).");
// ---------------- VirtualTag / ScriptedAlarm engines ----------------
public static readonly Counter<long> VirtualTagEval =
@@ -24,6 +24,12 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// Carries the full sub-code since #519, so a comms-lost condition reports
/// <see cref="OpcUaStatus.BadNoCommunication"/> rather than a bare <see cref="OpcUaStatus.Bad"/>.
/// </param>
/// <param name="UnshelveAtUtc">
/// For <see cref="AlarmShelvingKind.Timed"/>, the UTC instant the engine expires the shelve; otherwise
/// <c>null</c>. Carried so the node's Part 9 <c>ShelvingState.UnshelveTime</c> reports the real remaining
/// time instead of the SDK's ~24.8-day ceiling (Gitea #597). A timed snapshot without it (a caller that
/// predates the field) falls back to that ceiling — the engine still owns the expiry either way.
/// </param>
public sealed record AlarmConditionSnapshot(
bool Active,
bool Acknowledged,
@@ -32,7 +38,8 @@ public sealed record AlarmConditionSnapshot(
AlarmShelvingKind Shelving,
ushort Severity,
string Message,
uint StatusCode = OpcUaStatus.Good);
uint StatusCode = OpcUaStatus.Good,
DateTime? UnshelveAtUtc = null);
/// <summary>
/// Commons-local mirror of the Core <c>ShelvingKind</c> enum so this assembly carries no
@@ -63,8 +63,8 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength);
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar);
/// <inheritdoc />
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
@@ -172,7 +172,12 @@ public interface IOpcUaAddressSpaceSink
/// <param name="realm">The namespace realm the variable (and its <paramref name="parentFolderNodeId"/>) live
/// in. A historized UNS reference node registers the SAME <paramref name="historianTagname"/> as its backing
/// raw node — both NodeIds map to one tagname — so this call carries the shared tagname per realm.</param>
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null);
/// <param name="refuseArrayOnScalar">Gitea #610 review (R6): when true and the node is SCALAR, a client write
/// of an array value is refused inline (<c>BadTypeMismatch</c>, nothing dispatched) because the owning
/// driver's scalar encoder cannot take one (Modbus / S7). False (default) keeps the pre-#610 behaviour — the
/// write is dispatched and the driver decides (OpcUaClient forwards it to the upstream server, Galaxy encodes
/// it). Fixed at creation; an existing node keeps the value it was created with.</param>
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false);
/// <summary>
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
@@ -273,7 +278,7 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { }
/// <inheritdoc />
public void RebuildAddressSpace() { }
@@ -41,6 +41,7 @@ public static class DraftValidator
ValidateEquipReferenceResolution(draft, errors);
ValidateCalculationTags(draft, errors);
ValidateSqlConnectionStringNotPersisted(draft, errors);
ValidateMTConnectSingleDevice(draft, errors);
ValidateNodeAclScopes(draft, errors);
return errors;
}
@@ -343,6 +344,42 @@ public static class DraftValidator
}
}
/// <summary>Error code for an MTConnect driver instance that has more than one <c>/raw</c> Device (#628).</summary>
public const string MTConnectMultipleDevicesCode = "MTConnectMultipleDevices";
/// <summary>
/// The one message both the deploy gate and the authoring-time guard (<c>RawTreeService.CreateDeviceAsync</c>)
/// use, so the two can never describe different rules.
/// </summary>
/// <param name="driverLabel">The driver instance id or name to cite.</param>
public static string MTConnectMultipleDevicesMessage(string driverLabel) =>
$"MTConnect driver '{driverLabel}' has more than one Device. MTConnect binds all tags through one agent " +
"client per driver instance and does not route by /raw Device, and with several Devices the Device's " +
"DeviceConfig (agent URI / device name) is no longer merged into the driver config, so which Device would " +
"supply the endpoint is ambiguous. Author one MTConnect driver per agent device, each with a single Device.";
/// <summary>
/// #628 (decision (b)): an MTConnect driver instance owns exactly one agent client, so a second <c>/raw</c>
/// Device under it is a genuine ambiguity — the driver ignores <c>RawTagEntry.DeviceName</c>, and
/// <c>DriverDeviceConfigMerger</c> only merges a Device's <c>DeviceConfig</c> up to the driver config when
/// there is exactly ONE Device. The normal single-Device layout (every driver gets a default
/// <c>Device1</c>) stays valid.
/// </summary>
private static void ValidateMTConnectSingleDevice(DraftSnapshot draft, List<ValidationError> errors)
{
foreach (var driver in draft.DriverInstances)
{
if (!string.Equals(driver.DriverType, Core.Abstractions.DriverTypeNames.MTConnect, StringComparison.Ordinal))
continue;
var count = draft.Devices.Count(d =>
string.Equals(d.DriverInstanceId, driver.DriverInstanceId, StringComparison.Ordinal));
if (count <= 1) continue;
errors.Add(new(MTConnectMultipleDevicesCode,
MTConnectMultipleDevicesMessage(driver.Name) + $" ({count} Devices found.)",
driver.DriverInstanceId));
}
}
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
/// <list type="number">
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
@@ -0,0 +1,200 @@
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Gitea #610 — the single authority on whether a driver can WRITE a 1-D array tag. The address-space
/// composer consults it at compose time (both compose seams run the same composer, so the live-edit and
/// artifact-decode sides cannot disagree) to decide whether an authored-<c>ReadWrite</c> array tag
/// materialises as a writable OPC UA node. Before #610 every array node was forced read-only regardless of
/// driver; that remains the answer for every (driver, element type) pair NOT listed here.
/// <para>
/// <b>The rule is "never advertise a write the driver will refuse".</b> A pair is listed only when the
/// driver's <c>IWritable.WriteAsync</c> encodes a whole-array value end to end; a driver that refuses an
/// array value (a scalar-only encoder, a <c>NotSupportedException</c> guard) is left out so its array
/// nodes stay read-only and a client write is refused at the node (<c>BadNotWritable</c>) rather than
/// optimistically applied and reverted. The table is pure data, keyed off the same TagConfig keys the
/// drivers' own tag-definition factories read, because the composer has no reference to driver
/// assemblies. Each listed driver's test project pins its row against the real encoder
/// (<c>*ArrayWriteSupportParityTests</c>), so a driver gaining or losing an array encoder fails a test
/// until this table follows.
/// </para>
/// <list type="bullet">
/// <item><b>Modbus</b> — <c>region: Coils</c> (FC15, any element, at most 1968 coils) and
/// <c>region: HoldingRegisters</c> with a fixed-width numeric <c>dataType</c> (FC16) whose
/// <c>arrayLength × registers-per-element</c> fits the driver's <c>maxRegistersPerWrite</c> (default
/// 123 — the driver refuses rather than chunks a longer FC16). Bit-in-register, String and
/// Bool-in-register arrays have no array encoder; input regions are read-only.</item>
/// <item><b>S7</b> — <c>dataType</c> Byte / Int16 / UInt16 / Int32 / UInt32 / Float32 (one contiguous
/// block, <c>EncodeArrayBlock</c>). Bool arrays would clobber neighbouring bits; the wide / String /
/// DateTime element types have no array encoder.</item>
/// <item><b>OpcUaClient</b> — every element type: the value is forwarded to the upstream server as a
/// <c>Variant</c> unchanged, and the upstream server is the arbiter (of shape, too).</item>
/// <item><b>Galaxy</b> — the array variants <c>MxValueEncoder</c> encodes, keyed by the OPC UA
/// <c>DataType</c> the node is created with: Boolean / Int32 / Int64 / Float / Double / String /
/// DateTime — and only with a declared <c>arrayLength</c>: an undeclared length materialises an
/// unbounded node, and a short whole-array write to it would RESIZE the Galaxy attribute (the
/// mxaccessgw <c>gateway.md</c> contract), which is never what a client meant.</item>
/// <item>Everything else (AbCip, AbLegacy, TwinCAT, FOCAS, MQTT, MTConnect, Sql, Calculation) — scalar
/// write encoders only; array nodes stay read-only.</item>
/// </list>
/// <para>Modbus, S7 and Galaxy require a declared length — <c>isArray: true</c> AND an integer
/// <c>arrayLength &gt;= 1</c>. The Modbus and S7 tag factories treat anything else as a SCALAR tag (whose
/// scalar encoder cannot take an array value), though the composer still materialises an array node.</para>
/// <para>Multi-dimensional arrays (<c>ValueRank &gt; 1</c>) are out of scope altogether — every array node
/// is materialised 1-D (<c>ValueRank = OneDimension</c>), so nothing here considers more than one rank.</para>
/// </summary>
public static class DriverArrayWriteSupport
{
/// <summary>The Modbus driver's FC16 write cap when <c>maxRegistersPerWrite</c> is absent or <c>0</c>
/// (<c>ModbusDriver</c> resolves it the same way).</summary>
public const int ModbusDefaultMaxRegistersPerWrite = 123;
/// <summary>Modbus FC15's protocol ceiling on coils per request (0x07B0). The driver sends one FC15 for the
/// whole array without chunking, so a longer coil array is refused by every conformant device.</summary>
public const int ModbusMaxCoilsPerWrite = 1968;
// Element type → registers per element (ModbusDriver.RegisterCount).
private static readonly Dictionary<string, int> ModbusRegisterArrayTypes = new(StringComparer.OrdinalIgnoreCase)
{
["Int16"] = 1, ["UInt16"] = 1, ["Bcd16"] = 1,
["Int32"] = 2, ["UInt32"] = 2, ["Bcd32"] = 2, ["Float32"] = 2,
["Int64"] = 4, ["UInt64"] = 4, ["Float64"] = 4,
};
private static readonly HashSet<string> S7ArrayTypes = new(StringComparer.OrdinalIgnoreCase)
{
"Byte", "Int16", "UInt16", "Int32", "UInt32", "Float32",
};
private static readonly HashSet<string> GalaxyArrayDataTypes = new(StringComparer.Ordinal)
{
"Boolean", "Int32", "Int64", "Float", "Double", "String", "DateTime",
};
/// <summary>
/// Whether a driver of <paramref name="driverType"/> can write a 1-D array tag of the given shape.
/// </summary>
/// <param name="driverType">The owning driver instance's <c>DriverType</c> (a <see cref="DriverTypeNames"/>
/// constant); <see langword="null"/> or unknown ⇒ <see langword="false"/>.</param>
/// <param name="dataType">The tag's OPC UA <c>DataType</c> name (<c>Tag.DataType</c>).</param>
/// <param name="tagConfig">The tag's <c>TagConfig</c> JSON (driver-native address + element type).</param>
/// <param name="driverConfigs">The <c>DriverConfig</c> / <c>DeviceConfig</c> JSON blobs that can carry a
/// driver write cap (Modbus <c>maxRegistersPerWrite</c>). Every one is consulted and the SMALLEST cap
/// wins, so the answer stays conservative however the driver's config merge resolves them.</param>
/// <returns><see langword="true"/> only when the driver encodes a whole-array write for this tag.</returns>
public static bool Supports(string? driverType, string? dataType, string? tagConfig, params string?[] driverConfigs)
{
switch (driverType)
{
case DriverTypeNames.OpcUaClient:
return true;
case DriverTypeNames.Galaxy:
return dataType is not null && GalaxyArrayDataTypes.Contains(dataType)
&& TryParseObject(tagConfig, out var galaxyRoot) && ReadArrayLength(galaxyRoot) is not null;
case DriverTypeNames.Modbus:
{
// Same reads as ModbusTagDefinitionFactory: a blob with no `region` or no numeric `address` is not
// a Modbus tag at all (the factory refuses it). A present non-string region is read as absent ⇒
// HoldingRegisters, and an absent dataType ⇒ Int16 (TagConfigJson.TryReadEnumStrict).
if (!TryParseObject(tagConfig, out var root)
|| !root.TryGetProperty("region", out _)
|| !root.TryGetProperty("address", out var address) || address.ValueKind != JsonValueKind.Number
|| ReadArrayLength(root) is not { } count)
return false;
var region = ReadEnumName(root, "region") ?? "HoldingRegisters";
var native = ReadEnumName(root, "dataType") ?? "Int16";
if (string.Equals(region, "Coils", StringComparison.OrdinalIgnoreCase)) return count <= ModbusMaxCoilsPerWrite;
return string.Equals(region, "HoldingRegisters", StringComparison.OrdinalIgnoreCase)
&& ModbusRegisterArrayTypes.TryGetValue(native, out var registersPerElement)
&& (long)count * registersPerElement <= ModbusMaxRegistersPerWrite(driverConfigs);
}
case DriverTypeNames.S7:
{
// S7TagDefinitionFactory requires a dataType key (absent ⇒ not an S7 tag at all); a present
// non-string value falls back to Int16, exactly as the factory's strict enum read does.
if (!TryParseObject(tagConfig, out var root) || !root.TryGetProperty("dataType", out _)
|| ReadArrayLength(root) is null)
return false;
var native = ReadEnumName(root, "dataType") ?? "Int16";
return S7ArrayTypes.Contains(native);
}
default:
return false;
}
}
/// <summary>
/// Gitea #610 review (R6 MINOR-1) — whether a driver of <paramref name="driverType"/> is known to REFUSE an
/// array value written to one of its SCALAR tags, so the node manager may refuse such a write inline
/// (<c>BadTypeMismatch</c>, nothing dispatched) instead of storing it optimistically, dispatching it, and
/// reverting it when the scalar encoder throws.
/// <para>
/// Only Modbus and S7 qualify: their scalar encoders <c>Convert.ToXxx</c> the value, which throws on an
/// array. Every other driver keeps the pre-#610 behaviour — the write is dispatched and the driver
/// decides. That matters for the pass-through drivers: a browse-committed array attribute is committed
/// as a SCALAR tag (the <c>/raw</c> commit never sets <c>isArray</c>), the driver publishes an array into
/// it, and writing that array back worked before #610 — <c>OpcUaClient</c> forwards the
/// <c>Variant</c> unchanged (the upstream server arbitrates shape) and Galaxy's <c>MxValueEncoder</c>
/// encodes the array. An unknown / null driver type is not listed ⇒ <see langword="false"/>.
/// </para>
/// </summary>
/// <param name="driverType">The owning driver instance's <c>DriverType</c>.</param>
/// <returns><see langword="true"/> only when the driver's scalar write path cannot take an array value.</returns>
public static bool RefusesArrayValueOnScalarTag(string? driverType) =>
driverType is DriverTypeNames.Modbus or DriverTypeNames.S7;
// The smallest Modbus write cap configured in any config; none configured (absent / non-positive) ⇒ the driver
// default (123). The key is matched case-insensitively, like the driver's DTO binding.
private static int ModbusMaxRegistersPerWrite(string?[]? driverConfigs)
{
int? cap = null;
foreach (var config in driverConfigs ?? [])
{
if (!TryParseObject(config, out var root)) continue;
foreach (var prop in root.EnumerateObject())
{
if (string.Equals(prop.Name, "maxRegistersPerWrite", StringComparison.OrdinalIgnoreCase)
&& prop.Value.ValueKind == JsonValueKind.Number
&& prop.Value.TryGetInt32(out var configured) && configured > 0)
{
cap = Math.Min(cap ?? int.MaxValue, configured);
}
}
}
return cap ?? ModbusDefaultMaxRegistersPerWrite;
}
// The declared element count: isArray is the JSON literal true AND arrayLength is an Int32 >= 1 — the rule the
// Modbus and S7 tag factories apply. Anything else is a scalar to those drivers ⇒ null.
private static int? ReadArrayLength(JsonElement root) =>
root.TryGetProperty("isArray", out var isArray) && isArray.ValueKind == JsonValueKind.True
&& root.TryGetProperty("arrayLength", out var length) && length.ValueKind == JsonValueKind.Number
&& length.TryGetInt32(out var count) && count >= 1
? count
: null;
// Parses the TagConfig root object; a blank / malformed / non-object blob is "no tag" ⇒ false. The clone
// detaches the element from the disposed document.
private static bool TryParseObject(string? tagConfig, out JsonElement root)
{
root = default;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
try
{
using var doc = JsonDocument.Parse(tagConfig);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return false;
root = doc.RootElement.Clone();
return true;
}
catch (JsonException)
{
return false;
}
}
// An enum-valued field's NAME, or null when absent or not a JSON string — the drivers' strict reads treat a
// non-string token (a numerically-serialised enum) as absent and apply the field's default.
private static string? ReadEnumName(JsonElement root, string name) =>
root.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String ? el.GetString() : null;
}
@@ -39,7 +39,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// </param>
/// <param name="VirtualTagId">
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.Virtual"/> — stable
/// logical id the VirtualTagEngine addresses by. Null otherwise.
/// logical id the virtual-tag runtime addresses by. Null otherwise.
/// </param>
/// <param name="ScriptedAlarmId">
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.ScriptedAlarm"/> —
@@ -78,7 +78,7 @@ public sealed record DriverAttributeInfo(
/// Discriminates which runtime subsystem owns this node's Read/Write/
/// Subscribe dispatch. <c>Driver</c> = a real IDriver capability surface;
/// <c>Virtual</c> = a Phase 7 <see cref="DriverAttributeInfo"/>.VirtualTagId'd tag
/// computed by the VirtualTagEngine; <c>ScriptedAlarm</c> = a scripted Part 9 alarm
/// computed by the virtual-tag runtime; <c>ScriptedAlarm</c> = a scripted Part 9 alarm
/// materialized by the ScriptedAlarmEngine.
/// </summary>
public enum NodeSourceKind
@@ -3,7 +3,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Driver capability for alarm events. Optional — only drivers whose backends expose
/// alarm conditions implement this. Currently: Galaxy (MxAccess alarms), FOCAS
/// (CNC alarms), OPC UA Client (A&amp;C events from upstream server).
/// (CNC alarms), OPC UA Client (A&amp;C events from upstream server), MTConnect
/// (CONDITION data items, Gitea #626).
/// </summary>
public interface IAlarmSource
{
@@ -85,6 +86,22 @@ public sealed record AlarmAcknowledgeRequest(
/// other fields. <see cref="AlarmTransitionKind.Unspecified"/> when the upstream
/// path doesn't surface a distinct kind.
/// </param>
/// <param name="OpcUaSeverity">
/// Optional exact OPC UA severity (1..1000) for a source whose own scale is finer than the four
/// <see cref="AlarmSeverity"/> buckets (Gitea #626: MTConnect Warning = 500, Fault = 800). When set it
/// supersedes the bucket on the projected Part 9 condition (clamped to 1..1000); <c>null</c> — every
/// pre-#626 source — keeps the bucket mapping. <see cref="Severity"/> must still carry the nearest
/// bucket for consumers that only read the bucket.
/// </param>
/// <param name="QualityStatusCode">
/// Optional status code of the condition's SOURCE data (Part 9 <c>ConditionType.Quality</c>), for a
/// source that can report "no value for this condition" per alarm rather than per connection (Gitea
/// #626: MTConnect <c>Unavailable</c>). <c>null</c> keeps the pre-#626 behaviour (projected Good;
/// driver-wide comms loss is annotated separately by the host). An event whose <see cref="Kind"/> is
/// <see cref="AlarmTransitionKind.Unspecified"/> and which carries a quality is a
/// <b>quality-only annotation</b> (<see cref="IsQualityOnly"/>): the host updates (via its Quality-only node path) the condition's
/// Quality without touching its Active/Acked state and publishes no <c>alerts</c> row for it.
/// </param>
public sealed record AlarmEventArgs(
IAlarmSubscriptionHandle SubscriptionHandle,
string SourceNodeId,
@@ -96,7 +113,17 @@ public sealed record AlarmEventArgs(
string? OperatorComment = null,
DateTime? OriginalRaiseTimestampUtc = null,
string? AlarmCategory = null,
AlarmTransitionKind Kind = AlarmTransitionKind.Unspecified);
AlarmTransitionKind Kind = AlarmTransitionKind.Unspecified,
int? OpcUaSeverity = null,
uint? QualityStatusCode = null)
{
/// <summary>
/// True when this event only re-annotates the condition's source-data quality — a
/// <see cref="QualityStatusCode"/> with an <see cref="AlarmTransitionKind.Unspecified"/> kind. No
/// alarm-state change happened, so the host must not publish it as an alarm transition.
/// </summary>
public bool IsQualityOnly => Kind == AlarmTransitionKind.Unspecified && QualityStatusCode is not null;
}
/// <summary>Mirrors the <c>NodePermissions</c> alarm-severity enum in <c>docs/v2/acl-design.md</c>.</summary>
public enum AlarmSeverity { Low, Medium, High, Critical }
@@ -28,6 +28,16 @@ public interface IDriverCapabilityInvokerFactory
/// </param>
/// <returns>A per-instance <see cref="IDriverCapabilityInvoker"/>.</returns>
IDriverCapabilityInvoker Create(string driverInstanceId, string driverType, string? resilienceConfigJson = null);
/// <summary>
/// Release every piece of shared resilience state held for <paramref name="driverInstanceId"/> — called
/// by the host when the instance leaves the deployed plan (#609). A concrete factory drops the
/// instance's cached pipelines and its status-tracker snapshots, so the periodic resilience publisher
/// stops re-emitting (and re-priming new telemetry subscribers with) a decommissioned driver's last
/// breaker/failure counters. Default: no-op (a factory that holds no state).
/// </summary>
/// <param name="driverInstanceId">The driver instance that was removed.</param>
void Release(string driverInstanceId) { }
}
/// <summary>
@@ -132,6 +132,14 @@ public sealed record HistoryReadResult(
byte[]? ContinuationPoint);
/// <summary>Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates.</summary>
/// <remarks>
/// The first five members are the original set every historian backend serves. The remainder (#285) are
/// the further standard Part 13 aggregates a server-side HistoryRead can forward to a backend that
/// supports them (the OPC UA Client driver forwards all of them upstream). A backend that cannot serve a
/// member must throw <see cref="HistoryAggregateNotSupportedException"/> — the server maps that to
/// <c>BadAggregateNotSupported</c> rather than a generic failure. <b>Append only</b>: the numeric values
/// are not persisted, but every <c>switch</c> over this enum must keep a total default.
/// </remarks>
public enum HistoryAggregateType
{
Average,
@@ -139,6 +147,42 @@ public enum HistoryAggregateType
Maximum,
Total,
Count,
Interpolative,
TimeAverage,
Start,
End,
Delta,
Range,
MinimumActualTime,
MaximumActualTime,
StartBound,
EndBound,
DeltaBounds,
DurationGood,
DurationBad,
PercentGood,
PercentBad,
WorstQuality,
NumberOfTransitions,
AnnotationCount,
StandardDeviationPopulation,
VariancePopulation,
}
/// <summary>
/// Thrown by a history backend when it cannot serve the requested <see cref="HistoryAggregateType"/>.
/// The Server HistoryRead path translates it to <c>BadAggregateNotSupported</c> for that node only.
/// </summary>
public sealed class HistoryAggregateNotSupportedException : NotSupportedException
{
/// <summary>The aggregate the backend could not serve.</summary>
public HistoryAggregateType Aggregate { get; }
/// <summary>Creates the exception for <paramref name="aggregate"/>.</summary>
/// <param name="aggregate">The unsupported aggregate.</param>
public HistoryAggregateNotSupportedException(HistoryAggregateType aggregate)
: base($"The historian backend does not support the {aggregate} aggregate.")
=> Aggregate = aggregate;
}
/// <summary>
@@ -105,7 +105,17 @@ public static class Part9StateMachine
throw new ArgumentException("User identity required for audit.", nameof(user));
if (current.Acked == AlarmAckedState.Acknowledged)
return TransitionResult.NoOp(current, "already acknowledged");
{
// A COMMENTED re-ack carries a real comment even though nothing is acknowledged (Gitea #597 review
// m1): the OPC UA node reports it — the SDK writes the comment on a re-ack — so the system of
// record must hold it too, or the event advertises a comment that exists only on the node that
// served the call (never persisted, never replicated to the pair peer). Recorded exactly like
// AddComment; the ack fields (LastAck*) stay those of the real acknowledge. An uncommented re-ack
// is still a no-op.
return string.IsNullOrWhiteSpace(comment)
? TransitionResult.NoOp(current, "already acknowledged")
: ApplyAddComment(current, user, comment, nowUtc);
}
var audit = AppendComment(current.Comments, nowUtc, user, "Acknowledge", comment);
var next = current with
@@ -8,9 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// Per-evaluation <see cref="ScriptContext"/> for a virtual-tag script. Reads come
/// out of the engine's last-known-value cache (driver tags updated via the
/// <c>ITagUpstreamSource</c> subscription, virtual tags updated by prior
/// evaluations). Writes route through <c>VirtualTagEngine</c>'s
/// <c>OnScriptSetVirtualTag</c> callback so cross-tag write side effects still
/// participate in change-trigger cascades (via the engine's <c>CascadeAsync</c>).
/// evaluations). Writes route through the host-supplied set-virtual-tag callback.
/// </summary>
/// <remarks>
/// <para>
@@ -81,10 +81,12 @@ public static class ScriptSandbox
// surface (operator-authored hosting helpers, third-party packages, app code)
// is not on the list and stays unreachable.
var references = new List<MetadataReference>();
// #603: both halves are cached. CreateFromFile reads + maps the DLL, and the BCL half is ~170
// assemblies, so doing it per compile was the dominant fixed cost of every ScriptEvaluator build.
// MetadataReference is immutable and thread-safe, so sharing instances across compilations is safe.
foreach (var asm in pinnedAssemblies)
references.Add(MetadataReference.CreateFromFile(asm.Location));
foreach (var path in EnumerateBclAssemblyPaths())
references.Add(MetadataReference.CreateFromFile(path));
references.Add(PinnedReferences.GetOrAdd(asm, static a => MetadataReference.CreateFromFile(a.Location)));
references.AddRange(BclReferences.Value);
var imports = new[]
{
@@ -97,6 +99,13 @@ public static class ScriptSandbox
return new SandboxConfig(references, imports);
}
private static readonly System.Collections.Concurrent.ConcurrentDictionary<System.Reflection.Assembly, MetadataReference> PinnedReferences = new();
// The trusted-platform set is process-constant, so the BCL reference list is context-independent.
private static readonly Lazy<IReadOnlyList<MetadataReference>> BclReferences = new(
static () => EnumerateBclAssemblyPaths().Select(static p => (MetadataReference)MetadataReference.CreateFromFile(p)).ToArray(),
LazyThreadSafetyMode.ExecutionAndPublication);
private static IEnumerable<string> EnumerateBclAssemblyPaths()
{
// The .NET host advertises the resolved runtime-shared-framework + BCL DLL set
@@ -15,11 +15,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// from a last-known-value cache populated by the subscription callbacks.
/// </para>
/// <para>
/// The subscription path feeds <see cref="VirtualTagEngine"/>'s
/// <c>OnUpstreamChange</c> callback, which updates the engine's value cache and
/// schedules <c>CascadeAsync</c> to re-evaluate every change-driven dependent in
/// topological order. One subscription per distinct upstream tag path; the
/// engine tracks the mapping itself.
/// The subscription path feeds the consumer's change callback (scripted alarms via
/// <c>DependencyMuxTagUpstreamSource</c>). One subscription per distinct upstream tag path.
/// </para>
/// </remarks>
public interface ITagUpstreamSource
@@ -1,162 +0,0 @@
using Serilog;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// Periodic re-evaluation scheduler for tags with a non-null
/// <see cref="VirtualTagDefinition.TimerInterval"/>. Independent of the
/// change-trigger path — a tag can be timer-only, change-only, or both. One
/// <see cref="System.Threading.Timer"/> per interval-group keeps the wire count
/// low regardless of tag count.
/// </summary>
/// <remarks>
/// <para>
/// Each timer group carries a per-group in-flight flag (see
/// <c>TickGroup.InFlight</c>). When the timer fires while a tick for the same
/// group is still running, the new callback skips the work and increments
/// <see cref="SkippedTickCount"/> rather than blocking a thread-pool thread on
/// the engine's evaluation gate. This bounds the work outstanding at one tick
/// per group, regardless of how long an individual evaluation takes.
/// </para>
/// </remarks>
public sealed class TimerTriggerScheduler : IDisposable
{
private readonly VirtualTagEngine _engine;
private readonly ILogger _logger;
private readonly List<Timer> _timers = [];
private readonly List<TickGroup> _groups = [];
private readonly CancellationTokenSource _cts = new();
private long _skippedTickCount;
private bool _disposed;
private bool _started;
/// <summary>Initializes a new instance of the <see cref="TimerTriggerScheduler"/> class.</summary>
/// <param name="engine">The virtual tag engine to trigger evaluations on.</param>
/// <param name="logger">A logger for diagnostic output.</param>
public TimerTriggerScheduler(VirtualTagEngine engine, ILogger logger)
{
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Diagnostic counter: number of timer callbacks that skipped their work because
/// the prior tick for the same group was still running. Exposed for tests +
/// operational metrics. Monotonic; never resets.
/// </summary>
public long SkippedTickCount => Interlocked.Read(ref _skippedTickCount);
/// <summary>
/// Stand up one <see cref="Timer"/> per unique interval. All tags with
/// matching interval share a timer; each tick triggers re-evaluation of the
/// group in topological order so cascades are consistent with change-triggered
/// behavior.
/// </summary>
/// <param name="definitions">The virtual tag definitions to schedule timers for.</param>
/// <exception cref="InvalidOperationException">Thrown when <c>Start</c> is called more than once on the same instance.</exception>
public void Start(IReadOnlyList<VirtualTagDefinition> definitions)
{
if (_disposed) throw new ObjectDisposedException(nameof(TimerTriggerScheduler));
if (_started) throw new InvalidOperationException(
"TimerTriggerScheduler.Start has already been called. Create a new instance for each Load cycle.");
_started = true;
var byInterval = definitions
.Where(d => d.TimerInterval.HasValue && d.TimerInterval.Value > TimeSpan.Zero)
.GroupBy(d => d.TimerInterval!.Value);
foreach (var group in byInterval)
{
var paths = group.Select(d => d.Path).ToArray();
var interval = group.Key;
var ctx = new TickGroup(paths);
_groups.Add(ctx);
var timer = new Timer(_ => OnTimer(ctx), null, interval, interval);
_timers.Add(timer);
_logger.Information("TimerTriggerScheduler: {TagCount} tag(s) on {Interval} cadence",
paths.Length, interval);
}
}
private void OnTimer(TickGroup ctx)
{
if (_cts.IsCancellationRequested) return;
// Skip the tick when the prior one for this group is still running. Without
// this guard a slow evaluation (or one waiting on the engine's _evalGate) would
// cause subsequent timer callbacks to each pin a thread-pool thread on the
// gate, compounding under high tick rates.
if (Interlocked.CompareExchange(ref ctx.InFlight, 1, 0) != 0)
{
Interlocked.Increment(ref _skippedTickCount);
return;
}
// Run async without blocking the timer's pool-thread callback. The task is
// fire-and-forget — failures are logged inside RunTickAsync; the InFlight flag
// is reset in the finally block so the next tick can proceed.
_ = RunTickAsync(ctx);
}
private async Task RunTickAsync(TickGroup ctx)
{
try
{
foreach (var p in ctx.Paths)
{
if (_cts.IsCancellationRequested) return;
try
{
await _engine.EvaluateOneAsync(p, _cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
catch (Exception ex)
{
_logger.Error(ex, "TimerTriggerScheduler evaluate failed for {Path}", p);
}
}
}
finally
{
Interlocked.Exchange(ref ctx.InFlight, 0);
}
}
/// <summary>Releases all timers and disposes the scheduler's resources.</summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_cts.Cancel();
foreach (var t in _timers)
{
try { t.Dispose(); } catch { }
}
_timers.Clear();
_groups.Clear();
_cts.Dispose();
}
/// <summary>Groups tags that share the same timer interval.</summary>
private sealed class TickGroup
{
// 0 = idle, 1 = a tick is currently running (or queued) for this group. Use
// Interlocked.CompareExchange so a timer callback observes a consistent "is the
// prior tick still running" answer without taking a lock.
/// <summary>Flag indicating whether a tick is currently in flight for this group.</summary>
public int InFlight;
/// <summary>Gets the list of tag paths in this group.</summary>
public IReadOnlyList<string> Paths { get; }
/// <summary>Initializes a new instance of the <see cref="TickGroup"/> class.</summary>
/// <param name="paths">The tag paths in this timer group.</param>
public TickGroup(IReadOnlyList<string> paths)
{
Paths = paths;
}
}
}
@@ -1,41 +0,0 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// Operator-authored virtual-tag configuration row. Phase 7 Stream E (config DB
/// schema) materializes these from the <c>VirtualTag</c> + <c>Script</c> tables on
/// publish; the engine ingests a list of them at load time.
/// </summary>
/// <param name="Path">
/// UNS tag path — <c>Enterprise/Site/Area/Line/Equipment/TagName</c>. Used both as
/// the engine's internal id and the OPC UA browse path.
/// </param>
/// <param name="DataType">
/// Expected return type. The evaluator coerces the script's return value to this
/// type before publishing; mismatch surfaces as <c>BadTypeMismatch</c> quality on
/// the tag.
/// </param>
/// <param name="ScriptSource">Roslyn C# script source. Must compile under <c>ScriptSandbox</c>.</param>
/// <param name="ChangeTriggered">
/// True if any input tag's change (value / status / timestamp delta) should trigger
/// re-evaluation. Operator picks per tag — usually true for inputs that change at
/// protocol rates.
/// </param>
/// <param name="TimerInterval">
/// Optional periodic re-evaluation cadence. Null = timer-driven disabled. Both can
/// be enabled simultaneously; independent scheduling paths both end at
/// <see cref="VirtualTagEngine"/>'s <c>EvaluateInternalAsync</c>.
/// </param>
/// <param name="Historize">
/// When true, every evaluation result is forwarded to the configured
/// <see cref="IHistoryWriter"/>. Operator-set per tag; the Admin UI exposes as a
/// checkbox.
/// </param>
public sealed record VirtualTagDefinition(
string Path,
DriverDataType DataType,
string ScriptSource,
bool ChangeTriggered = true,
TimeSpan? TimerInterval = null,
bool Historize = false);
@@ -1,568 +0,0 @@
using System.Collections.Concurrent;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// The Phase 7 virtual-tag evaluation engine. Ingests a set of
/// <see cref="VirtualTagDefinition"/>s at load time, compiles each script against
/// <see cref="ScriptSandbox"/>, builds the dependency graph, subscribes to every
/// referenced upstream tag, and schedules re-evaluations on change + on timer.
/// </summary>
public sealed class VirtualTagEngine : IDisposable
{
private readonly ITagUpstreamSource _upstream;
private readonly IHistoryWriter _history;
private readonly ScriptLoggerFactory _loggerFactory;
private readonly ILogger _engineLogger;
private readonly Func<DateTime> _clock;
private readonly TimeSpan _scriptTimeout;
private readonly DependencyGraph _graph = new();
private readonly Dictionary<string, VirtualTagState> _tags = new(StringComparer.Ordinal);
/// <summary>
/// Compile cache for every virtual-tag script. Routes <see cref="Load"/>'s
/// <see cref="ScriptEvaluator{TContext, TResult}.Compile"/> calls through the
/// cache so the collectible <see cref="System.Runtime.Loader.AssemblyLoadContext"/>
/// each compile produces is actually disposed on the publish-replace path: the
/// cache's <see cref="CompiledScriptCache{TContext, TResult}.Clear"/> disposes
/// every materialised evaluator before dropping its dictionary entry, so a
/// config-publish releases the prior generation's ALCs and the per-publish
/// accretion is actually freed in production. Pre-fix, the engine called
/// <c>ScriptEvaluator.Compile</c> directly, which left the ALCs rooted until
/// the process exited.
/// </summary>
private readonly CompiledScriptCache<VirtualTagContext, object?> _compileCache = new();
private readonly ConcurrentDictionary<string, DataValueSnapshot> _valueCache = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, List<Action<string, DataValueSnapshot>>> _observers
= new(StringComparer.Ordinal);
private readonly List<IDisposable> _upstreamSubscriptions = [];
private readonly SemaphoreSlim _evalGate = new(1, 1);
private bool _loaded;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="VirtualTagEngine"/> class.
/// </summary>
/// <param name="upstream">The upstream tag source for reading input tags.</param>
/// <param name="loggerFactory">Factory for creating script loggers.</param>
/// <param name="engineLogger">Logger for engine events and errors.</param>
/// <param name="historyWriter">Optional history writer for recording tag evaluations; defaults to no-op.</param>
/// <param name="clock">Optional function providing current UTC time; defaults to <see cref="DateTime.UtcNow"/>.</param>
/// <param name="scriptTimeout">Optional timeout for script evaluation; defaults to <see cref="TimedScriptEvaluator{TContext, TResult}.DefaultTimeout"/>.</param>
public VirtualTagEngine(
ITagUpstreamSource upstream,
ScriptLoggerFactory loggerFactory,
ILogger engineLogger,
IHistoryWriter? historyWriter = null,
Func<DateTime>? clock = null,
TimeSpan? scriptTimeout = null)
{
_upstream = upstream ?? throw new ArgumentNullException(nameof(upstream));
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
_engineLogger = engineLogger ?? throw new ArgumentNullException(nameof(engineLogger));
_history = historyWriter ?? NullHistoryWriter.Instance;
_clock = clock ?? (() => DateTime.UtcNow);
_scriptTimeout = scriptTimeout ?? TimedScriptEvaluator<VirtualTagContext, object?>.DefaultTimeout;
}
/// <summary>Registered tag paths, in topological order. Empty before <see cref="Load"/>.</summary>
public IReadOnlyCollection<string> LoadedTagPaths => _tags.Keys;
/// <summary>Compile + register every tag in <paramref name="definitions"/>. Throws on cycle or any compile failure.</summary>
/// <param name="definitions">List of virtual tag definitions to load and compile.</param>
public void Load(IReadOnlyList<VirtualTagDefinition> definitions)
{
if (_disposed) throw new ObjectDisposedException(nameof(VirtualTagEngine));
if (definitions is null) throw new ArgumentNullException(nameof(definitions));
// Start from a clean slate — supports config-publish reloads.
UnsubscribeFromUpstream();
_tags.Clear();
_graph.Clear();
_compileCache.Clear();
var compileFailures = new List<string>();
var seenPaths = new HashSet<string>(StringComparer.Ordinal);
foreach (var def in definitions)
{
if (!seenPaths.Add(def.Path))
{
compileFailures.Add($"{def.Path}: duplicate path — only one definition per path is allowed");
continue;
}
if (!IsSupportedDataType(def.DataType))
{
compileFailures.Add(
$"{def.Path}: unsupported DataType DriverDataType.{def.DataType} — virtual tags only support scalar primitive types");
continue;
}
try
{
var extraction = DependencyExtractor.Extract(def.ScriptSource);
if (!extraction.IsValid)
{
var msgs = string.Join("; ", extraction.Rejections.Select(r => r.Message));
compileFailures.Add($"{def.Path}: dependency extraction rejected — {msgs}");
continue;
}
var evaluator = _compileCache.GetOrCompile(def.ScriptSource);
var timed = new TimedScriptEvaluator<VirtualTagContext, object?>(evaluator, _scriptTimeout);
var scriptLogger = _loggerFactory.Create(def.Path);
_tags[def.Path] = new VirtualTagState(def, timed, extraction.Reads, extraction.Writes, scriptLogger);
_graph.Add(def.Path, extraction.Reads);
}
catch (Exception ex)
{
compileFailures.Add($"{def.Path}: {ex.Message}");
}
}
// Validate every ctx.SetVirtualTag write target resolves to a registered virtual
// tag. A script writing to a non-existent virtual path would otherwise be silently
// dropped at runtime by OnScriptSetVirtualTag's warning-and-drop branch; catching
// it here surfaces operator typos as a publish failure.
foreach (var (path, state) in _tags)
{
foreach (var writeTarget in state.Writes)
{
if (!_tags.ContainsKey(writeTarget))
{
compileFailures.Add(
$"{path}: ctx.SetVirtualTag target '{writeTarget}' is not a registered virtual tag");
}
}
}
if (compileFailures.Count > 0)
{
var joined = string.Join("\n ", compileFailures);
throw new InvalidOperationException(
$"Virtual-tag engine load failed. {compileFailures.Count} script(s) did not compile:\n {joined}");
}
// Cycle check — throws DependencyCycleException on offense.
_ = _graph.TopologicalSort();
// Subscribe to every referenced upstream path (driver tags only — virtual tags
// cascade internally). Seed the cache with current upstream values so first
// evaluations see something real. Iterate _tags.Values (the registered set) rather
// than definitions to avoid indexing by a raw input list that may contain duplicates.
var upstreamPaths = _tags.Values
.SelectMany(s => s.Reads)
.Where(p => !_tags.ContainsKey(p))
.Distinct(StringComparer.Ordinal);
foreach (var path in upstreamPaths)
{
_valueCache[path] = _upstream.ReadTag(path);
_upstreamSubscriptions.Add(_upstream.SubscribeTag(path, OnUpstreamChange));
}
_loaded = true;
_engineLogger.Information(
"VirtualTagEngine loaded {TagCount} tag(s), {UpstreamCount} upstream subscription(s)",
_tags.Count, _upstreamSubscriptions.Count);
}
/// <summary>
/// Evaluate every registered tag once in topological order — used at startup so
/// virtual tags have a defined initial value rather than inheriting the cache
/// default. Also called after a config reload.
/// </summary>
/// <param name="ct">Cancellation token to stop evaluation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task EvaluateAllAsync(CancellationToken ct = default)
{
EnsureLoaded();
var order = _graph.TopologicalSort();
foreach (var path in order)
{
if (_tags.ContainsKey(path))
await EvaluateOneAsync(path, ct).ConfigureAwait(false);
}
}
/// <summary>Evaluate a single tag — used by the timer trigger + test hooks.</summary>
/// <param name="path">Path of the virtual tag to evaluate.</param>
/// <param name="ct">Cancellation token to stop evaluation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task EvaluateOneAsync(string path, CancellationToken ct = default)
{
EnsureLoaded();
if (!_tags.ContainsKey(path))
throw new ArgumentException($"Not a registered virtual tag: {path}", nameof(path));
return EvaluateInternalAsync(path, ct);
}
/// <summary>
/// Read the most recently evaluated value for <paramref name="path"/>. Driver
/// tags return the last-known upstream value; virtual tags return their last
/// evaluation result.
/// </summary>
/// <param name="path">Path of the tag to read.</param>
/// <returns>The tag's most recently evaluated value, or a Bad-quality snapshot if the path is unrecognized.</returns>
public DataValueSnapshot Read(string path)
{
if (string.IsNullOrWhiteSpace(path))
return new DataValueSnapshot(null, 0x80340000u, null, _clock());
return _valueCache.TryGetValue(path, out var v)
? v
: new DataValueSnapshot(null, 0x80340000u /* BadNodeIdUnknown */, null, _clock());
}
/// <summary>
/// Register an observer that fires on every evaluation of the given tag.
/// Returns an <see cref="IDisposable"/> to unsubscribe. Does NOT fire a seed
/// value — subscribers call <see cref="Read"/> for the current value if needed.
/// </summary>
/// <param name="path">Path of the tag to subscribe to.</param>
/// <param name="observer">Callback invoked with the tag path and new value on each evaluation.</param>
/// <returns>An <see cref="IDisposable"/> that removes the observer when disposed.</returns>
public IDisposable Subscribe(string path, Action<string, DataValueSnapshot> observer)
{
// Race-safe pattern paired with Unsub.Dispose: if Unsub.Dispose removed the
// dictionary entry between our GetOrAdd and the lock-protected Add, the list
// we hold a reference to is orphaned. Re-check the map under the lock and
// re-insert the list (or grab the current one) if needed, retrying until the
// dictionary observably contains the list we just added our observer to.
while (true)
{
var list = _observers.GetOrAdd(path, _ => []);
lock (list)
{
// Confirm the list is still the dictionary's value for this key. If
// Dispose removed the entry, _observers[path] either doesn't exist or
// points at a different (newer) list — retry.
if (_observers.TryGetValue(path, out var current) && ReferenceEquals(current, list))
{
list.Add(observer);
return new Unsub(this, path, observer);
}
}
// Lost the race — Dispose pruned the list out from under us. Loop and
// either re-create or pick up the newer list.
}
}
/// <summary>
/// Change-trigger entry point — called by the upstream subscription callback.
/// Updates the cache, fans out to observers (so OPC UA clients see the upstream
/// change too if they subscribed via the engine), and schedules every
/// change-triggered dependent for re-evaluation in topological order.
/// </summary>
/// <param name="path">Path of the upstream tag that changed.</param>
/// <param name="value">New value of the tag.</param>
internal void OnUpstreamChange(string path, DataValueSnapshot value)
{
_valueCache[path] = value;
NotifyObservers(path, value);
// Fire-and-forget — the upstream subscription callback must not block the
// driver's dispatcher. Exceptions during cascade are handled per-tag inside
// EvaluateInternalAsync.
_ = CascadeAsync(path, CancellationToken.None);
}
private async Task CascadeAsync(string upstreamPath, CancellationToken ct)
{
try
{
var dependents = _graph.TransitiveDependentsInOrder(upstreamPath);
foreach (var dep in dependents)
{
if (_tags.TryGetValue(dep, out var state) && state.Definition.ChangeTriggered)
await EvaluateInternalAsync(dep, ct).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_engineLogger.Error(ex, "VirtualTagEngine cascade failed for upstream {Path}", upstreamPath);
}
}
private async Task EvaluateInternalAsync(string path, CancellationToken ct)
{
if (!_tags.TryGetValue(path, out var state)) return;
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
try
{
var ctxCache = BuildReadCache(state.Reads);
// Cold-start guard — when any upstream input is still unset or Bad-quality,
// publish a BadWaitingForInitialData snapshot so OPC UA clients see a defined
// quality rather than observing "not yet computed" as a stale Good value.
// Evaluating with nulls would throw inside the script (scripts cast
// ctx.GetTag(path).Value directly) and produce a persistent BadInternalError.
if (!AreInputsReady(ctxCache))
{
var notReady = new DataValueSnapshot(null, 0x80320000u /* BadWaitingForInitialData */, null, _clock());
_valueCache[path] = notReady;
NotifyObservers(path, notReady);
return;
}
var context = new VirtualTagContext(
ctxCache,
(p, v) => OnScriptSetVirtualTag(p, v),
state.Logger,
_clock);
DataValueSnapshot result;
try
{
var raw = await state.Evaluator.RunAsync(context, ct).ConfigureAwait(false);
var coerced = CoerceResult(raw, state.Definition.DataType);
// null from CoerceResult means the conversion threw (raw was non-null but
// not convertible to the declared type). Surface as BadInternalError so
// the OPC UA client sees a defined Bad quality rather than a Good null.
result = (raw is not null && coerced is null)
? new DataValueSnapshot(null, 0x80020000u /* BadInternalError */, null, _clock())
: new DataValueSnapshot(coerced, 0u, _clock(), _clock());
}
catch (ScriptTimeoutException tex)
{
state.Logger.Warning("Script timed out after {Timeout}", tex.Timeout);
result = new DataValueSnapshot(null, 0x80020000u /* BadInternalError */, null, _clock());
}
catch (OperationCanceledException)
{
throw; // shutdown path — don't misclassify
}
catch (Exception ex)
{
state.Logger.Error(ex, "Virtual-tag script threw");
result = new DataValueSnapshot(null, 0x80020000u /* BadInternalError */, null, _clock());
}
_valueCache[path] = result;
NotifyObservers(path, result);
if (state.Definition.Historize) _history.Record(path, result);
}
finally
{
_evalGate.Release();
}
}
private IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(IReadOnlySet<string> reads)
{
var map = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
foreach (var r in reads)
{
map[r] = _valueCache.TryGetValue(r, out var v)
? v
: _upstream.ReadTag(r);
}
return map;
}
/// <summary>
/// Returns true when every entry in <paramref name="cache"/> has a
/// Good/Uncertain-quality <see cref="DataValueSnapshot.StatusCode"/> (bit 31 = 0).
/// Readiness is gated on <c>StatusCode</c> alone — a Good-quality snapshot with a
/// null <c>Value</c> (e.g. a script that returns <c>null</c>, or a
/// <c>ctx.SetVirtualTag</c> call with a null argument) is considered ready and the
/// downstream script is allowed to run. The downstream script will observe the null
/// value and typically throw an NRE, which the outer evaluation catch maps to
/// <c>BadInternalError</c> — the correct sentinel for "script ran but faulted"
/// rather than "inputs not yet available". Previously the null-value check
/// caused a Good-quality null upstream to permanently block all its dependents at
/// <c>BadWaitingForInitialData</c>.
/// </summary>
private static bool AreInputsReady(IReadOnlyDictionary<string, DataValueSnapshot> cache)
{
foreach (var kv in cache)
{
if (kv.Value is null) return false;
if ((kv.Value.StatusCode & 0x80000000u) != 0) return false;
}
return true;
}
private void OnScriptSetVirtualTag(string path, object? value)
{
if (!_tags.ContainsKey(path))
{
_engineLogger.Warning(
"Script attempted ctx.SetVirtualTag on non-virtual or non-registered path {Path}", path);
return;
}
var snap = new DataValueSnapshot(value, 0u, _clock(), _clock());
_valueCache[path] = snap;
NotifyObservers(path, snap);
if (_tags[path].Definition.Historize) _history.Record(path, snap);
// A cross-tag write must participate in the change-trigger cascade, exactly
// like an upstream delta — any change-triggered tag that reads this path
// would otherwise go stale until an unrelated trigger fires (see
// docs/VirtualTags.md, VirtualTagContext section). Fire-and-forget: this
// callback runs inside EvaluateInternalAsync with the non-reentrant
// _evalGate held, so the cascade must be scheduled, not invoked inline.
_ = CascadeAsync(path, CancellationToken.None);
}
private void NotifyObservers(string path, DataValueSnapshot value)
{
if (!_observers.TryGetValue(path, out var list)) return;
Action<string, DataValueSnapshot>[] snapshot;
lock (list) { snapshot = list.ToArray(); }
foreach (var obs in snapshot)
{
try { obs(path, value); }
catch (Exception ex)
{
_engineLogger.Warning(ex, "Virtual-tag observer for {Path} threw", path);
}
}
}
private static object? CoerceResult(object? raw, DriverDataType target)
{
if (raw is null) return null;
try
{
return target switch
{
DriverDataType.Boolean => Convert.ToBoolean(raw),
DriverDataType.Int16 => Convert.ToInt16(raw),
DriverDataType.Int32 => Convert.ToInt32(raw),
DriverDataType.Int64 => Convert.ToInt64(raw),
DriverDataType.UInt16 => Convert.ToUInt16(raw),
DriverDataType.UInt32 => Convert.ToUInt32(raw),
DriverDataType.UInt64 => Convert.ToUInt64(raw),
DriverDataType.Float32 => Convert.ToSingle(raw),
DriverDataType.Float64 => Convert.ToDouble(raw),
DriverDataType.String => Convert.ToString(raw) ?? string.Empty,
DriverDataType.DateTime => raw is DateTime dt ? dt : Convert.ToDateTime(raw),
// Any DriverDataType not in the explicit list (currently Reference, or any
// future enum member added without coercion support) must NOT silently
// return the uncoerced raw value — that would surface as a wire-level
// type mismatch on the OPC UA Variant. Throwing here is caught by the
// outer catch and mapped to BadInternalError. Load-time validation in
// IsSupportedDataType ensures operators never publish such a tag.
_ => throw new InvalidOperationException(
$"Virtual-tag CoerceResult does not support DriverDataType.{target}"),
};
}
catch
{
// Caller maps null to BadInternalError (0x80020000) — we let null propagate
// so the outer evaluation path sets the Bad quality on the snapshot.
return null;
}
}
/// <summary>
/// The set of <see cref="DriverDataType"/> values <see cref="CoerceResult"/> can
/// honour. Definitions declaring any other type are rejected at <see cref="Load"/>
/// so an operator typo (or a future enum member added without coercion support) is
/// caught at publish time rather than silently producing a type-mismatched value.
/// </summary>
private static bool IsSupportedDataType(DriverDataType t) => t switch
{
DriverDataType.Boolean => true,
DriverDataType.Int16 => true,
DriverDataType.Int32 => true,
DriverDataType.Int64 => true,
DriverDataType.UInt16 => true,
DriverDataType.UInt32 => true,
DriverDataType.UInt64 => true,
DriverDataType.Float32 => true,
DriverDataType.Float64 => true,
DriverDataType.String => true,
DriverDataType.DateTime => true,
_ => false,
};
private void UnsubscribeFromUpstream()
{
foreach (var s in _upstreamSubscriptions)
{
try { s.Dispose(); } catch { /* best effort */ }
}
_upstreamSubscriptions.Clear();
}
private void EnsureLoaded()
{
if (!_loaded) throw new InvalidOperationException(
"VirtualTagEngine not loaded. Call Load(definitions) first.");
}
/// <summary>
/// Releases all resources held by the engine, including upstream subscriptions and compiled script caches.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
UnsubscribeFromUpstream();
_tags.Clear();
_graph.Clear();
_compileCache.Dispose();
}
/// <summary>
/// Gets the dependency graph for testing purposes.
/// </summary>
internal DependencyGraph GraphForTesting => _graph;
private sealed class Unsub : IDisposable
{
private readonly VirtualTagEngine _engine;
private readonly string _path;
private readonly Action<string, DataValueSnapshot> _observer;
/// <summary>
/// Initializes a new instance of the <see cref="Unsub"/> class.
/// </summary>
/// <param name="e">The parent virtual tag engine.</param>
/// <param name="path">Path of the subscribed tag.</param>
/// <param name="observer">The observer callback to unsubscribe.</param>
public Unsub(VirtualTagEngine e, string path, Action<string, DataValueSnapshot> observer)
{
_engine = e; _path = path; _observer = observer;
}
/// <summary>
/// Removes the observer from the subscription list.
/// </summary>
public void Dispose()
{
if (_engine._observers.TryGetValue(_path, out var list))
{
lock (list)
{
list.Remove(_observer);
// If we removed the last observer, prune the dictionary entry so a
// long-running server doesn't accumulate empty Lists for paths that
// saw transient subscriptions. The emptiness check is inside the same
// lock so a concurrent Subscribe can't slip an observer in after we
// observe the list as empty.
if (list.Count == 0)
{
// ICollection<KeyValuePair<,>> removal is value-typed — only removes
// if both key + value still match (i.e. the dictionary still points
// at this list). This keeps a racing Subscribe's brand-new list safe.
((ICollection<KeyValuePair<string, List<Action<string, DataValueSnapshot>>>>)_engine._observers)
.Remove(new KeyValuePair<string, List<Action<string, DataValueSnapshot>>>(_path, list));
}
}
}
}
}
internal sealed record VirtualTagState(
VirtualTagDefinition Definition,
TimedScriptEvaluator<VirtualTagContext, object?> Evaluator,
IReadOnlySet<string> Reads,
IReadOnlySet<string> Writes,
ILogger Logger);
}
@@ -1,106 +0,0 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// Implements the driver-agnostic capability surface the
/// <c>DriverNodeManager</c> dispatches to when a node resolves to
/// <c>NodeSource.Virtual</c>. Reads return the engine's last-known
/// evaluation result; subscriptions forward engine-emitted change events as
/// <see cref="ISubscribable.OnDataChange"/> events.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="IWritable"/> is deliberately not implemented — OPC UA client
/// writes to virtual tags are rejected in <c>DriverNodeManager</c> before they
/// reach here. Scripts are the only write path, routed
/// through <c>ctx.SetVirtualTag</c>.
/// </para>
/// </remarks>
public sealed class VirtualTagSource : IReadable, ISubscribable
{
private readonly VirtualTagEngine _engine;
private readonly ConcurrentDictionary<string, Subscription> _subs = new(StringComparer.Ordinal);
/// <summary>
/// Initializes a new instance of the <see cref="VirtualTagSource"/> class.
/// </summary>
/// <param name="engine">The virtual tag engine.</param>
public VirtualTagSource(VirtualTagEngine engine)
{
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
}
/// <summary>
/// Occurs when data values change.
/// </summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <inheritdoc />
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
if (fullReferences is null) throw new ArgumentNullException(nameof(fullReferences));
var results = new DataValueSnapshot[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
results[i] = _engine.Read(fullReferences[i]);
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(results);
}
/// <inheritdoc />
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences,
TimeSpan publishingInterval,
CancellationToken cancellationToken)
{
if (fullReferences is null) throw new ArgumentNullException(nameof(fullReferences));
var handle = new SubscriptionHandle(Guid.NewGuid().ToString("N"));
var observers = new List<IDisposable>(fullReferences.Count);
// OPC UA convention: for each path, emit the initial-data callback BEFORE
// registering the change observer. This prevents a race where an upstream change
// fires the observer between the Subscribe call and the Read call, which would
// deliver a newer change event before the initial-data event, leaving the client
// with a stale last-known value.
foreach (var path in fullReferences)
{
var snap = _engine.Read(path);
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, path, snap));
observers.Add(_engine.Subscribe(path, (p, s) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, p, s))));
}
_subs[handle.DiagnosticId] = new Subscription(handle, observers);
return Task.FromResult<ISubscriptionHandle>(handle);
}
/// <inheritdoc />
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is null) throw new ArgumentNullException(nameof(handle));
if (_subs.TryRemove(handle.DiagnosticId, out var sub))
{
foreach (var d in sub.Observers)
{
try { d.Dispose(); } catch { }
}
}
return Task.CompletedTask;
}
private sealed class SubscriptionHandle : ISubscriptionHandle
{
/// <summary>
/// Initializes a new instance of the <see cref="SubscriptionHandle"/> class.
/// </summary>
/// <param name="id">The subscription identifier.</param>
public SubscriptionHandle(string id) { DiagnosticId = id; }
/// <inheritdoc />
public string DiagnosticId { get; }
}
private sealed record Subscription(SubscriptionHandle Handle, IReadOnlyList<IDisposable> Observers);
}
@@ -80,6 +80,9 @@ public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFac
// The tracker's snapshots describe the breakers inside the pipelines just discarded, so they go
// too — a retained "Breaker OPEN" from a pre-respawn outage could never self-correct (only the
// discarded pipeline's OnClosed stamps the close) and would show a false OPEN chip forever.
// Reinstate first: an instance id removed by an earlier deployment (Release ⇒ Retire) and now re-added
// must record again (#609).
_statusTracker.Reinstate(driverInstanceId);
_statusTracker.Invalidate(driverInstanceId);
return new CapabilityInvoker(
@@ -90,4 +93,21 @@ public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFac
statusTracker: _statusTracker,
optionsGeneration: generation);
}
/// <inheritdoc />
/// <remarks>
/// #609: without this the tracker kept a removed instance's snapshots forever (only <see cref="Create"/>
/// invalidated, and a removed instance is never re-created), and the resilience publisher re-emitted
/// them every tick — undoing the host's telemetry-hub eviction within one interval.
/// <para>The tracker <b>retires</b> the instance rather than merely invalidating it: the host stops the
/// child asynchronously, so a capability call it still had in flight completes through the released
/// invoker afterwards, and its call-start/success/complete writes re-created the entry (observed: the
/// child's in-flight SubscribeBulk). A retired id ignores those writes until a later <see cref="Create"/>
/// for the same id reinstates it.</para>
/// </remarks>
public void Release(string driverInstanceId)
{
_builder.Invalidate(driverInstanceId);
_statusTracker.Retire(driverInstanceId);
}
}
@@ -22,14 +22,38 @@ public sealed class DriverResilienceStatusTracker
{
private readonly ConcurrentDictionary<StatusKey, ResilienceStatusSnapshot> _status = new();
// Instances retired by Retire (removed from the deployed plan) and not since Reinstated (#609). Every
// Record* for one of these is ignored: the removed instance's child stops ASYNCHRONOUSLY, so a capability
// call it still had in flight completes through its invoker after the host's release and would otherwise
// re-create the entry — which the resilience publisher then re-emits every tick, forever. One entry per
// distinct removed id, so the set stays as small as the driver churn that fills it.
private readonly ConcurrentDictionary<string, byte> _retired = new(StringComparer.Ordinal);
/// <summary>
/// The single write path for every <c>Record*</c>. A write for a retired instance is dropped; the
/// post-write re-check closes the race with a concurrent <see cref="Retire"/> — a writer that passed the
/// first check just before the retire and lands its upsert just after the retire's sweep removes its own
/// write again, so no interleaving resurrects the instance.
/// </summary>
private void Upsert(
string driverInstanceId,
string hostName,
Func<StatusKey, ResilienceStatusSnapshot> add,
Func<StatusKey, ResilienceStatusSnapshot, ResilienceStatusSnapshot> update)
{
if (_retired.ContainsKey(driverInstanceId)) return;
var key = new StatusKey(driverInstanceId, hostName);
_status.AddOrUpdate(key, add, update);
if (_retired.ContainsKey(driverInstanceId)) _status.TryRemove(key, out _);
}
/// <summary>Record a Polly pipeline failure for <paramref name="hostName"/>.</summary>
/// <param name="driverInstanceId">The driver instance identifier.</param>
/// <param name="hostName">The host name.</param>
/// <param name="utcNow">The UTC timestamp of the failure event.</param>
public void RecordFailure(string driverInstanceId, string hostName, DateTime utcNow)
{
var key = new StatusKey(driverInstanceId, hostName);
_status.AddOrUpdate(key,
Upsert(driverInstanceId, hostName,
_ => new ResilienceStatusSnapshot { ConsecutiveFailures = 1, LastSampledUtc = utcNow },
(_, existing) => existing with
{
@@ -44,8 +68,7 @@ public sealed class DriverResilienceStatusTracker
/// <param name="utcNow">The UTC timestamp of the success event.</param>
public void RecordSuccess(string driverInstanceId, string hostName, DateTime utcNow)
{
var key = new StatusKey(driverInstanceId, hostName);
_status.AddOrUpdate(key,
Upsert(driverInstanceId, hostName,
_ => new ResilienceStatusSnapshot { ConsecutiveFailures = 0, LastSampledUtc = utcNow },
(_, existing) => existing with
{
@@ -60,8 +83,7 @@ public sealed class DriverResilienceStatusTracker
/// <param name="utcNow">The UTC timestamp of the breaker open event.</param>
public void RecordBreakerOpen(string driverInstanceId, string hostName, DateTime utcNow)
{
var key = new StatusKey(driverInstanceId, hostName);
_status.AddOrUpdate(key,
Upsert(driverInstanceId, hostName,
_ => new ResilienceStatusSnapshot { LastBreakerOpenUtc = utcNow, LastSampledUtc = utcNow },
(_, existing) => existing with { LastBreakerOpenUtc = utcNow, LastSampledUtc = utcNow });
}
@@ -77,8 +99,7 @@ public sealed class DriverResilienceStatusTracker
/// <param name="utcNow">The UTC timestamp of the breaker close event.</param>
public void RecordBreakerClose(string driverInstanceId, string hostName, DateTime utcNow)
{
var key = new StatusKey(driverInstanceId, hostName);
_status.AddOrUpdate(key,
Upsert(driverInstanceId, hostName,
_ => new ResilienceStatusSnapshot { LastBreakerClosedUtc = utcNow, LastSampledUtc = utcNow },
(_, existing) => existing with { LastBreakerClosedUtc = utcNow, LastSampledUtc = utcNow });
}
@@ -104,14 +125,39 @@ public sealed class DriverResilienceStatusTracker
return removed;
}
/// <summary>
/// Drop every snapshot for a driver instance that has been REMOVED from the deployed plan, and ignore
/// every later write for it until <see cref="Reinstate"/> (#609). Called by
/// <c>DriverCapabilityInvokerFactory.Release</c>. Unlike <see cref="Invalidate"/> (a respawn, whose new
/// invoker records straight away), the removed instance's child is still stopping — a call it had in
/// flight finishes after this returns, and without the hold its RecordCallStart / RecordSuccess /
/// RecordCallComplete re-create the entry the publisher then re-emits forever.
/// </summary>
/// <param name="driverInstanceId">The removed driver instance.</param>
/// <returns>The number of snapshots removed.</returns>
public int Retire(string driverInstanceId)
{
// Retire BEFORE sweeping: a write racing the sweep either lands first (and is swept) or re-checks after
// its upsert and removes itself (see Upsert).
_retired[driverInstanceId] = 0;
return Invalidate(driverInstanceId);
}
/// <summary>
/// Lift an earlier <see cref="Retire"/> so the instance records again — the same driver instance id
/// re-added in a later deployment. Called by <c>DriverCapabilityInvokerFactory.Create</c>; a no-op for
/// an id that was never retired.
/// </summary>
/// <param name="driverInstanceId">The driver instance being (re)created.</param>
public void Reinstate(string driverInstanceId) => _retired.TryRemove(driverInstanceId, out _);
/// <summary>Record a process recycle event (Tier C only).</summary>
/// <param name="driverInstanceId">The driver instance identifier.</param>
/// <param name="hostName">The host name.</param>
/// <param name="utcNow">The UTC timestamp of the recycle event.</param>
public void RecordRecycle(string driverInstanceId, string hostName, DateTime utcNow)
{
var key = new StatusKey(driverInstanceId, hostName);
_status.AddOrUpdate(key,
Upsert(driverInstanceId, hostName,
_ => new ResilienceStatusSnapshot { LastRecycleUtc = utcNow, LastSampledUtc = utcNow },
(_, existing) => existing with { LastRecycleUtc = utcNow, LastSampledUtc = utcNow });
}
@@ -124,8 +170,7 @@ public sealed class DriverResilienceStatusTracker
/// <param name="utcNow">The UTC timestamp of the footprint update.</param>
public void RecordFootprint(string driverInstanceId, string hostName, long baselineBytes, long currentBytes, DateTime utcNow)
{
var key = new StatusKey(driverInstanceId, hostName);
_status.AddOrUpdate(key,
Upsert(driverInstanceId, hostName,
_ => new ResilienceStatusSnapshot
{
BaselineFootprintBytes = baselineBytes,
@@ -150,8 +195,7 @@ public sealed class DriverResilienceStatusTracker
/// <param name="hostName">The host name.</param>
public void RecordCallStart(string driverInstanceId, string hostName)
{
var key = new StatusKey(driverInstanceId, hostName);
_status.AddOrUpdate(key,
Upsert(driverInstanceId, hostName,
_ => new ResilienceStatusSnapshot { CurrentInFlight = 1 },
(_, existing) => existing with { CurrentInFlight = existing.CurrentInFlight + 1 });
}
@@ -161,8 +205,7 @@ public sealed class DriverResilienceStatusTracker
/// <param name="hostName">The host name.</param>
public void RecordCallComplete(string driverInstanceId, string hostName)
{
var key = new StatusKey(driverInstanceId, hostName);
_status.AddOrUpdate(key,
Upsert(driverInstanceId, hostName,
_ => new ResilienceStatusSnapshot { CurrentInFlight = 0 }, // start-without-complete shouldn't happen; clamp to 0
(_, existing) => existing with { CurrentInFlight = Math.Max(0, existing.CurrentInFlight - 1) });
}
@@ -1,5 +1,6 @@
using CliFx.Attributes;
using CliFx.Infrastructure;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Commands;
@@ -51,8 +52,14 @@ public sealed class ProbeCommand : S7CommandBase
var snapshot = await driver.ReadAsync(["__probe"], ct);
var health = driver.GetHealth();
// Derive the headline verdict from BOTH the driver state AND the probe-read
// StatusCode so the operator never sees `Health: Healthy` over a Bad snapshot
// line (Driver.S7.Cli-012; mirrors Modbus.Cli's ComputeVerdict).
var verdict = ComputeVerdict(health.State, snapshot[0].StatusCode);
await console.Output.WriteLineAsync($"Host: {Host}:{Port}");
await console.Output.WriteLineAsync($"CPU: {CpuType} rack={Rack} slot={Slot}");
await console.Output.WriteLineAsync($"Verdict: {verdict}");
await console.Output.WriteLineAsync($"Health: {health.State}");
if (health.LastError is { } err)
await console.Output.WriteLineAsync($"Last error: {err}");
@@ -77,4 +84,28 @@ public sealed class ProbeCommand : S7CommandBase
await console.Output.WriteLineAsync($"Last error: {err}");
}
}
/// <summary>
/// Combine the driver-side <see cref="DriverState"/> with the probe snapshot's OPC UA
/// <c>StatusCode</c> into a single headline verdict: <c>FAIL</c> when the driver is not
/// Healthy or the snapshot is Bad, <c>DEGRADED</c> when Healthy but Uncertain, else <c>OK</c>.
/// </summary>
/// <param name="state">The driver's health state.</param>
/// <param name="statusCode">The OPC UA status code from the probe read.</param>
/// <returns>A string describing the verdict.</returns>
public static string ComputeVerdict(DriverState state, uint statusCode)
{
// Top 2 bits encode the quality class: 00 Good, 01 Uncertain, 10/11 Bad.
var qualityClass = statusCode & 0xC0000000u;
var snapshotGood = qualityClass == 0x00000000u;
var snapshotUncertain = qualityClass == 0x40000000u;
if (state != DriverState.Healthy || !snapshotGood && !snapshotUncertain)
return $"FAIL (driver={state}, probe={SnapshotFormatter.FormatStatus(statusCode)})";
if (snapshotUncertain)
return $"DEGRADED (driver={state}, probe={SnapshotFormatter.FormatStatus(statusCode)})";
return $"OK (driver={state}, probe={SnapshotFormatter.FormatStatus(statusCode)})";
}
}
@@ -88,7 +88,7 @@ public sealed class WriteCommand : S7CommandBase
internal static object ParseValue(string raw, S7DataType type)
{
if (type == S7DataType.Bool) return ParseBool(raw);
if (type == S7DataType.String) return raw;
if (type is S7DataType.String or S7DataType.WString or S7DataType.Char or S7DataType.WChar) return raw;
try
{
return type switch
@@ -102,7 +102,11 @@ public sealed class WriteCommand : S7CommandBase
S7DataType.UInt64 => (object)ulong.Parse(raw, CultureInfo.InvariantCulture),
S7DataType.Float32 => (object)float.Parse(raw, CultureInfo.InvariantCulture),
S7DataType.Float64 => (object)double.Parse(raw, CultureInfo.InvariantCulture),
S7DataType.DateTime => (object)DateTime.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
S7DataType.DateTime or S7DataType.Dtl or S7DataType.Date
=> (object)DateTime.Parse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
// TOD / TIME / S5TIME are Int32 milliseconds on the OPC UA side.
S7DataType.Tod or S7DataType.Time or S7DataType.S5Time
=> (object)long.Parse(raw, CultureInfo.InvariantCulture),
_ => throw new CliFx.Exceptions.CommandException($"Unsupported DataType '{type}' for write."),
};
}
@@ -3,7 +3,7 @@ using System.Reflection;
using libplctag;
using libplctag.NativeImport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
/// <summary>
/// Releases the native tag a FAILED libplctag initialisation leaves behind (T21 press pass 1 crash).
@@ -24,7 +24,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// 1.5.2 wrapper (<c>Tag._tag</c>, <c>NativeTagWrapper.nativeTagHandle</c>). If a future wrapper renames them the
/// release cannot run; the tag is then rooted for the life of the process instead, which keeps its delegate
/// alive. That is no crash but an UNBOUNDED leak — one native tag (holding its libplctag session open) per failed
/// create, and the data path re-creates a NotFound tag on every read — so <c>LibplctagOrphanReleaseTests</c> fails, so the bump is noticed.</para>
/// create, and the data path re-creates a NotFound tag on every read — so <c>LibplctagOrphanReleaseTests</c> / <c>LibplctagLegacyOrphanReleaseTests</c> fail, so the bump is noticed.</para>
/// <para>SHARED SOURCE (#580): this directory has no project. <c>Driver.AbCip</c> and <c>Driver.AbLegacy</c> each
/// compile this file in (<c>&lt;Compile Include&gt;</c>), so each assembly has its own internal copy and its own
/// counters. It must keep a <c>Libplctag*.cs</c> name — <c>LibplctagConfinementGuardTests</c> allows libplctag
/// references only in such files.</para>
/// </remarks>
internal static class LibplctagOrphanRelease
{
@@ -43,10 +47,7 @@ internal static class LibplctagOrphanRelease
private static int _released;
/// <summary>True when the pinned wrapper's private fields were found, so a failed tag can be destroyed.</summary>
internal static bool CanReadHandles => HandleField is not null && !ForceRootingForTest;
/// <summary>Test seam (review r1 M1): take the rooting fallback as if the wrapper's fields had been renamed.</summary>
internal static bool ForceRootingForTest { get; set; }
internal static bool CanReadHandles => HandleField is not null;
/// <summary>Orphaned native tags destroyed so far (process-wide; diagnostics and tests).</summary>
internal static int Released => Volatile.Read(ref _released);
@@ -113,12 +114,19 @@ internal static class LibplctagOrphanRelease
/// </summary>
/// <returns>The status the native tag held just before it was destroyed (what <c>GetStatus()</c> would have
/// reported), or null when nothing was destroyed.</returns>
internal static Status? ReleaseIfOrphaned(Tag tag)
internal static Status? ReleaseIfOrphaned(Tag tag) => ReleaseIfOrphaned(tag, CanReadHandles);
/// <summary>
/// <see cref="ReleaseIfOrphaned(Tag)"/> with the handle-readability decision passed in, so a test can take the
/// rooting fallback (review r1 M1) without a process-wide switch (Gitea #581). Production always passes
/// <see cref="CanReadHandles"/>.
/// </summary>
internal static Status? ReleaseIfOrphaned(Tag tag, bool canReadHandles)
{
try
{
if (tag.IsInitialized) return null;
if (!CanReadHandles)
if (!canReadHandles)
{
Rooted.Add(tag);
return null;
@@ -0,0 +1,55 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
/// <summary>
/// The single <see cref="AbStatus"/> to OPC UA StatusCode table shared by <c>AbCipStatusMapper</c> and
/// <c>AbLegacyStatusMapper</c> (#580) — the two drivers used to carry identical copies.
/// </summary>
public static class AbOpcUaStatusMap
{
/// <summary>OPC UA <c>Good</c>.</summary>
public const uint Good = 0u;
/// <summary>OPC UA <c>GoodMoreData</c>.</summary>
public const uint GoodMoreData = 0x00A60000u;
/// <summary>OPC UA <c>BadNodeIdUnknown</c>.</summary>
public const uint BadNodeIdUnknown = 0x80340000u;
/// <summary>OPC UA <c>BadNotWritable</c>.</summary>
public const uint BadNotWritable = 0x803B0000u;
/// <summary>OPC UA <c>BadOutOfRange</c>.</summary>
public const uint BadOutOfRange = 0x803C0000u;
/// <summary>OPC UA <c>BadNotSupported</c>.</summary>
public const uint BadNotSupported = 0x803D0000u;
/// <summary>OPC UA <c>BadDeviceFailure</c>.</summary>
public const uint BadDeviceFailure = 0x808B0000u;
/// <summary>OPC UA <c>BadCommunicationError</c>.</summary>
public const uint BadCommunicationError = 0x80050000u;
/// <summary>OPC UA <c>BadTimeout</c>.</summary>
public const uint BadTimeout = 0x800A0000u;
/// <summary>OPC UA <c>BadTypeMismatch</c>.</summary>
public const uint BadTypeMismatch = 0x80740000u;
/// <summary>Maps a transport-neutral <see cref="AbStatus"/> to an OPC UA StatusCode.</summary>
/// <param name="status">The transport status.</param>
/// <returns>The corresponding OPC UA status code.</returns>
public static uint MapStatus(AbStatus status) => status switch
{
AbStatus.Ok => Good,
AbStatus.Pending => GoodMoreData,
AbStatus.Timeout => BadTimeout,
AbStatus.NotFound => BadNodeIdUnknown,
AbStatus.NotAllowed => BadNotWritable,
AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall => BadOutOfRange,
AbStatus.Unsupported => BadNotSupported,
AbStatus.TypeMismatch => BadTypeMismatch,
AbStatus.DeviceStateConflict => BadDeviceFailure,
_ => BadCommunicationError, // BadConnection, BadReply, Partial, Abort
};
/// <summary>
/// Status for an exception thrown while creating / reading a tag: a transport exception maps through its
/// <see cref="AbStatus"/>; anything else is <see cref="BadCommunicationError"/>.
/// </summary>
/// <param name="ex">The exception.</param>
/// <returns>The OPC UA status code.</returns>
public static uint MapException(Exception ex) =>
ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError;
}

Some files were not shown because too many files have changed in this diff Show More