Compare commits

...

29 Commits

Author SHA1 Message Date
Joseph Doherty 123ddc3f40 fix(scripteditor): complete + hover absolute RawPaths (#490)
v2-ci / build (push) Successful in 4m25s
v2-ci / unit-tests (push) Failing after 14m58s
The issue reports a missing feature. The cause is a stale projection, and the
consequence was worse than the gap described.

`ScriptTagCatalog` still emitted `Tag.TagConfig.FullName` — the pre-v3 driver wire
address. Since v3 the mux key is the **RawPath**: `AddressSpaceComposer` harvests the
`ctx.GetTag("…")` literals into `DependencyRefs`, those become `VirtualTagActor`
dependency refs registered with `DependencyMuxActor`, whose `_byRef` is keyed Ordinal
by `AttributeValuePublished.FullReference` — and a driver's wire ref IS the RawPath
(`DriverHostActor._nodeIdByDriverRef` is keyed `(DriverInstanceId, RawPath)`). The
`{{equip}}/<RefName>` form already substitutes to RawPaths at both compose seams.

So the catalog was wrong in BOTH directions, not merely incomplete: completion
offered paths that could never resolve at runtime, while a CORRECT absolute RawPath
got no completion and hovered as "not a known configured tag path". The file's own
comment admitted the raw/UNS catalog was left for "Batch 2/3" and it never came.

Now projected through the shared `RawPathResolver` — the same byte-parity authority
`DraftValidator` and `AddressSpaceComposer` use — so a suggested path is identical to
the one the deploy gate and the runtime compute. A tag with a broken ancestry chain
resolves to null and is omitted: the runtime could not route it either, so offering
it would be a lie. Hover additionally gains the owning `DriverInstanceId`, which the
topology now makes available (it was always null before).

Editor-accepts <=> publish-accepts is preserved: no diagnostic keys off this catalog
(`OTSCRIPT_EQUIPREF` only marks `{{equip}}` paths), so this changes completion and
hover only — it cannot newly reject a script.

The existing tests pinned the v2 contract and were re-authored: a RawPath only exists
if the RawFolder -> DriverInstance -> Device -> TagGroup -> Tag chain does, so the
seed now builds one. Added coverage for group-nested paths, a driver at the cluster
root (no folder segment), a broken chain being omitted rather than guessed, and the
pre-v3 FullName no longer resolving.

Live-gated on docker-dev (rig rebuilt, both central nodes):
- completion, empty literal -> every RawPath incl. group-nested
  `opcua1/plc/OpcPlc/Telemetry/Basic/AlternatingBoolean`
- completion, prefix `pymod` -> `pymodbus/plc/HR200X`, `pymodbus/plc/ImportedTag`
- hover `pymodbus/plc/HR200X` -> "Tag · Type UInt16 · Driver MAIN-modbus"
- hover an unknown path -> "Not a known configured tag path"

AdminUI 742/742; script-analysis 55/55; solution builds 0 errors.
2026-07-26 11:08:11 -04:00
Joseph Doherty 0f38f48679 merge: Modbus RTU-over-TCP transport (#495)
Wave 1 of the driver-expansion program. The branch was complete and live-gated but
had never been merged; it sat 15 commits ahead and 50 behind master.

Purely additive behind the existing IModbusTransport seam — the application protocol
(function codes, codecs, planner, coalescing, write path, probe, materialisation,
HistoryRead) is identical across TCP and RTU; only the wire framing differs
([addr][PDU][CRC-16], no MBAP, no TxId). Zero changes to codecs/planner/health.

Selected by a new Transport config field (ModbusTransportMode: Tcp | RtuOverTcp),
routed through ModbusTransportFactory (throws on unknown mode), with the string-enum
guard on options/DTO/factory and a Transport selector on the AdminUI Modbus form.
ModbusSocketLifecycle was extracted from ModbusTcpTransport (behaviour-preserving)
and is composed by both transports.

Descoped by design: direct-serial (System.IO.Ports) and Modbus ASCII — RTU buses are
reached exclusively via a serial->Ethernet gateway. No per-tag config changes; the
existing per-tag UnitId already makes a multi-drop bus work.

Re-validated against current master at merge time (the branch's own gate predates 50
commits of master): no overlapping files, clean merge, solution builds 0 errors, and
Modbus 344/344, Modbus.Addressing 161/161, AdminUI 740/740, Runtime 507/507,
Configuration 131/131 all pass. Confirmed the Transport selector is still reachable
through master's current authoring path (DriverConfigModal -> ModbusDriverForm), so
the branch's live /run gate result still applies.
2026-07-26 10:58:51 -04:00
Joseph Doherty 0e93587b1c merge: test-correctness, config-secret and AdminUI fixes (#503 #501 #493 #499 #488 #505)
v2-ci / build (push) Successful in 4m14s
v2-ci / unit-tests (push) Failing after 14m48s
Six issues closed, one new one found, filed and fixed.

- #503 Galaxy EventPump flake — root cause was cross-test measurement leakage on a
  STATIC Meter, not a timing budget. 15/15 clean under the issue's own contention
  recipe. Three further defects in the same test fixed on the way, including a
  "held" dispatch loop that was never held (async void on EventHandler<T>) and an
  assertion built from a derived quantity, which could only fail spuriously.
- #501 Three absence assertions given positive controls (a fourth site the issue
  did not list). Proven falsifiable by breaking the production code: both breaks
  passed under the old AwaitAssert form.
- #493 docker-dev seed backfills ClusterNode.GrpcPort; upgrade step documented.
  Deliberately no EF migration — the right value is per-node config the DB cannot
  derive, and a wrong dial target is worse than a NULL that is already skipped.
- #499 Sql credentials refused in ClusterNode.DriverConfigOverridesJson, gated at
  the SAVE rather than the deploy: node overrides never enter the artifact, so a
  deploy gate would not stop the credential being stored. Live-verified.
- #488 Periodic desired-vs-actual subscription reconcile (option A), gated on
  ISubscribable so a non-subscribable driver is not Subscribe-spammed forever.
- #505 The AdminUI cluster-node editor had never worked at all — HTTP 500 on load
  (InputNumber<byte>) and Save a silent no-op (NodeId regex forbids the ':' every
  NodeId contains), with no ValidationSummary to reveal it. Pre-existing on master,
  invisible to 739 green AdminUI tests, found only by going to drive the page.

Also closed #492 (already fixed by 2964361a and drill-verified; no code change).

Verified: solution builds 0 errors; 2406 tests pass across Runtime (507), AdminUI
(739), Configuration (131), Galaxy (317), Cluster (147), Core (260),
ScriptedAlarms (82) and Sql (223). #499 and #505 live-gated on docker-dev with both
central nodes rebuilt; rig restored afterwards.
2026-07-26 10:51:43 -04:00
Joseph Doherty 4cc0215bb4 fix(adminui): the cluster-node editor was dead — 500 on load, silent no-op on save
Both defects are PRE-EXISTING (present on master, unrelated to #499) and were found
live-verifying the #499 guard, which lives on this page and was unreachable without
them fixed.

1. **HTTP 500 on load.** `FormModel.ServiceLevelBase` was `byte` to match the
   entity, and `InputNumber<T>`'s static constructor throws outright for
   `System.Byte` — "The type 'System.Byte' is not a supported numeric type". That
   escapes `BuildRenderTree` unhandled, which in Blazor takes the WHOLE page down,
   not one field. `/clusters/{id}/nodes/{nodeId}` and `.../nodes/new` have never
   rendered. Now `int` with the same `[Range(0, 255)]` and a cast on the way to the
   entity, so the stored type is unchanged. (Same failure mode as #504: an
   unhandled throw inside the render tree is a page-kill.)

2. **Save did nothing, silently.** `NodeId` was validated against
   `^[A-Za-z0-9_-]+$`, which forbids the colon — but every NodeId in this system is
   `host:port` (`ClusterRoleInfo` and `ConfigPublishCoordinator` derive it as
   `member.Address.Host:Port`, the seed writes `central-1:4053`, and
   `NodeDeploymentState.NodeId` is FK-bound to it). So every existing node failed
   validation, `OnValidSubmit` never ran, and the button was inert. Pattern now
   allows `:` and `.` (hostnames may be dotted).

3. **Added the missing `<ValidationSummary/>`.** The form had a
   `DataAnnotationsValidator` but nothing rendering its output, so a validation
   failure produced no feedback at all — which is precisely how (2) stayed
   invisible. This is the change that stops the next such defect being silent.

Live-verified on docker-dev (both central nodes rebuilt, since :9200 round-robins
the pair): page 500 → 200 on both routes, and a real edit now persists.
2026-07-26 10:42:04 -04:00
Joseph Doherty 4b9bcddbcc feat(drivers): periodic reconcile of desired vs actual subscriptions (#488)
Option A from the issue — the cheap consistency check, no interface change.

The desired ref set was re-applied on a Connected TRANSITION (`ResubscribeDesired`)
and on a deploy's `SetDesiredSubscriptions`, and nowhere else. A subscription lost
while the driver STAYED Connected — a subscribe that failed and was never retried,
a handle dropped down an error path — left the actor subscribed to nothing while
still reporting Healthy, indefinitely.

`ReconcileSubscription` now rides the existing 30 s `health-poll` tick: while
Connected, a driver holding a desired set but no live handle is re-subscribed.

Gated on `ISubscribable`. A driver that cannot subscribe at all can still be handed
a desired set, and without the guard it would be told `Subscribe` every tick and
fail every tick, forever.

This is a state-machine consistency check, NOT a probe — it can only see that this
actor holds no handle, never that a handle is present but dead server-side, because
`ISubscriptionHandle` is opaque. That is option B, and it stays deferred until a
real occurrence shows the handle outliving the subscription; it would need a
liveness member implemented across all eight drivers with different subscription
mechanics.

A redundant `Subscribe` is possible (a tick queued ahead of an already-queued one
observes the same no-handle state) and is harmless: `HandleSubscribeAsync` is a
`ReceiveAsync`, so no tick is processed mid-subscribe, and it drops any prior
subscription before establishing the new one. Suppressing it would need a
pending-subscribe flag threaded through every self-tell site — more state than the
race is worth.

`healthPollInterval` becomes an optional Props parameter, mirroring the existing
`rediscoverInterval` seam, so a test can drive the reconcile without waiting 30 s.

Three tests. The reconcile itself is proven by a stub whose FIRST subscribe throws:
nothing else would ever retry it, so a second attempt can only come from the
reconcile — disabling `ReconcileSubscription` turns it red. The two guard tests are
absence assertions and are bounded by a positive control rather than a sleep: a
counting health publisher makes ticks PROCESSED observable, so "still one subscribe"
means the reconcile ran and declined, not that the timer had not fired.

Explicitly not deploy-time drift (#486) — re-asserting an already-correct desired
set would not have helped there.
2026-07-26 10:30:19 -04:00
Joseph Doherty 2193d9f2e4 fix(config): refuse to store a Sql credential in a node config override (#499)
`ClusterNode.DriverConfigOverridesJson` is the third surface a Sql driver's config
is persisted on, and the only one nothing gated. It is a map keyed by
`DriverInstanceId` merged onto the cluster-level `DriverConfig`, so a literal
`connectionString` pasted there leaks exactly as one pasted into the driver — the
leak #498 closed for `DriverInstance.DriverConfig` and `Device.DeviceConfig`.

Gated at the SAVE, not at the deploy — a deliberate departure from both options the
issue offered:

- `DraftSnapshot` carries no `ClusterNode` rows, and adding them would widen the
  snapshot and every builder of one for a single rule about a value that never
  enters the artifact.
- More to the point, a deploy gate is the wrong instrument here. Node overrides are
  not in the artifact, so blocking a deploy would not stop the credential being
  stored — it is already in the database and replicated by then. Refusing the save
  is the only point where "refuse to store it" is literally true, which is #498's
  own framing: discarding a secret on read is not the same as refusing to store it.

The check moves into a shared `SqlCredentialGuard` so the two enforcement points
cannot drift; `DraftValidator` now calls it instead of its own private helper.

Kept deliberately narrow — the `Sql` driver's `connectionString` only, and only for
instance ids that ARE Sql drivers. The issue asked whether to generalise to
credential-shaped keys across every driver type; not doing that, for the same
reason #498 did not: a broader sweep would start refusing configs that are
legitimate today for drivers which never made an indirect-credential guarantee,
which is a regression rather than defence in depth. Widen per driver, as each gains
its own contract.

The error names the offending driver instance(s) and NEVER the value — it reaches
the AdminUI and the audit trail.

14 new cases cover both halves, including the case variants (System.Text.Json binds
`ConnectionString` to `connectionString`, so a case variant is the same key, not a
bypass), non-Sql ids being ignored, and every blank/malformed/non-object shape.
2026-07-26 10:30:04 -04:00
Joseph Doherty 8b7ea3213d fix(rig,docs): backfill ClusterNode.GrpcPort and document the upgrade step (#493)
`GrpcPort` is a nullable column added by Phase 5. Every insert in the docker-dev
seed is INSERT-if-not-exists — the right shape for a re-runnable seed, but it means
a new nullable column never reaches rows an earlier version of the file created. On
a persisted volume all six rows kept NULL, central found no dial targets, and
`TelemetryDial:Mode=Grpc` connected to nothing. (`AkkaPort` escaped this only
because it is NOT NULL with a default of 4053.)

Seed now backfills `GrpcPort IS NULL AND CreatedBy = 'docker-dev-seed'` to 4056.
NULL only — it must not overwrite a port an operator set deliberately on a
long-lived dev volume, and NULL is the unambiguous "predates the column" marker.

Deliberately NOT doing the suggested EF data migration for real deployments. The
correct value is each node's own `Telemetry:GrpcListenPort`, which is
per-deployment configuration the database cannot derive; a migration could only
invent one, and a WRONG dial target is worse than the NULL the dialer already skips
explicitly with a throttled warning. The rig can be backfilled because there the
port is fixed by docker-compose.yml and therefore actually knowable.

`docs/Telemetry.md` gains an upgrade section stating the prerequisite, the symptoms
(the throttled skip log, LISTEN with no ESTABLISHED, the pill never going live) and
the UPDATE to run before flipping any node to Grpc — plus why there is no migration.
2026-07-26 10:29:51 -04:00
Joseph Doherty 240d7aa8aa fix(test): give the three absence assertions a positive control (#501)
`AwaitAssert` returns on its FIRST success, so an assertion that is already true at
t=0 passes instantly and spends none of its duration. All three sites asserted an
absence against a collection that starts empty, so none of them ever gave the
handler the time their comments claimed.

Replaced with mailbox-ordering positive controls rather than sleeps:

- `RouteNativeAlarmAck_unknown_node_is_dropped` — follow the unmapped ack with a
  MAPPED one. The host forwards via `Tell` and the child handles `RouteAlarmAck`
  with `ReceiveAsync` (which suspends its own mailbox), so once the control reaches
  the driver the unmapped one has provably been handled.
- `RouteNativeAlarmAck_on_non_primary_is_dropped` — the role itself is the control:
  return the node to Primary and re-send. Comments (`gated` / `control`) are the
  discriminator, so a leaked ack is visible rather than merged into a count.
- `PeerProbeSupervisorTests.Single_node_snapshot_spawns_no_children` — a FOURTH site
  the issue did not list. It says the other `ChildCount.ShouldBe(0)` waits are each
  preceded by a `ShouldBe(1)`; that is true of three of the four, but not this one,
  which asserts the initial state. Now followed by a two-node snapshot: the count
  reaching 1 proves the single-node snapshot was processed, and a local-node child
  would have made it 2.

The issue warned these may turn red, which would be a real defect surfacing. They
did not — the routing is correct. Proven by breaking it deliberately instead:
disabling the Primary gate reddens the Secondary test, and routing unmapped ids to
an arbitrary mapping reddens the unknown-node test. Under the old form both breaks
passed.
2026-07-26 10:29:41 -04:00
Joseph Doherty 95295210b0 fix(test): scope the EventPump counter capture to its own pump (#503)
Root cause was cross-test measurement leakage, not a timing budget.

EventPump's `Meter` is STATIC — one instance per process — and a `MeterListener`
can only select by *meter* in `InstrumentPublished`. `StartMeterCapture` therefore
received measurements from every `EventPump` alive anywhere in the assembly. xUnit
runs test classes in parallel and several build pumps (`EventPumpStreamFaultTests`,
the `Driver-X` test in this same file), so a sibling's events landed in these
counters. That is why the flake needed a busy box: it needed two tests to overlap.
Measured directly during the gate — `Received = 11` in a run that emitted 10.

The old `Received == Dispatched + Dropped + InFlight` assertion is what turned the
leak into a failure. `InFlight` is DERIVED as `Received - Dispatched - Dropped`, so
the identity is a tautology that cannot fail on a real defect — only on a foreign
increment landing between its four separate `Interlocked.Read` calls. Hence the
reported subject, `counters.Received`. It is deleted, along with the `InFlight`
member, and replaced by direct assertions on the three MEASURED counters.

Three further defects in the same test, all found on the way:

- **The "held" dispatch loop was never held.** `OnDataChange` is `EventHandler<T>`
  — void-returning — so `async (_, _) => await gate` is async VOID: it returned to
  the dispatch loop at the first await and blocked nothing. Measured `Dispatched =
  2..3`, never 0. Drops happened only when the producer happened to outrun the
  consumer, so the arrangement the doc comment describes was fiction and the
  assertions rode on scheduling luck. Now a synchronous `ManualResetEventSlim.Wait()`
  on the loop's own thread, pinned by `Dispatched.ShouldBe(0)`.
- **Fixed `Task.Delay(150)`** replaced by a presence poll on the drop count (#500's
  rule). Dropped starts at 0, so it cannot be satisfied by the initial state.
- **Emission is staged** — one event, wait for the dispatcher to enter the handler,
  then the other nine — so the arithmetic is exact (`10 - 1 held - 2 buffered = 7`)
  rather than scheduler-dependent.

The gate release moved into `finally`, ahead of disposal: `DisposeAsync` awaits the
dispatch loop, which is now genuinely parked on that gate, so a failed assertion
would otherwise hang instead of failing.

Verified: 15/15 clean under the exact contention the issue measured (Runtime +
AdminUI + Core concurrently) — where the half-fixed version still failed on run 4.
Falsifiability: restoring the async-void handler turns the new assertions red.
2026-07-26 10:29:27 -04:00
Joseph Doherty e3155fbbf5 merge: never slice a DB-sourced string with the range operator (#504)
v2-ci / build (push) Successful in 4m11s
v2-ci / unit-tests (push) Failing after 14m22s
`@s.SourceHash[..12]` on an unvalidated nvarchar(64) threw out of
BuildRenderTree and took the whole /scripts page to an HTTP 500. Replaced with a
shared, null/short-safe `DisplayText.Abbreviate`, applied to all five unguarded
range-operator slices over DB-sourced strings in the AdminUI.

Live-verified on docker-dev: /scripts 500 -> 200 with the short hashes rendered
in full; long hashes still truncate with an ellipsis on the other pages.
2026-07-26 09:43:55 -04:00
Joseph Doherty 47d148daf9 fix(adminui): never slice a DB-sourced string with the range operator (#504)
`Scripts.razor` rendered a hash prefix as `@s.SourceHash[..12]…`. `SourceHash`
is a plain nvarchar(64) with no length floor, so any value shorter than 12
characters threw ArgumentOutOfRangeException out of BuildRenderTree — unhandled
in Blazor, which takes the WHOLE page to an HTTP 500, not just the offending
row. Live-reproduced on docker-dev, where two Script rows carry `h1` and
`h-abs-hr200`: /scripts was a hard 500.

New `Components/Shared/DisplayText.Abbreviate(string?, int)`:
- null/blank renders the Admin UI's em-dash placeholder rather than throwing;
- a value already within budget renders verbatim with NO ellipsis, so the
  display never implies text that isn't there (the old markup appended "…"
  unconditionally, outside the slice — `h1` would have shown as `h1…`);
- a maxLength < 1 is clamped, so a display bug cannot become a page crash.

Applied to every unguarded range-operator slice over a DB-sourced string found
by sweeping the AdminUI for `[..N]`:

  Scripts.razor                    SourceHash    (the reported crash)
  Certificates.razor               Thumbprint    (read off the on-disk store)
  Deployments.razor  x2            RevisionHash
  Clusters/ClusterOverview.razor   RevisionHash

The other `[..N]` hits are safe and untouched: Guid.ToString("N") slices
(always 32 chars) and the `Length > 60 ? [..60]` ternaries that self-guard.

NOTE — the issue text blamed the docker-dev seed; that was wrong and is
corrected on #504. `seed-clusters.sql` inserts only ServerCluster, ClusterNode
and LdapGroupRoleMapping. The short-hash rows came from earlier live-gate
authoring. A freshly seeded rig is fine; the trigger is any Script row that did
not go through ScriptEdit / UnsTreeService.HashSource — REST-API authored,
hand-inserted, or migrated in.

Tests: 14 new in DisplayTextTests, covering the short/null/blank/clamped cases
and a never-throws sweep over every value x budget combination. AdminUI suite
739/739.

Live-verified on docker-dev (AdminUI has no bUnit): /scripts 500 -> 200 across
both central nodes, rendering `hash=h1` and `hash=h-abs-hr200` in full;
/deployments still truncates 64-char hashes at 12 with the ellipsis;
/certificates and /clusters/MAIN unchanged.
2026-07-26 09:43:48 -04:00
Joseph Doherty 755ae1aa3f merge: re-assert non-normal scripted-alarm conditions after a (re)load (#487)
v2-ci / build (push) Successful in 4m21s
v2-ci / unit-tests (push) Failing after 3h14m40s
Closes the redeploy-reset gap on Part 9 scripted-alarm condition nodes — the
alarm analogue of the VirtualTag ReassertValue fix.

A full address-space rebuild re-materialises every condition node fresh, while
the engine's reload produces EmissionKind.None for an alarm whose state did not
change — so nothing wrote the node. Live-reproduced on docker-dev: a cleared-
but-still-UNACKNOWLEDGED alarm came back reporting acknowledged, dropping Retain
to false, so it vanished from ConditionRefresh entirely. The operator's
outstanding alarm silently disappeared from the alarm list on deploy.

ScriptedAlarmHostActor.ReassertConditionNodes now writes one AlarmStateUpdate
per alarm not in the Part 9 no-event position, sourced from the new
ScriptedAlarmEngine.GetProjections(). Node-only by construction — the projection
type carries no EmissionKind, so it cannot become an alerts row or a duplicate
historian record.

Live gate PASSED (pre-fix image vs. rebuilt, central-2): condition absent
pre-fix, present + Unacknowledged + Retain=True post-fix, stamped with its own
LastTransitionUtc; /alerts empty across two re-asserts with the panel proven
live by a real ACTIVATED transition.
2026-07-26 09:18:23 -04:00
Joseph Doherty 549c656489 fix(alarms): re-assert non-normal scripted-alarm conditions after a (re)load (#487)
A deploy that triggers a FULL address-space rebuild clears `_alarmConditions`
and re-materialises every condition node fresh — inactive, acked, confirmed,
unshelved. The engine reloads its persisted state and re-derives Active from the
predicate, so it ends up correct; but there is no transition to report, so
`LoadAsync` yields `EmissionKind.None` and `OnEngineEmission` drops it. Nothing
writes the node.

Live-reproduced on the docker-dev rig against the pre-fix image: an alarm that
had fired and cleared but was still UNACKNOWLEDGED came back from a rebuild
reporting Acknowledged with Retain=false — so it disappeared from
ConditionRefresh entirely, while the engine still held Unacknowledged. An
operator's outstanding alarm silently vanishes from the alarm list on deploy.

`ScriptedAlarmHostActor.ReassertConditionNodes` closes it. After each load it
reads the new `ScriptedAlarmEngine.GetProjections()` and sends one
`AlarmStateUpdate` per alarm NOT in the Part 9 no-event position.

Load-bearing properties:

- Node-only. It sends `AlarmStateUpdate` and nothing else — never the `alerts`
  topic, never the telemetry hub. `alerts` is the historization path, so a row
  per alarm per deploy would append a duplicate historian/AVEVA record every
  time anyone deploys. This is why it reads a projection rather than re-emitting:
  `ScriptedAlarmProjection` carries no `EmissionKind`, so there is nothing for
  the emission machinery — or a future refactor — to mistake for a transition.
  The issue's sketch said `GetState(alarmId)` + `LoadedAlarmIds`; that alone
  would have clobbered the node's severity and message, so the projection also
  carries the definition's severity, the resolved message template, and the
  last-observed worst-input quality.
- Only non-normal alarms. One in the no-event position already matches what the
  materialise built, so a never-fired alarm stays byte-identical to a deploy
  without this pass (including its Message, which the materialise seeds with the
  display name), and an all-normal deploy writes nothing at all.
- Timestamp is the condition's own `LastTransitionUtc`, not the deploy instant.
- No duplicate Part 9 events: `WriteAlarmCondition`'s delta-gate compares against
  the node's CURRENT state, so a re-assert onto a freshly materialised node is a
  genuine delta (fires once — correct, the rebuild reset what clients see) while
  one onto a surgically-preserved node is suppressed.

Tests: 6 new host tests + 6 new engine tests. Falsifiability checked by
disabling the call — 4 of the 6 host tests go red; the other 2 are absence
guards against an over-broad fix and pass either way by design.

Live gate (docker-dev, central-2, pre-fix image vs. rebuilt image):
- pre-fix: condition absent from ConditionRefresh after a rebuild;
- post-fix: present, Unacknowledged, Retain=True, stamped with its own
  LastTransitionUtc rather than the restart instant;
- /alerts stayed empty across two re-asserts, with the panel proven live by a
  real ACTIVATED transition immediately afterwards — no duplicate history.
2026-07-26 09:06:39 -04:00
Joseph Doherty 28c2866710 merge: Sql driver follow-ups #496/#497/#498 + the Runtime.Tests flake #500 (fix/sql-driver-followups)
v2-ci / build (push) Successful in 4m15s
v2-ci / unit-tests (push) Failing after 15m14s
Four commits closing the three follow-ups the Sql poll driver left behind, plus the
intermittent Runtime.Tests failure found while verifying them.

- #497 corrects 16 wrong OPC UA status-code constants across 6 drivers (the issue named
  3) and adds StatusCodeParityTests, a reflection guard checking every driver's
  hard-coded uint against the pinned SDK. That guard is what found the other 13.
- #498 fails the deploy when a Sql driver's persisted config carries a literal
  connectionString — the write-side half of a guarantee only enforced on read.
- #496 implements the design 8.1 catalog gate: authored table/column names are resolved
  against the live catalog at Initialize and replaced with the catalog's own spelling, so
  quoting becomes the backstop it was documented to be rather than the sole defence.
- #500 fixes the Runtime.Tests flake: four ordering/logic defects plus a class of tight
  presence budgets, now routed through a documented RuntimeActorTestBase.PresenceBudget.

Verified: full solution builds, all 41 unit-test projects green, Sql integration suite
21/21 against the real SQL Server on 10.100.0.35, and 30 consecutive Runtime.Tests runs
clean against a measured 13% baseline.

Still open by design: #499 (ClusterNode.DriverConfigOverridesJson is a third config
persistence surface DraftValidator cannot see) and #501 (two alarm-ack tests use
AwaitAssert for an absence assertion, so they prove nothing; the rewrite may
legitimately turn them red).
2026-07-25 22:27:26 -04:00
Joseph Doherty ee12568cab refactor(modbus-rtu): distinct CrcMismatch/UnitMismatch desync reasons
v2-ci / build (pull_request) Successful in 4m1s
v2-ci / unit-tests (pull_request) Failing after 13m33s
The RTU framer overloaded DesyncReason.TruncatedFrame for three distinct
failures — a genuine short read, a CRC-16 validation failure, and a
CRC-valid reply from the wrong RS-485 slave. Split the latter two into
dedicated CrcMismatch / UnitMismatch enum members so a future .Reason
consumer (metrics, operator diagnostics) can tell a wiring/EMI CRC fault
apart from a mis-addressed multi-drop reply. Behaviour is unchanged — all
three still throw ModbusTransportDesyncException and tear the socket down.

Framing tests now assert the specific Reason on each path.

Follow-up #12 from the Modbus RTU-over-TCP plan.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-25 15:13:48 -04:00
Joseph Doherty e27eb77187 docs(modbus-rtu): record Wave-1 RTU-over-TCP live /run gate result (PASSED)
v2-ci / build (pull_request) Successful in 3m45s
v2-ci / unit-tests (pull_request) Failing after 13m54s
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:50:10 -04:00
Joseph Doherty fcc7ed698e harden(modbus-rtu): factory throws on unknown transport + document RTU FC-shape assumption
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 15:33:49 -04:00
Joseph Doherty 4a8c9badce docs(modbus-rtu): note the co-running :5021 rtu_over_tcp fixture in Docker README + Dockerfile
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:36:00 -04:00
Joseph Doherty 650e27075f test(modbus-rtu): add rtu_over_tcp pymodbus fixture + RTU-over-TCP integration tests
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:29:10 -04:00
Joseph Doherty 132e7b63f6 feat(modbus-rtu): add Transport selector to the AdminUI Modbus driver form
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:19:15 -04:00
Joseph Doherty 13bd9820b0 feat(modbus-rtu): route driver + probe transports through ModbusTransportFactory
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:12:12 -04:00
Joseph Doherty 1d90bf3611 feat(modbus-rtu): add ModbusTransportFactory switch on Transport mode
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:08:10 -04:00
Joseph Doherty 20be5416b9 feat(modbus-rtu): bind Transport mode on options/DTO/factory (string-enum guard)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 14:04:34 -04:00
Joseph Doherty 47e9cd56ef feat(modbus-rtu): add ModbusRtuOverTcpTransport (RTU framing over socket lifecycle)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:56:19 -04:00
Joseph Doherty 2f38b5b285 refactor(modbus): extract ModbusSocketLifecycle from ModbusTcpTransport (behaviour-preserving)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:47:18 -04:00
Joseph Doherty 20100c36ad feat(modbus-rtu): validate response unit + cover partial-read/truncation in ModbusRtuFraming
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:42:25 -04:00
Joseph Doherty eed6617784 feat(modbus-rtu): add ModbusRtuFraming (ADU build + FC-aware length-less deframe)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:33:59 -04:00
Joseph Doherty 8d0f60ec51 feat(modbus-rtu): add ModbusCrc CRC-16/MODBUS helper + golden vectors
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:30:05 -04:00
Joseph Doherty e787aa572b feat(modbus-rtu): add ModbusTransportMode enum (Tcp | RtuOverTcp)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 13:28:30 -04:00
56 changed files with 3285 additions and 457 deletions
+22
View File
@@ -106,6 +106,28 @@ IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-b-2:4053')
(NodeId, ClusterId, Host, OpcUaPort, DashboardPort, AkkaPort, GrpcPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy) (NodeId, ClusterId, Host, OpcUaPort, DashboardPort, AkkaPort, GrpcPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES ('site-b-2:4053', 'SITE-B', 'site-b-2', 4840, 8081, 4053, 4056, 'urn:OtOpcUa:site-b-2', 150, 1, 'docker-dev-seed'); VALUES ('site-b-2:4053', 'SITE-B', 'site-b-2', 4840, 8081, 4053, 4056, 'urn:OtOpcUa:site-b-2', 150, 1, 'docker-dev-seed');
------------------------------------------------------------------------------
-- Backfill: GrpcPort on rows that predate the column (#493)
--
-- Every insert above is INSERT-if-not-exists, which is the right shape for a re-runnable seed
-- but means a *new nullable* column never reaches rows created by an earlier version of this
-- file. On a persisted volume that is exactly what happened when Phase 5 added GrpcPort: the
-- six ClusterNode rows already existed, so they kept NULL, TelemetryNodeSource found no dial
-- targets, and TelemetryDial:Mode=Grpc silently connected to nothing (throttled Warning per
-- node — the code degrades gracefully, so nothing failed loudly).
--
-- Backfills NULL only. It must not overwrite a port an operator set deliberately on a
-- long-lived dev volume; a NULL is the unambiguous "this row predates the column" marker.
--
-- AkkaPort deliberately has no equivalent here: it is NOT NULL with a default of 4053, so
-- pre-existing rows already carry a usable value and there is nothing to backfill.
------------------------------------------------------------------------------
UPDATE dbo.ClusterNode
SET GrpcPort = 4056 -- matches Telemetry__GrpcListenPort on all six nodes in docker-compose.yml
WHERE GrpcPort IS NULL
AND CreatedBy = 'docker-dev-seed';
------------------------------------------------------------------------------ ------------------------------------------------------------------------------
-- Galaxy MxAccess gateway — INTENTIONALLY NOT SEEDED (retired 2026-06-15) -- Galaxy MxAccess gateway — INTENTIONALLY NOT SEEDED (retired 2026-06-15)
-- --
+39
View File
@@ -102,6 +102,45 @@ Persisted scope per plan decision #14: `Enabled`, `Acked`, `Confirmed`, `Shelvin
Every mutation the state machine produces is immediately persisted inside the engine's `_evalGate` semaphore, so the store's view is always consistent with the in-memory state. Every mutation the state machine produces is immediately persisted inside the engine's `_evalGate` semaphore, so the store's view is always consistent with the in-memory state.
### Post-(re)load condition-node re-assert (#487)
A deploy that triggers a **full address-space rebuild** clears `_alarmConditions` and re-materialises every
condition node fresh — inactive, acked, confirmed, unshelved. The engine, reloading in parallel, restores each
alarm's persisted state and re-derives `Active` from the predicate, so it ends up *correct*; but a still-active
alarm has **no transition to report**, so `LoadAsync` yields `EmissionKind.None` and `OnEngineEmission` drops it.
Nothing then writes the node. Before this fix, an active alarm with static dependencies read **normal** to every
OPC UA client after such a deploy, until its next real transition — which for a static-dependency alarm may
never come.
`ScriptedAlarmHostActor.ReassertConditionNodes` closes it. After each load completes it reads
`ScriptedAlarmEngine.GetProjections()` — a plain read returning `ScriptedAlarmProjection` (condition state +
the definition's severity + the resolved message template + last-observed worst-input quality) — and sends one
`OpcUaPublishActor.AlarmStateUpdate` per alarm that is **not** in the Part 9 no-event position.
Four properties are load-bearing:
- **Node-only.** It sends `AlarmStateUpdate` and nothing else — never the `alerts` topic, never the telemetry
hub. `alerts` is the historization path, so an alerts row per alarm per deploy would append a duplicate
historian / AVEVA record every time anyone deploys. This is why the re-assert reads a projection instead of
re-emitting: `ScriptedAlarmProjection` carries no `EmissionKind`, so there is nothing for the emission
machinery — or a future refactor — to mistake for a transition.
- **Only non-normal alarms.** An alarm in the no-event position (enabled, inactive, acked, confirmed,
unshelved) already matches what the materialise built, so it is skipped. That keeps a never-fired alarm
byte-identical to a deploy without this pass — including its Message, which the materialise seeds with the
alarm's display name — and writes nothing at all on the common all-normal deploy.
- **Ordering.** `DriverHostActor` tells `RebuildAddressSpace` to the publish actor *before* it tells
`ApplyScriptedAlarms` to the host, and the re-assert runs later still (after an awaited `LoadAsync` pipes
back). Both are local `Tell`s, which enqueue synchronously, so the re-materialise is already queued ahead of
these writes.
- **No duplicate Part 9 events.** `WriteAlarmCondition`'s delta-gate compares the incoming snapshot against
the node's *current* state: a re-assert onto a freshly materialised (normal) node is a genuine delta and
fires exactly one condition event — correct, since the rebuild reset what clients see — while a re-assert
onto a surgically-preserved node that already holds the same state is suppressed. The write is ungated by
redundancy role, like every other node write here; the Secondary keeps its address space warm for failover.
The timestamp carried is the condition's own `LastTransitionUtc`, **not** the deploy instant, so a client
reading `Time` / `ReceiveTime` after a rebuild still sees when the alarm actually went active.
## Source integration ## Source integration
`ScriptedAlarmSource` (`ScriptedAlarmSource.cs`) adapts the engine to the driver-agnostic `IAlarmSource` interface. The existing `AlarmSurfaceInvoker` + `GenericDriverNodeManager` fan-out consumes it the same way it consumes Galaxy / AB CIP / FOCAS sources — there is no scripted-alarm-specific code path in the server plumbing. From that point on, the flow into `AlarmConditionState` nodes, the `AlarmAck` session check, and the Historian sink is shared — see [AlarmTracking.md](AlarmTracking.md). `ScriptedAlarmSource` (`ScriptedAlarmSource.cs`) adapts the engine to the driver-agnostic `IAlarmSource` interface. The existing `AlarmSurfaceInvoker` + `GenericDriverNodeManager` fan-out consumes it the same way it consumes Galaxy / AB CIP / FOCAS sources — there is no scripted-alarm-specific code path in the server plumbing. From that point on, the flow into `AlarmConditionState` nodes, the `AlarmAck` session check, and the Historian sink is shared — see [AlarmTracking.md](AlarmTracking.md).
+33
View File
@@ -128,6 +128,39 @@ transports. ScadaBridge itself has since closed that gap with this identical
interceptor pattern, so Phase 5 ships authenticated from the start rather than deferring auth to a interceptor pattern, so Phase 5 ships authenticated from the start rather than deferring auth to a
later hardening pass. See the design doc's superseded note for the full history. later hardening pass. See the design doc's superseded note for the full history.
## Upgrading an existing deployment: populate `ClusterNode.GrpcPort` first (#493)
`GrpcPort` is a **nullable column added by Phase 5**, and nothing backfills it onto rows that
already existed. On an upgraded deployment every `ClusterNode` row therefore carries `NULL`,
central finds **no dial targets**, and flipping `TelemetryDial:Mode` to `Grpc` connects to
nothing. Fresh installs are unaffected. `AkkaPort` did not have this problem only because it is
`NOT NULL` with a default of 4053.
Nothing fails loudly, which is what makes this worth stating up front — the code degrades
gracefully, once per node, throttled:
```
ClusterNode <id> has no GrpcPort; it exposes no telemetry stream and is skipped in this dial refresh
(further skips of this node are silent)
```
Other symptoms: no `ESTABLISHED` sockets on the telemetry port (a `LISTEN` but nothing else in
`docker exec <node> cat /proc/net/tcp6`), and the `/alerts` / `/script-log` pill never turning
live in `Grpc` mode.
**Before flipping any node to `Grpc`**, set each driver node's `GrpcPort` to that node's own
`Telemetry:GrpcListenPort` — via the AdminUI node editor, or directly:
```sql
UPDATE dbo.ClusterNode SET GrpcPort = <that node's Telemetry:GrpcListenPort> WHERE NodeId = '<node>';
```
There is deliberately **no EF data migration** backfilling this. The correct value is that node's
own listener port, which is per-deployment configuration the database cannot derive; a migration
could only invent one, and a *wrong* dial target is worse than the `NULL` the dialer already skips
explicitly. The docker-dev seed does backfill (`GrpcPort IS NULL AND CreatedBy = 'docker-dev-seed'`
→ 4056) because there the port is fixed by `docker-compose.yml` and therefore actually knowable.
## Reconnect and reliability ## Reconnect and reliability
Central's `TelemetryDialSupervisor` (an admin-role actor) runs **one reconnecting dialer per Central's `TelemetryDialSupervisor` (an admin-role actor) runs **one reconnecting dialer per
+8 -1
View File
@@ -94,7 +94,14 @@ dual-namespace + event-delivery), Runtime 377 (+ VT reassert regression), AdminU
## Documented follow-ups (non-blocking) ## Documented follow-ups (non-blocking)
- **Scripted-alarm redeploy recovery (CONFIRMED pre-existing bug, deferred — deserves its own careful fix). Filed as issue #487.** - **Scripted-alarm redeploy recovery — FIXED (issue #487), see `docs/ScriptedAlarms.md` §"Post-(re)load
condition-node re-assert".** Landed as the proposed shape below: `ScriptedAlarmHostActor.ReassertConditionNodes`
in `OnAlarmsLoaded`, node-only, never the `alerts` topic. Two deviations from the sketch, both deliberate:
it reads a new `ScriptedAlarmEngine.GetProjections()` rather than `GetState(alarmId)` + `LoadedAlarmIds`
(a projection also carries the definition's severity, the resolved message template, and the last-observed
worst-input quality — `GetState` alone would have clobbered severity/message on the node); and it skips
alarms sitting in the Part 9 no-event position, so a deploy where nothing is in alarm writes no condition
nodes at all. The original deferral text follows for context.
The scripted-alarm Part 9 condition node has the same redeploy-reset race the VT `ReassertValue` fix closed: The scripted-alarm Part 9 condition node has the same redeploy-reset race the VT `ReassertValue` fix closed:
`RebuildAddressSpace` clears `_alarmConditions` and `MaterialiseScriptedAlarms` recreates each condition `RebuildAddressSpace` clears `_alarmConditions` and `MaterialiseScriptedAlarms` recreates each condition
fresh/normal, but `ScriptedAlarmEngine.LoadAsync` reloads from the persisted state and yields fresh/normal, but `ScriptedAlarmEngine.LoadAsync` reloads from the persisted state and yields
@@ -30,7 +30,7 @@ it reaches 📝.
| Wave | Deliverable | Design | Impl. plan | Status | Effort | Fixture / hardware | | Wave | Deliverable | Design | Impl. plan | Status | Effort | Fixture / hardware |
|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|
| **0** | Universal Discover-backed browser | [design](2026-07-15-universal-discovery-browser-design.md) | [plan](2026-07-15-universal-discovery-browser-implementation.md) · [tasks](2026-07-15-universal-discovery-browser-implementation.md.tasks.json) | 🟡 **Live gate open** | SM | none (retrofits shipped drivers) | | **0** | Universal Discover-backed browser | [design](2026-07-15-universal-discovery-browser-design.md) | [plan](2026-07-15-universal-discovery-browser-implementation.md) · [tasks](2026-07-15-universal-discovery-browser-implementation.md.tasks.json) | 🟡 **Live gate open** | SM | none (retrofits shipped drivers) |
| **1** | Modbus RTU (extend Modbus) | [design](2026-07-15-modbus-rtu-driver-design.md) | [plan](2026-07-24-modbus-rtu-driver.md) · [tasks](2026-07-24-modbus-rtu-driver.md.tasks.json) | 📝 **Plan ready** (11 tasks) | **S (lowest)** | `pymodbus` `rtu_over_tcp` — CI-simulatable, no new infra | | **1** | Modbus RTU (extend Modbus) | [design](2026-07-15-modbus-rtu-driver-design.md) | [plan](2026-07-24-modbus-rtu-driver.md) · [tasks](2026-07-24-modbus-rtu-driver.md.tasks.json) | 🟢 **Built — live gate PASSED** (11 tasks, branch `feat/modbus-rtu-driver`, pending merge) | **S (lowest)** | `pymodbus` `rtu_over_tcp` — CI-simulatable, no new infra |
| **1** | SQL poll | [design](2026-07-15-sql-poll-driver-design.md) | [plan](2026-07-24-sql-poll-driver.md) · [tasks](2026-07-24-sql-poll-driver.md.tasks.json) | 📝 **Plan ready** (22 tasks) | SM | **existing central SQL Server** `10.100.0.35,14330` + SQLite unit | | **1** | SQL poll | [design](2026-07-15-sql-poll-driver-design.md) | [plan](2026-07-24-sql-poll-driver.md) · [tasks](2026-07-24-sql-poll-driver.md.tasks.json) | 📝 **Plan ready** (22 tasks) | SM | **existing central SQL Server** `10.100.0.35,14330` + SQLite unit |
| **2** | MTConnect Agent | [design](2026-07-15-mtconnect-driver-design.md) | [plan](2026-07-24-mtconnect-driver.md) · [tasks](2026-07-24-mtconnect-driver.md.tasks.json) | 📝 **Plan ready** (23 tasks) | SM | Dockerized `mtconnect/cppagent` — CI-simulatable | | **2** | MTConnect Agent | [design](2026-07-15-mtconnect-driver-design.md) | [plan](2026-07-24-mtconnect-driver.md) · [tasks](2026-07-24-mtconnect-driver.md.tasks.json) | 📝 **Plan ready** (23 tasks) | SM | Dockerized `mtconnect/cppagent` — CI-simulatable |
| **2** | MQTT / Sparkplug B | [design](2026-07-15-mqtt-sparkplug-driver-design.md) | [plan](2026-07-24-mqtt-sparkplug-driver.md) · [tasks](2026-07-24-mqtt-sparkplug-driver.md.tasks.json) | 📝 **Plan ready** (27 tasks, P1+P2) | ML | Mosquitto/EMQX + C# Sparkplug sim — CI-simulatable | | **2** | MQTT / Sparkplug B | [design](2026-07-15-mqtt-sparkplug-driver-design.md) | [plan](2026-07-24-mqtt-sparkplug-driver.md) · [tasks](2026-07-24-mqtt-sparkplug-driver.md.tasks.json) | 📝 **Plan ready** (27 tasks, P1+P2) | ML | Mosquitto/EMQX + C# Sparkplug sim — CI-simulatable |
@@ -92,16 +92,30 @@ marginal cost.
Both are **fully CI-simulatable with zero new hardware**; Modbus RTU needs no new infra at all, and Both are **fully CI-simulatable with zero new hardware**; Modbus RTU needs no new infra at all, and
SQL poll reuses the always-on central SQL Server. This is the recommended next build. SQL poll reuses the always-on central SQL Server. This is the recommended next build.
### Modbus RTU — 📝 Plan ready ### Modbus RTU — 🟢 Built, live gate PASSED (branch `feat/modbus-rtu-driver`, pending merge)
- **Design:** [`2026-07-15-modbus-rtu-driver-design.md`](2026-07-15-modbus-rtu-driver-design.md) - **Design:** [`2026-07-15-modbus-rtu-driver-design.md`](2026-07-15-modbus-rtu-driver-design.md)
- **Implementation plan:** [`2026-07-24-modbus-rtu-driver.md`](2026-07-24-modbus-rtu-driver.md) · [`.tasks.json`](2026-07-24-modbus-rtu-driver.md.tasks.json) — **11 tasks** (1 trivial / 5 small / 2 standard / 3 high-risk). - **Implementation plan:** [`2026-07-24-modbus-rtu-driver.md`](2026-07-24-modbus-rtu-driver.md) · [`.tasks.json`](2026-07-24-modbus-rtu-driver.md.tasks.json) — **11 tasks** (1 trivial / 5 small / 2 standard / 3 high-risk), all complete via subagent-driven-development.
- **Scope:** extend the existing Modbus driver with a `Transport` selector (RTU CRC-16 + FC-aware - **Scope:** extend the existing Modbus driver with a `Transport` selector (RTU CRC-16 + FC-aware
length, no TxId) + a `rtu_over_tcp` `pymodbus` docker profile + the AdminUI selector. length, no TxId) + a `rtu_over_tcp` `pymodbus` docker profile + the AdminUI selector.
**Direct-serial transport is descoped** (user, 2026-07-15) — RTU-over-TCP via a serial→Ethernet **Direct-serial transport is descoped** (user, 2026-07-15) — RTU-over-TCP via a serial→Ethernet
gateway is the only shipped mode, so there is no serial hardware gate. gateway is the only shipped mode, so there is no serial hardware gate.
- **Fixture:** add an `rtu_over_tcp` profile to the existing Modbus integration fixture. First P1 - **Fixture:** added the `rtu_over_tcp` profile (`framer=rtu`, host `:5021`, co-runs with `standard`)
step is confirming the pymodbus simulator exposes the RTU framer on a TCP server. to the Modbus integration fixture. **Unknown confirmed:** pymodbus 3.13's simulator DOES honour
- **Effort:** **S — the lowest on the roadmap.** Recommended first. `framer=rtu` on a TCP server (wire-probed a pure RTU FC03 response, no MBAP) — so the simple profile
path was taken; **no stdlib fallback server was needed.**
- **Verification (all green):** unit suite **344/344**; full solution build 0 errors; the 2 RTU
integration tests pass **live** against the real `framer=rtu` fixture (read HR5→5, write+readback
HR200←4242). Per-task review chain (spec + code reviewers) + a final whole-branch review, all
approved-to-merge.
- **Live `/run` gate — PASSED (2026-07-24, docker-dev rebuilt from this branch):**
- AdminUI `/raw` Modbus **driver** config modal renders the new **Transport** selector (with the
operator hint) — set to `RtuOverTcp`, saved.
- **Device Test Connect → `OK · 23 MS`** — the probe built a `ModbusRtuOverTcpTransport` via the
factory and spoke **FC03 over real RTU framing** to `10.100.0.35:5021`.
- Authored raw tags HR5 (r/o) + HR200 (r/w) → **deployment `06ec1632` Sealed** (all 6 nodes acked).
- Client.CLI on `opc.tcp://localhost:4840` (`multi-role`/`password`): **read** `ns=2;s=rtu-gate/Device1/HR5`
**5, Good**; **write** `ns=2;s=rtu-gate/Device1/HR200` = 4242 → readback **4242, Good**.
- **Effort:** **S — the lowest on the roadmap.** Delivered first, as recommended.
### SQL poll — 📝 Plan ready ### SQL poll — 📝 Plan ready
- **Design:** [`2026-07-15-sql-poll-driver-design.md`](2026-07-15-sql-poll-driver-design.md) - **Design:** [`2026-07-15-sql-poll-driver-design.md`](2026-07-15-sql-poll-driver-design.md)
@@ -60,6 +60,11 @@ public static class DraftValidator
/// credential in the ConfigDb — persisted, replicated to every node's artifact cache, and readable by /// credential in the ConfigDb — persisted, replicated to every node's artifact cache, and readable by
/// anyone with config access — while the runtime silently ignored it and the driver failed to connect. /// anyone with config access — while the runtime silently ignored it and the driver failed to connect.
/// Discarding a secret on read is not the same as refusing to store it.</para> /// Discarding a secret on read is not the same as refusing to store it.</para>
/// <para><b>Two of the three surfaces are checked here.</b> The third — a node's
/// <see cref="ClusterNode.DriverConfigOverridesJson"/> — is not reachable from
/// <see cref="DraftSnapshot"/> and is gated at its save instead (#499); see
/// <see cref="SqlCredentialGuard"/> for why a deploy gate is the wrong instrument for a value that
/// never enters the artifact.</para>
/// <para>Checked on <b>both</b> config surfaces a Sql driver reads: the instance's /// <para>Checked on <b>both</b> config surfaces a Sql driver reads: the instance's
/// <see cref="DriverInstance.DriverConfig"/> and the <see cref="Device.DeviceConfig"/> of every device /// <see cref="DriverInstance.DriverConfig"/> and the <see cref="Device.DeviceConfig"/> of every device
/// beneath it, because the two are merged before the DTO sees them — a credential pasted into the /// beneath it, because the two are merged before the DTO sees them — a credential pasted into the
@@ -74,7 +79,7 @@ public static class DraftValidator
/// </remarks> /// </remarks>
private static void ValidateSqlConnectionStringNotPersisted(DraftSnapshot draft, List<ValidationError> errors) private static void ValidateSqlConnectionStringNotPersisted(DraftSnapshot draft, List<ValidationError> errors)
{ {
const string ForbiddenKey = "connectionString"; const string ForbiddenKey = SqlCredentialGuard.ForbiddenKey;
var sqlInstanceIds = draft.DriverInstances var sqlInstanceIds = draft.DriverInstances
.Where(d => string.Equals(d.DriverType, Core.Abstractions.DriverTypeNames.Sql, StringComparison.Ordinal)) .Where(d => string.Equals(d.DriverType, Core.Abstractions.DriverTypeNames.Sql, StringComparison.Ordinal))
@@ -85,7 +90,7 @@ public static class DraftValidator
foreach (var d in draft.DriverInstances) foreach (var d in draft.DriverInstances)
{ {
if (!sqlInstanceIds.Contains(d.DriverInstanceId)) continue; if (!sqlInstanceIds.Contains(d.DriverInstanceId)) continue;
if (!HasTopLevelKey(d.DriverConfig, ForbiddenKey)) continue; if (!SqlCredentialGuard.CarriesLiteralConnectionString(d.DriverConfig)) continue;
errors.Add(new("SqlConnectionStringPersisted", errors.Add(new("SqlConnectionStringPersisted",
$"Sql driver instance '{d.DriverInstanceId}' has a '{ForbiddenKey}' key in its DriverConfig. " + $"Sql driver instance '{d.DriverInstanceId}' has a '{ForbiddenKey}' key in its DriverConfig. " +
"A Sql driver must name its credentials indirectly via 'connectionStringRef', which resolves " + "A Sql driver must name its credentials indirectly via 'connectionStringRef', which resolves " +
@@ -98,7 +103,7 @@ public static class DraftValidator
foreach (var dev in draft.Devices) foreach (var dev in draft.Devices)
{ {
if (!sqlInstanceIds.Contains(dev.DriverInstanceId)) continue; if (!sqlInstanceIds.Contains(dev.DriverInstanceId)) continue;
if (!HasTopLevelKey(dev.DeviceConfig, ForbiddenKey)) continue; if (!SqlCredentialGuard.CarriesLiteralConnectionString(dev.DeviceConfig)) continue;
errors.Add(new("SqlConnectionStringPersisted", errors.Add(new("SqlConnectionStringPersisted",
$"Device '{dev.DeviceId}' on Sql driver instance '{dev.DriverInstanceId}' has a " + $"Device '{dev.DeviceId}' on Sql driver instance '{dev.DriverInstanceId}' has a " +
$"'{ForbiddenKey}' key in its DeviceConfig. DeviceConfig is merged onto DriverConfig before " + $"'{ForbiddenKey}' key in its DeviceConfig. DeviceConfig is merged onto DriverConfig before " +
@@ -107,34 +112,6 @@ public static class DraftValidator
} }
} }
/// <summary>
/// True when <paramref name="json"/> is a JSON object carrying <paramref name="key"/> at its top level,
/// matched case-insensitively. Never throws — a blank, malformed or non-object blob simply has no keys,
/// and shaping the config JSON is another rule's job.
/// </summary>
/// <param name="json">The config blob to inspect.</param>
/// <param name="key">The property name to look for.</param>
/// <returns><see langword="true"/> when the key is present at the top level.</returns>
private static bool HasTopLevelKey(string? json, string key)
{
if (string.IsNullOrWhiteSpace(json)) return false;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object) return false;
foreach (var property in doc.RootElement.EnumerateObject())
{
if (string.Equals(property.Name, key, StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
catch (System.Text.Json.JsonException)
{
return false;
}
}
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver: /// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
/// <list type="number"> /// <list type="number">
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to /// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
@@ -0,0 +1,139 @@
using System.Text.Json;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
/// <summary>
/// The one place that decides whether a persisted config blob carries a literal Sql
/// <c>connectionString</c> (Gitea #498 / #499).
/// </summary>
/// <remarks>
/// <para>
/// A <c>Sql</c> driver names its credentials indirectly — <c>connectionStringRef</c> resolves from
/// the environment / secret store at Initialize — so nothing persisted should ever contain a
/// database password. The typed <c>SqlDriverConfigDto</c> already drops a <c>connectionString</c>
/// key on <b>read</b>, but discarding a secret on read is not the same as refusing to store it:
/// config blobs are schemaless JSON columns authored through raw-JSON textareas, so the key can
/// be written even though the runtime ignores it.
/// </para>
/// <para>
/// There are <b>three</b> surfaces a Sql driver's config is persisted on, and they need different
/// enforcement points:
/// <list type="bullet">
/// <item><c>DriverInstance.DriverConfig</c> and <c>Device.DeviceConfig</c> — both in the
/// deployed artifact, both visible to <c>DraftSnapshot</c>, both gated by
/// <c>DraftValidator</c>.</item>
/// <item><c>ClusterNode.DriverConfigOverridesJson</c> — the per-node override map. It is
/// <b>not</b> in <c>DraftSnapshot</c>, and deliberately does not go there: adding a
/// <c>ClusterNode</c> collection would widen the snapshot (and every builder of one) for a
/// single rule about a value that is not part of the artifact at all. More to the point, a
/// deploy gate is the wrong instrument here — the artifact never carries node overrides, so
/// blocking a deploy would not stop the credential being stored. It is already in the
/// database by then. This surface is therefore gated at the <b>save</b>, which is the only
/// point where "refuse to store it" is literally true.</item>
/// </list>
/// </para>
/// <para>
/// <b>Deliberately narrow.</b> This checks the <c>Sql</c> driver's <c>connectionString</c> key
/// only — not a general "credential-shaped key" sweep over every driver type. A broader rule
/// would start refusing configs that are legitimate today for drivers that never made this
/// guarantee, turning defence-in-depth into a regression. Widen it per driver, as each driver
/// gains an indirect-credential contract of its own.
/// </para>
/// </remarks>
public static class SqlCredentialGuard
{
/// <summary>The config key a Sql driver must never persist. Matched case-insensitively.</summary>
public const string ForbiddenKey = "connectionString";
/// <summary>
/// Whether <paramref name="configJson"/> is a JSON object carrying <see cref="ForbiddenKey"/> at
/// its top level.
/// </summary>
/// <remarks>
/// Matched <b>case-insensitively</b>: <c>System.Text.Json</c> binds <c>ConnectionString</c> to a
/// <c>connectionString</c> property by default, so a case variant is the same key, not a
/// different one. Only the top level is scanned — the DTO is flat, so a nested occurrence cannot
/// bind and is not the credential-shaped mistake this exists to catch.
/// </remarks>
/// <param name="configJson">The config blob to inspect.</param>
/// <returns><see langword="true"/> when the key is present at the top level.</returns>
public static bool CarriesLiteralConnectionString(string? configJson)
{
if (string.IsNullOrWhiteSpace(configJson)) return false;
try
{
using var doc = JsonDocument.Parse(configJson);
return doc.RootElement.ValueKind == JsonValueKind.Object
&& HasTopLevelKey(doc.RootElement, ForbiddenKey);
}
catch (JsonException)
{
// A malformed blob simply has no keys. Shaping the config JSON is another rule's job, and
// throwing here would turn a formatting mistake into a credential-guard failure.
return false;
}
}
/// <summary>
/// The <c>DriverInstanceId</c>s in a <c>ClusterNode.DriverConfigOverridesJson</c> map whose
/// override object carries a literal connection string, restricted to instances that are actually
/// Sql drivers.
/// </summary>
/// <remarks>
/// The override map is shaped <c>{ "&lt;DriverInstanceId&gt;": { …driver config keys… } }</c> and is
/// merged onto the cluster-level <c>DriverConfig</c>, so a credential pasted into one lands in
/// exactly the place <see cref="CarriesLiteralConnectionString"/> already refuses on the driver
/// itself.
/// </remarks>
/// <param name="overridesJson">The node's override map. Null / blank / malformed yields no
/// violations.</param>
/// <param name="sqlDriverInstanceIds">The ids of driver instances whose <c>DriverType</c> is
/// <c>Sql</c>. Keys outside this set are ignored — a non-Sql driver never made the indirect-credential
/// guarantee, so flagging it would be a regression, not defence in depth.</param>
/// <returns>The offending driver-instance ids, in document order; empty when there are none.</returns>
public static IReadOnlyList<string> FindNodeOverrideViolations(
string? overridesJson, IReadOnlyCollection<string> sqlDriverInstanceIds)
{
if (string.IsNullOrWhiteSpace(overridesJson) || sqlDriverInstanceIds.Count == 0)
{
return [];
}
var sqlIds = sqlDriverInstanceIds as IReadOnlySet<string>
?? sqlDriverInstanceIds.ToHashSet(StringComparer.Ordinal);
try
{
using var doc = JsonDocument.Parse(overridesJson);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return [];
List<string>? offenders = null;
foreach (var entry in doc.RootElement.EnumerateObject())
{
if (!sqlIds.Contains(entry.Name)) continue;
if (entry.Value.ValueKind != JsonValueKind.Object) continue;
if (!HasTopLevelKey(entry.Value, ForbiddenKey)) continue;
(offenders ??= []).Add(entry.Name);
}
return (IReadOnlyList<string>?)offenders ?? [];
}
catch (JsonException)
{
return [];
}
}
/// <summary>Whether <paramref name="obj"/> carries <paramref name="key"/> at its top level,
/// case-insensitively.</summary>
private static bool HasTopLevelKey(JsonElement obj, string key)
{
foreach (var property in obj.EnumerateObject())
{
if (string.Equals(property.Name, key, StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
}
@@ -334,6 +334,48 @@ public sealed class ScriptedAlarmEngine : IDisposable
public IReadOnlyCollection<AlarmConditionState> GetAllStates() public IReadOnlyCollection<AlarmConditionState> GetAllStates()
=> _alarms.Values.Select(a => a.Condition).ToArray(); => _alarms.Values.Select(a => a.Condition).ToArray();
/// <summary>
/// #487 — the currently-held condition of every loaded alarm, packaged with the definition's
/// severity, the rendered message template and the last-observed input quality, i.e. everything
/// a caller needs to <b>project</b> the alarm onto an OPC UA condition node without waiting for
/// the next transition.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is a read, not an emission.</b> It deliberately returns a distinct type rather
/// than a <see cref="ScriptedAlarmEvent"/>, and it never touches <see cref="OnEvent"/>. A
/// still-active alarm produces <see cref="EmissionKind.None"/> at load (there is no
/// transition to report), so the transition stream cannot carry the current state — that is
/// exactly why the caller needs this. Routing these through the emission path instead would
/// append a duplicate historian / <c>alerts</c> row on every deploy, so the shape here is
/// intentionally one the emission machinery cannot consume.
/// </para>
/// <para>
/// <b>Synchronization.</b> Reads <c>_alarms</c> and <c>_valueCache</c> — both
/// <see cref="ConcurrentDictionary{TKey, TValue}"/> — without taking <c>_evalGate</c>, the
/// same posture as <see cref="GetState"/> / <see cref="GetAllStates"/>. Each projection is
/// individually coherent; the set as a whole is not an atomic snapshot across a concurrent
/// re-evaluation. That is sufficient for the node-projection use: an evaluation racing this
/// read publishes its own transition through the normal path.
/// </para>
/// </remarks>
/// <returns>One projection per loaded alarm; empty before the first <see cref="LoadAsync"/>.</returns>
public IReadOnlyList<ScriptedAlarmProjection> GetProjections()
{
var projections = new List<ScriptedAlarmProjection>(_alarms.Count);
foreach (var (alarmId, state) in _alarms)
{
projections.Add(new ScriptedAlarmProjection(
AlarmId: alarmId,
Severity: state.Definition.Severity,
Message: MessageTemplate.Resolve(state.Definition.MessageTemplate, TryLookup),
Condition: state.Condition,
WorstInputStatusCode: LastWorstStatus(alarmId)));
}
return projections;
}
/// <summary>Acknowledges the specified alarm on behalf of the given user.</summary> /// <summary>Acknowledges the specified alarm on behalf of the given user.</summary>
/// <param name="alarmId">The alarm identifier.</param> /// <param name="alarmId">The alarm identifier.</param>
/// <param name="user">The user performing the acknowledgment.</param> /// <param name="user">The user performing the acknowledgment.</param>
@@ -974,6 +1016,26 @@ public sealed record ScriptedAlarmEvent(
// the top-2 severity bits. Default 0u == Good keeps every existing constructor call unchanged. // the top-2 severity bits. Default 0u == Good keeps every existing constructor call unchanged.
uint WorstInputStatusCode = 0u); uint WorstInputStatusCode = 0u);
/// <summary>
/// #487 — a loaded alarm's current condition, ready to be projected onto its OPC UA node.
/// Produced by <see cref="ScriptedAlarmEngine.GetProjections"/> on demand; it is <b>not</b> an
/// emission and never travels the <see cref="ScriptedAlarmEngine.OnEvent"/> path, so it can never
/// become an <c>alerts</c> row or a historian record. Deliberately a separate type from
/// <see cref="ScriptedAlarmEvent"/> — it carries no <see cref="EmissionKind"/>, so there is nothing
/// for a future refactor to mistake for a transition.
/// </summary>
/// <param name="AlarmId">The alarm identifier (also its OPC UA condition NodeId).</param>
/// <param name="Severity">The definition's severity bucket.</param>
/// <param name="Message">The message template resolved against the current value cache.</param>
/// <param name="Condition">The Part 9 condition state the engine currently holds.</param>
/// <param name="WorstInputStatusCode">The last-observed worst OPC UA StatusCode across the alarm's inputs.</param>
public sealed record ScriptedAlarmProjection(
string AlarmId,
AlarmSeverity Severity,
string Message,
AlarmConditionState Condition,
uint WorstInputStatusCode);
/// <summary> /// <summary>
/// Upstream source abstraction — intentionally identical shape to the virtual-tag /// Upstream source abstraction — intentionally identical shape to the virtual-tag
/// engine's so Stream G can compose them behind one driver bridge. /// engine's so Stream G can compose them behind one driver bridge.
@@ -130,6 +130,14 @@ public sealed class ModbusDriverOptions
/// </summary> /// </summary>
public MelsecFamily MelsecSubFamily { get; init; } = MelsecFamily.Q_L_iQR; public MelsecFamily MelsecSubFamily { get; init; } = MelsecFamily.Q_L_iQR;
/// <summary>
/// Wire transport selector. <see cref="ModbusTransportMode.Tcp"/> (default) is the
/// historical Modbus TCP/MBAP framing. <see cref="ModbusTransportMode.RtuOverTcp"/>
/// frames Modbus RTU PDUs (CRC16, no MBAP header) over the same TCP socket — for
/// serial-to-Ethernet gateways that bridge RS-485 without re-encapsulating to MBAP.
/// </summary>
public ModbusTransportMode Transport { get; init; } = ModbusTransportMode.Tcp;
/// <summary> /// <summary>
/// When <c>true</c>, the driver suppresses redundant writes: if the most recent /// When <c>true</c>, the driver suppresses redundant writes: if the most recent
/// successful write to a tag carried value V and a new write of V arrives, the second /// successful write to a tag carried value V and a new write of V arrives, the second
@@ -0,0 +1,17 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Wire transport for a Modbus driver instance. <see cref="Tcp"/> = Modbus/TCP (MBAP + TxId);
/// <see cref="RtuOverTcp"/> = RTU framing (address + CRC-16, no MBAP) tunnelled over a socket to
/// a serial→Ethernet gateway. Default <see cref="Tcp"/> — existing configs omit the field.
/// A direct-serial <c>Rtu</c> member is intentionally NOT reserved here (descoped 2026-07-15);
/// do not number-squat it.
/// </summary>
public enum ModbusTransportMode
{
/// <summary>Modbus/TCP — 7-byte MBAP header + transaction id (the historical default).</summary>
Tcp,
/// <summary>RTU framing (<c>[addr][PDU][CRC-16]</c>) tunnelled over a socket to a serial→Ethernet gateway.</summary>
RtuOverTcp,
}
@@ -0,0 +1,48 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// CRC-16/MODBUS helper: reflected polynomial <c>0xA001</c>, initial value <c>0xFFFF</c>.
/// On the wire the CRC is appended low byte first, high byte second — <see cref="Append"/>
/// does that; <see cref="Compute"/> just returns the 16-bit value. Byte-stream-agnostic:
/// no socket or framing knowledge lives here.
/// </summary>
public static class ModbusCrc
{
/// <summary>Computes the CRC-16/MODBUS checksum over <paramref name="data"/>.</summary>
/// <param name="data">The bytes to checksum (e.g. the RTU frame minus the trailing CRC).</param>
/// <returns>The 16-bit CRC value.</returns>
public static ushort Compute(ReadOnlySpan<byte> data)
{
ushort crc = 0xFFFF;
foreach (var b in data)
{
crc ^= b;
for (var i = 0; i < 8; i++)
{
var carry = (crc & 0x0001) != 0;
crc >>= 1;
if (carry)
{
crc ^= 0xA001;
}
}
}
return crc;
}
/// <summary>
/// Returns <paramref name="body"/> with its CRC-16/MODBUS appended, low byte first.
/// </summary>
/// <param name="body">The frame body to checksum.</param>
/// <returns>A new array: <paramref name="body"/> followed by <c>[crc &amp; 0xFF, crc &gt;&gt; 8]</c>.</returns>
public static byte[] Append(ReadOnlySpan<byte> body)
{
var crc = Compute(body);
var result = new byte[body.Length + 2];
body.CopyTo(result);
result[^2] = (byte)(crc & 0xFF);
result[^1] = (byte)(crc >> 8);
return result;
}
}
@@ -94,7 +94,8 @@ public sealed class ModbusDriver
/// <summary>Initializes a new Modbus TCP driver with the specified options and transport factory.</summary> /// <summary>Initializes a new Modbus TCP driver with the specified options and transport factory.</summary>
/// <param name="options">Driver configuration options.</param> /// <param name="options">Driver configuration options.</param>
/// <param name="driverInstanceId">Unique identifier for this driver instance.</param> /// <param name="driverInstanceId">Unique identifier for this driver instance.</param>
/// <param name="transportFactory">Factory to create the Modbus transport; defaults to ModbusTcpTransport.</param> /// <param name="transportFactory">Factory to create the Modbus transport; defaults to
/// <see cref="ModbusTransportFactory.Create"/>, which honours <see cref="ModbusDriverOptions.Transport"/>.</param>
/// <param name="logger">Logger instance; defaults to null logger if not provided.</param> /// <param name="logger">Logger instance; defaults to null logger if not provided.</param>
public ModbusDriver(ModbusDriverOptions options, string driverInstanceId, public ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null, Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null,
@@ -107,11 +108,7 @@ public sealed class ModbusDriver
_resolver = new EquipmentTagRefResolver<ModbusTagDefinition>( _resolver = new EquipmentTagRefResolver<ModbusTagDefinition>(
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null); r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
_transportFactory = transportFactory _transportFactory = transportFactory
?? (o => new ModbusTcpTransport( ?? (o => ModbusTransportFactory.Create(o));
o.Host, o.Port, o.Timeout, o.AutoReconnect,
keepAlive: o.KeepAlive,
idleDisconnect: o.IdleDisconnectTimeout,
reconnect: o.Reconnect));
_poll = new PollGroupEngine( _poll = new PollGroupEngine(
reader: ReadAsync, reader: ReadAsync,
onChange: (handle, tagRef, snapshot) => onChange: (handle, tagRef, snapshot) =>
@@ -74,6 +74,8 @@ public static class ModbusDriverFactoryExtensions
: ParseEnum<ModbusFamily>(dto.Family, "<driver-level>", driverInstanceId, "Family"), : ParseEnum<ModbusFamily>(dto.Family, "<driver-level>", driverInstanceId, "Family"),
MelsecSubFamily = dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR MelsecSubFamily = dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR
: ParseEnum<MelsecFamily>(dto.MelsecSubFamily, "<driver-level>", driverInstanceId, "MelsecSubFamily"), : ParseEnum<MelsecFamily>(dto.MelsecSubFamily, "<driver-level>", driverInstanceId, "MelsecSubFamily"),
Transport = dto.Transport is null ? ModbusTransportMode.Tcp
: ParseEnum<ModbusTransportMode>(dto.Transport, "<driver-level>", driverInstanceId, "Transport"),
AutoProhibitReprobeInterval = dto.AutoProhibitReprobeMs is { } reprobeMs ? TimeSpan.FromMilliseconds(reprobeMs) : null, AutoProhibitReprobeInterval = dto.AutoProhibitReprobeMs is { } reprobeMs ? TimeSpan.FromMilliseconds(reprobeMs) : null,
AutoReconnect = dto.AutoReconnect ?? true, AutoReconnect = dto.AutoReconnect ?? true,
// v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw Modbus // v3: the deploy artifact (Wave C) populates rawTags with the cluster's authored raw Modbus
@@ -159,6 +161,8 @@ public static class ModbusDriverFactoryExtensions
public string? Family { get; init; } public string? Family { get; init; }
/// <summary>Gets or sets the Melsec subfamily.</summary> /// <summary>Gets or sets the Melsec subfamily.</summary>
public string? MelsecSubFamily { get; init; } public string? MelsecSubFamily { get; init; }
/// <summary>Gets or sets the wire transport mode (Tcp / RtuOverTcp). Null defaults to Tcp.</summary>
public string? Transport { get; init; }
/// <summary>Gets or sets the automatic prohibition reprobei interval in milliseconds.</summary> /// <summary>Gets or sets the automatic prohibition reprobei interval in milliseconds.</summary>
public int? AutoProhibitReprobeMs { get; init; } public int? AutoProhibitReprobeMs { get; init; }
/// <summary>Gets or sets a value indicating whether automatic reconnection is enabled.</summary> /// <summary>Gets or sets a value indicating whether automatic reconnection is enabled.</summary>
@@ -29,15 +29,19 @@ public sealed class ModbusDriverProbe : IDriverProbe
/// <inheritdoc /> /// <inheritdoc />
public string DriverType => "Modbus"; public string DriverType => "Modbus";
/// <summary>The parsed probe target — host/port/unitId + the effective connection timeout, all read /// <summary>The parsed probe target — host/port/unitId + the effective connection timeout + the wire
/// from the SAME factory DTO shape (<c>ModbusDriverConfigDto</c>) the driver factory parses, so /// transport mode, all read from the SAME factory DTO shape (<c>ModbusDriverConfigDto</c>) the driver
/// <c>timeoutMs</c> binds identically to the factory (R2-11, 05/CONV-2; the OpcUaClient parity rule).</summary> /// factory parses, so <c>timeoutMs</c> binds identically to the factory (R2-11, 05/CONV-2; the
internal readonly record struct ProbeTarget(string Host, int Port, byte UnitId, TimeSpan Timeout); /// OpcUaClient parity rule) and an <c>RtuOverTcp</c>-authored config probes over RTU framing —
/// matching how the driver will actually run.</summary>
internal readonly record struct ProbeTarget(string Host, int Port, byte UnitId, TimeSpan Timeout, ModbusTransportMode Transport);
/// <summary>Parse the driver config JSON into a <see cref="ProbeTarget"/> using the factory DTO shape. /// <summary>Parse the driver config JSON into a <see cref="ProbeTarget"/> using the factory DTO shape.
/// Returns a null target + an error message when the JSON is invalid or has no host/port. The effective /// Returns a null target + an error message when the JSON is invalid or has no host/port. The effective
/// timeout mirrors the factory (<c>TimeSpan.FromMilliseconds(dto.TimeoutMs ?? …)</c>); when the config /// timeout mirrors the factory (<c>TimeSpan.FromMilliseconds(dto.TimeoutMs ?? …)</c>); when the config
/// omits <c>timeoutMs</c> the caller's <paramref name="fallbackTimeout"/> is used.</summary> /// omits <c>timeoutMs</c> the caller's <paramref name="fallbackTimeout"/> is used. <c>Transport</c>
/// mirrors the factory's null-defaults-to-Tcp convention (<see cref="ModbusDriverFactoryExtensions"/>);
/// an unrecognized value is reported as a probe-target error rather than silently falling back.</summary>
/// <param name="configJson">The driver config JSON (factory DTO shape).</param> /// <param name="configJson">The driver config JSON (factory DTO shape).</param>
/// <param name="fallbackTimeout">The timeout used when the config omits <c>timeoutMs</c>.</param> /// <param name="fallbackTimeout">The timeout used when the config omits <c>timeoutMs</c>.</param>
/// <returns>The parsed target, or a null target with an error string.</returns> /// <returns>The parsed target, or a null target with an error string.</returns>
@@ -52,8 +56,15 @@ public sealed class ModbusDriverProbe : IDriverProbe
if (string.IsNullOrWhiteSpace(dto.Host) || port <= 0) if (string.IsNullOrWhiteSpace(dto.Host) || port <= 0)
return (null, "Config has no host/port to probe."); return (null, "Config has no host/port to probe.");
var transportMode = ModbusTransportMode.Tcp;
if (dto.Transport is not null && !Enum.TryParse(dto.Transport, ignoreCase: true, out transportMode))
{
return (null, $"Config has unknown Transport '{dto.Transport}'. " +
$"Expected one of {string.Join(", ", Enum.GetNames<ModbusTransportMode>())}");
}
var timeout = dto.TimeoutMs is { } ms && ms > 0 ? TimeSpan.FromMilliseconds(ms) : fallbackTimeout; var timeout = dto.TimeoutMs is { } ms && ms > 0 ? TimeSpan.FromMilliseconds(ms) : fallbackTimeout;
return (new ProbeTarget(dto.Host!, port, dto.UnitId ?? 1, timeout), null); return (new ProbeTarget(dto.Host!, port, dto.UnitId ?? 1, timeout, transportMode), null);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -72,9 +83,17 @@ public sealed class ModbusDriverProbe : IDriverProbe
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeout); cts.CancelAfter(timeout);
// Phase 1 — TCP connect (using ModbusTcpTransport which handles IPv4 preference). // Phase 1 — TCP connect, over whichever transport the config's Transport mode selects
// (ModbusTransportFactory is the single mapping point — same switch the live driver uses).
// autoReconnect=false: this is a one-shot probe, no retry loops. // autoReconnect=false: this is a one-shot probe, no retry loops.
var transport = new ModbusTcpTransport(host, port, timeout, autoReconnect: false); var transport = ModbusTransportFactory.Create(new ModbusDriverOptions
{
Host = host,
Port = port,
Timeout = timeout,
Transport = t.Transport,
AutoReconnect = false,
});
await using (transport.ConfigureAwait(false)) await using (transport.ConfigureAwait(false))
{ {
try try
@@ -0,0 +1,187 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Modbus RTU framing: builds a request ADU (<c>[unitId] + PDU + CRC</c>) and deframes a
/// response back to its bare PDU. Byte-stream-agnostic — it operates on a <see cref="Stream"/>
/// and knows nothing about sockets or serial ports, so a future serial transport can reuse it.
/// </summary>
/// <remarks>
/// <para>
/// The single genuinely-new correctness risk of the RTU path: an RTU frame carries <b>no
/// length field</b> (unlike TCP's MBAP header), so the response size must be derived from
/// the function code. After reading <c>addr(1)</c> + <c>fc(1)</c>, the remaining byte count
/// is one of three shapes:
/// </para>
/// <list type="table">
/// <listheader><term>Shape</term><description>Trailing bytes after <c>addr,fc</c></description></listheader>
/// <item>
/// <term>Exception (<c>fc &amp; 0x80</c>)</term>
/// <description><c>excCode(1)</c> + <c>CRC(2)</c></description>
/// </item>
/// <item>
/// <term>Read (FC 01/02/03/04)</term>
/// <description><c>byteCount(1)</c> then <c>byteCount</c> data bytes + <c>CRC(2)</c></description>
/// </item>
/// <item>
/// <term>Write echo (FC 05/06/15/16)</term>
/// <description>fixed <c>4</c> bytes + <c>CRC(2)</c></description>
/// </item>
/// </list>
/// <para>
/// The trailing CRC is validated over <c>[addr, ...pdu]</c> (everything except the two CRC
/// bytes) <b>before</b> the frame is interpreted — so a corrupt exception frame surfaces as a
/// desync, never as a bogus <see cref="ModbusException"/>. A CRC mismatch or a short read
/// (truncation / stream closed mid-frame) throws <see cref="ModbusTransportDesyncException"/>;
/// a CRC-valid exception PDU throws <see cref="ModbusException"/> carrying the original
/// function code (<c>fc &amp; 0x7F</c>) and exception code.
/// </para>
/// <para>
/// Once the CRC passes, the response's address byte is validated against the addressed unit.
/// On an RS-485 multi-drop bus a CRC-valid reply from the <b>wrong</b> slave would otherwise be
/// silently accepted as the addressed unit's data; such a frame is a unit-mismatch
/// <see cref="ModbusTransportDesyncException"/>. This ordering is deliberate — a corrupt frame
/// stays a CRC/desync failure, and only a coherent-but-mis-addressed frame becomes a
/// unit-mismatch desync.
/// </para>
/// </remarks>
public static class ModbusRtuFraming
{
/// <summary>
/// Builds an RTU request ADU: the unit id, the PDU, and the trailing CRC-16/MODBUS
/// (appended low byte first).
/// </summary>
/// <param name="unitId">The RTU slave/unit address.</param>
/// <param name="pdu">The bare PDU (function code + data).</param>
/// <returns>A new array: <c>[unitId, ...pdu, crcLo, crcHi]</c>.</returns>
public static byte[] BuildAdu(byte unitId, ReadOnlySpan<byte> pdu)
{
var frame = new byte[1 + pdu.Length];
frame[0] = unitId;
pdu.CopyTo(frame.AsSpan(1));
return ModbusCrc.Append(frame);
}
/// <summary>
/// Reads a single RTU response frame from <paramref name="stream"/> and returns its bare PDU
/// (<c>[fc, ...data]</c>), with the leading unit-id and trailing CRC stripped.
/// </summary>
/// <param name="stream">The byte stream to read the response from.</param>
/// <param name="expectedUnit">
/// The unit id the request was addressed to. The response's address byte is validated against
/// it after the CRC passes; a mismatch is a unit-mismatch desync.
/// </param>
/// <param name="ct">A token to cancel the read.</param>
/// <returns>The bare response PDU (function code followed by its data).</returns>
/// <exception cref="ModbusTransportDesyncException">
/// The frame was truncated (stream closed mid-frame), its trailing CRC did not validate, or its
/// address byte did not match <paramref name="expectedUnit"/>.
/// </exception>
/// <exception cref="ModbusException">
/// The frame was a CRC-valid Modbus exception PDU (high bit set on the function code).
/// </exception>
public static async Task<byte[]> ReadResponsePduAsync(
Stream stream, byte expectedUnit, CancellationToken ct)
{
// Header: addr(1) + fc(1). The function code selects how many more bytes to read.
var header = new byte[2];
await ReadExactlyAsync(stream, header, ct).ConfigureAwait(false);
var fc = header[1];
int trailing; // bytes after addr+fc, up to and including the 2 CRC bytes.
if ((fc & 0x80) != 0)
{
// Exception frame: excCode(1) + CRC(2).
trailing = 1 + 2;
}
else if (fc is 0x01 or 0x02 or 0x03 or 0x04)
{
// Read response: byteCount(1) then byteCount data bytes + CRC(2).
var bc = new byte[1];
await ReadExactlyAsync(stream, bc, ct).ConfigureAwait(false);
var byteCount = bc[0];
var rest = new byte[byteCount + 2];
await ReadExactlyAsync(stream, rest, ct).ConfigureAwait(false);
return ValidateAndStrip(expectedUnit, header, bc, rest);
}
else
{
// Fixed 4-byte echo: correct for the write FCs this driver emits (05/06/0F/10). A future
// variable-length response FC outside 01-04 (e.g. FC23) would be mis-sized here and
// surface as a desync — revisit the FC-shape table if the driver starts emitting one.
trailing = 4 + 2;
}
var tail = new byte[trailing];
await ReadExactlyAsync(stream, tail, ct).ConfigureAwait(false);
return ValidateAndStrip(expectedUnit, header, ReadOnlySpan<byte>.Empty, tail);
}
/// <summary>
/// Assembles the full frame from its pieces, validates the trailing CRC over everything
/// except the CRC bytes, then validates the response address against
/// <paramref name="expectedUnit"/> and strips <c>addr</c> + <c>CRC</c> to return the bare PDU.
/// A CRC-valid exception PDU throws <see cref="ModbusException"/>.
/// </summary>
private static byte[] ValidateAndStrip(
byte expectedUnit, ReadOnlySpan<byte> header, ReadOnlySpan<byte> middle, ReadOnlySpan<byte> tail)
{
// Reassemble the whole frame: header(addr,fc) + middle(optional byteCount) + tail.
var frame = new byte[header.Length + middle.Length + tail.Length];
header.CopyTo(frame);
middle.CopyTo(frame.AsSpan(header.Length));
tail.CopyTo(frame.AsSpan(header.Length + middle.Length));
// CRC covers everything except the trailing 2 CRC bytes.
var body = frame.AsSpan(0, frame.Length - 2);
var expected = ModbusCrc.Compute(body);
var actual = (ushort)(frame[^2] | (frame[^1] << 8));
if (actual != expected)
throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.CrcMismatch,
$"Modbus RTU CRC mismatch: computed {expected:X4} got {actual:X4}");
// Unit-address validation runs only AFTER the CRC passes: a corrupt frame is a CRC/desync
// failure, and only a coherent-but-mis-addressed frame (a CRC-valid reply from the wrong
// RS-485 slave) is a unit-mismatch desync. Never silently accept another unit's data.
var addr = frame[0];
if (addr != expectedUnit)
throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.UnitMismatch,
$"Modbus RTU unit mismatch: expected {expectedUnit} got {addr}");
// Bare PDU = frame minus leading addr and trailing CRC.
var pdu = body[1..].ToArray();
// Exception PDU: high bit set on the function code. The CRC already validated, so this is a
// coherent protocol-level error — surface the ORIGINAL function code (fc & 0x7F).
if ((pdu[0] & 0x80) != 0)
{
var fc = (byte)(pdu[0] & 0x7F);
var exCode = pdu[1];
throw new ModbusException(fc, exCode, $"Modbus exception fc={fc} code={exCode}");
}
return pdu;
}
/// <summary>
/// Fills <paramref name="buf"/> completely from <paramref name="stream"/>, throwing a
/// truncation desync if the stream ends first. Mirrors
/// <c>ModbusTcpTransport.ReadExactlyAsync</c>, but normalises the end-of-stream to a
/// <see cref="ModbusTransportDesyncException"/> so a length-less RTU frame that arrives
/// short is classified as a desync rather than a bare <see cref="EndOfStreamException"/>.
/// </summary>
private static async Task ReadExactlyAsync(Stream stream, byte[] buf, CancellationToken ct)
{
var read = 0;
while (read < buf.Length)
{
var n = await stream.ReadAsync(buf.AsMemory(read), ct).ConfigureAwait(false);
if (n == 0)
throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
$"Modbus RTU frame truncated: expected {buf.Length} bytes, got {read}");
read += n;
}
}
}
@@ -0,0 +1,190 @@
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Concrete Modbus RTU-over-TCP transport. Composes <see cref="ModbusSocketLifecycle"/> for the
/// connect / reconnect / keep-alive / idle machinery and <see cref="ModbusRtuFraming"/> for the
/// wire layout — the same lifecycle <see cref="ModbusTcpTransport"/> uses, but with CRC framing
/// instead of MBAP.
/// </summary>
/// <remarks>
/// <para>
/// Delta from <see cref="ModbusTcpTransport"/>: an RTU ADU is <c>[unitId][PDU][CRC]</c> with
/// <b>no MBAP header and no transaction id</b>. Because there is no TxId to correlate
/// interleaved responses, at most one transaction may be on the bus at a time — the
/// <see cref="_gate"/> single-flight is therefore <b>mandatory</b>, not merely a
/// diagnostics convenience.
/// </para>
/// <para>
/// Survives mid-transaction socket drops the same way the TCP transport does: a socket-level
/// failure (<see cref="IOException"/> / <see cref="SocketException"/> /
/// <see cref="EndOfStreamException"/>, and the <see cref="ModbusTransportDesyncException"/>
/// that derives from <see cref="IOException"/>) triggers a single reconnect-then-retry; a
/// second failure bubbles up so the driver's health surface reflects the real state.
/// </para>
/// <para>
/// Every transaction runs under a per-op deadline (a linked
/// <see cref="CancellationTokenSource.CancelAfter(TimeSpan)"/>, design §7 / R2-01) so a
/// frozen gateway can never wedge a poll: the deadline firing is normalised to a
/// <see cref="ModbusTransportDesyncException"/> and tears the socket down so the next attempt
/// reconnects. A <b>caller</b> cancellation is distinguished from that timeout and propagates
/// as a plain <see cref="OperationCanceledException"/> with no teardown.
/// </para>
/// <para>
/// The constructor is connection-free; all socket I/O happens in <see cref="ConnectAsync"/> /
/// <see cref="SendAsync"/>. The <see cref="ForTest"/> seam injects a pre-connected
/// <see cref="Stream"/> (an in-memory duplex fake) so the send/receive orchestration,
/// single-flight, and deadline can be exercised with no socket — in that path
/// <see cref="_life"/> is <see langword="null"/> and the idle / reconnect machinery is
/// skipped (a raw injected stream cannot reconnect).
/// </para>
/// </remarks>
public sealed class ModbusRtuOverTcpTransport : IModbusTransport
{
private readonly ModbusSocketLifecycle? _life;
private readonly Stream? _testStream;
private readonly TimeSpan _timeout;
private readonly SemaphoreSlim _gate = new(1, 1);
private bool _disposed;
/// <summary>Initializes a new instance of the <see cref="ModbusRtuOverTcpTransport"/> class.</summary>
/// <param name="host">The host address or hostname of the Modbus gateway.</param>
/// <param name="port">The TCP port of the Modbus gateway.</param>
/// <param name="timeout">The timeout for socket operations and the per-op response deadline.</param>
/// <param name="autoReconnect">Whether to automatically reconnect on socket failures.</param>
/// <param name="keepAlive">Optional keep-alive configuration for the socket.</param>
/// <param name="idleDisconnect">Optional duration after which an idle socket is disconnected.</param>
/// <param name="reconnect">Optional reconnect backoff configuration.</param>
public ModbusRtuOverTcpTransport(
string host, int port, TimeSpan timeout, bool autoReconnect = true,
ModbusKeepAliveOptions? keepAlive = null,
TimeSpan? idleDisconnect = null,
ModbusReconnectOptions? reconnect = null)
{
_timeout = timeout;
_life = new ModbusSocketLifecycle(host, port, timeout, autoReconnect, keepAlive, idleDisconnect, reconnect);
}
private ModbusRtuOverTcpTransport(Stream testStream, TimeSpan timeout)
{
_timeout = timeout;
_testStream = testStream;
}
/// <summary>
/// Test seam: builds a transport over a pre-connected, in-memory duplex <paramref name="stream"/>,
/// bypassing <see cref="ConnectAsync"/> and the idle / reconnect machinery so the send/receive
/// orchestration, single-flight, and per-op deadline can be exercised with no real socket.
/// </summary>
/// <param name="stream">A pre-connected duplex stream standing in for the socket.</param>
/// <param name="timeout">The per-op response deadline.</param>
/// <returns>A transport driving <paramref name="stream"/> directly.</returns>
internal static ModbusRtuOverTcpTransport ForTest(Stream stream, TimeSpan timeout) => new(stream, timeout);
/// <inheritdoc />
public Task ConnectAsync(CancellationToken ct) =>
// The ForTest seam is handed a pre-connected stream — nothing to dial.
_life is null ? Task.CompletedTask : _life.ConnectAsync(ct);
/// <inheritdoc />
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (_disposed) throw new ObjectDisposedException(nameof(ModbusRtuOverTcpTransport));
if (CurrentStream is null) throw new InvalidOperationException("Transport not connected");
// Single-flight: RTU carries no transaction id, so at most one transaction may be on the bus
// at a time — serialise every send/receive under the gate.
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
// Proactive idle-disconnect (real socket only): if the socket has been quiet longer than
// the configured threshold, tear it down + reconnect before this PDU lands. Defends
// against silent NAT / firewall reaping.
if (_life is not null && _life.ShouldReconnectForIdle())
{
await _life.TearDownAsync().ConfigureAwait(false);
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
}
try
{
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
_life?.MarkSuccess();
return result;
}
catch (Exception ex) when (_life is not null && _life.AutoReconnect && ModbusSocketLifecycle.IsSocketLevelFailure(ex))
{
// Mid-transaction drop: tear down the dead socket, reconnect (with backoff if
// configured), resend. Single retry — a second failure propagates so health/status
// reflect reality. Never reached in the ForTest seam (a raw injected stream has no
// lifecycle to reconnect).
await _life.TearDownAsync().ConfigureAwait(false);
await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
_life.MarkSuccess();
return result;
}
}
finally
{
_gate.Release();
}
}
/// <summary>The live stream: the lifecycle's <see cref="NetworkStream"/>, or the injected test stream.</summary>
private Stream? CurrentStream => _life?.Stream ?? _testStream;
/// <summary>
/// Executes exactly one RTU transaction on the current stream: build the ADU, write + flush,
/// read back one deframed response PDU. See the class remarks for the desync / timeout /
/// caller-cancel handling.
/// </summary>
private async Task<byte[]> SendOnceAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var stream = CurrentStream;
if (stream is null) throw new InvalidOperationException("Transport not connected");
// RTU ADU: [unitId] + PDU + CRC — no MBAP header, no TxId.
var adu = ModbusRtuFraming.BuildAdu(unitId, pdu);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_timeout);
try
{
await stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
await stream.FlushAsync(cts.Token).ConfigureAwait(false);
// Framing owns the length-less RTU deframe + CRC + unit-address validation. A CRC-valid
// exception PDU throws ModbusException (socket still coherent — propagates, no teardown);
// truncation / CRC / unit-mismatch throw ModbusTransportDesyncException.
return await ModbusRtuFraming.ReadResponsePduAsync(stream, unitId, cts.Token).ConfigureAwait(false);
}
catch (ModbusTransportDesyncException)
{
// Framing violation: the stream is desynchronized — never reuse it.
if (_life is not null) await _life.TearDownAsync().ConfigureAwait(false);
throw;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// Per-op timeout fired (NOT the caller). The response is unknown / may still land later
// and corrupt the next read — tear the socket down and normalise as a desync so the
// reconnect retry / status mapping engages.
if (_life is not null) await _life.TearDownAsync().ConfigureAwait(false);
throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.Timeout,
$"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
}
}
/// <summary>Asynchronously disposes the transport and underlying socket resources.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
if (_life is not null) await _life.DisposeAsync().ConfigureAwait(false);
_gate.Dispose();
}
}
@@ -0,0 +1,221 @@
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Owns the raw TCP socket lifecycle shared by every Modbus-over-TCP transport (MBAP and
/// RTU-over-TCP alike): the IPv4-preference connect, OS-level keep-alive, geometric-backoff
/// reconnect, idle-disconnect tracking, and teardown. It exposes the current
/// <see cref="NetworkStream"/> so the composing transport can drive its own framing on top —
/// the lifecycle knows nothing about MBAP / RTU wire layout.
/// </summary>
/// <remarks>
/// <para>
/// Extracted verbatim from <see cref="ModbusTcpTransport"/> so the connect / reconnect /
/// keep-alive / idle behaviour is written once and composed by both transports. The
/// constructor is connection-free — all socket I/O happens in <see cref="ConnectAsync"/> /
/// <see cref="ConnectWithBackoffAsync"/>.
/// </para>
/// <para>
/// Why keep-alive matters for DL205/DL260: the AutomationDirect H2-ECOM100 does NOT send
/// TCP keepalives per <c>docs/v2/dl205.md</c> §behavioral-oddities, so any NAT/firewall
/// between the gateway and PLC can silently close an idle socket after 2-5 minutes.
/// Enabling OS-level <c>SO_KEEPALIVE</c> lets the driver's own side detect a stuck socket
/// in reasonable time even when the application is mostly idle.
/// </para>
/// </remarks>
public sealed class ModbusSocketLifecycle : IAsyncDisposable
{
private readonly string _host;
private readonly int _port;
private readonly TimeSpan _timeout;
private readonly bool _autoReconnect;
private readonly ModbusKeepAliveOptions _keepAlive;
private readonly TimeSpan? _idleDisconnect;
private readonly ModbusReconnectOptions _reconnect;
private TcpClient? _client;
private NetworkStream? _stream;
private DateTime _lastSuccessUtc = DateTime.UtcNow;
/// <summary>Initializes a new instance of the <see cref="ModbusSocketLifecycle"/> class.</summary>
/// <param name="host">The host address or hostname of the Modbus server.</param>
/// <param name="port">The TCP port of the Modbus server.</param>
/// <param name="timeout">The timeout for socket operations.</param>
/// <param name="autoReconnect">Whether to automatically reconnect on socket failures.</param>
/// <param name="keepAlive">Optional keep-alive configuration for the socket.</param>
/// <param name="idleDisconnect">Optional duration after which an idle socket is disconnected.</param>
/// <param name="reconnect">Optional reconnect backoff configuration.</param>
public ModbusSocketLifecycle(
string host, int port, TimeSpan timeout, bool autoReconnect = true,
ModbusKeepAliveOptions? keepAlive = null,
TimeSpan? idleDisconnect = null,
ModbusReconnectOptions? reconnect = null)
{
_host = host;
_port = port;
_timeout = timeout;
_autoReconnect = autoReconnect;
_keepAlive = keepAlive ?? new ModbusKeepAliveOptions();
_idleDisconnect = idleDisconnect;
_reconnect = reconnect ?? new ModbusReconnectOptions();
}
/// <summary>Gets the current live <see cref="NetworkStream"/>, or <see langword="null"/> when not connected.</summary>
public NetworkStream? Stream => _stream;
/// <summary>Gets a value indicating whether auto-reconnect on socket failures is enabled.</summary>
public bool AutoReconnect => _autoReconnect;
/// <summary>Establishes a connection to the Modbus server, preferring IPv4.</summary>
/// <param name="ct">A cancellation token to observe for cancellation.</param>
/// <returns>A task representing the asynchronous connection operation.</returns>
public async Task ConnectAsync(CancellationToken ct)
{
// Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
// dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and
// simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we
// burn the entire ConnectAsync budget before even trying IPv4. Resolving first +
// dialing the IPv4 address directly sidesteps that.
var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, ct).ConfigureAwait(false);
var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses,
a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
_client = new TcpClient(target.AddressFamily);
EnableKeepAlive(_client, _keepAlive);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_timeout);
await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
_stream = _client.GetStream();
_lastSuccessUtc = DateTime.UtcNow;
}
/// <summary>
/// Connect attempt with the configured geometric backoff. The first attempt fires after
/// <see cref="ModbusReconnectOptions.InitialDelay"/> (default zero — immediate); each
/// subsequent attempt sleeps for the previous delay times <c>BackoffMultiplier</c>,
/// capped at <c>MaxDelay</c>. Caller's cancellation token aborts the loop.
/// </summary>
/// <param name="ct">A cancellation token to observe for cancellation.</param>
/// <returns>A task representing the asynchronous reconnect operation.</returns>
public async Task ConnectWithBackoffAsync(CancellationToken ct)
{
var delay = _reconnect.InitialDelay;
var attempt = 0;
while (true)
{
if (delay > TimeSpan.Zero)
await Task.Delay(delay, ct).ConfigureAwait(false);
try
{
await ConnectAsync(ct).ConfigureAwait(false);
return;
}
catch (Exception ex) when (IsSocketLevelFailure(ex) && _autoReconnect)
{
attempt++;
// Geometric growth, capped. Use Math.Min on ticks so we don't overflow with
// pathological multipliers / long deployments.
var nextTicks = (long)(Math.Max(delay.Ticks, TimeSpan.FromMilliseconds(100).Ticks) * _reconnect.BackoffMultiplier);
delay = TimeSpan.FromTicks(Math.Min(nextTicks, _reconnect.MaxDelay.Ticks));
if (attempt >= 10)
{
// Bail after 10 attempts to surface persistent failure to the caller. With
// the default backoff (1s base, 2.0x mult, 30s cap) this is roughly 4 minutes
// of attempts; with InitialDelay=0 it's immediate up to the same cap.
throw;
}
}
}
}
/// <summary>
/// Reports whether the socket has been idle longer than the configured idle-disconnect
/// threshold and should be proactively torn down + reconnected before the next transaction.
/// Always <see langword="false"/> when no idle-disconnect timeout is configured.
/// </summary>
/// <returns><see langword="true"/> when the idle threshold has been exceeded.</returns>
public bool ShouldReconnectForIdle() =>
_idleDisconnect.HasValue && DateTime.UtcNow - _lastSuccessUtc > _idleDisconnect.Value;
/// <summary>Records that a transaction just succeeded, resetting the idle-disconnect clock.</summary>
public void MarkSuccess() => _lastSuccessUtc = DateTime.UtcNow;
/// <summary>Tears down the current socket + stream, leaving the lifecycle ready to reconnect.</summary>
/// <returns>A task representing the asynchronous teardown operation.</returns>
public async Task TearDownAsync()
{
try { if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false); }
catch { /* best-effort */ }
_stream = null;
try { _client?.Dispose(); } catch { }
_client = null;
}
/// <summary>
/// Enable SO_KEEPALIVE with aggressive probe timing. DL205/DL260 doesn't send keepalives
/// itself; having the OS probe the socket every ~30s lets the driver notice a dead PLC
/// or broken NAT path long before the default 2-hour Windows idle timeout fires.
/// Non-fatal if the underlying OS rejects the option (some older Linux / container
/// sandboxes don't expose the fine-grained timing levers — the driver still works,
/// application-level probe still detects problems).
/// </summary>
private static void EnableKeepAlive(TcpClient client, ModbusKeepAliveOptions opts)
{
if (!opts.Enabled) return;
try
{
client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
// A TimeSpan < 1s previously truncated to 0 via the int cast,
// which Windows / Linux interpret as "use the default" — silently defeating the
// configured keep-alive timing. Round up to at least 1 second so a sub-second
// configuration still produces a real keep-alive cadence. Negative values are
// also clamped to 1 to avoid surfacing as OS errors.
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime,
ClampToWholeSeconds(opts.Time));
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval,
ClampToWholeSeconds(opts.Interval));
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, opts.RetryCount);
}
catch { /* best-effort; older OSes may not expose the granular knobs */ }
}
/// <summary>
/// Cast a <see cref="TimeSpan"/> to a whole number of seconds with a
/// minimum of 1 — protects callers from the int-cast truncation that turned 500 ms
/// keep-alive timing into "use the default" on most OSes.
/// </summary>
/// <param name="ts">The timespan to clamp to whole seconds.</param>
/// <returns>The clamped duration expressed as a whole number of seconds, never less than 1.</returns>
internal static int ClampToWholeSeconds(TimeSpan ts)
{
var seconds = (int)Math.Ceiling(ts.TotalSeconds);
return seconds < 1 ? 1 : seconds;
}
/// <summary>
/// Distinguish socket-layer failures (eligible for reconnect-and-retry) from
/// protocol-layer failures (must propagate — retrying the same PDU won't help if the
/// PLC just returned exception 02 Illegal Data Address).
/// </summary>
/// <param name="ex">The exception to classify.</param>
/// <returns><see langword="true"/> when the exception is a socket-level failure.</returns>
internal static bool IsSocketLevelFailure(Exception ex) =>
ex is EndOfStreamException
|| ex is IOException
|| ex is SocketException
|| ex is ObjectDisposedException;
/// <summary>Asynchronously disposes the underlying socket + stream resources.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
try
{
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
}
catch { /* best-effort */ }
_client?.Dispose();
}
}
@@ -3,13 +3,19 @@ using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary> /// <summary>
/// Concrete Modbus TCP transport. Wraps a single <see cref="TcpClient"/> and serializes /// Concrete Modbus TCP transport. Wraps a single socket (owned by <see cref="ModbusSocketLifecycle"/>)
/// requests so at most one transaction is in-flight at a time — Modbus servers typically /// and serializes requests so at most one transaction is in-flight at a time — Modbus servers
/// support concurrent transactions, but the single-flight model keeps the wire trace /// typically support concurrent transactions, but the single-flight model keeps the wire trace
/// easy to diagnose and avoids interleaved-response correlation bugs. /// easy to diagnose and avoids interleaved-response correlation bugs.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Owns the MBAP framing (<see cref="SendOnceAsync"/>: 7-byte header + transaction-id
/// pairing) and composes <see cref="ModbusSocketLifecycle"/> for the connect / reconnect /
/// keep-alive / idle-disconnect machinery — the same lifecycle the RTU-over-TCP transport
/// reuses with different framing.
/// </para>
/// <para>
/// Survives mid-transaction socket drops: when a send/read fails with a socket-level /// Survives mid-transaction socket drops: when a send/read fails with a socket-level
/// error (<see cref="IOException"/>, <see cref="SocketException"/>, <see cref="EndOfStreamException"/>) /// error (<see cref="IOException"/>, <see cref="SocketException"/>, <see cref="EndOfStreamException"/>)
/// the transport disposes the dead socket, reconnects, and retries the PDU exactly /// the transport disposes the dead socket, reconnects, and retries the PDU exactly
@@ -26,19 +32,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// </remarks> /// </remarks>
public sealed class ModbusTcpTransport : IModbusTransport public sealed class ModbusTcpTransport : IModbusTransport
{ {
private readonly string _host; private readonly ModbusSocketLifecycle _life;
private readonly int _port;
private readonly TimeSpan _timeout; private readonly TimeSpan _timeout;
private readonly bool _autoReconnect;
private readonly ModbusKeepAliveOptions _keepAlive;
private readonly TimeSpan? _idleDisconnect;
private readonly ModbusReconnectOptions _reconnect;
private readonly SemaphoreSlim _gate = new(1, 1); private readonly SemaphoreSlim _gate = new(1, 1);
private TcpClient? _client;
private NetworkStream? _stream;
private ushort _nextTx; private ushort _nextTx;
private bool _disposed; private bool _disposed;
private DateTime _lastSuccessUtc = DateTime.UtcNow;
/// <summary>Initializes a new instance of the <see cref="ModbusTcpTransport"/> class.</summary> /// <summary>Initializes a new instance of the <see cref="ModbusTcpTransport"/> class.</summary>
/// <param name="host">The host address or hostname of the Modbus server.</param> /// <param name="host">The host address or hostname of the Modbus server.</param>
@@ -54,84 +52,18 @@ public sealed class ModbusTcpTransport : IModbusTransport
TimeSpan? idleDisconnect = null, TimeSpan? idleDisconnect = null,
ModbusReconnectOptions? reconnect = null) ModbusReconnectOptions? reconnect = null)
{ {
_host = host;
_port = port;
_timeout = timeout; _timeout = timeout;
_autoReconnect = autoReconnect; _life = new ModbusSocketLifecycle(host, port, timeout, autoReconnect, keepAlive, idleDisconnect, reconnect);
_keepAlive = keepAlive ?? new ModbusKeepAliveOptions();
_idleDisconnect = idleDisconnect;
_reconnect = reconnect ?? new ModbusReconnectOptions();
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task ConnectAsync(CancellationToken ct) public Task ConnectAsync(CancellationToken ct) => _life.ConnectAsync(ct);
{
// Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is
// dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and
// simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we
// burn the entire ConnectAsync budget before even trying IPv4. Resolving first +
// dialing the IPv4 address directly sidesteps that.
var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, ct).ConfigureAwait(false);
var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses,
a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork);
var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
_client = new TcpClient(target.AddressFamily);
EnableKeepAlive(_client, _keepAlive);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_timeout);
await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
_stream = _client.GetStream();
_lastSuccessUtc = DateTime.UtcNow;
}
/// <summary>
/// Enable SO_KEEPALIVE with aggressive probe timing. DL205/DL260 doesn't send keepalives
/// itself; having the OS probe the socket every ~30s lets the driver notice a dead PLC
/// or broken NAT path long before the default 2-hour Windows idle timeout fires.
/// Non-fatal if the underlying OS rejects the option (some older Linux / container
/// sandboxes don't expose the fine-grained timing levers — the driver still works,
/// application-level probe still detects problems).
/// </summary>
private static void EnableKeepAlive(TcpClient client, ModbusKeepAliveOptions opts)
{
if (!opts.Enabled) return;
try
{
client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
// A TimeSpan < 1s previously truncated to 0 via the int cast,
// which Windows / Linux interpret as "use the default" — silently defeating the
// configured keep-alive timing. Round up to at least 1 second so a sub-second
// configuration still produces a real keep-alive cadence. Negative values are
// also clamped to 1 to avoid surfacing as OS errors.
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime,
ClampToWholeSeconds(opts.Time));
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval,
ClampToWholeSeconds(opts.Interval));
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, opts.RetryCount);
}
catch { /* best-effort; older OSes may not expose the granular knobs */ }
}
/// <summary>
/// Cast a <see cref="TimeSpan"/> to a whole number of seconds with a
/// minimum of 1 — protects callers from the int-cast truncation that turned 500 ms
/// keep-alive timing into "use the default" on most OSes.
/// </summary>
/// <param name="ts">The timespan to clamp to whole seconds.</param>
/// <returns>The clamped duration expressed as a whole number of seconds, never less than 1.</returns>
internal static int ClampToWholeSeconds(TimeSpan ts)
{
var seconds = (int)Math.Ceiling(ts.TotalSeconds);
return seconds < 1 ? 1 : seconds;
}
/// <inheritdoc /> /// <inheritdoc />
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct) public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{ {
if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport)); if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
if (_stream is null) throw new InvalidOperationException("Transport not connected"); if (_life.Stream is null) throw new InvalidOperationException("Transport not connected");
await _gate.WaitAsync(ct).ConfigureAwait(false); await _gate.WaitAsync(ct).ConfigureAwait(false);
try try
@@ -140,27 +72,27 @@ public sealed class ModbusTcpTransport : IModbusTransport
// threshold, tear it down + reconnect before this PDU lands. Defends against silent // threshold, tear it down + reconnect before this PDU lands. Defends against silent
// NAT / firewall reaping where the socket looks alive locally but the upstream side // NAT / firewall reaping where the socket looks alive locally but the upstream side
// dropped it minutes ago. // dropped it minutes ago.
if (_idleDisconnect.HasValue && DateTime.UtcNow - _lastSuccessUtc > _idleDisconnect.Value) if (_life.ShouldReconnectForIdle())
{ {
await TearDownAsync().ConfigureAwait(false); await _life.TearDownAsync().ConfigureAwait(false);
await ConnectWithBackoffAsync(ct).ConfigureAwait(false); await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
} }
try try
{ {
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false); var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
_lastSuccessUtc = DateTime.UtcNow; _life.MarkSuccess();
return result; return result;
} }
catch (Exception ex) when (_autoReconnect && IsSocketLevelFailure(ex)) catch (Exception ex) when (_life.AutoReconnect && ModbusSocketLifecycle.IsSocketLevelFailure(ex))
{ {
// Mid-transaction drop: tear down the dead socket, reconnect (with backoff if // Mid-transaction drop: tear down the dead socket, reconnect (with backoff if
// configured), resend. Single retry — if it fails again, let it propagate so // configured), resend. Single retry — if it fails again, let it propagate so
// health/status reflect reality. // health/status reflect reality.
await TearDownAsync().ConfigureAwait(false); await _life.TearDownAsync().ConfigureAwait(false);
await ConnectWithBackoffAsync(ct).ConfigureAwait(false); await _life.ConnectWithBackoffAsync(ct).ConfigureAwait(false);
var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false); var result = await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
_lastSuccessUtc = DateTime.UtcNow; _life.MarkSuccess();
return result; return result;
} }
} }
@@ -170,43 +102,6 @@ public sealed class ModbusTcpTransport : IModbusTransport
} }
} }
/// <summary>
/// Connect attempt with the configured geometric backoff. The first attempt fires after
/// <see cref="ModbusReconnectOptions.InitialDelay"/> (default zero — immediate); each
/// subsequent attempt sleeps for the previous delay times <c>BackoffMultiplier</c>,
/// capped at <c>MaxDelay</c>. Caller's cancellation token aborts the loop.
/// </summary>
private async Task ConnectWithBackoffAsync(CancellationToken ct)
{
var delay = _reconnect.InitialDelay;
var attempt = 0;
while (true)
{
if (delay > TimeSpan.Zero)
await Task.Delay(delay, ct).ConfigureAwait(false);
try
{
await ConnectAsync(ct).ConfigureAwait(false);
return;
}
catch (Exception ex) when (IsSocketLevelFailure(ex) && _autoReconnect)
{
attempt++;
// Geometric growth, capped. Use Math.Min on ticks so we don't overflow with
// pathological multipliers / long deployments.
var nextTicks = (long)(Math.Max(delay.Ticks, TimeSpan.FromMilliseconds(100).Ticks) * _reconnect.BackoffMultiplier);
delay = TimeSpan.FromTicks(Math.Min(nextTicks, _reconnect.MaxDelay.Ticks));
if (attempt >= 10)
{
// Bail after 10 attempts to surface persistent failure to the caller. With
// the default backoff (1s base, 2.0x mult, 30s cap) this is roughly 4 minutes
// of attempts; with InitialDelay=0 it's immediate up to the same cap.
throw;
}
}
}
}
/// <summary> /// <summary>
/// Executes exactly one Modbus transaction on the current socket. /// Executes exactly one Modbus transaction on the current socket.
/// </summary> /// </summary>
@@ -230,7 +125,8 @@ public sealed class ModbusTcpTransport : IModbusTransport
/// </remarks> /// </remarks>
private async Task<byte[]> SendOnceAsync(byte unitId, byte[] pdu, CancellationToken ct) private async Task<byte[]> SendOnceAsync(byte unitId, byte[] pdu, CancellationToken ct)
{ {
if (_stream is null) throw new InvalidOperationException("Transport not connected"); var stream = _life.Stream;
if (stream is null) throw new InvalidOperationException("Transport not connected");
var txId = ++_nextTx; var txId = ++_nextTx;
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU // MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU
@@ -248,11 +144,11 @@ public sealed class ModbusTcpTransport : IModbusTransport
cts.CancelAfter(_timeout); cts.CancelAfter(_timeout);
try try
{ {
await _stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false); await stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
await _stream.FlushAsync(cts.Token).ConfigureAwait(false); await stream.FlushAsync(cts.Token).ConfigureAwait(false);
var header = new byte[7]; var header = new byte[7];
await ReadExactlyAsync(_stream, header, cts.Token).ConfigureAwait(false); await ReadExactlyAsync(stream, header, cts.Token).ConfigureAwait(false);
var respTxId = (ushort)((header[0] << 8) | header[1]); var respTxId = (ushort)((header[0] << 8) | header[1]);
if (respTxId != txId) if (respTxId != txId)
throw new ModbusTransportDesyncException( throw new ModbusTransportDesyncException(
@@ -264,7 +160,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
ModbusTransportDesyncException.DesyncReason.TruncatedFrame, ModbusTransportDesyncException.DesyncReason.TruncatedFrame,
$"Modbus response length too small: {respLen}"); $"Modbus response length too small: {respLen}");
var respPdu = new byte[respLen - 1]; var respPdu = new byte[respLen - 1];
await ReadExactlyAsync(_stream, respPdu, cts.Token).ConfigureAwait(false); await ReadExactlyAsync(stream, respPdu, cts.Token).ConfigureAwait(false);
// Exception PDU: function code has high bit set. This is a well-formed protocol-level // Exception PDU: function code has high bit set. This is a well-formed protocol-level
// error — the socket is still coherent, so it MUST propagate without teardown. // error — the socket is still coherent, so it MUST propagate without teardown.
@@ -280,7 +176,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
catch (ModbusTransportDesyncException) catch (ModbusTransportDesyncException)
{ {
// Framing violation: the socket is desynchronized — never reuse it. // Framing violation: the socket is desynchronized — never reuse it.
await TearDownAsync().ConfigureAwait(false); await _life.TearDownAsync().ConfigureAwait(false);
throw; throw;
} }
catch (OperationCanceledException) when (!ct.IsCancellationRequested) catch (OperationCanceledException) when (!ct.IsCancellationRequested)
@@ -288,33 +184,13 @@ public sealed class ModbusTcpTransport : IModbusTransport
// Per-op timeout fired (NOT the caller). The response is unknown / may still land later // Per-op timeout fired (NOT the caller). The response is unknown / may still land later
// and corrupt the next read — tear the socket down and normalize as a desync so the // and corrupt the next read — tear the socket down and normalize as a desync so the
// reconnect retry / status mapping engages. // reconnect retry / status mapping engages.
await TearDownAsync().ConfigureAwait(false); await _life.TearDownAsync().ConfigureAwait(false);
throw new ModbusTransportDesyncException( throw new ModbusTransportDesyncException(
ModbusTransportDesyncException.DesyncReason.Timeout, ModbusTransportDesyncException.DesyncReason.Timeout,
$"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms"); $"Modbus per-op response timeout after {_timeout.TotalMilliseconds:0}ms");
} }
} }
/// <summary>
/// Distinguish socket-layer failures (eligible for reconnect-and-retry) from
/// protocol-layer failures (must propagate — retrying the same PDU won't help if the
/// PLC just returned exception 02 Illegal Data Address).
/// </summary>
private static bool IsSocketLevelFailure(Exception ex) =>
ex is EndOfStreamException
|| ex is IOException
|| ex is SocketException
|| ex is ObjectDisposedException;
private async Task TearDownAsync()
{
try { if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false); }
catch { /* best-effort */ }
_stream = null;
try { _client?.Dispose(); } catch { }
_client = null;
}
private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct) private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct)
{ {
var read = 0; var read = 0;
@@ -332,12 +208,7 @@ public sealed class ModbusTcpTransport : IModbusTransport
{ {
if (_disposed) return; if (_disposed) return;
_disposed = true; _disposed = true;
try await _life.DisposeAsync().ConfigureAwait(false);
{
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
}
catch { /* best-effort */ }
_client?.Dispose();
_gate.Dispose(); _gate.Dispose();
} }
} }
@@ -1,9 +1,10 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary> /// <summary>
/// Raised when a Modbus TCP transaction leaves the single-flight socket in an unknown / /// Raised when a Modbus transaction leaves the single-flight socket in an unknown /
/// desynchronized state — a per-op response timeout, or a framing violation (transaction-id /// desynchronized state — a per-op response timeout, or a framing violation (transaction-id
/// mismatch or truncated MBAP length). Unlike a Modbus <em>exception PDU</em> (a well-formed /// mismatch, truncated frame, RTU CRC mismatch, or RTU unit-address mismatch). Unlike a Modbus
/// <em>exception PDU</em> (a well-formed
/// protocol-level error where the socket is still coherent and the request must simply /// protocol-level error where the socket is still coherent and the request must simply
/// propagate), a desync means bytes may still be in flight or half-read, so the socket is /// propagate), a desync means bytes may still be in flight or half-read, so the socket is
/// unusable and MUST be torn down before this is thrown. /// unusable and MUST be torn down before this is thrown.
@@ -27,8 +28,20 @@ public sealed class ModbusTransportDesyncException : IOException
/// <summary>The response MBAP transaction id did not match the request's.</summary> /// <summary>The response MBAP transaction id did not match the request's.</summary>
TxIdMismatch, TxIdMismatch,
/// <summary>The response MBAP length field was below the mandatory minimum (truncated frame).</summary> /// <summary>
/// The frame ended before it was complete — the MBAP length field was below the mandatory
/// minimum (TCP), or the stream closed mid-frame while reading a length-less RTU frame.
/// </summary>
TruncatedFrame, TruncatedFrame,
/// <summary>The RTU frame's trailing CRC-16 did not validate over the received bytes.</summary>
CrcMismatch,
/// <summary>
/// A CRC-valid RTU frame carried a slave/unit address other than the one addressed — on an
/// RS-485 multi-drop bus, a reply from the wrong slave.
/// </summary>
UnitMismatch,
} }
/// <summary>Gets the reason the socket was classified desynchronized.</summary> /// <summary>Gets the reason the socket was classified desynchronized.</summary>
@@ -0,0 +1,43 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Single mapping point from <see cref="ModbusDriverOptions"/> to the concrete
/// <see cref="IModbusTransport"/> its <see cref="ModbusDriverOptions.Transport"/> selects.
/// Consumed by both <see cref="ModbusDriver"/>'s default transport-factory closure and the
/// AdminUI Test-Connect probe, so the <see cref="ModbusTransportMode"/> switch lives in
/// exactly one place.
/// </summary>
public static class ModbusTransportFactory
{
/// <summary>
/// Builds the transport <paramref name="options"/>.<see cref="ModbusDriverOptions.Transport"/>
/// selects, threading every shared connection setting (Host/Port/Timeout/AutoReconnect/
/// KeepAlive/IdleDisconnectTimeout/Reconnect) through to whichever concrete transport is built.
/// </summary>
/// <param name="options">Driver configuration options.</param>
/// <returns>A <see cref="ModbusRtuOverTcpTransport"/> when <see cref="ModbusTransportMode.RtuOverTcp"/>
/// is selected; a <see cref="ModbusTcpTransport"/> when <see cref="ModbusTransportMode.Tcp"/> is selected.</returns>
/// <exception cref="ArgumentOutOfRangeException">
/// <paramref name="options"/>.<see cref="ModbusDriverOptions.Transport"/> is not a recognized
/// <see cref="ModbusTransportMode"/> member — fail loudly rather than silently falling back to TCP/MBAP.
/// </exception>
public static IModbusTransport Create(ModbusDriverOptions options)
{
ArgumentNullException.ThrowIfNull(options);
return options.Transport switch
{
ModbusTransportMode.RtuOverTcp => new ModbusRtuOverTcpTransport(
options.Host, options.Port, options.Timeout, options.AutoReconnect,
keepAlive: options.KeepAlive,
idleDisconnect: options.IdleDisconnectTimeout,
reconnect: options.Reconnect),
ModbusTransportMode.Tcp => new ModbusTcpTransport(
options.Host, options.Port, options.Timeout, options.AutoReconnect,
keepAlive: options.KeepAlive,
idleDisconnect: options.IdleDisconnectTimeout,
reconnect: options.Reconnect),
_ => throw new ArgumentOutOfRangeException(
nameof(options), options.Transport, "Unknown Modbus transport mode."),
};
}
}
@@ -81,7 +81,7 @@ else
<tr> <tr>
<td><span class="mono small">@c.Subject</span></td> <td><span class="mono small">@c.Subject</span></td>
<td><span class="mono small">@c.Issuer</span></td> <td><span class="mono small">@c.Issuer</span></td>
<td><span class="mono small">@c.Thumbprint[..16]…</span></td> <td><span class="mono small">@DisplayText.Abbreviate(c.Thumbprint, 16)</span></td>
<td>@c.NotBefore.ToString("u")</td> <td>@c.NotBefore.ToString("u")</td>
<td>@c.NotAfter.ToString("u")</td> <td>@c.NotAfter.ToString("u")</td>
@if (store.Kind is StoreKind.Trusted or StoreKind.Rejected) @if (store.Kind is StoreKind.Trusted or StoreKind.Rejected)
@@ -58,7 +58,7 @@ else
} }
else else
{ {
<div class="kv"><span class="k">Revision</span><span class="v mono">@_lastDeployment.RevisionHash[..16]…</span></div> <div class="kv"><span class="k">Revision</span><span class="v mono">@DisplayText.Abbreviate(_lastDeployment.RevisionHash, 16)</span></div>
<div class="kv"><span class="k">Status</span><span class="v">@_lastDeployment.Status</span></div> <div class="kv"><span class="k">Status</span><span class="v">@_lastDeployment.Status</span></div>
<div class="kv"><span class="k">Created</span><span class="v">@_lastDeployment.CreatedAtUtc.ToString("u")</span></div> <div class="kv"><span class="k">Created</span><span class="v">@_lastDeployment.CreatedAtUtc.ToString("u")</span></div>
@if (_lastDeployment.SealedAtUtc is not null) @if (_lastDeployment.SealedAtUtc is not null)
@@ -9,6 +9,8 @@
@using System.ComponentModel.DataAnnotations @using System.ComponentModel.DataAnnotations
@using ZB.MOM.WW.OtOpcUa.Configuration @using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities @using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Configuration.Validation
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory @inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav @inject NavigationManager Nav
@inject AuthenticationStateProvider AuthState @inject AuthenticationStateProvider AuthState
@@ -34,6 +36,10 @@ else
{ {
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="nodeEdit"> <EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="nodeEdit">
<DataAnnotationsValidator /> <DataAnnotationsValidator />
@* Without this, a validation failure suppresses OnValidSubmit with NO feedback whatsoever —
the Save button simply does nothing and the operator has no way to tell why. That is how the
NodeId-regex defect above stayed invisible. *@
<ValidationSummary class="alert alert-danger py-2 px-3" />
<section class="panel rise" style="animation-delay:.02s"> <section class="panel rise" style="animation-delay:.02s">
<div class="panel-head">Identity</div> <div class="panel-head">Identity</div>
<div style="padding:1rem"> <div style="padding:1rem">
@@ -178,6 +184,33 @@ else
} }
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
// #499 — the third Sql-credential surface. DriverConfigOverridesJson is merged onto the
// cluster-level DriverConfig, so a literal connectionString pasted here leaks exactly as one
// pasted into the driver itself. The deploy gate cannot see it (DraftSnapshot carries no
// ClusterNode rows) and would be the wrong place anyway: node overrides never enter the
// artifact, so by the time a deploy ran the credential would already be stored. Refuse the
// save instead. The message names the driver instance but NEVER the value.
if (!string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson))
{
var sqlDriverIds = await db.DriverInstances
.Where(d => d.ClusterId == ClusterId && d.DriverType == DriverTypeNames.Sql)
.Select(d => d.DriverInstanceId)
.ToListAsync();
var offenders = SqlCredentialGuard.FindNodeOverrideViolations(
_form.DriverConfigOverridesJson, sqlDriverIds);
if (offenders.Count > 0)
{
_error =
$"Driver config overrides carry a '{SqlCredentialGuard.ForbiddenKey}' for Sql driver " +
$"instance(s) {string.Join(", ", offenders)}. A Sql driver must name its credentials " +
"indirectly via 'connectionStringRef', which resolves from the environment / secret " +
"store at Initialize; a literal connection string here would be stored in the config " +
"database, and the runtime ignores it anyway. Remove the key.";
return;
}
}
if (IsNew) if (IsNew)
{ {
if (await db.ClusterNodes.AnyAsync(n => n.NodeId == _form.NodeId)) if (await db.ClusterNodes.AnyAsync(n => n.NodeId == _form.NodeId))
@@ -194,7 +227,7 @@ else
OpcUaPort = _form.OpcUaPort, OpcUaPort = _form.OpcUaPort,
DashboardPort = _form.DashboardPort, DashboardPort = _form.DashboardPort,
ApplicationUri = _form.ApplicationUri, ApplicationUri = _form.ApplicationUri,
ServiceLevelBase = _form.ServiceLevelBase, ServiceLevelBase = (byte)_form.ServiceLevelBase,
Enabled = _form.Enabled, Enabled = _form.Enabled,
DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson, DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
@@ -210,7 +243,7 @@ else
entity.OpcUaPort = _form.OpcUaPort; entity.OpcUaPort = _form.OpcUaPort;
entity.DashboardPort = _form.DashboardPort; entity.DashboardPort = _form.DashboardPort;
entity.ApplicationUri = _form.ApplicationUri; entity.ApplicationUri = _form.ApplicationUri;
entity.ServiceLevelBase = _form.ServiceLevelBase; entity.ServiceLevelBase = (byte)_form.ServiceLevelBase;
entity.Enabled = _form.Enabled; entity.Enabled = _form.Enabled;
entity.DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson; entity.DriverConfigOverridesJson = string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson) ? null : _form.DriverConfigOverridesJson;
} }
@@ -254,14 +287,26 @@ else
private sealed class FormModel private sealed class FormModel
{ {
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] // The ':' and '.' are REQUIRED, not permissive. Every NodeId in this system is "host:port" —
// ClusterRoleInfo and ConfigPublishCoordinator derive it as member.Address.Host:Port, the seed
// writes "central-1:4053", and NodeDeploymentState.NodeId is FK-bound to it. The previous
// pattern forbade the colon, so EVERY existing node failed validation and OnValidSubmit never
// ran: the Save button did nothing at all, silently. Hostnames may also be dotted
// (line3-opc-a.plant.local:4053).
[Required, RegularExpression("^[A-Za-z0-9_.:-]+$")]
public string NodeId { get; set; } = ""; public string NodeId { get; set; } = "";
[Required] public string Host { get; set; } = ""; [Required] public string Host { get; set; } = "";
[Range(1, 65535)] public int OpcUaPort { get; set; } = 4840; [Range(1, 65535)] public int OpcUaPort { get; set; } = 4840;
[Range(1, 65535)] public int DashboardPort { get; set; } = 8081; [Range(1, 65535)] public int DashboardPort { get; set; } = 8081;
[Required, RegularExpression("^urn:[A-Za-z0-9_:./-]+$")] [Required, RegularExpression("^urn:[A-Za-z0-9_:./-]+$")]
public string ApplicationUri { get; set; } = ""; public string ApplicationUri { get; set; } = "";
[Range(0, 255)] public byte ServiceLevelBase { get; set; } = 200; // int, NOT byte. Blazor's InputNumber<T> rejects System.Byte outright — its static
// constructor throws "The type 'System.Byte' is not a supported numeric type", which escapes
// BuildRenderTree unhandled and takes the WHOLE page to an HTTP 500. Binding this field to
// the entity's own byte type meant /clusters/{id}/nodes/{nodeId} and .../nodes/new never
// rendered at all. The Range attribute still holds it to a byte's domain, and SubmitAsync
// casts on the way to the entity, so the stored type is unchanged.
[Range(0, 255)] public int ServiceLevelBase { get; set; } = 200;
public bool Enabled { get; set; } = true; public bool Enabled { get; set; } = true;
public string? DriverConfigOverridesJson { get; set; } public string? DriverConfigOverridesJson { get; set; }
} }
@@ -55,7 +55,7 @@
{ {
<tr> <tr>
<td><code>@Short(d.DeploymentId)</code></td> <td><code>@Short(d.DeploymentId)</code></td>
<td><code>@d.RevisionHash[..12]…</code></td> <td><code>@DisplayText.Abbreviate(d.RevisionHash, 12)</code></td>
<td>@d.Status</td> <td>@d.Status</td>
<td>@d.CreatedBy</td> <td>@d.CreatedBy</td>
<td>@d.CreatedAtUtc.ToString("u")</td> <td>@d.CreatedAtUtc.ToString("u")</td>
@@ -113,7 +113,7 @@
_lastSuccess = result.Outcome == StartDeploymentOutcome.Accepted; _lastSuccess = result.Outcome == StartDeploymentOutcome.Accepted;
_lastMessage = result.Outcome switch _lastMessage = result.Outcome switch
{ {
StartDeploymentOutcome.Accepted => $"Deployment {Short(result.DeploymentId!.Value.Value)} dispatched (rev {result.RevisionHash!.Value.Value[..12]}…).", StartDeploymentOutcome.Accepted => $"Deployment {Short(result.DeploymentId!.Value.Value)} dispatched (rev {DisplayText.Abbreviate(result.RevisionHash!.Value.Value, 12)}).",
StartDeploymentOutcome.AnotherDeploymentInFlight => result.Message ?? "Another deployment is already in flight.", StartDeploymentOutcome.AnotherDeploymentInFlight => result.Message ?? "Another deployment is already in flight.",
StartDeploymentOutcome.NoChanges => "No changes detected since the last sealed deployment.", StartDeploymentOutcome.NoChanges => "No changes detected since the last sealed deployment.",
_ => result.Message ?? "Deployment rejected.", _ => result.Message ?? "Deployment rejected.",
@@ -38,7 +38,7 @@ else
<span class="mono">@s.ScriptId</span> <span class="mono">@s.ScriptId</span>
&middot; <span>@s.Name</span> &middot; <span>@s.Name</span>
&middot; <span class="chip chip-idle ms-1">@s.Language</span> &middot; <span class="chip chip-idle ms-1">@s.Language</span>
<span class="text-muted small ms-2 mono">hash=@s.SourceHash[..12]…</span> <span class="text-muted small ms-2 mono">hash=@DisplayText.Abbreviate(s.SourceHash, 12)</span>
</summary> </summary>
<div style="padding:0 1rem 1rem"> <div style="padding:0 1rem 1rem">
<div class="d-flex mb-2"> <div class="d-flex mb-2">
@@ -0,0 +1,45 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
/// <summary>
/// Rendering helpers for operator-facing text that comes out of the database.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists (#504).</b> Several pages abbreviated a hash / thumbprint with the range
/// operator — <c>@s.SourceHash[..12]…</c> — which throws
/// <see cref="ArgumentOutOfRangeException"/> the moment the value is shorter than the slice.
/// In Blazor that escapes <c>BuildRenderTree</c> unhandled and takes the <b>whole page</b> to an
/// HTTP 500, not just the offending row. Nothing enforces a minimum length at the schema, the
/// entity, or the render layer, so any hand-written / seeded / migrated row is a live page-kill.
/// The docker-dev seed proved it: its placeholder <c>SourceHash</c> values (<c>h1</c>,
/// <c>h-abs-hr200</c>) made <c>/scripts</c> a hard 500 on every stock bring-up.
/// </para>
/// <para>
/// Use <see cref="Abbreviate"/> for <b>every</b> such prefix. It never throws, and it appends the
/// ellipsis only when it actually truncated — a 2-character hash renders as <c>h1</c>, not
/// <c>h1…</c>, so the display never implies more text than exists.
/// </para>
/// </remarks>
public static class DisplayText
{
/// <summary>Placeholder rendered for a null / blank value. Matches the em-dash convention the
/// Admin UI already uses for "nothing to show" cells.</summary>
public const string Empty = "—";
/// <summary>
/// The first <paramref name="maxLength"/> characters of <paramref name="value"/>, followed by an
/// ellipsis when (and only when) characters were dropped.
/// </summary>
/// <param name="value">The value to abbreviate. May be null, blank, or shorter than
/// <paramref name="maxLength"/> — none of which throw.</param>
/// <param name="maxLength">Maximum number of characters to keep. Values below 1 are treated as 1,
/// so a caller cannot turn a display bug into a crash.</param>
/// <returns><see cref="Empty"/> for a null/blank value; the value verbatim when it already fits;
/// otherwise the truncated prefix plus an ellipsis.</returns>
public static string Abbreviate(string? value, int maxLength)
{
if (string.IsNullOrWhiteSpace(value)) return Empty;
if (maxLength < 1) maxLength = 1;
return value.Length <= maxLength ? value : string.Concat(value.AsSpan(0, maxLength), "…");
}
}
@@ -27,6 +27,16 @@
} }
</InputSelect> </InputSelect>
</div> </div>
<div class="col-md-3">
<label class="form-label">Transport</label>
<InputSelect @bind-Value="_form.Transport" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<ModbusTransportMode>())
{
<option value="@e">@e</option>
}
</InputSelect>
<div class="form-text">RtuOverTcp = talk raw RTU frames to a serial→Ethernet gateway; must match the gateway's mode.</div>
</div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">MELSEC sub-family</label> <label class="form-label">MELSEC sub-family</label>
<InputSelect @bind-Value="_form.MelsecSubFamily" @bind-Value:after="EmitAsync" class="form-select form-select-sm"> <InputSelect @bind-Value="_form.MelsecSubFamily" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@@ -290,6 +300,9 @@
public ModbusFamily Family { get; set; } = ModbusFamily.Generic; public ModbusFamily Family { get; set; } = ModbusFamily.Generic;
public MelsecFamily MelsecSubFamily { get; set; } = MelsecFamily.Q_L_iQR; public MelsecFamily MelsecSubFamily { get; set; } = MelsecFamily.Q_L_iQR;
// Wire transport (Tcp = Modbus/TCP MBAP; RtuOverTcp = RTU framing over a serial→Ethernet gateway)
public ModbusTransportMode Transport { get; set; } = ModbusTransportMode.Tcp;
// Transport flags // Transport flags
public bool AutoReconnect { get; set; } = true; public bool AutoReconnect { get; set; } = true;
public int IdleDisconnectTimeoutSeconds { get; set; } = 0; public int IdleDisconnectTimeoutSeconds { get; set; } = 0;
@@ -337,6 +350,7 @@
TimeoutSeconds = (int)o.Timeout.TotalSeconds, TimeoutSeconds = (int)o.Timeout.TotalSeconds,
Family = o.Family, Family = o.Family,
MelsecSubFamily = o.MelsecSubFamily, MelsecSubFamily = o.MelsecSubFamily,
Transport = o.Transport,
AutoReconnect = o.AutoReconnect, AutoReconnect = o.AutoReconnect,
IdleDisconnectTimeoutSeconds = o.IdleDisconnectTimeout.HasValue ? (int)o.IdleDisconnectTimeout.Value.TotalSeconds : 0, IdleDisconnectTimeoutSeconds = o.IdleDisconnectTimeout.HasValue ? (int)o.IdleDisconnectTimeout.Value.TotalSeconds : 0,
MaxRegistersPerRead = o.MaxRegistersPerRead, MaxRegistersPerRead = o.MaxRegistersPerRead,
@@ -387,6 +401,7 @@
MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535), MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535),
Family = Family, Family = Family,
MelsecSubFamily = MelsecSubFamily, MelsecSubFamily = MelsecSubFamily,
Transport = Transport,
WriteOnChangeOnly = WriteOnChangeOnly, WriteOnChangeOnly = WriteOnChangeOnly,
AutoReconnect = AutoReconnect, AutoReconnect = AutoReconnect,
KeepAlive = new ModbusKeepAliveOptions KeepAlive = new ModbusKeepAliveOptions
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
@@ -40,41 +41,42 @@ public sealed record ScriptTagInfo(string Path, string Kind, string DataType, st
/// <summary> /// <summary>
/// Default <see cref="IScriptTagCatalog"/>. Returns ONLY the resolvable keys a script may pass to /// Default <see cref="IScriptTagCatalog"/>. Returns ONLY the resolvable keys a script may pass to
/// <c>ctx.GetTag</c> / <c>ctx.SetVirtualTag</c> — the driver <c>FullName</c> for equipment tags, /// <c>ctx.GetTag</c> / <c>ctx.SetVirtualTag</c> — in v3 that is a raw tag's <b>RawPath</b>, plus the
/// the MXAccess dot-ref for FolderPath-scoped tags (<c>EquipmentId == null</c>), and the leaf /// leaf <c>Name</c> (best-effort) for virtual tags.
/// <c>Name</c> (best-effort) for virtual tags.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// <b>Fidelity over breadth.</b> Verified: the live runtime resolves a <c>ctx.GetTag("X")</c> /// <b>Fidelity over breadth.</b> This projects exactly what the live runtime resolves, and nothing
/// literal against the driver <c>FullName</c> — the resolution chain is /// else. Verified chain: <c>AddressSpaceComposer</c> harvests the <c>ctx.GetTag("…")</c> literals
/// <c>AddressSpaceComposer</c> (via <c>EquipmentScriptPaths.ExtractDependencyRefs</c>) harvesting the <c>ctx.GetTag("…")</c> literals /// (via <c>EquipmentScriptPaths.ExtractDependencyRefs</c>) into <c>DependencyRefs</c>; those become
/// into <c>EquipmentVirtualTagPlan.DependencyRefs</c> /// <c>VirtualTagActor._dependencyRefs</c>, registered with <c>DependencyMuxActor</c>, whose
/// (<c>src/Server/…OpcUaServer/AddressSpaceComposer.cs</c>); those become /// <c>_byRef</c> map is keyed (Ordinal) by <c>DriverInstanceActor.AttributeValuePublished.FullReference</c>
/// <c>VirtualTagActor._dependencyRefs</c>, registered with the /// — and in v3 a driver's wire reference <b>is the RawPath</b>
/// <c>DependencyMuxActor</c>, whose <c>_byRef</c> map is keyed by /// (<c>DriverHostActor._nodeIdByDriverRef</c> is keyed <c>(DriverInstanceId, RawPath)</c>).
/// <c>DriverInstanceActor.AttributeValuePublished.FullReference</c>
/// (<c>src/Server/…Runtime/VirtualTags/DependencyMuxActor.cs:97</c>) — and that
/// <c>FullReference</c> is the <c>FullName</c> field extracted from <c>Tag.TagConfig</c>
/// (see <c>Commons.Types.TagConfigIntent.Parse</c>, the shared byte-parity FullName authority).
/// The UNS-path engine (<c>Core.VirtualTags.VirtualTagEngine</c>, keyed by a slash-joined
/// <c>Enterprise/Site/Area/Line/Equipment/TagName</c>) is dormant — it is NOT wired into the
/// host — so UNS browse paths never resolve at runtime and are intentionally NOT suggested.
/// </para> /// </para>
/// <para> /// <para>
/// The per-category resolvable key: /// <b>This replaced a stale v2 projection (Gitea #490).</b> The catalog used to emit
/// <list type="bullet"> /// <c>Tag.TagConfig.FullName</c> — the pre-v3 wire address — which since v3 is <i>not</i> the mux
/// <item>Equipment driver tag (<c>EquipmentId != null</c>) → the driver <c>FullName</c> /// key. The consequence was worse than the missing feature the issue reported: completion offered
/// extracted from <c>Tag.TagConfig</c> (the verified <c>DependencyMux</c> key). /// paths that could never resolve at runtime, while a <b>correct</b> absolute RawPath got no
/// GalaxyMxGateway is a standard Equipment-kind driver, so Galaxy points resolve /// completion and hovered as "not a known configured tag path". Both directions are now right.
/// by this same <c>FullName</c> key.</item>
/// <item>VirtualTag → its leaf <c>Name</c> only, as a BEST-EFFORT key (the live resolution
/// of virtual-tag cascade/write targets is unconfirmed).</item>
/// </list>
/// </para> /// </para>
/// <para> /// <para>
/// Follow-up: surface the UNS browse path as a completion <i>detail</i> (a non-inserted hint /// RawPaths are built through the shared <see cref="RawPathResolver"/> — the same byte-parity
/// shown alongside the resolvable key) for discoverability, rather than as an inserted value. /// authority <c>DraftValidator</c> and <c>AddressSpaceComposer</c> use — so a suggested path is
/// identical to the one the deploy gate and the runtime compute. A tag whose ancestry chain is
/// broken (unknown device / driver, invalid segment) resolves to <see langword="null"/> and is
/// omitted: the runtime could not route it either, so offering it would be a lie.
/// </para>
/// <para>
/// The <c>{{equip}}/&lt;RefName&gt;</c> form is served separately by
/// <see cref="GetEquipmentReferenceNamesAsync"/>; the compose seams substitute those references to
/// the same RawPaths listed here.
/// </para>
/// <para>
/// The catalog is <b>fleet-wide</b> (no cluster filter), matching the runtime's key space: the
/// deployed artifact is fleet-wide and the mux is keyed by RawPath alone. Two clusters using the
/// same RawPath collapse to one suggestion, which is correct — the string is what resolves.
/// </para> /// </para>
/// <para> /// <para>
/// Each call creates and disposes its own context via the pooled factory — the same pattern /// Each call creates and disposes its own context via the pooled factory — the same pattern
@@ -149,22 +151,43 @@ public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> d
} }
/// <summary> /// <summary>
/// Loads Tags + VirtualTags (untracked) and projects one <see cref="ScriptTagInfo"/> per row — /// Loads the raw topology + Tags + VirtualTags (untracked) and projects one
/// the SINGLE source of the per-category resolvable-key projection shared by /// <see cref="ScriptTagInfo"/> per resolvable key — the SINGLE source of the projection shared by
/// <see cref="GetPathsAsync"/> (distinct paths, prefix-filtered) and /// <see cref="GetPathsAsync"/> (distinct paths, prefix-filtered) and <see cref="GetTagInfoAsync"/>
/// <see cref="GetTagInfoAsync"/> (exact Ordinal lookup). /// (exact Ordinal lookup).
/// </summary> /// </summary>
private async Task<IReadOnlyList<ScriptTagInfo>> BuildEntriesAsync(CancellationToken ct) private async Task<IReadOnlyList<ScriptTagInfo>> BuildEntriesAsync(CancellationToken ct)
{ {
await using var db = await dbFactory.CreateDbContextAsync(ct); await using var db = await dbFactory.CreateDbContextAsync(ct);
// v3: Tag is Raw-only — EquipmentId / FolderPath / DriverInstanceId were retired (the // The raw topology, fleet-wide, in the four ancestry maps RawPathResolver takes. Building the
// equipment↔Tag + driver bindings are gone). The resolvable key is the driver FullName carried // resolver here (rather than reimplementing the join) is what keeps a suggested path
// in TagConfig; project only the surviving columns. (The Raw/UNS tag catalog is rebuilt in // byte-identical to the one the deploy gate and the runtime compute.
// Batch 2/3.) var folders = (await db.RawFolders.AsNoTracking()
.Select(f => new { f.RawFolderId, f.ParentRawFolderId, f.Name })
.ToListAsync(ct))
.ToDictionary(f => f.RawFolderId, f => ((string?)f.ParentRawFolderId, f.Name), StringComparer.Ordinal);
var drivers = (await db.DriverInstances.AsNoTracking()
.Select(d => new { d.DriverInstanceId, d.RawFolderId, d.Name })
.ToListAsync(ct))
.ToDictionary(d => d.DriverInstanceId, d => ((string?)d.RawFolderId, d.Name), StringComparer.Ordinal);
var devices = (await db.Devices.AsNoTracking()
.Select(dev => new { dev.DeviceId, dev.DriverInstanceId, dev.Name })
.ToListAsync(ct))
.ToDictionary(dev => dev.DeviceId, dev => (dev.DriverInstanceId, dev.Name), StringComparer.Ordinal);
var groups = (await db.TagGroups.AsNoTracking()
.Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name })
.ToListAsync(ct))
.ToDictionary(g => g.TagGroupId, g => ((string?)g.ParentTagGroupId, g.Name), StringComparer.Ordinal);
var resolver = new RawPathResolver(folders, drivers, devices, groups);
var tagRows = await db.Tags var tagRows = await db.Tags
.AsNoTracking() .AsNoTracking()
.Select(t => new { t.Name, t.DataType, t.TagConfig }) .Select(t => new { t.DeviceId, t.TagGroupId, t.Name, t.DataType })
.ToListAsync(ct); .ToListAsync(ct);
var vtagRows = await db.VirtualTags var vtagRows = await db.VirtualTags
@@ -176,46 +199,22 @@ public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> d
foreach (var t in tagRows) foreach (var t in tagRows)
{ {
// The runtime GetTag key is the driver FullName from TagConfig; fall back to Name if absent. // v3: the runtime GetTag key IS the RawPath. A null result means a broken/unknown ancestry
var full = ExtractFullNameFromTagConfig(t.TagConfig); // chain — the runtime could not route that tag either, so it is omitted rather than guessed.
var path = string.IsNullOrWhiteSpace(full) ? t.Name : full; var path = resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
entries.Add(new ScriptTagInfo(path, "Tag", t.DataType, null)); if (path is null) continue;
var driverInstanceId = devices.TryGetValue(t.DeviceId, out var dev) ? dev.DriverInstanceId : null;
entries.Add(new ScriptTagInfo(path, "Tag", t.DataType, driverInstanceId));
} }
foreach (var v in vtagRows) foreach (var v in vtagRows)
{ {
// Virtual tag — best-effort: the live resolution of virtual-tag cascade/write targets is // Virtual tag — best-effort: the live resolution of virtual-tag cascade/write targets is
// unconfirmed, so emit the leaf Name only (no UNS browse path, which never resolves). // unconfirmed, so emit the leaf Name only.
entries.Add(new ScriptTagInfo(v.Name, "Virtual tag", v.DataType, null)); entries.Add(new ScriptTagInfo(v.Name, "Virtual tag", v.DataType, null));
} }
return entries; return entries;
} }
/// <summary>
/// Extracts the driver-side full reference from a <c>Tag.TagConfig</c> JSON blob — the
/// top-level <c>FullName</c> string every shipped driver stores. Mirrors
/// <c>Commons.Types.TagConfigIntent.Parse(...).FullName</c> (the shared byte-parity authority;
/// AdminUI does not reference that assembly here). Falls back to the raw blob when it is not
/// a JSON object carrying a string <c>FullName</c>.
/// </summary>
private static string ExtractFullNameFromTagConfig(string tagConfig)
{
// Best-effort: pull the driver FullName out of the TagConfig JSON blob if present.
// Returns "" when absent/unparseable so the caller falls back to the tag Name — never
// the raw blob (which would surface as a garbage completion path).
if (string.IsNullOrWhiteSpace(tagConfig)) return "";
try
{
using var doc = System.Text.Json.JsonDocument.Parse(tagConfig);
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object
&& doc.RootElement.TryGetProperty("FullName", out var fullName)
&& fullName.ValueKind == System.Text.Json.JsonValueKind.String)
{
return fullName.GetString() ?? "";
}
}
catch (System.Text.Json.JsonException) { /* fall through to tag Name */ }
return "";
}
} }
@@ -170,6 +170,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <summary>Interval between bounded post-connect re-discovery passes. Production default 2s; tests /// <summary>Interval between bounded post-connect re-discovery passes. Production default 2s; tests
/// inject a tiny value so the loop runs without real-time waits.</summary> /// inject a tiny value so the loop runs without real-time waits.</summary>
private readonly TimeSpan _rediscoverInterval; private readonly TimeSpan _rediscoverInterval;
private readonly TimeSpan _healthPollInterval;
/// <summary>Cap on the number of post-connect re-discovery passes — a backstop so a never-stabilising /// <summary>Cap on the number of post-connect re-discovery passes — a backstop so a never-stabilising
/// (or perpetually-empty) discovered set cannot spin the loop forever. Production default 15.</summary> /// (or perpetually-empty) discovered set cannot spin the loop forever. Production default 15.</summary>
@@ -239,6 +240,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <param name="rediscoverDiscoverTimeout">Optional per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/>; defaults to 30 seconds.</param> /// <param name="rediscoverDiscoverTimeout">Optional per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/>; defaults to 30 seconds.</param>
/// <param name="invoker">Optional Phase 6.1 resilience invoker wrapping this driver's capability /// <param name="invoker">Optional Phase 6.1 resilience invoker wrapping this driver's capability
/// calls; defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when not supplied.</param> /// calls; defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when not supplied.</param>
/// <param name="healthPollInterval">Optional period of the health-poll heartbeat, which also drives the
/// subscription reconcile (<see cref="ReconcileSubscription"/>); defaults to
/// <see cref="HealthPollInterval"/>. Exists so a test can drive the reconcile without waiting 30 s.</param>
/// <returns>A <see cref="Akka.Actor.Props"/> instance configured to create the wrapped <see cref="DriverInstanceActor"/>.</returns> /// <returns>A <see cref="Akka.Actor.Props"/> instance configured to create the wrapped <see cref="DriverInstanceActor"/>.</returns>
public static Props Props( public static Props Props(
IDriver driver, IDriver driver,
@@ -249,7 +253,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
TimeSpan? rediscoverInterval = null, TimeSpan? rediscoverInterval = null,
int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts, int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
TimeSpan? rediscoverDiscoverTimeout = null, TimeSpan? rediscoverDiscoverTimeout = null,
IDriverCapabilityInvoker? invoker = null) => IDriverCapabilityInvoker? invoker = null,
TimeSpan? healthPollInterval = null) =>
Akka.Actor.Props.Create(() => new DriverInstanceActor( Akka.Actor.Props.Create(() => new DriverInstanceActor(
driver, driver,
reconnectInterval ?? DefaultReconnectInterval, reconnectInterval ?? DefaultReconnectInterval,
@@ -259,7 +264,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
rediscoverInterval, rediscoverInterval,
rediscoverMaxAttempts, rediscoverMaxAttempts,
rediscoverDiscoverTimeout, rediscoverDiscoverTimeout,
invoker)); invoker,
healthPollInterval));
/// <summary> /// <summary>
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and /// Returns true when the driver should boot in DEV-STUB mode based on host platform and
@@ -293,6 +299,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <param name="rediscoverDiscoverTimeout">Per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/>; defaults to 30 seconds.</param> /// <param name="rediscoverDiscoverTimeout">Per-pass timeout for <see cref="ITagDiscovery.DiscoverAsync"/>; defaults to 30 seconds.</param>
/// <param name="invoker">Phase 6.1 resilience invoker wrapping this driver's capability calls; /// <param name="invoker">Phase 6.1 resilience invoker wrapping this driver's capability calls;
/// defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when null.</param> /// defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when null.</param>
/// <param name="healthPollInterval">Period of the health-poll heartbeat, which also drives the
/// subscription reconcile; defaults to <see cref="HealthPollInterval"/> when null.</param>
public DriverInstanceActor( public DriverInstanceActor(
IDriver driver, IDriver driver,
TimeSpan reconnectInterval, TimeSpan reconnectInterval,
@@ -302,7 +310,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
TimeSpan? rediscoverInterval = null, TimeSpan? rediscoverInterval = null,
int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts, int rediscoverMaxAttempts = DefaultRediscoverMaxAttempts,
TimeSpan? rediscoverDiscoverTimeout = null, TimeSpan? rediscoverDiscoverTimeout = null,
IDriverCapabilityInvoker? invoker = null) IDriverCapabilityInvoker? invoker = null,
TimeSpan? healthPollInterval = null)
{ {
_driver = driver; _driver = driver;
_invoker = invoker ?? NullDriverCapabilityInvoker.Instance; _invoker = invoker ?? NullDriverCapabilityInvoker.Instance;
@@ -313,6 +322,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
_rediscoverInterval = rediscoverInterval ?? DefaultRediscoverInterval; _rediscoverInterval = rediscoverInterval ?? DefaultRediscoverInterval;
_rediscoverMaxAttempts = rediscoverMaxAttempts; _rediscoverMaxAttempts = rediscoverMaxAttempts;
_rediscoverDiscoverTimeout = rediscoverDiscoverTimeout ?? DefaultRediscoverDiscoverTimeout; _rediscoverDiscoverTimeout = rediscoverDiscoverTimeout ?? DefaultRediscoverDiscoverTimeout;
_healthPollInterval = healthPollInterval ?? HealthPollInterval;
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1, OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
new KeyValuePair<string, object?>("event", startStubbed ? "spawn_stub" : "spawn"), new KeyValuePair<string, object?>("event", startStubbed ? "spawn_stub" : "spawn"),
new KeyValuePair<string, object?>("driver_type", driver.DriverType)); new KeyValuePair<string, object?>("driver_type", driver.DriverType));
@@ -335,7 +345,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// actor starts, before any state transition fires. Also start the periodic heartbeat so // actor starts, before any state transition fires. Also start the periodic heartbeat so
// long-lived Healthy drivers keep their snapshot fresh for newly-joined SignalR clients. // long-lived Healthy drivers keep their snapshot fresh for newly-joined SignalR clients.
PublishHealthSnapshot(); PublishHealthSnapshot();
Timers.StartPeriodicTimer("health-poll", HealthPollTick.Instance, HealthPollInterval); Timers.StartPeriodicTimer("health-poll", HealthPollTick.Instance, _healthPollInterval);
} }
private void Stubbed() private void Stubbed()
@@ -489,7 +499,58 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
// to Self (HandleSubscribeAsync already logged the underlying cause). Swallow it so it doesn't dead-letter. // to Self (HandleSubscribeAsync already logged the underlying cause). Swallow it so it doesn't dead-letter.
Receive<SubscriptionFailed>(msg => Receive<SubscriptionFailed>(msg =>
_log.Debug("DriverInstance {Id}: resubscribe reported failure: {Reason}", _driverInstanceId, msg.Reason)); _log.Debug("DriverInstance {Id}: resubscribe reported failure: {Reason}", _driverInstanceId, msg.Reason));
Receive<HealthPollTick>(_ => PublishHealthSnapshot()); Receive<HealthPollTick>(_ =>
{
PublishHealthSnapshot();
ReconcileSubscription();
});
}
/// <summary>
/// Periodic desired-vs-actual subscription reconcile (#488), riding the existing <c>health-poll</c>
/// tick. While Connected, a driver that has a desired subscription set but no live handle is
/// re-subscribed.
/// </summary>
/// <remarks>
/// <para>
/// The desired set is otherwise only (re)applied on a <b>Connected transition</b>
/// (<see cref="ResubscribeDesired"/>) or on a deploy's <see cref="SetDesiredSubscriptions"/>.
/// Nothing re-asserted it for a subscription that was lost while the driver <i>stayed</i>
/// Connected — a failed subscribe whose retry never came, or a handle dropped down an error
/// path. That is the hole this closes.
/// </para>
/// <para>
/// <b>This is a state-machine consistency check, not a probe.</b> It can only see that this
/// actor holds no handle; it cannot detect a handle that is present but dead server-side,
/// because <c>ISubscriptionHandle</c> is opaque (a <c>DiagnosticId</c> string and nothing
/// else). Detecting that needs a liveness member on <c>ISubscribable</c> implemented across
/// all eight drivers, each with different subscription mechanics — deliberately deferred
/// until a real occurrence shows the handle outliving the subscription.
/// </para>
/// <para>
/// Gated on <see cref="ISubscribable"/>: a driver that cannot subscribe at all may still have
/// been handed a desired set, and re-telling <c>Subscribe</c> to it every tick would fail
/// every tick, forever.
/// </para>
/// <para>
/// A redundant <c>Subscribe</c> is possible but harmless: a tick sitting in the mailbox ahead
/// of an already-queued <c>Subscribe</c> observes the same "no handle" state and tells a
/// second one. <see cref="HandleSubscribeAsync"/> is a <c>ReceiveAsync</c> (so no tick can be
/// processed mid-subscribe) and drops any prior subscription before establishing the new one,
/// so the duplicate costs one extra round-trip and converges. Suppressing it would need a
/// pending-subscribe flag threaded through every self-tell site — more state than the race
/// is worth.
/// </para>
/// </remarks>
private void ReconcileSubscription()
{
if (_driver is not ISubscribable) return;
if (_desiredRefs.Count == 0 || _subscriptionHandle is not null) return;
_log.Info(
"DriverInstance {Id}: connected with {Count} desired subscription ref(s) but no live subscription handle — re-subscribing (periodic reconcile)",
_driverInstanceId, _desiredRefs.Count);
Self.Tell(new Subscribe(_desiredRefs, _desiredInterval));
} }
private void Reconnecting() private void Reconnecting()
@@ -288,8 +288,101 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
// so a re-Apply with a fresh union simply supersedes the old one — no explicit unregister needed. // so a re-Apply with a fresh union simply supersedes the old one — no explicit unregister needed.
_mux?.Tell(new DependencyMuxActor.RegisterInterest(msg.DepRefs, Self)); _mux?.Tell(new DependencyMuxActor.RegisterInterest(msg.DepRefs, Self));
_log.Debug("ScriptedAlarmHost: loaded; registered mux interest for {Count} dep refs", msg.DepRefs.Count); _log.Debug("ScriptedAlarmHost: loaded; registered mux interest for {Count} dep refs", msg.DepRefs.Count);
ReassertConditionNodes();
} }
/// <summary>
/// #487 — re-project every loaded alarm that is NOT in the Part 9 "no-event" position back onto
/// its condition node, right after a (re)load.
///
/// <para>
/// <b>The bug this closes.</b> A deploy that triggers a FULL address-space rebuild clears
/// <c>_alarmConditions</c> and re-materialises every condition node fresh — inactive, acked,
/// confirmed, unshelved. The engine, meanwhile, restores each alarm's persisted state and
/// re-derives Active from the predicate, so a still-active alarm ends the reload
/// <i>correctly Active in the engine</i> but produces <see cref="EmissionKind.None"/> (there
/// is no transition to report) — and <see cref="OnEngineEmission"/> drops None. Nothing then
/// writes the node, so an active alarm with static dependencies reads NORMAL to every OPC UA
/// client until its next real transition, which may never come. Same shape as the VirtualTag
/// redeploy-reset the <c>ReassertValue</c> fix closed.
/// </para>
///
/// <para>
/// <b>Node-only, by construction.</b> This sends <see cref="OpcUaPublishActor.AlarmStateUpdate"/>
/// and nothing else — it never publishes to the <c>alerts</c> topic and never touches the
/// telemetry hub. That is the whole reason it reads <see cref="ScriptedAlarmEngine.GetProjections"/>
/// (a plain read returning <see cref="ScriptedAlarmProjection"/>) rather than re-emitting: an
/// <c>alerts</c> row per alarm per deploy would append a duplicate historian / AVEVA record
/// every time anyone deploys — the alarm analogue of the VirtualTag M1 historian issue.
/// </para>
///
/// <para>
/// <b>Ordering.</b> <c>DriverHostActor</c> Tells <c>RebuildAddressSpace</c> to the publish
/// actor BEFORE it Tells <see cref="ApplyScriptedAlarms"/> here, and this runs later still —
/// after an <c>await</c>ed <see cref="ScriptedAlarmEngine.LoadAsync"/> pipes back. Both Tells
/// enqueue synchronously into the publish actor's local mailbox, so the re-materialise is
/// already queued ahead of these writes: the node exists by the time they are handled.
/// </para>
///
/// <para>
/// <b>Why only non-normal alarms.</b> An alarm sitting in the no-event position already
/// matches what the materialise just built, so writing it would be a pure no-op on state —
/// but it would still overwrite the node's Message (the materialise seeds it with the alarm's
/// display name; a projection carries the resolved template). Skipping them keeps a
/// never-fired alarm byte-identical to a deploy without this pass, and keeps the write count
/// at zero on the overwhelmingly common all-normal deploy.
/// </para>
///
/// <para>
/// <b>Duplicate events.</b> None. <c>WriteAlarmCondition</c>'s delta-gate compares the
/// incoming snapshot against the node's CURRENT state, so a re-assert onto a freshly
/// materialised (normal) node is a genuine delta and fires one Part 9 event — correct, since
/// the rebuild reset what clients see — while a re-assert onto a surgically-preserved node
/// that already holds the same state is suppressed. This write is ungated by redundancy role,
/// like every other node write here: the Secondary keeps its address space warm for failover.
/// </para>
/// </summary>
private void ReassertConditionNodes()
{
var reasserted = 0;
foreach (var projection in _engine.GetProjections())
{
if (IsInNoEventPosition(projection.Condition))
{
continue;
}
_publishActor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
AlarmNodeId: projection.AlarmId,
State: ToSnapshot(projection),
// The instant the state we are re-asserting actually came about — NOT the deploy time.
// A client reading Time/ReceiveTime after a rebuild should still see when the alarm went
// active, not when the address space happened to be rebuilt.
TimestampUtc: projection.Condition.LastTransitionUtc,
Realm: AddressSpaceRealm.Uns));
reasserted++;
}
if (reasserted > 0)
{
_log.Info("ScriptedAlarmHost: re-asserted {Count} non-normal condition node(s) after (re)load", reasserted);
}
}
/// <summary>Whether <paramref name="condition"/> sits in the Part 9 "no-event" position — exactly the
/// state <see cref="AlarmConditionState.Fresh"/> produces and a freshly materialised condition node
/// already carries. Anything else (active, unacknowledged, unconfirmed, disabled, or shelved) is state
/// a rebuild would silently lose, so it is what <see cref="ReassertConditionNodes"/> re-projects.</summary>
/// <param name="condition">The engine's current condition state.</param>
/// <returns><c>true</c> when the condition carries nothing a rebuild could lose.</returns>
private static bool IsInNoEventPosition(AlarmConditionState condition)
=> condition.Enabled == AlarmEnabledState.Enabled
&& condition.Active == AlarmActiveState.Inactive
&& condition.Acked == AlarmAckedState.Acknowledged
&& condition.Confirmed == AlarmConfirmedState.Confirmed
&& condition.Shelving.Kind == ShelvingKind.Unshelved;
private void OnDependencyChanged(VirtualTagActor.DependencyValueChanged msg) private void OnDependencyChanged(VirtualTagActor.DependencyValueChanged msg)
{ {
// Feed the live value into the upstream the engine subscribes from. #478 — carry the source // Feed the live value into the upstream the engine subscribes from. #478 — carry the source
@@ -594,18 +687,38 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
/// Severity is the OPC UA 1..1000 value <see cref="SeverityToInt"/> derives from the coarse engine /// Severity is the OPC UA 1..1000 value <see cref="SeverityToInt"/> derives from the coarse engine
/// bucket, cast to the <c>ushort</c> the SDK <c>SetSeverity</c> expects. Shelving's 3-way Core kind /// bucket, cast to the <c>ushort</c> the SDK <c>SetSeverity</c> expects. Shelving's 3-way Core kind
/// maps 1:1 onto the Commons <see cref="AlarmShelvingKind"/>.</summary> /// maps 1:1 onto the Commons <see cref="AlarmShelvingKind"/>.</summary>
private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmEvent e) => new( private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmEvent e)
Active: e.Condition.Active == AlarmActiveState.Active, => ToSnapshot(e.Condition, e.Severity, e.Message, e.WorstInputStatusCode);
Acknowledged: e.Condition.Acked == AlarmAckedState.Acknowledged,
Confirmed: e.Condition.Confirmed == AlarmConfirmedState.Confirmed, /// <summary>#487 — the same projection for a <see cref="ScriptedAlarmProjection"/>: a state READ used
Enabled: e.Condition.Enabled == AlarmEnabledState.Enabled, /// to restore a condition node after a rebuild, never an emission. Shares
Shelving: MapShelving(e.Condition.Shelving.Kind), /// <see cref="ToSnapshot(AlarmConditionState, AlarmSeverity, string, uint)"/> with the transition path
Severity: (ushort)SeverityToInt(e.Severity), /// so a re-asserted node is byte-identical to what the alarm's own transition would have written.</summary>
Message: e.Message, /// <param name="p">The engine projection to map.</param>
/// <returns>The Commons snapshot the SDK sink projects onto the condition node.</returns>
private static AlarmConditionSnapshot ToSnapshot(ScriptedAlarmProjection p)
=> ToSnapshot(p.Condition, p.Severity, p.Message, p.WorstInputStatusCode);
/// <summary>The single Core → Commons condition projection, shared by the emission and re-assert
/// paths.</summary>
/// <param name="condition">The Part 9 condition state.</param>
/// <param name="severity">The engine's coarse severity bucket.</param>
/// <param name="message">The resolved condition message.</param>
/// <param name="worstInputStatusCode">The worst OPC UA StatusCode across the script's input tags.</param>
/// <returns>The Commons snapshot the SDK sink projects onto the condition node.</returns>
private static AlarmConditionSnapshot ToSnapshot(
AlarmConditionState condition, AlarmSeverity severity, string message, uint worstInputStatusCode) => new(
Active: condition.Active == AlarmActiveState.Active,
Acknowledged: condition.Acked == AlarmAckedState.Acknowledged,
Confirmed: condition.Confirmed == AlarmConfirmedState.Confirmed,
Enabled: condition.Enabled == AlarmEnabledState.Enabled,
Shelving: MapShelving(condition.Shelving.Kind),
Severity: (ushort)SeverityToInt(severity),
Message: message,
// #478 — the condition's Quality is the worst quality across the script's input tags at evaluation // #478 — the condition's Quality is the worst quality across the script's input tags at evaluation
// time (carried on the event by the engine). A transition fired while an input is Uncertain projects // time (carried on the event by the engine). A transition fired while an input is Uncertain projects
// Uncertain here so the full-snapshot write doesn't clobber quality back to Good. // Uncertain here so the full-snapshot write doesn't clobber quality back to Good.
Quality: QualityFromStatus(e.WorstInputStatusCode)); Quality: QualityFromStatus(worstInputStatusCode));
/// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/> /// <summary>Maps the Core <see cref="ShelvingKind"/> onto the Commons <see cref="AlarmShelvingKind"/>
/// mirror (the Commons assembly can't see the Core enum).</summary> /// mirror (the Commons assembly can't see the Core enum).</summary>
@@ -0,0 +1,132 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// <see cref="SqlCredentialGuard"/> — the shared "a Sql driver never persists a literal connection
/// string" check behind both the #498 deploy gate and the #499 node-override save gate.
/// </summary>
/// <remarks>
/// The node-override half is the one #499 is about: <c>ClusterNode.DriverConfigOverridesJson</c> is a
/// map keyed by <c>DriverInstanceId</c> that is merged onto the cluster-level <c>DriverConfig</c>, and
/// it is invisible to <c>DraftSnapshot</c> — so nothing gated it at all before.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class SqlCredentialGuardTests
{
/// <summary>The literal an operator would paste into a raw-JSON textarea.</summary>
private const string Leaked =
"""{"provider":"SqlServer","connectionString":"Server=sql,1433;Database=Mes;User ID=sa;Password=hunter2"}""";
private const string Clean = """{"provider":"SqlServer","connectionStringRef":"Sql:Line3"}""";
// ---- CarriesLiteralConnectionString --------------------------------------------------------
/// <summary>The key is caught at the top level of a config blob.</summary>
[Fact]
public void A_literal_connectionString_is_detected()
=> SqlCredentialGuard.CarriesLiteralConnectionString(Leaked).ShouldBeTrue();
/// <summary>The supported indirect form is not a violation — otherwise the rule would block the fix
/// it tells operators to apply.</summary>
[Fact]
public void The_indirect_connectionStringRef_form_is_clean()
=> SqlCredentialGuard.CarriesLiteralConnectionString(Clean).ShouldBeFalse();
/// <summary>System.Text.Json binds <c>ConnectionString</c> to a <c>connectionString</c> property by
/// default, so a case variant is the same key — not a bypass.</summary>
[Theory]
[InlineData("""{"ConnectionString":"Server=x;Password=p"}""")]
[InlineData("""{"CONNECTIONSTRING":"Server=x;Password=p"}""")]
[InlineData("""{"connectionstring":"Server=x;Password=p"}""")]
public void Case_variants_are_the_same_key(string json)
=> SqlCredentialGuard.CarriesLiteralConnectionString(json).ShouldBeTrue();
/// <summary>Blank, malformed and non-object blobs simply have no keys. Shaping the config JSON is
/// another rule's job, and throwing here would turn a formatting mistake into a credential failure.
/// </summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("{ not json")]
[InlineData("[1,2,3]")]
[InlineData("\"a string\"")]
public void Blank_malformed_and_non_object_blobs_are_not_violations(string? json)
=> SqlCredentialGuard.CarriesLiteralConnectionString(json).ShouldBeFalse();
/// <summary>Only the top level is scanned. The DTO is flat, so a nested occurrence cannot bind and is
/// not the credential-shaped mistake this rule exists to catch.</summary>
[Fact]
public void A_nested_occurrence_is_not_flagged()
=> SqlCredentialGuard
.CarriesLiteralConnectionString("""{"nested":{"connectionString":"Server=x"}}""")
.ShouldBeFalse();
// ---- FindNodeOverrideViolations (#499) -----------------------------------------------------
/// <summary>The #499 leak: a credential pasted into a node's per-driver override map.</summary>
[Fact]
public void A_credential_in_a_node_override_for_a_Sql_driver_is_a_violation()
{
var overrides = $$"""{"di-sql": {{Leaked}} }""";
SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"])
.ShouldBe(["di-sql"]);
}
/// <summary>Every offending instance is reported, not just the first — an operator fixing one at a
/// time would otherwise need as many save attempts as there are leaks.</summary>
[Fact]
public void Every_offending_instance_is_reported()
{
var overrides = $$"""{"di-a": {{Leaked}}, "di-clean": {{Clean}}, "di-b": {{Leaked}} }""";
SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-a", "di-b", "di-clean"])
.ShouldBe(["di-a", "di-b"]);
}
/// <summary>Keys outside the Sql set are ignored. A non-Sql driver never made the
/// indirect-credential guarantee, so flagging it would break a config that is legitimate today —
/// a regression, not defence in depth.</summary>
[Fact]
public void An_override_for_a_non_Sql_driver_is_ignored()
{
var overrides = $$"""{"di-modbus": {{Leaked}} }""";
SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"]).ShouldBeEmpty();
}
/// <summary>The realistic override — a node-specific endpoint — must keep saving.</summary>
[Fact]
public void A_normal_override_is_clean()
{
var overrides = """{"di-sql": {"commandTimeoutSeconds": 45}}""";
SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"]).ShouldBeEmpty();
}
/// <summary>Nothing to check when the cluster has no Sql drivers, or the node has no overrides.</summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("{ not json")]
[InlineData("[1,2,3]")]
public void Blank_and_malformed_override_maps_yield_no_violations(string? overrides)
=> SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"]).ShouldBeEmpty();
/// <summary>An empty Sql-driver set short-circuits — there is no instance the key could belong to.</summary>
[Fact]
public void No_Sql_drivers_means_no_violations()
=> SqlCredentialGuard.FindNodeOverrideViolations($$"""{"di-sql": {{Leaked}} }""", [])
.ShouldBeEmpty();
/// <summary>A non-object override entry cannot carry config keys, and must not throw.</summary>
[Fact]
public void A_non_object_override_entry_is_skipped()
=> SqlCredentialGuard.FindNodeOverrideViolations("""{"di-sql": "connectionString"}""", ["di-sql"])
.ShouldBeEmpty();
}
@@ -0,0 +1,180 @@
using Serilog;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
/// <summary>
/// #487 — <see cref="ScriptedAlarmEngine.GetProjections"/>: the read that lets a caller restore an
/// alarm's condition node after a full address-space rebuild, without waiting for the alarm's next
/// transition (which for a still-active alarm with static dependencies may never come).
/// </summary>
/// <remarks>
/// The load that motivates this is deliberately the one that emits NOTHING: an alarm whose persisted
/// state is already Active and whose predicate is still true produces <see cref="EmissionKind.None"/>,
/// so the transition stream cannot carry its state. These tests pin both halves — the projection does
/// report the state, and reading it does not fire an emission.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class ScriptedAlarmProjectionTests
{
private static ScriptedAlarmDefinition Alarm(
string id = "HighTemp",
string predicate = """return (int)ctx.GetTag("Temp").Value > 100;""",
string msg = "Temp is {Temp}",
AlarmSeverity sev = AlarmSeverity.High) =>
new(AlarmId: id,
EquipmentPath: "Plant/Line1/Reactor",
AlarmName: id,
Kind: AlarmKind.AlarmCondition,
Severity: sev,
MessageTemplate: msg,
PredicateScriptSource: predicate);
/// <summary>An already-Active persisted state, as a store would hold it across a restart/redeploy.</summary>
private static AlarmConditionState PersistedActive(
string alarmId = "HighTemp",
AlarmAckedState acked = AlarmAckedState.Unacknowledged,
DateTime? lastTransitionUtc = null) =>
new(alarmId,
AlarmEnabledState.Enabled,
AlarmActiveState.Active,
acked,
AlarmConfirmedState.Confirmed,
ShelvingState.Unshelved,
lastTransitionUtc ?? DateTime.UtcNow,
LastActiveUtc: lastTransitionUtc ?? DateTime.UtcNow,
LastClearedUtc: null,
LastAckUtc: null, LastAckUser: null, LastAckComment: null,
LastConfirmUtc: null, LastConfirmUser: null, LastConfirmComment: null,
Comments: []);
private static ScriptedAlarmEngine Build(FakeUpstream up, IAlarmStateStore store)
{
var logger = new LoggerConfiguration().CreateLogger();
return new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger);
}
/// <summary>The core of #487: a reload of a still-active alarm emits NOTHING, yet the projection
/// reports it Active — so a caller has a way to restore the node the rebuild reset.</summary>
[Fact]
public async Task A_still_active_alarm_emits_nothing_on_reload_but_projects_Active()
{
var up = new FakeUpstream();
up.Set("Temp", 150); // predicate still true — no transition to report
var store = new InMemoryAlarmStateStore();
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
using var eng = Build(up, store);
var emissions = new List<ScriptedAlarmEvent>();
eng.OnEvent += (_, e) => { lock (emissions) emissions.Add(e); }; // subscribe BEFORE the load
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
// The premise. If this ever starts emitting, the bug's shape has changed and the re-assert
// in ScriptedAlarmHostActor may have become a duplicate rather than the only source of truth.
lock (emissions)
{
emissions.ShouldBeEmpty("a still-active alarm has no transition to report on reload");
}
var projection = eng.GetProjections().ShouldHaveSingleItem();
projection.AlarmId.ShouldBe("HighTemp");
projection.Condition.Active.ShouldBe(AlarmActiveState.Active);
projection.Condition.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
}
/// <summary>The projection carries the definition's severity and the message template resolved against
/// the live value cache — so a re-asserted node is indistinguishable from one a real transition wrote.</summary>
[Fact]
public async Task Projection_carries_definition_severity_and_a_resolved_message()
{
var up = new FakeUpstream();
up.Set("Temp", 150);
var store = new InMemoryAlarmStateStore();
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
using var eng = Build(up, store);
await eng.LoadAsync([Alarm(sev: AlarmSeverity.Critical)], TestContext.Current.CancellationToken);
var projection = eng.GetProjections().ShouldHaveSingleItem();
projection.Severity.ShouldBe(AlarmSeverity.Critical);
projection.Message.ShouldBe("Temp is 150", "the template resolves against the current value cache");
}
/// <summary>Reading projections is a pure read — it must never raise <see cref="ScriptedAlarmEngine.OnEvent"/>,
/// or every deploy would append a duplicate historian / alerts row.</summary>
[Fact]
public async Task Reading_projections_never_fires_an_emission()
{
var up = new FakeUpstream();
up.Set("Temp", 150);
var store = new InMemoryAlarmStateStore();
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
using var eng = Build(up, store);
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
var emissions = 0;
eng.OnEvent += (_, _) => Interlocked.Increment(ref emissions);
for (var i = 0; i < 5; i++) eng.GetProjections().ShouldNotBeEmpty();
Volatile.Read(ref emissions).ShouldBe(0);
}
/// <summary>An alarm that is genuinely normal projects the no-event position, so a caller can tell
/// "nothing to restore" from "restore this".</summary>
[Fact]
public async Task A_normal_alarm_projects_the_no_event_position()
{
var up = new FakeUpstream();
up.Set("Temp", 50); // predicate false
using var eng = Build(up, new InMemoryAlarmStateStore());
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
var projection = eng.GetProjections().ShouldHaveSingleItem();
projection.Condition.Active.ShouldBe(AlarmActiveState.Inactive);
projection.Condition.Acked.ShouldBe(AlarmAckedState.Acknowledged);
projection.Condition.Enabled.ShouldBe(AlarmEnabledState.Enabled);
projection.Condition.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
}
/// <summary>The worst-input quality rides the projection, so restoring a node whose script inputs are
/// Bad does not clobber its Quality back to Good (#478's invariant, on the re-assert path).</summary>
[Fact]
public async Task Projection_carries_the_last_observed_worst_input_quality()
{
var up = new FakeUpstream();
up.Set("Temp", 150);
var store = new InMemoryAlarmStateStore();
await store.SaveAsync(PersistedActive(), TestContext.Current.CancellationToken);
using var eng = Build(up, store);
await eng.LoadAsync([Alarm()], TestContext.Current.CancellationToken);
eng.GetProjections().ShouldHaveSingleItem().WorstInputStatusCode.ShouldBe(0u, "inputs start Good");
up.Push("Temp", 150, 0x80000000u); // input goes Bad — condition freezes, quality is annotated
// The push re-evaluates on a background worker; poll for the tracked bucket to move.
var deadline = DateTime.UtcNow.AddSeconds(10);
while (eng.GetProjections()[0].WorstInputStatusCode == 0u && DateTime.UtcNow < deadline)
{
await Task.Delay(20, TestContext.Current.CancellationToken);
}
(eng.GetProjections()[0].WorstInputStatusCode >> 30).ShouldBe(2u, "a Bad input projects Bad quality");
}
/// <summary>Before the first load there is nothing to project — the read is safe, not a throw.</summary>
[Fact]
public void Projections_are_empty_before_the_first_load()
{
using var eng = Build(new FakeUpstream(), new InMemoryAlarmStateStore());
eng.GetProjections().ShouldBeEmpty();
}
}
@@ -10,59 +10,109 @@ using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary> /// <summary>
/// PR 6.2 — pins the EventPump's bounded-channel + drop-newest behavior. We /// PR 6.2 — pins the EventPump's bounded-channel + drop-newest behavior. We hold the dispatch loop
/// hold the dispatch loop with a slow handler so the channel fills, then verify /// with a blocking handler so the channel fills, then verify the producer keeps reading from the gw
/// the producer keeps reading from the gw stream and increments the /// stream and increments the <c>galaxy.events.dropped</c> counter rather than blocking.
/// <c>galaxy.events.dropped</c> counter rather than blocking.
/// </summary> /// </summary>
/// <remarks>
/// These tests observe two background loops (<c>RunAsync</c> / <c>DispatchLoopAsync</c>) through a
/// process-wide <see cref="Meter"/>, so every wait here is a <b>presence</b> assertion polled up to
/// <see cref="Budget"/> — never a fixed sleep. A fixed sleep is a bet that both loops get scheduled
/// within it, and #503 is what losing that bet looks like when the box is busy running other test
/// assemblies.
/// </remarks>
public sealed class EventPumpBoundedChannelTests public sealed class EventPumpBoundedChannelTests
{ {
/// <summary>Verifies that the event pump drops newest events when the bounded channel fills and records metrics for dropped events.</summary> /// <summary>Upper bound on how long a poll waits for a background loop to reach its terminal state.
/// Generous on purpose: it is spent only when something is genuinely broken, and it cannot turn a
/// failing assertion into a passing one.</summary>
private static readonly TimeSpan Budget = TimeSpan.FromSeconds(15);
/// <summary>
/// With the dispatch loop genuinely held and the channel therefore full, the producer keeps
/// draining the gw stream and counts every rejected write on <c>galaxy.events.dropped</c> —
/// newest-dropped, not back-pressure.
/// </summary>
/// <remarks>
/// <para>
/// The arrangement is staged so the arithmetic is exact rather than scheduler-dependent
/// (#503): emit ONE event, wait until the dispatcher has actually entered the handler (so it
/// holds that event and the channel is empty), and only then emit the remaining nine. The
/// channel accepts <c>capacity</c> of them and the rest must be dropped —
/// <c>10 1 held 2 buffered = 7</c>, every run.
/// </para>
/// </remarks>
[Fact] [Fact]
public async Task Drops_newest_when_channel_fills_and_records_metric() public async Task Drops_newest_when_channel_fills_and_records_metric()
{ {
var counters = StartMeterCapture(); const int totalEvents = 10;
const int channelCapacity = 2;
// One event is held inside the handler; `channelCapacity` more fit in the channel; the rest are dropped.
const int expectedDropped = totalEvents - 1 - channelCapacity;
// Unique per run: the counters are filtered to this exact galaxy.client tag, which is what keeps a
// parallel sibling test's pump out of them (#503). A literal name would work today and break the
// day someone reuses it.
var clientName = $"PumpTest-{Guid.NewGuid():N}";
var counters = StartMeterCapture(clientName);
// A SYNCHRONOUS gate, deliberately. OnDataChange is EventHandler<T> — void-returning — so an
// `async (_, _) => await gate` lambda is async void: it returns to the dispatch loop at the first
// await and holds nothing. That is exactly what #503 was: the loop drained freely, drops happened
// only when the producer happened to outrun the consumer, and the assertions rode on scheduling
// luck. Blocking the loop's own thread is what makes "the dispatcher is held" true.
var dispatchGate = new ManualResetEventSlim(false);
var handlerEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
EventPump? pump = null;
try try
{ {
var subscriber = new ManualSubscriber(); var subscriber = new ManualSubscriber();
var registry = new SubscriptionRegistry(); var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tag.A", ItemHandle: 7)]); registry.Register(1, [new TagBinding("Tag.A", ItemHandle: 7)]);
// Tiny channel + slow handler ⇒ producer hits FullMode.Wait → TryWrite false pump = new EventPump(
// for every overflow event. subscriber, registry, channelCapacity: channelCapacity, clientName: clientName);
var dispatchGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); pump.OnDataChange += (_, _) =>
await using var pump = new EventPump(
subscriber, registry, channelCapacity: 2, clientName: "PumpTest");
pump.OnDataChange += async (_, _) =>
{ {
// Block the dispatch loop until we've shoved enough events through to handlerEntered.TrySetResult();
// overflow the bounded channel. Consume the gate exactly once. dispatchGate.Wait();
await dispatchGate.Task.ConfigureAwait(false);
}; };
pump.Start(); pump.Start();
const int totalEvents = 10; // Stage 1 — one event, then wait for the dispatcher to take it and block. After this the
for (var i = 0; i < totalEvents; i++) // channel is provably empty and exactly one event is in flight inside the handler.
await subscriber.EmitAsync(itemHandle: 7, value: 0);
await handlerEntered.Task.WaitAsync(Budget);
// Stage 2 — the rest. `channelCapacity` are accepted; the remainder must be dropped.
for (var i = 1; i < totalEvents; i++)
{ {
await subscriber.EmitAsync(itemHandle: 7, value: i); await subscriber.EmitAsync(itemHandle: 7, value: i);
} }
// Give the producer a beat to run TryWrite for every event.
await Task.Delay(150);
// Capacity 2 + 1 in-flight in the dispatcher = 3 may have been accepted; the // PRESENCE assertion on the discriminator (#500's rule): poll until the drop count reaches its
// remainder should have hit the dropped counter. Don't pin exact counts — // terminal value rather than sleeping a fixed 150 ms and hoping the producer got scheduled.
// the scheduler can interleave; pin the invariants instead. // The budget is an upper bound before giving up — spending it costs nothing on the happy path,
counters.Received.ShouldBeGreaterThanOrEqualTo(totalEvents); // and no amount of it can make a genuinely broken pump pass. Dropped starts at 0, so this
counters.Dropped.ShouldBeGreaterThan(0, // cannot be satisfied by the initial state.
"with capacity=2 and a held dispatcher we must drop at least one of 10 events"); await WaitUntilAsync(() => counters.Dropped == expectedDropped,
(counters.Received).ShouldBe(counters.Dispatched + counters.Dropped + counters.InFlight, $"expected {expectedDropped} dropped events");
"received = dispatched + dropped + (events still queued)");
// Release the dispatcher so DisposeAsync can drain cleanly. counters.Received.ShouldBe(totalEvents,
dispatchGate.TrySetResult(); "the producer must keep reading the gw stream through the overflow, not back-pressure");
counters.Dropped.ShouldBe(expectedDropped);
counters.Dispatched.ShouldBe(0,
"the dispatch loop is still blocked in the handler, so nothing has completed dispatch — " +
"if this is non-zero the handler is not actually holding the loop and the test proves nothing");
} }
finally finally
{ {
// Release BEFORE disposing: DisposeAsync awaits the dispatch loop, and that loop is parked on
// this gate. Releasing it here (not at the end of the try) is what keeps a failed assertion a
// failure instead of a hang.
dispatchGate.Set();
if (pump is not null) await pump.DisposeAsync();
dispatchGate.Dispose();
counters.Dispose(); counters.Dispose();
} }
} }
@@ -110,23 +160,21 @@ public sealed class EventPumpBoundedChannelTests
// Poll until at least one galaxy.events.received measurement tagged // Poll until at least one galaxy.events.received measurement tagged
// galaxy.client=Driver-X lands in the listener, rather than using a // galaxy.client=Driver-X lands in the listener, rather than using a
// fixed delay that races under parallel test load on a busy box. // fixed delay that races under parallel test load on a busy box.
var deadline = DateTime.UtcNow.AddSeconds(5); // Budget, not 5 s: the same contention that produced #503 stretches how long the
bool found = false; // producer loop takes to be scheduled, and a presence poll costs nothing to over-budget.
while (DateTime.UtcNow < deadline) await WaitUntilAsync(
{ () =>
listener.RecordObservableInstruments();
bool hasMatch;
lock (captured)
{ {
hasMatch = captured.Any(c => listener.RecordObservableInstruments();
c.Instrument == "galaxy.events.received" && lock (captured)
c.Tags.Any(t => t.Key == "galaxy.client" && {
string.Equals((string?)t.Value, "Driver-X", StringComparison.Ordinal))); return captured.Any(c =>
} c.Instrument == "galaxy.events.received" &&
if (hasMatch) { found = true; break; } c.Tags.Any(t => t.Key == "galaxy.client" &&
await Task.Delay(25); string.Equals((string?)t.Value, "Driver-X", StringComparison.Ordinal)));
} }
_ = found; // assertion happens below after dispose },
"a galaxy.events.received measurement tagged galaxy.client=Driver-X");
} }
// The static Meter is shared across all EventPump instances in the test // The static Meter is shared across all EventPump instances in the test
@@ -146,7 +194,46 @@ public sealed class EventPumpBoundedChannelTests
ours.ShouldContain(c => c.Instrument == "galaxy.events.received"); ours.ShouldContain(c => c.Instrument == "galaxy.events.received");
} }
private static CounterCapture StartMeterCapture() /// <summary>Polls <paramref name="condition"/> until it holds or <see cref="Budget"/> elapses, failing
/// with <paramref name="what"/> on timeout. The presence-assertion counterpart to a fixed delay.</summary>
private static async Task WaitUntilAsync(Func<bool> condition, string what)
{
var deadline = DateTime.UtcNow + Budget;
while (DateTime.UtcNow < deadline)
{
if (condition()) return;
await Task.Delay(10);
}
condition().ShouldBeTrue($"timed out after {Budget.TotalSeconds:0}s waiting: {what}");
}
/// <summary>
/// Captures the three EventPump counters for <b>one specific pump</b>, identified by its
/// <c>galaxy.client</c> tag.
/// </summary>
/// <remarks>
/// <para>
/// The <c>galaxy.client</c> filter is the fix for #503, and it is not optional. The pump's
/// <see cref="Meter"/> is <b>static</b> — one instance for the whole process — and
/// <c>InstrumentPublished</c> can only select by <i>meter</i>, so an unfiltered listener
/// receives measurements from every <see cref="EventPump"/> alive anywhere in the assembly.
/// xUnit runs test classes in parallel and several of them build pumps
/// (<c>EventPumpStreamFaultTests</c>, the <c>Driver-X</c> test below), so a sibling's events
/// landed in these counters — that is why the flake needed a busy box: it needed the two
/// tests to overlap in time. The leak was measured directly, as <c>Received = 11</c> in a run
/// that emitted 10.
/// </para>
/// <para>
/// The old <c>Received == Dispatched + Dropped + InFlight</c> assertion is what turned the
/// leak into a failure: a foreign increment landing between its four separate
/// <see cref="Interlocked.Read(ref long)"/> calls made the two sides disagree. Hence the
/// reported subject, <c>counters.Received</c>.
/// </para>
/// </remarks>
/// <param name="clientName">The <c>galaxy.client</c> tag value to accept. Must be unique to the
/// calling test — pass a fresh GUID-suffixed name rather than a literal, so a future sibling test
/// cannot silently reintroduce the leak by reusing the same name.</param>
private static CounterCapture StartMeterCapture(string clientName)
{ {
var capture = new CounterCapture(); var capture = new CounterCapture();
var listener = new MeterListener(); var listener = new MeterListener();
@@ -154,8 +241,20 @@ public sealed class EventPumpBoundedChannelTests
{ {
if (instr.Meter.Name == EventPump.MeterName) l.EnableMeasurementEvents(instr); if (instr.Meter.Name == EventPump.MeterName) l.EnableMeasurementEvents(instr);
}; };
listener.SetMeasurementEventCallback<long>((instr, value, _, _) => listener.SetMeasurementEventCallback<long>((instr, value, tags, _) =>
{ {
var mine = false;
foreach (var tag in tags)
{
if (tag.Key == "galaxy.client" &&
string.Equals((string?)tag.Value, clientName, StringComparison.Ordinal))
{
mine = true;
break;
}
}
if (!mine) return;
switch (instr.Name) switch (instr.Name)
{ {
case "galaxy.events.received": Interlocked.Add(ref capture._received, value); break; case "galaxy.events.received": Interlocked.Add(ref capture._received, value); break;
@@ -178,8 +277,11 @@ public sealed class EventPumpBoundedChannelTests
public long Dispatched => Interlocked.Read(ref _dispatched); public long Dispatched => Interlocked.Read(ref _dispatched);
/// <summary>Gets the count of dropped events.</summary> /// <summary>Gets the count of dropped events.</summary>
public long Dropped => Interlocked.Read(ref _dropped); public long Dropped => Interlocked.Read(ref _dropped);
/// <summary>Gets the count of in-flight events.</summary> // There is deliberately no InFlight member. It could only be DERIVED as
public long InFlight => Math.Max(0, Received - Dispatched - Dropped); // Received - Dispatched - Dropped, which made the old
// `Received == Dispatched + Dropped + InFlight` assertion a tautology: it could not fail on a
// real defect, only on a torn read across the four separate Interlocked.Read calls. Assert the
// three measured counters directly instead (#503).
/// <summary>Disposes the meter listener.</summary> /// <summary>Disposes the meter listener.</summary>
public void Dispose() => Listener?.Dispose(); public void Dispose() => Listener?.Dispose();
} }
@@ -21,7 +21,10 @@ COPY profiles/ /fixtures/
# compose profile. See Docker/README.md §exception injection. # compose profile. See Docker/README.md §exception injection.
COPY exception_injector.py /fixtures/ COPY exception_injector.py /fixtures/
EXPOSE 5020 # 5020 = the shared port for the standard/dl205/mitsubishi/s7_1500/exception_injection
# profiles (only one binds it at a time); 5021 = the rtu_over_tcp profile, which co-runs
# with standard. Image-metadata honesty only — compose's ports: mapping doesn't need EXPOSE.
EXPOSE 5020 5021
# Default to the standard profile; docker-compose.yml overrides per service. # Default to the standard profile; docker-compose.yml overrides per service.
# --http_port intentionally omitted; pymodbus 3.13's web UI binds on a # --http_port intentionally omitted; pymodbus 3.13's web UI binds on a
@@ -2,15 +2,16 @@
The Modbus driver's integration tests talk to a The Modbus driver's integration tests talk to a
[`pymodbus`](https://pymodbus.readthedocs.io/) simulator running as a [`pymodbus`](https://pymodbus.readthedocs.io/) simulator running as a
pinned Docker container. One image, per-profile service in compose, same pinned Docker container. One image, per-profile service in compose. Most
port binding (`5020`) regardless of which profile is live. Docker is the profiles bind `:5020`, so only one of *those* runs at a time; the
only supported launch path — a fresh clone needs Docker Desktop and `rtu_over_tcp` profile binds `:5021` and is designed to co-run alongside
nothing else. `standard`. Docker is the only supported launch path — a fresh clone needs
Docker Desktop and nothing else.
| File | Purpose | | File | Purpose |
|---|---| |---|---|
| [`Dockerfile`](Dockerfile) | `python:3.12-slim-bookworm` + `pymodbus[simulator]==3.13.0` + every profile JSON + `exception_injector.py` | | [`Dockerfile`](Dockerfile) | `python:3.12-slim-bookworm` + `pymodbus[simulator]==3.13.0` + every profile JSON + `exception_injector.py` |
| [`docker-compose.yml`](docker-compose.yml) | One service per profile (`standard` / `dl205` / `mitsubishi` / `s7_1500` / `exception_injection`); all bind `:5020` so only one runs at a time | | [`docker-compose.yml`](docker-compose.yml) | One service per profile (`standard` / `dl205` / `mitsubishi` / `s7_1500` / `exception_injection` bind `:5020`, so only one of those runs at a time; `rtu_over_tcp` binds `:5021` and can run alongside `standard`) |
| [`profiles/*.json`](profiles/) | Same seed-register definitions the native launcher uses — canonical source | | [`profiles/*.json`](profiles/) | Same seed-register definitions the native launcher uses — canonical source |
| [`exception_injector.py`](exception_injector.py) | Pure-stdlib Modbus/TCP server that emits arbitrary exception codes per rule — used by the `exception_injection` profile | | [`exception_injector.py`](exception_injector.py) | Pure-stdlib Modbus/TCP server that emits arbitrary exception codes per rule — used by the `exception_injection` profile |
@@ -43,10 +44,11 @@ docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 up -d
docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 down docker compose -f tests\...\Docker\docker-compose.yml --profile dl205 down
``` ```
Only one profile binds `:5020` at a time; switch by stopping the current Only one of the `:5020` profiles binds at a time; switch by stopping the
service + starting another. The integration tests discriminate by a current service + starting another. (`rtu_over_tcp` is the exception — it
separate `MODBUS_SIM_PROFILE` env var so they skip correctly when the binds `:5021` and co-runs with `standard`.) The integration tests
wrong profile is live. discriminate by a separate `MODBUS_SIM_PROFILE` env var so they skip
correctly when the wrong profile is live.
> **⚠️ Profile-cycling gotcha (bit us in the 2026-07 sweep — see > **⚠️ Profile-cycling gotcha (bit us in the 2026-07 sweep — see
> `archreview/plans/INTEGRATION-SWEEP-STATUS.md`).** Each profile is a > `archreview/plans/INTEGRATION-SWEEP-STATUS.md`).** Each profile is a
@@ -78,6 +78,30 @@ services:
"--json_file", "/fixtures/s7_1500.json" "--json_file", "/fixtures/s7_1500.json"
] ]
# RTU-over-TCP profile. Same pymodbus simulator, but the server is framed
# RTU (framer=rtu) instead of socket/MBAP — this is what a serial→Ethernet
# gateway presents. Binds :5021 (NOT the shared :5020) so it co-runs with
# the `standard` profile: two families, two ports, no conflict. Serves
# rtu_over_tcp.json (byte-for-byte standard.json + framer/port swap).
rtu_over_tcp:
profiles: ["rtu_over_tcp"]
image: otopcua-pymodbus:3.13.0
build:
context: .
dockerfile: Dockerfile
container_name: otopcua-pymodbus-rtu_over_tcp
labels:
project: lmxopcua
restart: "no"
ports:
- "5021:5021"
command: [
"pymodbus.simulator",
"--modbus_server", "srv",
"--modbus_device", "dev",
"--json_file", "/fixtures/rtu_over_tcp.json"
]
# Exception-injection profile. Runs the standalone pure-stdlib Modbus/TCP # Exception-injection profile. Runs the standalone pure-stdlib Modbus/TCP
# server shipped as exception_injector.py instead of the pymodbus # server shipped as exception_injector.py instead of the pymodbus
# simulator — pymodbus naturally emits only exception codes 02 + 03, and # simulator — pymodbus naturally emits only exception codes 02 + 03, and
@@ -0,0 +1,97 @@
{
"_comment": "rtu_over_tcp.json — RTU-over-TCP Modbus server for the integration suite (RTU framing tunnelled over a socket, as a serial→Ethernet gateway presents it). Byte-for-byte standard.json EXCEPT the server block: framer=\"rtu\" (not \"socket\") + port 5021 (not 5020), so it co-runs with the standard profile on :5020. pymodbus 3.13's simulator honors framer=rtu on a TCP server — confirmed on the wire (controller spike, 2026-07-24): raw RTU FC03 read of HR[5] returned `01 03 02 00 05 78 47`, a pure RTU frame with no MBAP header. Layout is identical to standard.json: HR[0..31]=address-as-value, HR[100]=auto-increment, HR[200..209]=scratch, coils 1024..1055=alternating, coils 1100..1109=scratch. NOTE: pymodbus rejects unknown keys at device-list / setup level; explanatory comments live in the README + git history.",
"server_list": {
"srv": {
"comm": "tcp",
"host": "0.0.0.0",
"port": 5021,
"framer": "rtu",
"device_id": 1
}
},
"device_list": {
"dev": {
"setup": {
"co size": 2048,
"di size": 2048,
"hr size": 2048,
"ir size": 2048,
"shared blocks": true,
"type exception": false,
"defaults": {
"value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "},
"action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null}
}
},
"invalid": [],
"write": [
[0, 31],
[100, 100],
[200, 209],
[1024, 1055],
[1100, 1109]
],
"uint16": [
{"addr": 0, "value": 0}, {"addr": 1, "value": 1},
{"addr": 2, "value": 2}, {"addr": 3, "value": 3},
{"addr": 4, "value": 4}, {"addr": 5, "value": 5},
{"addr": 6, "value": 6}, {"addr": 7, "value": 7},
{"addr": 8, "value": 8}, {"addr": 9, "value": 9},
{"addr": 10, "value": 10}, {"addr": 11, "value": 11},
{"addr": 12, "value": 12}, {"addr": 13, "value": 13},
{"addr": 14, "value": 14}, {"addr": 15, "value": 15},
{"addr": 16, "value": 16}, {"addr": 17, "value": 17},
{"addr": 18, "value": 18}, {"addr": 19, "value": 19},
{"addr": 20, "value": 20}, {"addr": 21, "value": 21},
{"addr": 22, "value": 22}, {"addr": 23, "value": 23},
{"addr": 24, "value": 24}, {"addr": 25, "value": 25},
{"addr": 26, "value": 26}, {"addr": 27, "value": 27},
{"addr": 28, "value": 28}, {"addr": 29, "value": 29},
{"addr": 30, "value": 30}, {"addr": 31, "value": 31},
{"addr": 100, "value": 0,
"action": "increment",
"parameters": {"minval": 0, "maxval": 65535}},
{"addr": 200, "value": 0}, {"addr": 201, "value": 0},
{"addr": 202, "value": 0}, {"addr": 203, "value": 0},
{"addr": 204, "value": 0}, {"addr": 205, "value": 0},
{"addr": 206, "value": 0}, {"addr": 207, "value": 0},
{"addr": 208, "value": 0}, {"addr": 209, "value": 0}
],
"bits": [
{"addr": 1024, "value": 1}, {"addr": 1025, "value": 0},
{"addr": 1026, "value": 1}, {"addr": 1027, "value": 0},
{"addr": 1028, "value": 1}, {"addr": 1029, "value": 0},
{"addr": 1030, "value": 1}, {"addr": 1031, "value": 0},
{"addr": 1032, "value": 1}, {"addr": 1033, "value": 0},
{"addr": 1034, "value": 1}, {"addr": 1035, "value": 0},
{"addr": 1036, "value": 1}, {"addr": 1037, "value": 0},
{"addr": 1038, "value": 1}, {"addr": 1039, "value": 0},
{"addr": 1040, "value": 1}, {"addr": 1041, "value": 0},
{"addr": 1042, "value": 1}, {"addr": 1043, "value": 0},
{"addr": 1044, "value": 1}, {"addr": 1045, "value": 0},
{"addr": 1046, "value": 1}, {"addr": 1047, "value": 0},
{"addr": 1048, "value": 1}, {"addr": 1049, "value": 0},
{"addr": 1050, "value": 1}, {"addr": 1051, "value": 0},
{"addr": 1052, "value": 1}, {"addr": 1053, "value": 0},
{"addr": 1054, "value": 1}, {"addr": 1055, "value": 0},
{"addr": 1100, "value": 0}, {"addr": 1101, "value": 0},
{"addr": 1102, "value": 0}, {"addr": 1103, "value": 0},
{"addr": 1104, "value": 0}, {"addr": 1105, "value": 0},
{"addr": 1106, "value": 0}, {"addr": 1107, "value": 0},
{"addr": 1108, "value": 0}, {"addr": 1109, "value": 0}
],
"uint32": [],
"float32": [],
"string": [],
"repeat": []
}
}
}
@@ -0,0 +1,82 @@
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
/// <summary>
/// Reachability probe for the RTU-over-TCP Modbus simulator (pymodbus in Docker with
/// <c>framer=rtu</c>, see <c>Docker/docker-compose.yml</c> profile <c>rtu_over_tcp</c>) or a
/// real serial→Ethernet Modbus gateway. Mirrors <see cref="ModbusSimulatorFixture"/> but reads
/// <c>MODBUS_RTU_SIM_ENDPOINT</c> (default <c>10.100.0.35:5021</c> — the shared Docker host, a
/// distinct port from the standard profile's <c>:5020</c> so the two fixtures co-run). TCP-connects
/// once at fixture construction; each test checks <see cref="SkipReason"/> and calls
/// <c>Assert.Skip</c> when the endpoint was unreachable, so a dev box without a running simulator
/// still passes `dotnet test` cleanly.
/// </summary>
/// <remarks>
/// Same one-shot-probe discipline as <see cref="ModbusSimulatorFixture"/>: the probe socket is
/// not held for the life of the fixture (tests open their own <see cref="ModbusRtuOverTcpTransport"/>
/// against the same endpoint), and the fixture is a collection fixture so the probe runs once per
/// session rather than per test.
/// </remarks>
public sealed class ModbusRtuOverTcpFixture : IAsyncDisposable
{
// :5021 (not the standard profile's :5020) so the rtu_over_tcp container can co-run with the
// standard container on the shared Docker host. 10.100.0.35 = the shared Docker host (see
// CLAUDE.md "Docker Workflow"). Override with MODBUS_RTU_SIM_ENDPOINT to point at a real
// serial→Ethernet Modbus gateway, or a locally-running container.
private const string DefaultEndpoint = "10.100.0.35:5021";
private const string EndpointEnvVar = "MODBUS_RTU_SIM_ENDPOINT";
/// <summary>Gets the host address of the RTU-over-TCP Modbus simulator.</summary>
public string Host { get; }
/// <summary>Gets the port of the RTU-over-TCP Modbus simulator.</summary>
public int Port { get; }
/// <summary>Gets the skip reason if the simulator is unreachable; otherwise null.</summary>
public string? SkipReason { get; }
/// <summary>Initializes a new instance of the <see cref="ModbusRtuOverTcpFixture"/> class.</summary>
public ModbusRtuOverTcpFixture()
{
var raw = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint;
var parts = raw.Split(':', 2);
Host = parts[0];
Port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : 502;
try
{
// Force IPv4 on the probe — pymodbus binds 0.0.0.0 (IPv4 only) while .NET default-resolves
// "localhost" → IPv6 ::1 first and only then tries IPv4, surfacing as a 2s timeout under
// .NET 10. Resolving + dialing explicit IPv4 sidesteps the dual-stack ordering. (Same
// rationale as ModbusSimulatorFixture.)
using var client = new TcpClient(System.Net.Sockets.AddressFamily.InterNetwork);
var task = client.ConnectAsync(
System.Net.Dns.GetHostAddresses(Host)
.FirstOrDefault(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
?? System.Net.IPAddress.Loopback,
Port);
if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
{
SkipReason = $"RTU-over-TCP Modbus simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
$"Start the pymodbus Docker container (docker compose -f Docker/docker-compose.yml --profile rtu_over_tcp up -d --build) " +
$"or override {EndpointEnvVar}, then re-run.";
}
}
catch (Exception ex)
{
SkipReason = $"RTU-over-TCP Modbus simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
$"Start the pymodbus Docker container (docker compose -f Docker/docker-compose.yml --profile rtu_over_tcp up -d --build) " +
$"or override {EndpointEnvVar}, then re-run.";
}
}
/// <inheritdoc />
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Xunit.CollectionDefinition(Name)]
public sealed class ModbusRtuOverTcpCollection : Xunit.ICollectionFixture<ModbusRtuOverTcpFixture>
{
public const string Name = "ModbusRtuOverTcp";
}
@@ -0,0 +1,94 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
/// <summary>
/// End-to-end round-trip against the <c>rtu_over_tcp.json</c> pymodbus profile (or a real
/// serial→Ethernet Modbus gateway when <c>MODBUS_RTU_SIM_ENDPOINT</c> points at one), driving the
/// full <see cref="ModbusDriver"/> + real <see cref="ModbusRtuOverTcpTransport"/> stack with
/// <see cref="ModbusTransportMode.RtuOverTcp"/>. Proves the driver reads a seeded holding register
/// and writes+reads-back a scratch register when the wire carries RTU framing
/// (<c>[addr][PDU][CRC-16]</c>, no MBAP header) rather than Modbus/TCP.
/// </summary>
/// <remarks>
/// pymodbus 3.13's simulator honors <c>framer=rtu</c> on a TCP server — confirmed on the wire
/// (controller spike, 2026-07-24): a raw RTU FC03 read of HR[5] returned
/// <c>01 03 02 00 05 78 47</c>, a pure RTU frame with no MBAP header (data 0x0005 = 5). So the
/// fixture is the plain pymodbus simulator with framer/port swapped — no stdlib fallback server.
/// </remarks>
[Collection(ModbusRtuOverTcpCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "RtuOverTcp")]
public sealed class ModbusRtuOverTcpTests(ModbusRtuOverTcpFixture sim)
{
/// <summary>Reads a seeded holding register (HR[5]=5) over RTU-over-TCP framing.</summary>
[Fact]
public async Task Reads_a_seeded_holding_register_over_rtu_framing()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var opts = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Transport = ModbusTransportMode.RtuOverTcp,
Timeout = TimeSpan.FromSeconds(2),
Probe = new ModbusProbeOptions { Enabled = false },
RawTags = ModbusRawTags.Entries([
new ModbusTagDefinition(
Name: "HR5",
Region: ModbusRegion.HoldingRegisters,
Address: 5,
DataType: ModbusDataType.UInt16,
Writable: false),
]),
};
await using var driver = new ModbusDriver(opts, driverInstanceId: "modbus-rtu-int");
await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken);
results.Count.ShouldBe(1);
results[0].StatusCode.ShouldBe(0u); // Good
results[0].Value.ShouldBe((ushort)5); // HR[5] seeded = address-as-value
}
/// <summary>Writes then reads back a scratch holding register (HR[200]) over RTU-over-TCP framing.</summary>
[Fact]
public async Task Writes_and_reads_back_a_holding_register_over_rtu_framing()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var opts = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Transport = ModbusTransportMode.RtuOverTcp,
Timeout = TimeSpan.FromSeconds(2),
Probe = new ModbusProbeOptions { Enabled = false },
RawTags = ModbusRawTags.Entries([
new ModbusTagDefinition(
Name: "HR200",
Region: ModbusRegion.HoldingRegisters,
Address: 200,
DataType: ModbusDataType.UInt16,
Writable: true),
]),
};
await using var driver = new ModbusDriver(opts, driverInstanceId: "modbus-rtu-int-w");
await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
var writes = await driver.WriteAsync(
[new WriteRequest("HR200", (ushort)4242)],
TestContext.Current.CancellationToken);
writes.Count.ShouldBe(1);
writes[0].StatusCode.ShouldBe(0u);
var back = await driver.ReadAsync(["HR200"], TestContext.Current.CancellationToken);
back.Count.ShouldBe(1);
back[0].StatusCode.ShouldBe(0u);
back[0].Value.ShouldBe((ushort)4242);
}
}
@@ -0,0 +1,27 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusCrcTests
{
// Known CRC-16/MODBUS vectors (init 0xFFFF, poly 0xA001). CRC returned as ushort;
// on the wire it is appended low-byte-first.
[Theory]
// FC03 read HR: unit 1, addr 0, qty 1 -> CRC 0x0A84 (bytes 84 0A on the wire)
[InlineData(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 }, (ushort)0x0A84)]
// "123456789" canonical check value for CRC-16/MODBUS = 0x4B37
[InlineData(new byte[] { 0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39 }, (ushort)0x4B37)]
public void Compute_matches_known_vectors(byte[] frame, ushort expected)
=> ModbusCrc.Compute(frame).ShouldBe(expected);
[Fact]
public void AppendLowByteFirst_writes_lo_then_hi()
{
var body = new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 };
var withCrc = ModbusCrc.Append(body);
withCrc.Length.ShouldBe(body.Length + 2);
withCrc[^2].ShouldBe((byte)0x84); // CRC low byte
withCrc[^1].ShouldBe((byte)0x0A); // CRC high byte
}
}
@@ -14,7 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// (2) Sub-second <see cref="TimeSpan"/> values on <c>ModbusKeepAliveOptions.Time</c> / /// (2) Sub-second <see cref="TimeSpan"/> values on <c>ModbusKeepAliveOptions.Time</c> /
/// <c>Interval</c> — the int-cast in <c>EnableKeepAlive</c> truncated <c>500&#160;ms</c> to /// <c>Interval</c> — the int-cast in <c>EnableKeepAlive</c> truncated <c>500&#160;ms</c> to
/// <c>0</c>, which most OSes interpret as "use the default", silently defeating the /// <c>0</c>, which most OSes interpret as "use the default", silently defeating the
/// configured timing. <c>ModbusTcpTransport.ClampToWholeSeconds</c> rounds up to a minimum /// configured timing. <c>ModbusSocketLifecycle.ClampToWholeSeconds</c> rounds up to a minimum
/// of 1&#160;second. /// of 1&#160;second.
/// </summary> /// </summary>
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
@@ -70,7 +70,7 @@ public sealed class ModbusEdgeCaseValidationTests
[InlineData(60_000, 60)] [InlineData(60_000, 60)]
public void ClampToWholeSeconds_rounds_up_to_at_least_one_second(int ms, int expected) public void ClampToWholeSeconds_rounds_up_to_at_least_one_second(int ms, int expected)
{ {
ModbusTcpTransport.ClampToWholeSeconds(TimeSpan.FromMilliseconds(ms)).ShouldBe(expected); ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromMilliseconds(ms)).ShouldBe(expected);
} }
/// <summary>Verifies that negative time spans are treated as one second.</summary> /// <summary>Verifies that negative time spans are treated as one second.</summary>
@@ -80,6 +80,6 @@ public sealed class ModbusEdgeCaseValidationTests
// Defensive — operators occasionally configure a negative TimeSpan thinking it disables // Defensive — operators occasionally configure a negative TimeSpan thinking it disables
// the feature. The OS would reject the negative int — clamping to 1 keeps the socket // the feature. The OS would reject the negative int — clamping to 1 keeps the socket
// valid until the operator fixes the config. // valid until the operator fixes the config.
ModbusTcpTransport.ClampToWholeSeconds(TimeSpan.FromSeconds(-5)).ShouldBe(1); ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromSeconds(-5)).ShouldBe(1);
} }
} }
@@ -0,0 +1,134 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusRtuFramingTests
{
private static MemoryStream Canned(params byte[] frame) => new(frame);
[Fact]
public void BuildAdu_prefixes_unit_and_appends_crc()
{
var adu = ModbusRtuFraming.BuildAdu(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 });
adu.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A });
}
[Fact]
public async Task ReadResponse_FC03_returns_bare_pdu()
{
// addr=01 fc=03 byteCount=02 data=00 0A + CRC(lo,hi)
var body = new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A };
var frame = ModbusCrc.Append(body);
await using var s = Canned(frame);
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A }); // fc + byteCount + data, no addr/CRC
}
[Fact]
public async Task ReadResponse_exception_frame_throws_ModbusException()
{
// addr=01 fc=0x83 exc=0x02 + CRC
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 });
await using var s = Canned(frame);
var ex = await Should.ThrowAsync<ModbusException>(
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
ex.FunctionCode.ShouldBe((byte)0x03);
ex.ExceptionCode.ShouldBe((byte)0x02);
}
[Fact]
public async Task ReadResponse_bad_crc_throws_desync()
{
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
frame[^1] ^= 0xFF; // corrupt CRC high byte
await using var s = Canned(frame);
var ex = await Should.ThrowAsync<ModbusTransportDesyncException>(
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.CrcMismatch);
}
[Fact]
public async Task ReadResponse_FC06_write_echo_returns_four_byte_pdu()
{
// addr=01 fc=06 addr=00 07 value=00 2A + CRC -> PDU = 06 00 07 00 2A
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x06, 0x00, 0x07, 0x00, 0x2A });
await using var s = Canned(frame);
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
pdu.ShouldBe(new byte[] { 0x06, 0x00, 0x07, 0x00, 0x2A });
}
[Fact]
public async Task ReadResponse_wrong_unit_address_throws_desync()
{
// CRC-valid FC03 frame addressed to unit 0x02, but we asked for 0x01 -> unit-mismatch desync.
var frame = ModbusCrc.Append(new byte[] { 0x02, 0x03, 0x02, 0x00, 0x0A });
await using var s = Canned(frame);
var ex = await Should.ThrowAsync<ModbusTransportDesyncException>(
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.UnitMismatch);
}
[Fact]
public async Task ReadResponse_FC03_deframes_correctly_when_delivered_in_dribbles()
{
// Same valid FC03 frame, but the stream hands back only 1-2 bytes per ReadAsync — proves
// the ReadExactlyAsync accumulation loop reassembles a length-less frame across many reads.
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
await using var s = new DribbleStream(frame, chunk: 2);
var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A });
}
[Fact]
public async Task ReadResponse_stream_ends_early_throws_desync()
{
// Only the first 4 bytes of a 7-byte FC03 frame are available, then EOF (ReadAsync returns 0)
// -> the truncation path in ReadExactlyAsync must surface a desync, not hang or mis-parse.
var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
var truncated = frame[..4];
await using var s = new DribbleStream(truncated, chunk: 2);
var ex = await Should.ThrowAsync<ModbusTransportDesyncException>(
ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
ex.Reason.ShouldBe(ModbusTransportDesyncException.DesyncReason.TruncatedFrame);
}
/// <summary>
/// A read-only test double that dribbles its backing bytes out at most <c>chunk</c> at a time
/// per <see cref="ReadAsync(Memory{byte}, CancellationToken)"/> call, then returns 0 (EOF)
/// once exhausted. Exercises the partial-read accumulation loop and the truncation → desync
/// path that a single-shot <see cref="MemoryStream"/> never reaches.
/// </summary>
private sealed class DribbleStream(byte[] data, int chunk) : Stream
{
private int _pos;
public override int Read(byte[] buffer, int offset, int count)
{
var n = Math.Min(Math.Min(chunk, count), data.Length - _pos);
if (n <= 0) return 0;
Array.Copy(data, _pos, buffer, offset, n);
_pos += n;
return n;
}
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
var n = Math.Min(Math.Min(chunk, buffer.Length), data.Length - _pos);
if (n <= 0) return ValueTask.FromResult(0);
data.AsSpan(_pos, n).CopyTo(buffer.Span);
_pos += n;
return ValueTask.FromResult(n);
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => data.Length;
public override long Position { get => _pos; set => throw new NotSupportedException(); }
public override void Flush() { }
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
}
@@ -0,0 +1,110 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Exercises <see cref="ModbusRtuOverTcpTransport"/>'s send/receive orchestration, single-flight,
/// and per-op deadline against an in-memory duplex fake injected through the <c>ForTest</c> seam —
/// no real socket. The fake records the request ADU the transport writes and replays a canned RTU
/// response on read, or blocks forever (honouring cancellation) when <c>stall</c> is set.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusRtuOverTcpTransportTests
{
[Fact]
public async Task SendAsync_writes_rtu_adu_and_returns_deframed_pdu()
{
// Response the fake gateway will emit: FC03, 1 reg = 0x000A.
var response = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
var fake = new CapturingDuplexStream(response);
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromSeconds(2));
var pdu = await transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
TestContext.Current.CancellationToken);
pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A });
// The request went out as an RTU ADU: unit + pdu + CRC, NO MBAP header.
fake.Written.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A });
}
[Fact]
public async Task SendAsync_exception_pdu_surfaces_ModbusException()
{
var response = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 });
var fake = new CapturingDuplexStream(response);
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromSeconds(2));
await Should.ThrowAsync<ModbusException>(
transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
TestContext.Current.CancellationToken));
}
[Fact]
public async Task SendAsync_stalled_gateway_hits_per_op_deadline()
{
var fake = new CapturingDuplexStream(respondBytes: Array.Empty<byte>(), stall: true);
await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromMilliseconds(200));
await Should.ThrowAsync<ModbusTransportDesyncException>(
transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
TestContext.Current.CancellationToken));
}
/// <summary>
/// In-memory duplex <see cref="Stream"/> test double: records everything written and replays
/// <c>respondBytes</c> on read. When <c>stall</c> is set the read blocks until its cancellation
/// token fires (simulating a frozen gateway) so the transport's per-op deadline is exercised.
/// </summary>
private sealed class CapturingDuplexStream : Stream
{
private readonly byte[] _response;
private readonly bool _stall;
private readonly MemoryStream _written = new();
private int _readPos;
public CapturingDuplexStream(byte[] respondBytes, bool stall = false)
{
_response = respondBytes;
_stall = stall;
}
/// <summary>Gets the bytes the transport wrote to this stream (the request ADU).</summary>
public byte[] Written => _written.ToArray();
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => true;
public override long Length => throw new NotSupportedException();
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct = default)
{
if (_stall)
{
// Frozen gateway: never answer. Block until the per-op deadline (or caller) cancels.
var tcs = new TaskCompletionSource();
await using (ct.Register(() => tcs.TrySetCanceled(ct)))
await tcs.Task.ConfigureAwait(false);
return 0; // unreachable — the await above always throws when cancelled
}
var remaining = _response.Length - _readPos;
if (remaining <= 0) return 0;
var n = Math.Min(remaining, buffer.Length);
_response.AsSpan(_readPos, n).CopyTo(buffer.Span);
_readPos += n;
return n;
}
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken ct = default)
{
await _written.WriteAsync(buffer, ct).ConfigureAwait(false);
}
public override Task FlushAsync(CancellationToken ct) => Task.CompletedTask;
public override void Flush() { }
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
}
}
@@ -0,0 +1,33 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Pins the socket-lifecycle surface extracted from <see cref="ModbusTcpTransport"/> into the
/// reusable <see cref="ModbusSocketLifecycle"/> (Task 3). The clamp helper moved with it, and a
/// connect to a dead port must still surface the underlying socket failure.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusSocketLifecycleTests
{
/// <summary>Verifies the whole-seconds clamp rounds sub-second/negative durations up to a minimum of one.</summary>
/// <param name="seconds">The input duration in seconds.</param>
/// <param name="expected">The expected clamped value in whole seconds.</param>
[Theory]
[InlineData(0.4, 1)] // sub-second rounds up to 1 (the int-cast truncation guard)
[InlineData(2.0, 2)]
[InlineData(-5.0, 1)] // negative clamps to 1
public void ClampToWholeSeconds_rounds_up_min_one(double seconds, int expected)
=> ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromSeconds(seconds)).ShouldBe(expected);
/// <summary>Verifies a connect to a dead port surfaces the underlying socket failure.</summary>
[Fact]
public async Task Connect_to_dead_port_surfaces_socket_failure()
{
var life = new ModbusSocketLifecycle("127.0.0.1", 1, TimeSpan.FromMilliseconds(300),
autoReconnect: false);
await Should.ThrowAsync<System.Net.Sockets.SocketException>(
life.ConnectAsync(TestContext.Current.CancellationToken));
}
}
@@ -0,0 +1,47 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusTransportConfigRoundTripTests
{
// Mirrors the AdminUI ModbusDriverForm serializer: camelCase + string enums.
private static readonly JsonSerializerOptions _adminOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
};
[Fact]
public void Transport_serializes_as_a_string_not_a_number()
{
var opts = new ModbusDriverOptions { Host = "10.20.0.50", Port = 4001, Transport = ModbusTransportMode.RtuOverTcp };
var json = JsonSerializer.Serialize(opts, _adminOpts);
json.ShouldContain("\"transport\":\"RtuOverTcp\"");
json.ShouldNotContain("\"transport\":1", Case.Sensitive);
}
[Fact]
public void Factory_binds_RtuOverTcp_from_string_config()
{
const string json = """{ "host": "10.20.0.50", "port": 4001, "transport": "RtuOverTcp" }""";
using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-rtu", json);
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(driver)!;
opts.Transport.ShouldBe(ModbusTransportMode.RtuOverTcp);
}
[Fact]
public void Factory_defaults_Transport_to_Tcp_when_omitted()
{
const string json = """{ "host": "10.0.0.10" }""";
using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-tcp", json);
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(driver)!;
opts.Transport.ShouldBe(ModbusTransportMode.Tcp);
}
}
@@ -0,0 +1,27 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusTransportFactoryTests
{
[Fact]
public void Tcp_mode_builds_tcp_transport()
=> ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.Tcp })
.ShouldBeOfType<ModbusTcpTransport>();
[Fact]
public void RtuOverTcp_mode_builds_rtu_transport()
=> ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp })
.ShouldBeOfType<ModbusRtuOverTcpTransport>();
[Fact]
public void Default_options_build_tcp_transport()
=> ModbusTransportFactory.Create(new ModbusDriverOptions())
.ShouldBeOfType<ModbusTcpTransport>();
[Fact]
public void Unknown_transport_mode_throws()
=> Should.Throw<ArgumentOutOfRangeException>(
() => ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = (ModbusTransportMode)999 }));
}
@@ -0,0 +1,15 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
public sealed class ModbusTransportModeTests
{
[Fact]
public void Tcp_is_the_default_zero_member()
=> ((ModbusTransportMode)0).ShouldBe(ModbusTransportMode.Tcp);
[Fact]
public void Exactly_two_members_ship_in_v1()
=> Enum.GetNames<ModbusTransportMode>().ShouldBe(["Tcp", "RtuOverTcp"]);
}
@@ -0,0 +1,33 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Confirms the driver's default transport-factory closure — and, by extension, the
/// probe's transport construction — routes through <see cref="ModbusTransportFactory"/>
/// instead of hardcoding <see cref="ModbusTcpTransport"/>, so an <c>RtuOverTcp</c>-authored
/// config actually gets RTU framing rather than silently running as TCP/MBAP.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusTransportWiringTests
{
private static IModbusTransport BuildDefaultTransport(ModbusDriverOptions opts)
{
using var driver = new ModbusDriver(opts, "wire-test");
var factory = (Func<ModbusDriverOptions, IModbusTransport>)typeof(ModbusDriver)
.GetField("_transportFactory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.GetValue(driver)!;
return factory(opts);
}
[Fact]
public void Default_closure_builds_rtu_transport_for_RtuOverTcp()
=> BuildDefaultTransport(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp })
.ShouldBeOfType<ModbusRtuOverTcpTransport>();
[Fact]
public void Default_closure_builds_tcp_transport_for_Tcp()
=> BuildDefaultTransport(new ModbusDriverOptions())
.ShouldBeOfType<ModbusTcpTransport>();
}
@@ -0,0 +1,95 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
/// <summary>
/// #504 — <see cref="DisplayText.Abbreviate"/>, the null/short-safe replacement for the
/// <c>@value[..N]</c> range-operator slices the Admin UI used to abbreviate hashes and thumbprints.
/// </summary>
/// <remarks>
/// The bug being pinned: a range operator over an unvalidated DB string throws
/// <see cref="System.ArgumentOutOfRangeException"/> out of <c>BuildRenderTree</c>, which is unhandled
/// in Blazor and takes the WHOLE page to an HTTP 500 — not just the offending row. Nothing enforces a
/// minimum length at the schema, entity, or render layer, so the short-input cases below are the ones
/// that matter; the happy path is almost incidental.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class DisplayTextTests
{
/// <summary>A value longer than the budget is truncated and marked with an ellipsis — the normal
/// case for a 64-char SHA-256.</summary>
[Fact]
public void A_long_value_is_truncated_and_marked()
{
DisplayText.Abbreviate(new string('a', 64), 12).ShouldBe(new string('a', 12) + "…");
}
/// <summary>The regression: a value SHORTER than the budget must render, not throw. This is exactly
/// what a hand-inserted / API-authored <c>SourceHash</c> like <c>h1</c> hits.</summary>
[Theory]
[InlineData("h1", 12)]
[InlineData("h-abs-hr200", 12)] // 11 chars — one short of the old slice, the original crash
[InlineData("abc", 16)]
[InlineData("x", 64)]
public void A_value_shorter_than_the_budget_renders_verbatim(string value, int maxLength)
{
DisplayText.Abbreviate(value, maxLength).ShouldBe(value);
}
/// <summary>No ellipsis when nothing was dropped — the display must never imply text that is not
/// there. (The old markup appended "…" unconditionally, outside the slice.)</summary>
[Fact]
public void A_short_value_gets_no_ellipsis()
{
DisplayText.Abbreviate("h1", 12).ShouldNotContain("…");
}
/// <summary>A value exactly at the budget is the boundary — it fits, so it is neither truncated nor
/// marked.</summary>
[Fact]
public void A_value_exactly_at_the_budget_is_untouched()
{
DisplayText.Abbreviate("123456789012", 12).ShouldBe("123456789012");
}
/// <summary>Null / blank render as the Admin UI's em-dash placeholder rather than throwing or
/// producing a stray ellipsis.</summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void A_null_or_blank_value_renders_the_placeholder(string? value)
{
DisplayText.Abbreviate(value, 12).ShouldBe(DisplayText.Empty);
}
/// <summary>A nonsensical budget is clamped rather than allowed to throw — a display bug must not
/// become a page crash, which is the whole point of this helper.</summary>
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(int.MinValue)]
public void A_non_positive_budget_is_clamped_not_thrown(int maxLength)
{
DisplayText.Abbreviate("abcdef", maxLength).ShouldBe("a…");
}
/// <summary>Whatever the inputs, it never throws — the property that keeps a bad row from killing a
/// page. Includes the shapes that broke the old code.</summary>
[Fact]
public void It_never_throws_for_any_combination()
{
string?[] values = [null, "", " ", "h1", "h-abs-hr200", new string('f', 64), "…"];
int[] budgets = [int.MinValue, -1, 0, 1, 12, 16, 64, int.MaxValue];
foreach (var value in values)
{
foreach (var budget in budgets)
{
Should.NotThrow(() => DisplayText.Abbreviate(value, budget));
}
}
}
}
@@ -13,14 +13,21 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
/// pass to <c>ctx.GetTag("…")</c> / <c>ctx.SetVirtualTag("…")</c>. /// pass to <c>ctx.GetTag("…")</c> / <c>ctx.SetVirtualTag("…")</c>.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Fidelity finding (see <see cref="ScriptTagCatalog"/> doc comment): the runtime Akka path resolves /// <para>
/// a <c>ctx.GetTag</c> literal against the driver <b>FullName</b> (the wire reference in /// Fidelity finding (see the <see cref="ScriptTagCatalog"/> doc comment): the runtime resolves a
/// <c>Tag.TagConfig.FullName</c>), NOT the UNS browse path — the UNS-path engine is dormant. v3: Tag is /// <c>ctx.GetTag</c> literal through <c>DependencyMuxActor._byRef</c>, keyed Ordinal by the driver's
/// Raw-only (no <c>EquipmentId</c>/<c>FolderPath</c>/<c>DriverInstanceId</c>), so the catalog projects /// wire reference — and in v3 that wire reference <b>is the RawPath</b>
/// the surviving <c>Name</c>/<c>DataType</c>/<c>TagConfig</c> columns: the resolvable path derives from /// (<c>Folder/…/Driver/Device/[TagGroup/…]/Tag</c>). So the catalog projects RawPaths for raw tags and
/// the <c>TagConfig.FullName</c> field when present, else the tag <c>Name</c>; the projected /// the leaf <c>Name</c> for virtual tags.
/// <c>DriverInstanceId</c> is always <see langword="null"/>. Virtual tags emit their leaf Name. These /// </para>
/// assertions check the resolvable key is present AND that the UNS browse paths are absent. /// <para>
/// These assertions were rewritten for #490. They previously pinned the pre-v3 projection —
/// <c>Tag.TagConfig.FullName</c> — which since v3 is not the mux key at all, so they were encoding a
/// contract the runtime had stopped honouring: completion offered paths that could never resolve, and a
/// correct absolute RawPath resolved to nothing. The seed now builds a real raw topology
/// (RawFolder → DriverInstance → Device → TagGroup → Tag) because a RawPath only exists if that chain
/// does.
/// </para>
/// </remarks> /// </remarks>
[Trait("Category", "Unit")] [Trait("Category", "Unit")]
public sealed class ScriptTagCatalogTests public sealed class ScriptTagCatalogTests
@@ -43,8 +50,9 @@ public sealed class ScriptTagCatalogTests
} }
/// <summary> /// <summary>
/// Seeds an Area → Line → Equipment path with: one equipment driver tag (FullName "Motor.Speed"), /// Seeds the UNS path (Area → Line → Equipment) AND the raw topology a RawPath is built from:
/// one virtual tag, and one FolderPath-scoped tag (EquipmentId null, FolderPath set). /// RawFolder "Plant" → DriverInstance "Modbus" → Device "dev1", with one tag directly under the
/// device and one under a nested TagGroup. A virtual tag is added for the leaf-Name projection.
/// </summary> /// </summary>
private static void Seed(DbContextOptions<OtOpcUaConfigDbContext> opts) private static void Seed(DbContextOptions<OtOpcUaConfigDbContext> opts)
{ {
@@ -61,26 +69,38 @@ public sealed class ScriptTagCatalogTests
MachineCode = "machine_001", MachineCode = "machine_001",
}); });
// Raw tag — the GetTag key is the driver FullName from TagConfig. // Raw topology — the ancestry a RawPath is composed from.
db.RawFolders.Add(new RawFolder { RawFolderId = "RF-1", ClusterId = "MAIN", ParentRawFolderId = null, Name = "Plant" });
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-1", ClusterId = "MAIN", RawFolderId = "RF-1",
Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}",
});
db.Devices.Add(new Device { DeviceId = "DEV-1", DriverInstanceId = "DRV-1", Name = "dev1", DeviceConfig = "{}" });
db.TagGroups.Add(new TagGroup { TagGroupId = "TG-1", DeviceId = "DEV-1", ParentTagGroupId = null, Name = "Motors" });
// Directly under the device -> Plant/Modbus/dev1/Speed
db.Tags.Add(new Tag db.Tags.Add(new Tag
{ {
TagId = "TAG-EQ", TagId = "TAG-EQ",
DeviceId = "DEV-1", DeviceId = "DEV-1",
TagGroupId = null,
Name = "Speed", Name = "Speed",
DataType = "Float", DataType = "Float",
AccessLevel = TagAccessLevel.Read, AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"FullName\":\"Motor.Speed\"}", TagConfig = "{\"FullName\":\"Motor.Speed\"}",
}); });
// Raw tag whose FullName is an MXAccess dot-ref; resolves by that FullName. // Under a tag group -> Plant/Modbus/dev1/Motors/Torque
db.Tags.Add(new Tag db.Tags.Add(new Tag
{ {
TagId = "TAG-SP", TagId = "TAG-GRP",
DeviceId = "DEV-1", DeviceId = "DEV-1",
Name = "DownloadPath", TagGroupId = "TG-1",
DataType = "String", Name = "Torque",
DataType = "Double",
AccessLevel = TagAccessLevel.Read, AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"FullName\":\"DelmiaReceiver_001.DownloadPath\"}", TagConfig = "{\"FullName\":\"Motor.Torque\"}",
}); });
db.VirtualTags.Add(new VirtualTag db.VirtualTags.Add(new VirtualTag
@@ -95,30 +115,27 @@ public sealed class ScriptTagCatalogTests
db.SaveChanges(); db.SaveChanges();
} }
/// <summary>A null filter returns only the resolvable keys: the driver FullName for the equipment /// <summary>The v3 resolvable keys: a RawPath per raw tag (device-level and group-nested), plus the
/// tag, the MXAccess dot-ref for the FolderPath-scoped tag, and the virtual tag's leaf Name — and /// virtual tag's leaf Name — and NOT the pre-v3 <c>TagConfig.FullName</c>, which is no longer the mux
/// NONE of the UNS browse paths.</summary> /// key and would suggest a path that cannot resolve at runtime (#490).</summary>
[Fact] [Fact]
public async Task GetPaths_no_filter_returns_resolvable_keys_only() public async Task GetPaths_no_filter_returns_rawpaths_only()
{ {
var (catalog, opts) = Fresh(); var (catalog, opts) = Fresh();
Seed(opts); Seed(opts);
var paths = await catalog.GetPathsAsync(null, default); var paths = await catalog.GetPathsAsync(null, default);
// Equipment driver tag: the authoritative GetTag key (driver FullName). paths.ShouldContain("Plant/Modbus/dev1/Speed"); // directly under the device
paths.ShouldContain("Motor.Speed"); paths.ShouldContain("Plant/Modbus/dev1/Motors/Torque"); // nested under a tag group
paths.ShouldContain("Computed"); // virtual tag: leaf Name
// FolderPath-scoped tag: MXAccess dot-ref. // The pre-v3 wire address is NOT a resolvable key in v3.
paths.ShouldContain("DelmiaReceiver_001.DownloadPath"); paths.ShouldNotContain("Motor.Speed");
paths.ShouldNotContain("Motor.Torque");
// Virtual tag: leaf Name only. // UNS browse paths are intentionally NOT suggested (that engine is dormant).
paths.ShouldContain("Computed");
// The UNS browse paths are intentionally NOT suggested (the UNS-path engine is dormant).
paths.ShouldNotContain("Assembly/LineA/Machine1/Speed"); paths.ShouldNotContain("Assembly/LineA/Machine1/Speed");
paths.ShouldNotContain("DelmiaReceiver_001/DownloadPath");
paths.ShouldNotContain("Assembly/LineA/Machine1/Computed");
} }
/// <summary>The result is distinct.</summary> /// <summary>The result is distinct.</summary>
@@ -133,37 +150,66 @@ public sealed class ScriptTagCatalogTests
paths.Distinct(StringComparer.OrdinalIgnoreCase).Count().ShouldBe(paths.Count); paths.Distinct(StringComparer.OrdinalIgnoreCase).Count().ShouldBe(paths.Count);
} }
/// <summary>A literal prefix narrows the result (case-insensitive StartsWith) to matching keys.</summary> /// <summary>A literal prefix narrows the result (case-insensitive StartsWith). This is the completion
/// path an author actually hits: typing a partial absolute RawPath inside ctx.GetTag("…").</summary>
[Fact] [Fact]
public async Task GetPaths_prefix_filter_narrows() public async Task GetPaths_prefix_filter_narrows_on_a_partial_rawpath()
{ {
var (catalog, opts) = Fresh(); var (catalog, opts) = Fresh();
Seed(opts); Seed(opts);
var paths = await catalog.GetPathsAsync("Motor", default); var paths = await catalog.GetPathsAsync("Plant/Modbus/dev1/", default);
paths.ShouldContain("Motor.Speed"); paths.ShouldContain("Plant/Modbus/dev1/Speed");
paths.ShouldNotContain("DelmiaReceiver_001.DownloadPath"); paths.ShouldContain("Plant/Modbus/dev1/Motors/Torque");
paths.ShouldNotContain("Computed"); paths.ShouldNotContain("Computed");
paths.ShouldAllBe(p => p.StartsWith("Motor", StringComparison.OrdinalIgnoreCase)); paths.ShouldAllBe(p => p.StartsWith("Plant/Modbus/dev1/", StringComparison.OrdinalIgnoreCase));
} }
/// <summary>The prefix match is case-insensitive.</summary> /// <summary>The prefix match is case-insensitive (completion is forgiving; the exact lookup is not).</summary>
[Fact] [Fact]
public async Task GetPaths_prefix_filter_is_case_insensitive() public async Task GetPaths_prefix_filter_is_case_insensitive()
{ {
var (catalog, opts) = Fresh(); var (catalog, opts) = Fresh();
Seed(opts); Seed(opts);
var paths = await catalog.GetPathsAsync("motor", default); (await catalog.GetPathsAsync("plant/modbus", default)).ShouldContain("Plant/Modbus/dev1/Speed");
paths.ShouldContain("Motor.Speed");
} }
/// <summary>A tag whose <c>TagConfig</c> is malformed JSON must not throw — the catalog falls back /// <summary>A tag whose ancestry chain is broken (device row missing) has no RawPath. The runtime could
/// to the raw blob and still returns every other tag.</summary> /// not route it either, so it is omitted rather than guessed — and it must not throw or suppress the
/// tags that DO resolve.</summary>
[Fact] [Fact]
public async Task GetPaths_malformed_tagconfig_does_not_throw() public async Task GetPaths_tag_with_a_broken_ancestry_chain_is_omitted()
{
var (catalog, opts) = Fresh();
Seed(opts);
using (var db = new OtOpcUaConfigDbContext(opts))
{
db.Tags.Add(new Tag
{
TagId = "TAG-ORPHAN",
DeviceId = "DEV-DOES-NOT-EXIST",
Name = "Orphan",
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
});
db.SaveChanges();
}
IReadOnlyList<string> paths = [];
await Should.NotThrowAsync(async () => paths = await catalog.GetPathsAsync(null, default));
paths.ShouldNotContain("Orphan");
paths.ShouldContain("Plant/Modbus/dev1/Speed");
}
/// <summary>A malformed <c>TagConfig</c> is irrelevant to the RawPath (which comes from the topology,
/// not the blob) — it must neither throw nor drop the tag.</summary>
[Fact]
public async Task GetPaths_malformed_tagconfig_does_not_affect_the_rawpath()
{ {
var (catalog, opts) = Fresh(); var (catalog, opts) = Fresh();
Seed(opts); Seed(opts);
@@ -185,37 +231,33 @@ public sealed class ScriptTagCatalogTests
IReadOnlyList<string> paths = []; IReadOnlyList<string> paths = [];
await Should.NotThrowAsync(async () => paths = await catalog.GetPathsAsync(null, default)); await Should.NotThrowAsync(async () => paths = await catalog.GetPathsAsync(null, default));
// The malformed tag falls through to the raw blob (acceptable), and the other tags still appear. paths.ShouldContain("Plant/Modbus/dev1/Broken");
paths.ShouldContain("Motor.Speed"); paths.ShouldContain("Plant/Modbus/dev1/Speed");
paths.ShouldContain("DelmiaReceiver_001.DownloadPath");
paths.ShouldContain("Computed");
} }
/// <summary>A FolderPath-scoped tag with a null/empty <c>FolderPath</c> yields just its <c>Name</c> /// <summary>A driver at the cluster root (no RawFolder) contributes no folder segment.</summary>
/// (no leading dot).</summary>
[Fact] [Fact]
public async Task GetPaths_unbound_tag_without_folder_yields_name_only() public async Task GetPaths_driver_at_cluster_root_has_no_folder_segment()
{ {
var (catalog, opts) = Fresh(); var (catalog, opts) = Fresh();
using (var db = new OtOpcUaConfigDbContext(opts)) using (var db = new OtOpcUaConfigDbContext(opts))
{ {
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-ROOT", ClusterId = "MAIN", RawFolderId = null,
Name = "RootDrv", DriverType = "Modbus", DriverConfig = "{}",
});
db.Devices.Add(new Device { DeviceId = "DEV-ROOT", DriverInstanceId = "DRV-ROOT", Name = "d0", DeviceConfig = "{}" });
db.Tags.Add(new Tag db.Tags.Add(new Tag
{ {
TagId = "TAG-SP-ROOT", TagId = "TAG-ROOT", DeviceId = "DEV-ROOT", Name = "Scalar",
DeviceId = "DEV-1", DataType = "String", AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
Name = "RootScalar",
DataType = "String",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"FullName\":\"RootScalar\"}",
}); });
db.SaveChanges(); db.SaveChanges();
} }
var paths = await catalog.GetPathsAsync(null, default); (await catalog.GetPathsAsync(null, default)).ShouldContain("RootDrv/d0/Scalar");
paths.ShouldContain("RootScalar");
paths.ShouldNotContain(".RootScalar");
} }
/// <summary>An empty database yields an empty list rather than throwing.</summary> /// <summary>An empty database yields an empty list rather than throwing.</summary>
@@ -224,26 +266,24 @@ public sealed class ScriptTagCatalogTests
{ {
var (catalog, _) = Fresh(); var (catalog, _) = Fresh();
var paths = await catalog.GetPathsAsync(null, default); (await catalog.GetPathsAsync(null, default)).ShouldBeEmpty();
paths.ShouldBeEmpty();
} }
/// <summary>A raw tag whose FullName is a dot-ref resolves by that FullName to a Tag-kind info with /// <summary>Hover: an absolute RawPath resolves to Tag-kind info carrying the configured DataType and —
/// the configured DataType. v3: Tag no longer binds a driver, so the projected DriverInstanceId is null.</summary> /// new in #490 — the owning driver instance, which the topology now makes available.</summary>
[Fact] [Fact]
public async Task GetTagInfo_unbound_tag_returns_tag_info() public async Task GetTagInfo_absolute_rawpath_returns_tag_info_with_driver()
{ {
var (catalog, opts) = Fresh(); var (catalog, opts) = Fresh();
Seed(opts); Seed(opts);
var info = await catalog.GetTagInfoAsync("DelmiaReceiver_001.DownloadPath", default); var info = await catalog.GetTagInfoAsync("Plant/Modbus/dev1/Motors/Torque", default);
info.ShouldNotBeNull(); info.ShouldNotBeNull();
info!.Path.ShouldBe("DelmiaReceiver_001.DownloadPath"); info!.Path.ShouldBe("Plant/Modbus/dev1/Motors/Torque");
info.Kind.ShouldBe("Tag"); info.Kind.ShouldBe("Tag");
info.DataType.ShouldBe("String"); info.DataType.ShouldBe("Double");
info.DriverInstanceId.ShouldBeNull(); info.DriverInstanceId.ShouldBe("DRV-1");
} }
/// <summary>A virtual tag resolves by its leaf Name to a "Virtual tag"-kind info with no driver.</summary> /// <summary>A virtual tag resolves by its leaf Name to a "Virtual tag"-kind info with no driver.</summary>
@@ -256,12 +296,22 @@ public sealed class ScriptTagCatalogTests
var info = await catalog.GetTagInfoAsync("Computed", default); var info = await catalog.GetTagInfoAsync("Computed", default);
info.ShouldNotBeNull(); info.ShouldNotBeNull();
info!.Path.ShouldBe("Computed"); info!.Kind.ShouldBe("Virtual tag");
info.Kind.ShouldBe("Virtual tag");
info.DataType.ShouldBe("Double"); info.DataType.ShouldBe("Double");
info.DriverInstanceId.ShouldBeNull(); info.DriverInstanceId.ShouldBeNull();
} }
/// <summary>The pre-v3 wire address no longer resolves — the regression #490 is really about. Hovering
/// one must report "not a known path" rather than validating a string the runtime would drop.</summary>
[Fact]
public async Task GetTagInfo_pre_v3_fullname_no_longer_resolves()
{
var (catalog, opts) = Fresh();
Seed(opts);
(await catalog.GetTagInfoAsync("Motor.Speed", default)).ShouldBeNull();
}
/// <summary>An unknown path resolves to null.</summary> /// <summary>An unknown path resolves to null.</summary>
[Fact] [Fact]
public async Task GetTagInfo_unknown_path_returns_null() public async Task GetTagInfo_unknown_path_returns_null()
@@ -269,19 +319,20 @@ public sealed class ScriptTagCatalogTests
var (catalog, opts) = Fresh(); var (catalog, opts) = Fresh();
Seed(opts); Seed(opts);
(await catalog.GetTagInfoAsync("NoSuchPath", default)).ShouldBeNull(); (await catalog.GetTagInfoAsync("Plant/Modbus/dev1/NoSuchTag", default)).ShouldBeNull();
} }
/// <summary>The lookup is case-SENSITIVE (Ordinal): a path that differs only in case does NOT /// <summary>The lookup is case-SENSITIVE (Ordinal), mirroring the runtime DependencyMuxActor's
/// resolve, mirroring the runtime DependencyMuxActor's StringComparer.Ordinal keying.</summary> /// StringComparer.Ordinal keying — a case-mismatched path would not resolve live, so hover must not
/// claim it does.</summary>
[Fact] [Fact]
public async Task GetTagInfo_case_mismatch_returns_null_ordinal() public async Task GetTagInfo_case_mismatch_returns_null_ordinal()
{ {
var (catalog, opts) = Fresh(); var (catalog, opts) = Fresh();
Seed(opts); Seed(opts);
(await catalog.GetTagInfoAsync("motor.speed", default)).ShouldBeNull(); (await catalog.GetTagInfoAsync("plant/modbus/dev1/speed", default)).ShouldBeNull();
(await catalog.GetTagInfoAsync("Motor.Speed", default)).ShouldNotBeNull(); (await catalog.GetTagInfoAsync("Plant/Modbus/dev1/Speed", default)).ShouldNotBeNull();
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -61,4 +61,15 @@ public sealed class ModbusDriverFormModelTests
json.ShouldContain("\"family\":\"MELSEC\""); // enum-as-name (not a number), camelCase key json.ShouldContain("\"family\":\"MELSEC\""); // enum-as-name (not a number), camelCase key
json.ShouldNotContain("\"family\":0", Case.Sensitive); // never the numeric enum form json.ShouldNotContain("\"family\":0", Case.Sensitive); // never the numeric enum form
} }
[Fact]
public void Round_trip_preserves_Transport_mode()
{
var form = new ModbusDriverForm.FormModel { Transport = ModbusTransportMode.RtuOverTcp };
var json = JsonSerializer.Serialize(form.ToOptions(), JsonOpts);
var back = ModbusDriverForm.FormModel.FromOptions(
JsonSerializer.Deserialize<ModbusDriverOptions>(json, JsonOpts)!);
back.Transport.ShouldBe(ModbusTransportMode.RtuOverTcp);
json.ShouldContain("\"transport\":\"RtuOverTcp\""); // name string, never a number
}
} }
@@ -86,8 +86,19 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("Plant/gw/dev1/does-not-exist", "cmt", "alice")); actor.Tell(new DriverHostActor.RouteNativeAlarmAck("Plant/gw/dev1/does-not-exist", "cmt", "alice"));
// Give the (fire-and-forget) handler time to run; the unmapped node must produce no ack. // POSITIVE CONTROL, not a sleep (#501). `Acks` is empty at t=0, so any assertion made before the
AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800)); // host has processed the message above passes without testing anything — and AwaitAssert returns on
// its FIRST success, so it spends none of its duration and is the wrong tool for an absence.
//
// Instead send a MAPPED ack afterwards. The host processes its mailbox in order and forwards to the
// child via Tell, and the child handles RouteAlarmAck with ReceiveAsync (which suspends its own
// mailbox), so by the time this one reaches the driver the unmapped one has provably already been
// handled. Whatever `Acks` holds then is the complete answer.
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "control", "alice"));
AwaitAssert(() => recorder.Acks.ShouldContain(a => a.Comment == "control"), duration: Timeout);
recorder.Acks.Count.ShouldBe(1,
"only the control ack may reach the driver — the unmapped condition NodeId must have been dropped");
} }
/// <summary>On a SECONDARY node the ack is gated off (same primary gate as the inbound write path): the /// <summary>On a SECONDARY node the ack is gated off (same primary gate as the inbound write path): the
@@ -110,10 +121,25 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
}, },
CorrelationId.NewId())); CorrelationId.NewId()));
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "cmt", "alice")); actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "gated", "alice"));
// No ack reached the driver — the gate short-circuited before the inverse-map lookup. // POSITIVE CONTROL (#501) — same reasoning as the unmapped-node test, with the role itself as the
AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800)); // control: return the node to PRIMARY and re-send the SAME mapped ack. Mailbox ordering means that
// once this one reaches the driver, the Secondary-gated one has already been processed. The
// discriminator is the comment, so a leaked ack is visible rather than merged into the count.
actor.Tell(new RedundancyStateChanged(
new[]
{
new NodeRedundancyState(TestNode, RedundancyRole.Primary,
IsClusterLeader: true, IsDriverPrimary: true, AsOfUtc: DateTime.UtcNow),
},
CorrelationId.NewId()));
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "control", "alice"));
AwaitAssert(() => recorder.Acks.ShouldContain(a => a.Comment == "control"), duration: Timeout);
recorder.Acks.ShouldNotContain(a => a.Comment == "gated",
"the Primary gate must have dropped the ack sent while this node was Secondary");
recorder.Acks.Count.ShouldBe(1);
} }
/// <summary>Spawns the host with the recording alarm-driver factory, dispatches the deployment, and waits /// <summary>Spawns the host with the recording alarm-driver factory, dispatches the deployment, and waits
@@ -0,0 +1,122 @@
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Gitea #488 — the periodic desired-vs-actual subscription reconcile that rides
/// <see cref="DriverInstanceActor"/>'s existing <c>health-poll</c> tick.
/// </summary>
/// <remarks>
/// <para>
/// The hole it closes: the desired ref set is re-applied on a <b>Connected transition</b> and on a
/// deploy's <c>SetDesiredSubscriptions</c>, and nowhere else. A subscription lost while the driver
/// <i>stayed</i> Connected — the shape reproduced here, a subscribe that failed and was never
/// retried — left the actor permanently subscribed to nothing while reporting Healthy.
/// </para>
/// <para>
/// All three tests drive a short <c>healthPollInterval</c> rather than the production 30 s. The
/// two absence assertions are bounded by a positive control (a counted number of ticks provably
/// processed), never by a bare sleep.
/// </para>
/// </remarks>
public sealed class DriverInstanceActorSubscriptionReconcileTests : RuntimeActorTestBase
{
private static readonly TimeSpan FastPoll = TimeSpan.FromMilliseconds(100);
/// <summary>
/// The reconcile itself: the first subscribe fails, leaving the actor Connected with a desired set
/// and no handle. Nothing else would ever retry it — no reconnect, no deploy — so a second
/// subscribe can only come from the periodic reconcile.
/// </summary>
[Fact]
public void A_lost_subscription_is_re_established_while_still_connected()
{
var driver = new SubscribableStubDriver { SubscribeFailuresBeforeSuccess = 1 };
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver, healthPollInterval: FastPoll));
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// The connect-path subscribe runs and throws, so the handle stays null.
AwaitCondition(() => driver.SubscribeCount >= 1, PresenceBudget);
// Only the reconcile can produce this second attempt — and it must succeed this time.
AwaitCondition(() => driver.SubscribeCount >= 2, PresenceBudget);
driver.LastSubscribedRefs.ShouldBe(new[] { "tag-a" });
}
/// <summary>
/// A healthy subscription is left alone — the reconcile must not re-subscribe on every tick, which
/// would churn the backend every 30 s in production for no reason.
/// </summary>
[Fact]
public void A_healthy_subscription_is_not_re_subscribed_on_every_tick()
{
var publisher = new CountingHealthPublisher();
var driver = new SubscribableStubDriver();
var actor = Sys.ActorOf(DriverInstanceActor.Props(
driver, healthPublisher: publisher, healthPollInterval: FastPoll));
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.SubscribeCount >= 1, PresenceBudget);
// POSITIVE CONTROL for the absence below. Every health-poll tick publishes a snapshot, so the
// publisher count is a direct observation of ticks PROCESSED. Waiting for several of them is what
// makes "SubscribeCount is still 1" mean "the reconcile ran and declined" rather than "the timer
// had not fired yet" — the latter would pass instantly and prove nothing.
var before = publisher.Count;
AwaitCondition(() => publisher.Count >= before + 4, PresenceBudget);
driver.SubscribeCount.ShouldBe(1,
"the handle is live, so the reconcile must leave it alone");
}
/// <summary>
/// A driver that is not <see cref="ISubscribable"/> can still be handed a desired set. Reconciling
/// it would tell <c>Subscribe</c> every tick and fail every tick, forever — so the reconcile is
/// gated on the capability.
/// </summary>
[Fact]
public void A_non_subscribable_driver_is_never_reconciled()
{
var publisher = new CountingHealthPublisher();
var driver = new StubDriver(); // IDriver only — no ISubscribable
var actor = Sys.ActorOf(DriverInstanceActor.Props(
driver, healthPublisher: publisher, healthPollInterval: FastPoll));
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
// Same positive control: assert the reconcile stays silent across several PROCESSED ticks.
// EventFilter bounds the absence to this block, and the reconcile announces itself at Info —
// the first test above is the proof that this message does appear when the reconcile fires.
EventFilter.Info(contains: "periodic reconcile").Expect(0, () =>
{
var before = publisher.Count;
AwaitCondition(() => publisher.Count >= before + 4, PresenceBudget);
});
}
/// <summary>Counts health-snapshot publishes, which is one per <c>health-poll</c> tick once the actor
/// is running — the observable that turns "time passed" into "ticks were processed".</summary>
private sealed class CountingHealthPublisher : IDriverHealthPublisher
{
private int _count;
/// <summary>Number of snapshots published so far.</summary>
public int Count => Volatile.Read(ref _count);
/// <inheritdoc />
public void Publish(string clusterId, string driverInstanceId, DriverHealth health, int errorCount5Min)
=> Interlocked.Increment(ref _count);
}
}
@@ -114,6 +114,18 @@ internal sealed class SubscribableStubDriver : StubDriver, ISubscribable
/// synchronously-completed stub task hides (the continuation otherwise runs inline).</summary> /// synchronously-completed stub task hides (the continuation otherwise runs inline).</summary>
public bool UnsubscribeYields { get; set; } public bool UnsubscribeYields { get; set; }
/// <summary>
/// How many of the first <see cref="SubscribeAsync"/> calls throw before one succeeds. Default 0
/// (always succeed) leaves existing behaviour untouched.
/// </summary>
/// <remarks>
/// This is how a test reaches the state the #488 reconcile exists for: the actor is Connected and
/// holds a desired ref set, but the subscribe that should have established the handle failed, so
/// <c>_subscriptionHandle</c> is null and nothing else will ever re-assert it — there is no
/// connect transition coming, and no deploy.
/// </remarks>
public int SubscribeFailuresBeforeSuccess { get; set; }
/// <summary>Subscribes to the specified full references.</summary> /// <summary>Subscribes to the specified full references.</summary>
/// <param name="fullReferences">The full references to subscribe to.</param> /// <param name="fullReferences">The full references to subscribe to.</param>
/// <param name="publishingInterval">The publishing interval.</param> /// <param name="publishingInterval">The publishing interval.</param>
@@ -121,8 +133,13 @@ internal sealed class SubscribableStubDriver : StubDriver, ISubscribable
public Task<ISubscriptionHandle> SubscribeAsync( public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{ {
Interlocked.Increment(ref SubscribeCount); var attempt = Interlocked.Increment(ref SubscribeCount);
LastSubscribedRefs = fullReferences; LastSubscribedRefs = fullReferences;
if (attempt <= SubscribeFailuresBeforeSuccess)
{
throw new InvalidOperationException($"stub subscribe failure #{attempt}");
}
return Task.FromResult<ISubscriptionHandle>(_handle); return Task.FromResult<ISubscriptionHandle>(_handle);
} }
@@ -78,7 +78,9 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase
duration: PresenceBudget); duration: PresenceBudget);
} }
/// <summary>Verifies a single-node snapshot (just the local node) spawns no children.</summary> /// <summary>Verifies a single-node snapshot (just the local node) spawns no children — the local node
/// is never its own probe peer. Established against a following two-node snapshot as a positive
/// control, so the count of 1 (rather than 2) is what carries the "no local child" claim.</summary>
[Fact] [Fact]
public void Single_node_snapshot_spawns_no_children() public void Single_node_snapshot_spawns_no_children()
{ {
@@ -87,7 +89,20 @@ public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase
sup.Tell(Snapshot(State(Local, RedundancyRole.Primary))); sup.Tell(Snapshot(State(Local, RedundancyRole.Primary)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0), // POSITIVE CONTROL (#501). This is an ABSENCE assertion, and ChildCount is 0 before the snapshot
// above has been processed at all — so asserting it directly passes at t=0 and proves nothing
// (AwaitAssert returns on its first success, spending none of its duration). Unlike the other
// ChildCount.ShouldBe(0) waits in this class, there is no preceding ShouldBe(1) to make it a
// transition.
//
// Follow with a snapshot that DOES spawn exactly one child. The supervisor processes its mailbox
// in order, so once the count reaches 1 the single-node snapshot has provably been handled — and
// had it spawned a child for the local node, the count would be 2.
sup.Tell(Snapshot(
State(Local, RedundancyRole.Primary),
State(Peer, RedundancyRole.Secondary)));
AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1),
duration: PresenceBudget); duration: PresenceBudget);
} }
@@ -86,20 +86,22 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
Retain: false, Retain: false,
Enabled: true); Enabled: true);
private static ScriptedAlarmEngine BuildEngine(DependencyMuxTagUpstreamSource upstream) private static ScriptedAlarmEngine BuildEngine(
DependencyMuxTagUpstreamSource upstream, IAlarmStateStore? store = null)
{ {
var logger = new LoggerConfiguration().CreateLogger(); var logger = new LoggerConfiguration().CreateLogger();
return new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), new ScriptLoggerFactory(logger), logger); return new ScriptedAlarmEngine(
upstream, store ?? new InMemoryAlarmStateStore(), new ScriptLoggerFactory(logger), logger);
} }
/// <summary>The local node id used by the redundancy-gating tests.</summary> /// <summary>The local node id used by the redundancy-gating tests.</summary>
private static readonly NodeId LocalNode = new("node-A"); private static readonly NodeId LocalNode = new("node-A");
private (IActorRef Host, DependencyMuxTagUpstreamSource Upstream) Spawn( private (IActorRef Host, DependencyMuxTagUpstreamSource Upstream) Spawn(
TestProbe publish, TestProbe mux, NodeId? localNode = null) TestProbe publish, TestProbe mux, NodeId? localNode = null, IAlarmStateStore? store = null)
{ {
var upstream = new DependencyMuxTagUpstreamSource(); var upstream = new DependencyMuxTagUpstreamSource();
var engine = BuildEngine(upstream); var engine = BuildEngine(upstream, store);
var host = Sys.ActorOf(ScriptedAlarmHostActor.Props(publish.Ref, mux.Ref, upstream, engine, localNode)); var host = Sys.ActorOf(ScriptedAlarmHostActor.Props(publish.Ref, mux.Ref, upstream, engine, localNode));
return (host, upstream); return (host, upstream);
} }
@@ -794,4 +796,162 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
evt.AlarmId.ShouldBe("alm-1"); evt.AlarmId.ShouldBe("alm-1");
evt.TransitionKind.ShouldBe("Activated"); evt.TransitionKind.ShouldBe("Activated");
} }
// ---------------------------------------------------------------------------------------------
// #487 — post-(re)load condition-node re-assert.
//
// A deploy that triggers a FULL address-space rebuild re-materialises every condition node fresh
// (inactive/acked/confirmed/unshelved). The engine reloads its persisted state and re-derives Active
// from the predicate, so it ends up CORRECT — but a still-active alarm has no transition to report,
// yielding EmissionKind.None, which OnEngineEmission drops. Without the re-assert the node reads
// NORMAL to every OPC UA client until the alarm's next real transition — which, for an alarm with
// static dependencies, may never come.
// ---------------------------------------------------------------------------------------------
/// <summary>A persisted condition state as it survives a restart / redeploy, seeded into the store the
/// host's engine loads from.</summary>
private static AlarmConditionState Persisted(
string alarmId = "alm-1",
AlarmEnabledState enabled = AlarmEnabledState.Enabled,
AlarmActiveState active = AlarmActiveState.Active,
AlarmAckedState acked = AlarmAckedState.Unacknowledged,
AlarmConfirmedState confirmed = AlarmConfirmedState.Confirmed,
ShelvingKind shelving = ShelvingKind.Unshelved,
DateTime? lastTransitionUtc = null) =>
new(alarmId,
enabled,
active,
acked,
confirmed,
shelving == ShelvingKind.Unshelved ? ShelvingState.Unshelved : new ShelvingState(shelving, null),
lastTransitionUtc ?? DateTime.UtcNow,
LastActiveUtc: active == AlarmActiveState.Active ? lastTransitionUtc ?? DateTime.UtcNow : null,
LastClearedUtc: null,
LastAckUtc: null, LastAckUser: null, LastAckComment: null,
LastConfirmUtc: null, LastConfirmUser: null, LastConfirmComment: null,
Comments: []);
/// <summary>Seed <paramref name="state"/> into a fresh store and hand it back for
/// <see cref="Spawn"/>. The engine reads it during LoadAsync, exactly as it would after a restart.</summary>
private static IAlarmStateStore StoreWith(AlarmConditionState state)
{
var store = new InMemoryAlarmStateStore();
store.SaveAsync(state, CancellationToken.None).GetAwaiter().GetResult();
return store;
}
/// <summary>#487, the regression: an alarm whose persisted state is ACTIVE emits nothing on reload
/// (no transition to report), but the host must still write its state onto the freshly materialised
/// condition node — otherwise an active alarm silently reads normal after a full-rebuild deploy.</summary>
[Fact]
public void Reload_reasserts_an_active_alarm_onto_its_condition_node()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var (host, _) = Spawn(publish, mux, store: StoreWith(Persisted()));
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(severity: 800) }));
var state = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout);
state.AlarmNodeId.ShouldBe("alm-1");
state.Realm.ShouldBe(AddressSpaceRealm.Uns);
state.State.Active.ShouldBeTrue("the persisted state was Active — the rebuilt node must say so");
state.State.Acknowledged.ShouldBeFalse();
state.State.Enabled.ShouldBeTrue();
// The projection carries the definition's severity + resolved message, so a re-asserted node is
// indistinguishable from one the alarm's own transition would have written.
state.State.Severity.ShouldBe((ushort)1000); // 800 → Critical bucket → 1000
state.State.Message.ShouldBe("condition");
}
/// <summary>The re-assert is node-ONLY: it must never reach the <c>alerts</c> topic. That topic is the
/// historization path, so an alerts row per alarm per deploy would append a duplicate historian /
/// AVEVA record every time anyone deploys.</summary>
[Fact]
public void Reassert_never_publishes_to_the_alerts_topic()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var alerts = CreateTestProbe();
SubscribeToAlerts(alerts);
var (host, _) = Spawn(publish, mux, store: StoreWith(Persisted()));
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() }));
// Wait for the re-assert to actually happen FIRST — otherwise the absence window below could
// elapse before the load even completed and would prove nothing.
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout);
// Absence assertion: the elapsed time IS the assertion, so this stays deliberately short.
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>An alarm sitting in the Part 9 no-event position already matches what the materialise
/// built, so it is NOT re-asserted — a deploy where nothing is in alarm writes no condition nodes at
/// all, and a never-fired alarm keeps the display name the materialise seeded as its Message.</summary>
[Fact]
public void A_normal_alarm_is_not_reasserted()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var (host, _) = Spawn(publish, mux); // empty store ⇒ Fresh ⇒ no-event position
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() }));
// Sequence the absence: the re-assert (if any) happens right after the mux registration, so
// waiting for that first makes the window below cover the moment the write would have landed.
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(Timeout);
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Not just Active: every field a rebuild would silently lose is restored. A shelved (but
/// inactive) alarm is re-asserted too — a rebuilt node would otherwise come back unshelved, so an
/// operator's suppression would evaporate on the next deploy.</summary>
[Fact]
public void Reload_reasserts_a_shelved_but_inactive_alarm()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var (host, _) = Spawn(publish, mux, store: StoreWith(Persisted(
active: AlarmActiveState.Inactive,
acked: AlarmAckedState.Acknowledged,
shelving: ShelvingKind.OneShot)));
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() }));
var state = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout);
state.State.Active.ShouldBeFalse();
state.State.Shelving.ShouldBe(AlarmShelvingKind.OneShot);
}
/// <summary>The re-asserted timestamp is when the state actually came about, NOT the deploy instant —
/// a client reading Time/ReceiveTime after a rebuild should still see when the alarm went active.</summary>
[Fact]
public void Reassert_carries_the_conditions_own_transition_timestamp()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var wentActiveAt = DateTime.UtcNow.AddHours(-3);
var (host, _) = Spawn(publish, mux, store: StoreWith(Persisted(lastTransitionUtc: wentActiveAt)));
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan() }));
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(Timeout)
.TimestampUtc.ShouldBe(wentActiveAt);
}
/// <summary>A disabled PLAN exposes no condition node at all (MaterialiseScriptedAlarms skips it) and
/// is not loaded into the engine — so it must not be re-asserted either, or the write would target a
/// node that does not exist.</summary>
[Fact]
public void A_disabled_plan_is_not_reasserted()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var (host, _) = Spawn(publish, mux, store: StoreWith(Persisted()));
host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(enabled: false) }));
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
} }