Compare commits

...

55 Commits

Author SHA1 Message Date
Joseph Doherty 8801631739 Merge b640-601: honest deploy acks — Applied only after the address-space outcome, degraded flag + reason (#601)
v2-ci / build (push) Successful in 4m42s
v2-ci / unit-tests (push) Failing after 20m19s
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe

# Conflicts:
#	src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
2026-10-08 18:00:23 -04:00
Joseph Doherty 0a821417ea fix(deploy): derive the apply-outcome timeout from central's apply deadline (105 s) and name a timeout reason distinctly (#601 review)
60 s labelled a slow-but-clean large rebuild degraded. The deadline moves to a
shared Commons DeployTimings constant that ConfigPublishCoordinator reads, and
the node's timeout is deadline - 15 s margin, so the held ack still lands in
time. A timed-out ack's reason now says the outcome was not reported in time,
distinct from a real sink failure.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:54:09 -04:00
Joseph Doherty af02fce3c4 docs(deploy): degraded-rebuild repair + node-side reason write (#601 review)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:50:27 -04:00
Joseph Doherty 345f40550b fix(deploy): a node with a ConfigDb records its degraded apply on its own NodeDeploymentState row (#601 review)
The apply writes the node's row Applied/null before the address-space outcome
is known; central adds the reason only when it ACCEPTS the ack, so a discarded
(stale/timed-out) ack left a fused node's row reading clean. The prefix moves
to ApplyAck.DegradedReasonPrefix so both writers spell the row identically.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:46:18 -04:00
Joseph Doherty fe7603ec6e fix(opcua): an identical re-deploy after a degraded REBUILD repairs instead of reporting clean (#601 review)
A degraded rebuild advances _lastApplied (deliberately), so re-deploying the
same revision diffed to an empty plan and reported a CLEAN outcome - the
#601 're-deploy re-applies' repair path sealed green over an address space
still known partial. The publish actor now remembers that the last advance
was degraded and, on an empty plan, re-ensures the whole composition
(idempotent, no rebuild), reporting clean only when that pass is clean.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:44:12 -04:00
Joseph Doherty fdee129fd0 fix(docker-dev): opc-plc seed driver needs TargetNamespaceKind=SystemPlatform to initialize (#619)
Live gate: the seeded MAIN-opcua driver failed Initialize ('Equipment but no
UnsMappingTable'), so the opc-plc alarm leg could never connect.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:41:11 -04:00
Joseph Doherty 82ef89416f docs(deploy): degraded-apply ack amendment (#601)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:35:55 -04:00
Joseph Doherty f066fb9c80 feat(adminui): /fleet shows 'Applied (degraded)', /deployments 'Sealed (degraded)' (#601)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:34:58 -04:00
Joseph Doherty 49dfabc141 feat(deploy): coordinator records degraded acks; deployment seals but names degraded nodes (#601)
An Applied+Degraded ack keeps NodeDeploymentState.Status = Applied with
FailureReason 'Applied degraded: <reason>' (no enum/migration change). The
deployment still seals (the revision is running; latest-sealed drives drift)
but Deployment.FailureReason names the degraded nodes and the seal logs at
Warning. Recovered from the DB on singleton failover. Old-node acks (no field,
DPS JSON or gRPC) behave exactly as before.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:32:09 -04:00
Joseph Doherty 90d7f6dfca fix(deploy): hold the Applied ack until the address-space apply outcome is known (#601)
DriverHostActor no longer acks Applied the moment the revision advances. It
asks OpcUaPublishActor for the outcome (RebuildAddressSpace.ReportOutcome) and
acks Applied+Degraded(reason) when the apply was degraded; a missing outcome
times out (60 s, inside the coordinator's 2 min deadline) to a degraded ack,
never a clean one. Re-dispatching a revision whose apply was degraded or is
still unconfirmed re-applies instead of taking the clean idempotent
short-circuit - that re-apply is also what repairs the degraded nodes.
Tests with a bare publish probe use a clean-outcome auto-pilot probe.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:29:45 -04:00
Joseph Doherty 3bf874ae44 Merge b640-619alm: OpcUaClient native alarms route to RawPath conditions + ConditionRefresh; docker-dev opc-plc seed (#619)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:26:47 -04:00
Joseph Doherty 5af9aae2d4 Merge b640-641: native alarm state survives rebuild; projector pruned; AbCip ConditionId=RawPath (#641)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:26:47 -04:00
Joseph Doherty fcae87a5bc docs(alarms): #641 review follow-ups (comment carry, AbCip record, projector ack, residuals)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:20:38 -04:00
Joseph Doherty 17dc171940 fix(opcua): carry the condition Comment across a rebuild; the restore report never rolls Time back (#641 review)
Native conditions support AddComment / commented Acknowledge, so the comment (and its
ClientUserId) is condition state the rebuild dropped. And the deferred restore report
re-stamped Time with the carried instant even when a client method call had reported a
newer event in the restore->complete window; it now reports at the node's current Time.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:20:13 -04:00
Joseph Doherty 521e1eb89b feat(deploy): additive degraded flag on ApplyAck + publish-actor apply outcome reply (#601)
ApplyAck gains trailing Degraded/DegradedReason (defaulted, so an old node's
DPS JSON or gRPC ack decodes exactly as before). mesh_transport.proto adds
ApplyAckMessage.degraded = 6 / optional degraded_reason = 7. OpcUaPublishActor
replies RebuildCompleted(Degraded, Reason) on every handler path when the
RebuildAddressSpace carries ReportOutcome.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:19:57 -04:00
Joseph Doherty 25f02e4102 fix(runtime): the native-alarm projector learns a routed client acknowledgement (#641 review)
#641 keeps the projector's prior state across a deploy instead of resetting it to the
acked default. The projector never saw a client's ack (the SDK applies it to the node and
the host only routes it), so a Clear after ack+deploy now projected Acked=false and
un-acknowledged the operator's ack - previously masked by the per-deploy reset.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:19:24 -04:00
Joseph Doherty 5c88de078c fix(abcip): keep the ALMD last-told state across a re-subscribe so a still-faulted alarm is not re-raised (#641 review)
Now that ALMD transitions route, a re-subscribe (every reconnect, every alarm-set change)
started from an empty per-subscription record and re-RAISED every still-faulted alarm,
which the host projects as Active+unacked - un-acknowledging a condition the server had
just carried across the rebuild with its client ack. The record is now driver-owned and
pruned to the new set, mirroring MTConnect's _deliveredConditions.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:17:55 -04:00
Joseph Doherty 8f5b265db8 fix(opcuaclient): native-alarm ConditionRefresh + transition table, branch, dedup and thread-safety fixes (#619 re-review)
Re-review of 3de2cc3b/454775c6:
- AlarmTransitions could not reach (inactive, unacked): (active,acked)->(inactive,unacked) emitted Clear
  (host stayed acked) and a first-seen cleared-unacked condition emitted nothing. The table is now total;
  an exhaustive test replays every (prior, target) pair through the projector's semantics.
- The per-RawPath read-compute-emit was a lock-free TryGetValue/set across SDK publish threads; it now runs
  under one lock with the projection and emission.
- Condition BRANCH events (non-null BranchId) were projected (flipping the trunk) and remembered as the ack
  target (acking the branch); they are dropped. BranchId is select clause 9.
- EventId is copied, not aliased from the decoded notification.
- An unchanged state re-emitted Unspecified; it now emits nothing unless message/severity changed (an
  Unspecified projects from the host projector's default after a redeploy reset it).
- Exact upstream severity rides OpcUaSeverity (#626) instead of being re-bucketed.
- ShutdownAsync no longer forgets the last-told state: a reinit re-subscribes the same set and the refresh
  would otherwise re-raise every acked alarm. A CHANGED set (host rebuilt) forgets it.

ConditionRefresh: called after the alarm event item is created, after an in-place reconnect (subscriptions
found on the new session by display name, since a recreate clones them) and on RefreshRequired. Refresh
markers are never projected. BadNotSupported/BadMethodInvalid/... logs once. Wire test: a condition already
active before subscribe is reported Raise; a release+resubscribe refresh re-raises nothing (falsified by
disabling the refresh call).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:13:54 -04:00
Joseph Doherty 9b74a65b25 Merge b640-640: driver diagnostics surface on /hosts (#640)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:13:34 -04:00
Joseph Doherty 4128d9c313 test(runtime): de-flake Rediscovery_flag_persists (vacuous AwaitAssert, no publish trigger)
The AwaitAssert lambdas returned bool into an Action, so they never waited;
the test raced the rediscovery publish (5-22% failure on base too). Make the
asserts real and drive the republish with a short health poll interval.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:10:55 -04:00
Joseph Doherty bd23f557f3 docs(alarms): native condition state across a rebuild, projector prune, exact ConditionId routing (#641)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:09:24 -04:00
Joseph Doherty 64d2514e2a fix(abcip): report ALMD transitions with ConditionId = RawPath and a Kind (#641)
Found while verifying item 4 of #641. The host routes a native alarm transition
on an exact ConditionId == RawPath match (DriverHostActor.ForwardNativeAlarm),
but AbCipAlarmProjection emitted "{ref}#active" and no Kind, so every ALMD
raise/clear was dropped as "no alarm condition" — and even if routed, an
Unspecified kind projects no state change. It now reports the subscribed
reference (the RawPath) and Raise/Clear. A host test pins that a decorated id
is dropped.

FOCAS has the same id shape plus a device filter keyed by host address (a
RawPath subscription polls nothing); it needs a mapping decision and is
documented in docs/drivers/FOCAS.md rather than patched here.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:08:47 -04:00
Joseph Doherty 3fe6cbb905 fix(mtconnect): on a changed alarm set forget only the conditions that left (#641)
The driver cleared its whole "last told" record whenever the subscribed alarm
set changed, assuming the rebuild that accompanies an alarm-bearing delta had
reset every condition node. The server now carries each surviving native
condition's state (acknowledgement included) across the rebuild, so re-raising a
survivor would un-acknowledge it. Only entries that left the set are dropped; a
condition that comes back later is told again onto its fresh node.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:06:57 -04:00
Joseph Doherty 11f13f6326 fix(runtime): prune the native-alarm projector on deploy instead of clearing it (#641)
DriverHostActor cleared NativeAlarmProjector's per-condition prior state on
EVERY apply, so after any deploy a driver Acknowledge of a still-active alarm
projected Active=false (writing it inactive) and a Clear of an unacknowledged
alarm projected Acked=true (silently acknowledging it). The projector is now
pruned to the conditions the new composition still carries (RetainOnly).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:05:42 -04:00
Joseph Doherty 454775c67c fix(opcuaclient): derive AlarmTransitionKind from upstream ActiveState/AckedState (#619 review)
NativeAlarmProjector derives Active/Acked ONLY from AlarmEventArgs.Kind, and this driver
never set it - every routed event projected Unspecified, leaving the condition inactive+acked
(only Message/Severity moved). The live-gate expectation 'toggling Active as opc-plc cycles'
could not have passed. Select ActiveState/Id + AckedState/Id (two-element browse paths) and
emit the Kind sequence that moves the projector from the state last told to the host to the
event's (Raise+Acknowledge when a notification carries both).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:04:48 -04:00
Joseph Doherty baf8cc48c3 Merge b640-small: CI readme drift check, SlowUnit tags, LDAP bind pw var, historian/MTConnect docs (#606 #619 #600 #613)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:03:59 -04:00
Joseph Doherty 216dc63a28 Merge b640-focas: FOCAS Status/ subtree, PMC range coalescing, parts-count docs (#257 #266 #258, tracker #633)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:03:53 -04:00
Joseph Doherty 9244ae2c31 docs(mtconnect): feature status table; EVENT enums + DATA_SET/TABLE listed as unsupported (#613)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:03:26 -04:00
Joseph Doherty c86204b7d6 fix(focas): PMC coalescing treats any rc refusal as per-tag fallback, skips transient busy memo, one connect per batch (#266)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:03:25 -04:00
Joseph Doherty 598cb133a8 docs(historian): document deferred non-numeric types and UInt16->UInt4 fallback (#600)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:03:14 -04:00
Joseph Doherty bdf13d29ba chore(docker-dev): LDAP bind password via OTOPCUA_LDAP_BIND_PASSWORD with dev default (#619)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:02:51 -04:00
Joseph Doherty ad108f6a02 test(mqtt): tag 16 wall-clock-bound tests Category=SlowUnit; document local opt-out (#606)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:02:44 -04:00
Joseph Doherty 3de2cc3ba8 fix(opcuaclient): alarm index survives namespace reorder; match condition node first; ack with real EventId (#619 review)
The alarm index was keyed on ns=N strings resolved once at subscribe time, while an
in-place SDK reconnect transfers the subscription without re-running it - a remote
namespace reorder silently orphaned every alarm tag. Keys are now nsu= on both sides.
Events match the authored nodeId against the event's ConditionId first, then its
SourceNode, so several conditions on one source can be authored 1:1. Acknowledge
passes the EventId of the event that named the remembered condition (paired, so node
and EventId can never come from different conditions) instead of an empty one.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:02:21 -04:00
Joseph Doherty 555e8d8047 Merge b640-622: OpcUaClient history + reconnect wire tests (#622)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:02:21 -04:00
Joseph Doherty ddd7678269 fix(opcua): carry native alarm condition state across a full rebuild (#641)
A full RebuildAddressSpace recreated every native Part 9 condition inactive +
acknowledged, and when the alarm-tag set was unchanged (e.g. one
UnsTagReference added to an alarm-bearing raw tag) nothing re-told the new node
its state: the host does not re-subscribe for an unchanged set, and Galaxy /
OpcUaClient have no snapshot to replay anyway. Active alarms read normal until
their next device transition.

OtOpcUaNodeManager now captures each NATIVE condition's live state (from the
node, so a client-applied ack is kept) before the rebuild clears, restores it
when the same id is re-materialised native in the same apply, and reports ONE
condition event per restored off-normal condition at the new
IOpcUaAddressSpaceSink.CompleteAddressSpaceApply (after notifier wiring).
Unclaimed captures are discarded there, so a later re-add is fresh. Quality is
carried silently for normal conditions too (it is only re-annotated on a
connectivity edge). Scripted conditions are untouched (#487 re-asserts them).
OpcUaPublishActor calls CompleteAddressSpaceApply at the end of every apply.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 17:02:12 -04:00
Joseph Doherty 4a93d4cead test(opcuaclient): reconnect + subscription-resume wire test via restartable rig (#622)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:59:58 -04:00
Joseph Doherty 487d394cde docs(focas): Status/ flags, PMC coalescing, parts count via PARAM:6711, read-only policy (#257 #266 #258)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:59:08 -04:00
Joseph Doherty 8d916f2af7 feat(focas): coalesce adjacent PMC reads into one pmc_rdpmcrng (#266)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:59:07 -04:00
Joseph Doherty 73e54656f8 Merge b640-s7 (S7 live codec coverage + STRING/DT BadOutOfRange #632)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:57:59 -04:00
Joseph Doherty 489dc3d50a Merge b640-646 (AbLegacy indirect addressing #646)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:57:59 -04:00
Joseph Doherty c2bdb7d5de feat(hosts): driver diagnostics channel on /hosts, S7 negotiated PDU size first (#640)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:57:38 -04:00
Joseph Doherty dac0747bb4 docs(s7): fixture DB1 size 2048 references
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:57:24 -04:00
Joseph Doherty 73ec6cec0f Merge b640-642: typed native-alarm editor in RawTagModal (#642)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:57:05 -04:00
Joseph Doherty 73fdb80157 fix(ablegacy): indirect element index up to 999 on PLC-5; clear IndirectLastName on dispose
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:56:58 -04:00
Joseph Doherty 62f19dd013 test(adminui): guard NativeAlarmModel driver set against IAlarmSource implementers (#642)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:56:04 -04:00
Joseph Doherty d93491499d chore(docker-dev): seed OpcUaClient driver for opc-plc + doc native-alarm source semantics (#619)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:55:13 -04:00
Joseph Doherty bbea38483f test(s7): codec live tests use the documented byte-anchored DBB form (#632)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:55:10 -04:00
Joseph Doherty e56926dc47 fix(opcuaclient): native alarms translate RawPath <-> upstream source/condition ids (#619)
The host subscribes alarms with RawPaths and routes transitions on
(driver, RawPath), but the driver filtered upstream SourceNode ids against
the RawPaths and reported the upstream condition NodeId as ConditionId, so
no opc-plc (or any upstream) alarm could ever reach a condition node.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:54:44 -04:00
Joseph Doherty b0b0ce9c77 feat(ablegacy): resolve indirect addresses at read/write time (#646)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:53:56 -04:00
Joseph Doherty 5b36c717de feat(focas): cnc_statinfo status flags as Status/ fixed-tree nodes (#257)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:53:44 -04:00
Joseph Doherty 2aae3c094f test(s7): live python-snap7 coverage for the #632/#638 codecs, TSAP override and PDU size (#632)
Extends the s7_1500 profile (DB1 2048 bytes, 'hex' seed type) with distinct
non-zero seeds for STRING/WSTRING/CHAR/WCHAR/DTL/DATE/TOD/TIME/S5TIME plus
undecodable buffers; S7_1500CodecTests covers reads, BadOutOfRange paths,
write round-trips (S7_SIM_ALLOW_WRITES=1), TSAP keys and negotiated PDU size.
Skips when S7_SIM_ENDPOINT is unreachable.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:53:02 -04:00
Joseph Doherty dcc8adcf48 test(opcuaclient): HistoryRead Raw/AtTime/Processed wire tests against in-process server (#622)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:52:59 -04:00
Joseph Doherty 6951faa08f feat(adminui): typed native-alarm section in the raw tag modal (#642)
NativeAlarmModel: IsAlarm=false now removes the alarm key; advisory Warnings, driver gating, order-safe Edit.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:52:55 -04:00
Joseph Doherty 41e600e069 fix(s7): classic STRING/DT unrepresentable writes map to BadOutOfRange, not BadCommunicationError (#632)
S7.Net's own STRING/DT encoders throw ArgumentException; that fell into the
generic write catch (BadCommunicationError + Degraded health). Now surfaced as
S7ValueRangeException like the WSTRING/DATE codecs.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:51:20 -04:00
Joseph Doherty 839f32910d ci: gate code-reviews README drift via regen-readme.py --check (#606)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:49:40 -04:00
120 changed files with 6488 additions and 264 deletions
+6 -1
View File
@@ -33,6 +33,10 @@ jobs:
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
# #606: code-reviews/README.md must be regenerated from the per-module findings.md
# files; --check exits non-zero on drift without writing (see REVIEW-PROCESS.md section 5).
- name: code-reviews README drift check
run: python3 code-reviews/regen-readme.py --check
- name: dotnet restore
run: dotnet restore ZB.MOM.WW.OtOpcUa.slnx
- name: dotnet build
@@ -53,7 +57,8 @@ jobs:
# Self-maintaining — a new *.Tests project is covered automatically, no matrix to widen —
# and matches CLAUDE.md's own `dotnet test ZB.MOM.WW.OtOpcUa.slnx` guidance. Excludes only
# the env-gated tiers: E2E (nightly, needs the docker-dev fleet) and LiveIntegration (needs
# a real gateway/PLC/VPN). The skip-gated *.IntegrationTests Assert.Skip when their fixtures
# a real gateway/PLC/VPN). Category=SlowUnit (Mqtt socket-timeout tests, #606) is deliberately NOT
# excluded here — it is a local-dev-loop opt-out only. The skip-gated *.IntegrationTests Assert.Skip when their fixtures
# are unreachable, so they run harmlessly here (and are counted by the skip-gate below).
- name: dotnet test (whole solution, minus E2E + LiveIntegration)
run: >
+25 -6
View File
@@ -184,6 +184,11 @@ is still default-closed; what changed is which deltas it whitelists.
re-diffs the same composition and re-ensures what failed, at the cost of re-emitting that
apply's removes (a re-remove returns false and ratchets to a rebuild, which is the correct,
bounded resync). A **rebuild** still advances even with failures.
- **…and the deploy ack now SAYS so (#601).** The Applied ack waits for `OpcUaPublishActor`'s
`RebuildCompleted` (timeout = shared `DeployTimings.ApplyDeadline` − 15 s = 105 s ⇒ degraded "outcome not reported in time", never clean) and carries an additive `Degraded`/`DegradedReason`
on both ack transports; central seals but records `NodeDeploymentState.FailureReason = "Applied degraded: …"`
(`/fleet` "Applied (degraded)"), and a re-dispatch of a degraded revision re-applies instead of short-circuiting.
See `docs/Redundancy.md` §"What an `Applied` ack means".
- **Any surgical `false`/throw still ratchets to `SafeRebuild()`** — that one-way fallback is
what makes every whitelist above safe.
- **Announcements:** `AnnounceAddedNodes` announces surgically-added **Raw** containers/tags
@@ -359,6 +364,10 @@ dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests # a single tes
dotnet test --filter "FullyQualifiedName~MyTestClass.MyMethod" # a single test
```
`Category=SlowUnit` (#606) tags the 16 wall-clock-bound Driver.Mqtt unit tests (blackhole connect-timeout /
reconnect-backoff windows, ~1 min of the project's 73 s). CI runs them (its filter excludes only E2E +
LiveIntegration); for a fast local loop add `--filter "Category!=SlowUnit"`.
CVE-2025-6965 / GHSA-2m69-gcr7-jv3q (transitive native `SQLitePCLRaw.lib.e_sqlite3`)
is **resolved** — the temporary `NuGetAuditSuppress` was removed once the patched bundle
shipped. `Core.AlarmHistorian` now carries a surgical direct pin of
@@ -1105,6 +1114,9 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
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.
- **FOCAS (#257/#266):** `cnc_statinfo` flags are a `Status/` fixed-tree subtree (`FIXED:Status/Run`…, capability-probed);
adjacent PMC tags in one poll batch coalesce into one `pmc_rdpmcrng` (`PmcCoalesce` config, default on) — a refused range
falls back to per-tag reads, a transport failure fails all members without retry. Parts count stays `PARAM:6711` (docs only).
- **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
@@ -1113,13 +1125,15 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
- **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`.
Connect reads 2 bytes of `MB0` so a PUT/GET-disabled CPU fails the probe. The negotiated PDU size is logged, on
`S7Driver.GetDiagnostics()`, and (#640) on `/hosts` via the optional `IDriverDiagnostics` bag (string map on
`DriverHealthChanged.Diagnostics` / proto `has_diagnostics`+`diagnostics`; its digest is in the publish fingerprint).
- **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
and are emitted to libplctag as decimal (libplctag's `pccc.c` parses decimal only). MicroLogix function files parse
but read `BadNotSupported`. Indirect `N7:[N7:0]` (#646) is resolved by the driver at read/write time (index re-read
every call, never cached; bad index = `BadOutOfRange`; indirect writes are not atomic). 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
@@ -1136,10 +1150,15 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
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).
only while a handler is attached, and drops the record of only the conditions that LEFT the set when the subscribed
alarm SET changes (#641) — never 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.
- **Native condition state survives a full rebuild (#641):** `RebuildAddressSpace` captures each NATIVE condition's
live node state (client acks included), the same-apply re-materialise restores it, and `CompleteAddressSpaceApply`
(called by `OpcUaPublishActor` at the end of every apply) reports ONE event per restored off-normal condition and
discards unclaimed captures. `NativeAlarmProjector` is pruned per apply, not cleared. Native routing is an exact
`ConditionId == RawPath` match — AbCip's `#active` suffix was fixed; FOCAS is still unroutable (`docs/drivers/FOCAS.md`).
- **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
+2
View File
@@ -102,6 +102,8 @@ The first build takes a few minutes (.NET SDK image + restore + publish). **No m
Central nodes authenticate against the shared GLAuth at `10.100.0.35:3893` (baseDN `dc=zb,dc=local`). `DevStubMode` is **not** active. Sign in with any test user (password `password`); `multi-role` / `password` returns all three roles (Administrator, Designer, Viewer). Group→role mappings are seeded by `seed/seed-clusters.sql`. The GLAuth source of truth + deploy runbook is in `scadaproj/infra/glauth/`. **Do not** enable `DevStubMode` outside local debugging — production must always bind a real LDAP backend.
The LDAP service-account bind password is read from the host env var `OTOPCUA_LDAP_BIND_PASSWORD` (compose default `serviceaccount123`, the committed dev password of the shared GLAuth, so the rig works with nothing set). Export it before `docker compose up` to bind with a different password (#619).
## Headless deploy
```bash
+3 -3
View File
@@ -194,7 +194,7 @@ x-ldap-env: &ldap-env
Security__Ldap__AllowInsecure: "true"
Security__Ldap__SearchBase: "dc=zb,dc=local"
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
Security__Ldap__ServiceAccountPassword: "${OTOPCUA_LDAP_BIND_PASSWORD:-serviceaccount123}"
Security__Ldap__GroupToRole__ReadOnly: "ReadOnly"
Security__Ldap__GroupToRole__WriteOperate: "WriteOperate"
Security__Ldap__GroupToRole__WriteTune: "WriteTune"
@@ -402,7 +402,7 @@ services:
Security__Ldap__AllowInsecure: "true"
Security__Ldap__SearchBase: "dc=zb,dc=local"
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
Security__Ldap__ServiceAccountPassword: "${OTOPCUA_LDAP_BIND_PASSWORD:-serviceaccount123}"
Security__DeployApiKey: "docker-dev-deploy-key"
Security__Auth__DisableLogin: "${OTOPCUA_DISABLE_LOGIN:-true}"
# #520 stage 6 — node ACL enforcement, the dark switch. Default OFF, exactly as in production.
@@ -535,7 +535,7 @@ services:
Security__Ldap__AllowInsecure: "true"
Security__Ldap__SearchBase: "dc=zb,dc=local"
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
Security__Ldap__ServiceAccountPassword: "${OTOPCUA_LDAP_BIND_PASSWORD:-serviceaccount123}"
Security__DeployApiKey: "docker-dev-deploy-key"
Security__Auth__DisableLogin: "${OTOPCUA_DISABLE_LOGIN:-true}"
# Node ACL enforcement, the dark switch — same env override and same default-OFF as central-1.
+30
View File
@@ -201,3 +201,33 @@ IF NOT EXISTS (SELECT 1 FROM dbo.Tag WHERE TagId = 'MAIN-modbus-hr200')
VALUES ('MAIN-modbus-hr200', 'MAIN-modbus-dev', NULL, 'HR200', 'UInt16', 'ReadWrite', 1,
N'{ "region": "HoldingRegisters", "address": 200, "dataType": "UInt16", "writable": true }');
SELECT DriverInstanceId, ClusterId, DriverType, Enabled FROM dbo.DriverInstance WHERE ClusterId='MAIN';
------------------------------------------------------------------------------
-- #619: OpcUaClient driver against the opc-plc fixture (10.100.0.35:50000, started by
-- tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker with --alm).
-- Tag identity under v3 is the RawPath; the upstream node is authored in TagConfig.nodeId.
-- * FastUInt1 (ns=3;s=FastUInt1) is the always-moving VALUE control: proves the driver is connected and
-- the RawPath -> nodeId table resolves before any alarm assertion is trusted.
-- * The ALARM tag's nodeId must be the SourceNode opc-plc stamps on its condition events - the driver
-- matches events on it. It is NOT guessable (and opc-plc was not running when this was authored), so
-- it is supplied at seed time: browse opc-plc (Client.CLI `browse -r -d 4`, or `alarms --refresh`
-- which prints each event's source) and set @OpcPlcAlarmSourceNodeId below. Empty = no alarm tag.
-- Idempotent.
------------------------------------------------------------------------------
DECLARE @OpcPlcAlarmSourceNodeId nvarchar(400) = N''; -- e.g. N'ns=3;s=<source from opc-plc>'
IF NOT EXISTS (SELECT 1 FROM dbo.DriverInstance WHERE DriverInstanceId = 'MAIN-opcua')
INSERT dbo.DriverInstance(DriverInstanceId, ClusterId, RawFolderId, Name, DriverType, DriverConfig, Enabled)
VALUES ('MAIN-opcua', 'MAIN', NULL, 'opcplc', 'OpcUaClient',
N'{ "SecurityPolicy": "None", "SecurityMode": "None", "AuthType": "Anonymous", "AutoAcceptCertificates": true, "TargetNamespaceKind": "SystemPlatform" }', 1);
IF NOT EXISTS (SELECT 1 FROM dbo.Device WHERE DeviceId = 'MAIN-opcua-dev')
INSERT dbo.Device(DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig)
VALUES ('MAIN-opcua-dev', 'MAIN-opcua', 'plc', 1, N'{ "EndpointUrl": "opc.tcp://10.100.0.35:50000" }');
IF NOT EXISTS (SELECT 1 FROM dbo.Tag WHERE TagId = 'MAIN-opcua-fastuint1')
INSERT dbo.Tag(TagId, DeviceId, TagGroupId, Name, DataType, AccessLevel, WriteIdempotent, TagConfig)
VALUES ('MAIN-opcua-fastuint1', 'MAIN-opcua-dev', NULL, 'FastUInt1', 'UInt32', 'Read', 0,
N'{ "nodeId": "ns=3;s=FastUInt1" }');
IF LEN(@OpcPlcAlarmSourceNodeId) > 0 AND NOT EXISTS (SELECT 1 FROM dbo.Tag WHERE TagId = 'MAIN-opcua-alarm1')
INSERT dbo.Tag(TagId, DeviceId, TagGroupId, Name, DataType, AccessLevel, WriteIdempotent, TagConfig)
VALUES ('MAIN-opcua-alarm1', 'MAIN-opcua-dev', NULL, 'Alarm1', 'Boolean', 'Read', 0,
N'{ "nodeId": "' + @OpcPlcAlarmSourceNodeId + N'", "alarm": { "alarmType": "AlarmCondition", "severity": 500 } }');
SELECT DriverInstanceId, ClusterId, DriverType, Enabled FROM dbo.DriverInstance WHERE DriverInstanceId = 'MAIN-opcua';
+52
View File
@@ -54,6 +54,58 @@ same condition is wired as an **event notifier of every referencing equipment fo
- The `alerts`-topic `AlarmTransitionEvent` carries the (possibly empty) **list of referencing
equipment paths**, and the AdminUI `/alerts` page shows **one row per condition** (primary
identity RawPath + condition NodeId) with the equipment list as display metadata.
- **Routing is an exact `ConditionId == RawPath` match** (`DriverHostActor.ForwardNativeAlarm`). A driver that
decorates the id has every transition dropped as "no alarm condition" — AbCip did (`"{ref}#active"`, fixed
under #641); FOCAS still does (see `drivers/FOCAS.md`). A native source must also set `Kind`: an
`Unspecified` kind projects no state change.
### Native condition state survives a full rebuild (Gitea #641)
A full `RebuildAddressSpace` recreates every condition inactive + acknowledged. For **scripted** alarms the
engine re-asserts every non-normal condition after the reload (#487). **Native** conditions had nothing
equivalent: the host re-subscribes a driver's alarms only when its alarm-ref *set* changes, and a
transition-only source (Galaxy, OpcUaClient) has nothing to replay anyway — so a rebuild that left the alarm
set unchanged (adding one `UnsTagReference` to an alarm-bearing raw tag is enough to force one, as are
`ChangedEquipment`, a scripted-alarm edit, a raw container rename, an alarm severity/type edit, or any
surgical fallback to `SafeRebuild()`) left every active native alarm reading **normal** until its next device
transition.
The node manager now carries native state across the rebuild itself:
1. `RebuildAddressSpace` captures each **native** condition's live state off the **node** — Active, Acked,
Confirmed, Severity, Message, Comment + ClientUserId, Quality, Time, `ActiveState.TransitionTime` — before clearing. Reading the
node, not a host-side cache, is what keeps a **client's** acknowledgement: the SDK applies it to the node
and neither the driver nor `NativeAlarmProjector` ever sees it.
2. `MaterialiseAlarmCondition(isNative: true)` for the same id in the same apply restores it. **Quality is
always restored** (it is otherwise re-annotated only on a connectivity edge, so even a normal condition
would sit at `BadWaitingForInitialData`); the rest only when the condition was off-normal (active or
unacknowledged). A normal condition keeps what the materialise built — message and authored severity
included — exactly the #487 rule. Shelving/Enabled are not carried (native conditions refuse them, #587).
3. `IOpcUaAddressSpaceSink.CompleteAddressSpaceApply`, which `OpcUaPublishActor` calls at the end of
**every** apply (in a `finally`), reports **one** condition event per restored condition — after the
notifier wiring, so the raw folder, every referencing equipment folder and `i=2253` items all get it,
stamped with the original `Time`, not the deploy time (or the node's newer `Time`, if a client method call
reported in the restore→complete window — that window can yield a second, state-correct event) — and **discards** every capture no materialise
claimed, so a later deploy re-adding the same RawPath gets a fresh node (no resurrection). A kind swap
(native ↔ scripted on one id) is never restored. Node-only: no `/alerts` row, no historian record.
Two companions keep the host and driver in step with the carried node:
- `DriverHostActor` **prunes** `NativeAlarmProjector` to the still-deployed conditions on each apply
(`RetainOnly`) instead of clearing it. Clearing made the next driver Acknowledge of an active alarm
project `Active=false`, and the next Clear of an unacknowledged one project `Acked=true` — after *any*
deploy, rebuild or not.
- MTConnect's "last told" record is pruned to the conditions that **left** the subscribed set rather than
cleared on any set change; re-raising a surviving (carried) condition would un-acknowledge it. AbCip's ALMD
last-seen `InFaulted` record is likewise driver-owned and pruned to the set, so a re-subscribe (every
reconnect / set change) does not re-raise a still-faulted alarm.
- A routed **client** acknowledgement marks the projector's prior state acknowledged
(`NativeAlarmProjector.MarkAcknowledged`), so a later driver Clear does not project it unacknowledged.
Accepted residual: a condition that clears at the device while the rebuild runs is restored active until
the driver's Clear arrives — which it does, because the driver's own record still holds it active. The capture
is keyed by RawPath only, so a tag deleted and re-created under the same RawPath in one deploy inherits the old
condition's state until its next transition.
## Condition event identity fields (what a client reads on the wire)
+2
View File
@@ -175,6 +175,8 @@ The checked-in `appsettings*.json` files are deliberately thin: they carry only
**`Grpc` mode: central dials each node, a node posts its ack to central.** `CentralCommunicationActor` fans one `NodeCommandService.Execute` call per node — the target set is every enabled, non-maintenance `ClusterNode` row that carries a `GrpcPort` (dialled at `Host`+`GrpcPort`), rebuilt every `ContactRefreshSeconds`. There is no receptionist and no single fleet-wide (or per-cluster) client object: it is a plain per-node dial. A node posts its `ApplyAck` with `GrpcApplyAckSender`, which walks `CentralGrpcEndpoints` in order and stops at the first success — an availability list, not a fan-out list, so central normally sees one ack per node per dispatch (the walk as a whole is at-least-once on a slow-then-retried endpoint; `ConfigPublishCoordinator.HandleAck` absorbs a duplicate idempotently). Both directions are **fire-and-forget, with no retry**: a failed dispatch is logged (naming the node and the gRPC status) and dropped in the dispatch second — the deploy path's 2-minute apply deadline and a node's boot-time replay (`DriverHostActor.Bootstrap()` re-applying an orphan `Applying` row) are the only recovery mechanisms, exactly as they were under `ClusterClient`.
The ack itself is sent only once the node's address-space apply outcome is known, and carries an additive `degraded` flag (`ApplyAckMessage` fields 6/7; trailing `ApplyAck.Degraded`/`DegradedReason` on DPS) — an old node's ack decodes clean, exactly as before. See [Redundancy.md § "What an `Applied` ack means"](Redundancy.md#what-an-applied-ack-means-the-degraded-apply-amendment-gitea-601) (Gitea #601).
| Key | Type | Default | Meaning |
|---|---|---|---|
| `GrpcPsk` | string? | `null` (unset) | Node-side only, required under `Grpc` on a driver-role node: this node's pre-shared key for its own `Cluster`, presented as the bearer on outbound acks and required on inbound `NodeCommandService` calls. Production form is `${secret:OT-MESH-PSK-<clusterId>}`; supply a literal only on a rig, via `MeshTransport__GrpcPsk` — never commit one. |
+16
View File
@@ -273,6 +273,22 @@ for this node), which is the signal to check that `ServerHistorian:Enabled=true`
> so a HistoryRead on it runs to the full `CallTimeout` (~30 s) before erroring. Auto-provisioning on deploy
> keeps freshly-historized tags from hitting that slow path. See the `CallTimeout` row above.
### Deferred types and the UInt16 fallback (#600 item 4)
Historization is **numeric-analog only** in v1. `HistorianTypeMapper` classifies exactly nine types as
historizable (`Boolean`, `Int16/32/64`, `UInt16/32/64`, `Float32`, `Float64`):
- **Deferred: `String`, `DateTime`, `Reference`.** The historian's SQL analog write path cannot carry them.
Provisioning skips them (counted in the `skipped=` tally, via `IsHistorizable`), the mapper throws
`NotSupportedException` for them, and `ContinuousHistorizationRecorder` drops their values and counts them
in `DroppedNonNumeric`. A tag authored `isHistorized` with one of these types therefore silently records
nothing; HistoryRead on it returns whatever the historian holds (typically `GoodNoData`).
- **Fallback: `UInt16 -> Uint4`.** The historian's `UInt2` write path is unproven upstream, so UInt16 tags
are provisioned as the wider `UInt4`. Values are unaffected; only the historian-side storage type differs.
(`Boolean -> Int2` is the other fallback, below.)
Lifting either needs gateway-side support first; neither is scheduled.
### Boolean historization maps to Int2 (issue #441)
`EnsureTags` is contract-documented (HistorianGateway 0.2.0+) as an **upsert** — *"creates or updates
+10
View File
@@ -68,6 +68,16 @@ removed later.
auto-creates nested groups; typed columns win over `TagConfigJson` on conflict (flagged in
the review grid). **Import is all-or-nothing** — any invalid row blocks the whole commit.
Export round-trips the same shape.
- **Native alarm section (Gitea #642)** — the Edit-tag modal carries a shared `NativeAlarmEditor`
below the per-driver editor / JSON textarea: a "Native alarm (Part 9 condition)" switch plus
`alarmType`, `severity` (1..1000) and `historizeToAveva` (default/yes/no), writing the
driver-agnostic `alarm` object of `TagConfig`. Offered only for `IAlarmSource` drivers (MTConnect,
AbCip, FOCAS, OpcUaClient, Galaxy — `NativeAlarmModel.SupportsNativeAlarms`); on any other driver an
existing `alarm` object is shown with a "no effect" note and a Remove button, never silently
dropped. Switching it off REMOVES the `alarm` key. Validation is advisory only (warnings) — the
deploy gate has no `alarm` rule and the server clamps severity, so the editor is never stricter.
Every edit re-parses the latest `ConfigJson` (`NativeAlarmModel.Edit`), so it composes with the
driver editor in either order. Not yet on the manual-entry grid (use JSON/CSV there).
- **Browse device…** — enabled per the two-tier resolution (bespoke `IDriverBrowser` →
universal `DiscoveryDriverBrowser` when the driver's `ITagDiscovery.SupportsOnlineDiscovery`
is true → grayed out otherwise). Multi-selected leaves become raw `Tag` rows under the
+48
View File
@@ -585,6 +585,54 @@ Two properties are load-bearing and easy to break:
2-minute apply deadline and a node's boot-time replay (`DriverHostActor.Bootstrap()` re-applying an
orphan `Applying` row) are the only recovery mechanisms, unchanged from the DPS/`ClusterClient` era.
### What an `Applied` ack means: the degraded-apply amendment (Gitea #601)
Until #601 the node sent `ApplyAck(Applied)` the moment its revision advanced — *before*
`OpcUaPublishActor` had even dequeued the address-space rebuild — so an apply that swallowed sink faults
(the degraded non-rebuild apply that deliberately does not advance `_lastApplied`) still sealed the
deployment green fleet-wide. Now:
- **The Applied ack waits for the address-space outcome.** `DriverHostActor` sends
`RebuildAddressSpace(ReportOutcome: true)`; `OpcUaPublishActor` replies `RebuildCompleted(Degraded, Reason)`
on every path (clean / sink faults / failed rebuild / pipeline throw / no usable artifact — an empty plan
is clean), and only then is the ack sent. It is a plain `Tell` + reply message — no `Ask`, no `await`, so
nothing resumes off the actor thread.
- **A missing outcome is degraded, never clean.** If no reply arrives within
`DriverHostActor.DefaultApplyOutcomeTimeout` the ack goes out `Applied` + degraded, with a reason starting
*"address-space apply outcome not reported in time"* — distinct from a real sink failure (which carries
`failedNodes=` / "rebuild failed" / "threw"), so an operator can tell a slow or lost apply from a broken one.
The timeout is **derived**, not a free constant: `DeployTimings.ApplyDeadline` (Commons, 2 min — the same
value `ConfigPublishCoordinator.DefaultApplyDeadline` reads) minus `DeployTimings.ApplyOutcomeSafetyMargin`
(15 s) = **105 s**, so a slow-but-clean rebuild of a large address space is not labelled degraded and the
ack still reaches central before its deadline. Change the deadline there and both sides move together.
Accepted edge: the timer starts after the artifact read + driver reconcile, so if those alone exceed the
15 s margin a never-answered apply can still time the deployment out. A node with no publish actor wired
acks clean at once, as before.
- **The ack carries an ADDITIVE degraded flag on both transports.** `ApplyAck` gained trailing
`Degraded = false, DegradedReason = null` (an old node's DPS JSON decodes clean); `ApplyAckMessage` gained
`bool degraded = 6; optional string degraded_reason = 7;` (an old node's gRPC ack decodes clean). The
outcome stays `APPLIED` — a degraded node *did* apply the revision.
- **Central records it without a status-model change.** `ConfigPublishCoordinator.PersistNodeAck` keeps the
node's `NodeDeploymentState.Status = Applied` and writes `FailureReason = "Applied degraded: <reason>"`
(`ConfigPublishCoordinator.IsDegraded` = Applied **and** a reason). The deployment still **seals** —
every node runs the revision, and "latest sealed" drives drift detection and the publish actor's fallback,
so leaving it unsealed would show the fleet a revision behind what it runs — but `Deployment.FailureReason`
names the degraded nodes and the seal logs at Warning. Recovered from the rows on singleton failover. A
degraded ack alongside a `Failed` one is still `PartiallyFailed`. `/fleet` renders the node as
**Applied (degraded)** (caution chip); `/deployments` renders **Sealed (degraded)** with the reason as a tooltip.
- **Re-deploying a degraded revision repairs it.** The node's idempotent "already at this revision" short-
circuit is skipped while the current revision's apply is degraded or its outcome is still pending — the
dispatch re-applies instead, the publish actor re-diffs the un-advanced composition and re-ensures the
missing nodes, and the ack reports the new outcome. A degraded **rebuild** is the other shape: it DOES
advance `_lastApplied`, so the identical re-deploy diffs to an empty plan — the publish actor remembers
that the last advance was degraded and, on that empty plan, re-ensures the whole composition (idempotent,
no second rebuild), reporting clean only if that pass is clean.
- **A node with a ConfigDb also writes the reason on its own row** (`ApplyAck.DegradedReasonPrefix`), because
central only records it when it *accepts* the ack — a timed-out deployment's late ack is discarded.
- **Mixed-version fleets.** An old node never sets the flag, so its acks are treated exactly as before
(clean). A new node acking an old central: the old coordinator ignores the unknown field and seals as it
always did — no worse than today.
Phase 2 changes no redundancy semantics: the Primary gate, ServiceLevel, and the singleton layout
are untouched, and `redundancy-state` itself stays on DPS (it is bidirectional and built from
`Cluster.State`, which makes it genuinely mesh-bound — the hardest remaining dependency before the
+17
View File
@@ -112,6 +112,23 @@ it — but it does mean the panel cannot answer "which node saw this?". Adding t
widening the wire record, not changing the page. The one sub-instance breakdown the channel *does*
carry is per-host connectivity, below.
### Driver diagnostics ride `driver-health` (Gitea #640)
A driver may implement the optional `IDriverDiagnostics` (Core.Abstractions): a pure in-memory
`IReadOnlyDictionary<string,string> GetDiagnostics()` of operator-facing facts that are not driver
*state* (S7's negotiated PDU size is the first; others opt in later). `DriverInstanceActor` pulls it on
every health publish (try/caught, unguarded by the invoker like `GetHostStatuses`), bounds it
(`DriverDiagnostics.Bound`: 16 entries, text truncated to 200 chars, ordinal key order), and carries it as
`DriverHealthChanged.Diagnostics` -> proto `DriverHealth.has_diagnostics = 13` / `map<string,string>
diagnostics = 14` (additive; old nodes never set them, old central ignores them) -> `HostsDriverRow.DiagnosticItems`,
rendered as a small key/value list under the driver name on `/hosts` (nothing when empty).
- **null ≠ empty**: null = driver lacks the capability; the presence flag preserves an empty bag.
- **The diagnostics digest is part of the publish fingerprint, and must stay there** - a diagnostic fact
(e.g. PDU size changing on a reconnect) moves no other component, so without it the dedup swallows the
publish. Pinned by `DriverInstanceActorDiagnosticsTests.A_diagnostics_change_republishes_despite_otherwise_unchanged_health`
(stable-health stub - the shared `StubDriver` returns `UtcNow` and would pass vacuously).
### Per-host connectivity rides `driver-health` (Gitea #521)
`DriverHealthChanged.HostStatuses` carries each driver's `IHostConnectivityProbe.GetHostStatuses()`
+4
View File
@@ -264,6 +264,10 @@ analog alarms are a follow-up) and **disabled by default** — shops running FT
Alarm & Events should keep it off and take alarms through the native route, since
the projection semantics don't exactly mirror Rockwell FT A&E.
Each event carries `ConditionId` = the subscribed reference (the alarm tag's RawPath — the key the host
routes native transitions on) and `Kind` = `Raise` / `Clear`. Before Gitea #641 the projection sent
`"{ref}#active"` with no kind, so the host dropped every ALMD transition as unrouted.
## Testing
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/` cover the driver, host-address parser, UDT planner, and alarm projection via fake tag runtimes.
+15 -7
View File
@@ -69,7 +69,7 @@ pieces for driver-side routing (e.g. deciding a bit needs read-modify-write):
| `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) |
| `N7:[N7:0]` / `N[N7:0]:5` | Indirect (index word read from the PLC) — resolved at read/write time (#646) |
`AbLegacyDataType` covers the corresponding PCCC types: `Bit`, `Int` (N), `Long`
(L), `Float` (F), `AnalogInt` (A), `String` (ST), and the `TimerElement` /
@@ -118,11 +118,18 @@ physical inputs and may reject the write.
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.
- **Indirect addressing (#247 parse, #646 runtime).** `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).
At runtime the driver reads the inner address first (same runtime factory, so libplctag and Native alike), requires
an integer in range (word/element `0..255`, or `0..999` on PLC-5/LogixPccc; file `0..999`), composes the concrete address and runs the normal
read/write path against it. **The index is re-read on every read/write - never cached** - and the previous
resolution's handle is released when the index moves. A bad index (negative, too large, float/string source) is
`BadOutOfRange` for that tag only (no health impact); an inner-read failure propagates its own status (a wire
failure also degrades health like any read). **Writes** resolve then write and are **not atomic**: the index can
change between the two PLC accesses. Bit RMW writes (`B3:[N7:0]/4`) resolve first, then RMW the concrete word.
The Native transport never sees the bracket form (it receives the composed name), so N-file indirect stays inside
the wire-proven envelope; other file types are as (un)verified as their direct forms. Live-gated on `ab_server`
by `AbLegacyIndirectAddressingTests` (`N7:0=5`, `N7:5=42`, `N7:[N7:0]` reads 42).
## Configuration
@@ -211,7 +218,8 @@ 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 |
| MicroLogix function files (RTC/HSC/...), PLC-5 octal I/O | **none** — parsed only; native refuses function files as Unsupported |
| Indirect addressing (`N7:[N7:0]`) | `ab_server` N-file, both transports (#646) - resolved by the driver, the transport sees the composed address |
| T/C/R sub-elements (`.PRE`/`.ACC`/`.LEN`/`.POS`), 6-byte T/C/R element layout | **none** |
### Native known gaps
+81 -3
View File
@@ -146,7 +146,7 @@ reference them will throw at startup with a message pointing here.
| Capability | Wire path | Notes |
|------------|-----------|-------|
| `IReadable` | `ReadAsync` → `cnc_rdpmcrng` / `cnc_rdparam` / `cnc_rdmacro` | One TCP request/response per read; `Focas.Wire` serializes requests on socket 2 internally |
| `IWritable` | returns `BadNotWritable` | OtOpcUa is read-only against FOCAS by design — no `cnc_wrparam` / `pmc_wrpmcrng` / `cnc_wrmacro` path is implemented |
| `IWritable` | returns `BadNotWritable` | OtOpcUa is read-only against FOCAS by design — no `cnc_wrparam` / `pmc_wrpmcrng` / `cnc_wrmacro` path is implemented, and none will be (policy: the stranded write/unlock backlog items #268–#271 are won't-do; a write path on a production CNC is a safety decision, not a feature gap) |
| `ITagDiscovery` | `DiscoverAsync` | Emits `FOCAS/{device}/{tag}` folders per configured device |
| `ISubscribable` | polled via shared `PollGroupEngine` | FOCAS has no push model — subscriptions turn into per-tag polling groups |
| `IHostConnectivityProbe` | periodic `cnc_rdcncstat` | Probe cadence is `Probe.Interval`; transitions fire `OnHostStatusChanged` |
@@ -224,15 +224,36 @@ What gets populated (all under `FOCAS/{deviceHostAddress}/`, addressed as
| `Spindle/{name}/` | `Load` (Float64, % of rated load), `MotorSpeedPct` (Float64, % of maximum speed) — one folder per discovered spindle | `cnc_rdspdlname` once + `cnc_rdspmeter` type 0 + type 1 per tick |
| `Program/` | `Name` (filename), `ONumber`, `Number`, `MainNumber`, `Sequence`, `BlockCount` — a field the CNC did not report (refused sub-read, payload without an O-number) reads `BadNoData`, never `0` | `cnc_exeprgname2` + `cnc_rdblkcount` + cached `cnc_rddynamic2` |
| `OperationMode/` | `Mode` (int), `ModeText` ("AUTO", "MDI", "EDIT", …) | `cnc_rdopmode` |
| `Status/` | `Auto`, `Run`, `Motion`, `Mstb`, `Emergency`, `Alarm`, `Edit`, `TmMode` (raw FANUC codes, Int32) + `RunText` ("RESET", "STOP", "HOLD", "START", "MSTR") — see [CNC status flags](#cnc-status-flags-status) | `cnc_statinfo` (polled on `ProgramPollInterval`) |
| `Timers/` | `PowerOnSeconds`, `OperatingSeconds`, `CuttingSeconds`, `CycleSeconds` | `cnc_rdtimer` × 4 |
### CNC status flags (`Status/`)
`cnc_statinfo` (the `ODBST` block) is exposed as read-only `FIXED:Status/<field>` nodes (#257). Values are the
control's raw codes, published verbatim as Int32 so a client sees exactly what the CNC reported:
| Field | Codes |
|---|---|
| `Run` | 0 RESET, 1 STOP, 2 HOLD, 3 START, 4 MSTR (`RunText` is the label) |
| `Auto` | operation mode (0 MDI, 1 MEM, 3 EDIT, 4 HND, 5 JOG, … — control-dependent) |
| `Motion` | 0 none, 1 axis motion, 2 dwell |
| `Mstb` | 0 idle, 1 executing an M/S/T/B function |
| `Emergency` | 0 not, 1 emergency stop, 2 reset |
| `Alarm` | 0 none, 1 alarm, … (battery-low etc. are control-dependent) |
| `Edit`, `TmMode` | program-edit state; T/M mode |
Author one the same way as any fixed-tree tag — `FIXED:Status/Run` (Int32), `FIXED:Status/RunText` (String).
Like `Program/`, the subtree is gated on a bootstrap probe: a control that refuses `cnc_statinfo` gets no
`Status/` nodes and the rest of the tree is unaffected. Reads are served from the poll snapshot, so they
issue no wire traffic and go `BadCommunicationError` once the device has stopped answering.
### Per-series node suppression
The driver probes each optional call once at bootstrap. If the target CNC
returns `EW_FUNC` / `EW_NOOPT` / `EW_VERSION` on the wire, the
corresponding subtree is **not emitted** — the operator doesn't see nodes
that will only ever return `BadDeviceFailure`. Capability suppression
covers `Spindle/`, `Program/` + `OperationMode/`, `Timers/`, and
covers `Spindle/`, `Program/` + `OperationMode/`, `Status/`, `Timers/`, and
per-axis `ServoLoad` independently. Identity + `Axes/*` position reads
(which every Fanuc CNC supports) are always emitted.
@@ -265,6 +286,54 @@ fallen back to `PositionDecimalPlaces` and published raw counts.
numbers whose meaning is MTB-specific, and which no browse can discover. Those
go under the device folder alongside the fixed subtree.
### Parts count (`PARAM:6711`)
There is deliberately **no** dedicated parts-count node (#258): the count is an ordinary CNC parameter, so it
is already readable as a hand-authored `PARAM:` tag (Int32), and a fixed `Parts/` subtree would hard-code
parameter numbers that are control- and MTB-specific. On the standard FANUC 0i / 16i / 30i parameter map:
| Address | Parameter | Meaning |
|---|---|---|
| `PARAM:6711` | No. 6711 | Number of parts machined (resets with the operator's clear) |
| `PARAM:6712` | No. 6712 | Total number of parts machined (cumulative) |
| `PARAM:6713` | No. 6713 | Number of parts required (the target) |
The count-up trigger (which M code or cycle end increments it) is set by No. 6710 and bit 0 of No. 6700,
so confirm in the machine's parameter manual that the counter is actually enabled and that 6711–6713 mean
what the MTB says they mean before relying on them. Cycle time per part is not provided; the `Timers/`
subtree exposes `CycleSeconds` and a client can difference it across a `PARAM:6711` change.
## PMC range coalescing
A poll batch that reads several nearby PMC tags used to issue one `pmc_rdpmcrng` per tag. The driver now
merges tags of the same PMC letter and width that sit close together into **one** range read and slices each
tag's value out of the result (#266). Single, isolated PMC tags — the common "one tag per area" case — are
read exactly as before.
```jsonc
"PmcCoalesce": {
"Enabled": true, // default
"MaxSpanBytes": 128, // hard cap on one merged range
"MaxGapBytes": 8 // bytes nobody asked for tolerated between two merged tags; 0 = only strictly adjacent
}
```
Rules worth knowing:
- Tags merge only within the same letter and width class (Bit and Byte are one class; Word, Long, Float, Double
are separate). A multi-byte tag joins a range only when its offset from the range start is a multiple of its
width, because the CNC splits a range into width-sized slots from the start.
- **Spare gap bytes must be valid PMC addresses.** A gap that crosses a hole in the CNC's PMC map makes the CNC
refuse the whole range. That is handled, not fatal: a refused range is remembered per device and its tags are
read **individually**, each with its own true status (a refusal is never stamped onto healthy members). If
that happens routinely, lower `MaxGapBytes` (0 disables gap-spanning entirely) or set `Enabled` to `false`.
- A transport failure or timeout on the merged read fails every member with `BadCommunicationError` and does
**not** retry per tag — the link is down, so per-tag retries would only multiply timeouts.
- Read-only: this changes `pmc_rdpmcrng` reads only; nothing here can write.
The config key lives in the driver `Config` JSON. The AdminUI typed form does not yet surface it (an unknown
key edited through that form is dropped), so set it in the raw config.
## Alarm projection
Alarm surfacing is **disabled by default** because the polling cost is wasted
@@ -294,6 +363,13 @@ Every alarm transition fires `OnAlarmEvent` with:
Cleared alarms fire a second event with `" (cleared)"` appended to the message
so downstream consumers can ignore the clear if they only care about raises.
> ⚠️ **Not routable to a v3 raw condition (found under Gitea #641).** The server routes a native transition
> only when `ConditionId` equals an alarm-bearing raw tag's RawPath, and the host subscribes those RawPaths —
> but this projection filters devices by **host address** (so a RawPath list matches no device and nothing is
> polled) and emits `"{host}#{Type}:{AlarmNumber}"` with no `Kind`. FOCAS native alarms therefore reach no
> Part 9 condition today. Fixing it needs a mapping decision (the CNC exposes one flat alarm list per device,
> not one condition per tag), so it is tracked separately rather than patched.
## Handle recycling
FANUC CNCs have a finite FWLIB handle pool (~5–10 concurrent connections) and
@@ -324,7 +400,9 @@ latency spike once per cadence.
driver surface via `FakeFocasClient`. Includes the alarm-projection raise /
clear diffing tests.
- **Integration tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/`
hold the Docker simulator scaffold; see
hold the Docker simulator scaffold (`OTOPCUA_FOCAS_SIM_ENDPOINT=host:port`; they skip when it is absent;
`Series/WireBackendStatusAndPmcTests` covers `Status/` and PMC coalescing against the mock's `statinfo` and
`pmc` state); see
[`docs/v2/implementation/focas-wire-protocol.md`](../v2/implementation/focas-wire-protocol.md)
for what the simulator emits vs. real CNC behaviour.
- **E2E script** — `scripts/e2e/test-focas.ps1` stages Host + Proxy + a real
+23 -7
View File
@@ -94,6 +94,19 @@ request/response handshake — is a different mechanism and remains deferred; se
| `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) |
### Feature status at a glance (#613)
| Feature | Status | Detail |
|---------|--------|--------|
| SAMPLE / EVENT / CONDITION values | **Supported** | EVENT values are Strings carrying the Agent's own word |
| CONDITION as native Part 9 alarms (#626) | **Supported, opt-in** per raw tag via the `alarm` object | [CONDITION alarms](#condition-alarms-626) |
| `TIME_SERIES` arrays (#627) | **Supported** as variable-length `double[]` (Float64 tags only) | [TIME_SERIES arrays](#time_series-arrays-627) |
| One Agent client per driver, one `/raw` Device (#628) | **Enforced** (`MTConnectMultipleDevices` deploy error); multi-device Agents use several driver instances | [Known limitations](#known-limitations) |
| Write-back (`IWritable`) | **Supported, off by default** (`Write.Enabled` + Agent `AllowPut`) | [Write-back](#write-back--observation-injection-off-by-default) |
| EVENT controlled vocabularies as OPC UA enumerations | **Not supported** (stay String); not scheduled | See [Deferred](#deferred-not-in-this-build) |
| `DATA_SET` / `TABLE` representations | **Not supported** - read as `BadNotSupported` | [Quality mapping](#quality-mapping) |
| MTConnect Interfaces, SHDR ingest | Not built | [Deferred](#deferred-not-in-this-build) |
**`IReadable.ReadAsync` is never called by the running server.** The
production data plane is entirely `ISubscribable` — `DriverInstanceActor`
subscribes once and lives off `OnDataChange`. `ReadAsync` exists for the
@@ -462,14 +475,15 @@ and records a state only when an alarm handler was attached to receive it. Conse
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.)
- A re-subscribe with a **changed** alarm set drops the told record of only the conditions that **left**
the set. The server carries every native condition's state (acknowledgement included) across a full
rebuild itself (Gitea #641 — see `docs/AlarmTracking.md`), so re-raising a surviving condition would
only un-acknowledge it; a condition that left and later comes back was materialised fresh and is told
again. The **same** set (a reconnect) keeps the whole record. A rebuild that leaves the alarm set
unchanged triggers no re-subscribe, and needs none — the server-side carry-over covers it.
- 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`,
**EVENT controlled vocabularies as OPC UA enumerations — not built (parked, low priority).** 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.
@@ -571,7 +585,9 @@ for anything beyond values-and-conditions.
- **P1.5 fast-follow** (design
[§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**.
controlled-vocabulary values → OPC UA enumerations is **not built** (EVENTs stay Strings), and
structured `DATA_SET`/`TABLE` observations stay `BadNotSupported`; both are tracked as a low-priority
follow-up, not part of P1.5.
- **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
+18
View File
@@ -91,6 +91,24 @@ rather than surfacing as a runtime surprise.
| `IAlarmSource` | `SubscribeAlarmsAsync` (EventFilter) + `AcknowledgeAsync` | Subscribes to upstream alarm/condition events and forwards acks |
| `IHistoryProvider` | `ReadRawAsync` / `ReadProcessedAsync` / `ReadAtTimeAsync` → `Session.HistoryReadAsync` | **Unique to this driver** — passthrough history read against the upstream server |
> **Native alarms (#619).** An alarm raw tag is `TagConfig` `{ "nodeId": "<upstream SourceNode>", "alarm": {...} }`.
> The host subscribes with RawPaths and routes on (driver, RawPath), so the driver matches each
> event's `ConditionId` (then its `SourceNode`) against the tag's authored `nodeId` and reports the **RawPath** as
> `ConditionId`. Author `nodeId` as the upstream **condition** node for a 1:1 mapping (required when one source carries
> several conditions, e.g. Hi + HiHi); authoring the **source** node folds every condition of that source into one
> tag. `Acknowledge` is called on the condition node of the last event seen for the RawPath **with that event's
> EventId** (Part 9 §5.7.3). The event's `ActiveState/Id` + `AckedState/Id` are selected and turned into the
> `Raise`/`Acknowledge`/`Clear`/`Retrigger` kinds the host projector consumes (without a kind no upstream alarm could ever
> show active); the kinds are emitted from the state **last told to the host** per RawPath, so an identical re-delivery
> emits nothing (no re-raise, which would un-ack). Condition **branch** events (non-null `BranchId`) are ignored.
> **`ConditionRefresh`** is called after the event item is created, after every in-place reconnect, and on a
> `RefreshRequired` event, so a condition already active upstream is reported at subscribe; `RefreshStart`/`RefreshEnd`
> markers are dropped. A server without it (`BadNotSupported`/`BadMethodInvalid`/…) is logged once and tolerated.
> Last-told state survives a re-initialise (the host re-subscribes the same set over nodes it did not rebuild) and is
> forgotten when the subscribed set changes (the host rebuilt, recreating the nodes inactive). Index keys are server-stable (`nsu=`), so a namespace reorder across a reconnect keeps
> matching. docker-dev seeds an opc-plc driver (`MAIN-opcua`); the alarm tag is
> created only when `@OpcPlcAlarmSourceNodeId` is set in `docker-dev/seed/seed-clusters.sql`.
> This driver does **not** implement `IRediscoverable` — there is no
> push-driven rediscovery signal from a remote OPC UA server in this driver.
> `IHistoryProvider` is implemented by no other driver; history reads for every
+7
View File
@@ -49,6 +49,13 @@ The driver ctor change that made this possible:
- `S7_1500SmokeTests.Driver_write_then_read_round_trip_on_scratch_word` —
`DB1.DBW100` write → read-back; proves write path + buffer visibility
- `S7_1500CodecTests` (#632/#638) — live python-snap7 coverage of the STRING / WSTRING / CHAR / WCHAR /
DTL / DATE / TOD / TIME / S5TIME codecs: seeded reads (DB1 800..960, distinct non-zero), BadOutOfRange
reads of undecodable buffers, write round-trips and BadOutOfRange refused writes (scratch DB1 1100..1199;
needs `S7_SIM_ALLOW_WRITES=1`), the `LocalTsap`/`RemoteTsap`/`ConnectionType` override keys (proves they
reach the wire; snap7 accepts any TSAP, so TSAP semantics remain hardware-only), and the negotiated PDU
size (`NegotiatedPduSize` / `GetDiagnostics`). Seed map in `Docker/README.md`.
### Unit
- `S7AddressParserTests` — S7 address syntax (`DB1.DBD0`, `M10.3`, `IW4`, etc.)
+8 -3
View File
@@ -223,8 +223,10 @@ The PDU size the CPU negotiates during the connection handshake (typically 240 b
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.
message (`S7 connected (CPU S71500, PDU 240 bytes)`). S7 also implements the optional
`IDriverDiagnostics` capability (explicitly, string-valued: `S7.NegotiatedPduSize` = `"480"`, omitted while 0 =
not negotiated yet), so the value rides the driver-health snapshot and shows under the driver name on the
AdminUI `/hosts` page (Gitea #640). See `docs/Telemetry.md` ("Driver diagnostics ride `driver-health`").
## Capability surface
@@ -286,7 +288,10 @@ fresh drop after a healthy period reconnects immediately; worst-case recovery la
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/` cover the
address parser, the reinterpret/box conversions, and the driver lifecycle.
- **Integration fixture** — a Docker S7 simulator on the shared docker host; see
[S7-Test-Fixture.md](S7-Test-Fixture.md) for the coverage map and endpoint.
[S7-Test-Fixture.md](S7-Test-Fixture.md) for the coverage map and endpoint. The wide/date-time
codecs (STRING, WSTRING, CHAR, WCHAR, DTL, DATE, TOD, TIME, S5TIME) and the TSAP override keys have
live snap7 coverage in `S7_1500CodecTests`; an unrepresentable STRING/DT write is `BadOutOfRange`
(not a comms fault) like the other codecs.
- **CLI** — [Driver.S7.Cli.md](../Driver.S7.Cli.md) documents the standalone
read/write/probe CLI for manual checks against a real or simulated CPU.
@@ -7,9 +7,32 @@ public enum ApplyAckOutcome { Applied, Failed }
/// <summary>
/// Per-node acknowledgment returned by <c>DriverHostActor</c> to the dispatching coordinator.
/// </summary>
/// <param name="DeploymentId">The deployment being acknowledged.</param>
/// <param name="NodeId">The acknowledging node.</param>
/// <param name="Outcome">Whether the node applied the deployment.</param>
/// <param name="FailureReason">Why the apply failed; null on <see cref="ApplyAckOutcome.Applied"/>.</param>
/// <param name="CorrelationId">The dispatch's correlation id.</param>
/// <param name="Degraded">
/// Gitea #601: <see langword="true"/> when the node applied the deployment (the revision advanced) but
/// its OPC UA address-space apply was DEGRADED — swallowed per-node sink faults, a failed rebuild, or no
/// outcome reported in time — so the served address space may be partial. Only meaningful with
/// <see cref="ApplyAckOutcome.Applied"/>. Trailing and defaulted so an ack from a node built before
/// this field existed (DPS JSON or gRPC without field 6) decodes exactly as it always did: clean.
/// </param>
/// <param name="DegradedReason">A short, operator-readable description of the degradation, or null.</param>
public sealed record ApplyAck(
DeploymentId DeploymentId,
NodeId NodeId,
ApplyAckOutcome Outcome,
string? FailureReason,
CorrelationId CorrelationId);
CorrelationId CorrelationId,
bool Degraded = false,
string? DegradedReason = null)
{
/// <summary>
/// Gitea #601 — prefix of <c>NodeDeploymentState.FailureReason</c> on an <b>Applied</b> row whose
/// address-space apply was degraded. Shared by central (which writes it from the ack) and a node with
/// a ConfigDb (which writes its own row), so both spell the row identically.
/// </summary>
public const string DegradedReasonPrefix = "Applied degraded: ";
}
@@ -0,0 +1,25 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
/// <summary>
/// The deploy-path timing contract shared by central (<c>ConfigPublishCoordinator</c>) and the node
/// (<c>DriverHostActor</c>). One place, so the node's ack-hold timeout can never drift past the deadline
/// central times the deployment out on.
/// </summary>
public static class DeployTimings
{
/// <summary>How long central waits for every expected node's ack before sealing the deployment TimedOut
/// (<c>ConfigPublishCoordinator.DefaultApplyDeadline</c>).</summary>
public static readonly TimeSpan ApplyDeadline = TimeSpan.FromMinutes(2);
/// <summary>Gitea #601 — how far inside <see cref="ApplyDeadline"/> a node's held Applied ack is released
/// when the address-space outcome has not arrived, leaving room for transport latency.</summary>
public static readonly TimeSpan ApplyOutcomeSafetyMargin = TimeSpan.FromSeconds(15);
/// <summary>Gitea #601 — the node's default apply-outcome timeout: <see cref="ApplyDeadline"/> −
/// <see cref="ApplyOutcomeSafetyMargin"/> (105 s). Long enough that a slow-but-clean rebuild of a large
/// address space is not labelled degraded, short enough to ack before central gives up.</summary>
/// <remarks>The timer is armed after the artifact read and driver reconcile, so if those alone took
/// longer than the margin the ack can still land after the deadline — accepted: they are seconds in
/// practice, and lengthening the margin would label more slow-but-clean rebuilds degraded.</remarks>
public static TimeSpan ApplyOutcomeTimeout => ApplyDeadline - ApplyOutcomeSafetyMargin;
}
@@ -59,6 +59,12 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
/// <para><b>Value-equality caveat</b> — the same one <paramref name="HostStatuses"/> carries: the
/// record's generated <c>Equals</c> compares the sample list by REFERENCE.</para>
/// </param>
/// <param name="Diagnostics">
/// Operator-facing driver diagnostics from <c>IDriverDiagnostics.GetDiagnostics()</c> (e.g. S7's
/// negotiated PDU size), bounded and in ordinal key order; null when the driver does not implement the
/// capability (Gitea #640). Additive, never a state change. Same by-reference value-equality caveat as
/// <paramref name="HostStatuses"/>.
/// </param>
public sealed record DriverHealthChanged(
string ClusterId,
string DriverInstanceId,
@@ -70,7 +76,8 @@ public sealed record DriverHealthChanged(
DateTime? RediscoveryNeededUtc = null,
string? RediscoveryReason = null,
IReadOnlyList<HostConnectivityStatus>? HostStatuses = null,
SkippedTagReport? SkippedTags = null)
SkippedTagReport? SkippedTags = null,
IReadOnlyDictionary<string, string>? Diagnostics = null)
{
/// <summary>
/// DPS topic name. Both the runtime <c>AkkaDriverHealthPublisher</c> and the AdminUI
@@ -216,6 +216,11 @@ public static class MeshTransportDtoMapper
if (message.FailureReason is not null)
proto.FailureReason = message.FailureReason;
// #601 — additive fields; a clean ack leaves both unset, byte-identical to a pre-#601 encode.
proto.Degraded = message.Degraded;
if (message.DegradedReason is not null)
proto.DegradedReason = message.DegradedReason;
return proto;
}
@@ -249,7 +254,10 @@ public static class MeshTransportDtoMapper
NodeId.Parse(proto.NodeId),
outcome,
proto.HasFailureReason ? proto.FailureReason : null,
CorrelationId.Parse(proto.CorrelationId));
CorrelationId.Parse(proto.CorrelationId),
// #601 — absent on an old node's ack, which decodes as false/null: today's clean ack.
Degraded: proto.Degraded,
DegradedReason: proto.HasDegradedReason ? proto.DegradedReason : null);
}
// ---------------------------------------------------------------- Envelope
@@ -69,6 +69,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
/// <inheritdoc />
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
/// <inheritdoc />
public void CompleteAddressSpaceApply() => _inner.CompleteAddressSpaceApply();
/// <inheritdoc />
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => _inner.RaiseNodesAddedModelChange(affectedNodeId, realm);
@@ -185,6 +185,19 @@ public interface IOpcUaAddressSpaceSink
/// </summary>
void RebuildAddressSpace();
/// <summary>
/// Gitea #641 — mark the END of one address-space apply pass (after <see cref="RebuildAddressSpace"/>, if
/// one ran, and every materialise / notifier-wiring call that followed it). An SDK-backed sink carries each
/// native condition's live Part 9 state across a rebuild and restores it when the same condition is
/// re-materialised; this is where it reports the ONE condition event per restored condition (now that
/// every notifier folder is wired) and DISCARDS any captured state no materialise claimed, so a later
/// deploy that re-adds the same id gets a fresh node rather than a resurrection. <c>OpcUaPublishActor</c>
/// calls it once at the end of every apply, rebuild or not. Idempotent; the default is a no-op.
/// </summary>
void CompleteAddressSpaceApply()
{
}
/// <summary>
/// Announce that nodes were added at runtime (discovered-node injection) under
/// <paramref name="affectedNodeId"/> so subscribed clients refresh their browse
@@ -105,6 +105,11 @@ message ApplyAckMessage {
ApplyOutcome outcome = 3;
optional string failure_reason = 4; // nullable in the record — presence distinguishes null from ""
string correlation_id = 5; // CorrelationId (Guid) in its "N" hex form
// Gitea #601 — ADDITIVE. True when the node applied the deployment but its address-space apply was
// degraded (sink faults, failed rebuild, or no outcome in time). An old node never sets it, so it decodes
// as false: exactly today's clean ack. Only meaningful with APPLY_OUTCOME_APPLIED.
bool degraded = 6;
optional string degraded_reason = 7; // short operator-readable reason; presence = set
}
// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy.ApplyAckOutcome, shifted by one so 0 stays
@@ -82,6 +82,12 @@ message DriverHealth {
// proto3, which a repeated field does not. So no companion has_ flag (and one would collide: protoc
// already generates a HasSkippedTags property for this field).
SkippedTags skipped_tags = 12;
// Driver diagnostics bag (Gitea #640), e.g. S7's negotiated PDU size. Additive: an old node never sets
// these and an old central ignores them. A map cannot distinguish absent from empty, and the two differ
// ("driver has no IDriverDiagnostics" vs "it has one and currently has nothing to say"), so the presence
// flag carries that bit - the same shape as has_host_statuses.
bool has_diagnostics = 13;
map<string, string> diagnostics = 14;
}
// Mirrors ZB.MOM.WW.OtOpcUa.Core.Abstractions.SkippedTagReport.
@@ -0,0 +1,58 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Optional driver capability: expose a small bag of operator-facing diagnostic key/value pairs
/// (e.g. S7's negotiated PDU size) that ride the driver-health snapshot to the AdminUI <c>/hosts</c>
/// page (Gitea #640).
/// </summary>
/// <remarks>
/// <para>
/// <b>Why a bag rather than fields on <see cref="DriverHealth"/>.</b> The facts worth showing are
/// driver-specific and do not change the driver's <i>state</i>; a typed field per driver would
/// widen the core record and the telemetry proto for every driver. Same additive-never-a-state-change
/// rule as <see cref="ISkippedTagReporter"/>.
/// </para>
/// <para>
/// Values are strings so the telemetry proto stays a plain <c>map&lt;string,string&gt;</c>. Keep the
/// bag small (<see cref="DriverDiagnostics.MaxEntries"/> entries, text truncated to
/// <see cref="DriverDiagnostics.MaxValueLength"/>): it is published on every state change.
/// </para>
/// </remarks>
public interface IDriverDiagnostics
{
/// <summary>
/// Snapshot of the driver's diagnostics. Must be a pure in-memory read with no I/O — it is called on
/// the health-publish path, deliberately NOT through the resilience pipeline.
/// </summary>
/// <returns>The key/value pairs; empty when there is nothing to show yet.</returns>
IReadOnlyDictionary<string, string> GetDiagnostics();
}
/// <summary>Bounds and helpers shared by every consumer of <see cref="IDriverDiagnostics"/>.</summary>
public static class DriverDiagnostics
{
/// <summary>Maximum number of entries carried on a health snapshot; extras are dropped (ordinal key order).</summary>
public const int MaxEntries = 16;
/// <summary>Maximum length of a key or value carried on a health snapshot; longer text is truncated.</summary>
public const int MaxValueLength = 200;
/// <summary>
/// Normalises a driver's bag for publishing: ordinal key order, capped entry count, truncated text.
/// Deterministic so the same facts always produce the same fingerprint digest.
/// </summary>
/// <param name="raw">The bag the driver returned.</param>
/// <returns>A bounded, ordered copy.</returns>
public static IReadOnlyDictionary<string, string> Bound(IReadOnlyDictionary<string, string> raw)
{
var result = new SortedDictionary<string, string>(StringComparer.Ordinal);
foreach (var kv in raw.OrderBy(k => k.Key, StringComparer.Ordinal))
{
if (result.Count >= MaxEntries) break;
result[Truncate(kv.Key ?? "")] = Truncate(kv.Value ?? "");
}
return result;
}
private static string Truncate(string s) => s.Length <= MaxValueLength ? s : s[..MaxValueLength];
}
@@ -34,6 +34,10 @@ public interface IDriverHealthPublisher
/// <see cref="ISkippedTagReporter"/>. Contradicts the "Healthy, 0 errors, reads advancing" reading
/// of a driver that is in fact ignoring part or all of its configuration (Gitea #541).
/// </param>
/// <param name="diagnostics">
/// Bounded driver diagnostics from <see cref="IDriverDiagnostics.GetDiagnostics"/>; null when the
/// driver is not an <see cref="IDriverDiagnostics"/> (Gitea #640).
/// </param>
void Publish(
string clusterId,
string driverInstanceId,
@@ -42,7 +46,8 @@ public interface IDriverHealthPublisher
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null,
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null,
SkippedTagReport? skippedTags = null);
SkippedTagReport? skippedTags = null,
IReadOnlyDictionary<string, string>? diagnostics = null);
}
/// <summary>
@@ -65,6 +70,7 @@ public sealed class NullDriverHealthPublisher : IDriverHealthPublisher
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null,
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null,
SkippedTagReport? skippedTags = null)
SkippedTagReport? skippedTags = null,
IReadOnlyDictionary<string, string>? diagnostics = null)
{ /* no-op */ }
}
@@ -70,6 +70,12 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
// prior subscriptions before starting the new one is semantically faithful. Snapshotting +
// clearing under the same lock DisposeAsync uses guarantees the stale subs can never be
// double-owned (and thus never double-disposed) by a racing dispose.
// Gitea #641 review — forget only the refs that LEFT the set. A survivor keeps its last-seen InFaulted, so
// a re-subscribe (every reconnect, every alarm-set change) does not re-RAISE a still-faulted alarm: the
// server carries the condition across a rebuild, and a Raise would un-acknowledge it. A ref that left and
// comes back was materialised fresh, so it starts from "not faulted" and is raised again.
_driver.AlarmFaultRecord.RetainOnly(sub.SourceNodeIds);
List<Subscription> stale;
lock (_subsLock)
{
@@ -190,26 +196,31 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
// is intentional and matches the documented behaviour.
var severity = ToInt(severityDv.Value);
var wasFaulted = sub.LastInFaulted.GetValueOrDefault(nodeId, false);
sub.LastInFaulted[nodeId] = nowFaulted;
var wasFaulted = _driver.AlarmFaultRecord.Exchange(nodeId, nowFaulted);
// ConditionId is the subscribed reference itself — the alarm tag's RawPath, the key the host routes
// native transitions on — and Kind tells the host's projector what changed (Gitea #641). This used
// to send "{ref}#active" with no Kind, so the host dropped every ALMD transition as unrouted (and an
// Unspecified kind would have projected no state change anyway).
if (!wasFaulted && nowFaulted)
{
_driver.InvokeAlarmEvent(new AlarmEventArgs(
sub.Handle, nodeId, ConditionId: $"{nodeId}#active",
sub.Handle, nodeId, ConditionId: nodeId,
AlarmType: "ALMD",
Message: $"ALMD {nodeId} raised",
Severity: MapSeverity(severity),
SourceTimestampUtc: DateTime.UtcNow));
SourceTimestampUtc: DateTime.UtcNow,
Kind: AlarmTransitionKind.Raise));
}
else if (wasFaulted && !nowFaulted)
{
_driver.InvokeAlarmEvent(new AlarmEventArgs(
sub.Handle, nodeId, ConditionId: $"{nodeId}#active",
sub.Handle, nodeId, ConditionId: nodeId,
AlarmType: "ALMD",
Message: $"ALMD {nodeId} cleared",
Severity: MapSeverity(severity),
SourceTimestampUtc: DateTime.UtcNow));
SourceTimestampUtc: DateTime.UtcNow,
Kind: AlarmTransitionKind.Clear));
}
}
}
@@ -297,9 +308,44 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
/// <summary>Gets or sets the polling loop task.</summary>
public Task Loop { get; set; } = Task.CompletedTask;
}
}
/// <summary>Gets the dictionary tracking the last known InFaulted state for each node.</summary>
public Dictionary<string, bool> LastInFaulted { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Gitea #641 review — the last <c>InFaulted</c> the ALMD projection told the server, per alarm RawPath. Owned by
/// the <see cref="AbCipDriver"/> rather than a subscription or a projection, so it survives a re-subscribe and a
/// re-initialise (both of which happen on every reconnect): the server's condition nodes outlive them, and a
/// re-raise there would un-acknowledge. Thread-safe — a retiring poll loop may overlap the new one for a tick.
/// </summary>
internal sealed class AbCipAlarmFaultRecord
{
private readonly Dictionary<string, bool> _lastInFaulted = new(StringComparer.Ordinal);
private readonly Lock _lock = new();
/// <summary>Records <paramref name="nowFaulted"/> for <paramref name="nodeId"/>, returning the prior value
/// (<c>false</c> when none).</summary>
/// <param name="nodeId">The alarm RawPath.</param>
/// <param name="nowFaulted">The state just read.</param>
/// <returns>The previously recorded state.</returns>
public bool Exchange(string nodeId, bool nowFaulted)
{
lock (_lock)
{
var was = _lastInFaulted.GetValueOrDefault(nodeId, false);
_lastInFaulted[nodeId] = nowFaulted;
return was;
}
}
/// <summary>Forgets every ref not in <paramref name="nodeIds"/>.</summary>
/// <param name="nodeIds">The newly subscribed refs.</param>
public void RetainOnly(IReadOnlyList<string> nodeIds)
{
var keep = new HashSet<string>(nodeIds, StringComparer.Ordinal);
lock (_lock)
{
foreach (var k in _lastInFaulted.Keys.Where(k => !keep.Contains(k)).ToList()) _lastInFaulted.Remove(k);
}
}
}
@@ -118,6 +118,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly ILogger<AbCipDriver> _logger;
private AbCipAlarmProjection _alarmProjection;
/// <summary>Gitea #641 review — the ALMD projection's last-told InFaulted per alarm ref; outlives every
/// projection and subscription (see <see cref="AbCipAlarmFaultRecord"/>).</summary>
internal AbCipAlarmFaultRecord AlarmFaultRecord { get; } = new();
private DriverHealth _health = new(DriverState.Unknown, null, null);
/// <summary>Occurs when a subscribed tag's value changes.</summary>
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <item><c>L9:0</c> — long-integer file (SLC 5/05+, 32-bit).</item>
/// <item><c>I:017/07</c> — PLC-5 only: I/O word + bit indices are <b>octal</b> (#244); stored decimal.</item>
/// <item><c>RTC:0.HR</c> — MicroLogix only: function file (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI, #245).</item>
/// <item><c>N7:[N7:0]</c>, <c>N[N7:0]:5</c> — indirect addressing (#247): parsed, not resolved at runtime.</item>
/// <item><c>N7:[N7:0]</c>, <c>N[N7:0]:5</c> — indirect addressing (#247 parse, #646 runtime): the driver reads the index first and composes the concrete address.</item>
/// </list>
/// <para>The libplctag transport takes <see cref="ToLibplctagName"/> as its <c>name=...</c> attribute and the
/// native transport re-parses the same string, so both are handed identical (decimal) numbers. This parser
@@ -42,8 +42,8 @@ public sealed record AbLegacyAddress(
/// <summary>
/// True when the file number or the word number is sourced from another PCCC address evaluated
/// at runtime (<c>N7:[N7:0]</c> or <c>N[N7:0]:5</c>). Neither transport can encode the bracket
/// form, and resolving it needs a pre-read of the index word, which is not implemented — the
/// driver answers an indirect tag <c>BadNotSupported</c>.
/// form, so the driver (#646) pre-reads the index, substitutes it, and hands the transport the
/// concrete address; an indirect name never reaches a transport.
/// </summary>
public bool IsIndirect => IndirectFileSource is not null || IndirectWordSource is not null;
@@ -123,12 +123,6 @@ public static class AbLegacyTagDefinitionFactory
/// The RawPath handed to <see cref="FromTagConfig"/> during inspection — the mapper only copies it
/// onto <see cref="AbLegacyTagDefinition.Name"/> and never validates it.
/// </summary>
private static bool IsIndirectFor(string address, AbLegacyPlcFamily? family)
{
if (family is { } f) return AbLegacyAddress.TryParse(address, f)?.IsIndirect == true;
return Enum.GetValues<AbLegacyPlcFamily>().Any(c => AbLegacyAddress.TryParse(address, c)?.IsIndirect == true);
}
private const string InspectionRawPath = "(inspect)";
/// <summary>
@@ -179,8 +173,6 @@ public static class AbLegacyTagDefinitionFactory
{
if (AbLegacyAddress.Validate(accepted.Address, family) is { } addressError)
warnings.Add($"AbLegacy tag address: {addressError} The driver reads this tag as BadNodeIdUnknown.");
else if (IsIndirectFor(accepted.Address, family))
warnings.Add($"AbLegacy address '{accepted.Address}' is indirect; indirect addressing is not supported and the tag reads BadNotSupported.");
return warnings;
}
@@ -388,8 +388,6 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
try
{
// Indirect addresses (N7:[N7:0]) parse but are not resolved at runtime (#247) — answer the
// refusal before a runtime is created for a name neither transport can encode.
var preParsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
if (preParsed is null)
{
@@ -399,8 +397,18 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
}
if (preParsed.IsIndirect)
{
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
continue;
// Indirect (#646): read the index word(s) NOW (never cached - the index can change between
// polls), compose the concrete address, then run the normal path against it.
var res = await ResolveIndirectAsync(device, def, preParsed, cancellationToken).ConfigureAwait(false);
if (res.Def is null)
{
results[i] = new DataValueSnapshot(null, res.Status, null, now);
if (res.TransportFault)
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"transport fault resolving indirect address for {reference}");
continue;
}
def = res.Def;
}
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
@@ -513,11 +521,21 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
continue;
}
// Indirect addressing is parse-only (#247): refuse before any runtime or RMW read is created.
// Indirect (#646): resolve the index first (non-atomic with the write - the index may change
// between the two PLC accesses), then write the concrete address through the normal path.
if (parsed.IsIndirect)
{
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
continue;
var res = await ResolveIndirectAsync(device, def, parsed, cancellationToken).ConfigureAwait(false);
if (res.Def is null)
{
results[i] = new WriteResult(res.Status);
if (res.TransportFault)
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"transport fault resolving indirect address for {w.FullReference}");
continue;
}
def = res.Def;
parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily)!;
}
// A write the transport refuses outright (native: a T/C/R status-bit sub-element such as T4:0.DN,
@@ -841,6 +859,137 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
}
}
/// <summary>Largest word/element index an indirect source may yield (PCCC element offset is one byte).</summary>
private const int MaxIndirectElement = AbLegacyArray.MaxElements - 1;
/// <summary>PLC-5 data files hold up to 1000 words (extended PCCC offset), unlike SLC/MicroLogix (256).</summary>
private const int MaxIndirectElementPlc5 = 999;
/// <summary>Largest data-file number an indirect source may yield (PLC-5 allows up to 999).</summary>
private const int MaxIndirectFile = 999;
/// <summary>
/// Resolves an indirect address (#646). Reads each index source through the same runtime/transport path
/// (same factory, so libplctag and Native alike), range-checks it, and composes a concrete tag definition
/// whose <c>Name</c> is keyed by the resolved address so the normal runtime cache applies. Nothing about the
/// index is cached: every call re-reads it. A bad index is <c>BadOutOfRange</c> (per-tag, no health impact);
/// an inner-read failure propagates its mapped status (<c>TransportFault</c> true for a wire failure).
/// </summary>
private async Task<IndirectResolution> ResolveIndirectAsync(
DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress parsed, CancellationToken ct)
{
var fileNumber = parsed.FileNumber;
var word = parsed.WordNumber;
var maxElement = device.Options.PlcFamily is AbLegacyPlcFamily.Plc5 or AbLegacyPlcFamily.LogixPccc
? MaxIndirectElementPlc5 : MaxIndirectElement;
if (parsed.IndirectFileSource is { } fileSrc)
{
var r = await ReadIndexAsync(device, fileSrc, ct).ConfigureAwait(false);
if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault);
if (r.Value is < 0 or > MaxIndirectFile)
return new IndirectResolution(null, AbLegacyStatusMapper.BadOutOfRange, false);
fileNumber = (int)r.Value!;
}
if (parsed.IndirectWordSource is { } wordSrc)
{
var r = await ReadIndexAsync(device, wordSrc, ct).ConfigureAwait(false);
if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault);
if (r.Value < 0 || r.Value > maxElement)
return new IndirectResolution(null, AbLegacyStatusMapper.BadOutOfRange, false);
word = (int)r.Value!;
}
var concrete = parsed with
{
FileNumber = fileNumber, WordNumber = word, IndirectFileSource = null, IndirectWordSource = null,
};
var concreteText = concrete.ToLibplctagName();
var resolvedName = $"{def.Name}=>{concreteText}";
// The index moved: drop the previous resolution's handle so a sweeping index does not accumulate one
// libplctag handle per value it ever took.
var previous = device.IndirectLastName.TryGetValue(def.Name, out var prior) ? prior : null;
device.IndirectLastName[def.Name] = resolvedName;
if (previous is not null && !string.Equals(previous, resolvedName, StringComparison.OrdinalIgnoreCase))
{
var oldLock = device.GetRuntimeLock(previous);
await oldLock.WaitAsync(ct).ConfigureAwait(false);
try { EvictRuntime(device, previous); } finally { oldLock.Release(); }
}
return new IndirectResolution(def with { Name = resolvedName, Address = concreteText }, 0, false);
}
/// <summary>Reads one direct index address as an integer through the shared parent-runtime cache.</summary>
private async Task<(long? Value, uint Status, bool TransportFault)> ReadIndexAsync(
DeviceState device, AbLegacyAddress inner, CancellationToken ct)
{
AbLegacyDataType type;
if (inner.BitIndex is not null) type = AbLegacyDataType.Bit;
else if (inner.FileLetter == "L") type = AbLegacyDataType.Long;
else if (inner.FileLetter is "F" or "ST") return (null, AbLegacyStatusMapper.BadOutOfRange, false);
else type = AbLegacyDataType.Int;
// A bit-addressed index reads its parent word and decodes the bit, like the RMW path does.
var runtimeName = (inner with { BitIndex = null }).ToLibplctagName();
var gate = device.GetRmwLock(runtimeName);
await gate.WaitAsync(ct).ConfigureAwait(false);
try
{
IAbLegacyTagRuntime runtime;
try
{
runtime = await EnsureParentRuntimeAsync(device, runtimeName, ct).ConfigureAwait(false);
await runtime.ReadAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException) { throw; }
catch (AbTransportException ex) when (IsLocalRefusal(ex))
{
return (null, AbLegacyStatusMapper.BadNotSupported, false);
}
catch
{
EvictParentRuntime(device, runtimeName);
return (null, AbLegacyStatusMapper.BadCommunicationError, true);
}
var status = runtime.GetStatus();
if (status != 0)
{
EvictParentRuntime(device, runtimeName);
return (null, AbLegacyStatusMapper.MapStatus(status), true);
}
var raw = runtime.DecodeValue(type, inner.BitIndex);
try
{
return raw switch
{
bool b => (b ? 1L : 0L, 0u, false),
null => (null, AbLegacyStatusMapper.BadOutOfRange, false),
_ => (Convert.ToInt64(raw), 0u, false),
};
}
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
{
return (null, AbLegacyStatusMapper.BadOutOfRange, false);
}
}
finally
{
gate.Release();
}
}
private static void EvictParentRuntime(DeviceState device, string name)
{
if (device.ParentRuntimes.TryRemove(name, out var stale))
{
try { stale.Dispose(); } catch { /* best-effort */ }
}
}
private sealed record IndirectResolution(AbLegacyTagDefinition? Def, uint Status, bool TransportFault);
private async Task<IAbLegacyTagRuntime> EnsureParentRuntimeAsync(
AbLegacyDriver.DeviceState device, string parentName, CancellationToken ct)
{
@@ -1086,6 +1235,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
public System.Collections.Concurrent.ConcurrentDictionary<string, IAbLegacyTagRuntime> ParentRuntimes { get; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>Indirect tag name to the resolved-address runtime key it last used (#646), so a moved index evicts its stale handle.</summary>
public System.Collections.Concurrent.ConcurrentDictionary<string, string> IndirectLastName { get; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Per-key creation locks for <see cref="Runtimes"/> and <see cref="ParentRuntimes"/>.
/// A caller holds this before the TryGetValue + Create + InitializeAsync + TryAdd
@@ -1181,6 +1334,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
Runtimes.Clear();
foreach (var r in ParentRuntimes.Values) r.Dispose();
ParentRuntimes.Clear();
IndirectLastName.Clear();
// Dispose + clear the per-parent RMW gates and the per-runtime/creation locks so
// ReinitializeAsync cycles don't orphan their SemaphoreSlim instances (each leaks a
// wait handle once contended).
@@ -31,6 +31,8 @@ public sealed class FocasDriverOptions
public FocasHandleRecycleOptions HandleRecycle { get; init; } = new();
/// <summary>Gets the fixed tree options.</summary>
public FocasFixedTreeOptions FixedTree { get; init; } = new();
/// <summary>Gets the PMC range-coalescing options (Gitea #266).</summary>
public FocasPmcCoalesceOptions PmcCoalesce { get; init; } = new();
/// <summary>
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
@@ -41,6 +43,32 @@ public sealed class FocasDriverOptions
public int ProbeTimeoutSeconds { get; init; } = 10;
}
/// <summary>
/// Merges adjacent / near PMC reads in one poll batch into fewer <c>pmc_rdpmcrng</c> calls (Gitea #266).
/// On by default: a batch with a single PMC tag per area is unaffected, and a coalesced read the CNC
/// rejects falls back to per-tag reads, so enabling it cannot make a readable tag unreadable.
/// </summary>
public sealed class FocasPmcCoalesceOptions
{
/// <summary>Gets a value indicating whether PMC coalescing is on.</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Largest range, in bytes, one coalesced read may cover. Bounds the response size and the
/// blast radius of a rejected range. Default 128.
/// </summary>
[Range(1, 1024)]
public int MaxSpanBytes { get; init; } = 128;
/// <summary>
/// Largest run of bytes nobody asked for that may sit between two merged tags. Reading a few
/// spare bytes is cheaper than a second round-trip, but every spare byte must be a valid PMC
/// address on the CNC. Default 8; 0 merges only strictly adjacent tags.
/// </summary>
[Range(0, 1024)]
public int MaxGapBytes { get; init; } = 8;
}
/// <summary>
/// Fixed-node tree exposed by FOCAS per <c>docs/v2/driver-specs.md §7</c> —
/// <c>Identity/</c>, <c>Axes/{name}/</c>, etc. populated from
@@ -498,8 +498,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
// #266 — merge adjacent / near PMC reads of one device into fewer pmc_rdpmcrng calls. Indices it
// serves are marked here and skipped by the per-tag loop below; everything else (including every
// single-tag range and any range the CNC rejected) takes the unchanged per-tag path.
var served = _options.PmcCoalesce.Enabled
? await CoalescePmcReadsAsync(fullReferences, results, now, cancellationToken).ConfigureAwait(false)
: null;
for (var i = 0; i < fullReferences.Count; i++)
{
if (served is not null && served[i]) continue;
var reference = fullReferences[i];
// Fixed-tree T1 — fixed-tree references are synthesized from the cached
@@ -581,6 +589,127 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return results;
}
/// <summary>
/// PMC coalescing pre-pass (Gitea #266). Groups the batch's PMC tags by device, plans merged ranges
/// (<see cref="FocasPmcCoalescer"/>) and serves every multi-member range with ONE
/// <c>pmc_rdpmcrng</c>, slicing each tag's value out of the returned slots.
/// </summary>
/// <remarks>
/// Failure handling is the point of the design. A rejected range is NOT mis-attributed to its members:
/// <list type="bullet">
/// <item>a comms-level failure (timeout / transport) fails every member with that status — the
/// link is down, so re-reading each member would multiply timeouts;</item>
/// <item>any other non-Good status (typically a spare "gap" byte that is not a valid PMC address)
/// leaves the members UNSERVED, so the per-tag loop reads them individually and each gets its own
/// true status — and the range is remembered so later polls skip the doomed merged read.</item>
/// </list>
/// Returns null when nothing was served.
/// </remarks>
private async Task<bool[]?> CoalescePmcReadsAsync(
IReadOnlyList<string> references, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
{
Dictionary<DeviceState, List<FocasPmcReadItem>>? byDevice = null;
for (var i = 0; i < references.Count; i++)
{
try
{
var reference = references[i];
if (_fixedTreeActive && TryReadFixedTree(reference, now) is not null) continue;
if (!_resolver.TryResolve(reference, out var def)) continue;
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
var parsed = ResolveParsedAddress(def);
if (parsed.Kind != FocasAreaKind.Pmc || parsed.PmcLetter is null) continue;
(byDevice ??= []).TryAdd(device, []);
byDevice[device].Add(new FocasPmcReadItem(i, parsed.PmcLetter, parsed.Number, def.DataType, parsed.BitIndex));
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
/* malformed / unresolvable — the per-tag loop reports it with its usual status */
}
}
if (byDevice is null) return null;
bool[]? served = null;
foreach (var (device, items) in byDevice)
{
if (items.Count < 2) continue;
var plans = FocasPmcCoalescer.Plan(items, _options.PmcCoalesce.MaxSpanBytes, _options.PmcCoalesce.MaxGapBytes);
if (!plans.Any(p => p.Members.Count >= 2 && !device.CoalesceRejected.ContainsKey(p.Key))) continue;
// One connect attempt per device per batch: if it fails, every PMC tag of the device fails with
// that status here instead of paying a connect attempt per plan and again per tag.
IFocasClient client;
try { client = await EnsureConnectedAsync(device, ct).ConfigureAwait(false); }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception)
{
FailMembers(items, FocasStatusMapper.BadCommunicationError, references, results, now, ref served,
$"FOCAS connect failed reading PMC tags of {device.Options.HostAddress}");
continue;
}
foreach (var plan in plans)
{
if (plan.Members.Count < 2) continue;
if (device.CoalesceRejected.ContainsKey(plan.Key)) continue;
var members = plan.Members;
try
{
var (slots, status) = await client
.ReadPmcSlotsAsync(plan.Letter, plan.SlotType, plan.Start, plan.End, ct)
.ConfigureAwait(false);
if (status == FocasStatusMapper.Good && slots is not null)
{
foreach (var m in members)
{
var slot = plan.SlotOf(m);
results[m.Index] = slot < slots.Count
? new DataValueSnapshot(FocasPmcCoalescer.MapSlot(slots[slot], m.Type, m.BitIndex), status, now, now)
: new DataValueSnapshot(null, FocasStatusMapper.BadOutOfRange, now, now);
(served ??= new bool[references.Count])[m.Index] = true;
}
Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, now, null));
}
else
{
// The CNC REFUSED the merged range (any return code, mapped or not). Don't guess which
// member is at fault: let the per-tag loop give each its own answer. Remember the range
// so later polls skip the doomed read — except for a transient refusal (EW_BUSY maps to
// BadDeviceFailure), which says nothing about the range.
if (status != FocasStatusMapper.BadDeviceFailure)
device.CoalesceRejected[plan.Key] = 0;
_logger.LogDebug(
"FOCAS PMC range {Key} on {Host} rejected (0x{Status:X8}) — reading its {Count} tags individually",
plan.Key, device.Options.HostAddress, status, members.Count);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception)
{
// Transport failure / timeout: the link is suspect, so fail the members without per-tag retries.
FailMembers(members, FocasStatusMapper.BadCommunicationError, references, results, now, ref served,
$"FOCAS transport failure reading PMC range {plan.Key}");
}
}
}
return served;
void FailMembers(IEnumerable<FocasPmcReadItem> failed, uint status, IReadOnlyList<string> refs,
DataValueSnapshot[] res, DateTime stamp, ref bool[]? servedRef, string reason)
{
foreach (var m in failed)
{
res[m.Index] = new DataValueSnapshot(null, status, null, stamp);
(servedRef ??= new bool[refs.Count])[m.Index] = true;
}
Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
Volatile.Read(ref _health).LastSuccessfulRead,
$"{reason} (0x{status:X8})"));
}
}
// ---- IWritable ----
/// <inheritdoc />
@@ -751,6 +880,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
EmitFixedVariable(opMode, "OperationMode", "ModeText", DriverDataType.String);
}
// Fixed-tree T2.5 — Status subtree (cnc_statinfo flags, #257), gated on its own probe.
if (cache.Capabilities.Status)
{
var status = deviceFolder.Folder("Status", "Status");
foreach (var field in StatusFields)
EmitFixedVariable(status, "Status", field, DriverDataType.Int32);
EmitFixedVariable(status, "Status", "RunText", DriverDataType.String);
}
// Fixed-tree T3 — Timers subtree (power-on / operating / cutting / cycle).
if (cache.Capabilities.Timers)
{
@@ -1008,6 +1146,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var servoLoads = await SafeProbe(() => client.GetServoLoadsAsync(ct), []);
var programInfo = await SafeTryProbe(() => client.GetProgramInfoAsync(ct));
var timer = await SafeTryProbe(() => client.GetTimerAsync(FocasTimerKind.PowerOn, ct));
var statusProbe = await SafeTryProbe(() => client.GetStatusAsync(ct));
var spindleLoad = await SafeProbe(() => client.GetSpindleLoadsAsync(ct), []);
var caps = new FocasFixedTreeCapabilities(
@@ -1016,7 +1155,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
SpindleMotorSpeed: spindleMotorSpeeds.Count > 0,
ServoLoad: servoLoads.Count > 0,
ProgramInfo: programInfo is not null,
Timers: timer is not null);
Timers: timer is not null,
Status: statusProbe is not null);
state.LastStatus = statusProbe;
state.FixedTreeCache = new FocasFixedTreeCache(sys, [.. axes], [.. spindles], caps);
// The bootstrap IS an answer from the device, and Identity comes entirely from it —
@@ -1054,6 +1195,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var programPollDue = DateTime.MinValue;
var timerPollDue = DateTime.MinValue;
var statusPollDue = DateTime.MinValue;
while (!ct.IsCancellationRequested)
{
try
@@ -1129,6 +1271,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
programPollDue = DateTime.UtcNow + programInterval;
}
// Status flags (cnc_statinfo, #257) — same cadence as program/mode: run/emergency/alarm
// transitions are operator-timescale events and one call returns all of them.
if (cache.Capabilities.Status
&& programInterval > TimeSpan.Zero && DateTime.UtcNow >= statusPollDue)
{
try { state.LastStatus = await client.GetStatusAsync(ct).ConfigureAwait(false); }
catch (Exception ex)
{
/* transient — keep the last value; staleness is dated by LastFixedPollUtc */
_logger.LogDebug(ex, "FOCAS status poll failed for {Host}", state.Options.HostAddress);
}
statusPollDue = DateTime.UtcNow + programInterval;
}
// Timers — slowest cadence. Fires 4 FWLIB calls per tick (one per kind).
var timerInterval = _options.FixedTree.TimerPollInterval;
if (cache.Capabilities.Timers
@@ -1245,6 +1401,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
private static readonly string[] StatusFields = ["Auto", "Run", "Motion", "Mstb", "Emergency", "Alarm", "Edit", "TmMode"];
private static object? TimerValue(DeviceState state, FocasTimerKind kind) =>
state.LastTimers.TryGetValue(kind, out var t) ? (object)t.TotalSeconds : null;
@@ -1385,6 +1543,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
"OperationMode/Mode" => state.LastProgramInfo?.Mode,
"OperationMode/ModeText" => state.LastProgramInfo?.Mode is { } mode
? FocasOpMode.ToText(mode) : null,
"Status/Auto" => state.LastStatus?.Auto,
"Status/Run" => state.LastStatus?.Run,
"Status/Motion" => state.LastStatus?.Motion,
"Status/Mstb" => state.LastStatus?.Mstb,
"Status/Emergency" => state.LastStatus?.Emergency,
"Status/Alarm" => state.LastStatus?.Alarm,
"Status/Edit" => state.LastStatus?.Edit,
"Status/TmMode" => state.LastStatus?.TmMode,
"Status/RunText" => state.LastStatus is { } st ? FocasRunState.ToText(st.Run) : null,
"Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn),
"Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating),
"Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting),
@@ -1618,7 +1785,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
bool SpindleMotorSpeed, // cnc_rdspmeter type 1 (motor speed) bootstrap probe succeeded
bool ServoLoad, // cnc_rdsvmeter bootstrap probe returned data
bool ProgramInfo, // cnc_exeprgname2 + cnc_rdblkcount + cnc_rdopmode work
bool Timers); // cnc_rdtimer works for at least PowerOn
bool Timers, // cnc_rdtimer works for at least PowerOn
bool Status = false); // cnc_statinfo answered at bootstrap (#257)
internal sealed class DeviceState(FocasHostAddress parsedAddress, FocasDeviceOptions options)
{
@@ -1683,6 +1851,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// holding no value.
/// </summary>
public ConcurrentDictionary<string, double> LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// PMC ranges (<see cref="FocasPmcRangePlan.Key"/>) the CNC rejected as a merged read, so later polls skip
/// straight to per-tag reads instead of paying a doomed round-trip every cycle (#266).
/// </summary>
public ConcurrentDictionary<string, byte> CoalesceRejected { get; } = new(StringComparer.Ordinal);
/// <summary>Gets or sets the last <c>cnc_statinfo</c> snapshot (#257); null until the first answer.</summary>
public FocasStatus? LastStatus { get; set; }
/// <summary>Gets or sets the last program information snapshot.</summary>
public FocasProgramInfo? LastProgramInfo { get; set; }
/// <summary>Gets or sets the cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence.</summary>
@@ -115,6 +115,7 @@ public static class FocasDriverFactoryExtensions
FixedTree = BuildFixedTree(dto.FixedTree),
AlarmProjection = BuildAlarmProjection(dto.AlarmProjection),
HandleRecycle = BuildHandleRecycle(dto.HandleRecycle),
PmcCoalesce = BuildPmcCoalesce(dto.PmcCoalesce),
};
return new FocasDriverBinding(options, BuildClientFactory(dto, driverInstanceId, loggerFactory));
@@ -206,6 +207,19 @@ public static class FocasDriverFactoryExtensions
};
}
/// <summary>Map the optional <c>PmcCoalesce</c> section; a missing section or key keeps the default.</summary>
private static FocasPmcCoalesceOptions BuildPmcCoalesce(FocasPmcCoalesceDto? dto)
{
var defaults = new FocasPmcCoalesceOptions();
if (dto is null) return defaults;
return new FocasPmcCoalesceOptions
{
Enabled = dto.Enabled ?? defaults.Enabled,
MaxSpanBytes = dto.MaxSpanBytes is > 0 ? dto.MaxSpanBytes.Value : defaults.MaxSpanBytes,
MaxGapBytes = dto.MaxGapBytes is >= 0 ? dto.MaxGapBytes.Value : defaults.MaxGapBytes,
};
}
private static FocasCncSeries ParseSeries(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return FocasCncSeries.Unknown;
@@ -284,6 +298,9 @@ public static class FocasDriverFactoryExtensions
/// <summary>Gets or sets the handle recycle configuration options.</summary>
public FocasHandleRecycleDto? HandleRecycle { get; init; }
/// <summary>Gets or sets the PMC range-coalescing configuration options.</summary>
public FocasPmcCoalesceDto? PmcCoalesce { get; init; }
}
internal sealed class FocasDeviceDto
@@ -348,6 +365,17 @@ public static class FocasDriverFactoryExtensions
public TimeSpan? PollInterval { get; init; }
}
/// <summary>Optional <c>PmcCoalesce</c> config section (#266).</summary>
internal sealed class FocasPmcCoalesceDto
{
/// <summary>Gets or sets a value indicating whether coalescing is on.</summary>
public bool? Enabled { get; init; }
/// <summary>Gets or sets the largest coalesced range in bytes.</summary>
public int? MaxSpanBytes { get; init; }
/// <summary>Gets or sets the largest tolerated gap in bytes.</summary>
public int? MaxGapBytes { get; init; }
}
/// <summary>Optional <c>HandleRecycle</c> config section.</summary>
internal sealed class FocasHandleRecycleDto
{
@@ -0,0 +1,137 @@
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>One PMC read the coalescer may merge — the caller's batch index plus the parsed address and type.</summary>
/// <param name="Index">Position in the caller's batch; results are sliced back to it.</param>
/// <param name="Letter">PMC area letter (<c>R</c>, <c>X</c>, ...), upper-case.</param>
/// <param name="Number">Byte address.</param>
/// <param name="Type">The tag's declared data type (decides slot width and decode).</param>
/// <param name="BitIndex">Bit index for a <see cref="FocasDataType.Bit"/> tag.</param>
internal readonly record struct FocasPmcReadItem(int Index, string Letter, int Number, FocasDataType Type, int? BitIndex);
/// <summary>
/// One planned <c>pmc_rdpmcrng</c> read covering <see cref="Members"/>. <see cref="End"/> is inclusive and is a
/// whole number of <see cref="SlotWidth"/>-byte slots from <see cref="Start"/>.
/// </summary>
internal sealed record FocasPmcRangePlan(
string Letter,
FocasDataType SlotType,
int SlotWidth,
int Start,
int End,
IReadOnlyList<FocasPmcReadItem> Members)
{
/// <summary>Stable key for the "this range was rejected, read members individually" memo.</summary>
public string Key => $"{Letter}:{SlotType}:{Start}-{End}";
/// <summary>Slot index (into the decoded range) that holds <paramref name="item"/>'s value.</summary>
/// <param name="item">A member of this plan.</param>
/// <returns>The zero-based slot index.</returns>
public int SlotOf(FocasPmcReadItem item) => (item.Number - Start) / SlotWidth;
}
/// <summary>
/// Merges adjacent / near PMC reads in one poll batch into fewer <c>pmc_rdpmcrng</c> calls (Gitea #266).
/// </summary>
/// <remarks>
/// <para>Items are grouped by <c>(letter, slot width class)</c> and merged in address order while (a) the gap to
/// the previous member is at most <c>maxGapBytes</c> and (b) the whole range stays within <c>maxSpanBytes</c>.
/// A multi-byte member only joins a range when its offset from the range start is a multiple of its width — the
/// CNC splits a range into width-sized slots from the start, so an unaligned member would be decoded from the
/// wrong bytes. Members that cannot merge become single-member plans; the driver reads those through the
/// ordinary per-tag path, so the common "one tag per area" case is byte-for-byte unchanged.</para>
/// <para>Pure and deterministic: no I/O, no clock.</para>
/// </remarks>
internal static class FocasPmcCoalescer
{
/// <summary>Plans the reads for a batch.</summary>
/// <param name="items">The PMC reads in the batch (any order).</param>
/// <param name="maxSpanBytes">Largest range, in bytes, one coalesced read may cover (&gt;= 1).</param>
/// <param name="maxGapBytes">Largest run of unrequested bytes tolerated between two members (&gt;= 0).</param>
/// <returns>Range plans; every input item appears in exactly one plan.</returns>
public static IReadOnlyList<FocasPmcRangePlan> Plan(
IEnumerable<FocasPmcReadItem> items, int maxSpanBytes, int maxGapBytes)
{
maxSpanBytes = Math.Max(1, maxSpanBytes);
maxGapBytes = Math.Max(0, maxGapBytes);
var plans = new List<FocasPmcRangePlan>();
var groups = items
.GroupBy(i => (Letter: i.Letter.ToUpperInvariant(), Slot: SlotTypeOf(i.Type)))
.OrderBy(g => g.Key.Letter, StringComparer.Ordinal).ThenBy(g => g.Key.Slot);
foreach (var group in groups)
{
var width = FocasWireClient.PmcByteWidth((short)FocasPmcDataTypeLookup.FromFocasDataType(group.Key.Slot));
var sorted = group.OrderBy(i => i.Number).ThenBy(i => i.Index).ToList();
List<FocasPmcReadItem>? members = null;
var start = 0;
var end = 0; // inclusive
void Flush()
{
if (members is null) return;
// Round the range up to a whole number of slots so the CNC returns no partial trailing slot.
var bytes = end - start + 1;
var rounded = (bytes + width - 1) / width * width;
plans.Add(new FocasPmcRangePlan(group.Key.Letter, group.Key.Slot, width, start, start + rounded - 1, members));
members = null;
}
foreach (var item in sorted)
{
var itemEnd = item.Number + width - 1;
if (members is not null)
{
var gap = Math.Max(0, item.Number - (end + 1));
var aligned = (item.Number - start) % width == 0;
var newEnd = Math.Max(end, itemEnd);
// The span check is on the ROUNDED length a flush would produce.
var roundedSpan = (newEnd - start + 1 + width - 1) / width * width;
if (aligned && gap <= maxGapBytes && roundedSpan <= maxSpanBytes)
{
members.Add(item);
end = newEnd;
continue;
}
Flush();
}
members = [item];
start = item.Number;
end = itemEnd;
}
Flush();
}
return plans;
}
/// <summary>The data type whose wire width a read of <paramref name="type"/> uses (Bit and Byte share one).</summary>
/// <param name="type">The tag's declared type.</param>
/// <returns>The representative slot type.</returns>
public static FocasDataType SlotTypeOf(FocasDataType type) => type switch
{
FocasDataType.Bit or FocasDataType.Byte => FocasDataType.Byte,
_ => type,
};
/// <summary>
/// Decodes one raw PMC slot (as <c>pmc_rdpmcrng</c> parsed it) into the tag's published value. The single
/// decode authority for both the per-tag and the coalesced path.
/// </summary>
/// <param name="raw">The slot value.</param>
/// <param name="type">The tag's declared type.</param>
/// <param name="bitIndex">Bit index for a Bit tag.</param>
/// <returns>The boxed value.</returns>
public static object MapSlot(long raw, FocasDataType type, int? bitIndex) => type switch
{
FocasDataType.Bit => (object)(((raw >> (bitIndex ?? 0)) & 1L) != 0),
FocasDataType.Byte => (object)(sbyte)(raw & 0xFFL),
FocasDataType.Int16 => (object)(short)raw,
FocasDataType.Int32 => (object)(int)raw,
FocasDataType.Float32 => (object)BitConverter.Int32BitsToSingle((int)raw),
FocasDataType.Float64 => (object)BitConverter.Int64BitsToDouble(raw),
_ => (object)raw,
};
}
@@ -73,6 +73,24 @@ public interface IFocasClient : IDisposable
/// <returns>The list of currently active alarms; empty when none are active.</returns>
Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken);
/// <summary>
/// Read one PMC range (<c>pmc_rdpmcrng</c>) as raw slots: <c>(end - start + 1)</c> bytes split into
/// slots of <paramref name="slotType"/>'s width, from <paramref name="start"/>. The primitive behind PMC
/// range coalescing (Gitea #266) — the driver slices individual tag values out of the returned slots with
/// the same decode a per-tag read uses.
/// </summary>
/// <param name="letter">PMC area letter.</param>
/// <param name="slotType">Decides the slot width (Bit/Byte = 1, Int16 = 2, Int32/Float32 = 4, Float64 = 8).</param>
/// <param name="start">First byte address.</param>
/// <param name="end">Last byte address, inclusive; a whole number of slots from <paramref name="start"/>.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>
/// The raw slot values (null when not Good) and the mapped OPC UA status. A returned non-Good status is
/// always a CNC return-code refusal; a transport failure or timeout throws instead.
/// </returns>
Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(
string letter, FocasDataType slotType, int start, int end, CancellationToken cancellationToken);
// ---- Fixed-tree T1 (identity + axis discovery + fast-poll dynamic bundle) ----
/// <summary>
@@ -125,6 +143,17 @@ public interface IFocasClient : IDisposable
/// <returns>The aggregate program + operation-mode snapshot.</returns>
Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken);
// ---- Fixed-tree T2.5 (CNC status flags, Gitea #257) ----
/// <summary>
/// Read the CNC status flags via <c>cnc_statinfo</c> (<c>ODBST</c>). Populates the
/// <c>Status/*</c> subtree. Throws when the CNC refuses the call, which the driver's
/// bootstrap probe reads as "this series has no status block" and suppresses the subtree.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The CNC status flag snapshot.</returns>
Task<FocasStatus> GetStatusAsync(CancellationToken cancellationToken);
// ---- Fixed-tree T3 (timers) ----
/// <summary>
@@ -172,6 +201,38 @@ public interface IFocasClient : IDisposable
Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken);
}
/// <summary>
/// CNC status flags from <c>cnc_statinfo</c> (<c>ODBST</c>) — raw FANUC codes, published verbatim as
/// Int32 so a client sees exactly what the control reported. <see cref="Run"/> additionally has a
/// text projection (<see cref="FocasRunState.ToText"/>).
/// </summary>
/// <param name="Auto">Automatic-operation mode code.</param>
/// <param name="Run">Automatic-operation state (0 RESET, 1 STOP, 2 HOLD, 3 START, 4 MSTR).</param>
/// <param name="Motion">Axis motion (0 none, 1 motion, 2 dwell).</param>
/// <param name="Mstb">M/S/T/B function state (0 none, 1 executing).</param>
/// <param name="Emergency">Emergency stop (0 not, 1 emergency, 2 reset).</param>
/// <param name="Alarm">Alarm state (0 none, 1 alarm, 2 battery-low, 3 fan).</param>
/// <param name="Edit">Program-edit state.</param>
/// <param name="TmMode">T/M mode as reported by the control.</param>
public sealed record FocasStatus(int Auto, int Run, int Motion, int Mstb, int Emergency, int Alarm, int Edit, int TmMode);
/// <summary>Human-readable text for <see cref="FocasStatus.Run"/>.</summary>
public static class FocasRunState
{
/// <summary>Converts the <c>ODBST.run</c> code to its operator-facing label.</summary>
/// <param name="run">The run-state code.</param>
/// <returns>The label, or <c>Run{n}</c> for a code outside the documented set.</returns>
public static string ToText(int run) => run switch
{
0 => "RESET",
1 => "STOP",
2 => "HOLD",
3 => "START",
4 => "MSTR",
_ => $"Run{run}",
};
}
/// <summary>
/// One servo-meter entry — one axis's current load percentage; null when the CNC's record
/// described no usable value (never a fabricated 0).
@@ -61,6 +61,11 @@ public sealed class SynchronizedFocasClient : IFocasClient
FocasAddress address, FocasDataType type, CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.ReadAsync(address, type, ct), cancellationToken);
/// <inheritdoc />
public Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(
string letter, FocasDataType slotType, int start, int end, CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.ReadPmcSlotsAsync(letter, slotType, start, end, ct), cancellationToken);
/// <inheritdoc />
public Task<uint> WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken) =>
@@ -90,6 +95,10 @@ public sealed class SynchronizedFocasClient : IFocasClient
public Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetProgramInfoAsync(ct), cancellationToken);
/// <inheritdoc />
public Task<FocasStatus> GetStatusAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetStatusAsync(ct), cancellationToken);
/// <inheritdoc />
public Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetTimerAsync(kind, ct), cancellationToken);
@@ -67,6 +67,33 @@ public sealed class WireFocasClient : IFocasClient
};
}
/// <inheritdoc />
public async Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(
string letter, FocasDataType slotType, int start, int end, CancellationToken cancellationToken)
{
// Contract: a RETURNED status is always a CNC return code (a refusal); a transport failure THROWS.
// The coalescing caller needs that distinction — it falls back per tag on a refusal but must not
// re-hit a dead link (#266).
RequireConnected();
cancellationToken.ThrowIfCancellationRequested();
var area = FocasPmcAreaLookup.FromLetter(letter);
if (area is null) return (null, FocasStatusMapper.BadNodeIdUnknown);
var dataType = FocasPmcDataTypeLookup.FromFocasDataType(slotType);
try
{
var result = await _wire.ReadPmcRangeAsync(area.Value, dataType, (ushort)start, (ushort)end, cancellationToken)
.ConfigureAwait(false);
if (!result.IsOk || result.Value is null)
return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
return (result.Value.Values, FocasStatusMapper.Good);
}
catch (FocasWireException ex) when (ex.Rc is short rc)
{
return (null, FocasStatusMapper.MapFocasReturn(rc));
}
}
/// <inheritdoc />
public Task<uint> WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
@@ -129,6 +156,18 @@ public sealed class WireFocasClient : IFocasClient
AxesCount: axesCount);
}
/// <inheritdoc />
public async Task<FocasStatus> GetStatusAsync(CancellationToken cancellationToken)
{
RequireConnected();
var result = await _wire.ReadStatusAsync(cancellationToken).ConfigureAwait(false);
ThrowIfRcNonZero(result.Rc, "cnc_statinfo", result.IsOk);
return MapStatus(result.Value!);
}
internal static FocasStatus MapStatus(WireStatus s) =>
new(s.Auto, s.Run, s.Motion, s.Mstb, s.Emergency, s.Alarm, s.Edit, s.TmMode);
/// <inheritdoc />
public async Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken cancellationToken)
{
@@ -313,17 +352,8 @@ public sealed class WireFocasClient : IFocasClient
return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
var values = result.Value.Values;
if (values.Count == 0) return (null, FocasStatusMapper.BadOutOfRange);
var raw = values[0];
var mapped = type switch
{
FocasDataType.Bit => (object)(((long)raw >> (address.BitIndex ?? 0) & 1L) != 0),
FocasDataType.Byte => (object)(sbyte)(raw & 0xFFL),
FocasDataType.Int16 => (object)(short)raw,
FocasDataType.Int32 => (object)(int)raw,
FocasDataType.Float32 => (object)BitConverter.Int32BitsToSingle((int)raw),
FocasDataType.Float64 => (object)BitConverter.Int64BitsToDouble(raw),
_ => (object)raw,
};
// One decode authority shared with the coalesced path (FocasPmcCoalescer.MapSlot).
var mapped = FocasPmcCoalescer.MapSlot(values[0], type, address.BitIndex);
return (mapped, FocasStatusMapper.Good);
}
catch (FocasWireException ex)
@@ -50,18 +50,13 @@ public sealed partial class MTConnectDriver : IAlarmSource
/// <summary>
/// ConditionId (RawPath) → what the server was last told. Guarded by <see cref="_alarmLock"/>. Survives a
/// re-subscribe with the SAME reference set (a reconnect) and lifecycle restarts — the server's condition
/// nodes outlive both, and re-raising there would un-acknowledge. Cleared WHOLE when the subscribed set
/// changes: only an alarm-bearing raw-tag delta changes it, that delta is a full address-space rebuild,
/// and a rebuild recreates every condition node inactive — so everything must be told again.
/// nodes outlive both, and re-raising there would un-acknowledge. When the subscribed set changes (an
/// alarm-bearing raw-tag delta, i.e. a full rebuild) only the entries that LEFT the set are dropped: the
/// server carries every surviving condition's state across the rebuild (Gitea #641), so a survivor needs
/// no re-tell, while a condition that comes back later was materialised fresh and is told again.
/// </summary>
private readonly Dictionary<string, MTConnectToldCondition> _deliveredConditions = new(StringComparer.Ordinal);
/// <summary>
/// The reference set of the last <see cref="SubscribeAlarmsAsync"/>, kept across an unsubscribe so a
/// release-then-resubscribe of an identical set is still recognised. Guarded by <see cref="_alarmLock"/>.
/// </summary>
private HashSet<string>? _lastAlarmReferences;
/// <summary>The CONDITION tracker for the current session; replaced alongside the observation index.</summary>
private MTConnectConditionTracker _conditions = new();
@@ -108,16 +103,15 @@ public sealed partial class MTConnectDriver : IAlarmSource
lock (_alarmLock)
{
// A CHANGED set means an alarm-bearing delta, which the server applies as a full rebuild: every
// condition node — including ones still in the set — was recreated inactive and has been told
// nothing. Re-tell all of them. The SAME set is a reconnect: keep the record, or every still-active
// alarm would be re-raised and lose its acknowledgement.
if (_lastAlarmReferences is null || !_lastAlarmReferences.SetEquals(references))
// Forget only the conditions that LEFT the set (Gitea #641). A changed set is an alarm-bearing delta,
// which the server applies as a full rebuild — but the server carries every surviving condition's
// state (acknowledgement included) across that rebuild, so re-raising a survivor would only
// un-acknowledge it. A condition that left and later comes back was materialised fresh and has no
// record, so it is told again. The SAME set (a reconnect) prunes nothing.
foreach (var conditionId in _deliveredConditions.Keys.Where(k => !references.Contains(k)).ToList())
{
_deliveredConditions.Clear();
_deliveredConditions.Remove(conditionId);
}
_lastAlarmReferences = references;
}
EmitConditionTransitions(touchedDataItemIds: null, resync: true);
@@ -92,6 +92,29 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// ---- IAlarmSource state ----
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, RemoteAlarmSubscription> _alarmSubscriptions = new();
/// <summary>#619: RawPath → the upstream condition NodeId last seen for it. The host acknowledges by
/// RawPath (its only identity for the condition) but Part 9 Acknowledge must be called on the CONDITION
/// node, which differs from the authored source node.</summary>
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, AlarmAckTarget> _alarmConditionByRawPath =
new(StringComparer.Ordinal);
/// <summary>#619 review: the (Active, Acked) state last told to the host per reported condition id, from
/// which <see cref="AlarmTransitions"/> picks the Kind sequence that moves the host's projector to the
/// upstream event's state.</summary>
private readonly Dictionary<string, AlarmToldState> _alarmStateByRawPath = new(StringComparer.Ordinal);
/// <summary>#619 re-review: serialises the read-compute-emit over <see cref="_alarmStateByRawPath"/>. Event
/// notifications arrive on SDK publish threads (and, across a resubscribe, from two subscriptions at once), so
/// a lock-free prior/target exchange could emit two kind sequences computed from the same prior.</summary>
private readonly object _alarmStateLock = new();
/// <summary>#619 re-review: the ref set the last <see cref="SubscribeAlarmsAsync"/> carried. Survives
/// <see cref="ShutdownAsync"/> deliberately — see <see cref="BeginAlarmSubscription"/>.</summary>
private HashSet<string>? _lastAlarmRefSet;
/// <summary>Latched once the upstream server has refused ConditionRefresh as unsupported (logged once).</summary>
private int _conditionRefreshUnsupportedLogged;
private long _nextAlarmSubscriptionId;
/// <summary>Occurs when an alarm event is received from the remote OPC UA server.</summary>
@@ -879,6 +902,11 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
catch { /* best-effort */ }
}
_alarmSubscriptions.Clear();
// #619 re-review: the last-told alarm states (and ack targets) deliberately SURVIVE a shutdown. A
// ReinitializeAsync (comms-loss watchdog / Force Reconnect) is followed by the host re-subscribing the
// same set over condition nodes it did NOT rebuild; forgetting here would make the post-subscribe
// ConditionRefresh re-raise every active+acked alarm (an un-ack flicker). BeginAlarmSubscription forgets
// them when the set changes, which is when the host did rebuild.
await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false);
_namespaceMap = null;
@@ -1621,6 +1649,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
private const int AlarmFieldSeverity = 4;
private const int AlarmFieldTime = 5;
private const int AlarmFieldConditionId = 6;
private const int AlarmFieldActiveState = 7;
private const int AlarmFieldAckedState = 8;
private const int AlarmFieldBranchId = 9;
/// <inheritdoc />
public async Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
@@ -1635,11 +1666,24 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// Pre-resolve the source-node filter set so the per-event notification handler can
// match in O(1) without re-parsing on every event.
var sourceFilter = new HashSet<string>(sourceNodeIds, StringComparer.Ordinal);
// #619: under v3 the host passes RawPaths, never upstream node ids. Translate each to the upstream
// source id its tag authored (TagConfig.nodeId) so the per-event match compares like with like.
// Keys are server-stable (AlarmIndexKey) so a namespace reorder across an in-place SDK reconnect —
// which transfers this subscription without re-running SubscribeAlarmsAsync — cannot orphan them.
var sessionForResolve = Session;
var mapForResolve = _namespaceMap;
var sourceFilter = BuildAlarmSourceIndex(sourceNodeIds, raw =>
{
if (sessionForResolve is null) return null;
if (!TryResolveTagNode(sessionForResolve, raw, out var live)
&& !NamespaceMap.TryResolve(sessionForResolve, raw, out live)) return null;
return AlarmIndexKey(mapForResolve, live);
});
BeginAlarmSubscription(sourceNodeIds);
var subscription = new Subscription(telemetry: null!, new SubscriptionOptions
{
DisplayName = $"opcua-alarm-sub-{id}",
DisplayName = $"{AlarmSubscriptionNamePrefix}{id}",
PublishingInterval = 500, // 500ms — alarms don't need fast polling; the server pushes
KeepAliveCount = _options.SubscriptionKeepAliveCount,
LifetimeCount = _options.SubscriptionLifetimeCount,
@@ -1674,6 +1718,28 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
BrowsePath = [], // empty path = the condition node itself
AttributeId = Attributes.NodeId,
});
// #619 review: the host derives Active/Acked solely from AlarmEventArgs.Kind, so the condition state
// must be selected. TWO-element browse paths — a single "ActiveState/Id" QualifiedName names nothing.
filter.SelectClauses.Add(new SimpleAttributeOperand
{
TypeDefinitionId = ObjectTypeIds.AlarmConditionType,
BrowsePath = [new QualifiedName(BrowseNames.ActiveState), new QualifiedName(BrowseNames.Id)],
AttributeId = Attributes.Value,
});
filter.SelectClauses.Add(new SimpleAttributeOperand
{
TypeDefinitionId = ObjectTypeIds.AcknowledgeableConditionType,
BrowsePath = [new QualifiedName(BrowseNames.AckedState), new QualifiedName(BrowseNames.Id)],
AttributeId = Attributes.Value,
});
// #619 re-review: BranchId — a non-null one marks a condition BRANCH event (a previous state, with its own
// EventId). Only the trunk (null BranchId) is the condition's current state.
filter.SelectClauses.Add(new SimpleAttributeOperand
{
TypeDefinitionId = ObjectTypeIds.ConditionType,
BrowsePath = [new QualifiedName(BrowseNames.BranchId)],
AttributeId = Attributes.Value,
});
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
@@ -1710,6 +1776,11 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false);
_alarmSubscriptions[id] = new RemoteAlarmSubscription(subscription, handle, eventItem, notifHandler);
// #619: a new event item only reports FUTURE transitions — a condition already active upstream would
// stay invisible until it next changed. ConditionRefresh re-delivers every retained condition's
// current state through the normal path (deduplicated against the last-told state).
await TryConditionRefreshAsync(subscription, cancellationToken).ConfigureAwait(false);
}
finally { _gate.Release(); }
@@ -1765,15 +1836,30 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
var callRequests = new CallMethodRequestCollection();
foreach (var ack in acknowledgements)
{
// ConditionId is an upstream condition-node id captured from the alarm event (not a
// tag RawPath), so it re-binds directly against the session via the static resolve.
if (!NamespaceMap.TryResolve(session, ack.ConditionId, out var conditionId)) continue;
// #619: the host acks by RawPath; Acknowledge must hit the upstream CONDITION node.
// The remembered target pairs the condition node with the EventId of the event that named it,
// so the ack can never mix one condition's node with another's EventId. Nothing seen yet ⇒ the
// tag's authored node (correct when the tag was authored on the condition node), else the id
// verbatim (pre-v3 callers that pass an upstream condition id directly).
NodeId conditionId;
byte[] eventId;
if (_alarmConditionByRawPath.TryGetValue(ack.ConditionId, out var seen))
{
if (!NamespaceMap.TryResolve(session, seen.ConditionRef, out conditionId)) continue;
eventId = seen.EventId;
}
else
{
if (!TryResolveTagNode(session, ack.ConditionId, out conditionId)
&& !NamespaceMap.TryResolve(session, ack.ConditionId, out conditionId)) continue;
eventId = Array.Empty<byte>();
}
callRequests.Add(new CallMethodRequest
{
ObjectId = conditionId,
MethodId = MethodIds.AcknowledgeableConditionType_Acknowledge,
InputArguments = [
new Variant(Array.Empty<byte>()), // EventId — server-side best-effort; empty resolves to 'most recent'
new Variant(eventId), // Part 9 §5.7.3: the EventId being acknowledged (empty only when none seen)
new Variant(new LocalizedText(ack.Comment ?? string.Empty)),
],
});
@@ -1831,32 +1917,339 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
private void OnEventNotification(
OpcUaAlarmSubscriptionHandle handle,
HashSet<string> sourceFilter,
IReadOnlyDictionary<string, string> sourceFilter,
MonitoredItem item,
MonitoredItemNotificationEventArgs args)
{
if (args.NotificationValue is not EventFieldList efl) return;
if (efl.EventFields.Count <= AlarmFieldConditionId) return;
RouteAlarmEvent(efl.EventFields, sourceFilter, handle);
}
var sourceNode = efl.EventFields[AlarmFieldSourceNode].Value?.ToString() ?? string.Empty;
if (sourceFilter.Count > 0 && !sourceFilter.Contains(sourceNode)) return;
/// <summary>
/// #619 re-review: open (or re-open) the host's alarm subscription over <paramref name="sourceNodeIds"/>.
/// A CHANGED set means the host rebuilt its address space (an alarm-bearing delta always does), recreating
/// every condition node inactive+acked — so every last-told state is forgotten and the post-subscribe
/// ConditionRefresh re-reports from that default. The SAME set (a reinit / reconnect re-subscribe) keeps
/// them, so the refresh copy of an already-reported state emits nothing. Ack targets of refs no longer
/// subscribed are pruned either way.
/// </summary>
/// <param name="sourceNodeIds">The refs the host is subscribing.</param>
internal void BeginAlarmSubscription(IReadOnlyList<string> sourceNodeIds)
{
var set = new HashSet<string>(sourceNodeIds, StringComparer.Ordinal);
lock (_alarmStateLock)
{
if (_lastAlarmRefSet is null || !_lastAlarmRefSet.SetEquals(set)) _alarmStateByRawPath.Clear();
_lastAlarmRefSet = set;
foreach (var known in _alarmConditionByRawPath.Keys)
if (!set.Contains(known)) _alarmConditionByRawPath.TryRemove(known, out _);
}
}
var eventType = efl.EventFields[AlarmFieldEventType].Value?.ToString() ?? "BaseEventType";
var message = (efl.EventFields[AlarmFieldMessage].Value as LocalizedText)?.Text ?? string.Empty;
var severity = efl.EventFields[AlarmFieldSeverity].Value is ushort sev ? sev : (ushort)0;
var time = efl.EventFields[AlarmFieldTime].Value is DateTime t ? t : DateTime.UtcNow;
var conditionId = efl.EventFields[AlarmFieldConditionId].Value?.ToString() ?? string.Empty;
/// <summary>
/// #619 — route one upstream event: drop refresh markers (a RefreshRequired asks for a new refresh), project
/// it, and emit the kinds that move the host from the state last told to the event's. An event that changes
/// nothing the host shows (a ConditionRefresh copy) emits nothing.
/// </summary>
/// <param name="fields">The event's fields, in select-clause order.</param>
/// <param name="sourceFilter">The subscription's upstream-key → RawPath index.</param>
/// <param name="handle">The subscription handle to stamp.</param>
internal void RouteAlarmEvent(
IReadOnlyList<Variant> fields, IReadOnlyDictionary<string, string> sourceFilter, IAlarmSubscriptionHandle handle)
{
if (IsRefreshRequired(fields))
{
// Part 9 §5.11.7: the server lost events (queue overflow / restart) and asks for a refresh.
_ = Task.Run(() => RefreshAllAlarmSubscriptionsAsync(CancellationToken.None));
return;
}
OnAlarmEvent?.Invoke(this, new AlarmEventArgs(
// Projection, the per-RawPath bookkeeping AND the emission run under one lock, so two notifications for
// one condition (two publish threads; two subscriptions across a resubscribe) can never compute their
// kinds from the same prior, nor reach the host out of order. The host's handler only marshals.
lock (_alarmStateLock)
{
var e = ProjectEvent(fields, sourceFilter, handle,
n => AlarmIndexKey(_namespaceMap, n), (raw, target) => _alarmConditionByRawPath[raw] = target,
out var state);
if (e is null) return;
if (state is not { } target)
{
// Not a condition (no ActiveState/AckedState) — a message/severity-only event, as before.
OnAlarmEvent?.Invoke(this, e);
return;
}
// Fresh prior = the host projector's own default (inactive, acked).
var told = new AlarmToldState(target.Active, target.Acked, e.Message, e.OpcUaSeverity);
var prior = _alarmStateByRawPath.TryGetValue(e.ConditionId, out var p)
? p
: new AlarmToldState(false, true, null, null);
_alarmStateByRawPath[e.ConditionId] = told;
var kinds = AlarmTransitions((prior.Active, prior.Acked), target);
if (kinds.Count == 0)
{
// Same state: a message/severity change still reaches the host; an identical copy does not.
if (prior == told) return;
kinds = [AlarmTransitionKind.Unspecified];
}
foreach (var kind in kinds)
OnAlarmEvent?.Invoke(this, e with { Kind = kind });
}
}
/// <summary>#619 re-review — what the host was last told about one condition: its state plus the message and
/// exact severity, so a refresh copy is recognised as a duplicate.</summary>
/// <param name="Active">ActiveState/Id last told.</param>
/// <param name="Acked">AckedState/Id last told.</param>
/// <param name="Message">Message last told (null before any event).</param>
/// <param name="Severity">Exact severity last told (null before any event).</param>
private readonly record struct AlarmToldState(bool Active, bool Acked, string? Message, int? Severity);
/// <summary>True for a <c>RefreshRequiredEventType</c> event (the server asks the client to ConditionRefresh).</summary>
/// <param name="fields">The event's fields.</param>
/// <returns>Whether the event is a RefreshRequired marker.</returns>
internal static bool IsRefreshRequired(IReadOnlyList<Variant> fields) =>
fields.Count > AlarmFieldEventType && fields[AlarmFieldEventType].Value is NodeId t
&& t == ObjectTypeIds.RefreshRequiredEventType;
/// <summary>True for the three ConditionRefresh marker event types — never alarms.</summary>
/// <param name="eventType">The event's EventType.</param>
/// <returns>Whether it is a refresh marker.</returns>
private static bool IsRefreshMarker(object? eventType) =>
eventType is NodeId t
&& (t == ObjectTypeIds.RefreshStartEventType || t == ObjectTypeIds.RefreshEndEventType
|| t == ObjectTypeIds.RefreshRequiredEventType);
/// <summary>A ConditionRefresh refusal meaning "this server does not do refresh" (tolerated, logged once).</summary>
/// <param name="code">The refusal status.</param>
/// <returns>Whether the server simply lacks ConditionRefresh.</returns>
internal static bool IsConditionRefreshUnsupported(StatusCode code) =>
code == StatusCodes.BadNotSupported || code == StatusCodes.BadMethodInvalid
|| code == StatusCodes.BadNotImplemented || code == StatusCodes.BadServiceUnsupported
|| code == StatusCodes.BadNodeIdUnknown;
/// <summary>
/// #619 — call ConditionRefresh for <paramref name="subscription"/> so conditions already active upstream
/// are reported. Never throws: an unsupported server is logged once, any other failure warns.
/// </summary>
/// <param name="subscription">The (created) alarm subscription.</param>
/// <param name="cancellationToken">Cancellation.</param>
/// <returns>A task completing when the refresh call returns.</returns>
private async Task TryConditionRefreshAsync(Subscription subscription, CancellationToken cancellationToken)
{
try
{
await subscription.ConditionRefreshAsync(cancellationToken).ConfigureAwait(false);
}
catch (ServiceResultException ex) when (IsConditionRefreshUnsupported(ex.StatusCode))
{
if (Interlocked.Exchange(ref _conditionRefreshUnsupportedLogged, 1) == 0)
_logger.LogInformation(
"OpcUaClientDriver '{DriverInstanceId}': upstream server does not support ConditionRefresh " +
"({StatusCode}); conditions already active are reported only on their next transition.",
_driverInstanceId, ex.StatusCode);
}
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
_logger.LogWarning(ex,
"OpcUaClientDriver '{DriverInstanceId}': ConditionRefresh on alarm subscription {SubscriptionId} failed; " +
"conditions already active upstream may not be reported until their next transition.",
_driverInstanceId, subscription.Id);
}
catch (OperationCanceledException)
{
// The caller cancelled; the subscription itself is in place.
}
}
/// <summary>
/// #619 — ConditionRefresh every alarm subscription on the current session: after an in-place reconnect
/// (transferred or SDK-recreated subscriptions — matched by display name, since a recreate clones the
/// <see cref="Subscription"/> objects) and on a RefreshRequired event. Events missed during the outage
/// reach the host through the normal deduplicated path.
/// </summary>
/// <param name="cancellationToken">Cancellation.</param>
/// <returns>A task completing when every refresh call has returned.</returns>
private async Task RefreshAllAlarmSubscriptionsAsync(CancellationToken cancellationToken)
{
if (_alarmSubscriptions.IsEmpty) return;
try
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (ObjectDisposedException) { return; }
try
{
var session = Session;
if (session is null) return;
foreach (var sub in session.Subscriptions.ToList())
{
if (sub.DisplayName?.StartsWith(AlarmSubscriptionNamePrefix, StringComparison.Ordinal) == true && sub.Created)
await TryConditionRefreshAsync(sub, cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "OpcUaClientDriver '{DriverInstanceId}': alarm ConditionRefresh sweep failed.",
_driverInstanceId);
}
finally
{
try { _gate.Release(); } catch (ObjectDisposedException) { }
}
}
/// <summary>Display-name prefix of every alarm subscription (how a refresh sweep finds them on a session).</summary>
private const string AlarmSubscriptionNamePrefix = "opcua-alarm-sub-";
/// <summary>
/// #619 review — the <see cref="AlarmTransitionKind"/> sequence that moves the host's
/// <c>NativeAlarmProjector</c> (which knows only kinds: Raise ⇒ active+unacked, Acknowledge ⇒ acked,
/// Clear ⇒ inactive keeping acked) from <paramref name="prior"/> to <paramref name="target"/>. A single
/// upstream event can need two (an alarm that went active and was acknowledged between two notifications).
/// No state change ⇒ empty (the caller decides whether a message/severity change warrants an Unspecified).
/// </summary>
/// <param name="prior">The state last told to the host.</param>
/// <param name="target">The upstream event's ActiveState/Id + AckedState/Id.</param>
/// <returns>The kinds to emit, in order.</returns>
internal static IReadOnlyList<AlarmTransitionKind> AlarmTransitions(
(bool Active, bool Acked) prior, (bool Active, bool Acked) target)
{
if (prior == target) return [];
return target switch
{
// Active+unacked: a fresh raise, or a re-arm of an active-acked condition.
(true, false) => [prior.Active ? AlarmTransitionKind.Retrigger : AlarmTransitionKind.Raise],
// Active+acked: raise first when it was inactive (Raise always lands unacked).
(true, true) => prior.Active
? [AlarmTransitionKind.Acknowledge]
: [AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge],
// Inactive+acked: Clear keeps the prior ack, so ack after it when that was unacked.
(false, true) => prior.Active
? prior.Acked ? [AlarmTransitionKind.Clear] : [AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge]
: [AlarmTransitionKind.Acknowledge],
// Inactive+UNacked (went active and cleared unacknowledged — e.g. first seen by a ConditionRefresh):
// the only kinds that un-ack are Raise/Retrigger, so pass through active, then Clear.
(false, false) => prior.Active
? prior.Acked ? [AlarmTransitionKind.Retrigger, AlarmTransitionKind.Clear] : [AlarmTransitionKind.Clear]
: [AlarmTransitionKind.Raise, AlarmTransitionKind.Clear],
};
}
/// <summary>
/// #619 — index an alarm subscription's source refs by UPSTREAM source id. The host (v3) subscribes with
/// the alarm tags' <b>RawPaths</b>; an upstream event carries its own source NodeId, so the match key is
/// the upstream id the tag authored and the value is the RawPath to report back. A ref
/// <paramref name="resolveUpstream"/> cannot resolve is kept verbatim (direct upstream-id use).
/// </summary>
/// <param name="refs">The refs handed to <c>SubscribeAlarmsAsync</c>.</param>
/// <param name="resolveUpstream">RawPath → serialized live upstream NodeId, or null when unknown.</param>
/// <returns>Upstream source id → ref to report.</returns>
internal static Dictionary<string, string> BuildAlarmSourceIndex(
IReadOnlyList<string> refs, Func<string, string?> resolveUpstream)
{
var index = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var r in refs)
index[resolveUpstream(r) ?? r] = r;
return index;
}
/// <summary>
/// #619 — project one upstream event into an <see cref="AlarmEventArgs"/>. <c>ConditionId</c> and
/// <c>SourceNodeId</c> are the authored RawPath, because <c>DriverHostActor</c> routes native alarms on
/// (driver, RawPath); the upstream condition node (needed to acknowledge) is handed to
/// <paramref name="rememberCondition"/>. Null when the event's source is not an authored one.
/// </summary>
internal static AlarmEventArgs? ProjectEvent(
IReadOnlyList<Variant> fields,
IReadOnlyDictionary<string, string> sourceIndex,
IAlarmSubscriptionHandle handle,
Func<NodeId, string> keyOf,
Action<string, AlarmAckTarget> rememberCondition) =>
ProjectEvent(fields, sourceIndex, handle, keyOf, rememberCondition, out _);
/// <summary>As the five-argument overload, also returning the event's (ActiveState/Id, AckedState/Id) through
/// <c>state</c> when it is an alarm condition (null otherwise).</summary>
internal static AlarmEventArgs? ProjectEvent(
IReadOnlyList<Variant> fields,
IReadOnlyDictionary<string, string> sourceIndex,
IAlarmSubscriptionHandle handle,
Func<NodeId, string> keyOf,
Action<string, AlarmAckTarget> rememberCondition,
out (bool Active, bool Acked)? state)
{
state = null;
if (fields.Count <= AlarmFieldConditionId) return null;
// ConditionRefresh brackets its re-delivery with RefreshStart/RefreshEnd — markers, never alarms.
if (IsRefreshMarker(fields[AlarmFieldEventType].Value)) return null;
// A condition BRANCH event reports a previous state with its own EventId: projecting it would flip the
// trunk's state, remembering it would make Acknowledge ack the branch.
if (fields.Count > AlarmFieldBranchId && fields[AlarmFieldBranchId].Value is NodeId branch && !NodeId.IsNull(branch))
return null;
if (fields.Count > AlarmFieldAckedState
&& fields[AlarmFieldActiveState].Value is bool active
&& fields[AlarmFieldAckedState].Value is bool acked)
state = (active, acked);
// Pass-through (no authored refs) keeps the raw serialized ids; an authored index compares
// server-stable keys (see AlarmIndexKey), matching the CONDITION node first — the only way to tell
// several conditions on one source apart — and then the source node.
var sourceField = fields[AlarmFieldSourceNode].Value;
var conditionField = fields[AlarmFieldConditionId].Value;
string reported, upstreamCondition;
if (sourceIndex.Count == 0)
{
reported = sourceField?.ToString() ?? string.Empty;
upstreamCondition = conditionField?.ToString() ?? string.Empty;
}
else
{
upstreamCondition = conditionField is NodeId cn && !NodeId.IsNull(cn) ? keyOf(cn) : string.Empty;
var sourceKey = sourceField is NodeId sn && !NodeId.IsNull(sn) ? keyOf(sn) : string.Empty;
if (!(upstreamCondition.Length > 0 && sourceIndex.TryGetValue(upstreamCondition, out reported!))
&& !(sourceKey.Length > 0 && sourceIndex.TryGetValue(sourceKey, out reported!)))
return null;
}
var eventType = fields[AlarmFieldEventType].Value?.ToString() ?? "BaseEventType";
var message = (fields[AlarmFieldMessage].Value as LocalizedText)?.Text ?? string.Empty;
var severity = fields[AlarmFieldSeverity].Value is ushort sev ? sev : (ushort)0;
var time = fields[AlarmFieldTime].Value is DateTime t ? t : DateTime.UtcNow;
if (sourceIndex.Count != 0 && upstreamCondition.Length > 0)
rememberCondition(reported, new AlarmAckTarget(
upstreamCondition,
// A copy: the decoded notification's array is not ours to alias for the ack's lifetime.
(fields[AlarmFieldEventId].Value as byte[])?.ToArray() ?? Array.Empty<byte>()));
return new AlarmEventArgs(
SubscriptionHandle: handle,
SourceNodeId: sourceNode,
ConditionId: conditionId,
SourceNodeId: reported,
ConditionId: sourceIndex.Count == 0 ? upstreamCondition : reported,
AlarmType: eventType,
Message: message,
Severity: MapSeverity(severity),
SourceTimestampUtc: time));
SourceTimestampUtc: time,
// #626: the upstream severity is already the exact Part 9 value — carry it, don't re-bucket.
OpcUaSeverity: severity == 0 ? null : severity);
}
/// <summary>#619 — what Acknowledge needs for one RawPath: the upstream condition node (server-stable
/// reference) and the EventId of the event that named it.</summary>
/// <param name="ConditionRef">Upstream condition NodeId, rendered by <see cref="AlarmIndexKey"/>.</param>
/// <param name="EventId">EventId of the most recent event seen for that condition.</param>
internal readonly record struct AlarmAckTarget(string ConditionRef, byte[] EventId);
/// <summary>#619 — the key both sides of the alarm index use: server-stable (<c>nsu=</c>) when a map is
/// available, so a remote namespace-table reorder across an in-place reconnect cannot orphan it.</summary>
/// <param name="map">The current session's namespace map, or null.</param>
/// <param name="nodeId">The NodeId to key.</param>
/// <returns>The index key.</returns>
internal static string AlarmIndexKey(NamespaceMap? map, NodeId nodeId) =>
map?.ToStableReference(nodeId) ?? nodeId.ToString() ?? string.Empty;
/// <summary>
/// Map an OPC UA <c>BaseEventType.Severity</c> (1..1000) to our coarse-grained
/// <see cref="AlarmSeverity"/> bucket. Thresholds match the OPC UA A&amp;C Part 9
@@ -2364,6 +2757,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
TransitionTo(HostState.Running);
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
_diag.Record(OpcUaClientDiagnostics.ReconnectSuccess);
// #619: transitions during the outage were missed (a recreated subscription starts empty; a transferred
// one may have overflowed). ConditionRefresh re-reports current states; unchanged ones dedup to nothing.
if (!_alarmSubscriptions.IsEmpty)
_ = Task.Run(() => RefreshAllAlarmSubscriptionsAsync(CancellationToken.None));
return;
}
@@ -32,7 +32,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// </para>
/// </remarks>
public sealed class S7Driver
: IDriver, ITagDiscovery, IReadable, IWritable, IBatchSubscribable, IHostConnectivityProbe, ISkippedTagReporter, IDisposable, IAsyncDisposable
: IDriver, ITagDiscovery, IReadable, IWritable, IBatchSubscribable, IHostConnectivityProbe, ISkippedTagReporter, IDriverDiagnostics, IDisposable, IAsyncDisposable
{
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541).</summary>
private readonly SkippedTagLedger _skippedTags = new();
@@ -463,6 +463,19 @@ public sealed class S7Driver
["S7.NegotiatedPduSize"] = NegotiatedPduSize,
};
/// <summary>
/// <see cref="IDriverDiagnostics"/> view of the same facts, as strings, for the <c>/hosts</c> page
/// (Gitea #640). Empty before the first connect (PDU size 0 is "not negotiated yet", not a size).
/// </summary>
/// <returns>The diagnostics bag.</returns>
IReadOnlyDictionary<string, string> IDriverDiagnostics.GetDiagnostics()
{
var pdu = NegotiatedPduSize;
return pdu > 0
? new Dictionary<string, string> { ["S7.NegotiatedPduSize"] = pdu.ToString(System.Globalization.CultureInfo.InvariantCulture) }
: new Dictionary<string, string>();
}
private static readonly HashSet<S7DataType> ByteAnchoredDataTypes = new()
{
S7DataType.Int64,
@@ -1555,14 +1568,21 @@ public sealed class S7Driver
// ReadScalarBlockAsync read, so WriteBytesAsync writes the whole reserved STRING. A value
// longer than StringLength throws ArgumentException (S7.Net rejects overflow; we do NOT
// silently truncate). A null value encodes as the empty string.
return global::S7.Net.Types.S7String.ToByteArray(Convert.ToString(value) ?? "", tag.StringLength);
// #632: surfaced as S7ValueRangeException (→ BadOutOfRange) like the WSTRING codec — a
// client value error, not a comms fault (the raw ArgumentException used to land in the
// generic catch as BadCommunicationError + Degraded).
try { return global::S7.Net.Types.S7String.ToByteArray(Convert.ToString(value) ?? "", tag.StringLength); }
catch (ArgumentException ex) { throw new S7ValueRangeException($"S7 STRING value does not fit the declared {tag.StringLength}-character field: {ex.Message}"); }
case S7DataType.DateTime:
// S7.Net's DateTime.ToByteArray builds the 8-byte DT BCD block. It validates the
// 1990–2089 DT range and throws ArgumentOutOfRangeException for an out-of-range year —
// we surface that (no silent clamp). Value coerced via Convert.ToDateTime (accepts a
// System.DateTime or a parseable timestamp string).
return global::S7.Net.Types.DateTime.ToByteArray(Convert.ToDateTime(value));
// #632: the range refusal maps to BadOutOfRange (see STRING above).
var dt = Convert.ToDateTime(value);
try { return global::S7.Net.Types.DateTime.ToByteArray(dt); }
catch (ArgumentOutOfRangeException ex) { throw new S7ValueRangeException($"S7 DATE_AND_TIME value {dt:O} is outside the DT range (1990–2089): {ex.Message}"); }
// Writes refuse (S7ValueRangeException → BadOutOfRange) rather than truncate or clamp.
case S7DataType.WString: return S7ValueCodecs.EncodeWString(value, tag.StringLength);
@@ -57,7 +57,8 @@
<tr>
<td><code>@Short(d.DeploymentId)</code></td>
<td><code>@DisplayText.Abbreviate(d.RevisionHash, 12)</code></td>
<td>@d.Status</td>
@* #601: Sealed + a reason = sealed with a degraded address-space apply on some node(s). *@
<td title="@(d.FailureReason ?? "")">@(d.Status == DeploymentStatus.Sealed && !string.IsNullOrEmpty(d.FailureReason) ? "Sealed (degraded)" : d.Status.ToString())</td>
<td>@d.CreatedBy</td>
<td>@d.CreatedAtUtc.ToString("u")</td>
<td>@(d.SealedAtUtc?.ToString("u") ?? "—")</td>
@@ -77,7 +78,8 @@
DeploymentStatus Status,
string CreatedBy,
DateTime CreatedAtUtc,
DateTime? SealedAtUtc);
DateTime? SealedAtUtc,
string? FailureReason);
private IReadOnlyList<DeploymentRow> _deployments = Array.Empty<DeploymentRow>();
private bool _busy;
@@ -102,7 +104,8 @@
d.Status,
d.CreatedBy,
d.CreatedAtUtc,
d.SealedAtUtc))
d.SealedAtUtc,
d.FailureReason))
.Take(50)
.ToListAsync();
@@ -88,7 +88,7 @@ else
<span class="chip chip-idle me-1">@role</span>
}
</td>
<td><span class="chip @StatusChipClass(r.Status)">@StatusLabel(r.Status)</span></td>
<td><span class="chip @StatusChipClass(r.Status, r.FailureReason)">@StatusLabel(r.Status, r.FailureReason)</span></td>
<td>@(r.AppliedAtUtc?.ToString("u") ?? "—")</td>
<td>@(r.StartedAtUtc?.ToString("u") ?? "—")</td>
<td title="@(r.FailureReason ?? "")">@(string.IsNullOrEmpty(r.FailureReason) ? "" : DisplayText.Abbreviate(r.FailureReason, 80))</td>
@@ -184,15 +184,22 @@ else
}
}
private static string StatusChipClass(NodeDeploymentStatus? status) => status switch
{
NodeDeploymentStatus.Applied => "chip-ok",
NodeDeploymentStatus.Applying => "chip-caution",
NodeDeploymentStatus.Failed => "chip-alert",
_ => "chip-idle",
};
// #601 — an Applied row carrying a reason is a DEGRADED apply (the node runs the revision but its address
// space may be partial); it must not render as the same green chip as a clean apply.
private static bool IsDegraded(NodeDeploymentStatus? status, string? failureReason) =>
status is { } s && ZB.MOM.WW.OtOpcUa.ControlPlane.Coordinators.ConfigPublishCoordinator.IsDegraded(s, failureReason);
private static string StatusLabel(NodeDeploymentStatus? status) => status?.ToString() ?? "waiting";
private static string StatusChipClass(NodeDeploymentStatus? status, string? failureReason) =>
IsDegraded(status, failureReason) ? "chip-caution" : status switch
{
NodeDeploymentStatus.Applied => "chip-ok",
NodeDeploymentStatus.Applying => "chip-caution",
NodeDeploymentStatus.Failed => "chip-alert",
_ => "chip-idle",
};
private static string StatusLabel(NodeDeploymentStatus? status, string? failureReason) =>
IsDegraded(status, failureReason) ? "Applied (degraded)" : status?.ToString() ?? "waiting";
public async ValueTask DisposeAsync()
{
@@ -189,6 +189,17 @@ else
@* Own line — inline after the name the two identifiers ran together. *@
<div class="mono small text-muted">@d.DriverInstanceId</div>
}
@if (d.DiagnosticItems.Count > 0)
{
@* Driver-reported diagnostics (Gitea #640), e.g. S7 negotiated PDU size.
Renders nothing for a driver with none. *@
<div class="small text-muted" data-testid="driver-diagnostics">
@foreach (var kv in d.DiagnosticItems)
{
<div class="mono">@kv.Key: @kv.Value</div>
}
</div>
}
</td>
<td>@(d.DriverType ?? "—")</td>
<td><span class="chip @DriverChipClass(d.State)">@d.State</span></td>
@@ -9,6 +9,7 @@
@using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Commons.Types
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject IRawTreeService Svc
@@ -90,6 +91,9 @@
<div class="form-text">Schemaless per driver type. Validated server-side at deploy.</div>
}
</div>
<NativeAlarmEditor ConfigJson="@_form.TagConfig"
ConfigJsonChanged="@(v => _form.TagConfig = v)"
DriverType="@DriverType" />
}
@if (!string.IsNullOrWhiteSpace(_error))
@@ -0,0 +1,104 @@
@* Shared native-alarm section (Gitea #642): authors the driver-agnostic TagConfig `alarm` object
(alarmType / severity / historizeToAveva) for a raw tag. A thin shell over NativeAlarmModel — all rules
live there. It sits OUTSIDE the per-driver typed editor (Galaxy has none) and writes the same ConfigJson.
Order-safety: every change goes through NativeAlarmModel.Edit(ConfigJson, ...), which re-parses the
LATEST json, so it can never clobber a key the driver editor just wrote; the driver models in turn
preserve the `alarm` key (pinned by NativeAlarmEditorModelTests).
Plain input/select + @onchange (no InputNumber/EditForm binding — see the AdminUI binding pitfalls). *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@if (_m.IsAlarm || _supported)
{
<div class="mb-3 border rounded p-2" data-testid="native-alarm-editor">
@if (!_supported)
{
@* Never hide an authored alarm silently: show it and offer removal rather than dropping it. *@
<div class="text-warning small">
This tag carries an <span class="mono">alarm</span> object, but driver type
<span class="mono">@DriverType</span> does not raise native alarms, so it has no effect.
</div>
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" id="native-alarm-remove"
@onclick="@(() => Update(m => m.IsAlarm = false))">Remove alarm object</button>
}
else
{
<div class="form-check form-switch">
<input id="native-alarm-enabled" type="checkbox" class="form-check-input"
checked="@_m.IsAlarm"
@onchange="@(e => Update(m => m.IsAlarm = e.Value is true))" />
<label class="form-check-label" for="native-alarm-enabled">Native alarm (Part 9 condition)</label>
</div>
@if (_m.IsAlarm)
{
<div class="row g-2 mt-1">
<div class="col-md-4">
<label class="form-label" for="native-alarm-type">Alarm type</label>
<select id="native-alarm-type" class="form-select form-select-sm" value="@_m.AlarmType"
@onchange="@(e => Update(m => m.AlarmType = e.Value?.ToString() ?? "AlarmCondition"))">
@foreach (var t in NativeAlarmModel.KnownAlarmTypes) { <option value="@t">@t</option> }
@if (!NativeAlarmModel.KnownAlarmTypes.Contains(_m.AlarmType))
{
<option value="@_m.AlarmType">@_m.AlarmType (custom)</option>
}
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="native-alarm-severity">Severity (1..1000)</label>
<input id="native-alarm-severity" type="number" min="1" max="1000" class="form-control form-control-sm"
value="@_m.Severity"
@onchange="@(e => Update(m => m.Severity = int.TryParse(e.Value?.ToString(), out var s) ? s : 500))" />
</div>
<div class="col-md-4">
<label class="form-label" for="native-alarm-historize">Historize to AVEVA</label>
<select id="native-alarm-historize" class="form-select form-select-sm"
value="@HistorizeValue(_m.HistorizeToAveva)"
@onchange="@(e => Update(m => m.HistorizeToAveva = ParseHistorize(e.Value?.ToString())))">
<option value="default">Default (yes)</option>
<option value="yes">Yes</option>
<option value="no">No (live alerts only)</option>
</select>
</div>
</div>
@foreach (var w in _m.Warnings())
{
<div class="text-warning small mt-1">@w</div>
}
}
}
</div>
}
@code {
/// <summary>The tag's raw TagConfig JSON (supports <c>@bind-ConfigJson</c>).</summary>
[Parameter] public string? ConfigJson { get; set; }
/// <summary>Two-way config callback.</summary>
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
/// <summary>Driver type of the owning device; gates whether the editor is offered.</summary>
[Parameter] public string? DriverType { get; set; }
private NativeAlarmModel _m = new();
private bool _supported;
private string? _lastConfigJson;
protected override void OnParametersSet()
{
_supported = NativeAlarmModel.SupportsNativeAlarms(DriverType);
if (ConfigJson == _lastConfigJson) { return; }
_lastConfigJson = ConfigJson;
_m = NativeAlarmModel.FromJson(ConfigJson);
}
private static string HistorizeValue(bool? v) => v is null ? "default" : v.Value ? "yes" : "no";
private static bool? ParseHistorize(string? v) => v switch { "yes" => true, "no" => false, _ => null };
private async Task Update(Action<NativeAlarmModel> apply)
{
var json = NativeAlarmModel.Edit(ConfigJson, apply);
_lastConfigJson = json;
_m = NativeAlarmModel.FromJson(json);
await ConfigJsonChanged.InvokeAsync(json);
}
}
@@ -52,6 +52,8 @@ public sealed record HostsDriverInstanceInfo(
/// is no answer — the driver cannot report skips, or it faulted before building a tag table. Lets
/// "ignoring 20 of 22 tags" show even while every other column on the row — state, last read, error
/// count, last error — reads perfectly healthy (Gitea #541).</param>
/// <param name="Diagnostics">Driver-reported diagnostics (e.g. S7 negotiated PDU size); null or empty when
/// the driver has none to show (Gitea #640).</param>
/// <param name="Enabled">Whether the matching configured instance is authored enabled; <see langword="true"/>
/// when no instance matched. Fed to the row's expandable <c>DriverStatusPanel</c> (Gitea #546). An
/// unmatched row defaults to enabled on purpose: the row only exists because a live snapshot arrived,
@@ -62,8 +64,16 @@ public sealed record HostsDriverRow(
DateTime? RediscoveryNeededUtc = null, string? RediscoveryReason = null,
IReadOnlyList<HostConnectivityStatus>? HostStatuses = null,
SkippedTagReport? SkippedTags = null,
bool Enabled = true)
bool Enabled = true,
IReadOnlyDictionary<string, string>? Diagnostics = null)
{
/// <summary>Diagnostics as ordered key/value pairs for rendering; empty when there are none, in which
/// case the page renders nothing.</summary>
public IReadOnlyList<KeyValuePair<string, string>> DiagnosticItems { get; } =
(Diagnostics ?? new Dictionary<string, string>())
.OrderBy(k => k.Key, StringComparer.Ordinal)
.ToList();
/// <summary>Whether this driver is ignoring at least one authored tag — the condition the whole
/// column exists to make visible.</summary>
public bool HasSkippedTags => SkippedTags is { SkippedCount: > 0 };
@@ -177,7 +187,8 @@ public static class HostsDriverView
s.RediscoveryReason,
s.HostStatuses,
s.SkippedTags,
instance?.Enabled ?? true);
instance?.Enabled ?? true,
s.Diagnostics);
})
.OrderBy(d => d.Name ?? d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
.ThenBy(d => d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
@@ -90,14 +90,72 @@ public sealed class NativeAlarmModel
// Re-attach (no-op when it was already the same node; required when we created a fresh one).
_root["alarm"] = alarm;
}
else
{
// Toggled off (#642): the alarm object must go, or a "disabled" tag would still deploy as a condition.
_root.Remove("alarm");
}
return TagConfigJson.Serialize(_root);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or null when the model is valid.</returns>
/// <remarks>Deliberately never errors: the deploy gate has no <c>alarm</c> rule and
/// <c>TagConfigIntent.ParseAlarm</c> accepts any alarmType string and any severity (clamped 1..1000 at
/// publish), so rejecting here would be stricter than the gate. Out-of-range input is advisory —
/// see <see cref="Warnings"/>.</remarks>
public string? Validate() => null;
/// <summary>Advisory messages for values the server will accept but silently normalise.</summary>
/// <returns>Zero or more warning strings.</returns>
public IEnumerable<string> Warnings()
{
if (!IsAlarm) { yield break; }
if (Severity is < 1 or > 1000)
{
yield return $"Severity {Severity} is outside 1..1000; the server clamps it.";
}
if (!KnownAlarmTypes.Contains(AlarmType))
{
yield return $"Alarm type '{AlarmType}' is not one of {string.Join("/", KnownAlarmTypes)}; the server treats it as AlarmCondition.";
}
}
/// <summary>The Part 9 condition subtypes the server distinguishes (OffNormal/Discrete; the rest map to the base condition).</summary>
public static readonly IReadOnlyList<string> KnownAlarmTypes =
["AlarmCondition", "OffNormalAlarm", "DiscreteAlarm", "LimitAlarm"];
/// <summary>Driver types that implement <c>IAlarmSource</c> (so a TagConfig <c>alarm</c> object is
/// meaningful). The alarm object itself is driver-agnostic; this only gates the editor's visibility.</summary>
private static readonly HashSet<string> AlarmDriverTypes = new(StringComparer.OrdinalIgnoreCase)
{
Core.Abstractions.DriverTypeNames.MTConnect, Core.Abstractions.DriverTypeNames.AbCip,
Core.Abstractions.DriverTypeNames.FOCAS, Core.Abstractions.DriverTypeNames.OpcUaClient,
Core.Abstractions.DriverTypeNames.Galaxy,
};
/// <summary>True when the driver type can emit native alarms.</summary>
/// <param name="driverType">Driver type name.</param>
/// <returns>Whether the native-alarm editor applies.</returns>
public static bool SupportsNativeAlarms(string? driverType)
=> driverType is not null && AlarmDriverTypes.Contains(driverType);
/// <summary>
/// Order-safe edit: re-parses the LATEST <paramref name="json"/> (so a keystroke in the per-driver
/// editor is never overwritten by a stale cached copy), applies <paramref name="mutate"/>, serialises.
/// Only the <c>alarm</c> key is touched; every other key is preserved verbatim.
/// </summary>
/// <param name="json">Current TagConfig JSON.</param>
/// <param name="mutate">Edit to apply to the freshly-parsed model.</param>
/// <returns>The new TagConfig JSON.</returns>
public static string Edit(string? json, Action<NativeAlarmModel> mutate)
{
var m = FromJson(json);
mutate(m);
return m.ToJson();
}
/// <summary>Default <c>alarmType</c> seeded when an alarm-bearing attribute is picked but the
/// TagConfig has no <c>alarm</c> object yet. Galaxy's <c>AlarmExtension</c> is a boolean
/// off-normal condition, so <c>OffNormalAlarm</c> is the natural Part 9 subtype.</summary>
@@ -57,7 +57,9 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
{
public const string DeploymentsTopic = "deployments";
public const string DeploymentAcksTopic = "deployment-acks";
public static readonly TimeSpan DefaultApplyDeadline = TimeSpan.FromMinutes(2);
/// <summary>Central's apply deadline — the shared <see cref="DeployTimings.ApplyDeadline"/>, which the node's
/// apply-outcome timeout is derived from (Gitea #601).</summary>
public static readonly TimeSpan DefaultApplyDeadline = DeployTimings.ApplyDeadline;
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
private readonly TimeSpan _applyDeadline;
@@ -83,6 +85,30 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
private static readonly IEqualityComparer<NodeId> NodeIdComparer = new CaseInsensitiveNodeIdComparer();
private readonly Dictionary<NodeId, ApplyAckOutcome> _acks = new(NodeIdComparer);
/// <summary>Gitea #601 — the expected nodes whose Applied ack was DEGRADED (address-space apply partial).
/// Recovered from the DB on failover via <see cref="IsDegraded"/>.</summary>
private readonly HashSet<NodeId> _degraded = new(NodeIdComparer);
/// <summary>
/// Gitea #601 — prefix of <c>NodeDeploymentState.FailureReason</c> on an <b>Applied</b> row whose apply
/// was degraded. The status model is deliberately unchanged (no new enum value, no migration): a
/// degraded node DID apply the revision, so its status stays <see cref="NodeDeploymentStatus.Applied"/>
/// and the degradation rides the existing reason column, which <c>/fleet</c> already renders.
/// </summary>
public const string DegradedReasonPrefix = ApplyAck.DegradedReasonPrefix;
/// <summary>
/// Gitea #601 — whether a <c>NodeDeploymentState</c> row records a degraded apply: status
/// <see cref="NodeDeploymentStatus.Applied"/> with a non-empty <c>FailureReason</c>. A clean Applied row
/// never carries a reason (neither this coordinator nor <c>DriverHostActor</c> writes one), so an
/// old node's row reads as clean exactly as before.
/// </summary>
/// <param name="status">The row's status.</param>
/// <param name="failureReason">The row's failure reason.</param>
/// <returns><see langword="true"/> for an Applied-but-degraded row.</returns>
public static bool IsDegraded(NodeDeploymentStatus status, string? failureReason) =>
status == NodeDeploymentStatus.Applied && !string.IsNullOrEmpty(failureReason);
private DeploymentId? _current;
private HashSet<NodeId> _expectedAcks = new(NodeIdComparer);
@@ -149,9 +175,12 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
_expectedAcks = nodeStates.Select(s => NodeId.Parse(s.NodeId)).ToHashSet(NodeIdComparer);
foreach (var s in nodeStates.Where(s => s.Status != NodeDeploymentStatus.Applying))
{
_acks[NodeId.Parse(s.NodeId)] = s.Status == NodeDeploymentStatus.Applied
? ApplyAckOutcome.Applied
: ApplyAckOutcome.Failed;
if (IsDegraded(s.Status, s.FailureReason)) _degraded.Add(NodeId.Parse(s.NodeId));
}
// Resume the deadline timer using the remaining time. The deadline runs from when the
// deployment was first marked AwaitingApplyAcks (Deployment.CreatedAtUtc is a close enough
@@ -174,6 +203,7 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
{
_current = msg.DeploymentId;
_acks.Clear();
_degraded.Clear();
// Seed NodeDeploymentState rows so a failover knows which nodes were expected to ack.
using (var db = _dbFactory.CreateDbContext())
@@ -237,6 +267,9 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
}
_acks[msg.NodeId] = msg.Outcome;
// #601 — an old node never sets Degraded, so its ack takes the clean branch exactly as before.
if (IsDegradedAck(msg)) _degraded.Add(msg.NodeId);
else _degraded.Remove(msg.NodeId);
PersistNodeAck(msg);
if (_acks.Count < _expectedAcks.Count) return;
@@ -258,15 +291,51 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
? NodeDeploymentStatus.Applied
: NodeDeploymentStatus.Failed;
row.AppliedAtUtc = DateTime.UtcNow;
row.FailureReason = msg.FailureReason;
// #601 — an Applied+degraded ack keeps status Applied and records WHY in the reason column, so the row
// reads as degraded (IsDegraded) rather than clean. A clean or Failed ack writes what it always did.
row.FailureReason = IsDegradedAck(msg)
? Truncate(DegradedReasonPrefix + (string.IsNullOrWhiteSpace(msg.DegradedReason)
? "address-space apply degraded (no reason given)"
: msg.DegradedReason))
: msg.FailureReason;
db.SaveChanges();
}
private static bool IsDegradedAck(ApplyAck msg) => msg.Outcome == ApplyAckOutcome.Applied && msg.Degraded;
// FailureReason is nvarchar(2048) on both tables (OtOpcUaConfigDbContext); a reason built from a node's
// free text must never fail the SaveChanges that records the ack.
private const int FailureReasonMaxLength = 2048;
private static string Truncate(string s) =>
s.Length <= FailureReasonMaxLength ? s : s[..(FailureReasonMaxLength - 1)] + "…";
private void SealDeployment()
{
if (_current is null) return;
using var db = _dbFactory.CreateDbContext();
UpdateDeploymentStatus(db, _current.Value, DeploymentStatus.Sealed, sealNow: true);
if (_degraded.Count > 0)
{
// #601 — still Sealed: every node DID apply this revision and is running it, and "latest sealed" is
// what drift detection and the publish actor's fallback key on, so a degraded deploy that was not
// sealed would make the fleet look one revision behind what it actually runs. Sealed is not the
// same as clean, though: the deployment's reason names the degraded nodes (per-node detail is on
// each NodeDeploymentState row) and the seal logs at Warning.
var nodes = _degraded.Select(n => n.Value).Order(StringComparer.OrdinalIgnoreCase).ToList();
var d = db.Deployments.FirstOrDefault(x => x.DeploymentId == _current.Value.Value);
if (d is not null)
d.FailureReason = Truncate(
$"Sealed with a degraded address-space apply on {nodes.Count} node(s): {string.Join(", ", nodes)}. " +
"See each node's NodeDeploymentState.FailureReason; re-deploying the same revision re-applies it.");
db.SaveChanges();
_log.Warning(
"Deployment {Id} sealed DEGRADED (acks={Count}) — the address-space apply was degraded on: {Nodes}",
_current.Value, _acks.Count, string.Join(", ", nodes));
ResetForNext();
return;
}
db.SaveChanges();
_log.Info("Deployment {Id} sealed (acks={Count})", _current.Value, _acks.Count);
ResetForNext();
@@ -318,6 +387,7 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
_current = null;
_expectedAcks.Clear();
_acks.Clear();
_degraded.Clear();
}
private const string DeadlineTimerKey = "apply-deadline";
@@ -130,7 +130,8 @@ public static class TelemetryProtoMapCentral
RediscoveryNeededUtc: msg.RediscoveryNeededUtc?.ToDateTime(),
RediscoveryReason: msg.HasRediscoveryReason ? msg.RediscoveryReason : null,
HostStatuses: ToHostStatuses(msg),
SkippedTags: ToSkippedTags(msg));
SkippedTags: ToSkippedTags(msg),
Diagnostics: msg.HasDiagnostics ? new Dictionary<string, string>(msg.Diagnostics) : null);
}
/// <summary>
@@ -164,6 +164,14 @@ public static class TelemetryProtoMapNode
msg.SkippedTags = payload;
}
// Gitea #640. Presence flag first - an empty map cannot say whether the driver has the capability.
if (e.Diagnostics is not null)
{
msg.HasDiagnostics = true;
foreach (var kv in e.Diagnostics)
msg.Diagnostics[kv.Key ?? ""] = kv.Value ?? "";
}
return msg;
}
@@ -65,6 +65,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// a native re-materialise adds, and <see cref="RebuildAddressSpace"/> clears it alongside
/// <see cref="_alarmConditions"/> so a re-materialise as the other kind is correct.</summary>
private readonly HashSet<RealmKey> _nativeAlarmNodeIds = new();
/// <summary>Gitea #641: every NATIVE condition's live state, captured by <see cref="RebuildAddressSpace"/>
/// just before it clears <see cref="_alarmConditions"/>, waiting for <see cref="MaterialiseAlarmCondition"/>
/// to re-create the same id as native in the same apply. Whatever is unclaimed when
/// <see cref="CompleteAddressSpaceApply"/> runs is discarded. Guarded by <c>Lock</c>.</summary>
private readonly Dictionary<RealmKey, NativeConditionCarryOver> _rebuildCarryOver = new();
/// <summary>Gitea #641: conditions whose non-normal state a re-materialise restored, owed ONE condition
/// event at <see cref="CompleteAddressSpaceApply"/> (deferred until then so every notifier folder the
/// apply wires receives it). Each entry pins the instance restored, so a condition replaced in between is
/// not reported. Guarded by <c>Lock</c>.</summary>
private readonly List<(RealmKey Key, AlarmConditionState Alarm)> _restoredConditionReports = new();
/// <summary>Phase C: NodeId → resolved historian tagname for every variable materialised
/// Historizing. Populated by <see cref="EnsureVariable"/> when a historian tagname is supplied; the
/// (later) HistoryRead override resolves a HistoryRead request's NodeId against this map. Cleared on
@@ -1432,9 +1442,109 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// H6a: record native (driver-fed) conditions so a later task can route their inbound
// Acknowledge to the driver rather than the scripted engine.
if (isNative) _nativeAlarmNodeIds.Add(conditionKey);
// Gitea #641: a native condition the rebuild in this apply discarded gets its live state back. Removed
// whatever the kind, so a native→scripted swap on the same id drops the capture instead of leaving it
// for a later re-add to resurrect.
if (_rebuildCarryOver.Remove(conditionKey, out var carried) && isNative)
RestoreCarryOver(alarm, conditionKey, carried);
}
}
/// <summary>
/// Gitea #641 — one native condition's live Part 9 state, carried across a full
/// <see cref="RebuildAddressSpace"/>.
/// </summary>
/// <param name="State">The gate-relevant fields, read off the node as <see cref="ReadConditionDelta"/> does.</param>
/// <param name="Time">The condition's <c>Time</c> — the instant its current state came about.</param>
/// <param name="ActiveTransitionTime">When it last changed Active, if the SDK recorded it.</param>
/// <param name="QualityTimestamp">The source timestamp of its Quality annotation, if present.</param>
/// <param name="Comment">The condition's <c>Comment</c> (a client's ack / AddComment text), if any.</param>
/// <param name="ClientUserId">Who left that comment, if recorded.</param>
private sealed record NativeConditionCarryOver(
AlarmConditionDelta State, DateTime Time, DateTime? ActiveTransitionTime, DateTime? QualityTimestamp,
LocalizedText? Comment = null, string? ClientUserId = null);
/// <summary>
/// Gitea #641 — read what a native condition node holds right now, so the rebuild can put it back.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why the NODE, not a host-side cache.</b> The node is the only place that knows the true state:
/// a client's Acknowledge (and its comment) is applied to the node by the SDK and never passes through
/// the driver or <c>NativeAlarmProjector</c>. Replaying from any host-side record would un-acknowledge it.
/// </para>
/// <para>
/// <b>Why it is needed at all.</b> A rebuild recreates every condition inactive + acknowledged. When the
/// alarm-tag set is unchanged — the common case: adding a <c>UnsTagReference</c> to an alarm-bearing raw
/// tag is enough to force one — the host does not re-subscribe the driver's alarms, and even a
/// re-subscribe would not help a transition-only source (Galaxy, OpcUaClient) with nothing to replay.
/// Without this, every active alarm read normal until its next device transition.
/// </para>
/// </remarks>
/// <param name="condition">The live native condition, about to be discarded.</param>
/// <returns>Its carried state.</returns>
private static NativeConditionCarryOver CaptureCarryOver(AlarmConditionState condition) => new(
ReadConditionDelta(condition),
condition.Time?.Value ?? DateTime.UtcNow,
condition.ActiveState?.TransitionTime?.Value,
condition.Quality?.SourceTimestamp?.Value,
condition.Comment?.Value,
condition.ClientUserId?.Value);
/// <summary>
/// Gitea #641 — put a carried state back onto the freshly materialised native condition. MUST be called
/// under <c>Lock</c>, after the condition is registered.
/// </summary>
/// <remarks>
/// <para>
/// <b>Quality always; the rest only off-normal.</b> Source quality is annotated only on a driver's
/// connectivity edge, so a rebuild otherwise left every native condition — normal ones included —
/// reading <c>BadWaitingForInitialData</c> until the driver next flapped; it is restored silently. A
/// condition in the no-event position (inactive + acknowledged) otherwise keeps exactly what the
/// materialise built, message and authored severity included, and reports nothing — the same rule the
/// scripted re-assert follows (#487).
/// </para>
/// <para>
/// <b>Reported later, once.</b> The state is applied now, so a Read or ConditionRefresh in the window
/// already sees it, but the one condition event waits for <see cref="CompleteAddressSpaceApply"/>: the
/// equipment folders the condition reports through are wired AFTER this call
/// (<see cref="WireAlarmNotifiers"/>), and reporting now would miss them. Native conditions refuse
/// Confirm, shelving and Enable/Disable (#587), so Enabled and Shelving are not carried.
/// </para>
/// </remarks>
/// <param name="alarm">The new condition instance.</param>
/// <param name="conditionKey">Its key.</param>
/// <param name="carried">What the discarded instance held.</param>
private void RestoreCarryOver(AlarmConditionState alarm, RealmKey conditionKey, NativeConditionCarryOver carried)
{
var state = carried.State;
if (alarm.Quality is not null)
{
alarm.Quality.Value = state.Quality;
if (alarm.Quality.SourceTimestamp is not null && carried.QualityTimestamp is { } qualityAt)
alarm.Quality.SourceTimestamp.Value = qualityAt;
}
var retained = state.Active || !state.Acknowledged;
if (!retained) return;
alarm.SetActiveState(SystemContext, state.Active);
if (carried.ActiveTransitionTime is { } activeAt && alarm.ActiveState?.TransitionTime is { } transitionTime)
transitionTime.Value = activeAt;
alarm.SetAcknowledgedState(SystemContext, state.Acknowledged);
if (alarm.ConfirmedState is not null) alarm.SetConfirmedState(SystemContext, state.Confirmed);
alarm.SetSeverity(SystemContext, (EventSeverity)state.MappedSeverity);
alarm.Message.Value = new LocalizedText(state.Message);
alarm.Retain.Value = true;
if (alarm.Comment is not null && carried.Comment is not null) alarm.Comment.Value = carried.Comment;
if (alarm.ClientUserId is not null && carried.ClientUserId is not null) alarm.ClientUserId.Value = carried.ClientUserId;
alarm.Time.Value = carried.Time;
alarm.ReceiveTime.Value = carried.Time;
_restoredConditionReports.Add((conditionKey, alarm));
}
/// <summary>One materialised native-alarm condition together with the equipment folders wired as extra
/// event-notifier roots for it. The <see cref="Folders"/> list is mutated in place under <c>Lock</c> as
/// notifiers are wired / torn down; <see cref="Alarm"/> is the concrete <see cref="AlarmConditionState"/>
@@ -3207,6 +3317,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
_historizedTagnames.Clear();
_arrayOnScalarRefused.Clear();
// Gitea #641: capture every NATIVE condition's live state before the conditions are discarded, so the
// re-materialise that follows in this same apply can put it back (see CaptureCarryOver). Scripted
// conditions are not captured — their engine re-asserts them after the reload (#487).
_rebuildCarryOver.Clear();
_restoredConditionReports.Clear();
foreach (var (key, alarm) in _alarmConditions)
{
if (_nativeAlarmNodeIds.Contains(key)) _rebuildCarryOver[key] = CaptureCarryOver(alarm);
}
foreach (var alarm in _alarmConditions.Values)
{
alarm.Parent?.RemoveChild(alarm);
@@ -3251,6 +3371,41 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <inheritdoc cref="IOpcUaAddressSpaceSink.CompleteAddressSpaceApply"/>
/// <remarks>
/// Gitea #641: reports ONE condition event for each native condition whose off-normal state the rebuild
/// carried over (stamped with the instant that state came about, not the deploy time — as the scripted
/// re-assert does), then discards whatever captured state no materialise claimed. A condition the apply
/// replaced again after restoring it is skipped: the instance reported must be the one restored.
/// </remarks>
public void CompleteAddressSpaceApply()
{
int reported = 0, discarded;
lock (Lock)
{
foreach (var (key, alarm) in _restoredConditionReports)
{
if (!_alarmConditions.TryGetValue(key, out var live) || !ReferenceEquals(live, alarm)) continue;
// The node's CURRENT Time: the carried instant when nothing reported since the restore, or the
// newer instant of a report that did (a client method call on an SDK thread can land between the
// restore and here) — never roll the condition's Time back.
ReportConditionEvent(alarm, alarm.Time.Value);
reported++;
}
discarded = _rebuildCarryOver.Count;
_restoredConditionReports.Clear();
_rebuildCarryOver.Clear();
}
if (reported > 0 || discarded > 0)
{
_logger.LogInformation(
"OtOpcUaNodeManager: rebuild carried {Reported} active/unacknowledged native condition(s) over; discarded {Discarded} captured condition(s) the apply did not re-create",
reported, discarded);
}
}
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/>
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
{
@@ -84,6 +84,9 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
/// <inheritdoc />
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
/// <inheritdoc />
public void CompleteAddressSpaceApply() => _nodeManager.CompleteAddressSpaceApply();
/// <inheritdoc />
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId, realm);
@@ -38,7 +38,8 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher
DateTime? rediscoveryNeededUtc = null,
string? rediscoveryReason = null,
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null,
SkippedTagReport? skippedTags = null)
SkippedTagReport? skippedTags = null,
IReadOnlyDictionary<string, string>? diagnostics = null)
{
var msg = new DriverHealthChanged(
clusterId,
@@ -51,7 +52,8 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher
rediscoveryNeededUtc,
rediscoveryReason,
hostStatuses,
skippedTags);
skippedTags,
diagnostics);
DistributedPubSub.Get(_system).Mediator.Tell(new Publish(TopicName, msg));
// Phase 5: fan the same snapshot into the node-local live-telemetry hub (no-op until a gRPC
// client subscribes). The DPS publish above is unchanged — the hub is a strictly additive tap.
@@ -84,6 +84,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// </remarks>
public static readonly TimeSpan DefaultApplyWedgeTimeout = TimeSpan.FromMinutes(5);
/// <summary>
/// Gitea #601 — how long an Applied ack waits for <c>OpcUaPublishActor</c>'s address-space apply
/// outcome (<see cref="OpcUa.OpcUaPublishActor.RebuildCompleted"/>) before it is sent anyway.
/// </summary>
/// <remarks>
/// Derived from central's apply deadline (<see cref="DeployTimings.ApplyDeadline"/>, 2 min — the same
/// constant <c>ConfigPublishCoordinator.DefaultApplyDeadline</c> reads) minus
/// <see cref="DeployTimings.ApplyOutcomeSafetyMargin"/> (15 s) = 105 s: long enough that a
/// slow-but-clean rebuild of a large address space is not labelled degraded, short enough that the ack
/// still lands before central times the deployment out. An ack sent on this timeout is marked
/// <b>degraded</b> with a reason starting <see cref="OutcomeNotReportedReasonPrefix"/>, never clean —
/// the revision did advance, but nobody has confirmed the address space matches it.
/// </remarks>
public static readonly TimeSpan DefaultApplyOutcomeTimeout = DeployTimings.ApplyOutcomeTimeout;
/// <summary>Gitea #601 — the reason prefix of a degraded ack sent because the outcome TIMED OUT, so an
/// operator can tell "not reported in time" from a real sink failure.</summary>
public const string OutcomeNotReportedReasonPrefix = "address-space apply outcome not reported in time";
/// <summary>Minimum interval between two supervision reprovisions of the SAME driver child (archreview
/// review F3). Akka's default decider restarts a faulting child immediately and without limit, and the
/// reprovision hook makes every restart do ShutdownAsync + InitializeAsync + SubscribeAsync — so an
@@ -244,6 +263,21 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
private IActorRef? _scriptedAlarmHost;
private RevisionHash? _currentRevision;
/// <summary>
/// Gitea #601 — the last address-space apply of <see cref="_currentRevision"/> reported DEGRADED (or no
/// outcome in time). A re-dispatch of that same revision then RE-APPLIES instead of taking the
/// idempotent "already at this revision" short-circuit: the short-circuit would ack a clean Applied
/// for an address space known to be partial, and the re-apply is what repairs it (a degraded
/// non-rebuild apply leaves the publish actor's composition un-advanced, so it re-diffs the missing
/// nodes). Reset by every clean outcome and by every boot path that restores a revision.
/// </summary>
private bool _currentRevisionDegraded;
/// <summary>Gitea #601 — Applied acks held until the address-space apply reports its outcome, keyed by
/// the apply's correlation id (echoed back in <see cref="OpcUa.OpcUaPublishActor.RebuildCompleted"/>).</summary>
private readonly Dictionary<CorrelationId, PendingAck> _pendingAcks = new();
private DeploymentId? _applyingDeploymentId;
private readonly Dictionary<string, ChildEntry> _children = new(StringComparer.Ordinal);
@@ -431,6 +465,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// </summary>
private readonly Func<CommonsNodeId, bool>? _sharesHistorizationScopeWithLocal;
/// <summary>Gitea #601 — how long an Applied ack waits for the address-space outcome (see
/// <see cref="DefaultApplyOutcomeTimeout"/>).</summary>
private readonly TimeSpan _applyOutcomeTimeout;
/// <summary>This host's <see cref="Applying"/>-wedge watchdog delay (see
/// <see cref="DefaultApplyWedgeTimeout"/>).</summary>
private readonly TimeSpan _applyWedgeTimeout;
@@ -595,6 +633,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
/// <param name="commsRecoveryAuditDelay">Archreview 03/S15: how long after a driver reconnects the
/// recovery audit waits before reporting refs still latched <c>BadNoCommunication</c>. Defaults to
/// <see cref="DefaultCommsRecoveryAuditDelay"/>; tests shorten it.</param>
/// <param name="applyOutcomeTimeout">Gitea #601: how long an Applied ack waits for the address-space
/// apply outcome before it is sent anyway, marked degraded ("outcome unknown"). Defaults to
/// <see cref="DefaultApplyOutcomeTimeout"/>; tests shorten it.</param>
/// <returns>The Akka.NET <see cref="Akka.Actor.Props"/> used to spawn this actor.</returns>
public static Props Props(
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
@@ -626,7 +667,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
TimeSpan? applyWedgeTimeout = null,
TimeSpan? reprovisionBackoff = null,
TimeSpan? commsRecoveryAuditDelay = null,
Func<CommonsNodeId, bool>? sharesAlarmQueueWithLocal = null) =>
Func<CommonsNodeId, bool>? sharesAlarmQueueWithLocal = null,
TimeSpan? applyOutcomeTimeout = null) =>
// WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an
// expression tree. Six IActorRef? parameters and several interface-typed ones mean a
// mis-ordered argument is usually type-compatible and therefore compiles clean, then binds
@@ -639,7 +681,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher,
alarmStateStore, telemetryHub, primaryRoleSource, historizationRoleView,
sharesHistorizationScopeWithLocal, applyWedgeTimeout, reprovisionBackoff, commsRecoveryAuditDelay,
sharesAlarmQueueWithLocal));
sharesAlarmQueueWithLocal, applyOutcomeTimeout));
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
/// <param name="dbFactory">Database context factory for configuration database access.</param>
@@ -737,8 +779,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
TimeSpan? applyWedgeTimeout = null,
TimeSpan? reprovisionBackoff = null,
TimeSpan? commsRecoveryAuditDelay = null,
Func<CommonsNodeId, bool>? sharesAlarmQueueWithLocal = null)
Func<CommonsNodeId, bool>? sharesAlarmQueueWithLocal = null,
TimeSpan? applyOutcomeTimeout = null)
{
_applyOutcomeTimeout = applyOutcomeTimeout ?? DefaultApplyOutcomeTimeout;
_sharesAlarmQueueWithLocal = sharesAlarmQueueWithLocal;
_applyWedgeTimeout = applyWedgeTimeout ?? DefaultApplyWedgeTimeout;
_reprovisionBackoff = reprovisionBackoff ?? DefaultReprovisionBackoff;
@@ -1288,6 +1332,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
private void Steady()
{
Receive<DispatchDeployment>(HandleDispatchFromSteady);
// #601 — the held Applied ack is released by the address-space outcome (or its timeout); both can
// land in any behaviour, so all three register them.
Receive<OpcUa.OpcUaPublishActor.RebuildCompleted>(HandleRebuildCompleted);
Receive<ApplyOutcomeTimedOut>(HandleApplyOutcomeTimedOut);
Receive<ArtifactCached>(HandleArtifactCached);
Receive<ArtifactCacheFailed>(HandleArtifactCacheFailed);
// A wedge tick already in the mailbox when the apply finished — dropped at Debug, see the handler.
@@ -1348,6 +1396,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// Direct ConfigDb read (BeginFetchAndCacheApply / BeginDirectApply ran in Steady, then
// Become(Applying)); completing the apply transitions back to Steady.
Receive<ArtifactForApply>(HandleArtifactForApply);
Receive<OpcUa.OpcUaPublishActor.RebuildCompleted>(HandleRebuildCompleted);
Receive<ApplyOutcomeTimedOut>(HandleApplyOutcomeTimedOut);
// The cache write is piped back too, and it outlives the apply that started it by design — so
// its completion can land in EITHER state and both must handle it or it dead-letters.
Receive<ArtifactCached>(HandleArtifactCached);
@@ -1793,9 +1843,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// #626 — a QUALITY-ONLY annotation (an MTConnect condition gone Unavailable, or back) is not an alarm
// transition. It rides the SAME quality-only node path the #477 connectivity annotation uses
// (AlarmQualityUpdate → WriteAlarmQuality, which touches Quality and nothing else), never a projected
// snapshot: the projector's prior state is cleared on every apply, so a projected "keep the state"
// after a deploy would default to inactive+acked and silently acknowledge a retained, unacked
// condition — and would rewrite its message and severity. Ungated by role (a Secondary keeps quality
// snapshot: the projector never sees a client's acknowledgement (that is applied to the node by the
// SDK) and forgets a condition a deploy removed, so a projected "keep the state" can disagree with
// the node — silently acknowledging a retained, unacked condition or rewriting its message and
// severity. Ungated by role (a Secondary keeps quality
// warm, as for connectivity) and no alerts row.
if (msg.Args is { IsQualityOnly: true, QualityStatusCode: { } quality })
{
@@ -2017,6 +2068,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
return;
}
// Gitea #641 review — the node is already acknowledged (the SDK applies the client's ack); tell the
// projector too, or the next driver Clear projects (Active=false, Acked=prev=false) and un-acknowledges it.
// Most sources (MTConnect, FOCAS) send no Acknowledge transition back, and the projector now survives a
// deploy rather than resetting to its acked default.
_nativeAlarmProjector.MarkAcknowledged(msg.ConditionNodeId);
if (!_children.TryGetValue(target.DriverInstanceId, out var entry))
{
_log.Debug("DriverHost {Node}: driver {Driver} not running for alarm ack of {Cond} — dropped",
@@ -2173,6 +2230,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// The redundancy-state topic also carries OpcUaProbeResult (OpcUaPublishActor peer-probes).
// We don't consume it here — drop it so it doesn't dead-letter (matches PeerProbeSupervisor).
Receive<ZB.MOM.WW.OtOpcUa.Runtime.Health.PeerOpcUaProbeActor.OpcUaProbeResult>(_ => { });
// #601 — an apply that completed just before the host went Stale still owes its ack; the
// coordinator is not behind the ConfigDb, so release it exactly as in Steady.
Receive<OpcUa.OpcUaPublishActor.RebuildCompleted>(HandleRebuildCompleted);
Receive<ApplyOutcomeTimedOut>(HandleApplyOutcomeTimedOut);
// An inbound operator write can't be serviced while the config DB is unreachable — fast-fail so the
// node-manager's bounded Ask gets an immediate clear status instead of dead-lettering into a timeout.
Receive<RouteNodeWrite>(_ =>
@@ -2219,7 +2280,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
private void HandleDispatchFromSteady(DispatchDeployment msg)
{
if (_currentRevision is { } cur && cur == msg.RevisionHash)
// #601: the short-circuit claims a CLEAN Applied, so it is only honest while the current revision's
// address space is known-good — not after a degraded apply, and not while its outcome is still
// pending. Either way the dispatch re-applies instead, which is also what repairs a degraded apply.
var currentRevisionUnconfirmed = _currentRevisionDegraded
|| _pendingAcks.Values.Any(p => p.Revision == msg.RevisionHash);
if (_currentRevision is { } cur && cur == msg.RevisionHash && !currentRevisionUnconfirmed)
{
// Idempotent — already at this rev. Ack and stay Steady.
_log.Debug("DriverHost {Node}: dispatch {Id} matches current rev {Rev}; immediate ACK",
@@ -2228,6 +2294,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
return;
}
if (_currentRevision is { } same && same == msg.RevisionHash)
_log.Info(
"DriverHost {Node}: dispatch {Id} matches current rev {Rev}, but its address-space apply was degraded or is unconfirmed — re-applying rather than acking clean",
_localNode, msg.DeploymentId, msg.RevisionHash);
if (_fetchAndCacheMode)
{
BeginFetchAndCacheApply(msg.DeploymentId, msg.RevisionHash, msg.CorrelationId);
@@ -2387,7 +2458,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
_currentRevision = msg.Revision;
UpsertNodeDeploymentState(msg.DeploymentId, NodeDeploymentStatus.Applied, failureReason: null);
SendAck(msg.DeploymentId, ApplyAckOutcome.Applied, failureReason: null, msg.Correlation);
// Gitea #601: the Applied ack is NOT sent here any more. It used to go out right now, before the
// address-space apply below had even been dequeued, so a degraded apply (swallowed sink faults,
// _lastApplied not advanced) still sealed the deployment green fleet-wide. The ack is now held
// until OpcUaPublishActor replies RebuildCompleted (HandleRebuildCompleted) or the outcome
// timeout fires (sent degraded, "outcome unknown"). With no publish actor there is no address
// space to wait for, so the ack goes out clean immediately — exactly the old behaviour.
AwaitAddressSpaceOutcomeThenAck(msg.DeploymentId, msg.Revision, msg.Correlation);
// Rebuild the address space + push subscriptions FROM the bytes this apply already has, so
// nothing downstream re-reads the artifact: under FetchAndCache OpcUaPublishActor has no
// central SQL to read at all, and under Direct a bytes-less RebuildAddressSpace would make
@@ -2399,8 +2476,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
// different actor with its own mailbox, and ParsedDeploymentArtifact is a disposable,
// non-thread-safe object owned by this handler (disposed in the finally below, likely before
// the other actor even dequeues). It opens its own single parse on the far side.
// ReportOutcome: the reply (to Self — this Tell runs inside a receive, so the implicit sender is
// this actor) releases the held ack. Plain Tell + reply message, no Ask/await, so nothing here
// resumes off the actor thread.
_opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.RebuildAddressSpace(
msg.Correlation, msg.DeploymentId, appliedBlob));
msg.Correlation, msg.DeploymentId, appliedBlob, ReportOutcome: true));
// SubscribeBulk pass: hand each driver its desired tag references so live values flow into
// the just-rebuilt address space instead of staying BadWaitingForInitialData.
PushDesiredSubscriptionsFromParsed(msg.DeploymentId, parsed);
@@ -2420,6 +2500,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
}
catch (Exception ex)
{
// #601: a throw after the Applied ack was put on hold — the Failed ack below is this apply's one
// answer, so the held Applied must never follow it when the address-space outcome lands.
DropPendingAck(msg.Correlation);
UpsertNodeDeploymentState(msg.DeploymentId, NodeDeploymentStatus.Failed, ex.Message);
SendAck(msg.DeploymentId, ApplyAckOutcome.Failed, ex.Message, msg.Correlation);
OtOpcUaTelemetry.DeploymentApplied.Add(1, new KeyValuePair<string, object?>("outcome", "reject"));
@@ -2440,6 +2523,99 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
}
}
/// <summary>Gitea #601 — an Applied ack held until the address-space apply reports its outcome.</summary>
private sealed record PendingAck(DeploymentId DeploymentId, RevisionHash Revision, CorrelationId Correlation);
/// <summary>Gitea #601 — self-timer: the address-space outcome for this correlation did not arrive in time.</summary>
private sealed record ApplyOutcomeTimedOut(CorrelationId Correlation);
private static string ApplyOutcomeTimerKey(CorrelationId correlation) => $"apply-outcome-{correlation}";
/// <summary>
/// Gitea #601 — holds the Applied ack for an apply whose revision has just advanced until the
/// address-space apply outcome is known, arming <see cref="_applyOutcomeTimeout"/> as the backstop.
/// With no publish actor wired (tests, a node with no OPC UA data plane) there is no outcome to wait
/// for, so the ack is sent clean at once — byte-for-byte the pre-#601 behaviour.
/// </summary>
private void AwaitAddressSpaceOutcomeThenAck(DeploymentId deploymentId, RevisionHash revision, CorrelationId correlation)
{
if (_opcUaPublishActor is null)
{
_currentRevisionDegraded = false;
SendAck(deploymentId, ApplyAckOutcome.Applied, failureReason: null, correlation);
return;
}
if (_pendingAcks.Remove(correlation, out var superseded))
_log.Warning(
"DriverHost {Node}: a second apply reused correlation {Correlation} while deployment {Old}'s ack was still waiting for its address-space outcome — that ack is superseded",
_localNode, correlation, superseded.DeploymentId);
_pendingAcks[correlation] = new PendingAck(deploymentId, revision, correlation);
Timers.StartSingleTimer(ApplyOutcomeTimerKey(correlation), new ApplyOutcomeTimedOut(correlation), _applyOutcomeTimeout);
}
/// <summary>Gitea #601 — forgets a held ack (its apply failed after all, and acks Failed instead).</summary>
private void DropPendingAck(CorrelationId correlation)
{
if (_pendingAcks.Remove(correlation))
Timers.Cancel(ApplyOutcomeTimerKey(correlation));
}
/// <summary>
/// Gitea #601 — the address-space apply reported its outcome: release the held ack, clean or
/// degraded. Registered in every behaviour (Steady, Applying, Stale) because the reply can land in
/// any of them — the host returns to Steady before the publish actor has even dequeued the rebuild.
/// </summary>
private void HandleRebuildCompleted(OpcUa.OpcUaPublishActor.RebuildCompleted msg)
{
if (!_pendingAcks.Remove(msg.Correlation, out var pending))
{
_log.Debug("DriverHost {Node}: address-space outcome for {Correlation} has no held ack (already timed out or superseded); dropping",
_localNode, msg.Correlation);
return;
}
Timers.Cancel(ApplyOutcomeTimerKey(msg.Correlation));
ReleasePendingAck(pending, msg.Degraded, msg.DegradedReason);
}
/// <summary>
/// Gitea #601 — the address-space outcome did not arrive within <see cref="_applyOutcomeTimeout"/>.
/// The ack is sent anyway so the coordinator does not time the deployment out on a node that DID
/// advance its revision — but degraded, never clean: nobody has confirmed the address space.
/// </summary>
private void HandleApplyOutcomeTimedOut(ApplyOutcomeTimedOut msg)
{
if (!_pendingAcks.Remove(msg.Correlation, out var pending)) return;
ReleasePendingAck(pending, degraded: true,
$"{OutcomeNotReportedReasonPrefix} ({_applyOutcomeTimeout.TotalSeconds:0}s) — no sink failure was observed; the apply may still be running (slow) or its outcome was lost");
}
private void ReleasePendingAck(PendingAck pending, bool degraded, string? reason)
{
// Only the CURRENT revision's flag is ours to set — an older apply's late outcome must not mark a
// newer revision degraded (or clean).
if (_currentRevision is { } cur && cur == pending.Revision)
_currentRevisionDegraded = degraded;
if (degraded)
{
// #601 review — the apply wrote this node's own row Applied/null before the outcome was known. Central
// adds the reason from the ack only when it ACCEPTS the ack (a timed-out or superseded deployment's ack
// is discarded), so a node with a ConfigDb records it itself; a driver-only node no-ops here.
UpsertNodeDeploymentState(pending.DeploymentId, NodeDeploymentStatus.Applied,
ApplyAck.DegradedReasonPrefix + (reason ?? "address-space apply degraded"));
OtOpcUaTelemetry.DeploymentApplied.Add(1, new KeyValuePair<string, object?>("outcome", "degraded"));
_log.Warning(
"DriverHost {Node}: deployment {Id} (rev {Rev}) applied DEGRADED — {Reason}. Acking Applied+degraded; re-dispatching the same revision re-applies it",
_localNode, pending.DeploymentId, pending.Revision, reason);
}
SendAck(pending.DeploymentId, ApplyAckOutcome.Applied, failureReason: null, pending.Correlation,
degraded, degraded ? reason ?? "address-space apply degraded" : null);
}
/// <summary>
/// Self-timer telling this host it has been in <see cref="Applying"/> for
/// <see cref="_applyWedgeTimeout"/> without finishing.
@@ -3078,7 +3254,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
_alarmNodeIdByDriverRef.Clear();
_driverRefByAlarmNodeId.Clear();
_alarmMetaByNodeId.Clear();
_nativeAlarmProjector.Clear();
// NOT _nativeAlarmProjector.Clear() (Gitea #641): it is pruned to the still-deployed conditions after the
// maps are rebuilt below, so a redeploy that keeps an alarm keeps its projected prior state.
// The per-driver secondary indexes are rebuilt WHOLE alongside their primaries below — same pass,
// same clear — so no incremental maintenance exists to get out of step with them.
_valueRefsByDriver.Clear();
@@ -3132,6 +3309,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
}
}
// Gitea #641 — forget the projected prior state of conditions this composition no longer carries, and keep
// the rest. Clearing it whole on every apply made the next driver Acknowledge of a still-active alarm
// project Active=false (the default prior), and the next Clear of an unacknowledged one project
// Acked=true — on any deploy, rebuild or not. The node manager carries the NODE's state across a rebuild,
// so the two stay in step. Keyed by the bare condition NodeId, exactly as ForwardNativeAlarm projects.
_nativeAlarmProjector.RetainOnly(_driverRefByAlarmNodeId.Keys.ToHashSet(StringComparer.Ordinal));
// Cache the per-driver payload as THE authority, in the same clear-and-repopulate pass as the routing
// maps above so a rename/removal can never leave a stale entry behind. Keyed over the UNION of the two
// ref maps AND the running children: a child with no authored tags must get an explicit EMPTY set (which
@@ -3662,9 +3846,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
}
}
private void SendAck(DeploymentId deploymentId, ApplyAckOutcome outcome, string? failureReason, CorrelationId correlation)
private void SendAck(
DeploymentId deploymentId, ApplyAckOutcome outcome, string? failureReason, CorrelationId correlation,
bool degraded = false, string? degradedReason = null)
{
var ack = new ApplyAck(deploymentId, _localNode, outcome, failureReason, correlation);
var ack = new ApplyAck(deploymentId, _localNode, outcome, failureReason, correlation, degraded, degradedReason);
if (_ackRouter is not null)
{
// Either a direct coordinator handle (tests) or, under MeshTransport:Mode = Grpc, the
@@ -1153,6 +1153,28 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
}
}
/// <summary>Bounded driver diagnostics, or null when the driver is not an <see cref="IDriverDiagnostics"/>
/// (Gitea #640). Pulled fresh and called UNGUARDED for the same reasons as
/// <see cref="CurrentSkippedTags"/>; a throwing implementation loses only the diagnostics.</summary>
private IReadOnlyDictionary<string, string>? CurrentDiagnostics()
{
if (_driver is not IDriverDiagnostics diag) return null;
try
{
return DriverDiagnostics.Bound(diag.GetDiagnostics());
}
catch (Exception ex)
{
_log.Warning(ex, "DriverInstance {Id}: GetDiagnostics threw during health publish; omitting diagnostics", _driverInstanceId);
return null;
}
}
/// <summary>Value digest of the diagnostics bag for the publish fingerprint. Null in, null out. The bag is
/// already in ordinal key order (<see cref="DriverDiagnostics.Bound"/>), so equal facts digest equally.</summary>
private static string? DiagnosticsDigest(IReadOnlyDictionary<string, string>? d) =>
d is null ? null : string.Join('|', d.Select(kv => $"{kv.Key}={kv.Value}"));
/// <summary>Records the driver's rediscovery raise and re-publishes health so the signal reaches the
/// AdminUI promptly rather than waiting for the next 30 s heartbeat.
/// <para><b>Advisory only.</b> The served address space is deliberately NOT rebuilt: v3 authors raw tags
@@ -1357,6 +1379,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
var errorCount = ErrorCount5Min();
var hostStatuses = CurrentHostStatuses();
var skippedTags = CurrentSkippedTags();
var diagnostics = CurrentDiagnostics();
// _rediscoveryNeededUtc is PART OF THE FINGERPRINT on purpose. A rediscovery raise on an
// otherwise-unchanged Healthy driver leaves (state, lastSuccess, lastError, errorCount)
// identical, so without it the dedup below would swallow the very publish that carries the
@@ -1375,14 +1398,20 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// is identical to a clean driver's and the publish that carries the signal is exactly the one
// dedup would swallow. Flattened to a STRING for the same reason as the host digest — the
// report holds an IReadOnlyList, which a tuple compares by reference.
//
// The diagnostics digest (Gitea #640) is the fourth instance: a diagnostic fact (S7's PDU size
// changing across a reconnect) moves none of the other components, so without it the publish
// that carries it is deduped away. Flattened to a STRING - the bag is a dictionary, which a
// tuple compares by reference.
var fingerprint = (health.State, health.LastSuccessfulRead, health.LastError, errorCount,
_rediscoveryNeededUtc, HostStatusDigest(hostStatuses), SkippedTagDigest(skippedTags));
_rediscoveryNeededUtc, HostStatusDigest(hostStatuses), SkippedTagDigest(skippedTags),
DiagnosticsDigest(diagnostics));
if (_lastPublishedFingerprint is { } prev && prev.Equals(fingerprint))
return observed;
_lastPublishedFingerprint = fingerprint;
_healthPublisher.Publish(
_clusterId, _driverInstanceId, health, errorCount, _rediscoveryNeededUtc, _rediscoveryReason,
hostStatuses, skippedTags);
hostStatuses, skippedTags, diagnostics);
}
catch (Exception ex)
{
@@ -1418,7 +1447,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <summary>Fingerprint of the last <see cref="PublishHealthSnapshot"/> call; null until first publish.</summary>
private (DriverState State, DateTime? LastSuccess, string? LastError, int ErrorCount,
DateTime? RediscoveryNeededUtc, string? HostStatusDigest, string? SkippedTagDigest)? _lastPublishedFingerprint;
DateTime? RediscoveryNeededUtc, string? HostStatusDigest, string? SkippedTagDigest,
string? DiagnosticsDigest)? _lastPublishedFingerprint;
/// <inheritdoc />
protected override void PostStop()
@@ -40,9 +40,36 @@ public sealed class NativeAlarmProjector
StatusCode: e.QualityStatusCode ?? OpcUaStatus.Good);
}
/// <summary>Clears all tracked per-node state (call on address-space rebuild).</summary>
/// <summary>
/// Gitea #641 review — record a client acknowledgement the node manager applied to <paramref name="nodeId"/>
/// and routed toward the driver. The projector otherwise never sees it, and a later Clear would project the
/// stale unacknowledged prior. An untracked condition already defaults to acknowledged.
/// </summary>
/// <param name="nodeId">The acknowledged condition node id.</param>
public void MarkAcknowledged(string nodeId)
{
if (_prior.TryGetValue(nodeId, out var p)) _prior[nodeId] = (p.Active, true);
}
/// <summary>Clears all tracked per-node state.</summary>
public void Clear() => _prior.Clear();
/// <summary>
/// Gitea #641 — drop the tracked state of every condition NOT in <paramref name="deployedNodeIds"/>, keeping
/// the rest. Called on every apply instead of <see cref="Clear"/>: a condition that is still deployed keeps
/// its state across the deploy (the node manager carries the node's state across a rebuild too), so the
/// next Acknowledge does not project <c>Active = false</c> onto an active alarm and the next Clear does not
/// project <c>Acked = true</c> onto an unacknowledged one. A removed condition is forgotten, so a later
/// re-add starts from the default.
/// </summary>
/// <param name="deployedNodeIds">The condition node ids the new composition still materialises.</param>
public void RetainOnly(IReadOnlySet<string> deployedNodeIds)
{
ArgumentNullException.ThrowIfNull(deployedNodeIds);
foreach (var nodeId in _prior.Keys.Where(k => !deployedNodeIds.Contains(k)).ToList())
_prior.Remove(nodeId);
}
private static ushort MapSeverity(AlarmSeverity s) => s switch
{
AlarmSeverity.Low => 200,
@@ -100,8 +100,31 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// that cannot succeed, leaving the rebuild a no-op and clients browsing an empty server. Null
/// on the normal path, where loading from the ConfigDb by id is correct.
/// </param>
/// <param name="ReportOutcome">
/// Gitea #601 — when <see langword="true"/>, the actor replies to the sender with a
/// <see cref="RebuildCompleted"/> once the apply outcome is known (clean or degraded), on EVERY path
/// through the handler. <c>DriverHostActor</c> sets it on the deploy path and sends the deployment's
/// ack only after that reply, so a degraded apply can no longer seal a deployment green. Off for the
/// boot/restore paths, which re-ack nothing.
/// </param>
public sealed record RebuildAddressSpace(
CorrelationId Correlation, DeploymentId? DeploymentId = null, byte[]? Artifact = null);
CorrelationId Correlation, DeploymentId? DeploymentId = null, byte[]? Artifact = null,
bool ReportOutcome = false);
/// <summary>
/// Gitea #601 — the reply to a <see cref="RebuildAddressSpace"/> carrying
/// <see cref="RebuildAddressSpace.ReportOutcome"/>: whether the address-space apply was clean.
/// </summary>
/// <param name="Correlation">The rebuild's correlation id, echoed.</param>
/// <param name="DeploymentId">The rebuild's deployment id, echoed (null for a legacy caller).</param>
/// <param name="Degraded">
/// <see langword="true"/> when the apply swallowed per-node sink faults, its rebuild failed, the
/// pipeline threw, or there was no usable artifact — i.e. the served address space may be partial or
/// stale. An empty plan (nothing changed) is clean.
/// </param>
/// <param name="DegradedReason">A short operator-readable reason; null when clean.</param>
public sealed record RebuildCompleted(
CorrelationId Correlation, DeploymentId? DeploymentId, bool Degraded, string? DegradedReason);
public sealed record ServiceLevelChanged(byte ServiceLevel);
@@ -134,6 +157,15 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// protecting. Distinguishes "the artifact went missing under a running server" (issue #485 — warn, and
/// hold what is materialised) from "nothing has been deployed here yet" (a quiet no-op at boot).</summary>
private bool _hasAppliedComposition;
/// <summary>
/// Gitea #601 review — <see cref="_lastApplied"/> was advanced by a DEGRADED rebuild (a rebuild advances
/// even with swallowed failures, see <see cref="HandleRebuildCore"/>). A re-deploy of the same revision
/// then diffs to an EMPTY plan; without this flag that empty plan reported clean, so the "re-deploy
/// re-applies" repair path sealed green over an address space still known to be partial. While set, an
/// empty plan re-materialises the whole composition (idempotent ensures) and reports clean only when
/// that pass is itself clean.
/// </summary>
private bool _lastAppliedDegraded;
private DbHealthProbeActor.DbHealthStatus? _lastDbHealth;
private RedundancyStateChanged? _lastSnapshot;
private (bool Ok, DateTime At)? _probeAboutMe;
@@ -437,6 +469,36 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
}
private void HandleRebuild(RebuildAddressSpace msg)
{
// Captured up front: the pipeline below is synchronous on this actor's thread today, but the reply
// must reach whoever asked even if a future edit introduces an await into it.
var requester = Sender;
(bool Degraded, string? Reason) outcome;
try
{
outcome = HandleRebuildCore(msg);
}
finally
{
// Gitea #641 — close the apply on EVERY exit, early returns and a throwing pass included: the node
// manager reports here the native conditions a rebuild carried over (after the notifier wiring the
// passes did) and discards any captured state no materialise claimed. Skipping it on a throw would
// leave that state for a later surgical re-add of the same id to resurrect. No-op when nothing is
// pending; never throws out of the handler.
try { _sink.CompleteAddressSpaceApply(); }
catch (Exception ex)
{
_log.Error(ex, "OpcUaPublish: sink.CompleteAddressSpaceApply threw (correlation={Correlation})", msg.Correlation);
}
}
// Gitea #601 — the outcome goes out only after the apply (including #641's completion step) is done.
if (msg.ReportOutcome)
requester.Tell(new RebuildCompleted(msg.Correlation, msg.DeploymentId, outcome.Degraded, outcome.Reason));
}
/// <summary>The rebuild pipeline. Returns whether the apply was degraded, and why (Gitea #601).</summary>
private (bool Degraded, string? Reason) HandleRebuildCore(RebuildAddressSpace msg)
{
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString());
@@ -460,8 +522,9 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
{
_log.Error(ex, "OpcUaPublish: sink.RebuildAddressSpace threw (correlation={Correlation})",
msg.Correlation);
return (true, $"address-space rebuild threw: {ex.Message}");
}
return;
return (false, null);
}
try
@@ -499,7 +562,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
_log.Debug(
"OpcUaPublish: no artifact to materialise yet (deployment={Id}, correlation={Correlation})",
msg.DeploymentId, msg.Correlation);
return;
return (true, "no usable artifact reached the address-space apply; the previously served address space was kept");
}
// Parse the artifact ONCE for this rebuild. Every consumer below (data-plane authority, then the
@@ -530,9 +593,31 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
if (plan.IsEmpty)
{
if (_lastAppliedDegraded)
{
// #601 review — the composition is unchanged but the last apply of it was a degraded
// rebuild: re-ensure every node (each Ensure* no-ops on a node that stands) so the
// re-deploy actually repairs what that rebuild could not place.
var repairFailed = _applier.MaterialiseHierarchy(composition)
+ MaterialiseNodes(plan, composition, fullComposition: true);
if (repairFailed > 0)
{
OtOpcUaTelemetry.OpcUaApplyFailed.Add(1, new KeyValuePair<string, object?>("kind", "nodes"));
_log.Error(
"OpcUaPublish: re-apply of a degraded composition is STILL degraded (correlation={Correlation}, failedNodes={FailedNodes})",
msg.Correlation, repairFailed);
return (true, $"re-apply of a previously degraded address space is still degraded (failedNodes={repairFailed})");
}
_lastAppliedDegraded = false;
_log.Info("OpcUaPublish: re-apply repaired the previously degraded address space (correlation={Correlation})",
msg.Correlation);
return (false, null);
}
_log.Debug("OpcUaPublish: rebuild requested but plan is empty (correlation={Correlation})",
msg.Correlation);
return;
return (false, null);
}
var outcome = _applier.Apply(plan);
@@ -575,6 +660,9 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
{
_lastApplied = composition;
_hasAppliedComposition = true;
// #601 review — advanced despite failures only on a rebuild; remember it so an identical
// re-deploy repairs instead of short-circuiting clean on the empty plan.
_lastAppliedDegraded = outcome.RebuildFailed || failedNodes > 0;
}
else
{
@@ -608,16 +696,19 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
"OpcUaPublish: address-space apply DEGRADED (correlation={Correlation}, rebuildFailed={RebuildFailed}, failedNodes={FailedNodes}, added={Added}, removed={Removed}, changed={Changed}) — the running address space may be stale or partial",
msg.Correlation, outcome.RebuildFailed, failedNodes,
outcome.AddedNodes, outcome.RemovedNodes, outcome.ChangedNodes);
return (true, outcome.RebuildFailed
? $"address-space rebuild failed (failedNodes={failedNodes})"
: $"address-space apply degraded (failedNodes={failedNodes}); not recorded as applied, the next deploy of this revision re-diffs and repairs it");
}
else
{
_log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
msg.Correlation, outcome.AddedNodes, outcome.RemovedNodes, outcome.ChangedNodes, outcome.RebuildCalled);
}
_log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
msg.Correlation, outcome.AddedNodes, outcome.RemovedNodes, outcome.ChangedNodes, outcome.RebuildCalled);
return (false, null);
}
catch (Exception ex)
{
_log.Error(ex, "OpcUaPublish: rebuild pipeline threw (correlation={Correlation})", msg.Correlation);
return (true, $"address-space apply threw: {ex.Message}");
}
}
@@ -201,6 +201,61 @@ public class MeshTransportDtoMapperTests
back.FailureReason.ShouldBeEmpty();
}
[Fact]
public void ApplyAck_Degraded_RoundTrips_WithItsReason()
{
// #601: an Applied ack whose address-space apply was DEGRADED must reach central as degraded —
// a node that loses the flag on the wire reports a clean apply it did not have.
var domain = new ApplyAck(
new DeploymentId(DeploymentGuid),
new NodeId("site-a-1"),
ApplyAckOutcome.Applied,
null,
new CorrelationId(CorrelationGuid),
Degraded: true,
DegradedReason: "failedNodes=3");
var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain)));
back.ShouldBe(domain);
back.Degraded.ShouldBeTrue();
back.DegradedReason.ShouldBe("failedNodes=3");
}
[Fact]
public void ApplyAck_Clean_EncodesNoDegradedFields()
{
var proto = MeshTransportDtoMapper.ToProto(new ApplyAck(
new DeploymentId(DeploymentGuid), new NodeId("site-a-1"), ApplyAckOutcome.Applied, null,
new CorrelationId(CorrelationGuid)));
proto.Degraded.ShouldBeFalse();
proto.HasDegradedReason.ShouldBeFalse();
}
[Fact]
public void ApplyAck_FromAnOldNode_WithoutTheDegradedFields_DecodesAsTodaysCleanAck()
{
// #601 compatibility: a node built before the degraded fields existed sends exactly fields 1-5.
// Its ack must decode exactly as it always did — not degraded, no reason.
var oldNodeBytes = new ApplyAckMessage
{
DeploymentId = DeploymentGuid.ToString("N"),
NodeId = "site-a-1",
Outcome = ApplyOutcome.Applied,
CorrelationId = CorrelationGuid.ToString("N"),
}.ToByteArray();
var back = MeshTransportDtoMapper.FromProto(ApplyAckMessage.Parser.ParseFrom(oldNodeBytes));
back.Outcome.ShouldBe(ApplyAckOutcome.Applied);
back.Degraded.ShouldBeFalse();
back.DegradedReason.ShouldBeNull();
back.ShouldBe(new ApplyAck(
new DeploymentId(DeploymentGuid), new NodeId("site-a-1"), ApplyAckOutcome.Applied, null,
new CorrelationId(CorrelationGuid)));
}
[Fact]
public void ApplyAck_UnspecifiedOutcome_Throws_RatherThanDecodingToAMeaningfulValue()
{
@@ -80,11 +80,13 @@ public class DeferredSinkForwardingReflectionTests
// v3 B4-WP2 dual-namespace invariant: a node id alone is no longer globally unique across the two
// namespaces (Raw vs UNS), so every sink method that NAMES a node must carry an AddressSpaceRealm
// discriminator — the sink resolves the namespace from the realm rather than parsing the id string.
// RebuildAddressSpace is the sole node-naming-free method (it clears BOTH realms). This guard locks in
// the realm surface so a future method can't be added that names a node without a realm.
// RebuildAddressSpace (it clears BOTH realms) and CompleteAddressSpaceApply (it closes an apply pass,
// Gitea #641) are the only node-naming-free methods. This guard locks in the realm surface so a future
// method can't be added that names a node without a realm.
foreach (var method in ForwardingInterfaces.SelectMany(i => i.GetMethods()))
{
if (method.Name == nameof(IOpcUaAddressSpaceSink.RebuildAddressSpace)) continue;
if (method.Name is nameof(IOpcUaAddressSpaceSink.RebuildAddressSpace)
or nameof(IOpcUaAddressSpaceSink.CompleteAddressSpaceApply)) continue;
method.GetParameters().Any(p => CarriesRealm(p.ParameterType)).ShouldBeTrue(
$"{method.DeclaringType!.Name}.{method.Name} names a node but has no AddressSpaceRealm parameter — " +
@@ -189,6 +189,80 @@ public sealed class AbCipAlarmProjectionTests
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
// Gitea #641 (item 4): the host routes a native transition on ConditionId == the subscribed RawPath and
// projects it from Kind. The projection used to emit "{ref}#active" with an Unspecified kind, so every
// ALMD transition was dropped as "no alarm condition" — and would have projected no state change even if
// it had routed.
AlarmEventArgs raised, cleared;
lock (events)
{
raised = events.First(e => e.Message.Contains("raised"));
cleared = events.First(e => e.Message.Contains("cleared"));
}
raised.ConditionId.ShouldBe("HighTemp");
raised.Kind.ShouldBe(AlarmTransitionKind.Raise);
cleared.ConditionId.ShouldBe("HighTemp");
cleared.Kind.ShouldBe(AlarmTransitionKind.Clear);
}
/// <summary>
/// Gitea #641 review — the server carries every surviving native condition's state (a client's
/// acknowledgement included) across an address-space rebuild, and the host re-subscribes alarms on every
/// reconnect and every alarm-set change. A re-subscribe used to start from an empty "last seen" record, so
/// a still-faulted ALMD was re-RAISED, which the host projects as Active+UNacked — un-acknowledging an alarm
/// an operator had already acknowledged. The record is the driver's, kept for refs that stay in the set.
/// </summary>
[Fact]
public async Task A_resubscribe_does_not_re_raise_a_still_faulted_alarm()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var raises = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { if (e.Kind == AlarmTransitionKind.Raise) lock (raises) raises.Add(e); };
var sub1 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForTagCreation(factory, "HighTemp");
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
await WaitForEventAsync(raises, _ => true);
// A reconnect / alarm-set change: the same ref, still faulted.
var sub2 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForMoreReadsAsync(factory, "HighTemp", 6);
lock (raises) raises.Count.ShouldBe(1, "a re-subscribe must not re-raise (and so un-acknowledge) a still-faulted alarm");
await drv.UnsubscribeAlarmsAsync(sub1, CancellationToken.None);
await drv.UnsubscribeAlarmsAsync(sub2, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Gitea #641 review — positive control: a ref that LEFT the subscribed set was materialised fresh
/// (inactive) when it came back, so it is raised again.</summary>
[Fact]
public async Task A_ref_that_left_the_set_and_came_back_is_raised_again()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var raises = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { if (e.Kind == AlarmTransitionKind.Raise) lock (raises) raises.Add(e); };
await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForTagCreation(factory, "HighTemp");
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
await WaitForEventAsync(raises, _ => true);
await drv.SubscribeAlarmsAsync([], CancellationToken.None);
await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForEventAsync(raises, _ => raises.Count >= 2);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that unsubscribing stops the alarm poll loop.</summary>
@@ -0,0 +1,55 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
/// <summary>
/// #646 against <c>ab_server</c>: <c>N7:[N7:0]</c> reads the index word, then the word it names, on both
/// transports. Writes N7:0 and N7:5 (simulator-only, gated by <see cref="IntegrationWriteGate"/>).
/// </summary>
[Collection(AbLegacyServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Simulator", "ab_server-PCCC")]
public sealed class AbLegacyIndirectAddressingTests(AbLegacyServerFixture sim)
{
/// <summary>Seeds N7:0=5 and N7:5=42, then reads and writes through <c>N7:[N7:0]</c>.</summary>
/// <param name="transport">The transport stack the driver runs on.</param>
[AbLegacyTheory]
[MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))]
public async Task Slc500_indirect_word_read_and_write_resolve_through_the_index(AbLegacyTransport transport)
{
if (sim.SkipReasonFor(KnownProfiles.Slc500) is { } skip) Assert.Skip(skip);
IntegrationWriteGate.SkipUnlessWritesAllowed();
var deviceUri = $"ab://{sim.Host}:{sim.PortFor(KnownProfiles.Slc500)}/{sim.CipPath}";
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(deviceUri, AbLegacyPlcFamily.Slc500)],
RawTags = AbLegacyRawTags.Entries(
new AbLegacyTagDefinition("Index", deviceUri, "N7:0", AbLegacyDataType.Int, Writable: true),
new AbLegacyTagDefinition("Target", deviceUri, "N7:5", AbLegacyDataType.Int, Writable: true),
new AbLegacyTagDefinition("Indirect", deviceUri, "N7:[N7:0]", AbLegacyDataType.Int, Writable: true)),
Timeout = TimeSpan.FromSeconds(5),
Probe = new AbLegacyProbeOptions { Enabled = false },
Transport = transport,
}, driverInstanceId: $"ablegacy-indirect-{transport}");
var ct = TestContext.Current.CancellationToken;
await drv.InitializeAsync("{}", ct);
var seeded = await drv.WriteAsync(
[new WriteRequest("Index", (short)5), new WriteRequest("Target", (short)42)], ct);
seeded.ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good);
var read = (await drv.ReadAsync(["Indirect"], ct)).Single();
read.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
Convert.ToInt32(read.Value).ShouldBe(42);
// Write through the indirection lands on N7:5.
(await drv.WriteAsync([new WriteRequest("Indirect", (short)77)], ct)).Single()
.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var direct = (await drv.ReadAsync(["Target"], ct)).Single();
Convert.ToInt32(direct.Value).ShouldBe(77);
}
}
@@ -181,28 +181,6 @@ public sealed class AbLegacyFamilyAddressingTests
factory.Tags["I:15/15"].ReadCount.ShouldBe(1);
}
/// <summary>An indirect tag is answered BadNotSupported on read and write, and no runtime is ever created for it.</summary>
[Fact]
public async Task Indirect_tag_is_BadNotSupported_and_creates_no_runtime()
{
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
new AbLegacyTagDefinition("Ind", "ab://10.0.0.5/1,0", "N7:[N7:0]", AbLegacyDataType.Int, Writable: true),
new AbLegacyTagDefinition("Ok", "ab://10.0.0.5/1,0", "N7:1", AbLegacyDataType.Int, Writable: true));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 5 };
var read = await drv.ReadAsync(["Ind", "Ok"], CancellationToken.None);
var write = await drv.WriteAsync(
[new WriteRequest("Ind", 1), new WriteRequest("Ok", 1)], CancellationToken.None);
read[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
write[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
// positive control: the neighbouring direct tag in the same batch still works, so the refusal is per-tag
read[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
write[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
factory.Tags.Keys.ShouldNotContain(k => k.Contains('['));
}
/// <summary>A MicroLogix function-file tag reaches the transport under its libplctag name.</summary>
[Fact]
public async Task MicroLogix_function_file_tag_reaches_the_transport()
@@ -0,0 +1,131 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>#646: indirect addresses (N7:[N7:0], N[N7:0]:5) resolve at read/write time.</summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyIndirectAddressingTests
{
private const string Host = "ab://10.0.0.5/1,0";
private static async Task<(AbLegacyDriver drv, FakeAbLegacyTagFactory factory)> NewDriverAsync(
params AbLegacyTagDefinition[] tags)
{
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500)],
RawTags = AbLegacyRawTags.Entries(tags),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
return (drv, factory);
}
private static void Seed(FakeAbLegacyTagFactory f, params (string name, object value)[] values)
{
var map = values.ToDictionary(v => v.name, v => v.value, StringComparer.OrdinalIgnoreCase);
f.Customise = p => new FakeAbLegacyTag(p) { Value = map.TryGetValue(p.TagName, out var v) ? v : 0 };
}
[Fact]
public async Task Word_indirect_reads_index_then_composed_address()
{
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
Seed(f, ("N7:0", (short)5), ("N7:5", (short)42));
var r = (await drv.ReadAsync(["Ind"], CancellationToken.None)).Single();
r.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
r.Value.ShouldBe((short)42);
f.Tags["N7:0"].ReadCount.ShouldBe(1);
f.Tags["N7:5"].ReadCount.ShouldBe(1);
f.Tags.Keys.ShouldNotContain(k => k.Contains('['));
}
[Fact]
public async Task File_indirect_resolves_the_file_number()
{
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N[N7:0]:5", AbLegacyDataType.Int));
Seed(f, ("N7:0", (short)9), ("N9:5", (short)77));
var r = (await drv.ReadAsync(["Ind"], CancellationToken.None)).Single();
r.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
r.Value.ShouldBe((short)77);
f.Tags.ContainsKey("N9:5").ShouldBeTrue();
}
[Fact]
public async Task Index_is_re_read_every_poll_never_cached()
{
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
Seed(f, ("N7:0", (short)5), ("N7:5", (short)42), ("N7:6", (short)43));
(await drv.ReadAsync(["Ind"], CancellationToken.None)).Single().Value.ShouldBe((short)42);
f.Tags["N7:0"].Value = (short)6;
(await drv.ReadAsync(["Ind"], CancellationToken.None)).Single().Value.ShouldBe((short)43);
f.Tags["N7:0"].ReadCount.ShouldBe(2);
// the stale resolution's handle is released when the index moves
f.Tags["N7:5"].Disposed.ShouldBeTrue();
}
[Theory]
[InlineData(-1)]
[InlineData(256)]
[InlineData(30000)]
public async Task Out_of_range_index_is_BadOutOfRange_without_health_impact(int index)
{
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
Seed(f, ("N7:0", (short)index));
var r = (await drv.ReadAsync(["Ind"], CancellationToken.None)).Single();
r.StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
drv.GetHealth().State.ShouldNotBe(DriverState.Degraded);
f.Tags.Keys.ShouldNotContain(k => k.StartsWith("N7:") && k != "N7:0");
}
[Fact]
public async Task Inner_read_failure_propagates_its_status()
{
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
f.Customise = p => new FakeAbLegacyTag(p) { Value = (short)5, Status = p.TagName == "N7:0" ? -14 : 0 };
var r = (await drv.ReadAsync(["Ind"], CancellationToken.None)).Single();
r.StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good);
r.StatusCode.ShouldNotBe(AbLegacyStatusMapper.BadOutOfRange);
f.Tags.ContainsKey("N7:5").ShouldBeFalse();
}
[Fact]
public async Task Write_resolves_index_then_writes_the_composed_address()
{
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int, Writable: true));
Seed(f, ("N7:0", (short)5));
var w = (await drv.WriteAsync([new WriteRequest("Ind", 99)], CancellationToken.None)).Single();
w.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
f.Tags["N7:5"].WriteCount.ShouldBe(1);
f.Tags["N7:5"].Value.ShouldBe((short)99);
f.Tags["N7:0"].WriteCount.ShouldBe(0);
}
[Fact]
public async Task Write_with_bad_index_is_BadOutOfRange_and_writes_nothing()
{
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int, Writable: true));
Seed(f, ("N7:0", (short)-3));
var w = (await drv.WriteAsync([new WriteRequest("Ind", 1)], CancellationToken.None)).Single();
w.StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
}
}
@@ -0,0 +1,179 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series;
/// <summary>
/// Live-wire coverage (focas-mock) for #257 (<c>cnc_statinfo</c> status flags) and #266 (PMC
/// range coalescing). Skips cleanly when <c>OTOPCUA_FOCAS_SIM_ENDPOINT</c> (default
/// <c>localhost:8193</c>) is unreachable. Read-only: no write path is exercised.
/// </summary>
[Collection(FocasSimCollection.Name)]
public sealed class WireBackendStatusAndPmcTests
{
private readonly FocasSimFixture _fx;
/// <summary>Initializes a new instance of the <see cref="WireBackendStatusAndPmcTests"/> class.</summary>
/// <param name="fx">The FOCAS simulator fixture.</param>
public WireBackendStatusAndPmcTests(FocasSimFixture fx) => _fx = fx;
private string DeviceHost => _fx.DeviceUri;
/// <summary>The mock's <c>cnc_statinfo</c> flags surface through the Status/ subtree and an authored FIXED: tag.</summary>
[Fact]
public async Task Status_flags_populate_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
sysinfo = new
{
addinfo = 0, max_axis = 8, cnc_type = "M ", mt_type = "M ",
series = "30i ", version = "A1.0", axes = "1 ",
},
axis_names = new[] { "X" },
statinfo = new { aut = 1, run = 3, motion = 1, mstb = 0, emergency = 0, alarm = 1, edit = 0 },
}, ct);
var driver = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
RawTags = FocasRawTags.Entries(
new FocasTagDefinition("RunFlag", DeviceHost, "FIXED:Status/Run", FocasDataType.Int32, Writable: false)),
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(100),
ProgramPollInterval = TimeSpan.FromMilliseconds(200),
},
}, driverInstanceId: "focas-wire-status", clientFactory: new WireFocasClientFactory());
await using (driver)
{
await driver.InitializeAsync("{}", ct);
await WaitFor(() => driver.GetDeviceState(DeviceHost) is { LastStatus: not null }, TimeSpan.FromSeconds(5));
var state = driver.GetDeviceState(DeviceHost);
state.ShouldNotBeNull();
state.LastStatus.ShouldNotBeNull();
state.LastStatus.Run.ShouldBe(3);
state.LastStatus.Alarm.ShouldBe(1);
var snaps = await driver.ReadAsync(["RunFlag", $"{DeviceHost}/Status/Alarm", $"{DeviceHost}/Status/RunText"], ct);
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
snaps[0].Value.ShouldBe(3);
snaps[1].Value.ShouldBe(1);
snaps[2].Value.ShouldBe("START");
// The flags follow the machine.
await _fx.PatchStateAsync(new { statinfo = new { run = 1, emergency = 1 } }, ct);
await WaitFor(() => driver.GetDeviceState(DeviceHost) is { LastStatus.Run: 1 }, TimeSpan.FromSeconds(5));
driver.GetDeviceState(DeviceHost)!.LastStatus!.Emergency.ShouldBe(1);
}
}
/// <summary>
/// A poll batch of adjacent PMC bytes is served by ONE range read against the live wire, every tag gets
/// its own value, and a tag outside the merge window still reads through the per-tag path. Seeds are
/// byte-typed: the mock serves one entry per ADDRESS for every PMC type, which matches the real
/// byte-addressed range only for width 1.
/// </summary>
[Fact]
public async Task Adjacent_pmc_bytes_coalesce_into_one_range_read_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
pmc = new { R = new Dictionary<string, object>
{
["200"] = new { type = "byte", value = 17 },
["201"] = new { type = "byte", value = 200 },
["202"] = new { type = "byte", value = 0b0000_0100 },
["203"] = new { type = "byte", value = 9 },
["900"] = new { type = "byte", value = 33 },
}},
}, ct);
var counting = new CountingFactory();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
RawTags = FocasRawTags.Entries(
new FocasTagDefinition("R200", DeviceHost, "R200", FocasDataType.Byte, Writable: false),
new FocasTagDefinition("R201", DeviceHost, "R201", FocasDataType.Byte, Writable: false),
new FocasTagDefinition("R202b2", DeviceHost, "R202.2", FocasDataType.Bit, Writable: false),
new FocasTagDefinition("R203", DeviceHost, "R203", FocasDataType.Byte, Writable: false),
new FocasTagDefinition("R900", DeviceHost, "R900", FocasDataType.Byte, Writable: false)),
Probe = new FocasProbeOptions { Enabled = false },
}, driverInstanceId: "wire-pmc-coalesce", clientFactory: counting);
await using (drv)
{
await drv.InitializeAsync("{}", ct);
var snaps = await drv.ReadAsync(["R200", "R201", "R202b2", "R203", "R900"], ct);
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
Convert.ToInt32(snaps[0].Value).ShouldBe(17);
Convert.ToInt32(snaps[1].Value).ShouldBe(-56); // 200 as a signed byte
snaps[2].Value.ShouldBe(true);
Convert.ToInt32(snaps[3].Value).ShouldBe(9);
Convert.ToInt32(snaps[4].Value).ShouldBe(33);
counting.Client.ShouldNotBeNull();
counting.Client.RangeReads.ShouldBe(1, "R200..R203 merge into one pmc_rdpmcrng");
counting.Client.SingleReads.ShouldBe(1, "R900 is outside the gap window and reads on its own");
}
}
private sealed class CountingFactory : IFocasClientFactory
{
private readonly WireFocasClientFactory _inner = new();
public CountingClient? Client { get; private set; }
public void EnsureUsable() => _inner.EnsureUsable();
public IFocasClient Create() => Client = new CountingClient(_inner.Create());
}
private sealed class CountingClient(IFocasClient inner) : IFocasClient
{
public int RangeReads { get; private set; }
public int SingleReads { get; private set; }
public bool IsConnected => inner.IsConnected;
public Task ConnectAsync(FocasHostAddress a, TimeSpan t, CancellationToken ct) => inner.ConnectAsync(a, t, ct);
public Task<(object? value, uint status)> ReadAsync(FocasAddress a, FocasDataType t, CancellationToken ct)
{ SingleReads++; return inner.ReadAsync(a, t, ct); }
public Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(string l, FocasDataType t, int s, int e, CancellationToken ct)
{ RangeReads++; return inner.ReadPmcSlotsAsync(l, t, s, e, ct); }
public Task<uint> WriteAsync(FocasAddress a, FocasDataType t, object? v, CancellationToken ct) => inner.WriteAsync(a, t, v, ct);
public Task<bool> ProbeAsync(CancellationToken ct) => inner.ProbeAsync(ct);
public Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct) => inner.ReadAlarmsAsync(ct);
public Task<FocasSysInfo> GetSysInfoAsync(CancellationToken ct) => inner.GetSysInfoAsync(ct);
public Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken ct) => inner.GetAxisNamesAsync(ct);
public Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken ct) => inner.GetSpindleNamesAsync(ct);
public Task<FocasDynamicSnapshot> ReadDynamicAsync(int i, CancellationToken ct) => inner.ReadDynamicAsync(i, ct);
public Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) => inner.GetProgramInfoAsync(ct);
public Task<FocasStatus> GetStatusAsync(CancellationToken ct) => inner.GetStatusAsync(ct);
public Task<FocasTimer> GetTimerAsync(FocasTimerKind k, CancellationToken ct) => inner.GetTimerAsync(k, ct);
public Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken ct) => inner.GetServoLoadsAsync(ct);
public Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken ct) => inner.GetSpindleLoadsAsync(ct);
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken ct) => inner.GetSpindleMotorSpeedsAsync(ct);
public void Dispose() => inner.Dispose();
}
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (pred()) return;
await Task.Delay(50);
}
}
}
@@ -56,6 +56,51 @@ internal class FakeFocasClient : IFocasClient
return Task.FromResult((value, status));
}
/// <summary>PMC byte memory the range read serves, keyed by (upper-case letter, byte address); absent = 0.</summary>
public Dictionary<(string Letter, int Address), byte> PmcMemory { get; } = new();
/// <summary>Gets the log of range reads issued (letter, slot type, start, end).</summary>
public List<(string Letter, FocasDataType SlotType, int Start, int End)> PmcRangeReads { get; } = new();
/// <summary>Ranges (letter, start, end) the fake CNC rejects with <see cref="PmcRangeRejectStatus"/>.</summary>
public List<Func<string, int, int, bool>> PmcRangeRejectors { get; } = new();
/// <summary>The status a rejected range returns.</summary>
public uint PmcRangeRejectStatus { get; set; } = FocasStatusMapper.BadOutOfRange;
/// <summary>When true every range read throws (transport failure).</summary>
public bool ThrowOnPmcRange { get; set; }
/// <summary>Reads a PMC range as raw big-endian slots from <see cref="PmcMemory"/>.</summary>
/// <param name="letter">PMC letter.</param>
/// <param name="slotType">Slot type.</param>
/// <param name="start">First byte.</param>
/// <param name="end">Last byte, inclusive.</param>
/// <param name="ct">The cancellation token.</param>
public virtual Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(
string letter, FocasDataType slotType, int start, int end, CancellationToken ct)
{
PmcRangeReads.Add((letter, slotType, start, end));
if (ThrowOnPmcRange) throw new InvalidOperationException("transport down");
if (PmcRangeRejectors.Any(r => r(letter, start, end)))
return Task.FromResult<(IReadOnlyList<long>?, uint)>((null, PmcRangeRejectStatus));
var width = slotType switch
{
FocasDataType.Int16 => 2,
FocasDataType.Int32 or FocasDataType.Float32 => 4,
FocasDataType.Float64 => 8,
_ => 1,
};
var slots = new List<long>();
for (var o = start; o + width - 1 <= end; o += width)
{
long v = 0;
for (var k = 0; k < width; k++)
v = (v << 8) | (PmcMemory.TryGetValue((letter, o + k), out var b) ? b : (byte)0);
// Sign-extend like the wire parser (Int16/Int32/Int64 big-endian); width 1 stays 0..255.
v = width switch { 2 => (short)v, 4 => (int)v, _ => v };
slots.Add(v);
}
return Task.FromResult<(IReadOnlyList<long>?, uint)>((slots, FocasStatusMapper.Good));
}
/// <summary>Writes a value to a FOCAS address asynchronously.</summary>
/// <param name="address">The FOCAS address to write to.</param>
/// <param name="type">The data type of the value.</param>
@@ -121,6 +166,21 @@ internal class FakeFocasClient : IFocasClient
public virtual Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) =>
Task.FromResult(ProgramInfo);
/// <summary>Gets or sets the cnc_statinfo status snapshot.</summary>
public FocasStatus Status { get; set; } = new(1, 3, 1, 0, 0, 0, 0, 0);
/// <summary>Gets or sets a value indicating whether <see cref="GetStatusAsync"/> throws (older series / refused call).</summary>
public bool ThrowOnStatus { get; set; }
/// <summary>Gets the number of status reads served.</summary>
public int StatusReads { get; private set; }
/// <summary>Gets the CNC status flags asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<FocasStatus> GetStatusAsync(CancellationToken ct)
{
StatusReads++;
if (ThrowOnStatus) throw new InvalidOperationException("cnc_statinfo refused");
return Task.FromResult(Status);
}
/// <summary>Gets the dictionary of timers keyed by timer kind.</summary>
public Dictionary<FocasTimerKind, FocasTimer> Timers { get; } = [];
/// <summary>Gets timer data asynchronously.</summary>
@@ -0,0 +1,111 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>Gitea #266 — the pure planner behind PMC range coalescing.</summary>
[Trait("Category", "Unit")]
public sealed class FocasPmcCoalescerTests
{
private static FocasPmcReadItem B(int index, string letter, int number, FocasDataType t = FocasDataType.Byte, int? bit = null) =>
new(index, letter, number, t, bit);
[Fact]
public void Adjacent_bytes_merge_into_one_range()
{
var plans = FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 101), B(2, "R", 102)], 128, 8);
var p = plans.ShouldHaveSingleItem();
(p.Letter, p.Start, p.End).ShouldBe(("R", 100, 102));
p.Members.Count.ShouldBe(3);
}
[Fact]
public void A_gap_within_the_threshold_merges_and_beyond_it_splits()
{
FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 108)], 128, 8).Count.ShouldBe(1); // 7 byte gap
FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 110)], 128, 8).Count.ShouldBe(2); // 9 byte gap
FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 102)], 128, 0).Count.ShouldBe(2); // gap budget 0
FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 101)], 128, 0).Count.ShouldBe(1); // adjacent still fine
}
[Fact]
public void The_span_cap_is_a_hard_limit_on_range_length()
{
var items = Enumerable.Range(0, 10).Select(i => B(i, "R", 100 + i)).ToList();
var plans = FocasPmcCoalescer.Plan(items, maxSpanBytes: 4, maxGapBytes: 8);
plans.Select(p => p.End - p.Start + 1).ShouldAllBe(len => len <= 4);
plans.Sum(p => p.Members.Count).ShouldBe(10);
plans.Count.ShouldBe(3); // 4 + 4 + 2
}
[Fact]
public void Different_letters_never_merge()
{
var plans = FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "D", 101), B(2, "R", 101)], 128, 8);
plans.Count.ShouldBe(2);
plans.Single(p => p.Letter == "R").Members.Count.ShouldBe(2);
plans.Single(p => p.Letter == "D").Members.Count.ShouldBe(1);
}
[Fact]
public void Bits_of_one_byte_share_a_slot_and_bits_and_bytes_share_a_range()
{
var plans = FocasPmcCoalescer.Plan(
[B(0, "X", 0, FocasDataType.Bit, 0), B(1, "X", 0, FocasDataType.Bit, 7), B(2, "X", 1)], 128, 8);
var p = plans.ShouldHaveSingleItem();
(p.Start, p.End).ShouldBe((0, 1));
p.SlotOf(p.Members.Single(m => m.Index == 2)).ShouldBe(1);
}
[Fact]
public void Words_merge_only_when_aligned_to_the_range_start_and_the_range_is_whole_slots()
{
// 100,102 aligned -> one range [100,103]; 105 is unaligned (offset 5) -> its own range.
var plans = FocasPmcCoalescer.Plan(
[B(0, "D", 100, FocasDataType.Int16), B(1, "D", 102, FocasDataType.Int16), B(2, "D", 105, FocasDataType.Int16)], 128, 8);
plans.Count.ShouldBe(2);
var first = plans.Single(p => p.Start == 100);
(first.End, first.SlotWidth, first.Members.Count).ShouldBe((103, 2, 2));
first.SlotOf(first.Members.Single(m => m.Number == 102)).ShouldBe(1);
plans.Single(p => p.Start == 105).Members.Count.ShouldBe(1);
}
[Fact]
public void A_word_range_with_a_gap_is_rounded_up_to_whole_slots()
{
// 100 (word) + 103 (word): offset 3 is unaligned -> split; 100 + 104 aligned, gap 2 -> merged, end 105.
var plans = FocasPmcCoalescer.Plan(
[B(0, "D", 100, FocasDataType.Int16), B(1, "D", 104, FocasDataType.Int16)], 128, 8);
var p = plans.ShouldHaveSingleItem();
(p.Start, p.End).ShouldBe((100, 105));
((p.End - p.Start + 1) % p.SlotWidth).ShouldBe(0);
}
[Fact]
public void Different_widths_on_one_letter_do_not_merge()
{
FocasPmcCoalescer.Plan([B(0, "D", 100, FocasDataType.Byte), B(1, "D", 101, FocasDataType.Int16)], 128, 8)
.Count.ShouldBe(2);
}
[Fact]
public void Every_input_appears_in_exactly_one_plan_and_input_order_is_irrelevant()
{
var items = new[] { B(0, "R", 300), B(1, "R", 100), B(2, "R", 102), B(3, "R", 101), B(4, "R", 104), B(5, "R", 300) };
var a = FocasPmcCoalescer.Plan(items, 128, 8);
var b = FocasPmcCoalescer.Plan(items.Reverse(), 128, 8);
a.SelectMany(p => p.Members).Select(m => m.Index).OrderBy(x => x).ShouldBe([0, 1, 2, 3, 4, 5]);
a.Select(p => (p.Start, p.End)).ShouldBe(b.Select(p => (p.Start, p.End)));
}
[Fact]
public void Slot_decode_matches_the_per_tag_mapping()
{
FocasPmcCoalescer.MapSlot(0b0000_0100, FocasDataType.Bit, 2).ShouldBe(true);
FocasPmcCoalescer.MapSlot(0b0000_0100, FocasDataType.Bit, 3).ShouldBe(false);
FocasPmcCoalescer.MapSlot(0xFF, FocasDataType.Byte, null).ShouldBe((sbyte)-1);
FocasPmcCoalescer.MapSlot(-2, FocasDataType.Int16, null).ShouldBe((short)-2);
FocasPmcCoalescer.MapSlot(BitConverter.SingleToInt32Bits(1.5f), FocasDataType.Float32, null).ShouldBe(1.5f);
}
}
@@ -0,0 +1,243 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>Gitea #266 — driver-level behaviour of PMC range coalescing in a poll batch.</summary>
[Trait("Category", "Unit")]
public sealed class FocasPmcCoalescingDriverTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasTagDefinition Tag(string name, string addr, FocasDataType t) =>
new(name, Host, addr, t, Writable: false);
private static async Task<(FocasDriver drv, FakeFocasClient client)> StartAsync(
FocasPmcCoalesceOptions? opts, params FocasTagDefinition[] tags)
{
var client = new FakeFocasClient();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Probe = new FocasProbeOptions { Enabled = false },
RawTags = FocasRawTags.Entries(tags),
PmcCoalesce = opts ?? new FocasPmcCoalesceOptions(),
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
await drv.InitializeAsync("{}", CancellationToken.None);
return (drv, client);
}
[Fact]
public async Task Adjacent_pmc_tags_are_served_by_one_range_read_and_sliced_back_per_tag()
{
var (drv, c) = await StartAsync(null,
Tag("A", "R100", FocasDataType.Byte),
Tag("B", "R101", FocasDataType.Byte),
Tag("Bit", "R102.3", FocasDataType.Bit),
Tag("Far", "R900", FocasDataType.Byte));
c.PmcMemory[("R", 100)] = 0x11;
c.PmcMemory[("R", 101)] = 0xFF;
c.PmcMemory[("R", 102)] = 0b0000_1000;
c.Values["R900"] = (sbyte)7;
var r = await drv.ReadAsync(["A", "B", "Bit", "Far"], CancellationToken.None);
c.PmcRangeReads.ShouldBe([("R", FocasDataType.Byte, 100, 102)]);
r[0].Value.ShouldBe((sbyte)0x11);
r[1].Value.ShouldBe((sbyte)-1);
r[2].Value.ShouldBe(true);
r[3].Value.ShouldBe((sbyte)7); // lone tag: ordinary per-tag path, no range read of its own
r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Aligned_words_decode_from_their_own_slot()
{
var (drv, c) = await StartAsync(null,
Tag("W0", "D200", FocasDataType.Int16),
Tag("W1", "D202", FocasDataType.Int16));
c.PmcMemory[("D", 200)] = 0x01; c.PmcMemory[("D", 201)] = 0x02; // 0x0102 = 258
c.PmcMemory[("D", 202)] = 0xFF; c.PmcMemory[("D", 203)] = 0xFE; // -2
var r = await drv.ReadAsync(["W0", "W1"], CancellationToken.None);
c.PmcRangeReads.ShouldBe([("D", FocasDataType.Int16, 200, 203)]);
r[0].Value.ShouldBe((short)258);
r[1].Value.ShouldBe((short)-2);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_single_pmc_tag_never_issues_a_range_read()
{
var (drv, c) = await StartAsync(null, Tag("A", "R100", FocasDataType.Byte));
c.Values["R100"] = (sbyte)5;
var r = await drv.ReadAsync(["A"], CancellationToken.None);
c.PmcRangeReads.ShouldBeEmpty();
r[0].Value.ShouldBe((sbyte)5);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Disabling_coalescing_restores_per_tag_reads()
{
var (drv, c) = await StartAsync(new FocasPmcCoalesceOptions { Enabled = false },
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
c.Values["R100"] = (sbyte)1; c.Values["R101"] = (sbyte)2;
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
c.PmcRangeReads.ShouldBeEmpty();
r[0].Value.ShouldBe((sbyte)1);
r[1].Value.ShouldBe((sbyte)2);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task The_span_cap_and_gap_threshold_from_options_are_honoured()
{
var (drv, c) = await StartAsync(new FocasPmcCoalesceOptions { MaxSpanBytes = 2, MaxGapBytes = 0 },
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte), Tag("C", "R102", FocasDataType.Byte));
c.Values["R102"] = (sbyte)9;
var r = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
c.PmcRangeReads.ShouldBe([("R", FocasDataType.Byte, 100, 101)]); // C is alone -> per-tag
r[2].Value.ShouldBe((sbyte)9);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_range_the_cnc_rejects_falls_back_to_per_tag_reads_with_their_own_status_and_is_remembered()
{
var (drv, c) = await StartAsync(null,
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R106", FocasDataType.Byte));
c.PmcRangeRejectors.Add((_, s, e) => e - s + 1 > 2); // the spare gap bytes are not valid addresses
c.Values["R100"] = (sbyte)1;
c.Values["R106"] = (sbyte)2;
c.ReadStatuses["R106"] = FocasStatusMapper.BadNodeIdUnknown; // B is genuinely bad; A must NOT inherit that
var r1 = await drv.ReadAsync(["A", "B"], CancellationToken.None);
r1[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
r1[0].Value.ShouldBe((sbyte)1);
r1[1].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
c.PmcRangeReads.Count.ShouldBe(1);
await drv.ReadAsync(["A", "B"], CancellationToken.None);
c.PmcRangeReads.Count.ShouldBe(1, "a rejected range is remembered; later polls go straight to per-tag reads");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_refusal_with_an_unmapped_rc_that_maps_to_BadCommunicationError_still_falls_back_per_tag()
{
var (drv, c) = await StartAsync(null,
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
c.PmcRangeRejectors.Add((_, _, _) => true);
c.PmcRangeRejectStatus = FocasStatusMapper.BadCommunicationError; // what an unlisted rc / EW_PARITY / EW_SOCKET maps to
c.Values["R100"] = (sbyte)1; c.Values["R101"] = (sbyte)2;
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
r.Select(s => s.Value).ShouldBe([(object)(sbyte)1, (sbyte)2]);
r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
await drv.ReadAsync(["A", "B"], CancellationToken.None);
c.PmcRangeReads.Count.ShouldBe(1, "a deterministic rc refusal is memoised");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_transient_busy_refusal_is_not_memoised()
{
var (drv, c) = await StartAsync(null,
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
var busy = true;
c.PmcRangeRejectors.Add((_, _, _) => busy);
c.PmcRangeRejectStatus = FocasStatusMapper.BadDeviceFailure; // EW_BUSY
c.Values["R100"] = (sbyte)1; c.Values["R101"] = (sbyte)2;
(await drv.ReadAsync(["A", "B"], CancellationToken.None)).ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
busy = false;
c.PmcMemory[("R", 100)] = 5; c.PmcMemory[("R", 101)] = 6;
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
c.PmcRangeReads.Count.ShouldBe(2, "the range is retried once the CNC is no longer busy");
r.Select(s => s.Value).ShouldBe([(object)(sbyte)5, (sbyte)6]);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_down_device_costs_one_connect_attempt_per_batch_not_one_per_plan_and_tag()
{
var client = new FakeFocasClient { ThrowOnConnect = true };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Probe = new FocasProbeOptions { Enabled = false },
RawTags = FocasRawTags.Entries(new[]
{
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte),
Tag("C", "R900", FocasDataType.Byte), Tag("D", "R2000", FocasDataType.Byte),
}),
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
await drv.InitializeAsync("{}", CancellationToken.None);
var before = client.ConnectCount;
var r = await drv.ReadAsync(["A", "B", "C", "D"], CancellationToken.None);
r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
(client.ConnectCount - before).ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_transport_failure_fails_every_member_without_per_tag_retries()
{
var (drv, c) = await StartAsync(null,
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
c.ThrowOnPmcRange = true;
c.Values["R100"] = (sbyte)1; // would read Good if the driver wrongly fell back
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
r.ShouldAllBe(s => s.Value == null);
c.PmcRangeReads.Count.ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Result_order_follows_the_request_when_pmc_and_non_pmc_tags_are_mixed()
{
var (drv, c) = await StartAsync(null,
Tag("P", "PARAM:6711", FocasDataType.Int32),
Tag("A", "R100", FocasDataType.Byte),
Tag("B", "R101", FocasDataType.Byte));
c.Values["PARAM:6711"] = 1234;
c.PmcMemory[("R", 100)] = 3; c.PmcMemory[("R", 101)] = 4;
var r = await drv.ReadAsync(["B", "P", "A"], CancellationToken.None);
r[0].Value.ShouldBe((sbyte)4);
r[1].Value.ShouldBe(1234);
r[2].Value.ShouldBe((sbyte)3);
c.PmcRangeReads.Count.ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Duplicate_references_in_one_batch_each_get_their_value()
{
var (drv, c) = await StartAsync(null, Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
c.PmcMemory[("R", 100)] = 3; c.PmcMemory[("R", 101)] = 4;
var r = await drv.ReadAsync(["A", "B", "A"], CancellationToken.None);
r.Select(s => s.Value).ShouldBe([(object)(sbyte)3, (sbyte)4, (sbyte)3]);
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,173 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Gitea #257 — <c>cnc_statinfo</c> (Auto / Run / Motion / Mstb / Emergency / Alarm / Edit / TmMode)
/// surfaces as a read-only <c>Status/</c> fixed-tree subtree, authorable as <c>FIXED:Status/Run</c>.
/// The wire client already decoded the call (<c>FocasWireClient.ReadStatusAsync</c>) but nothing consumed it.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasStatusFlagsTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasDriver NewDriver(FakeFocasClient client, params FocasTagDefinition[] tags) =>
new(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Probe = new FocasProbeOptions { Enabled = false },
RawTags = FocasRawTags.Entries(tags),
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(10),
ProgramPollInterval = TimeSpan.FromMilliseconds(10),
},
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
private static async Task WaitAsync(Func<bool> predicate)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < deadline && !predicate()) await Task.Delay(15);
predicate().ShouldBeTrue("condition never became true");
}
[Fact]
public void The_wire_status_block_maps_field_for_field()
{
var s = Wire.WireFocasClient.MapStatus(new Wire.WireStatus(Auto: 1, Run: 2, Motion: 3, Mstb: 4, Emergency: 5, Alarm: 6, Edit: 7, TmMode: 8));
s.ShouldBe(new FocasStatus(1, 2, 3, 4, 5, 6, 7, 8));
}
[Theory]
[InlineData(0, "RESET")]
[InlineData(1, "STOP")]
[InlineData(2, "HOLD")]
[InlineData(3, "START")]
[InlineData(4, "MSTR")]
[InlineData(9, "Run9")]
public void Run_state_text(int run, string text) => FocasRunState.ToText(run).ShouldBe(text);
[Fact]
public async Task Status_flags_read_good_through_the_fixed_tree_references()
{
var client = new FakeFocasClient { Status = new FocasStatus(Auto: 1, Run: 3, Motion: 1, Mstb: 2, Emergency: 0, Alarm: 1, Edit: 0, TmMode: 4) };
var drv = NewDriver(client);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus is not null);
string[] fields = ["Auto", "Run", "Motion", "Mstb", "Emergency", "Alarm", "Edit", "TmMode"];
int[] expected = [1, 3, 1, 2, 0, 1, 0, 4];
var read = await drv.ReadAsync([.. fields.Select(f => $"{Host}/Status/{f}")], CancellationToken.None);
for (var i = 0; i < fields.Length; i++)
{
read[i].StatusCode.ShouldBe(FocasStatusMapper.Good, fields[i]);
read[i].Value.ShouldBe(expected[i], fields[i]);
}
var text = await drv.ReadAsync([$"{Host}/Status/RunText"], CancellationToken.None);
text[0].Value.ShouldBe("START");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task An_authored_FIXED_Status_tag_reads_the_flag()
{
var client = new FakeFocasClient { Status = new FocasStatus(1, 3, 1, 0, 1, 0, 0, 0) };
var drv = NewDriver(client,
new FocasTagDefinition("EStop", Host, "FIXED:Status/Emergency", FocasDataType.Int32, Writable: false));
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus is not null);
var snap = (await drv.ReadAsync(["EStop"], CancellationToken.None))[0];
snap.StatusCode.ShouldBe(FocasStatusMapper.Good);
snap.Value.ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Status_updates_follow_the_machine()
{
var client = new FakeFocasClient { Status = new FocasStatus(1, 0, 0, 0, 0, 0, 0, 0) };
var drv = NewDriver(client);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus is not null);
client.Status = new FocasStatus(1, 3, 1, 0, 0, 0, 0, 0);
await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus?.Run == 3);
(await drv.ReadAsync([$"{Host}/Status/Run"], CancellationToken.None))[0].Value.ShouldBe(3);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task A_cnc_that_refuses_statinfo_gets_no_Status_node_and_the_rest_of_the_tree_survives()
{
var client = new FakeFocasClient { ThrowOnStatus = true };
var drv = NewDriver(client);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitAsync(() => drv.GetDeviceState(Host)?.FixedTreeCache is not null);
var builder = new RecordingBuilder();
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldNotContain(f => f.BrowseName == "Status");
builder.Variables.ShouldContain(v => v.Info.FullName == "FIXED:Identity/SeriesNumber");
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Discovery_emits_every_status_leaf_with_a_parseable_read_only_address()
{
var drv = NewDriver(new FakeFocasClient());
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitAsync(() => drv.GetDeviceState(Host)?.FixedTreeCache is not null);
var builder = new RecordingBuilder();
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Status");
var leaves = builder.Variables.Where(v => v.Info.FullName.StartsWith("FIXED:Status/", StringComparison.Ordinal)).ToList();
leaves.Select(l => l.BrowseName).ShouldBe(
["Auto", "Run", "Motion", "Mstb", "Emergency", "Alarm", "Edit", "TmMode", "RunText"], ignoreOrder: true);
foreach (var (_, info) in leaves)
{
FocasAddress.TryParse(info.FullName).ShouldNotBeNull();
info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
await drv.ShutdownAsync(CancellationToken.None);
}
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string name, DriverDataType type, object? value) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}
@@ -151,27 +151,31 @@ public sealed class MTConnectAlarmSourceTests
}
[Fact]
public async Task A_changed_alarm_set_re_tells_every_condition_and_the_same_set_does_not()
public async Task A_changed_alarm_set_re_tells_only_conditions_that_left_and_came_back()
{
// Review MAJOR 2: a changed set is an alarm-bearing delta ⇒ full rebuild ⇒ every condition node was
// recreated inactive, so an active condition still in the set must be re-asserted onto it.
// Gitea #641: a changed set is an alarm-bearing delta ⇒ a full rebuild, but the server now carries every
// surviving condition's state (acknowledgement included) across it. Re-raising a survivor would
// UN-acknowledge it, so only the record of a condition that LEFT the set is dropped: one that comes back
// was materialised fresh and must be told again.
var (driver, agent, events) = await AlarmDriverAsync();
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
events.ShouldHaveSingleItem().Kind.ShouldBe(AlarmTransitionKind.Raise);
await driver.SubscribeAlarmsAsync([ConditionRawPath, OtherConditionRawPath], Ct);
events.Count.ShouldBe(2, "A is re-raised onto its rebuilt node");
await driver.SubscribeAlarmsAsync([ConditionRawPath, OtherConditionRawPath], Ct); // A survives: silent
await driver.SubscribeAlarmsAsync([OtherConditionRawPath, ConditionRawPath], Ct); // same set: silent
events.Count.ShouldBe(1, "a surviving condition is not re-raised");
await driver.SubscribeAlarmsAsync([OtherConditionRawPath], Ct); // A removed
await driver.SubscribeAlarmsAsync([OtherConditionRawPath, ConditionRawPath], Ct); // A re-added: fresh node
events.Count.ShouldBe(2, "a condition that left the set and came back is told again");
events.Last().ConditionId.ShouldBe(ConditionRawPath);
events.Last().Kind.ShouldBe(AlarmTransitionKind.Raise);
await driver.SubscribeAlarmsAsync([OtherConditionRawPath, ConditionRawPath], Ct);
// Positive control: the next real transition is the very next event.
await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct);
events.Select(e => e.Kind).ShouldBe(
[AlarmTransitionKind.Raise, AlarmTransitionKind.Raise, AlarmTransitionKind.Clear],
"the same set (a reconnect) must re-raise nothing");
[AlarmTransitionKind.Raise, AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
await driver.ShutdownAsync(Ct);
}
@@ -30,6 +30,7 @@ public sealed class MqttConnectionTests
/// actually wedges a client — a refused port returns ECONNREFUSED instantly and would pass
/// whether or not any deadline logic existed.
/// </summary>
[Trait("Category", "SlowUnit")]
[Fact]
public async Task ConnectAsync_BlackholeBroker_FailsWithinDeadline_NoHang()
{
@@ -95,6 +96,7 @@ public sealed class MqttConnectionTests
/// <c>IMqttClient</c> held across failures. A failed attempt must therefore leave the
/// connection usable rather than poisoned.
/// </summary>
[Trait("Category", "SlowUnit")]
[Fact]
public async Task ConnectAsync_CanBeRetriedOnTheSameInstanceAfterAFailedAttempt()
{
@@ -592,6 +594,7 @@ public sealed class MqttConnectionTests
/// <c>InitializeAsync</c>) — it must not silently leave a background reconnect worker
/// hammering an endpoint the caller has already given up on.
/// </summary>
[Trait("Category", "SlowUnit")]
[Fact]
public async Task ConnectAsync_InitialAttemptFails_LeavesNoBackgroundReconnectStorm()
{
@@ -729,6 +732,7 @@ public sealed class MqttConnectionTests
/// <c>Faulted</c>) — and the backoff keeps the retry rate low enough that a handful of
/// attempts, not hundreds, land in the observation window.
/// </summary>
[Trait("Category", "SlowUnit")]
[Fact]
public async Task BrokerStaysDown_RetriesUnderBackoff_WithoutHotLooping()
{
@@ -759,6 +763,7 @@ public sealed class MqttConnectionTests
/// Dispose during a reconnect backoff must stop the supervisor dead — no leaked task, no
/// later attempt resurrecting a connection the caller has torn down.
/// </summary>
[Trait("Category", "SlowUnit")]
[Fact]
public async Task Dispose_DuringReconnectBackoff_StopsTheSupervisor_NoFurtherAttempts()
{
@@ -875,6 +880,7 @@ public sealed class MqttConnectionTests
/// against a perfectly healthy connection, at Debug level, inflating <c>attempt</c> so the
/// NEXT genuine outage waits max backoff instead of min.
/// </summary>
[Trait("Category", "SlowUnit")]
[Fact]
public async Task CallerReconnectsWhileTheSupervisorIsInBackoff_SupervisorStandsDownSilently()
{
@@ -917,6 +923,7 @@ public sealed class MqttConnectionTests
/// A stood-down supervisor answers it one min-backoff (2 s) later. The 5 s window separates the
/// two by a clear margin; widening the burn only widens the separation.
/// </remarks>
[Trait("Category", "SlowUnit")]
[Fact]
public async Task AfterACallerWinsTheRace_TheNextGenuineOutageStillRecoversAtMinBackoff()
{
@@ -971,6 +978,7 @@ public sealed class MqttConnectionTests
/// dispose-elapsed assertion cannot tell those apart — <c>DisposeAsync</c>'s own bounded join
/// returns promptly either way, leaving the supervisor alive behind it.
/// </remarks>
[Trait("Category", "SlowUnit")]
[Fact]
public async Task ReSubscribeThatHangsIgnoringItsToken_DoesNotParkTheSupervisor()
{
@@ -1085,6 +1093,7 @@ public sealed class MqttConnectionTests
/// An unrecoverable rejection must not leave a supervisor grinding away every backoff period
/// against a broker that will never accept these credentials.
/// </summary>
[Trait("Category", "SlowUnit")]
[Fact]
public async Task ConnectAsync_RefusedCredentials_StartsNoReconnectSupervisor()
{
@@ -1156,6 +1165,7 @@ public sealed class MqttConnectionTests
/// The supervisor's own attempts go through the same CONNACK check. A broker that starts
/// refusing mid-life (credentials revoked, client banned) must stop the loop, not feed it.
/// </summary>
[Trait("Category", "SlowUnit")]
[Fact]
public async Task Reconnect_BrokerStartsRefusing_FaultsAndStopsTheSupervisor()
{
@@ -1179,6 +1189,7 @@ public sealed class MqttConnectionTests
}
/// <summary>The mirror case: a transient refusal keeps the supervisor retrying under backoff.</summary>
[Trait("Category", "SlowUnit")]
[Fact]
public async Task Reconnect_TransientRefusal_KeepsRetryingUnderBackoff()
{
@@ -211,6 +211,7 @@ public sealed class MqttDriverDiscoveryTests
/// fails must land <see cref="DriverState.Faulted"/> rather than letting the failure escape
/// with the health surface still claiming Healthy.
/// </summary>
[Trait("Category", "SlowUnit")]
[Fact]
public async Task ReinitializeAsync_SessionChangingDelta_Rebuilds_AndFaultsOnUnreachableBroker()
{
@@ -83,6 +83,7 @@ public sealed class MqttDriverSessionLifecycleTests
/// The control for the test above: the normalization must not quietly rewrite a <i>legal</i>
/// value, or a deployment's authored connect budget would silently become the default.
/// </summary>
[Trait("Category", "SlowUnit")]
[Fact]
public async Task InitializeAsync_WithAnAuthoredConnectTimeout_KeepsIt()
{
@@ -126,6 +127,7 @@ public sealed class MqttDriverSessionLifecycleTests
/// can drop a session (which is what this test needs from it) but cannot PUBLISH.
/// </para>
/// </remarks>
[Trait("Category", "SlowUnit")]
[Fact]
public async Task ABrokerDrop_MarksCachedRefsBadNoCommunication_AndTheIdenticalReplayRecovers()
{
@@ -243,6 +243,7 @@ public sealed class SparkplugPrimaryHostTests
/// one-second role sample, forever. It now backs off. The clock is frozen, so the real one-second
/// loop ticks several times without the backoff window ever opening; advancing the clock opens it.
/// </summary>
[Trait("Category", "SlowUnit")]
[Fact]
public async Task A_failing_claim_backs_off_instead_of_redialling_every_tick()
{
@@ -313,6 +314,7 @@ public sealed class SparkplugPrimaryHostTests
/// Deterministic rather than timing-dependent: the gate is taken before the connect starts, so
/// the broker having accepted the socket proves the claim is inside it when dispose runs.
/// </remarks>
[Trait("Category", "SlowUnit")]
[Fact]
public async Task Disposing_while_a_reconcile_is_in_flight_does_not_fault_on_a_disposed_gate()
{
@@ -1,3 +1,4 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -71,6 +72,349 @@ public sealed class OpcUaClientAlarmTests
await drv.AcknowledgeAsync([], TestContext.Current.CancellationToken);
}
// ---- #619: v3 RawPath <-> upstream-source translation on the native-alarm path ----
private static VariantCollection EventFields(string sourceNode, string conditionNode) =>
[
new Variant(new byte[] { 1 }), // EventId
new Variant(new NodeId("TripAlarmType", 3)), // EventType
new Variant(NodeId.Parse(sourceNode)), // SourceNode
new Variant(new LocalizedText("tripped")), // Message
new Variant((ushort)700), // Severity
new Variant(DateTime.UtcNow), // Time
new Variant(NodeId.Parse(conditionNode)), // ConditionId
];
/// <summary>The host hands SubscribeAlarmsAsync RawPaths; the index must be keyed by the UPSTREAM source id.</summary>
[Fact]
public void BuildAlarmSourceIndex_keys_the_upstream_source_id_to_the_RawPath()
{
var index = OpcUaClientDriver.BuildAlarmSourceIndex(
["Plant/opc/plc/Trip1"],
raw => raw == "Plant/opc/plc/Trip1" ? "ns=3;s=Alarms.Trip1" : null);
index["ns=3;s=Alarms.Trip1"].ShouldBe("Plant/opc/plc/Trip1");
}
/// <summary>A ref the authored table cannot resolve is kept verbatim (direct upstream-id use stays valid).</summary>
[Fact]
public void BuildAlarmSourceIndex_keeps_an_unresolvable_ref_as_its_own_key()
{
var index = OpcUaClientDriver.BuildAlarmSourceIndex(["ns=3;s=Direct"], _ => null);
index["ns=3;s=Direct"].ShouldBe("ns=3;s=Direct");
}
/// <summary>The emitted ConditionId must be the RawPath - DriverHostActor routes on (driver, RawPath).</summary>
[Fact]
public void ProjectEvent_reports_the_RawPath_as_ConditionId_and_remembers_the_condition_node()
{
var index = new Dictionary<string, string> { ["ns=3;s=Alarms.Trip1"] = "Plant/opc/plc/Trip1" };
string? rememberedRaw = null, rememberedCond = null;
var e = OpcUaClientDriver.ProjectEvent(
EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond"), index, new FakeAlarmHandle(),
n => n.ToString(), (raw, target) => { rememberedRaw = raw; rememberedCond = target.ConditionRef; });
e.ShouldNotBeNull();
e.ConditionId.ShouldBe("Plant/opc/plc/Trip1");
e.SourceNodeId.ShouldBe("Plant/opc/plc/Trip1");
rememberedRaw.ShouldBe("Plant/opc/plc/Trip1");
rememberedCond.ShouldBe("ns=3;s=Alarms.Trip1.Cond");
}
/// <summary>An event from a source nobody authored is filtered out.</summary>
[Fact]
public void ProjectEvent_drops_an_unauthored_source()
{
var index = new Dictionary<string, string> { ["ns=3;s=Alarms.Trip1"] = "Plant/opc/plc/Trip1" };
OpcUaClientDriver.ProjectEvent(
EventFields("ns=3;s=Other", "ns=3;s=Other.Cond"), index, new FakeAlarmHandle(), n => n.ToString(), (_, _) => { })
.ShouldBeNull();
}
/// <summary>
/// A remote namespace-table reorder across an in-place SDK reconnect must not orphan the alarm index:
/// it is built once at subscribe time, while events keep arriving on the (transferred) subscription with
/// the NEW namespace indexes. Keys are therefore server-stable (<c>nsu=</c>) on both sides.
/// </summary>
[Fact]
public void ProjectEvent_matches_across_a_namespace_reorder()
{
var before = new NamespaceTable(); before.Append("urn:other"); before.Append("urn:plc"); // urn:plc = 2
var after = new NamespaceTable(); after.Append("urn:new"); after.Append("urn:other"); after.Append("urn:plc"); // urn:plc = 3
var mapBefore = NamespaceMap.FromTable(before);
var mapAfter = NamespaceMap.FromTable(after);
var index = OpcUaClientDriver.BuildAlarmSourceIndex(
["Plant/opc/plc/Trip1"], _ => OpcUaClientDriver.AlarmIndexKey(mapBefore, new NodeId("Alarms.Trip1", 2)));
var e = OpcUaClientDriver.ProjectEvent(
EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond"), index, new FakeAlarmHandle(),
n => OpcUaClientDriver.AlarmIndexKey(mapAfter, n), (_, _) => { });
e.ShouldNotBeNull();
e.ConditionId.ShouldBe("Plant/opc/plc/Trip1");
}
/// <summary>
/// A source with several conditions (Hi + HiHi on one variable) can only be told apart by the CONDITION
/// node, so a tag authored on the condition node matches on the event's ConditionId — 1:1, no collapse.
/// </summary>
[Fact]
public void ProjectEvent_matches_a_tag_authored_on_the_condition_node()
{
var index = new Dictionary<string, string>
{
["ns=3;s=Tank.HiHi"] = "Plant/opc/plc/HiHi",
["ns=3;s=Tank.Hi"] = "Plant/opc/plc/Hi",
};
var e = OpcUaClientDriver.ProjectEvent(
EventFields("ns=3;s=Tank", "ns=3;s=Tank.HiHi"), index, new FakeAlarmHandle(), n => n.ToString(), (_, _) => { });
e.ShouldNotBeNull();
e.ConditionId.ShouldBe("Plant/opc/plc/HiHi");
}
/// <summary>
/// The ack target pairs the condition node WITH the EventId of the event that named it, so Acknowledge
/// carries the real EventId (Part 9 §5.7.3) instead of an empty one a strict server rejects
/// <c>BadEventIdUnknown</c>, and the two can never come from different conditions.
/// </summary>
[Fact]
public void ProjectEvent_remembers_the_condition_with_its_EventId()
{
var index = new Dictionary<string, string> { ["ns=3;s=Alarms.Trip1"] = "Plant/opc/plc/Trip1" };
OpcUaClientDriver.AlarmAckTarget? target = null;
OpcUaClientDriver.ProjectEvent(
EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond"), index, new FakeAlarmHandle(),
n => n.ToString(), (_, t) => target = t);
target.ShouldNotBeNull();
target.Value.ConditionRef.ShouldBe("ns=3;s=Alarms.Trip1.Cond");
target.Value.EventId.ShouldBe(new byte[] { 1 });
}
// ---- #619 review: the host's NativeAlarmProjector derives Active/Acked ONLY from AlarmEventArgs.Kind; an
// Unspecified kind keeps the prior state (inactive+acked when fresh), so without a Kind no upstream alarm
// could ever show active. ----
/// <summary>ActiveState/Id and AckedState/Id are read from the two trailing select clauses.</summary>
[Fact]
public void ProjectEvent_reads_the_condition_state()
{
var index = new Dictionary<string, string> { ["ns=3;s=Alarms.Trip1"] = "Plant/opc/plc/Trip1" };
var fields = EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond");
fields.Add(new Variant(true)); // ActiveState/Id
fields.Add(new Variant(false)); // AckedState/Id
OpcUaClientDriver.ProjectEvent(fields, index, new FakeAlarmHandle(), n => n.ToString(), (_, _) => { },
out var state);
state.ShouldBe((true, false));
}
/// <summary>The transition sequence drives a projector from the driver's last-told state to the event's.</summary>
[Theory]
[InlineData(false, true, true, false, new[] { AlarmTransitionKind.Raise })]
[InlineData(false, true, true, true, new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge })]
[InlineData(true, false, true, true, new[] { AlarmTransitionKind.Acknowledge })]
[InlineData(true, false, false, false, new[] { AlarmTransitionKind.Clear })]
[InlineData(true, true, false, true, new[] { AlarmTransitionKind.Clear })]
[InlineData(false, false, false, true, new[] { AlarmTransitionKind.Acknowledge })]
[InlineData(true, false, false, true, new[] { AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge })]
[InlineData(true, true, true, false, new[] { AlarmTransitionKind.Retrigger })]
[InlineData(true, false, true, false, new AlarmTransitionKind[0])]
public void AlarmTransitions_move_prior_to_target(
bool priorActive, bool priorAcked, bool active, bool acked, AlarmTransitionKind[] expected)
{
OpcUaClientDriver.AlarmTransitions((priorActive, priorAcked), (active, acked)).ShouldBe(expected);
}
// ---- #619 re-review: the transition table must reach EVERY target from EVERY prior. ----
/// <summary>The host projector's semantics, restated: the only thing the driver's kinds act upon.</summary>
private static (bool Active, bool Acked) Simulate((bool Active, bool Acked) s, AlarmTransitionKind k) => k switch
{
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
AlarmTransitionKind.Acknowledge => (s.Active, true),
AlarmTransitionKind.Clear => (false, s.Acked),
_ => s,
};
/// <summary>
/// Exhaustive over all 16 (prior, target) pairs: replaying the emitted kinds through the projector's
/// semantics lands on the target, and an unchanged state emits nothing. The table this replaced reached
/// (inactive, acked) from (active, acked) → (inactive, UNACKED) wrongly, and could not express a
/// cleared-but-unacked condition first seen by a ConditionRefresh at all.
/// </summary>
[Fact]
public void AlarmTransitions_reach_every_target_from_every_prior()
{
bool[] b = [false, true];
foreach (var pa in b) foreach (var pk in b) foreach (var ta in b) foreach (var tk in b)
{
var kinds = OpcUaClientDriver.AlarmTransitions((pa, pk), (ta, tk));
var s = (pa, pk);
foreach (var k in kinds) s = Simulate(s, k);
s.ShouldBe((ta, tk), $"prior ({pa},{pk}) -> target ({ta},{tk}) via [{string.Join(",", kinds)}]");
if ((pa, pk) == (ta, tk)) kinds.ShouldBeEmpty();
kinds.ShouldNotContain(AlarmTransitionKind.Unspecified);
}
}
private static VariantCollection ConditionFields(
bool active, bool acked, string message = "tripped", ushort severity = 700, byte[]? eventId = null,
NodeId? branchId = null, NodeId? eventType = null)
{
var f = EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond");
f[0] = new Variant(eventId ?? new byte[] { 1 });
if (eventType is not null) f[1] = new Variant(eventType);
f[3] = new Variant(new LocalizedText(message));
f[4] = new Variant(severity);
f.Add(new Variant(active));
f.Add(new Variant(acked));
f.Add(branchId is null ? Variant.Null : new Variant(branchId));
return f;
}
private static readonly Dictionary<string, string> TripIndex = new() { ["ns=3;s=Alarms.Trip1"] = "Plant/opc/plc/Trip1" };
private static List<AlarmEventArgs> Record(OpcUaClientDriver drv)
{
var seen = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { lock (seen) seen.Add(e); };
return seen;
}
/// <summary>The remembered EventId is a COPY — the decoded notification's array is not ours to alias.</summary>
[Fact]
public void ProjectEvent_copies_the_EventId()
{
var raw = new byte[] { 7, 8, 9 };
OpcUaClientDriver.AlarmAckTarget? target = null;
OpcUaClientDriver.ProjectEvent(ConditionFields(true, false, eventId: raw), TripIndex, new FakeAlarmHandle(),
n => n.ToString(), (_, t) => target = t);
raw[0] = 0;
target!.Value.EventId.ShouldBe(new byte[] { 7, 8, 9 });
}
/// <summary>
/// A condition BRANCH event (BranchId non-null) reports a previous state of the condition and carries its
/// own EventId — projecting it would flip the trunk's state, and remembering it would ack the branch.
/// </summary>
[Fact]
public void ProjectEvent_ignores_a_condition_branch_event()
{
var remembered = false;
OpcUaClientDriver.ProjectEvent(
ConditionFields(false, false, branchId: new NodeId(Guid.NewGuid(), 3)), TripIndex, new FakeAlarmHandle(),
n => n.ToString(), (_, _) => remembered = true)
.ShouldBeNull();
remembered.ShouldBeFalse();
// Positive control: the same event with a null BranchId (the trunk) projects.
OpcUaClientDriver.ProjectEvent(ConditionFields(false, false), TripIndex, new FakeAlarmHandle(),
n => n.ToString(), (_, _) => { })
.ShouldNotBeNull();
}
/// <summary>RefreshStart/RefreshEnd are markers around a ConditionRefresh, never alarms — even pass-through.</summary>
[Fact]
public void Refresh_markers_are_never_projected_as_alarms()
{
foreach (var type in new[] { ObjectTypeIds.RefreshStartEventType, ObjectTypeIds.RefreshEndEventType, ObjectTypeIds.RefreshRequiredEventType })
{
var fields = EventFields("i=2253", "i=0");
fields[1] = new Variant(type);
OpcUaClientDriver.ProjectEvent(fields, new Dictionary<string, string>(), new FakeAlarmHandle(),
n => n.ToString(), (_, _) => { }).ShouldBeNull($"{type} is a refresh marker");
}
OpcUaClientDriver.IsRefreshRequired(EventFieldsOfType(ObjectTypeIds.RefreshRequiredEventType)).ShouldBeTrue();
OpcUaClientDriver.IsRefreshRequired(EventFieldsOfType(ObjectTypeIds.RefreshEndEventType)).ShouldBeFalse();
}
private static VariantCollection EventFieldsOfType(NodeId type)
{
var f = EventFields("i=2253", "i=0");
f[1] = new Variant(type);
return f;
}
/// <summary>
/// A ConditionRefresh re-delivers every retained condition's CURRENT state. One the host was already told
/// must emit nothing — no re-raise (which would un-ack an acked alarm) and no Unspecified (which a host
/// projector reset by a redeploy would project from its inactive default).
/// </summary>
[Fact]
public void A_refresh_of_an_already_reported_state_emits_nothing()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-dedup");
var seen = Record(drv);
var handle = new FakeAlarmHandle();
drv.RouteAlarmEvent(ConditionFields(true, false), TripIndex, handle);
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
seen.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle); // the refresh copy
seen.Count.ShouldBe(2);
// Positive control: a genuine message change on an unchanged state still reaches the host.
drv.RouteAlarmEvent(ConditionFields(true, true, message: "worse"), TripIndex, handle);
seen.Count.ShouldBe(3);
seen[2].Kind.ShouldBe(AlarmTransitionKind.Unspecified);
seen[2].Message.ShouldBe("worse");
}
/// <summary>The exact upstream severity rides OpcUaSeverity (#626) so the host does not re-bucket it.</summary>
[Fact]
public void The_exact_upstream_severity_is_carried()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-sev");
var seen = Record(drv);
drv.RouteAlarmEvent(ConditionFields(true, false, severity: 650), TripIndex, new FakeAlarmHandle());
seen.Single().OpcUaSeverity.ShouldBe(650);
}
/// <summary>
/// A driver re-initialise (comms-loss watchdog, Force Reconnect) re-subscribes the SAME set over nodes the
/// host did not rebuild, so the last-told state must survive it — or the refresh after it would re-raise
/// every acked active alarm (un-ack). A CHANGED set means the host rebuilt (nodes recreated inactive), so
/// there the state is forgotten and the refresh re-raises from scratch.
/// </summary>
[Fact]
public async Task Last_told_state_survives_a_reinit_of_the_same_set_but_not_a_set_change()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-reinit");
var seen = Record(drv);
var handle = new FakeAlarmHandle();
drv.BeginAlarmSubscription(["Plant/opc/plc/Trip1"]);
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
seen.Count.ShouldBe(2);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
drv.BeginAlarmSubscription(["Plant/opc/plc/Trip1"]);
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
seen.Count.ShouldBe(2, "same set after a reinit: the refresh copy is a duplicate");
drv.BeginAlarmSubscription(["Plant/opc/plc/Trip1", "Plant/opc/plc/Trip2"]);
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
seen.Skip(2).Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
}
/// <summary>A server without ConditionRefresh is tolerated; anything else is a real failure.</summary>
[Fact]
public void ConditionRefresh_unsupported_status_codes()
{
OpcUaClientDriver.IsConditionRefreshUnsupported(StatusCodes.BadNotSupported).ShouldBeTrue();
OpcUaClientDriver.IsConditionRefreshUnsupported(StatusCodes.BadMethodInvalid).ShouldBeTrue();
OpcUaClientDriver.IsConditionRefreshUnsupported(StatusCodes.BadNotImplemented).ShouldBeTrue();
OpcUaClientDriver.IsConditionRefreshUnsupported(StatusCodes.BadTimeout).ShouldBeFalse();
}
private sealed class FakeAlarmHandle : IAlarmSubscriptionHandle
{
/// <summary>Gets the diagnostic identifier for this alarm handle.</summary>
@@ -39,7 +39,7 @@ docker compose --profile s7_1500 up -d --build --force-recreate
```
Confirm it took: `docker logs otopcua-python-snap7-s7_1500` prints
`seeded DB1 size=1024 seeds=<n>` at startup — check `<n>` matches the seed count
`seeded DB1 size=2048 seeds=<n>` at startup — check `<n>` matches the seed count
you just wrote. This is the fastest way to tell a stale image from a bad address.
On the shared Docker host the deployment mirror at `/opt/otopcua-s7/` is
@@ -78,7 +78,7 @@ without Docker running.
## What's encoded in `profiles/s7_1500.json`
DB1 (1024 bytes) + MB (256 bytes) + TM0/CT0 (64 bytes each) with typed seeds at
DB1 (2048 bytes) + MB (256 bytes) + TM0/CT0 (64 bytes each) with typed seeds at
known offsets:
| Address | Type | Seed | Purpose |
@@ -93,7 +93,7 @@ known offsets:
| `MW0` | u16 | `1` | `S7ProbeOptions.ProbeAddress` default |
Seed types supported: `u8`, `i8`, `u16`, `i16`, `u32`, `i32`, `f32`,
`bool` (with `"bit": 0..7`), `ascii` (S7 STRING).
`bool` (with `"bit": 0..7`), `ascii` (S7 STRING), `hex` (raw bytes).
### Span-planner gate seeds (offsets 300+, `S7_1500SpanReadTests`)
@@ -119,6 +119,32 @@ inside the holes cluster A's span reads through. No tag maps to them; they are
there so a mis-computed slice offset returns garbage instead of a zero that could
match by luck. Every seed above is distinct and non-zero for the same reason.
### Codec gate seeds (offsets 800+, `S7_1500CodecTests`, #632/#638)
DB1 is 2048 bytes. Seeds use the `hex` seed type (raw big-endian bytes) for WSTRING/DTL/undecodable
headers. All distinct and non-zero. Writes go to the **unseeded** 1100..1199 scratch area
(`S7_SIM_ALLOW_WRITES=1` required).
| Address | Type | Seed | Decodes to |
|---|---|---|---|
| `DB1.DBB800` | STRING max 32 | `Siemens S7` | `"Siemens S7"` |
| `DB1.DBB840` | WSTRING max 8 | `0008 0005 0047 0072 00fc 00df 0065` | `"Grüße"` |
| `DB1.DBB870` | CHAR | `0x51` | `"Q"` |
| `DB1.DBB872` | WCHAR | `0x20AC` | `"€"` |
| `DB1.DBB880` | DTL | `07e8 03 0f 06 0d 2d 38 2f075f4c` | 2024-03-15 13:45:56.7890123 |
| `DB1.DBW900` | DATE | `11488` | 2021-06-15 |
| `DB1.DBD904` | TOD | `49556789` | 49 556 789 ms (13:45:56.789) |
| `DB1.DBD910` / `914` | TIME | `123456789` / `-86400123` | same, ms |
| `DB1.DBW920` / `922` | S5TIME | `0x2789` / `0x0123` | 789 000 ms / 1 230 ms |
| `DB1.DBW960` | DATE | `0xFFFF` | 2169-06-06 |
| `DB1.DBB930` | DTL | all zero (unseeded) | BadOutOfRange (uninitialised) |
| `DB1.DBW940` | S5TIME | `0x0A5F` | BadOutOfRange (non-BCD nibble) |
| `DB1.DBD944` | TOD | `0xFFFFFFFF` | BadOutOfRange (past midnight) |
| `DB1.DBB950` | WSTRING max 3 | `0003 0009` | BadOutOfRange (cur > max) |
snap7 does not validate STRING/WSTRING headers or TSAPs, so the TSAP-override theory proves the keys
reach the wire and the connection still works — not TSAP semantics (hardware only).
### Timer / Counter (TM / CT) areas
`TM0` and `CT0` are 64 bytes each — **32 device cells**, since a timer is a 2-byte
@@ -1,10 +1,10 @@
{
"_description": "S7-1500 profile — DB1 (1024 bytes) + MB (256 bytes) + TM/CT (64 bytes each) with well-known seeds at named offsets for the smoke + byte-order + string + Timer/Counter tests. Big-endian Siemens wire order throughout.",
"_description": "S7-1500 profile — DB1 (2048 bytes) + MB (256 bytes) + TM/CT (64 bytes each) with well-known seeds at named offsets for the smoke + byte-order + string + Timer/Counter tests. Big-endian Siemens wire order throughout.",
"areas": [
{
"area": "DB",
"index": 1,
"size": 1024,
"size": 2048,
"seeds": [
{ "_desc": "DB1.DBW0 — read-back probe, S7Driver default ProbeAddress target is MW0; this shadows it",
"offset": 0, "type": "u16", "value": 4242 },
@@ -46,7 +46,30 @@
{ "_desc": "DB1.DBW500 — isolated narrow scalar: its own single-member span, demoted by PrefersPerTagRead into a leftover that rides the multi-var pass",
"offset": 500, "type": "u16", "value": 60001 },
{ "_desc": "DB1.DBW600 — second isolated narrow scalar, likewise a multi-var leftover",
"offset": 600, "type": "i16", "value": -32001 }
"offset": 600, "type": "i16", "value": -32001 },
{ "_desc": "CODEC GATE (S7_1500CodecTests, #632/#638). Every seed from 800 to 969 is distinct and non-zero so a decode that returns zero/default can never pass. 800: STRING max 32 cur 10 'Siemens S7'",
"offset": 800, "type": "ascii", "value": "Siemens S7", "max_len": 32 },
{ "_desc": "DB1 @840 — WSTRING max 8 cur 5 'Grüße' (ü=00FC, ß=00DF) — non-ASCII proves UTF-16BE, not Latin-1",
"offset": 840, "type": "hex", "value": "0008 0005 0047 0072 00fc 00df 0065" },
{ "_desc": "DB1 @870 — CHAR 'Q'", "offset": 870, "type": "u8", "value": 81 },
{ "_desc": "DB1 @872 — WCHAR U+20AC '€' (does not fit Latin-1)", "offset": 872, "type": "u16", "value": 8364 },
{ "_desc": "DB1 @880 — DTL 2024-03-15 13:45:56.7890123 (weekday 6 = Friday, ns 789012300)",
"offset": 880, "type": "hex", "value": "07e8 03 0f 06 0d 2d 38 2f075f4c" },
{ "_desc": "DB1 @900 — DATE 2021-06-15 = 11488 days since 1990-01-01", "offset": 900, "type": "u16", "value": 11488 },
{ "_desc": "DB1 @904 — TOD 13:45:56.789 = 49556789 ms", "offset": 904, "type": "u32", "value": 49556789 },
{ "_desc": "DB1 @910 — TIME +123456789 ms", "offset": 910, "type": "i32", "value": 123456789 },
{ "_desc": "DB1 @914 — TIME -86400123 ms (negative duration)", "offset": 914, "type": "i32", "value": -86400123 },
{ "_desc": "DB1 @920 — S5TIME 0x2789: base 2 (x1 s), BCD 789 = 789000 ms", "offset": 920, "type": "u16", "value": 10121 },
{ "_desc": "DB1 @922 — S5TIME 0x0123: base 0 (x10 ms), BCD 123 = 1230 ms", "offset": 922, "type": "u16", "value": 291 },
{ "_desc": "UNDECODABLE buffers — reads must surface BadOutOfRange, not a value. @930 DTL stays all-zero (uninitialised) on purpose. @940: S5TIME 0x0A5F has a non-BCD nibble",
"offset": 940, "type": "u16", "value": 2655 },
{ "_desc": "DB1 @944 — TOD 0xFFFFFFFF, past midnight", "offset": 944, "type": "u32", "value": 4294967295 },
{ "_desc": "DB1 @950 — WSTRING max 3 cur 9 (current length exceeds reserved)",
"offset": 950, "type": "hex", "value": "0003 0009" },
{ "_desc": "DB1 @960 — DATE 0xFFFF = 65535 days = 2169-06-06, the far end of the DATE range (still a valid decode)",
"offset": 960, "type": "u16", "value": 65535 }
]
},
{
@@ -45,7 +45,7 @@ def seed_buffer(buf: bytearray, seeds: list[dict]) -> None:
"""Poke seed values into the area buffer at declared byte offsets.
Each seed is {"offset": int, "type": str, "value": int|float|bool|str}
where type ∈ {u8, i8, u16, i16, u32, i32, f32, bool, ascii}. Endianness is
where type ∈ {u8, i8, u16, i16, u32, i32, f32, bool, ascii, hex}. Endianness is
big-endian (Siemens wire format).
"""
for seed in seeds:
@@ -73,6 +73,11 @@ def seed_buffer(buf: bytearray, seeds: list[dict]) -> None:
buf[off] |= (1 << bit)
else:
buf[off] &= ~(1 << bit) & 0xFF
elif t == "hex":
# Raw bytes, big-endian wire order as written. Used for the multi-byte S7 types the
# scalar seeds cannot express (WSTRING, DTL, ...): {"type": "hex", "value": "07e8030f..."}.
raw = bytes.fromhex(str(v).replace(" ", ""))
buf[off:off + len(raw)] = raw
elif t == "ascii":
# Siemens STRING type: byte 0 = max length, byte 1 = current length,
# bytes 2+ = payload. Seeds supply the payload text; we fill max/cur.
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// <summary>
/// #610 item 2 (S7-016): array write round trip against the python-snap7 S7-1500 profile. Skips
/// cleanly when the sim is absent. Uses DB1 bytes 700+ (the profile's 1024-byte DB1 has no seed
/// cleanly when the sim is absent. Uses DB1 bytes 700+ (the profile's 2048-byte DB1 has no seed
/// there), so the writes cannot disturb another test's seeds. Writes also require
/// <c>S7_SIM_ALLOW_WRITES=1</c>.
/// </summary>
@@ -0,0 +1,263 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// <summary>
/// Gitea #632 / #638 live coverage: the S7 STRING / WSTRING / CHAR / WCHAR / DTL / DATE / TOD / TIME /
/// S5TIME codecs against the python-snap7 S7-1500 profile, over real ISO-on-TCP + S7comm, plus the
/// TSAP override keys (<c>LocalTsap</c> / <c>RemoteTsap</c> / <c>ConnectionType</c>) and the negotiated
/// PDU size. Wide/date-time types are byte-anchored (DBB) by driver rule. Read seeds live at DB1 800..960 in <c>Docker/profiles/s7_1500.json</c> and are all
/// distinct and non-zero, so a decode that returned zero/default cannot pass. Write tests use the
/// unseeded DB1 1100..1199 scratch area and additionally need <c>S7_SIM_ALLOW_WRITES=1</c>. Everything
/// skips cleanly when <c>S7_SIM_ENDPOINT</c> (default the shared Docker host) is unreachable.
/// </summary>
[Collection(Snap7ServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "S7_1500")]
public sealed class S7_1500CodecTests(Snap7ServerFixture sim)
{
private const uint BadOutOfRange = 0x803C0000u;
private static S7DriverOptions Options(string host, int port, params S7TagDefinition[] tags) => new()
{
Host = host,
Port = port,
CpuType = S7CpuType.S71500,
Rack = 0,
Slot = 0,
Timeout = TimeSpan.FromSeconds(5),
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(tags),
};
private static S7DriverOptions WithTsap(S7DriverOptions o, string? local, string? remote, S7ConnectionType? ct) => new()
{
Host = o.Host, Port = o.Port, CpuType = o.CpuType, Rack = o.Rack, Slot = o.Slot, Timeout = o.Timeout,
Probe = o.Probe, RawTags = o.RawTags,
LocalTsap = local, RemoteTsap = remote, ConnectionType = ct,
};
private void RequireSim()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
}
private static void RequireWrites()
{
if (Environment.GetEnvironmentVariable("S7_SIM_ALLOW_WRITES") != "1")
Assert.Skip("Write test: set S7_SIM_ALLOW_WRITES=1 to allow writes to the endpoint under test (simulator only).");
}
// ---- reads ----
/// <summary>Every codec decodes its seeded, distinct non-zero wire bytes through real S7comm.</summary>
[Fact]
public async Task Codecs_decode_seeded_bytes_through_real_S7comm()
{
RequireSim();
var o = Options(sim.Host, sim.Port,
new S7TagDefinition("Str", "DB1.DBB800", S7DataType.String, StringLength: 32),
new S7TagDefinition("WStr", "DB1.DBB840", S7DataType.WString, StringLength: 8),
new S7TagDefinition("Chr", "DB1.DBB870", S7DataType.Char),
new S7TagDefinition("WChr", "DB1.DBB872", S7DataType.WChar),
new S7TagDefinition("Dtl", "DB1.DBB880", S7DataType.Dtl),
new S7TagDefinition("Date", "DB1.DBB900", S7DataType.Date),
new S7TagDefinition("Tod", "DB1.DBB904", S7DataType.Tod),
new S7TagDefinition("TimePos", "DB1.DBB910", S7DataType.Time),
new S7TagDefinition("TimeNeg", "DB1.DBB914", S7DataType.Time),
new S7TagDefinition("S5Sec", "DB1.DBB920", S7DataType.S5Time),
new S7TagDefinition("S5Cs", "DB1.DBB922", S7DataType.S5Time),
new S7TagDefinition("DateMax", "DB1.DBB960", S7DataType.Date));
await using var drv = new S7Driver(o, "s7-codec-read");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var names = o.RawTags.Select(t => t.RawPath).ToArray();
var r = await drv.ReadAsync(names, TestContext.Current.CancellationToken);
r.Count.ShouldBe(names.Length);
for (var i = 0; i < r.Count; i++) r[i].StatusCode.ShouldBe(0u, names[i]);
object? V(string n) => r[Array.IndexOf(names, n)].Value;
V("Str").ShouldBe("Siemens S7");
V("WStr").ShouldBe("Grüße");
V("Chr").ShouldBe("Q");
V("WChr").ShouldBe("€");
V("Dtl").ShouldBe(new DateTime(2024, 3, 15, 13, 45, 56).AddTicks(7_890_123));
V("Date").ShouldBe(new DateTime(2021, 6, 15));
V("Tod").ShouldBe(49_556_789);
V("TimePos").ShouldBe(123_456_789);
V("TimeNeg").ShouldBe(-86_400_123);
V("S5Sec").ShouldBe(789_000);
V("S5Cs").ShouldBe(1_230);
V("DateMax").ShouldBe(new DateTime(2169, 6, 6));
}
/// <summary>Undecodable buffers surface BadOutOfRange per tag, leave siblings Good, and do not degrade health.</summary>
[Fact]
public async Task Undecodable_buffers_read_BadOutOfRange_without_degrading_health()
{
RequireSim();
var o = Options(sim.Host, sim.Port,
new S7TagDefinition("DtlUninit", "DB1.DBB930", S7DataType.Dtl),
new S7TagDefinition("S5NonBcd", "DB1.DBB940", S7DataType.S5Time),
new S7TagDefinition("TodPastMidnight", "DB1.DBB944", S7DataType.Tod),
new S7TagDefinition("WStrCurOverMax", "DB1.DBB950", S7DataType.WString, StringLength: 3),
new S7TagDefinition("Good", "DB1.DBB904", S7DataType.Tod));
await using var drv = new S7Driver(o, "s7-codec-bad");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var r = await drv.ReadAsync(
["DtlUninit", "S5NonBcd", "TodPastMidnight", "WStrCurOverMax", "Good"],
TestContext.Current.CancellationToken);
for (var i = 0; i < 4; i++) r[i].StatusCode.ShouldBe(BadOutOfRange, $"tag #{i}");
r[4].StatusCode.ShouldBe(0u);
r[4].Value.ShouldBe(49_556_789);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a PLC buffer the codec refuses is data, not a comms fault");
}
// ---- writes ----
private static readonly S7TagDefinition[] ScratchTags =
[
new("Str", "DB1.DBB1100", S7DataType.String, StringLength: 8),
new("WStr", "DB1.DBB1120", S7DataType.WString, StringLength: 6),
new("Chr", "DB1.DBB1150", S7DataType.Char),
new("WChr", "DB1.DBB1152", S7DataType.WChar),
new("Dtl", "DB1.DBB1160", S7DataType.Dtl),
new("Date", "DB1.DBB1180", S7DataType.Date),
new("Tod", "DB1.DBB1184", S7DataType.Tod),
new("Time", "DB1.DBB1188", S7DataType.Time),
new("S5", "DB1.DBB1192", S7DataType.S5Time),
];
/// <summary>Write each codec's value, read it back through the driver (distinct non-zero values).</summary>
[Fact]
public async Task Codec_writes_round_trip_through_real_S7comm()
{
RequireSim();
RequireWrites();
await using var drv = new S7Driver(Options(sim.Host, sim.Port, ScratchTags), "s7-codec-write");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var dtl = new DateTime(2031, 11, 30, 23, 59, 58).AddTicks(1_234_567);
(string Tag, object Value)[] cases =
[
("Str", "Plant-7"),
("WStr", "Ünï€9"),
("Chr", "Z"),
("WChr", "ß"),
("Dtl", dtl),
("Date", new DateTime(2077, 2, 28)),
("Tod", 3_723_456),
("Time", -7_654_321),
("S5", 456_000),
];
var res = await drv.WriteAsync(
[.. cases.Select(c => new WriteRequest(c.Tag, c.Value))], TestContext.Current.CancellationToken);
for (var i = 0; i < res.Count; i++) res[i].StatusCode.ShouldBe(0u, cases[i].Tag);
var read = await drv.ReadAsync([.. cases.Select(c => c.Tag)], TestContext.Current.CancellationToken);
for (var i = 0; i < read.Count; i++)
{
read[i].StatusCode.ShouldBe(0u, cases[i].Tag);
read[i].Value.ShouldBe(cases[i].Value, cases[i].Tag);
}
}
/// <summary>
/// Unrepresentable writes are refused BadOutOfRange with nothing sent (the previously written value
/// survives) and without degrading driver health. Covers classic STRING + DT (fixed in this change:
/// they used to map to BadCommunicationError) and every #632 codec.
/// </summary>
[Fact]
public async Task Unrepresentable_writes_are_BadOutOfRange_and_leave_the_PLC_untouched()
{
RequireSim();
RequireWrites();
var tags = ScratchTags.Append(new S7TagDefinition("Dt", "DB1.DBB1196", S7DataType.DateTime)).ToArray();
await using var drv = new S7Driver(Options(sim.Host, sim.Port, tags), "s7-codec-range");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var ct = TestContext.Current.CancellationToken;
// Known-good baseline so "nothing was sent" is observable as an unchanged value.
var good = new Dictionary<string, object>
{
["Str"] = "Keep-8ch", ["WStr"] = "Keep-6", ["Chr"] = "K", ["WChr"] = "W",
["Dtl"] = new DateTime(2025, 5, 6, 7, 8, 9), ["Date"] = new DateTime(2000, 1, 2),
["Tod"] = 1_000_001, ["Time"] = 2_000_002, ["S5"] = 3_000,
};
(await drv.WriteAsync([.. good.Select(g => new WriteRequest(g.Key, g.Value))], ct))
.ShouldAllBe(x => x.StatusCode == 0u);
(string Tag, object Bad)[] bad =
[
("Str", "123456789"), // 9 > max 8
("WStr", "1234567"), // 7 > max 6
("Chr", "€"), // U+20AC does not fit Latin-1
("Dtl", new DateTime(1960, 1, 1)), // before 1970
("Date", new DateTime(1980, 1, 1)), // before 1990
("Tod", 86_400_000), // past midnight
("Time", 3_000_000_000L), // > Int32
("S5", 15), // fits no S5TIME time base exactly
("Dt", new DateTime(1980, 1, 1)), // outside the 1990-2089 DT range
];
var res = await drv.WriteAsync([.. bad.Select(b => new WriteRequest(b.Tag, b.Bad))], ct);
for (var i = 0; i < res.Count; i++) res[i].StatusCode.ShouldBe(BadOutOfRange, bad[i].Tag);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
var back = await drv.ReadAsync([.. good.Keys], ct);
var keys = good.Keys.ToArray();
for (var i = 0; i < back.Count; i++)
{
back[i].StatusCode.ShouldBe(0u, keys[i]);
back[i].Value.ShouldBe(good[keys[i]], $"{keys[i]} must be unchanged after the refused write");
}
}
// ---- TSAP override + PDU ----
/// <summary>
/// The TSAP override keys reach the wire. snap7's server accepts any TSAP, so this proves the
/// override is applied and the connection still works — NOT TSAP semantics (a real CPU rejects a
/// wrong TSAP; only hardware can prove the values themselves).
/// </summary>
[Theory]
[InlineData("01.00", "03.01", null)]
[InlineData("4D.57", null, null)]
[InlineData(null, "02.01", null)]
[InlineData(null, null, S7ConnectionType.PG)]
[InlineData(null, null, S7ConnectionType.OP)]
public async Task TSAP_override_keys_connect_and_read_through_real_S7comm(
string? local, string? remote, S7ConnectionType? connType)
{
RequireSim();
var o = WithTsap(
Options(sim.Host, sim.Port, new S7TagDefinition("Probe", "DB1.DBW0", S7DataType.UInt16)),
local, remote, connType);
await using var drv = new S7Driver(o, "s7-tsap");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var r = await drv.ReadAsync(["Probe"], TestContext.Current.CancellationToken);
r[0].StatusCode.ShouldBe(0u);
Convert.ToInt32(r[0].Value).ShouldBe(S7_1500Profile.ProbeSeedValue);
}
/// <summary>The negotiated PDU size is a real S7comm value after connect and 0 after shutdown.</summary>
[Fact]
public async Task Negotiated_PDU_size_is_observed_after_connect()
{
RequireSim();
var o = Options(sim.Host, sim.Port, new S7TagDefinition("Probe", "DB1.DBW0", S7DataType.UInt16));
await using var drv = new S7Driver(o, "s7-pdu");
drv.NegotiatedPduSize.ShouldBe(0, "no connect yet");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
// S7comm PDUs are 240 (S7-300) / 480 (S7-1200/1500 default) / up to 960 bytes.
drv.NegotiatedPduSize.ShouldBeInRange(240, 960);
drv.GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe(drv.NegotiatedPduSize);
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
drv.NegotiatedPduSize.ShouldBe(0);
}
}
@@ -299,7 +299,7 @@ public sealed class S7ScalarBlockTests
{
var tag = Tag(S7DataType.String, stringLength: 5);
// 6 chars into a 5-char reserved field — S7.Net throws ArgumentException.
Should.Throw<ArgumentException>(() => S7Driver.EncodeScalarBlock(tag, "ABCDEF"));
Should.Throw<S7ValueRangeException>(() => S7Driver.EncodeScalarBlock(tag, "ABCDEF"));
}
// ── String round-trip identity (encode → decode) ──────────────────────────────────────
@@ -376,7 +376,7 @@ public sealed class S7ScalarBlockTests
{
var tag = Tag(S7DataType.DateTime);
var value = new System.DateTime(year, 1, 1, 0, 0, 0);
Should.Throw<ArgumentOutOfRangeException>(() => S7Driver.EncodeScalarBlock(tag, value));
Should.Throw<S7ValueRangeException>(() => S7Driver.EncodeScalarBlock(tag, value));
}
// ── DateTime round-trip identity (encode → decode) ────────────────────────────────────
@@ -140,13 +140,16 @@ public sealed class S7TsapAndPduTests
using var drv = new S7Driver(opts, "s7-pdu", factory);
drv.NegotiatedPduSize.ShouldBe(0);
drv.GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe(0);
((IDriverDiagnostics)drv).GetDiagnostics().ShouldBeEmpty("0 is 'not negotiated yet', not a size (#640)");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
drv.NegotiatedPduSize.ShouldBe(480);
drv.GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe(480);
((IDriverDiagnostics)drv).GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe("480");
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
drv.NegotiatedPduSize.ShouldBe(0);
((IDriverDiagnostics)drv).GetDiagnostics().ShouldBeEmpty();
}
[Fact]
@@ -407,6 +407,35 @@ public sealed class S7ValueCodecTests
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// #632 live-coverage finding: classic STRING and DT writes use S7.Net's own encoders, which throw
/// <see cref="ArgumentException"/> / <see cref="ArgumentOutOfRangeException"/> for an unrepresentable
/// value. That fell into the generic catch (BadCommunicationError + Degraded health) while the
/// sibling WSTRING/DATE codecs return BadOutOfRange — a client error must not look like a comms fault.
/// </summary>
[Fact]
public async Task Classic_STRING_and_DT_unrepresentable_writes_are_BadOutOfRange_without_degrading_health()
{
var plc = new MemoryS7Plc();
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Probe = new S7ProbeOptions { Enabled = false },
RawTags = S7RawTags.Entries(
new S7TagDefinition("Str", "DB1.DBB0", S7DataType.String, StringLength: 4),
new S7TagDefinition("Dt", "DB1.DBB20", S7DataType.DateTime)),
};
using var drv = new S7Driver(opts, "s7-classic-range", new MemoryS7PlcFactory(plc));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var ct = TestContext.Current.CancellationToken;
(await drv.WriteAsync([new WriteRequest("Str", "abcd")], ct))[0].StatusCode.ShouldBe(0u);
(await drv.WriteAsync([new WriteRequest("Str", "abcde")], ct))[0].StatusCode.ShouldBe(0x803C0000u);
(await drv.WriteAsync([new WriteRequest("Dt", new DateTime(1980, 1, 1))], ct))[0].StatusCode.ShouldBe(0x803C0000u);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
(await drv.ReadAsync(["Str"], ct))[0].Value.ShouldBe("abcd");
}
// ---- fakes ----
private sealed class MemoryS7PlcFactory(MemoryS7Plc plc) : IS7PlcFactory
@@ -272,6 +272,24 @@ public sealed class HostsDriverViewTests
row.SkippedTags!.SkippedCount.ShouldBe(20);
}
/// <summary>#640: diagnostics ride the snapshot onto the row, ordered by key; none renders nothing.</summary>
[Fact]
public void Diagnostics_reach_the_row_ordered_and_absent_diagnostics_yield_no_items()
{
var with = Snap("MAIN", "s7-a") with
{
Diagnostics = new Dictionary<string, string> { ["Z"] = "2", ["S7.NegotiatedPduSize"] = "480" },
};
var without = Snap("MAIN", "s7-b");
var rows = HostsDriverView.Build([with, without], nodes: null, instances: null).Single().Drivers;
var a = rows.Single(r => r.DriverInstanceId == "s7-a");
a.DiagnosticItems.Select(k => k.Key).ShouldBe(["S7.NegotiatedPduSize", "Z"]);
a.DiagnosticItems[0].Value.ShouldBe("480");
rows.Single(r => r.DriverInstanceId == "s7-b").DiagnosticItems.ShouldBeEmpty();
}
/// <summary>
/// A truncated sample must SAY it is truncated. Without the note a capped list reads as the whole
/// list, and an operator who fixes the twenty named tags believes the job is done.
@@ -28,10 +28,10 @@ public sealed class AbLegacyFamilyAddressGateTests
}
[Fact]
public void Gate_warns_about_indirect_addresses_which_never_read()
public void Gate_does_not_warn_about_indirect_addresses_which_the_driver_resolves()
{
EquipmentTagConfigInspector.Inspect(DriverTypeNames.AbLegacy, Cfg("N7:[N7:0]"), "{\"PlcFamily\":\"Slc500\"}")
.ShouldContain(x => x.Contains("indirect"));
.ShouldBeEmpty();
}
[Theory]
@@ -0,0 +1,53 @@
using System.Reflection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Gitea #642 guard: <see cref="NativeAlarmModel.SupportsNativeAlarms"/> is a hand-maintained set; it must equal
/// the drivers whose concrete types implement <see cref="IAlarmSource"/>. Discovery is by reflection over the
/// driver assemblies deployed beside this test; an assembly's driver type is the <see cref="DriverTypeNames"/>
/// constant whose FIELD NAME equals the assembly suffix (Driver.Galaxy -> Galaxy = "GalaxyMxGateway").
/// </summary>
public sealed class NativeAlarmDriverSetGuardTests
{
private static readonly string[] NonDriverSuffixes = [".Contracts", ".Addressing", ".Cli", ".Historian.Gateway"];
[Fact]
public void SupportsNativeAlarms_matches_the_drivers_implementing_IAlarmSource()
{
var binDir = Path.GetDirectoryName(typeof(NativeAlarmDriverSetGuardTests).Assembly.Location)!;
var constants = typeof(DriverTypeNames).GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
.ToDictionary(f => f.Name, f => (string)f.GetRawConstantValue()!, StringComparer.OrdinalIgnoreCase);
const string prefix = "ZB.MOM.WW.OtOpcUa.Driver.";
var implementers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var scanned = 0;
foreach (var dll in Directory.GetFiles(binDir, prefix + "*.dll"))
{
var name = Path.GetFileNameWithoutExtension(dll);
if (NonDriverSuffixes.Any(s => name.EndsWith(s, StringComparison.Ordinal))) { continue; }
Type[] types;
try { types = Assembly.LoadFrom(dll).GetTypes(); }
catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(t => t is not null).ToArray()!; }
scanned++;
if (!types.Any(t => t.IsClass && !t.IsAbstract && typeof(IAlarmSource).IsAssignableFrom(t))) { continue; }
var suffix = name[prefix.Length..];
constants.TryGetValue(suffix, out var typeName).ShouldBeTrue($"no DriverTypeNames constant named '{suffix}' for {name}");
implementers.Add(typeName!);
}
scanned.ShouldBeGreaterThan(5, "driver assemblies were not deployed to the test output; the guard would be vacuous");
implementers.Count.ShouldBeGreaterThan(0);
foreach (var typeName in constants.Values)
{
NativeAlarmModel.SupportsNativeAlarms(typeName).ShouldBe(implementers.Contains(typeName),
$"NativeAlarmModel.AlarmDriverTypes disagrees with IAlarmSource for driver type '{typeName}'");
}
}
}
@@ -0,0 +1,106 @@
using System.Text.Json.Nodes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>Gitea #642 — the shared NativeAlarmEditor's pure-model behaviour: remove-on-off, composition
/// with the per-driver editors' models, validation parity with the deploy gate, driver-type gating.</summary>
public sealed class NativeAlarmEditorModelTests
{
[Fact]
public void ToJson_with_IsAlarm_false_removes_the_alarm_key_and_keeps_the_rest()
{
var m = NativeAlarmModel.FromJson("""{"FullName":"X.Y","alarm":{"alarmType":"LimitAlarm","severity":300,"extra":1}}""");
m.IsAlarm = false;
var o = JsonNode.Parse(m.ToJson())!.AsObject();
o.ContainsKey("alarm").ShouldBeFalse();
o["FullName"]!.GetValue<string>().ShouldBe("X.Y");
}
[Fact]
public void Toggle_off_then_on_again_starts_from_defaults()
{
var off = NativeAlarmModel.Edit("""{"alarm":{"severity":300}}""", m => m.IsAlarm = false);
var on = NativeAlarmModel.Edit(off, m => m.IsAlarm = true);
var back = NativeAlarmModel.FromJson(on);
back.IsAlarm.ShouldBeTrue();
back.Severity.ShouldBe(500);
back.AlarmType.ShouldBe("AlarmCondition");
}
[Fact]
public void Edit_preserves_unknown_keys_at_both_levels()
{
var json = NativeAlarmModel.Edit("""{"FullName":"A","zz":1,"alarm":{"alarmType":"OffNormalAlarm","custom":"k"}}""",
m => m.Severity = 900);
var o = JsonNode.Parse(json)!.AsObject();
o["zz"]!.GetValue<int>().ShouldBe(1);
o["alarm"]!["custom"]!.GetValue<string>().ShouldBe("k");
o["alarm"]!["severity"]!.GetValue<int>().ShouldBe(900);
}
[Fact]
public void Alarm_and_mtconnect_driver_editor_compose_in_either_order()
{
const string start = """{"fullName":"Xact"}""";
// alarm first, then driver editor (driver model re-parses the alarm-bearing JSON)
var a1 = NativeAlarmModel.Edit(start, m => { m.IsAlarm = true; m.Severity = 800; });
var d1 = MTConnectTagConfigModel.FromJson(a1);
d1.FullName = "Yact";
var r1 = d1.ToJson();
// driver editor first, then alarm
var d2 = MTConnectTagConfigModel.FromJson(start);
d2.FullName = "Yact";
var r2 = NativeAlarmModel.Edit(d2.ToJson(), m => { m.IsAlarm = true; m.Severity = 800; });
foreach (var r in new[] { r1, r2 })
{
var o = JsonNode.Parse(r)!.AsObject();
o["fullName"]!.GetValue<string>().ShouldBe("Yact");
o["alarm"]!["severity"]!.GetValue<int>().ShouldBe(800);
}
}
[Theory]
[InlineData("Modbus")]
[InlineData("S7")]
[InlineData("AbCip")]
[InlineData("AbLegacy")]
[InlineData("TwinCAT")]
[InlineData("FOCAS")]
[InlineData("OpcUaClient")]
[InlineData("Sql")]
[InlineData("Mqtt")]
[InlineData("MTConnect")]
public void Every_typed_driver_model_preserves_an_alarm_object(string driverType)
{
const string json = """{"alarm":{"alarmType":"LimitAlarm","severity":123}}""";
var round = TagConfigRoundTrip.Through(driverType, json);
NativeAlarmModel.FromJson(round).Severity.ShouldBe(123);
}
[Fact]
public void Validate_is_not_stricter_than_the_deploy_gate()
{
// The gate/parser accepts any severity (clamped 1..1000 at publish) and any alarmType string.
var m = NativeAlarmModel.FromJson("""{"alarm":{"alarmType":"Whatever","severity":5000}}""");
m.Validate().ShouldBeNull();
m.Warnings().ShouldNotBeEmpty(); // advisory only
}
[Fact]
public void SupportsNativeAlarms_covers_exactly_the_IAlarmSource_drivers()
{
foreach (var t in new[] { DriverTypeNames.MTConnect, DriverTypeNames.AbCip, DriverTypeNames.FOCAS,
DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy })
NativeAlarmModel.SupportsNativeAlarms(t).ShouldBeTrue(t);
foreach (var t in new[] { DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.Mqtt, null })
NativeAlarmModel.SupportsNativeAlarms(t).ShouldBeFalse(t);
}
}
@@ -0,0 +1,23 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>Test helper: FromJson→ToJson through the driver's typed TagConfig model.</summary>
internal static class TagConfigRoundTrip
{
public static string Through(string driverType, string json) => driverType switch
{
DriverTypeNames.Modbus => ModbusTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.S7 => S7TagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.AbCip => AbCipTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.AbLegacy => AbLegacyTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.TwinCAT => TwinCATTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.FOCAS => FocasTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.OpcUaClient => OpcUaClientTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.Sql => SqlTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.Mqtt => MqttTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.MTConnect => MTConnectTagConfigModel.FromJson(json).ToJson(),
_ => throw new ArgumentOutOfRangeException(nameof(driverType)),
};
}
@@ -40,6 +40,10 @@
<!-- #576: the AB form tests deserialise the saved JSON through the drivers' own factory parse. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
</ItemGroup>
</Project>
@@ -0,0 +1,220 @@
using System.Text;
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json.Linq;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Coordinators;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// Gitea #601 — central's side of the honest deploy ack. A node that applied a deployment but whose
/// address-space apply was DEGRADED acks <see cref="ApplyAckOutcome.Applied"/> with
/// <see cref="ApplyAck.Degraded"/>. The deployment still SEALS (the revision is what the node now runs, and
/// "latest sealed" drives drift + the publish actor's fallback) but never looks clean: the node's
/// <c>NodeDeploymentState</c> row keeps status Applied with its degraded reason in <c>FailureReason</c>,
/// and the <c>Deployment</c> row's <c>FailureReason</c> names the degraded nodes. An ack from an old node
/// (no degraded field) is handled exactly as before.
/// </summary>
public sealed class ConfigPublishCoordinatorDegradedAckTests : ControlPlaneActorTestBase
{
private static readonly RevisionHash TestRevision = RevisionHash.Parse(new string('c', 64));
private static readonly TimeSpan NoTimeout = TimeSpan.FromMinutes(5);
private const string NodeA = "site-a-1:4053";
private const string NodeB = "site-a-2:4053";
[Fact]
public void A_degraded_ack_seals_but_is_recorded_as_degraded_on_the_node_and_the_deployment()
{
var dbFactory = NewInMemoryDbFactory();
var deploymentId = SeedDispatchingDeployment(dbFactory);
SeedNodes(dbFactory, NodeA, NodeB);
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));
AwaitStatus(dbFactory, DeploymentStatus.AwaitingApplyAcks);
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse(NodeA), ApplyAckOutcome.Applied, null,
CorrelationId.NewId()));
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse(NodeB), ApplyAckOutcome.Applied, null,
CorrelationId.NewId(), Degraded: true, DegradedReason: "address-space apply degraded (failedNodes=3)"));
AwaitStatus(dbFactory, DeploymentStatus.Sealed);
using var db = dbFactory.CreateDbContext();
var deployment = db.Deployments.Single();
deployment.FailureReason.ShouldNotBeNull();
deployment.FailureReason.ShouldContain("degraded");
deployment.FailureReason.ShouldContain(NodeB);
deployment.FailureReason.ShouldNotContain(NodeA);
var rowB = db.NodeDeploymentStates.Single(s => s.NodeId == NodeB);
rowB.Status.ShouldBe(NodeDeploymentStatus.Applied);
rowB.FailureReason.ShouldNotBeNull();
rowB.FailureReason.ShouldStartWith(ConfigPublishCoordinator.DegradedReasonPrefix);
rowB.FailureReason.ShouldContain("failedNodes=3");
ConfigPublishCoordinator.IsDegraded(rowB.Status, rowB.FailureReason).ShouldBeTrue();
var rowA = db.NodeDeploymentStates.Single(s => s.NodeId == NodeA);
rowA.Status.ShouldBe(NodeDeploymentStatus.Applied);
rowA.FailureReason.ShouldBeNull();
ConfigPublishCoordinator.IsDegraded(rowA.Status, rowA.FailureReason).ShouldBeFalse();
}
[Fact]
public void Old_node_acks_without_the_degraded_field_seal_clean_exactly_as_before()
{
// The five-argument constructor IS the pre-#601 ack shape: Degraded defaults to false.
var dbFactory = NewInMemoryDbFactory();
var deploymentId = SeedDispatchingDeployment(dbFactory);
SeedNodes(dbFactory, NodeA, NodeB);
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));
AwaitStatus(dbFactory, DeploymentStatus.AwaitingApplyAcks);
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse(NodeA), ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse(NodeB), ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
AwaitStatus(dbFactory, DeploymentStatus.Sealed);
using var db = dbFactory.CreateDbContext();
db.Deployments.Single().FailureReason.ShouldBeNull();
db.NodeDeploymentStates.All(s => s.Status == NodeDeploymentStatus.Applied && s.FailureReason == null)
.ShouldBeTrue();
}
[Fact]
public void A_degraded_ack_alongside_a_failed_one_is_still_PartiallyFailed()
{
var dbFactory = NewInMemoryDbFactory();
var deploymentId = SeedDispatchingDeployment(dbFactory);
SeedNodes(dbFactory, NodeA, NodeB);
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));
AwaitStatus(dbFactory, DeploymentStatus.AwaitingApplyAcks);
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse(NodeA), ApplyAckOutcome.Failed, "boom", CorrelationId.NewId()));
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse(NodeB), ApplyAckOutcome.Applied, null,
CorrelationId.NewId(), Degraded: true, DegradedReason: "x"));
AwaitStatus(dbFactory, DeploymentStatus.PartiallyFailed);
using var db = dbFactory.CreateDbContext();
db.NodeDeploymentStates.Single(s => s.NodeId == NodeA).FailureReason.ShouldBe("boom");
db.NodeDeploymentStates.Single(s => s.NodeId == NodeA).Status.ShouldBe(NodeDeploymentStatus.Failed);
}
[Fact]
public void A_recovered_coordinator_remembers_a_degraded_ack_that_landed_before_failover()
{
// Node B's degraded ack was persisted by a previous singleton instance; this instance recovers the
// in-flight deployment from the DB and must still seal it as degraded when node A acks clean.
var dbFactory = NewInMemoryDbFactory();
var deploymentId = SeedDispatchingDeployment(dbFactory, DeploymentStatus.AwaitingApplyAcks);
SeedNodes(dbFactory, NodeA, NodeB);
using (var db = dbFactory.CreateDbContext())
{
db.NodeDeploymentStates.Add(new NodeDeploymentState
{
NodeId = NodeA, DeploymentId = deploymentId.Value, Status = NodeDeploymentStatus.Applying,
});
db.NodeDeploymentStates.Add(new NodeDeploymentState
{
NodeId = NodeB, DeploymentId = deploymentId.Value, Status = NodeDeploymentStatus.Applied,
FailureReason = ConfigPublishCoordinator.DegradedReasonPrefix + "failedNodes=1",
AppliedAtUtc = DateTime.UtcNow,
});
db.SaveChanges();
}
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse(NodeA), ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
AwaitStatus(dbFactory, DeploymentStatus.Sealed);
using var verify = dbFactory.CreateDbContext();
var reason = verify.Deployments.Single().FailureReason;
reason.ShouldNotBeNull();
reason.ShouldContain(NodeB);
}
[Fact]
public void An_old_nodes_DPS_ack_without_the_degraded_fields_deserializes_as_a_clean_ack()
{
// The DPS ack path rides Akka's default serializer for the record. Serialize a NEW ack, strip the two
// #601 members from the payload (what an old node sends), and decode it with this build.
var ack = new ApplyAck(DeploymentId.NewId(), NodeId.Parse(NodeA), ApplyAckOutcome.Applied, null,
CorrelationId.NewId(), Degraded: true, DegradedReason: "will be stripped");
var serializer = Sys.Serialization.FindSerializerFor(ack);
var json = JObject.Parse(Encoding.UTF8.GetString(serializer.ToBinary(ack)));
json.Remove("Degraded").ShouldBeTrue();
json.Remove("DegradedReason").ShouldBeTrue();
var back = (ApplyAck)serializer.FromBinary(
Encoding.UTF8.GetBytes(json.ToString(Newtonsoft.Json.Formatting.None)), typeof(ApplyAck));
back.Outcome.ShouldBe(ApplyAckOutcome.Applied);
back.Degraded.ShouldBeFalse();
back.DegradedReason.ShouldBeNull();
back.DeploymentId.ShouldBe(ack.DeploymentId);
// ...and the new fields survive the same serializer when present.
var full = (ApplyAck)serializer.FromBinary(serializer.ToBinary(ack), typeof(ApplyAck));
full.ShouldBe(ack);
}
private void AwaitStatus(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, DeploymentStatus expected) =>
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.Deployments.Single().Status.ShouldBe(expected);
}, duration: TimeSpan.FromSeconds(5));
private static void SeedNodes(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, params string[] nodes)
{
using var db = dbFactory.CreateDbContext();
db.ServerClusters.Add(new ServerCluster
{
ClusterId = "SITE-A",
Name = "Site A",
Enterprise = "zb",
Site = "site-a",
NodeCount = 2,
RedundancyMode = RedundancyMode.Warm,
CreatedBy = "test",
});
foreach (var nodeId in nodes)
{
db.ClusterNodes.Add(new ClusterNode
{
NodeId = nodeId,
ClusterId = "SITE-A",
Host = nodeId.Split(':')[0],
ApplicationUri = $"urn:OtOpcUa:{nodeId}",
Enabled = true,
CreatedBy = "test",
});
}
db.SaveChanges();
}
private static DeploymentId SeedDispatchingDeployment(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
DeploymentStatus status = DeploymentStatus.Dispatching)
{
var id = DeploymentId.NewId();
using var db = dbFactory.CreateDbContext();
db.Deployments.Add(new Deployment
{
DeploymentId = id.Value,
RevisionHash = TestRevision.Value,
Status = status,
CreatedBy = "test",
});
db.SaveChanges();
return id;
}
}
@@ -276,6 +276,43 @@ public sealed class TelemetryProtoMapCentralTests
TelemetryProtoMapCentral.ToHealth(absentSkips).SkippedTags.ShouldBeNull();
}
/// <summary>
/// #640: the diagnostics bag round-trips with the null vs empty distinction intact, and a pre-#640
/// node (field never set) decodes to null.
/// </summary>
[Fact]
public void ToHealth_diagnostics_round_trip_with_the_null_vs_empty_distinction_intact()
{
var populated = new DriverHealth
{
ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy",
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
HasDiagnostics = true,
Diagnostics = { ["S7.NegotiatedPduSize"] = "480", ["Other"] = "x" },
};
var mapped = TelemetryProtoMapCentral.ToHealth(populated).Diagnostics;
mapped.ShouldNotBeNull();
mapped!.Count.ShouldBe(2);
mapped["S7.NegotiatedPduSize"].ShouldBe("480");
var empty = new DriverHealth
{
ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy",
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
HasDiagnostics = true,
};
var mappedEmpty = TelemetryProtoMapCentral.ToHealth(empty).Diagnostics;
mappedEmpty.ShouldNotBeNull();
mappedEmpty!.ShouldBeEmpty();
var absent = new DriverHealth
{
ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy",
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
};
TelemetryProtoMapCentral.ToHealth(absent).Diagnostics.ShouldBeNull();
}
/// <summary>
/// An unparseable host state degrades to <see cref="HostState.Unknown"/> rather than throwing. A node
/// running a newer build that added an enum member must not be able to kill central's telemetry
@@ -248,6 +248,33 @@ public sealed class TelemetryStreamGrpcServiceTests
.DriverHealth.HasHostStatuses.ShouldBeFalse();
}
/// <summary>
/// Node side of the #640 diagnostics carry: presence flag + entries written; a capability-less driver
/// (null) leaves the flag clear, and an empty bag still sets it.
/// </summary>
[Fact]
public void ToProto_health_writes_the_diagnostics_presence_flag_and_entries()
{
var withDiag = new DriverHealthChanged(
"cluster-a", "drv-1", "Healthy", null, null, 0, DateTime.UtcNow,
Diagnostics: new Dictionary<string, string> { ["S7.NegotiatedPduSize"] = "480" });
var evt = TelemetryProtoMapNode.ToProto(new TelemetryItem.Health(withDiag), "c");
evt.DriverHealth.HasDiagnostics.ShouldBeTrue();
evt.DriverHealth.Diagnostics["S7.NegotiatedPduSize"].ShouldBe("480");
var empty = new DriverHealthChanged(
"cluster-a", "drv-1", "Healthy", null, null, 0, DateTime.UtcNow,
Diagnostics: new Dictionary<string, string>());
var emptyEvt = TelemetryProtoMapNode.ToProto(new TelemetryItem.Health(empty), "c");
emptyEvt.DriverHealth.HasDiagnostics.ShouldBeTrue();
emptyEvt.DriverHealth.Diagnostics.ShouldBeEmpty();
var none = new DriverHealthChanged("cluster-a", "drv-1", "Healthy", null, null, 0, DateTime.UtcNow);
var noneEvt = TelemetryProtoMapNode.ToProto(new TelemetryItem.Health(none), "c");
noneEvt.DriverHealth.HasDiagnostics.ShouldBeFalse();
noneEvt.DriverHealth.Diagnostics.ShouldBeEmpty();
}
[Fact]
public async Task Client_disconnect_mid_stream_ends_cleanly_without_leaking_a_slot()
{
@@ -0,0 +1,125 @@
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// Gitea #641 over the wire — a full address-space rebuild that leaves the native alarm set unchanged must not
/// leave an active condition reading inactive. A Server-object (<c>i=2253</c>) item, which outlives the rebuild
/// (#586), receives exactly ONE condition event carrying the restored state — and an acknowledgement a client
/// applied before the rebuild survives it.
/// </summary>
/// <remarks>
/// The rebuild is driven exactly as <c>OpcUaPublishActor</c> drives one: <c>RebuildAddressSpace</c>, the
/// re-materialise + notifier wiring, then <c>CompleteAddressSpaceApply</c>. The count is read after a later
/// sentinel transition arrives on the same item, so a duplicate would already be queued ahead of it.
/// The native ack router echoes nothing back, like MTConnect and FOCAS: the acknowledgement lives only on the node.
/// </remarks>
public sealed class NativeAlarmRebuildCarryOverWireTests
{
private const NodePermissions All =
NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead
| NodePermissions.AlarmAcknowledge | NodePermissions.MethodCall;
private static AlarmConditionSnapshot Fault(string message) => new(
Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: 800, Message: message);
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task A_rebuild_with_an_unchanged_alarm_set_reports_the_restored_state_once(bool clientAcked)
{
var ct = TestContext.Current.CancellationToken;
var rig = await StartAsync($"Rebuild641{clientAcked}", encrypted: false, ct);
try
{
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
rig.Nm.NativeAlarmAckRouter = _ => { }; // a driver whose ack is a no-op: nothing echoes back
rig.MaterialiseNative();
using var session = await rig.ConnectAsync(anonymous: false, ct);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
var serverItem = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
await subscription.ApplyChangesAsync(ct);
rig.Project(native: true, Fault("fault"));
await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "fault"));
var expectedBefore = 1;
if (clientAcked)
{
await session.CallAsync(
ConditionNodeId(session, native: true), MethodIds.AcknowledgeableConditionType_Acknowledge, ct,
events.LatestEventId(serverItem, "fault"), new LocalizedText("ack"));
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(serverItem, "fault").Count == 2);
expectedBefore = 2;
}
// The deploy: a rebuild whose composition carries the same alarm (e.g. one UnsTagReference added).
rig.Sink.RebuildAddressSpace();
rig.MaterialiseNative();
rig.Sink.CompleteAddressSpaceApply();
rig.Project(native: true, Fault("sentinel") with { Acknowledged = clientAcked });
await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "sentinel"));
var faultEvents = events.ConditionEvents(serverItem, "fault");
faultEvents.Count.ShouldBe(expectedBefore + 1, "the pre-rebuild event(s) plus exactly one restored event");
var restored = faultEvents[^1];
restored[RecordedEvents.ActiveField].Value.ShouldBe(true);
restored[RecordedEvents.AckedField].Value.ShouldBe(clientAcked);
restored[RecordedEvents.RetainField].Value.ShouldBe(true);
restored[RecordedEvents.SeverityField].Value.ShouldBe((ushort)800);
// And a ConditionRefresh-style read of the live node agrees.
var active = await session.ReadValueAsync(
new NodeId($"{RawAlarmPath}/ActiveState/Id", ConditionNodeId(session, native: true).NamespaceIndex), ct);
active.Value.ShouldBe(true);
await subscription.DeleteAsync(true, ct);
}
finally { await rig.DisposeAsync(); }
}
/// <summary>Falsification control: a NORMAL native condition produces no event across the rebuild.</summary>
[Fact]
public async Task A_rebuild_reports_nothing_for_a_normal_condition()
{
var ct = TestContext.Current.CancellationToken;
var rig = await StartAsync("Rebuild641Normal", encrypted: false, ct);
try
{
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
rig.MaterialiseNative();
using var session = await rig.ConnectAsync(anonymous: false, ct);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
var serverItem = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
await subscription.ApplyChangesAsync(ct);
rig.Project(native: true, Fault("cleared") with { Active = false, Acknowledged = true });
await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "cleared"));
rig.Sink.RebuildAddressSpace();
rig.MaterialiseNative();
rig.Sink.CompleteAddressSpaceApply();
rig.Project(native: true, Fault("sentinel"));
await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "sentinel"));
events.ConditionEvents(serverItem, "cleared").Count.ShouldBe(1);
events.ConditionEvents(serverItem, "temp_hi").ShouldBeEmpty("the fresh node is not reported");
await subscription.DeleteAsync(true, ct);
}
finally { await rig.DisposeAsync(); }
}
}
@@ -0,0 +1,200 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// HistoryRead leg of the OpcUaClient driver's wire coverage (Gitea #622): the real
/// <see cref="OpcUaClientDriver"/> issues Raw / Processed / AtTime reads over loopback to the in-process
/// OtOpcUa server, whose node manager dispatches to an injected deterministic
/// <see cref="IHistorianDataSource"/> (<see cref="FakeHistorian"/>) behind a historized raw variable.
/// </summary>
[Trait("Category", "Integration")]
public sealed class OpcUaClientDriverHistoryWireTests
{
private const string HistRef = "Sim/dev1/Hist";
private const string HistTag = "Plant.Hist.Series";
private const uint Uncertain = 0x40000000u;
private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
private static DateTime At(int seconds) => T0.AddSeconds(seconds);
/// <summary>Five samples, one per second, value = 1..5; the third is Uncertain.</summary>
private static readonly DataValueSnapshot[] Series =
[
new(1.0, 0u, At(0), At(0)),
new(2.0, 0u, At(1), At(1)),
new(3.0, Uncertain, At(2), At(2)),
new(4.0, 0u, At(3), At(3)),
new(5.0, 0u, At(4), At(4)),
];
private sealed class FakeHistorian : IHistorianDataSource
{
public List<string> Calls { get; } = [];
public Task<HistoryReadResult> ReadRawAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken ct)
{
lock (Calls) Calls.Add($"raw:{fullReference}");
var hits = Series.Where(s => s.SourceTimestampUtc >= startUtc && s.SourceTimestampUtc < endUtc);
if (maxValuesPerNode > 0) hits = hits.Take((int)maxValuesPerNode);
return Task.FromResult(new HistoryReadResult([.. hits], null));
}
public Task<HistoryReadResult> ReadProcessedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken ct)
{
lock (Calls) Calls.Add($"processed:{fullReference}:{aggregate}");
if (aggregate != HistoryAggregateType.Average)
throw new HistoryAggregateNotSupportedException(aggregate);
var buckets = new List<DataValueSnapshot>();
for (var b = startUtc; b < endUtc; b += interval)
{
var inBucket = Series.Where(s => s.SourceTimestampUtc >= b && s.SourceTimestampUtc < b + interval).ToList();
if (inBucket.Count == 0) continue;
buckets.Add(new DataValueSnapshot(inBucket.Average(s => (double)s.Value!), 0u, b, b));
}
return Task.FromResult(new HistoryReadResult(buckets, null));
}
public Task<HistoryReadResult> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken ct)
{
lock (Calls) Calls.Add($"attime:{fullReference}");
// Step semantics: the last sample at or before the requested instant; BadNoData before the first.
var result = timestampsUtc.Select(t =>
{
var prior = Series.LastOrDefault(s => s.SourceTimestampUtc <= t);
return prior is null
? new DataValueSnapshot(null, 0x809B0000u /* BadNoData */, t, t)
: new DataValueSnapshot(prior.Value, prior.StatusCode, t, t);
}).ToList();
return Task.FromResult(new HistoryReadResult(result, null));
}
public Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken ct) =>
Task.FromResult(new HistoricalEventsResult([], null));
public HistorianHealthSnapshot GetHealthSnapshot() =>
new(0, 0, 0, 0, null, null, null, false, false, null, null, []);
public void Dispose() { }
}
private static async Task<(ServerEventRig Rig, FakeHistorian Fake, OpcUaClientDriver Drv)> StartAsync(
string name, CancellationToken ct)
{
var rig = await OpcUaClientDriverWireTests.StartRigAsync(name, ct);
var fake = new FakeHistorian();
rig.Nm.HistorianDataSource = fake;
var id = rig.EnsureRawVariable("Hist", "Double", writable: false, historianTagname: HistTag);
OpcUaClientDriverWireTests.Publish(rig, id, 5.0);
var drv = OpcUaClientDriverWireTests.NewDriver(
rig, name,
extraTags: [OpcUaClientDriverWireTests.Tag(HistRef, ServerEventRig.RawNsu(id))]);
await drv.InitializeAsync("{}", ct);
return (rig, fake, drv);
}
[Fact]
public async Task ReadRaw_returns_the_historians_samples_with_values_timestamps_and_statuses()
{
var ct = TestContext.Current.CancellationToken;
var (rig, fake, drv) = await StartAsync("hist-raw", ct);
await using var _r = rig;
await using var _d = drv;
var result = await drv.ReadRawAsync(HistRef, At(0), At(10), 0, ct);
result.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([1.0, 2.0, 3.0, 4.0, 5.0]);
result.Samples.Select(s => s.SourceTimestampUtc).ShouldBe(Series.Select(s => s.SourceTimestampUtc));
result.Samples.Select(s => s.StatusCode).ShouldBe([0u, 0u, Uncertain, 0u, 0u]);
result.ContinuationPoint.ShouldBeNull("the whole window fit in one page");
lock (fake.Calls) fake.Calls.ShouldContain($"raw:{HistTag}", "the read must reach the historian under the node's tagname");
}
[Fact]
public async Task ReadRaw_honours_the_window_and_pages_with_a_continuation_point_when_the_cap_is_hit()
{
var ct = TestContext.Current.CancellationToken;
var (rig, _, drv) = await StartAsync("hist-raw-page", ct);
await using var _r = rig;
await using var _d = drv;
// Window [1s, 4s) holds samples 2,3,4 (end exclusive).
var window = await drv.ReadRawAsync(HistRef, At(1), At(4), 0, ct);
window.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([2.0, 3.0, 4.0]);
// A cap of 3 against 5 available: first three samples and a continuation point.
var paged = await drv.ReadRawAsync(HistRef, At(0), At(10), 3, ct);
paged.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([1.0, 2.0, 3.0]);
paged.ContinuationPoint.ShouldNotBeNull("a full page must carry a continuation point");
}
[Fact]
public async Task ReadAtTime_returns_one_sample_per_requested_instant_with_the_prior_value()
{
var ct = TestContext.Current.CancellationToken;
var (rig, fake, drv) = await StartAsync("hist-attime", ct);
await using var _r = rig;
await using var _d = drv;
var result = await drv.ReadAtTimeAsync(HistRef, [At(-1), At(1), At(2), At(3).AddMilliseconds(500)], ct);
result.Samples.Count.ShouldBe(4);
result.Samples[0].StatusCode.ShouldNotBe(0u, "before the first sample there is no value");
Convert.ToDouble(result.Samples[1].Value).ShouldBe(2.0);
result.Samples[1].StatusCode.ShouldBe(0u);
Convert.ToDouble(result.Samples[2].Value).ShouldBe(3.0);
result.Samples[2].StatusCode.ShouldBe(Uncertain);
Convert.ToDouble(result.Samples[3].Value).ShouldBe(4.0);
result.Samples.Skip(1).Select(s => s.SourceTimestampUtc)
.ShouldBe([At(1), At(2), At(3).AddMilliseconds(500)]);
lock (fake.Calls) fake.Calls.ShouldContain($"attime:{HistTag}");
}
[Fact]
public async Task ReadProcessed_serves_a_supported_aggregate_and_returns_nothing_for_an_unsupported_one()
{
var ct = TestContext.Current.CancellationToken;
var (rig, fake, drv) = await StartAsync("hist-processed", ct);
await using var _r = rig;
await using var _d = drv;
// Average over 2s buckets across [0s,6s): {1,2}=1.5, {3,4}=3.5, {5}=5.
var avg = await drv.ReadProcessedAsync(HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Average, ct);
avg.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([1.5, 3.5, 5.0]);
avg.Samples.Select(s => s.SourceTimestampUtc).ShouldBe([At(0), At(2), At(4)]);
// The backend refuses Count: the server maps that to BadAggregateNotSupported for the node, which the
// driver surfaces as no samples (it does not expose the per-node status). The Average above is the
// positive control that the same path serves data, and the call must have REACHED the backend.
var count = await drv.ReadProcessedAsync(HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Count, ct);
count.Samples.ShouldBeEmpty();
lock (fake.Calls) fake.Calls.ShouldContain($"processed:{HistTag}:Count");
}
[Fact]
public async Task A_read_of_a_non_historized_node_returns_nothing_and_never_reaches_the_historian()
{
var ct = TestContext.Current.CancellationToken;
var (rig, fake, drv) = await StartAsync("hist-none", ct);
await using var _r = rig;
await using var _d = drv;
var result = await drv.ReadRawAsync(OpcUaClientDriverWireTests.SpeedRef, At(0), At(10), 0, ct);
result.Samples.ShouldBeEmpty();
// Positive control: the historized sibling does reach the historian on the same session.
(await drv.ReadRawAsync(HistRef, At(0), At(10), 0, ct)).Samples.Count.ShouldBe(5);
lock (fake.Calls) fake.Calls.Count.ShouldBe(1, "only the historized read dispatches");
}
}
@@ -0,0 +1,80 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// Reconnect + resume leg of the OpcUaClient driver's wire coverage (Gitea #622): the in-process server is
/// stopped and a fresh one is booted on the SAME port / application URI / PKI store — a process restart as the
/// driver sees it, with an empty address space the test then re-creates. The driver must detect the loss,
/// rebuild a session, and its existing subscription must deliver again with no caller action.
/// </summary>
[Trait("Category", "Integration")]
public sealed class OpcUaClientDriverReconnectWireTests
{
private static async Task WaitAsync(Func<bool> condition, string because, TimeSpan? timeout = null)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(60));
while (DateTime.UtcNow < deadline)
{
if (condition()) return;
await Task.Delay(50);
}
condition().ShouldBeTrue(because);
}
[Fact]
public async Task After_a_server_restart_the_driver_reconnects_and_the_subscription_resumes_delivering()
{
var ct = TestContext.Current.CancellationToken;
var rig = await OpcUaClientDriverWireTests.StartRigAsync("drv-reconnect", ct);
await using var _rig = rig;
OpcUaClientDriverWireTests.Publish(rig, OpcUaClientDriverWireTests.SpeedId, 10.0f);
await using var drv = OpcUaClientDriverWireTests.NewDriver(
rig, "wire-reconnect", reconnectPeriod: TimeSpan.FromSeconds(1));
await drv.InitializeAsync("{}", ct);
var seen = new List<float>();
drv.OnDataChange += (_, e) =>
{
if (e.FullReference == OpcUaClientDriverWireTests.SpeedRef && e.Snapshot.StatusCode == 0u)
lock (seen) seen.Add(Convert.ToSingle(e.Snapshot.Value));
};
var states = new List<HostState>();
drv.OnHostStatusChanged += (_, e) => { lock (states) states.Add(e.NewState); };
await drv.SubscribeAsync([OpcUaClientDriverWireTests.SpeedRef], TimeSpan.FromMilliseconds(100), ct);
await WaitAsync(() => { lock (seen) return seen.Contains(10.0f); }, "initial value must arrive before the outage");
// Outage: the loss must be DETECTED (host leaves Running) while the server is genuinely down.
await rig.RestartAsync(ct, whileDown: async () =>
{
await WaitAsync(
() => { lock (states) return states.Any(s => s != HostState.Running); },
"the driver must notice the server is gone", TimeSpan.FromSeconds(30));
});
// The new server is empty: redeploy the nodes and publish a value no earlier delivery could have carried.
rig.EnsureRawVariable("Speed", "Float", writable: true);
rig.EnsureRawVariable("Counter", "Int32", writable: false);
OpcUaClientDriverWireTests.Publish(rig, OpcUaClientDriverWireTests.SpeedId, 77.0f);
await WaitAsync(() => drv.GetHostStatuses().Single().State == HostState.Running,
"the driver must reconnect after the server is back");
await WaitAsync(() => { lock (seen) return seen.Contains(77.0f); },
"the pre-outage subscription must resume and deliver the post-restart value");
// Ongoing delivery, not just a one-off initial value.
OpcUaClientDriverWireTests.Publish(rig, OpcUaClientDriverWireTests.SpeedId, 88.0f);
await WaitAsync(() => { lock (seen) return seen.Contains(88.0f); },
"a later change must also reach the resumed subscription");
// And the data plane works for plain reads on the new session.
var back = await drv.ReadAsync([OpcUaClientDriverWireTests.SpeedRef], ct);
back[0].StatusCode.ShouldBe(0u);
Convert.ToSingle(back[0].Value).ShouldBe(88.0f);
}
}
@@ -13,19 +13,17 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// fixture" scaffolds in <c>Driver.OpcUaClient.Tests</c> (Gitea #622). No opc-plc and no Docker: every test
/// here runs a real secure-channel + session + subscription exchange over loopback. The driver authenticates
/// with the rig's API-key credential so writes carry the <c>WriteOperate</c> role.
/// <para>
/// HistoryRead is NOT covered here (deferred): the server-side leg needs an <c>IHistorianDataSource</c>
/// stub plus a historized-variable rig seam, and the driver's passthrough shapes are unit-tested already.
/// </para>
/// HistoryRead lives in <see cref="OpcUaClientDriverHistoryWireTests"/>; reconnect/resume in
/// <see cref="OpcUaClientDriverReconnectWireTests"/>.
/// </summary>
[Trait("Category", "Integration")]
public sealed class OpcUaClientDriverWireTests
{
private const string SpeedRef = "Sim/dev1/Speed";
private const string RoRef = "Sim/dev1/Counter";
internal const string SpeedRef = "Sim/dev1/Speed";
internal const string RoRef = "Sim/dev1/Counter";
private const string MissingRef = "Sim/dev1/Missing";
private const string SpeedId = ServerEventRig.RawDeviceFolder + "/Speed";
private const string CounterId = ServerEventRig.RawDeviceFolder + "/Counter";
internal const string SpeedId = ServerEventRig.RawDeviceFolder + "/Speed";
internal const string CounterId = ServerEventRig.RawDeviceFolder + "/Counter";
private sealed class RecordingWriteGateway(bool succeed) : IOpcUaNodeWriteGateway
{
@@ -38,10 +36,12 @@ public sealed class OpcUaClientDriverWireTests
}
}
private static RawTagEntry Tag(string rawPath, string nodeId) =>
internal static RawTagEntry Tag(string rawPath, string nodeId) =>
new(rawPath, JsonSerializer.Serialize(new { nodeId }), WriteIdempotent: true);
private static OpcUaClientDriver NewDriver(ServerEventRig rig, string id, string? browseRoot = null) =>
internal static OpcUaClientDriver NewDriver(
ServerEventRig rig, string id, string? browseRoot = null, IReadOnlyList<RawTagEntry>? extraTags = null,
TimeSpan? reconnectPeriod = null) =>
new(new OpcUaClientDriverOptions
{
EndpointUrl = rig.EndpointUrl,
@@ -50,6 +50,7 @@ public sealed class OpcUaClientDriverWireTests
Tag(SpeedRef, ServerEventRig.RawNsu(SpeedId)),
Tag(RoRef, ServerEventRig.RawNsu(CounterId)),
Tag(MissingRef, ServerEventRig.RawNsu($"{ServerEventRig.RawDeviceFolder}/NoSuchNode")),
.. extraTags ?? [],
],
SecurityPolicy = OpcUaSecurityPolicy.None,
SecurityMode = OpcUaSecurityMode.None,
@@ -62,9 +63,10 @@ public sealed class OpcUaClientDriverWireTests
SessionTimeout = TimeSpan.FromSeconds(30),
KeepAliveInterval = TimeSpan.FromSeconds(1),
BrowseRoot = browseRoot,
ReconnectPeriod = reconnectPeriod ?? TimeSpan.FromSeconds(5),
}, id);
private static async Task<ServerEventRig> StartRigAsync(string name, CancellationToken ct)
internal static async Task<ServerEventRig> StartRigAsync(string name, CancellationToken ct)
{
var rig = await ServerEventRig.StartAsync(name, encrypted: false, ct);
rig.EnsureRawVariable("Speed", "Float", writable: true);
@@ -72,7 +74,7 @@ public sealed class OpcUaClientDriverWireTests
return rig;
}
private static void Publish(ServerEventRig rig, string id, object value) =>
internal static void Publish(ServerEventRig rig, string id, object value) =>
rig.Sink.WriteValue(id, value, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
/// <summary>Reads round-trip through the real wire; a node the server lacks does not read Good.</summary>
@@ -253,6 +255,68 @@ public sealed class OpcUaClientDriverWireTests
"the writable variable and the read-only one must classify differently");
}
/// <summary>
/// #619 — ConditionRefresh over the wire: a condition already active+unacked BEFORE the driver subscribes is
/// reported (Raise, keyed by the RawPath) right after subscribe, not only on its next transition. A second
/// subscribe of the same set refreshes again and must emit nothing (no re-raise); a positive control then
/// proves the feed is still live by acknowledging upstream and seeing exactly the Acknowledge arrive.
/// </summary>
[Fact]
public async Task A_condition_already_active_before_subscribe_is_reported_by_ConditionRefresh()
{
const string AlarmRef = "Sim/dev1/TempHi";
var ct = TestContext.Current.CancellationToken;
await using var rig = await StartRigAsync("drv-alarm-refresh", ct);
rig.MaterialiseNative();
rig.Project(native: true, new AlarmConditionSnapshot(Active: true, Acknowledged: false, Confirmed: true,
Enabled: true, AlarmShelvingKind.Unshelved, 800, "already hot"));
await using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions
{
EndpointUrl = rig.EndpointUrl,
RawTags = [Tag(AlarmRef, ServerEventRig.RawNsu(ServerEventRig.RawAlarmPath))],
SecurityPolicy = OpcUaSecurityPolicy.None,
SecurityMode = OpcUaSecurityMode.None,
AuthType = OpcUaAuthType.Username,
Username = "apikey",
Password = ServerEventRig.KeyPassword,
AutoAcceptCertificates = true,
TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform,
Timeout = TimeSpan.FromSeconds(10),
SessionTimeout = TimeSpan.FromSeconds(30),
KeepAliveInterval = TimeSpan.FromSeconds(1),
}, "wire-alarm-refresh");
await drv.InitializeAsync("{}", ct);
var seen = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { lock (seen) seen.Add(e); };
AlarmEventArgs[] Seen() { lock (seen) return [.. seen]; }
var first = await drv.SubscribeAlarmsAsync([AlarmRef], ct);
await RecordedEvents.WaitUntilAsync(() => Seen().Any(e => e.Kind == AlarmTransitionKind.Raise));
var raise = Seen().Single(e => e.Kind == AlarmTransitionKind.Raise);
raise.ConditionId.ShouldBe(AlarmRef);
raise.Message.ShouldBe("already hot");
raise.OpcUaSeverity.ShouldBe(800);
Seen().ShouldAllBe(e => e.ConditionId == AlarmRef, "refresh markers must never surface as alarms");
// Same set again, released first exactly as DriverInstanceActor does → a fresh subscription and another
// ConditionRefresh; the identical state must not re-raise.
var before = Seen().Length;
await drv.UnsubscribeAlarmsAsync(first, ct);
await drv.SubscribeAlarmsAsync([AlarmRef], ct);
await Task.Delay(1500, ct); // let the second refresh's events land before the control transition
// Positive control: an upstream acknowledge arrives as exactly one Acknowledge, after which nothing else.
rig.Project(native: true, new AlarmConditionSnapshot(Active: true, Acknowledged: true, Confirmed: true,
Enabled: true, AlarmShelvingKind.Unshelved, 800, "already hot"));
await RecordedEvents.WaitUntilAsync(() => Seen().Any(e => e.Kind == AlarmTransitionKind.Acknowledge));
await Task.Delay(500, ct);
Seen().Skip(before).Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Acknowledge],
"the second refresh re-delivered an unchanged state and must have emitted nothing");
}
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
@@ -38,6 +38,7 @@ internal sealed class ServerEventRig : IAsyncDisposable
private OpcUaApplicationHost _host = null!;
private OtOpcUaSdkServer _server = null!;
private int _port;
private OpcUaApplicationHostOptions _hostOptions = null!;
private ServerEventRig(string pkiRoot) => _pkiRoot = pkiRoot;
@@ -51,28 +52,44 @@ internal sealed class ServerEventRig : IAsyncDisposable
{
var rig = new ServerEventRig(Path.Combine(Path.GetTempPath(), $"otopcua-{name}-{Guid.NewGuid():N}"));
rig._port = AllocateFreePort();
rig._server = new OtOpcUaSdkServer();
rig._host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = $"OtOpcUa.{name}",
ApplicationUri = $"urn:OtOpcUa.{name}.{Guid.NewGuid():N}",
OpcUaPort = rig._port,
PublicHostname = "127.0.0.1",
PkiStoreRoot = rig._pkiRoot,
EnabledSecurityProfiles = encrypted
? [OpcUaSecurityProfile.None, OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt]
: [OpcUaSecurityProfile.None],
AutoAcceptUntrustedClientCertificates = true,
},
NullLogger<OpcUaApplicationHost>.Instance,
new KeyPresentingAuthenticator());
await rig._host.StartAsync(rig._server, ct);
rig.Sink = new SdkAddressSpaceSink(rig.Nm);
rig._hostOptions = new OpcUaApplicationHostOptions
{
ApplicationName = $"OtOpcUa.{name}",
ApplicationUri = $"urn:OtOpcUa.{name}.{Guid.NewGuid():N}",
OpcUaPort = rig._port,
PublicHostname = "127.0.0.1",
PkiStoreRoot = rig._pkiRoot,
EnabledSecurityProfiles = encrypted
? [OpcUaSecurityProfile.None, OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt]
: [OpcUaSecurityProfile.None],
AutoAcceptUntrustedClientCertificates = true,
};
await rig.BootAsync(ct);
return rig;
}
private async Task BootAsync(CancellationToken ct)
{
_server = new OtOpcUaSdkServer();
_host = new OpcUaApplicationHost(
_hostOptions, NullLogger<OpcUaApplicationHost>.Instance, new KeyPresentingAuthenticator());
await _host.StartAsync(_server, ct);
Sink = new SdkAddressSpaceSink(Nm);
}
/// <summary>
/// Stops the server and boots a fresh one on the SAME port, application URI and PKI store (a process
/// restart as a client sees it). The address space is EMPTY afterwards: the caller re-creates its nodes and
/// re-publishes values, as a redeploy would. <paramref name="whileDown"/> runs once the old server is gone
/// and before the new one binds, so a test can observe the outage.
/// </summary>
public async Task RestartAsync(CancellationToken ct, Func<Task>? whileDown = null)
{
await _host.DisposeAsync();
if (whileDown is not null) await whileDown();
await BootAsync(ct);
}
/// <summary>Wires the authorizer over the given keys and grants, enforcing or not.</summary>
public DataPlaneAuthorityState Enforce(bool enabled, ApiKey[] keys, params NodeAcl[] grants) =>
Enforce(enabled, keys, ScopePaths(), grants);
@@ -148,11 +165,11 @@ internal sealed class ServerEventRig : IAsyncDisposable
/// Adds a raw value variable under <see cref="RawDeviceFolder"/> (the folder is ensured), for tests that
/// drive a real client against plain values rather than alarm conditions. Additive to the alarm helpers.
/// </summary>
public string EnsureRawVariable(string name, string dataType, bool writable)
public string EnsureRawVariable(string name, string dataType, bool writable, string? historianTagname = null)
{
Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
var id = $"{RawDeviceFolder}/{name}";
Sink.EnsureVariable(id, RawDeviceFolder, name, dataType, writable, AddressSpaceRealm.Raw);
Sink.EnsureVariable(id, RawDeviceFolder, name, dataType, writable, AddressSpaceRealm.Raw, historianTagname);
return id;
}
@@ -0,0 +1,284 @@
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Gitea #641 — a full <see cref="OtOpcUaNodeManager.RebuildAddressSpace"/> recreates every native condition
/// inactive, and when the alarm-tag set is unchanged nothing tells the new node its state again (the driver
/// does not re-subscribe, and Galaxy / OpcUaClient have nothing to replay anyway). The node manager therefore
/// carries each NATIVE condition's live state across the rebuild: captured before the clear, restored when
/// the same id is re-materialised native in the same apply, and reported as ONE condition event when the
/// apply completes (<see cref="OtOpcUaNodeManager.CompleteAddressSpaceApply"/>).
/// </summary>
/// <remarks>
/// "One event" is observed here through the condition's <c>EventId</c>, which every report stamps fresh.
/// The exact count over the wire is pinned by <c>NativeAlarmRebuildCarryOverWireTests</c>.
/// </remarks>
public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable
{
private const string Device = "Plant/MTConnect/dev1";
private const string Alarm = "Plant/MTConnect/dev1/Xtravel";
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-641-{Guid.NewGuid():N}");
private static AlarmConditionSnapshot Fault(bool acked) => new(
Active: true, Acknowledged: acked, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: 800, Message: "Xtravel FAULT 1001",
StatusCode: OpcUaStatus.Good);
[Trait("Category", "Unit")]
[Fact]
public async Task An_active_unacked_native_condition_survives_a_rebuild_and_is_reported_once()
{
var (host, nm) = await BootAsync();
var raisedAt = new DateTime(2026, 10, 8, 9, 0, 0, DateTimeKind.Utc);
Materialise(nm, native: true);
nm.WriteAlarmCondition(Alarm, Fault(acked: false), raisedAt, AddressSpaceRealm.Raw);
nm.RebuildAddressSpace();
Materialise(nm, native: true);
var restored = Condition(nm);
var idBeforeComplete = (byte[]?)restored.EventId.Value?.Clone();
// State is back as soon as the node exists — a Read or a ConditionRefresh in between already sees it.
restored.ActiveState.Id.Value.ShouldBeTrue();
restored.AckedState.Id.Value.ShouldBeFalse();
restored.Retain.Value.ShouldBeTrue();
restored.Severity.Value.ShouldBe((ushort)800);
restored.Message.Value.Text.ShouldBe("Xtravel FAULT 1001");
restored.Quality.Value.Code.ShouldBe(StatusCodes.Good);
nm.CompleteAddressSpaceApply();
// The report happens at the END of the apply (after the notifier wiring), stamped with the instant the
// state came about, not the deploy time.
restored.EventId.Value.ShouldNotBe(idBeforeComplete);
restored.Time.Value.ShouldBe(raisedAt);
// ...and only once: a second completion with nothing pending reports nothing.
var idAfterComplete = (byte[]?)restored.EventId.Value?.Clone();
nm.CompleteAddressSpaceApply();
restored.EventId.Value.ShouldBe(idAfterComplete);
await host.DisposeAsync();
}
/// <summary>
/// The acknowledgement a CLIENT applied lives only on the node (the SDK applied it; no driver or projector
/// saw it) — the reason the carry-over reads the node rather than any host-side cache.
/// </summary>
[Trait("Category", "Unit")]
[Fact]
public async Task A_client_acknowledgement_survives_the_rebuild()
{
var (host, nm) = await BootAsync();
Materialise(nm, native: true);
nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw);
// What the SDK's Acknowledge handler does to the node; nothing else hears of it.
Condition(nm).SetAcknowledgedState(nm.SystemContext, true);
nm.RebuildAddressSpace();
Materialise(nm, native: true);
nm.CompleteAddressSpaceApply();
var restored = Condition(nm);
restored.ActiveState.Id.Value.ShouldBeTrue();
restored.AckedState.Id.Value.ShouldBeTrue();
restored.Retain.Value.ShouldBeTrue();
await host.DisposeAsync();
}
/// <summary>
/// Gitea #641 review — the comment a client left with its acknowledgement is condition state too (native
/// conditions support AddComment), and an event the condition reported AFTER the restore (a client method
/// call landing on an SDK thread before the apply completes) must not have its newer <c>Time</c> rolled
/// back by the deferred restore report.
/// </summary>
[Trait("Category", "Unit")]
[Fact]
public async Task The_comment_is_carried_and_a_newer_report_is_not_rolled_back()
{
var (host, nm) = await BootAsync();
var raisedAt = new DateTime(2026, 10, 8, 9, 0, 0, DateTimeKind.Utc);
Materialise(nm, native: true);
nm.WriteAlarmCondition(Alarm, Fault(acked: false), raisedAt, AddressSpaceRealm.Raw);
Condition(nm).Comment.Value = new LocalizedText("on it");
nm.RebuildAddressSpace();
Materialise(nm, native: true);
var later = raisedAt.AddMinutes(5);
nm.WriteAlarmQuality(Alarm, StatusCodes.BadNoCommunication, later, AddressSpaceRealm.Raw); // reports at `later`
nm.CompleteAddressSpaceApply();
var restored = Condition(nm);
restored.Comment.Value.Text.ShouldBe("on it");
restored.Time.Value.ShouldBe(later, "the restore report must not stamp the condition back to the raise time");
await host.DisposeAsync();
}
/// <summary>Falsification control: a condition in the no-event position comes back exactly as a fresh
/// materialise builds it, and reports nothing.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task A_normal_native_condition_reports_nothing_and_keeps_the_fresh_state()
{
var (host, nm) = await BootAsync();
Materialise(nm, native: true);
nm.WriteAlarmCondition(Alarm, Fault(acked: false) with { Active = false, Acknowledged = true, Message = "cleared" },
DateTime.UtcNow, AddressSpaceRealm.Raw);
nm.RebuildAddressSpace();
Materialise(nm, native: true);
var fresh = Condition(nm);
var idBefore = (byte[]?)fresh.EventId.Value?.Clone();
nm.CompleteAddressSpaceApply();
fresh.EventId.Value.ShouldBe(idBefore);
fresh.ActiveState.Id.Value.ShouldBeFalse();
fresh.AckedState.Id.Value.ShouldBeTrue();
fresh.Retain.Value.ShouldBeFalse();
fresh.Message.Value.Text.ShouldBe("Xtravel", "a normal condition keeps the materialise's message");
fresh.Severity.Value.ShouldBe((ushort)700, "and its authored severity");
await host.DisposeAsync();
}
/// <summary>
/// The source-data quality is carried for a NORMAL condition too, silently: quality is annotated only on a
/// driver's connectivity edge, so without the carry a rebuild left every normal native condition reading
/// <c>BadWaitingForInitialData</c> until the driver next flapped.
/// </summary>
[Trait("Category", "Unit")]
[Fact]
public async Task A_normal_condition_keeps_its_quality_without_an_event()
{
var (host, nm) = await BootAsync();
Materialise(nm, native: true);
nm.WriteAlarmQuality(Alarm, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
nm.RebuildAddressSpace();
Materialise(nm, native: true);
var fresh = Condition(nm);
var idBefore = (byte[]?)fresh.EventId.Value?.Clone();
nm.CompleteAddressSpaceApply();
fresh.Quality.Value.Code.ShouldBe(StatusCodes.Good);
fresh.EventId.Value.ShouldBe(idBefore);
await host.DisposeAsync();
}
/// <summary>A condition the rebuild did NOT re-create has its captured state discarded when the apply
/// completes, so a later deploy that adds the same RawPath back gets a fresh node, not a resurrection.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task A_condition_removed_by_the_rebuild_is_not_resurrected_by_a_later_add()
{
var (host, nm) = await BootAsync();
Materialise(nm, native: true);
nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw);
nm.RebuildAddressSpace();
nm.CompleteAddressSpaceApply(); // the alarm was removed: nothing re-materialised it
Materialise(nm, native: true); // a later, surgical re-add of the same RawPath
nm.CompleteAddressSpaceApply();
var readded = Condition(nm);
readded.ActiveState.Id.Value.ShouldBeFalse();
readded.AckedState.Id.Value.ShouldBeTrue();
await host.DisposeAsync();
}
/// <summary>A native condition re-materialised as SCRIPTED under the same id is a different alarm: nothing
/// is restored onto it (and the scripted engine's own #487 re-assert owns scripted state).</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task A_kind_swap_on_the_same_id_is_not_restored()
{
var (host, nm) = await BootAsync();
Materialise(nm, native: true);
nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw);
nm.RebuildAddressSpace();
Materialise(nm, native: false);
nm.CompleteAddressSpaceApply();
Condition(nm).ActiveState.Id.Value.ShouldBeFalse();
await host.DisposeAsync();
}
/// <summary>Scripted conditions are not captured — <c>ScriptedAlarmHostActor.ReassertConditionNodes</c>
/// (#487) already re-projects them after a reload, and a second source would double-report.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task A_scripted_condition_is_not_carried()
{
var (host, nm) = await BootAsync();
Materialise(nm, native: false);
nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw);
nm.RebuildAddressSpace();
Materialise(nm, native: false);
nm.CompleteAddressSpaceApply();
Condition(nm).ActiveState.Id.Value.ShouldBeFalse();
await host.DisposeAsync();
}
private static void Materialise(OtOpcUaNodeManager nm, bool native)
{
nm.EnsureFolder(Device, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
nm.MaterialiseAlarmCondition(Alarm, Device, "Xtravel", "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: native);
}
private static Opc.Ua.AlarmConditionState Condition(OtOpcUaNodeManager nm) =>
nm.TryGetAlarmCondition(Alarm, AddressSpaceRealm.Raw).ShouldNotBeNull();
private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm)> BootAsync()
{
var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.Gitea641Test",
ApplicationUri = $"urn:OtOpcUa.Gitea641Test:{Guid.NewGuid():N}",
OpcUaPort = AllocateFreePort(),
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
Microsoft.Extensions.Logging.Abstractions.NullLogger<OpcUaApplicationHost>.Instance);
var server = new OtOpcUaSdkServer();
await host.StartAsync(server, Ct);
return (host, server.NodeManager!);
}
private static int AllocateFreePort()
{
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
listener.Start();
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
/// <summary>Cleans up the PKI root directory.</summary>
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); }
catch { /* best-effort cleanup */ }
}
}
}

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