Compare commits
55 Commits
a8b07ae12d
...
8801631739
| Author | SHA1 | Date | |
|---|---|---|---|
| 8801631739 | |||
| 0a821417ea | |||
| af02fce3c4 | |||
| 345f40550b | |||
| fe7603ec6e | |||
| fdee129fd0 | |||
| 82ef89416f | |||
| f066fb9c80 | |||
| 49dfabc141 | |||
| 90d7f6dfca | |||
| 3bf874ae44 | |||
| 5af9aae2d4 | |||
| fcae87a5bc | |||
| 17dc171940 | |||
| 521e1eb89b | |||
| 25f02e4102 | |||
| 5c88de078c | |||
| 8f5b265db8 | |||
| 9b74a65b25 | |||
| 4128d9c313 | |||
| bd23f557f3 | |||
| 64d2514e2a | |||
| 3fe6cbb905 | |||
| 11f13f6326 | |||
| 454775c67c | |||
| baf8cc48c3 | |||
| 216dc63a28 | |||
| 9244ae2c31 | |||
| c86204b7d6 | |||
| 598cb133a8 | |||
| bdf13d29ba | |||
| ad108f6a02 | |||
| 3de2cc3ba8 | |||
| 555e8d8047 | |||
| ddd7678269 | |||
| 4a93d4cead | |||
| 487d394cde | |||
| 8d916f2af7 | |||
| 73e54656f8 | |||
| 489dc3d50a | |||
| c2bdb7d5de | |||
| dac0747bb4 | |||
| 73ec6cec0f | |||
| 73fdb80157 | |||
| 62f19dd013 | |||
| d93491499d | |||
| bbea38483f | |||
| e56926dc47 | |||
| b0b0ce9c77 | |||
| 5b36c717de | |||
| 2aae3c094f | |||
| dcc8adcf48 | |||
| 6951faa08f | |||
| 41e600e069 | |||
| 839f32910d |
@@ -33,6 +33,10 @@ jobs:
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
# #606: code-reviews/README.md must be regenerated from the per-module findings.md
|
||||
# files; --check exits non-zero on drift without writing (see REVIEW-PROCESS.md section 5).
|
||||
- name: code-reviews README drift check
|
||||
run: python3 code-reviews/regen-readme.py --check
|
||||
- name: dotnet restore
|
||||
run: dotnet restore ZB.MOM.WW.OtOpcUa.slnx
|
||||
- name: dotnet build
|
||||
@@ -53,7 +57,8 @@ jobs:
|
||||
# Self-maintaining — a new *.Tests project is covered automatically, no matrix to widen —
|
||||
# and matches CLAUDE.md's own `dotnet test ZB.MOM.WW.OtOpcUa.slnx` guidance. Excludes only
|
||||
# the env-gated tiers: E2E (nightly, needs the docker-dev fleet) and LiveIntegration (needs
|
||||
# a real gateway/PLC/VPN). The skip-gated *.IntegrationTests Assert.Skip when their fixtures
|
||||
# a real gateway/PLC/VPN). Category=SlowUnit (Mqtt socket-timeout tests, #606) is deliberately NOT
|
||||
# excluded here — it is a local-dev-loop opt-out only. The skip-gated *.IntegrationTests Assert.Skip when their fixtures
|
||||
# are unreachable, so they run harmlessly here (and are counted by the skip-gate below).
|
||||
- name: dotnet test (whole solution, minus E2E + LiveIntegration)
|
||||
run: >
|
||||
|
||||
@@ -184,6 +184,11 @@ is still default-closed; what changed is which deltas it whitelists.
|
||||
re-diffs the same composition and re-ensures what failed, at the cost of re-emitting that
|
||||
apply's removes (a re-remove returns false and ratchets to a rebuild, which is the correct,
|
||||
bounded resync). A **rebuild** still advances even with failures.
|
||||
- **…and the deploy ack now SAYS so (#601).** The Applied ack waits for `OpcUaPublishActor`'s
|
||||
`RebuildCompleted` (timeout = shared `DeployTimings.ApplyDeadline` − 15 s = 105 s ⇒ degraded "outcome not reported in time", never clean) and carries an additive `Degraded`/`DegradedReason`
|
||||
on both ack transports; central seals but records `NodeDeploymentState.FailureReason = "Applied degraded: …"`
|
||||
(`/fleet` "Applied (degraded)"), and a re-dispatch of a degraded revision re-applies instead of short-circuiting.
|
||||
See `docs/Redundancy.md` §"What an `Applied` ack means".
|
||||
- **Any surgical `false`/throw still ratchets to `SafeRebuild()`** — that one-way fallback is
|
||||
what makes every whitelist above safe.
|
||||
- **Announcements:** `AnnounceAddedNodes` announces surgically-added **Raw** containers/tags
|
||||
@@ -359,6 +364,10 @@ dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests # a single tes
|
||||
dotnet test --filter "FullyQualifiedName~MyTestClass.MyMethod" # a single test
|
||||
```
|
||||
|
||||
`Category=SlowUnit` (#606) tags the 16 wall-clock-bound Driver.Mqtt unit tests (blackhole connect-timeout /
|
||||
reconnect-backoff windows, ~1 min of the project's 73 s). CI runs them (its filter excludes only E2E +
|
||||
LiveIntegration); for a fast local loop add `--filter "Category!=SlowUnit"`.
|
||||
|
||||
CVE-2025-6965 / GHSA-2m69-gcr7-jv3q (transitive native `SQLitePCLRaw.lib.e_sqlite3`)
|
||||
is **resolved** — the temporary `NuGetAuditSuppress` was removed once the patched bundle
|
||||
shipped. `Core.AlarmHistorian` now carries a surgical direct pin of
|
||||
@@ -1105,6 +1114,9 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
|
||||
primary-host claim backs off 2 s → 30 s; writes encode the birth's own datatype (unsupported birth types
|
||||
`BadNotSupported`).
|
||||
- **FOCAS / AbLegacy:** background loops are joined (≤ 5 s) on shutdown.
|
||||
- **FOCAS (#257/#266):** `cnc_statinfo` flags are a `Status/` fixed-tree subtree (`FIXED:Status/Run`…, capability-probed);
|
||||
adjacent PMC tags in one poll batch coalesce into one `pmc_rdpmcrng` (`PmcCoalesce` config, default on) — a refused range
|
||||
falls back to per-tag reads, a transport failure fails all members without retry. Parts count stays `PARAM:6711` (docs only).
|
||||
- **Re-Initialize without teardown (#630):** FOCAS, AbLegacy, AbCip, TwinCAT, S7 and OpcUaClient `InitializeAsync`
|
||||
first release the prior device/session state (cancel, ≤ 5 s join, dispose — incl. the AbCip/FOCAS alarm
|
||||
projections) — the device-state half of `ShutdownAsync` only; the poll engine is kept and the actor re-subscribes
|
||||
@@ -1113,13 +1125,15 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
|
||||
- **S7 (#632/#638):** STRING/WSTRING/CHAR/WCHAR and DTL/DATE/TOD/TIME/S5TIME codecs (`S7ValueCodecs`; TOD/TIME/S5TIME
|
||||
surface as Int32 ms); an undecodable buffer or unrepresentable write is `BadOutOfRange` with no health impact.
|
||||
Optional `localTsap`/`remoteTsap`/`connectionType` override the slot TSAP (`remoteTsap` ⊕ `connectionType`). Test
|
||||
Connect reads 2 bytes of `MB0` so a PUT/GET-disabled CPU fails the probe. The negotiated PDU size is logged and on
|
||||
`S7Driver.GetDiagnostics()` only — there is no driver-diagnostics surface on `/hosts`.
|
||||
Connect reads 2 bytes of `MB0` so a PUT/GET-disabled CPU fails the probe. The negotiated PDU size is logged, on
|
||||
`S7Driver.GetDiagnostics()`, and (#640) on `/hosts` via the optional `IDriverDiagnostics` bag (string map on
|
||||
`DriverHealthChanged.Diagnostics` / proto `has_diagnostics`+`diagnostics`; its digest is in the publish fingerprint).
|
||||
- **TwinCAT (#637):** TIME/TOD publish Float64 ms, DATE/DT a UTC DateTime; a tag authored `UDInt` keeps the raw counter
|
||||
(and keeps historizing — DateTime is not historized).
|
||||
- **AbLegacy (#636):** `AbLegacyAddress` (in `AbLegacy.Contracts`) is family-aware: PLC-5 `I:`/`O:` indices are OCTAL
|
||||
and are emitted to libplctag as decimal (libplctag's `pccc.c` parses decimal only). MicroLogix function files and
|
||||
indirect `N7:[N7:0]` addresses parse but read `BadNotSupported`. One check (`AbLegacyAddress.Validate`) backs the
|
||||
and are emitted to libplctag as decimal (libplctag's `pccc.c` parses decimal only). MicroLogix function files parse
|
||||
but read `BadNotSupported`. Indirect `N7:[N7:0]` (#646) is resolved by the driver at read/write time (index re-read
|
||||
every call, never cached; bad index = `BadOutOfRange`; indirect writes are not atomic). One check (`AbLegacyAddress.Validate`) backs the
|
||||
editor (family-independent) and the deploy gate (family from the tag's `DeviceConfig.PlcFamily`); an unparseable
|
||||
address reads `BadNodeIdUnknown` for that tag only.
|
||||
- **OpcUaClient (#635):** OperationLimits are honoured by the SDK session (`SessionClientBatched`) — do not re-add
|
||||
@@ -1136,10 +1150,15 @@ host logger factory (wired in `DriverFactoryBootstrap`; the FOCAS and TwinCAT fa
|
||||
active nativeCode (ties: ordinal nativeCode, then type); ack is server-side only. ⚠️ UNAVAILABLE never clears: an
|
||||
active condition stays active+acked with a quality-only `BadNoCommunication` (same as #481 transport loss). The
|
||||
driver dedups against what the server was last TOLD (level+nativeCode+nativeSeverity+qualifier — not text), records
|
||||
only while a handler is attached, and clears that record when the subscribed alarm SET changes (a rebuild recreates
|
||||
the nodes inactive) but not on a reconnect of the same set — a replayed `/current` must never re-raise (un-ack).
|
||||
only while a handler is attached, and drops the record of only the conditions that LEFT the set when the subscribed
|
||||
alarm SET changes (#641) — never on a reconnect of the same set; a replayed `/current` must never re-raise (un-ack).
|
||||
`AlarmEventArgs` gained optional `OpcUaSeverity` (exact Part 9 severity) and `QualityStatusCode`; a quality-only
|
||||
event goes out as `AlarmQualityUpdate` per node and never through the snapshot projection.
|
||||
- **Native condition state survives a full rebuild (#641):** `RebuildAddressSpace` captures each NATIVE condition's
|
||||
live node state (client acks included), the same-apply re-materialise restores it, and `CompleteAddressSpaceApply`
|
||||
(called by `OpcUaPublishActor` at the end of every apply) reports ONE event per restored off-normal condition and
|
||||
discards unclaimed captures. `NativeAlarmProjector` is pruned per apply, not cleared. Native routing is an exact
|
||||
`ConditionId == RawPath` match — AbCip's `#active` suffix was fixed; FOCAS is still unroutable (`docs/drivers/FOCAS.md`).
|
||||
- **Driver removal (#609):** removing a driver releases its capability-invoker pipelines and resilience
|
||||
tracker, *then* evicts its telemetry snapshots. ⚠️ Both are **tombstoned**, not just cleared: the removed
|
||||
child stops asynchronously and its in-flight subscribe/connect still completes through the invoker (and
|
||||
|
||||
@@ -102,6 +102,8 @@ The first build takes a few minutes (.NET SDK image + restore + publish). **No m
|
||||
|
||||
Central nodes authenticate against the shared GLAuth at `10.100.0.35:3893` (baseDN `dc=zb,dc=local`). `DevStubMode` is **not** active. Sign in with any test user (password `password`); `multi-role` / `password` returns all three roles (Administrator, Designer, Viewer). Group→role mappings are seeded by `seed/seed-clusters.sql`. The GLAuth source of truth + deploy runbook is in `scadaproj/infra/glauth/`. **Do not** enable `DevStubMode` outside local debugging — production must always bind a real LDAP backend.
|
||||
|
||||
The LDAP service-account bind password is read from the host env var `OTOPCUA_LDAP_BIND_PASSWORD` (compose default `serviceaccount123`, the committed dev password of the shared GLAuth, so the rig works with nothing set). Export it before `docker compose up` to bind with a different password (#619).
|
||||
|
||||
## Headless deploy
|
||||
|
||||
```bash
|
||||
|
||||
@@ -194,7 +194,7 @@ x-ldap-env: &ldap-env
|
||||
Security__Ldap__AllowInsecure: "true"
|
||||
Security__Ldap__SearchBase: "dc=zb,dc=local"
|
||||
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
|
||||
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
|
||||
Security__Ldap__ServiceAccountPassword: "${OTOPCUA_LDAP_BIND_PASSWORD:-serviceaccount123}"
|
||||
Security__Ldap__GroupToRole__ReadOnly: "ReadOnly"
|
||||
Security__Ldap__GroupToRole__WriteOperate: "WriteOperate"
|
||||
Security__Ldap__GroupToRole__WriteTune: "WriteTune"
|
||||
@@ -402,7 +402,7 @@ services:
|
||||
Security__Ldap__AllowInsecure: "true"
|
||||
Security__Ldap__SearchBase: "dc=zb,dc=local"
|
||||
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
|
||||
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
|
||||
Security__Ldap__ServiceAccountPassword: "${OTOPCUA_LDAP_BIND_PASSWORD:-serviceaccount123}"
|
||||
Security__DeployApiKey: "docker-dev-deploy-key"
|
||||
Security__Auth__DisableLogin: "${OTOPCUA_DISABLE_LOGIN:-true}"
|
||||
# #520 stage 6 — node ACL enforcement, the dark switch. Default OFF, exactly as in production.
|
||||
@@ -535,7 +535,7 @@ services:
|
||||
Security__Ldap__AllowInsecure: "true"
|
||||
Security__Ldap__SearchBase: "dc=zb,dc=local"
|
||||
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
|
||||
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
|
||||
Security__Ldap__ServiceAccountPassword: "${OTOPCUA_LDAP_BIND_PASSWORD:-serviceaccount123}"
|
||||
Security__DeployApiKey: "docker-dev-deploy-key"
|
||||
Security__Auth__DisableLogin: "${OTOPCUA_DISABLE_LOGIN:-true}"
|
||||
# Node ACL enforcement, the dark switch — same env override and same default-OFF as central-1.
|
||||
|
||||
@@ -201,3 +201,33 @@ IF NOT EXISTS (SELECT 1 FROM dbo.Tag WHERE TagId = 'MAIN-modbus-hr200')
|
||||
VALUES ('MAIN-modbus-hr200', 'MAIN-modbus-dev', NULL, 'HR200', 'UInt16', 'ReadWrite', 1,
|
||||
N'{ "region": "HoldingRegisters", "address": 200, "dataType": "UInt16", "writable": true }');
|
||||
SELECT DriverInstanceId, ClusterId, DriverType, Enabled FROM dbo.DriverInstance WHERE ClusterId='MAIN';
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- #619: OpcUaClient driver against the opc-plc fixture (10.100.0.35:50000, started by
|
||||
-- tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/Docker with --alm).
|
||||
-- Tag identity under v3 is the RawPath; the upstream node is authored in TagConfig.nodeId.
|
||||
-- * FastUInt1 (ns=3;s=FastUInt1) is the always-moving VALUE control: proves the driver is connected and
|
||||
-- the RawPath -> nodeId table resolves before any alarm assertion is trusted.
|
||||
-- * The ALARM tag's nodeId must be the SourceNode opc-plc stamps on its condition events - the driver
|
||||
-- matches events on it. It is NOT guessable (and opc-plc was not running when this was authored), so
|
||||
-- it is supplied at seed time: browse opc-plc (Client.CLI `browse -r -d 4`, or `alarms --refresh`
|
||||
-- which prints each event's source) and set @OpcPlcAlarmSourceNodeId below. Empty = no alarm tag.
|
||||
-- Idempotent.
|
||||
------------------------------------------------------------------------------
|
||||
DECLARE @OpcPlcAlarmSourceNodeId nvarchar(400) = N''; -- e.g. N'ns=3;s=<source from opc-plc>'
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.DriverInstance WHERE DriverInstanceId = 'MAIN-opcua')
|
||||
INSERT dbo.DriverInstance(DriverInstanceId, ClusterId, RawFolderId, Name, DriverType, DriverConfig, Enabled)
|
||||
VALUES ('MAIN-opcua', 'MAIN', NULL, 'opcplc', 'OpcUaClient',
|
||||
N'{ "SecurityPolicy": "None", "SecurityMode": "None", "AuthType": "Anonymous", "AutoAcceptCertificates": true, "TargetNamespaceKind": "SystemPlatform" }', 1);
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.Device WHERE DeviceId = 'MAIN-opcua-dev')
|
||||
INSERT dbo.Device(DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig)
|
||||
VALUES ('MAIN-opcua-dev', 'MAIN-opcua', 'plc', 1, N'{ "EndpointUrl": "opc.tcp://10.100.0.35:50000" }');
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.Tag WHERE TagId = 'MAIN-opcua-fastuint1')
|
||||
INSERT dbo.Tag(TagId, DeviceId, TagGroupId, Name, DataType, AccessLevel, WriteIdempotent, TagConfig)
|
||||
VALUES ('MAIN-opcua-fastuint1', 'MAIN-opcua-dev', NULL, 'FastUInt1', 'UInt32', 'Read', 0,
|
||||
N'{ "nodeId": "ns=3;s=FastUInt1" }');
|
||||
IF LEN(@OpcPlcAlarmSourceNodeId) > 0 AND NOT EXISTS (SELECT 1 FROM dbo.Tag WHERE TagId = 'MAIN-opcua-alarm1')
|
||||
INSERT dbo.Tag(TagId, DeviceId, TagGroupId, Name, DataType, AccessLevel, WriteIdempotent, TagConfig)
|
||||
VALUES ('MAIN-opcua-alarm1', 'MAIN-opcua-dev', NULL, 'Alarm1', 'Boolean', 'Read', 0,
|
||||
N'{ "nodeId": "' + @OpcPlcAlarmSourceNodeId + N'", "alarm": { "alarmType": "AlarmCondition", "severity": 500 } }');
|
||||
SELECT DriverInstanceId, ClusterId, DriverType, Enabled FROM dbo.DriverInstance WHERE DriverInstanceId = 'MAIN-opcua';
|
||||
|
||||
@@ -54,6 +54,58 @@ same condition is wired as an **event notifier of every referencing equipment fo
|
||||
- The `alerts`-topic `AlarmTransitionEvent` carries the (possibly empty) **list of referencing
|
||||
equipment paths**, and the AdminUI `/alerts` page shows **one row per condition** (primary
|
||||
identity RawPath + condition NodeId) with the equipment list as display metadata.
|
||||
- **Routing is an exact `ConditionId == RawPath` match** (`DriverHostActor.ForwardNativeAlarm`). A driver that
|
||||
decorates the id has every transition dropped as "no alarm condition" — AbCip did (`"{ref}#active"`, fixed
|
||||
under #641); FOCAS still does (see `drivers/FOCAS.md`). A native source must also set `Kind`: an
|
||||
`Unspecified` kind projects no state change.
|
||||
|
||||
### Native condition state survives a full rebuild (Gitea #641)
|
||||
|
||||
A full `RebuildAddressSpace` recreates every condition inactive + acknowledged. For **scripted** alarms the
|
||||
engine re-asserts every non-normal condition after the reload (#487). **Native** conditions had nothing
|
||||
equivalent: the host re-subscribes a driver's alarms only when its alarm-ref *set* changes, and a
|
||||
transition-only source (Galaxy, OpcUaClient) has nothing to replay anyway — so a rebuild that left the alarm
|
||||
set unchanged (adding one `UnsTagReference` to an alarm-bearing raw tag is enough to force one, as are
|
||||
`ChangedEquipment`, a scripted-alarm edit, a raw container rename, an alarm severity/type edit, or any
|
||||
surgical fallback to `SafeRebuild()`) left every active native alarm reading **normal** until its next device
|
||||
transition.
|
||||
|
||||
The node manager now carries native state across the rebuild itself:
|
||||
|
||||
1. `RebuildAddressSpace` captures each **native** condition's live state off the **node** — Active, Acked,
|
||||
Confirmed, Severity, Message, Comment + ClientUserId, Quality, Time, `ActiveState.TransitionTime` — before clearing. Reading the
|
||||
node, not a host-side cache, is what keeps a **client's** acknowledgement: the SDK applies it to the node
|
||||
and neither the driver nor `NativeAlarmProjector` ever sees it.
|
||||
2. `MaterialiseAlarmCondition(isNative: true)` for the same id in the same apply restores it. **Quality is
|
||||
always restored** (it is otherwise re-annotated only on a connectivity edge, so even a normal condition
|
||||
would sit at `BadWaitingForInitialData`); the rest only when the condition was off-normal (active or
|
||||
unacknowledged). A normal condition keeps what the materialise built — message and authored severity
|
||||
included — exactly the #487 rule. Shelving/Enabled are not carried (native conditions refuse them, #587).
|
||||
3. `IOpcUaAddressSpaceSink.CompleteAddressSpaceApply`, which `OpcUaPublishActor` calls at the end of
|
||||
**every** apply (in a `finally`), reports **one** condition event per restored condition — after the
|
||||
notifier wiring, so the raw folder, every referencing equipment folder and `i=2253` items all get it,
|
||||
stamped with the original `Time`, not the deploy time (or the node's newer `Time`, if a client method call
|
||||
reported in the restore→complete window — that window can yield a second, state-correct event) — and **discards** every capture no materialise
|
||||
claimed, so a later deploy re-adding the same RawPath gets a fresh node (no resurrection). A kind swap
|
||||
(native ↔ scripted on one id) is never restored. Node-only: no `/alerts` row, no historian record.
|
||||
|
||||
Two companions keep the host and driver in step with the carried node:
|
||||
|
||||
- `DriverHostActor` **prunes** `NativeAlarmProjector` to the still-deployed conditions on each apply
|
||||
(`RetainOnly`) instead of clearing it. Clearing made the next driver Acknowledge of an active alarm
|
||||
project `Active=false`, and the next Clear of an unacknowledged one project `Acked=true` — after *any*
|
||||
deploy, rebuild or not.
|
||||
- MTConnect's "last told" record is pruned to the conditions that **left** the subscribed set rather than
|
||||
cleared on any set change; re-raising a surviving (carried) condition would un-acknowledge it. AbCip's ALMD
|
||||
last-seen `InFaulted` record is likewise driver-owned and pruned to the set, so a re-subscribe (every
|
||||
reconnect / set change) does not re-raise a still-faulted alarm.
|
||||
- A routed **client** acknowledgement marks the projector's prior state acknowledged
|
||||
(`NativeAlarmProjector.MarkAcknowledged`), so a later driver Clear does not project it unacknowledged.
|
||||
|
||||
Accepted residual: a condition that clears at the device while the rebuild runs is restored active until
|
||||
the driver's Clear arrives — which it does, because the driver's own record still holds it active. The capture
|
||||
is keyed by RawPath only, so a tag deleted and re-created under the same RawPath in one deploy inherits the old
|
||||
condition's state until its next transition.
|
||||
|
||||
## Condition event identity fields (what a client reads on the wire)
|
||||
|
||||
|
||||
@@ -175,6 +175,8 @@ The checked-in `appsettings*.json` files are deliberately thin: they carry only
|
||||
|
||||
**`Grpc` mode: central dials each node, a node posts its ack to central.** `CentralCommunicationActor` fans one `NodeCommandService.Execute` call per node — the target set is every enabled, non-maintenance `ClusterNode` row that carries a `GrpcPort` (dialled at `Host`+`GrpcPort`), rebuilt every `ContactRefreshSeconds`. There is no receptionist and no single fleet-wide (or per-cluster) client object: it is a plain per-node dial. A node posts its `ApplyAck` with `GrpcApplyAckSender`, which walks `CentralGrpcEndpoints` in order and stops at the first success — an availability list, not a fan-out list, so central normally sees one ack per node per dispatch (the walk as a whole is at-least-once on a slow-then-retried endpoint; `ConfigPublishCoordinator.HandleAck` absorbs a duplicate idempotently). Both directions are **fire-and-forget, with no retry**: a failed dispatch is logged (naming the node and the gRPC status) and dropped in the dispatch second — the deploy path's 2-minute apply deadline and a node's boot-time replay (`DriverHostActor.Bootstrap()` re-applying an orphan `Applying` row) are the only recovery mechanisms, exactly as they were under `ClusterClient`.
|
||||
|
||||
The ack itself is sent only once the node's address-space apply outcome is known, and carries an additive `degraded` flag (`ApplyAckMessage` fields 6/7; trailing `ApplyAck.Degraded`/`DegradedReason` on DPS) — an old node's ack decodes clean, exactly as before. See [Redundancy.md § "What an `Applied` ack means"](Redundancy.md#what-an-applied-ack-means-the-degraded-apply-amendment-gitea-601) (Gitea #601).
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `GrpcPsk` | string? | `null` (unset) | Node-side only, required under `Grpc` on a driver-role node: this node's pre-shared key for its own `Cluster`, presented as the bearer on outbound acks and required on inbound `NodeCommandService` calls. Production form is `${secret:OT-MESH-PSK-<clusterId>}`; supply a literal only on a rig, via `MeshTransport__GrpcPsk` — never commit one. |
|
||||
|
||||
@@ -273,6 +273,22 @@ for this node), which is the signal to check that `ServerHistorian:Enabled=true`
|
||||
> so a HistoryRead on it runs to the full `CallTimeout` (~30 s) before erroring. Auto-provisioning on deploy
|
||||
> keeps freshly-historized tags from hitting that slow path. See the `CallTimeout` row above.
|
||||
|
||||
### Deferred types and the UInt16 fallback (#600 item 4)
|
||||
|
||||
Historization is **numeric-analog only** in v1. `HistorianTypeMapper` classifies exactly nine types as
|
||||
historizable (`Boolean`, `Int16/32/64`, `UInt16/32/64`, `Float32`, `Float64`):
|
||||
|
||||
- **Deferred: `String`, `DateTime`, `Reference`.** The historian's SQL analog write path cannot carry them.
|
||||
Provisioning skips them (counted in the `skipped=` tally, via `IsHistorizable`), the mapper throws
|
||||
`NotSupportedException` for them, and `ContinuousHistorizationRecorder` drops their values and counts them
|
||||
in `DroppedNonNumeric`. A tag authored `isHistorized` with one of these types therefore silently records
|
||||
nothing; HistoryRead on it returns whatever the historian holds (typically `GoodNoData`).
|
||||
- **Fallback: `UInt16 -> Uint4`.** The historian's `UInt2` write path is unproven upstream, so UInt16 tags
|
||||
are provisioned as the wider `UInt4`. Values are unaffected; only the historian-side storage type differs.
|
||||
(`Boolean -> Int2` is the other fallback, below.)
|
||||
|
||||
Lifting either needs gateway-side support first; neither is scheduled.
|
||||
|
||||
### Boolean historization maps to Int2 (issue #441)
|
||||
|
||||
`EnsureTags` is contract-documented (HistorianGateway 0.2.0+) as an **upsert** — *"creates or updates
|
||||
|
||||
+10
@@ -68,6 +68,16 @@ removed later.
|
||||
auto-creates nested groups; typed columns win over `TagConfigJson` on conflict (flagged in
|
||||
the review grid). **Import is all-or-nothing** — any invalid row blocks the whole commit.
|
||||
Export round-trips the same shape.
|
||||
- **Native alarm section (Gitea #642)** — the Edit-tag modal carries a shared `NativeAlarmEditor`
|
||||
below the per-driver editor / JSON textarea: a "Native alarm (Part 9 condition)" switch plus
|
||||
`alarmType`, `severity` (1..1000) and `historizeToAveva` (default/yes/no), writing the
|
||||
driver-agnostic `alarm` object of `TagConfig`. Offered only for `IAlarmSource` drivers (MTConnect,
|
||||
AbCip, FOCAS, OpcUaClient, Galaxy — `NativeAlarmModel.SupportsNativeAlarms`); on any other driver an
|
||||
existing `alarm` object is shown with a "no effect" note and a Remove button, never silently
|
||||
dropped. Switching it off REMOVES the `alarm` key. Validation is advisory only (warnings) — the
|
||||
deploy gate has no `alarm` rule and the server clamps severity, so the editor is never stricter.
|
||||
Every edit re-parses the latest `ConfigJson` (`NativeAlarmModel.Edit`), so it composes with the
|
||||
driver editor in either order. Not yet on the manual-entry grid (use JSON/CSV there).
|
||||
- **Browse device…** — enabled per the two-tier resolution (bespoke `IDriverBrowser` →
|
||||
universal `DiscoveryDriverBrowser` when the driver's `ITagDiscovery.SupportsOnlineDiscovery`
|
||||
is true → grayed out otherwise). Multi-selected leaves become raw `Tag` rows under the
|
||||
|
||||
@@ -585,6 +585,54 @@ Two properties are load-bearing and easy to break:
|
||||
2-minute apply deadline and a node's boot-time replay (`DriverHostActor.Bootstrap()` re-applying an
|
||||
orphan `Applying` row) are the only recovery mechanisms, unchanged from the DPS/`ClusterClient` era.
|
||||
|
||||
### What an `Applied` ack means: the degraded-apply amendment (Gitea #601)
|
||||
|
||||
Until #601 the node sent `ApplyAck(Applied)` the moment its revision advanced — *before*
|
||||
`OpcUaPublishActor` had even dequeued the address-space rebuild — so an apply that swallowed sink faults
|
||||
(the degraded non-rebuild apply that deliberately does not advance `_lastApplied`) still sealed the
|
||||
deployment green fleet-wide. Now:
|
||||
|
||||
- **The Applied ack waits for the address-space outcome.** `DriverHostActor` sends
|
||||
`RebuildAddressSpace(ReportOutcome: true)`; `OpcUaPublishActor` replies `RebuildCompleted(Degraded, Reason)`
|
||||
on every path (clean / sink faults / failed rebuild / pipeline throw / no usable artifact — an empty plan
|
||||
is clean), and only then is the ack sent. It is a plain `Tell` + reply message — no `Ask`, no `await`, so
|
||||
nothing resumes off the actor thread.
|
||||
- **A missing outcome is degraded, never clean.** If no reply arrives within
|
||||
`DriverHostActor.DefaultApplyOutcomeTimeout` the ack goes out `Applied` + degraded, with a reason starting
|
||||
*"address-space apply outcome not reported in time"* — distinct from a real sink failure (which carries
|
||||
`failedNodes=` / "rebuild failed" / "threw"), so an operator can tell a slow or lost apply from a broken one.
|
||||
The timeout is **derived**, not a free constant: `DeployTimings.ApplyDeadline` (Commons, 2 min — the same
|
||||
value `ConfigPublishCoordinator.DefaultApplyDeadline` reads) minus `DeployTimings.ApplyOutcomeSafetyMargin`
|
||||
(15 s) = **105 s**, so a slow-but-clean rebuild of a large address space is not labelled degraded and the
|
||||
ack still reaches central before its deadline. Change the deadline there and both sides move together.
|
||||
Accepted edge: the timer starts after the artifact read + driver reconcile, so if those alone exceed the
|
||||
15 s margin a never-answered apply can still time the deployment out. A node with no publish actor wired
|
||||
acks clean at once, as before.
|
||||
- **The ack carries an ADDITIVE degraded flag on both transports.** `ApplyAck` gained trailing
|
||||
`Degraded = false, DegradedReason = null` (an old node's DPS JSON decodes clean); `ApplyAckMessage` gained
|
||||
`bool degraded = 6; optional string degraded_reason = 7;` (an old node's gRPC ack decodes clean). The
|
||||
outcome stays `APPLIED` — a degraded node *did* apply the revision.
|
||||
- **Central records it without a status-model change.** `ConfigPublishCoordinator.PersistNodeAck` keeps the
|
||||
node's `NodeDeploymentState.Status = Applied` and writes `FailureReason = "Applied degraded: <reason>"`
|
||||
(`ConfigPublishCoordinator.IsDegraded` = Applied **and** a reason). The deployment still **seals** —
|
||||
every node runs the revision, and "latest sealed" drives drift detection and the publish actor's fallback,
|
||||
so leaving it unsealed would show the fleet a revision behind what it runs — but `Deployment.FailureReason`
|
||||
names the degraded nodes and the seal logs at Warning. Recovered from the rows on singleton failover. A
|
||||
degraded ack alongside a `Failed` one is still `PartiallyFailed`. `/fleet` renders the node as
|
||||
**Applied (degraded)** (caution chip); `/deployments` renders **Sealed (degraded)** with the reason as a tooltip.
|
||||
- **Re-deploying a degraded revision repairs it.** The node's idempotent "already at this revision" short-
|
||||
circuit is skipped while the current revision's apply is degraded or its outcome is still pending — the
|
||||
dispatch re-applies instead, the publish actor re-diffs the un-advanced composition and re-ensures the
|
||||
missing nodes, and the ack reports the new outcome. A degraded **rebuild** is the other shape: it DOES
|
||||
advance `_lastApplied`, so the identical re-deploy diffs to an empty plan — the publish actor remembers
|
||||
that the last advance was degraded and, on that empty plan, re-ensures the whole composition (idempotent,
|
||||
no second rebuild), reporting clean only if that pass is clean.
|
||||
- **A node with a ConfigDb also writes the reason on its own row** (`ApplyAck.DegradedReasonPrefix`), because
|
||||
central only records it when it *accepts* the ack — a timed-out deployment's late ack is discarded.
|
||||
- **Mixed-version fleets.** An old node never sets the flag, so its acks are treated exactly as before
|
||||
(clean). A new node acking an old central: the old coordinator ignores the unknown field and seals as it
|
||||
always did — no worse than today.
|
||||
|
||||
Phase 2 changes no redundancy semantics: the Primary gate, ServiceLevel, and the singleton layout
|
||||
are untouched, and `redundancy-state` itself stays on DPS (it is bidirectional and built from
|
||||
`Cluster.State`, which makes it genuinely mesh-bound — the hardest remaining dependency before the
|
||||
|
||||
@@ -112,6 +112,23 @@ it — but it does mean the panel cannot answer "which node saw this?". Adding t
|
||||
widening the wire record, not changing the page. The one sub-instance breakdown the channel *does*
|
||||
carry is per-host connectivity, below.
|
||||
|
||||
### Driver diagnostics ride `driver-health` (Gitea #640)
|
||||
|
||||
A driver may implement the optional `IDriverDiagnostics` (Core.Abstractions): a pure in-memory
|
||||
`IReadOnlyDictionary<string,string> GetDiagnostics()` of operator-facing facts that are not driver
|
||||
*state* (S7's negotiated PDU size is the first; others opt in later). `DriverInstanceActor` pulls it on
|
||||
every health publish (try/caught, unguarded by the invoker like `GetHostStatuses`), bounds it
|
||||
(`DriverDiagnostics.Bound`: 16 entries, text truncated to 200 chars, ordinal key order), and carries it as
|
||||
`DriverHealthChanged.Diagnostics` -> proto `DriverHealth.has_diagnostics = 13` / `map<string,string>
|
||||
diagnostics = 14` (additive; old nodes never set them, old central ignores them) -> `HostsDriverRow.DiagnosticItems`,
|
||||
rendered as a small key/value list under the driver name on `/hosts` (nothing when empty).
|
||||
|
||||
- **null ≠ empty**: null = driver lacks the capability; the presence flag preserves an empty bag.
|
||||
- **The diagnostics digest is part of the publish fingerprint, and must stay there** - a diagnostic fact
|
||||
(e.g. PDU size changing on a reconnect) moves no other component, so without it the dedup swallows the
|
||||
publish. Pinned by `DriverInstanceActorDiagnosticsTests.A_diagnostics_change_republishes_despite_otherwise_unchanged_health`
|
||||
(stable-health stub - the shared `StubDriver` returns `UtcNow` and would pass vacuously).
|
||||
|
||||
### Per-host connectivity rides `driver-health` (Gitea #521)
|
||||
|
||||
`DriverHealthChanged.HostStatuses` carries each driver's `IHostConnectivityProbe.GetHostStatuses()`
|
||||
|
||||
@@ -264,6 +264,10 @@ analog alarms are a follow-up) and **disabled by default** — shops running FT
|
||||
Alarm & Events should keep it off and take alarms through the native route, since
|
||||
the projection semantics don't exactly mirror Rockwell FT A&E.
|
||||
|
||||
Each event carries `ConditionId` = the subscribed reference (the alarm tag's RawPath — the key the host
|
||||
routes native transitions on) and `Kind` = `Raise` / `Clear`. Before Gitea #641 the projection sent
|
||||
`"{ref}#active"` with no kind, so the host dropped every ALMD transition as unrouted.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/` cover the driver, host-address parser, UDT planner, and alarm projection via fake tag runtimes.
|
||||
|
||||
@@ -69,7 +69,7 @@ pieces for driver-side routing (e.g. deciding a bit needs read-modify-write):
|
||||
| `I:0/0` / `O:1/2` / `S:1` | Input / output / status system files (no file number) |
|
||||
| `I:017/07` | **PLC-5 only:** `I:`/`O:` word and bit indices are **octal** (#244); a digit 8/9 is a parse error |
|
||||
| `RTC:0.HR` / `HSC:0.ACC` | **MicroLogix only:** function files RTC, HSC, DLS, MMI, PTO, PWM, STI, EII, IOS, BHI (#245) |
|
||||
| `N7:[N7:0]` / `N[N7:0]:5` | Indirect (index word read from the PLC) — parsed, **not supported at runtime** (#247) |
|
||||
| `N7:[N7:0]` / `N[N7:0]:5` | Indirect (index word read from the PLC) — resolved at read/write time (#646) |
|
||||
|
||||
`AbLegacyDataType` covers the corresponding PCCC types: `Bit`, `Int` (N), `Long`
|
||||
(L), `Float` (F), `AnalogInt` (A), `String` (ST), and the `TimerElement` /
|
||||
@@ -118,11 +118,18 @@ physical inputs and may reject the write.
|
||||
members default to Int32). ⚠️ **Wire-unverified on both transports:** there is no capture and no simulator
|
||||
coverage; the libplctag path passes the name through and the native transport has no file-type code for them, so
|
||||
it refuses the tag as `Unsupported` (`BadNotSupported` to the client; see Wire evidence).
|
||||
- **Indirect addressing (#247).** `N7:[N7:0]` (word from another address) and `N[N7:0]:5` (file from another
|
||||
address) parse, one level deep (the inner address must be direct, and carries the family's octal rule).
|
||||
**Runtime resolution is not implemented** (it needs a pre-read of the index word and per-resolved-address
|
||||
runtimes): reads and writes of an indirect tag answer `BadNotSupported` before any runtime is created, on both
|
||||
transports.
|
||||
- **Indirect addressing (#247 parse, #646 runtime).** `N7:[N7:0]` (word from another address) and `N[N7:0]:5` (file
|
||||
from another address) parse, one level deep (the inner address must be direct, and carries the family's octal rule).
|
||||
At runtime the driver reads the inner address first (same runtime factory, so libplctag and Native alike), requires
|
||||
an integer in range (word/element `0..255`, or `0..999` on PLC-5/LogixPccc; file `0..999`), composes the concrete address and runs the normal
|
||||
read/write path against it. **The index is re-read on every read/write - never cached** - and the previous
|
||||
resolution's handle is released when the index moves. A bad index (negative, too large, float/string source) is
|
||||
`BadOutOfRange` for that tag only (no health impact); an inner-read failure propagates its own status (a wire
|
||||
failure also degrades health like any read). **Writes** resolve then write and are **not atomic**: the index can
|
||||
change between the two PLC accesses. Bit RMW writes (`B3:[N7:0]/4`) resolve first, then RMW the concrete word.
|
||||
The Native transport never sees the bracket form (it receives the composed name), so N-file indirect stays inside
|
||||
the wire-proven envelope; other file types are as (un)verified as their direct forms. Live-gated on `ab_server`
|
||||
by `AbLegacyIndirectAddressingTests` (`N7:0=5`, `N7:5=42`, `N7:[N7:0]` reads 42).
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -211,7 +218,8 @@ Everything below is against the `ab_server` simulator; no real PCCC controller h
|
||||
| N-file write (`N7:5`) | capture `slc500-write-n7` + native `AbLegacyReadSmokeTests` write/read round trip (SLC 500) |
|
||||
| F-file read (`F8`) | one capture, `slc500-read-f8` (codec parity only; no native F write on a wire) |
|
||||
| S (incl. the default probe `S:0`), B, T, C, R, L, ST, A, O, I | **none** — encoded from the PCCC file-type table |
|
||||
| MicroLogix function files (RTC/HSC/...), PLC-5 octal I/O, indirect addressing | **none** — parsed only; native refuses function files and indirect names as Unsupported |
|
||||
| MicroLogix function files (RTC/HSC/...), PLC-5 octal I/O | **none** — parsed only; native refuses function files as Unsupported |
|
||||
| Indirect addressing (`N7:[N7:0]`) | `ab_server` N-file, both transports (#646) - resolved by the driver, the transport sees the composed address |
|
||||
| T/C/R sub-elements (`.PRE`/`.ACC`/`.LEN`/`.POS`), 6-byte T/C/R element layout | **none** |
|
||||
|
||||
### Native known gaps
|
||||
|
||||
+81
-3
@@ -146,7 +146,7 @@ reference them will throw at startup with a message pointing here.
|
||||
| Capability | Wire path | Notes |
|
||||
|------------|-----------|-------|
|
||||
| `IReadable` | `ReadAsync` → `cnc_rdpmcrng` / `cnc_rdparam` / `cnc_rdmacro` | One TCP request/response per read; `Focas.Wire` serializes requests on socket 2 internally |
|
||||
| `IWritable` | returns `BadNotWritable` | OtOpcUa is read-only against FOCAS by design — no `cnc_wrparam` / `pmc_wrpmcrng` / `cnc_wrmacro` path is implemented |
|
||||
| `IWritable` | returns `BadNotWritable` | OtOpcUa is read-only against FOCAS by design — no `cnc_wrparam` / `pmc_wrpmcrng` / `cnc_wrmacro` path is implemented, and none will be (policy: the stranded write/unlock backlog items #268–#271 are won't-do; a write path on a production CNC is a safety decision, not a feature gap) |
|
||||
| `ITagDiscovery` | `DiscoverAsync` | Emits `FOCAS/{device}/{tag}` folders per configured device |
|
||||
| `ISubscribable` | polled via shared `PollGroupEngine` | FOCAS has no push model — subscriptions turn into per-tag polling groups |
|
||||
| `IHostConnectivityProbe` | periodic `cnc_rdcncstat` | Probe cadence is `Probe.Interval`; transitions fire `OnHostStatusChanged` |
|
||||
@@ -224,15 +224,36 @@ What gets populated (all under `FOCAS/{deviceHostAddress}/`, addressed as
|
||||
| `Spindle/{name}/` | `Load` (Float64, % of rated load), `MotorSpeedPct` (Float64, % of maximum speed) — one folder per discovered spindle | `cnc_rdspdlname` once + `cnc_rdspmeter` type 0 + type 1 per tick |
|
||||
| `Program/` | `Name` (filename), `ONumber`, `Number`, `MainNumber`, `Sequence`, `BlockCount` — a field the CNC did not report (refused sub-read, payload without an O-number) reads `BadNoData`, never `0` | `cnc_exeprgname2` + `cnc_rdblkcount` + cached `cnc_rddynamic2` |
|
||||
| `OperationMode/` | `Mode` (int), `ModeText` ("AUTO", "MDI", "EDIT", …) | `cnc_rdopmode` |
|
||||
| `Status/` | `Auto`, `Run`, `Motion`, `Mstb`, `Emergency`, `Alarm`, `Edit`, `TmMode` (raw FANUC codes, Int32) + `RunText` ("RESET", "STOP", "HOLD", "START", "MSTR") — see [CNC status flags](#cnc-status-flags-status) | `cnc_statinfo` (polled on `ProgramPollInterval`) |
|
||||
| `Timers/` | `PowerOnSeconds`, `OperatingSeconds`, `CuttingSeconds`, `CycleSeconds` | `cnc_rdtimer` × 4 |
|
||||
|
||||
### CNC status flags (`Status/`)
|
||||
|
||||
`cnc_statinfo` (the `ODBST` block) is exposed as read-only `FIXED:Status/<field>` nodes (#257). Values are the
|
||||
control's raw codes, published verbatim as Int32 so a client sees exactly what the CNC reported:
|
||||
|
||||
| Field | Codes |
|
||||
|---|---|
|
||||
| `Run` | 0 RESET, 1 STOP, 2 HOLD, 3 START, 4 MSTR (`RunText` is the label) |
|
||||
| `Auto` | operation mode (0 MDI, 1 MEM, 3 EDIT, 4 HND, 5 JOG, … — control-dependent) |
|
||||
| `Motion` | 0 none, 1 axis motion, 2 dwell |
|
||||
| `Mstb` | 0 idle, 1 executing an M/S/T/B function |
|
||||
| `Emergency` | 0 not, 1 emergency stop, 2 reset |
|
||||
| `Alarm` | 0 none, 1 alarm, … (battery-low etc. are control-dependent) |
|
||||
| `Edit`, `TmMode` | program-edit state; T/M mode |
|
||||
|
||||
Author one the same way as any fixed-tree tag — `FIXED:Status/Run` (Int32), `FIXED:Status/RunText` (String).
|
||||
Like `Program/`, the subtree is gated on a bootstrap probe: a control that refuses `cnc_statinfo` gets no
|
||||
`Status/` nodes and the rest of the tree is unaffected. Reads are served from the poll snapshot, so they
|
||||
issue no wire traffic and go `BadCommunicationError` once the device has stopped answering.
|
||||
|
||||
### Per-series node suppression
|
||||
|
||||
The driver probes each optional call once at bootstrap. If the target CNC
|
||||
returns `EW_FUNC` / `EW_NOOPT` / `EW_VERSION` on the wire, the
|
||||
corresponding subtree is **not emitted** — the operator doesn't see nodes
|
||||
that will only ever return `BadDeviceFailure`. Capability suppression
|
||||
covers `Spindle/`, `Program/` + `OperationMode/`, `Timers/`, and
|
||||
covers `Spindle/`, `Program/` + `OperationMode/`, `Status/`, `Timers/`, and
|
||||
per-axis `ServoLoad` independently. Identity + `Axes/*` position reads
|
||||
(which every Fanuc CNC supports) are always emitted.
|
||||
|
||||
@@ -265,6 +286,54 @@ fallen back to `PositionDecimalPlaces` and published raw counts.
|
||||
numbers whose meaning is MTB-specific, and which no browse can discover. Those
|
||||
go under the device folder alongside the fixed subtree.
|
||||
|
||||
### Parts count (`PARAM:6711`)
|
||||
|
||||
There is deliberately **no** dedicated parts-count node (#258): the count is an ordinary CNC parameter, so it
|
||||
is already readable as a hand-authored `PARAM:` tag (Int32), and a fixed `Parts/` subtree would hard-code
|
||||
parameter numbers that are control- and MTB-specific. On the standard FANUC 0i / 16i / 30i parameter map:
|
||||
|
||||
| Address | Parameter | Meaning |
|
||||
|---|---|---|
|
||||
| `PARAM:6711` | No. 6711 | Number of parts machined (resets with the operator's clear) |
|
||||
| `PARAM:6712` | No. 6712 | Total number of parts machined (cumulative) |
|
||||
| `PARAM:6713` | No. 6713 | Number of parts required (the target) |
|
||||
|
||||
The count-up trigger (which M code or cycle end increments it) is set by No. 6710 and bit 0 of No. 6700,
|
||||
so confirm in the machine's parameter manual that the counter is actually enabled and that 6711–6713 mean
|
||||
what the MTB says they mean before relying on them. Cycle time per part is not provided; the `Timers/`
|
||||
subtree exposes `CycleSeconds` and a client can difference it across a `PARAM:6711` change.
|
||||
|
||||
## PMC range coalescing
|
||||
|
||||
A poll batch that reads several nearby PMC tags used to issue one `pmc_rdpmcrng` per tag. The driver now
|
||||
merges tags of the same PMC letter and width that sit close together into **one** range read and slices each
|
||||
tag's value out of the result (#266). Single, isolated PMC tags — the common "one tag per area" case — are
|
||||
read exactly as before.
|
||||
|
||||
```jsonc
|
||||
"PmcCoalesce": {
|
||||
"Enabled": true, // default
|
||||
"MaxSpanBytes": 128, // hard cap on one merged range
|
||||
"MaxGapBytes": 8 // bytes nobody asked for tolerated between two merged tags; 0 = only strictly adjacent
|
||||
}
|
||||
```
|
||||
|
||||
Rules worth knowing:
|
||||
|
||||
- Tags merge only within the same letter and width class (Bit and Byte are one class; Word, Long, Float, Double
|
||||
are separate). A multi-byte tag joins a range only when its offset from the range start is a multiple of its
|
||||
width, because the CNC splits a range into width-sized slots from the start.
|
||||
- **Spare gap bytes must be valid PMC addresses.** A gap that crosses a hole in the CNC's PMC map makes the CNC
|
||||
refuse the whole range. That is handled, not fatal: a refused range is remembered per device and its tags are
|
||||
read **individually**, each with its own true status (a refusal is never stamped onto healthy members). If
|
||||
that happens routinely, lower `MaxGapBytes` (0 disables gap-spanning entirely) or set `Enabled` to `false`.
|
||||
- A transport failure or timeout on the merged read fails every member with `BadCommunicationError` and does
|
||||
**not** retry per tag — the link is down, so per-tag retries would only multiply timeouts.
|
||||
- Read-only: this changes `pmc_rdpmcrng` reads only; nothing here can write.
|
||||
|
||||
The config key lives in the driver `Config` JSON. The AdminUI typed form does not yet surface it (an unknown
|
||||
key edited through that form is dropped), so set it in the raw config.
|
||||
|
||||
## Alarm projection
|
||||
|
||||
Alarm surfacing is **disabled by default** because the polling cost is wasted
|
||||
@@ -294,6 +363,13 @@ Every alarm transition fires `OnAlarmEvent` with:
|
||||
Cleared alarms fire a second event with `" (cleared)"` appended to the message
|
||||
so downstream consumers can ignore the clear if they only care about raises.
|
||||
|
||||
> ⚠️ **Not routable to a v3 raw condition (found under Gitea #641).** The server routes a native transition
|
||||
> only when `ConditionId` equals an alarm-bearing raw tag's RawPath, and the host subscribes those RawPaths —
|
||||
> but this projection filters devices by **host address** (so a RawPath list matches no device and nothing is
|
||||
> polled) and emits `"{host}#{Type}:{AlarmNumber}"` with no `Kind`. FOCAS native alarms therefore reach no
|
||||
> Part 9 condition today. Fixing it needs a mapping decision (the CNC exposes one flat alarm list per device,
|
||||
> not one condition per tag), so it is tracked separately rather than patched.
|
||||
|
||||
## Handle recycling
|
||||
|
||||
FANUC CNCs have a finite FWLIB handle pool (~5–10 concurrent connections) and
|
||||
@@ -324,7 +400,9 @@ latency spike once per cadence.
|
||||
driver surface via `FakeFocasClient`. Includes the alarm-projection raise /
|
||||
clear diffing tests.
|
||||
- **Integration tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/`
|
||||
hold the Docker simulator scaffold; see
|
||||
hold the Docker simulator scaffold (`OTOPCUA_FOCAS_SIM_ENDPOINT=host:port`; they skip when it is absent;
|
||||
`Series/WireBackendStatusAndPmcTests` covers `Status/` and PMC coalescing against the mock's `statinfo` and
|
||||
`pmc` state); see
|
||||
[`docs/v2/implementation/focas-wire-protocol.md`](../v2/implementation/focas-wire-protocol.md)
|
||||
for what the simulator emits vs. real CNC behaviour.
|
||||
- **E2E script** — `scripts/e2e/test-focas.ps1` stages Host + Proxy + a real
|
||||
|
||||
@@ -94,6 +94,19 @@ request/response handshake — is a different mechanism and remains deferred; se
|
||||
| `IRediscoverable` | watches the Agent's `Header.instanceId` | No consumer wires it today — see [Known limitations](#known-limitations) |
|
||||
| `IAlarmSource` | CONDITION observations off the same `/sample` pump → Part 9 transitions keyed by the alarm tag's RawPath | Opt-in per raw tag (`alarm` object) — see [CONDITION alarms](#condition-alarms-626) |
|
||||
|
||||
### Feature status at a glance (#613)
|
||||
|
||||
| Feature | Status | Detail |
|
||||
|---------|--------|--------|
|
||||
| SAMPLE / EVENT / CONDITION values | **Supported** | EVENT values are Strings carrying the Agent's own word |
|
||||
| CONDITION as native Part 9 alarms (#626) | **Supported, opt-in** per raw tag via the `alarm` object | [CONDITION alarms](#condition-alarms-626) |
|
||||
| `TIME_SERIES` arrays (#627) | **Supported** as variable-length `double[]` (Float64 tags only) | [TIME_SERIES arrays](#time_series-arrays-627) |
|
||||
| One Agent client per driver, one `/raw` Device (#628) | **Enforced** (`MTConnectMultipleDevices` deploy error); multi-device Agents use several driver instances | [Known limitations](#known-limitations) |
|
||||
| Write-back (`IWritable`) | **Supported, off by default** (`Write.Enabled` + Agent `AllowPut`) | [Write-back](#write-back--observation-injection-off-by-default) |
|
||||
| EVENT controlled vocabularies as OPC UA enumerations | **Not supported** (stay String); not scheduled | See [Deferred](#deferred-not-in-this-build) |
|
||||
| `DATA_SET` / `TABLE` representations | **Not supported** - read as `BadNotSupported` | [Quality mapping](#quality-mapping) |
|
||||
| MTConnect Interfaces, SHDR ingest | Not built | [Deferred](#deferred-not-in-this-build) |
|
||||
|
||||
**`IReadable.ReadAsync` is never called by the running server.** The
|
||||
production data plane is entirely `ISubscribable` — `DriverInstanceActor`
|
||||
subscribes once and lives off `OnDataChange`. `ReadAsync` exists for the
|
||||
@@ -462,14 +475,15 @@ and records a state only when an alarm handler was attached to receive it. Conse
|
||||
condition the driver owns `BadNoCommunication` on the connectivity edge (#477) and keeps its Active
|
||||
state; the reconnect edge re-annotates Good, after which the driver re-asserts any still-`Unavailable`
|
||||
condition's Bad quality on its re-subscribe.
|
||||
- A re-subscribe with a **changed** alarm set clears the whole told record: only an alarm-bearing raw-tag
|
||||
delta changes the set, the server applies that as a full rebuild, and a rebuild recreates every
|
||||
condition node inactive — so every active condition is re-asserted onto its new node. The **same** set
|
||||
(a reconnect) keeps the record. (A rebuild that leaves this driver's alarm set unchanged triggers no
|
||||
re-subscribe; that is a host-wide gap shared with every native source.)
|
||||
- A re-subscribe with a **changed** alarm set drops the told record of only the conditions that **left**
|
||||
the set. The server carries every native condition's state (acknowledgement included) across a full
|
||||
rebuild itself (Gitea #641 — see `docs/AlarmTracking.md`), so re-raising a surviving condition would
|
||||
only un-acknowledge it; a condition that left and later comes back was materialised fresh and is told
|
||||
again. The **same** set (a reconnect) keeps the whole record. A rebuild that leaves the alarm set
|
||||
unchanged triggers no re-subscribe, and needs none — the server-side carry-over covers it.
|
||||
- An alarm-only driver (no value tags) still runs the `/sample` pump.
|
||||
|
||||
**EVENT controlled vocabularies as OPC UA enumerations — won't fix.** EVENT values (`Execution`,
|
||||
**EVENT controlled vocabularies as OPC UA enumerations — not built (parked, low priority).** EVENT values (`Execution`,
|
||||
`ControllerMode`, …) stay Strings carrying the Agent's own word; an enumeration would need a per-type
|
||||
vocabulary table that vendors extend freely.
|
||||
|
||||
@@ -571,7 +585,9 @@ for anything beyond values-and-conditions.
|
||||
- **P1.5 fast-follow** (design
|
||||
[§9](../plans/2026-07-15-mtconnect-driver-design.md#9-phasing--effort)): CONDITION → native Part 9
|
||||
alarms **shipped** (#626, see [CONDITION alarms](#condition-alarms-626)); EVENT
|
||||
controlled-vocabulary values → OPC UA enumerations is **won't-fix**.
|
||||
controlled-vocabulary values → OPC UA enumerations is **not built** (EVENTs stay Strings), and
|
||||
structured `DATA_SET`/`TABLE` observations stay `BadNotSupported`; both are tracked as a low-priority
|
||||
follow-up, not part of P1.5.
|
||||
- **P2 (on demand): SHDR adapter ingest.** A `SourceMode: "Agent" | "Shdr"`
|
||||
switch that opens the raw pipe-delimited SHDR TCP socket directly. Loses
|
||||
auto-discovery (no device model without an Agent in front, so
|
||||
|
||||
@@ -91,6 +91,24 @@ rather than surfacing as a runtime surprise.
|
||||
| `IAlarmSource` | `SubscribeAlarmsAsync` (EventFilter) + `AcknowledgeAsync` | Subscribes to upstream alarm/condition events and forwards acks |
|
||||
| `IHistoryProvider` | `ReadRawAsync` / `ReadProcessedAsync` / `ReadAtTimeAsync` → `Session.HistoryReadAsync` | **Unique to this driver** — passthrough history read against the upstream server |
|
||||
|
||||
> **Native alarms (#619).** An alarm raw tag is `TagConfig` `{ "nodeId": "<upstream SourceNode>", "alarm": {...} }`.
|
||||
> The host subscribes with RawPaths and routes on (driver, RawPath), so the driver matches each
|
||||
> event's `ConditionId` (then its `SourceNode`) against the tag's authored `nodeId` and reports the **RawPath** as
|
||||
> `ConditionId`. Author `nodeId` as the upstream **condition** node for a 1:1 mapping (required when one source carries
|
||||
> several conditions, e.g. Hi + HiHi); authoring the **source** node folds every condition of that source into one
|
||||
> tag. `Acknowledge` is called on the condition node of the last event seen for the RawPath **with that event's
|
||||
> EventId** (Part 9 §5.7.3). The event's `ActiveState/Id` + `AckedState/Id` are selected and turned into the
|
||||
> `Raise`/`Acknowledge`/`Clear`/`Retrigger` kinds the host projector consumes (without a kind no upstream alarm could ever
|
||||
> show active); the kinds are emitted from the state **last told to the host** per RawPath, so an identical re-delivery
|
||||
> emits nothing (no re-raise, which would un-ack). Condition **branch** events (non-null `BranchId`) are ignored.
|
||||
> **`ConditionRefresh`** is called after the event item is created, after every in-place reconnect, and on a
|
||||
> `RefreshRequired` event, so a condition already active upstream is reported at subscribe; `RefreshStart`/`RefreshEnd`
|
||||
> markers are dropped. A server without it (`BadNotSupported`/`BadMethodInvalid`/…) is logged once and tolerated.
|
||||
> Last-told state survives a re-initialise (the host re-subscribes the same set over nodes it did not rebuild) and is
|
||||
> forgotten when the subscribed set changes (the host rebuilt, recreating the nodes inactive). Index keys are server-stable (`nsu=`), so a namespace reorder across a reconnect keeps
|
||||
> matching. docker-dev seeds an opc-plc driver (`MAIN-opcua`); the alarm tag is
|
||||
> created only when `@OpcPlcAlarmSourceNodeId` is set in `docker-dev/seed/seed-clusters.sql`.
|
||||
|
||||
> This driver does **not** implement `IRediscoverable` — there is no
|
||||
> push-driven rediscovery signal from a remote OPC UA server in this driver.
|
||||
> `IHistoryProvider` is implemented by no other driver; history reads for every
|
||||
|
||||
@@ -49,6 +49,13 @@ The driver ctor change that made this possible:
|
||||
- `S7_1500SmokeTests.Driver_write_then_read_round_trip_on_scratch_word` —
|
||||
`DB1.DBW100` write → read-back; proves write path + buffer visibility
|
||||
|
||||
- `S7_1500CodecTests` (#632/#638) — live python-snap7 coverage of the STRING / WSTRING / CHAR / WCHAR /
|
||||
DTL / DATE / TOD / TIME / S5TIME codecs: seeded reads (DB1 800..960, distinct non-zero), BadOutOfRange
|
||||
reads of undecodable buffers, write round-trips and BadOutOfRange refused writes (scratch DB1 1100..1199;
|
||||
needs `S7_SIM_ALLOW_WRITES=1`), the `LocalTsap`/`RemoteTsap`/`ConnectionType` override keys (proves they
|
||||
reach the wire; snap7 accepts any TSAP, so TSAP semantics remain hardware-only), and the negotiated PDU
|
||||
size (`NegotiatedPduSize` / `GetDiagnostics`). Seed map in `Docker/README.md`.
|
||||
|
||||
### Unit
|
||||
|
||||
- `S7AddressParserTests` — S7 address syntax (`DB1.DBD0`, `M10.3`, `IW4`, etc.)
|
||||
|
||||
+8
-3
@@ -223,8 +223,10 @@ The PDU size the CPU negotiates during the connection handshake (typically 240 b
|
||||
extended PDU) is snapshotted at every (re)connect and exposed as `S7Driver.NegotiatedPduSize` /
|
||||
`S7Driver.GetDiagnostics()["S7.NegotiatedPduSize"]` (0 before the first connect and after shutdown). It is
|
||||
also written to the `S7Driver connected` / `reconnected` log lines and to the AdminUI Test Connect
|
||||
message (`S7 connected (CPU S71500, PDU 240 bytes)`). `DriverHealth` has no diagnostics bag in this
|
||||
codebase, so the value is not yet on the `/hosts` health snapshot — adding one is a Core change.
|
||||
message (`S7 connected (CPU S71500, PDU 240 bytes)`). S7 also implements the optional
|
||||
`IDriverDiagnostics` capability (explicitly, string-valued: `S7.NegotiatedPduSize` = `"480"`, omitted while 0 =
|
||||
not negotiated yet), so the value rides the driver-health snapshot and shows under the driver name on the
|
||||
AdminUI `/hosts` page (Gitea #640). See `docs/Telemetry.md` ("Driver diagnostics ride `driver-health`").
|
||||
|
||||
## Capability surface
|
||||
|
||||
@@ -286,7 +288,10 @@ fresh drop after a healthy period reconnects immediately; worst-case recovery la
|
||||
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/` cover the
|
||||
address parser, the reinterpret/box conversions, and the driver lifecycle.
|
||||
- **Integration fixture** — a Docker S7 simulator on the shared docker host; see
|
||||
[S7-Test-Fixture.md](S7-Test-Fixture.md) for the coverage map and endpoint.
|
||||
[S7-Test-Fixture.md](S7-Test-Fixture.md) for the coverage map and endpoint. The wide/date-time
|
||||
codecs (STRING, WSTRING, CHAR, WCHAR, DTL, DATE, TOD, TIME, S5TIME) and the TSAP override keys have
|
||||
live snap7 coverage in `S7_1500CodecTests`; an unrepresentable STRING/DT write is `BadOutOfRange`
|
||||
(not a comms fault) like the other codecs.
|
||||
- **CLI** — [Driver.S7.Cli.md](../Driver.S7.Cli.md) documents the standalone
|
||||
read/write/probe CLI for manual checks against a real or simulated CPU.
|
||||
|
||||
|
||||
@@ -7,9 +7,32 @@ public enum ApplyAckOutcome { Applied, Failed }
|
||||
/// <summary>
|
||||
/// Per-node acknowledgment returned by <c>DriverHostActor</c> to the dispatching coordinator.
|
||||
/// </summary>
|
||||
/// <param name="DeploymentId">The deployment being acknowledged.</param>
|
||||
/// <param name="NodeId">The acknowledging node.</param>
|
||||
/// <param name="Outcome">Whether the node applied the deployment.</param>
|
||||
/// <param name="FailureReason">Why the apply failed; null on <see cref="ApplyAckOutcome.Applied"/>.</param>
|
||||
/// <param name="CorrelationId">The dispatch's correlation id.</param>
|
||||
/// <param name="Degraded">
|
||||
/// Gitea #601: <see langword="true"/> when the node applied the deployment (the revision advanced) but
|
||||
/// its OPC UA address-space apply was DEGRADED — swallowed per-node sink faults, a failed rebuild, or no
|
||||
/// outcome reported in time — so the served address space may be partial. Only meaningful with
|
||||
/// <see cref="ApplyAckOutcome.Applied"/>. Trailing and defaulted so an ack from a node built before
|
||||
/// this field existed (DPS JSON or gRPC without field 6) decodes exactly as it always did: clean.
|
||||
/// </param>
|
||||
/// <param name="DegradedReason">A short, operator-readable description of the degradation, or null.</param>
|
||||
public sealed record ApplyAck(
|
||||
DeploymentId DeploymentId,
|
||||
NodeId NodeId,
|
||||
ApplyAckOutcome Outcome,
|
||||
string? FailureReason,
|
||||
CorrelationId CorrelationId);
|
||||
CorrelationId CorrelationId,
|
||||
bool Degraded = false,
|
||||
string? DegradedReason = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gitea #601 — prefix of <c>NodeDeploymentState.FailureReason</c> on an <b>Applied</b> row whose
|
||||
/// address-space apply was degraded. Shared by central (which writes it from the ack) and a node with
|
||||
/// a ConfigDb (which writes its own row), so both spell the row identically.
|
||||
/// </summary>
|
||||
public const string DegradedReasonPrefix = "Applied degraded: ";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
||||
|
||||
/// <summary>
|
||||
/// The deploy-path timing contract shared by central (<c>ConfigPublishCoordinator</c>) and the node
|
||||
/// (<c>DriverHostActor</c>). One place, so the node's ack-hold timeout can never drift past the deadline
|
||||
/// central times the deployment out on.
|
||||
/// </summary>
|
||||
public static class DeployTimings
|
||||
{
|
||||
/// <summary>How long central waits for every expected node's ack before sealing the deployment TimedOut
|
||||
/// (<c>ConfigPublishCoordinator.DefaultApplyDeadline</c>).</summary>
|
||||
public static readonly TimeSpan ApplyDeadline = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// <summary>Gitea #601 — how far inside <see cref="ApplyDeadline"/> a node's held Applied ack is released
|
||||
/// when the address-space outcome has not arrived, leaving room for transport latency.</summary>
|
||||
public static readonly TimeSpan ApplyOutcomeSafetyMargin = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>Gitea #601 — the node's default apply-outcome timeout: <see cref="ApplyDeadline"/> −
|
||||
/// <see cref="ApplyOutcomeSafetyMargin"/> (105 s). Long enough that a slow-but-clean rebuild of a large
|
||||
/// address space is not labelled degraded, short enough to ack before central gives up.</summary>
|
||||
/// <remarks>The timer is armed after the artifact read and driver reconcile, so if those alone took
|
||||
/// longer than the margin the ack can still land after the deadline — accepted: they are seconds in
|
||||
/// practice, and lengthening the margin would label more slow-but-clean rebuilds degraded.</remarks>
|
||||
public static TimeSpan ApplyOutcomeTimeout => ApplyDeadline - ApplyOutcomeSafetyMargin;
|
||||
}
|
||||
@@ -59,6 +59,12 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
|
||||
/// <para><b>Value-equality caveat</b> — the same one <paramref name="HostStatuses"/> carries: the
|
||||
/// record's generated <c>Equals</c> compares the sample list by REFERENCE.</para>
|
||||
/// </param>
|
||||
/// <param name="Diagnostics">
|
||||
/// Operator-facing driver diagnostics from <c>IDriverDiagnostics.GetDiagnostics()</c> (e.g. S7's
|
||||
/// negotiated PDU size), bounded and in ordinal key order; null when the driver does not implement the
|
||||
/// capability (Gitea #640). Additive, never a state change. Same by-reference value-equality caveat as
|
||||
/// <paramref name="HostStatuses"/>.
|
||||
/// </param>
|
||||
public sealed record DriverHealthChanged(
|
||||
string ClusterId,
|
||||
string DriverInstanceId,
|
||||
@@ -70,7 +76,8 @@ public sealed record DriverHealthChanged(
|
||||
DateTime? RediscoveryNeededUtc = null,
|
||||
string? RediscoveryReason = null,
|
||||
IReadOnlyList<HostConnectivityStatus>? HostStatuses = null,
|
||||
SkippedTagReport? SkippedTags = null)
|
||||
SkippedTagReport? SkippedTags = null,
|
||||
IReadOnlyDictionary<string, string>? Diagnostics = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// DPS topic name. Both the runtime <c>AkkaDriverHealthPublisher</c> and the AdminUI
|
||||
|
||||
@@ -216,6 +216,11 @@ public static class MeshTransportDtoMapper
|
||||
if (message.FailureReason is not null)
|
||||
proto.FailureReason = message.FailureReason;
|
||||
|
||||
// #601 — additive fields; a clean ack leaves both unset, byte-identical to a pre-#601 encode.
|
||||
proto.Degraded = message.Degraded;
|
||||
if (message.DegradedReason is not null)
|
||||
proto.DegradedReason = message.DegradedReason;
|
||||
|
||||
return proto;
|
||||
}
|
||||
|
||||
@@ -249,7 +254,10 @@ public static class MeshTransportDtoMapper
|
||||
NodeId.Parse(proto.NodeId),
|
||||
outcome,
|
||||
proto.HasFailureReason ? proto.FailureReason : null,
|
||||
CorrelationId.Parse(proto.CorrelationId));
|
||||
CorrelationId.Parse(proto.CorrelationId),
|
||||
// #601 — absent on an old node's ack, which decodes as false/null: today's clean ack.
|
||||
Degraded: proto.Degraded,
|
||||
DegradedReason: proto.HasDegradedReason ? proto.DegradedReason : null);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Envelope
|
||||
|
||||
@@ -69,6 +69,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
|
||||
/// <inheritdoc />
|
||||
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void CompleteAddressSpaceApply() => _inner.CompleteAddressSpaceApply();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => _inner.RaiseNodesAddedModelChange(affectedNodeId, realm);
|
||||
|
||||
|
||||
@@ -185,6 +185,19 @@ public interface IOpcUaAddressSpaceSink
|
||||
/// </summary>
|
||||
void RebuildAddressSpace();
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 — mark the END of one address-space apply pass (after <see cref="RebuildAddressSpace"/>, if
|
||||
/// one ran, and every materialise / notifier-wiring call that followed it). An SDK-backed sink carries each
|
||||
/// native condition's live Part 9 state across a rebuild and restores it when the same condition is
|
||||
/// re-materialised; this is where it reports the ONE condition event per restored condition (now that
|
||||
/// every notifier folder is wired) and DISCARDS any captured state no materialise claimed, so a later
|
||||
/// deploy that re-adds the same id gets a fresh node rather than a resurrection. <c>OpcUaPublishActor</c>
|
||||
/// calls it once at the end of every apply, rebuild or not. Idempotent; the default is a no-op.
|
||||
/// </summary>
|
||||
void CompleteAddressSpaceApply()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Announce that nodes were added at runtime (discovered-node injection) under
|
||||
/// <paramref name="affectedNodeId"/> so subscribed clients refresh their browse
|
||||
|
||||
@@ -105,6 +105,11 @@ message ApplyAckMessage {
|
||||
ApplyOutcome outcome = 3;
|
||||
optional string failure_reason = 4; // nullable in the record — presence distinguishes null from ""
|
||||
string correlation_id = 5; // CorrelationId (Guid) in its "N" hex form
|
||||
// Gitea #601 — ADDITIVE. True when the node applied the deployment but its address-space apply was
|
||||
// degraded (sink faults, failed rebuild, or no outcome in time). An old node never sets it, so it decodes
|
||||
// as false: exactly today's clean ack. Only meaningful with APPLY_OUTCOME_APPLIED.
|
||||
bool degraded = 6;
|
||||
optional string degraded_reason = 7; // short operator-readable reason; presence = set
|
||||
}
|
||||
|
||||
// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy.ApplyAckOutcome, shifted by one so 0 stays
|
||||
|
||||
@@ -82,6 +82,12 @@ message DriverHealth {
|
||||
// proto3, which a repeated field does not. So no companion has_ flag (and one would collide: protoc
|
||||
// already generates a HasSkippedTags property for this field).
|
||||
SkippedTags skipped_tags = 12;
|
||||
// Driver diagnostics bag (Gitea #640), e.g. S7's negotiated PDU size. Additive: an old node never sets
|
||||
// these and an old central ignores them. A map cannot distinguish absent from empty, and the two differ
|
||||
// ("driver has no IDriverDiagnostics" vs "it has one and currently has nothing to say"), so the presence
|
||||
// flag carries that bit - the same shape as has_host_statuses.
|
||||
bool has_diagnostics = 13;
|
||||
map<string, string> diagnostics = 14;
|
||||
}
|
||||
|
||||
// Mirrors ZB.MOM.WW.OtOpcUa.Core.Abstractions.SkippedTagReport.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Optional driver capability: expose a small bag of operator-facing diagnostic key/value pairs
|
||||
/// (e.g. S7's negotiated PDU size) that ride the driver-health snapshot to the AdminUI <c>/hosts</c>
|
||||
/// page (Gitea #640).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why a bag rather than fields on <see cref="DriverHealth"/>.</b> The facts worth showing are
|
||||
/// driver-specific and do not change the driver's <i>state</i>; a typed field per driver would
|
||||
/// widen the core record and the telemetry proto for every driver. Same additive-never-a-state-change
|
||||
/// rule as <see cref="ISkippedTagReporter"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Values are strings so the telemetry proto stays a plain <c>map<string,string></c>. Keep the
|
||||
/// bag small (<see cref="DriverDiagnostics.MaxEntries"/> entries, text truncated to
|
||||
/// <see cref="DriverDiagnostics.MaxValueLength"/>): it is published on every state change.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IDriverDiagnostics
|
||||
{
|
||||
/// <summary>
|
||||
/// Snapshot of the driver's diagnostics. Must be a pure in-memory read with no I/O — it is called on
|
||||
/// the health-publish path, deliberately NOT through the resilience pipeline.
|
||||
/// </summary>
|
||||
/// <returns>The key/value pairs; empty when there is nothing to show yet.</returns>
|
||||
IReadOnlyDictionary<string, string> GetDiagnostics();
|
||||
}
|
||||
|
||||
/// <summary>Bounds and helpers shared by every consumer of <see cref="IDriverDiagnostics"/>.</summary>
|
||||
public static class DriverDiagnostics
|
||||
{
|
||||
/// <summary>Maximum number of entries carried on a health snapshot; extras are dropped (ordinal key order).</summary>
|
||||
public const int MaxEntries = 16;
|
||||
|
||||
/// <summary>Maximum length of a key or value carried on a health snapshot; longer text is truncated.</summary>
|
||||
public const int MaxValueLength = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Normalises a driver's bag for publishing: ordinal key order, capped entry count, truncated text.
|
||||
/// Deterministic so the same facts always produce the same fingerprint digest.
|
||||
/// </summary>
|
||||
/// <param name="raw">The bag the driver returned.</param>
|
||||
/// <returns>A bounded, ordered copy.</returns>
|
||||
public static IReadOnlyDictionary<string, string> Bound(IReadOnlyDictionary<string, string> raw)
|
||||
{
|
||||
var result = new SortedDictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var kv in raw.OrderBy(k => k.Key, StringComparer.Ordinal))
|
||||
{
|
||||
if (result.Count >= MaxEntries) break;
|
||||
result[Truncate(kv.Key ?? "")] = Truncate(kv.Value ?? "");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Truncate(string s) => s.Length <= MaxValueLength ? s : s[..MaxValueLength];
|
||||
}
|
||||
@@ -34,6 +34,10 @@ public interface IDriverHealthPublisher
|
||||
/// <see cref="ISkippedTagReporter"/>. Contradicts the "Healthy, 0 errors, reads advancing" reading
|
||||
/// of a driver that is in fact ignoring part or all of its configuration (Gitea #541).
|
||||
/// </param>
|
||||
/// <param name="diagnostics">
|
||||
/// Bounded driver diagnostics from <see cref="IDriverDiagnostics.GetDiagnostics"/>; null when the
|
||||
/// driver is not an <see cref="IDriverDiagnostics"/> (Gitea #640).
|
||||
/// </param>
|
||||
void Publish(
|
||||
string clusterId,
|
||||
string driverInstanceId,
|
||||
@@ -42,7 +46,8 @@ public interface IDriverHealthPublisher
|
||||
DateTime? rediscoveryNeededUtc = null,
|
||||
string? rediscoveryReason = null,
|
||||
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null,
|
||||
SkippedTagReport? skippedTags = null);
|
||||
SkippedTagReport? skippedTags = null,
|
||||
IReadOnlyDictionary<string, string>? diagnostics = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -65,6 +70,7 @@ public sealed class NullDriverHealthPublisher : IDriverHealthPublisher
|
||||
DateTime? rediscoveryNeededUtc = null,
|
||||
string? rediscoveryReason = null,
|
||||
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null,
|
||||
SkippedTagReport? skippedTags = null)
|
||||
SkippedTagReport? skippedTags = null,
|
||||
IReadOnlyDictionary<string, string>? diagnostics = null)
|
||||
{ /* no-op */ }
|
||||
}
|
||||
|
||||
@@ -70,6 +70,12 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
// prior subscriptions before starting the new one is semantically faithful. Snapshotting +
|
||||
// clearing under the same lock DisposeAsync uses guarantees the stale subs can never be
|
||||
// double-owned (and thus never double-disposed) by a racing dispose.
|
||||
// Gitea #641 review — forget only the refs that LEFT the set. A survivor keeps its last-seen InFaulted, so
|
||||
// a re-subscribe (every reconnect, every alarm-set change) does not re-RAISE a still-faulted alarm: the
|
||||
// server carries the condition across a rebuild, and a Raise would un-acknowledge it. A ref that left and
|
||||
// comes back was materialised fresh, so it starts from "not faulted" and is raised again.
|
||||
_driver.AlarmFaultRecord.RetainOnly(sub.SourceNodeIds);
|
||||
|
||||
List<Subscription> stale;
|
||||
lock (_subsLock)
|
||||
{
|
||||
@@ -190,26 +196,31 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
// is intentional and matches the documented behaviour.
|
||||
var severity = ToInt(severityDv.Value);
|
||||
|
||||
var wasFaulted = sub.LastInFaulted.GetValueOrDefault(nodeId, false);
|
||||
sub.LastInFaulted[nodeId] = nowFaulted;
|
||||
var wasFaulted = _driver.AlarmFaultRecord.Exchange(nodeId, nowFaulted);
|
||||
|
||||
// ConditionId is the subscribed reference itself — the alarm tag's RawPath, the key the host routes
|
||||
// native transitions on — and Kind tells the host's projector what changed (Gitea #641). This used
|
||||
// to send "{ref}#active" with no Kind, so the host dropped every ALMD transition as unrouted (and an
|
||||
// Unspecified kind would have projected no state change anyway).
|
||||
if (!wasFaulted && nowFaulted)
|
||||
{
|
||||
_driver.InvokeAlarmEvent(new AlarmEventArgs(
|
||||
sub.Handle, nodeId, ConditionId: $"{nodeId}#active",
|
||||
sub.Handle, nodeId, ConditionId: nodeId,
|
||||
AlarmType: "ALMD",
|
||||
Message: $"ALMD {nodeId} raised",
|
||||
Severity: MapSeverity(severity),
|
||||
SourceTimestampUtc: DateTime.UtcNow));
|
||||
SourceTimestampUtc: DateTime.UtcNow,
|
||||
Kind: AlarmTransitionKind.Raise));
|
||||
}
|
||||
else if (wasFaulted && !nowFaulted)
|
||||
{
|
||||
_driver.InvokeAlarmEvent(new AlarmEventArgs(
|
||||
sub.Handle, nodeId, ConditionId: $"{nodeId}#active",
|
||||
sub.Handle, nodeId, ConditionId: nodeId,
|
||||
AlarmType: "ALMD",
|
||||
Message: $"ALMD {nodeId} cleared",
|
||||
Severity: MapSeverity(severity),
|
||||
SourceTimestampUtc: DateTime.UtcNow));
|
||||
SourceTimestampUtc: DateTime.UtcNow,
|
||||
Kind: AlarmTransitionKind.Clear));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,9 +308,44 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
|
||||
/// <summary>Gets or sets the polling loop task.</summary>
|
||||
public Task Loop { get; set; } = Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the dictionary tracking the last known InFaulted state for each node.</summary>
|
||||
public Dictionary<string, bool> LastInFaulted { get; } = new(StringComparer.Ordinal);
|
||||
/// <summary>
|
||||
/// Gitea #641 review — the last <c>InFaulted</c> the ALMD projection told the server, per alarm RawPath. Owned by
|
||||
/// the <see cref="AbCipDriver"/> rather than a subscription or a projection, so it survives a re-subscribe and a
|
||||
/// re-initialise (both of which happen on every reconnect): the server's condition nodes outlive them, and a
|
||||
/// re-raise there would un-acknowledge. Thread-safe — a retiring poll loop may overlap the new one for a tick.
|
||||
/// </summary>
|
||||
internal sealed class AbCipAlarmFaultRecord
|
||||
{
|
||||
private readonly Dictionary<string, bool> _lastInFaulted = new(StringComparer.Ordinal);
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
/// <summary>Records <paramref name="nowFaulted"/> for <paramref name="nodeId"/>, returning the prior value
|
||||
/// (<c>false</c> when none).</summary>
|
||||
/// <param name="nodeId">The alarm RawPath.</param>
|
||||
/// <param name="nowFaulted">The state just read.</param>
|
||||
/// <returns>The previously recorded state.</returns>
|
||||
public bool Exchange(string nodeId, bool nowFaulted)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var was = _lastInFaulted.GetValueOrDefault(nodeId, false);
|
||||
_lastInFaulted[nodeId] = nowFaulted;
|
||||
return was;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forgets every ref not in <paramref name="nodeIds"/>.</summary>
|
||||
/// <param name="nodeIds">The newly subscribed refs.</param>
|
||||
public void RetainOnly(IReadOnlyList<string> nodeIds)
|
||||
{
|
||||
var keep = new HashSet<string>(nodeIds, StringComparer.Ordinal);
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var k in _lastInFaulted.Keys.Where(k => !keep.Contains(k)).ToList()) _lastInFaulted.Remove(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
private readonly ILogger<AbCipDriver> _logger;
|
||||
private AbCipAlarmProjection _alarmProjection;
|
||||
|
||||
/// <summary>Gitea #641 review — the ALMD projection's last-told InFaulted per alarm ref; outlives every
|
||||
/// projection and subscription (see <see cref="AbCipAlarmFaultRecord"/>).</summary>
|
||||
internal AbCipAlarmFaultRecord AlarmFaultRecord { get; } = new();
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
/// <summary>Occurs when a subscribed tag's value changes.</summary>
|
||||
|
||||
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
/// <item><c>L9:0</c> — long-integer file (SLC 5/05+, 32-bit).</item>
|
||||
/// <item><c>I:017/07</c> — PLC-5 only: I/O word + bit indices are <b>octal</b> (#244); stored decimal.</item>
|
||||
/// <item><c>RTC:0.HR</c> — MicroLogix only: function file (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI, #245).</item>
|
||||
/// <item><c>N7:[N7:0]</c>, <c>N[N7:0]:5</c> — indirect addressing (#247): parsed, not resolved at runtime.</item>
|
||||
/// <item><c>N7:[N7:0]</c>, <c>N[N7:0]:5</c> — indirect addressing (#247 parse, #646 runtime): the driver reads the index first and composes the concrete address.</item>
|
||||
/// </list>
|
||||
/// <para>The libplctag transport takes <see cref="ToLibplctagName"/> as its <c>name=...</c> attribute and the
|
||||
/// native transport re-parses the same string, so both are handed identical (decimal) numbers. This parser
|
||||
@@ -42,8 +42,8 @@ public sealed record AbLegacyAddress(
|
||||
/// <summary>
|
||||
/// True when the file number or the word number is sourced from another PCCC address evaluated
|
||||
/// at runtime (<c>N7:[N7:0]</c> or <c>N[N7:0]:5</c>). Neither transport can encode the bracket
|
||||
/// form, and resolving it needs a pre-read of the index word, which is not implemented — the
|
||||
/// driver answers an indirect tag <c>BadNotSupported</c>.
|
||||
/// form, so the driver (#646) pre-reads the index, substitutes it, and hands the transport the
|
||||
/// concrete address; an indirect name never reaches a transport.
|
||||
/// </summary>
|
||||
public bool IsIndirect => IndirectFileSource is not null || IndirectWordSource is not null;
|
||||
|
||||
|
||||
-8
@@ -123,12 +123,6 @@ public static class AbLegacyTagDefinitionFactory
|
||||
/// The RawPath handed to <see cref="FromTagConfig"/> during inspection — the mapper only copies it
|
||||
/// onto <see cref="AbLegacyTagDefinition.Name"/> and never validates it.
|
||||
/// </summary>
|
||||
private static bool IsIndirectFor(string address, AbLegacyPlcFamily? family)
|
||||
{
|
||||
if (family is { } f) return AbLegacyAddress.TryParse(address, f)?.IsIndirect == true;
|
||||
return Enum.GetValues<AbLegacyPlcFamily>().Any(c => AbLegacyAddress.TryParse(address, c)?.IsIndirect == true);
|
||||
}
|
||||
|
||||
private const string InspectionRawPath = "(inspect)";
|
||||
|
||||
/// <summary>
|
||||
@@ -179,8 +173,6 @@ public static class AbLegacyTagDefinitionFactory
|
||||
{
|
||||
if (AbLegacyAddress.Validate(accepted.Address, family) is { } addressError)
|
||||
warnings.Add($"AbLegacy tag address: {addressError} The driver reads this tag as BadNodeIdUnknown.");
|
||||
else if (IsIndirectFor(accepted.Address, family))
|
||||
warnings.Add($"AbLegacy address '{accepted.Address}' is indirect; indirect addressing is not supported and the tag reads BadNotSupported.");
|
||||
return warnings;
|
||||
}
|
||||
|
||||
|
||||
@@ -388,8 +388,6 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
|
||||
try
|
||||
{
|
||||
// Indirect addresses (N7:[N7:0]) parse but are not resolved at runtime (#247) — answer the
|
||||
// refusal before a runtime is created for a name neither transport can encode.
|
||||
var preParsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
||||
if (preParsed is null)
|
||||
{
|
||||
@@ -399,8 +397,18 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
}
|
||||
if (preParsed.IsIndirect)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
|
||||
continue;
|
||||
// Indirect (#646): read the index word(s) NOW (never cached - the index can change between
|
||||
// polls), compose the concrete address, then run the normal path against it.
|
||||
var res = await ResolveIndirectAsync(device, def, preParsed, cancellationToken).ConfigureAwait(false);
|
||||
if (res.Def is null)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, res.Status, null, now);
|
||||
if (res.TransportFault)
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"transport fault resolving indirect address for {reference}");
|
||||
continue;
|
||||
}
|
||||
def = res.Def;
|
||||
}
|
||||
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
@@ -513,11 +521,21 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
continue;
|
||||
}
|
||||
|
||||
// Indirect addressing is parse-only (#247): refuse before any runtime or RMW read is created.
|
||||
// Indirect (#646): resolve the index first (non-atomic with the write - the index may change
|
||||
// between the two PLC accesses), then write the concrete address through the normal path.
|
||||
if (parsed.IsIndirect)
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
|
||||
continue;
|
||||
var res = await ResolveIndirectAsync(device, def, parsed, cancellationToken).ConfigureAwait(false);
|
||||
if (res.Def is null)
|
||||
{
|
||||
results[i] = new WriteResult(res.Status);
|
||||
if (res.TransportFault)
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"transport fault resolving indirect address for {w.FullReference}");
|
||||
continue;
|
||||
}
|
||||
def = res.Def;
|
||||
parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily)!;
|
||||
}
|
||||
|
||||
// A write the transport refuses outright (native: a T/C/R status-bit sub-element such as T4:0.DN,
|
||||
@@ -841,6 +859,137 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Largest word/element index an indirect source may yield (PCCC element offset is one byte).</summary>
|
||||
private const int MaxIndirectElement = AbLegacyArray.MaxElements - 1;
|
||||
|
||||
/// <summary>PLC-5 data files hold up to 1000 words (extended PCCC offset), unlike SLC/MicroLogix (256).</summary>
|
||||
private const int MaxIndirectElementPlc5 = 999;
|
||||
|
||||
/// <summary>Largest data-file number an indirect source may yield (PLC-5 allows up to 999).</summary>
|
||||
private const int MaxIndirectFile = 999;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an indirect address (#646). Reads each index source through the same runtime/transport path
|
||||
/// (same factory, so libplctag and Native alike), range-checks it, and composes a concrete tag definition
|
||||
/// whose <c>Name</c> is keyed by the resolved address so the normal runtime cache applies. Nothing about the
|
||||
/// index is cached: every call re-reads it. A bad index is <c>BadOutOfRange</c> (per-tag, no health impact);
|
||||
/// an inner-read failure propagates its mapped status (<c>TransportFault</c> true for a wire failure).
|
||||
/// </summary>
|
||||
private async Task<IndirectResolution> ResolveIndirectAsync(
|
||||
DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress parsed, CancellationToken ct)
|
||||
{
|
||||
var fileNumber = parsed.FileNumber;
|
||||
var word = parsed.WordNumber;
|
||||
var maxElement = device.Options.PlcFamily is AbLegacyPlcFamily.Plc5 or AbLegacyPlcFamily.LogixPccc
|
||||
? MaxIndirectElementPlc5 : MaxIndirectElement;
|
||||
|
||||
if (parsed.IndirectFileSource is { } fileSrc)
|
||||
{
|
||||
var r = await ReadIndexAsync(device, fileSrc, ct).ConfigureAwait(false);
|
||||
if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault);
|
||||
if (r.Value is < 0 or > MaxIndirectFile)
|
||||
return new IndirectResolution(null, AbLegacyStatusMapper.BadOutOfRange, false);
|
||||
fileNumber = (int)r.Value!;
|
||||
}
|
||||
if (parsed.IndirectWordSource is { } wordSrc)
|
||||
{
|
||||
var r = await ReadIndexAsync(device, wordSrc, ct).ConfigureAwait(false);
|
||||
if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault);
|
||||
if (r.Value < 0 || r.Value > maxElement)
|
||||
return new IndirectResolution(null, AbLegacyStatusMapper.BadOutOfRange, false);
|
||||
word = (int)r.Value!;
|
||||
}
|
||||
|
||||
var concrete = parsed with
|
||||
{
|
||||
FileNumber = fileNumber, WordNumber = word, IndirectFileSource = null, IndirectWordSource = null,
|
||||
};
|
||||
var concreteText = concrete.ToLibplctagName();
|
||||
var resolvedName = $"{def.Name}=>{concreteText}";
|
||||
|
||||
// The index moved: drop the previous resolution's handle so a sweeping index does not accumulate one
|
||||
// libplctag handle per value it ever took.
|
||||
var previous = device.IndirectLastName.TryGetValue(def.Name, out var prior) ? prior : null;
|
||||
device.IndirectLastName[def.Name] = resolvedName;
|
||||
if (previous is not null && !string.Equals(previous, resolvedName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var oldLock = device.GetRuntimeLock(previous);
|
||||
await oldLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try { EvictRuntime(device, previous); } finally { oldLock.Release(); }
|
||||
}
|
||||
return new IndirectResolution(def with { Name = resolvedName, Address = concreteText }, 0, false);
|
||||
}
|
||||
|
||||
/// <summary>Reads one direct index address as an integer through the shared parent-runtime cache.</summary>
|
||||
private async Task<(long? Value, uint Status, bool TransportFault)> ReadIndexAsync(
|
||||
DeviceState device, AbLegacyAddress inner, CancellationToken ct)
|
||||
{
|
||||
AbLegacyDataType type;
|
||||
if (inner.BitIndex is not null) type = AbLegacyDataType.Bit;
|
||||
else if (inner.FileLetter == "L") type = AbLegacyDataType.Long;
|
||||
else if (inner.FileLetter is "F" or "ST") return (null, AbLegacyStatusMapper.BadOutOfRange, false);
|
||||
else type = AbLegacyDataType.Int;
|
||||
|
||||
// A bit-addressed index reads its parent word and decodes the bit, like the RMW path does.
|
||||
var runtimeName = (inner with { BitIndex = null }).ToLibplctagName();
|
||||
var gate = device.GetRmwLock(runtimeName);
|
||||
await gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
IAbLegacyTagRuntime runtime;
|
||||
try
|
||||
{
|
||||
runtime = await EnsureParentRuntimeAsync(device, runtimeName, ct).ConfigureAwait(false);
|
||||
await runtime.ReadAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (AbTransportException ex) when (IsLocalRefusal(ex))
|
||||
{
|
||||
return (null, AbLegacyStatusMapper.BadNotSupported, false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
EvictParentRuntime(device, runtimeName);
|
||||
return (null, AbLegacyStatusMapper.BadCommunicationError, true);
|
||||
}
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status != 0)
|
||||
{
|
||||
EvictParentRuntime(device, runtimeName);
|
||||
return (null, AbLegacyStatusMapper.MapStatus(status), true);
|
||||
}
|
||||
var raw = runtime.DecodeValue(type, inner.BitIndex);
|
||||
try
|
||||
{
|
||||
return raw switch
|
||||
{
|
||||
bool b => (b ? 1L : 0L, 0u, false),
|
||||
null => (null, AbLegacyStatusMapper.BadOutOfRange, false),
|
||||
_ => (Convert.ToInt64(raw), 0u, false),
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
|
||||
{
|
||||
return (null, AbLegacyStatusMapper.BadOutOfRange, false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static void EvictParentRuntime(DeviceState device, string name)
|
||||
{
|
||||
if (device.ParentRuntimes.TryRemove(name, out var stale))
|
||||
{
|
||||
try { stale.Dispose(); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record IndirectResolution(AbLegacyTagDefinition? Def, uint Status, bool TransportFault);
|
||||
|
||||
private async Task<IAbLegacyTagRuntime> EnsureParentRuntimeAsync(
|
||||
AbLegacyDriver.DeviceState device, string parentName, CancellationToken ct)
|
||||
{
|
||||
@@ -1086,6 +1235,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, IAbLegacyTagRuntime> ParentRuntimes { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Indirect tag name to the resolved-address runtime key it last used (#646), so a moved index evicts its stale handle.</summary>
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, string> IndirectLastName { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Per-key creation locks for <see cref="Runtimes"/> and <see cref="ParentRuntimes"/>.
|
||||
/// A caller holds this before the TryGetValue + Create + InitializeAsync + TryAdd
|
||||
@@ -1181,6 +1334,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
Runtimes.Clear();
|
||||
foreach (var r in ParentRuntimes.Values) r.Dispose();
|
||||
ParentRuntimes.Clear();
|
||||
IndirectLastName.Clear();
|
||||
// Dispose + clear the per-parent RMW gates and the per-runtime/creation locks so
|
||||
// ReinitializeAsync cycles don't orphan their SemaphoreSlim instances (each leaks a
|
||||
// wait handle once contended).
|
||||
|
||||
@@ -31,6 +31,8 @@ public sealed class FocasDriverOptions
|
||||
public FocasHandleRecycleOptions HandleRecycle { get; init; } = new();
|
||||
/// <summary>Gets the fixed tree options.</summary>
|
||||
public FocasFixedTreeOptions FixedTree { get; init; } = new();
|
||||
/// <summary>Gets the PMC range-coalescing options (Gitea #266).</summary>
|
||||
public FocasPmcCoalesceOptions PmcCoalesce { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
|
||||
@@ -41,6 +43,32 @@ public sealed class FocasDriverOptions
|
||||
public int ProbeTimeoutSeconds { get; init; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges adjacent / near PMC reads in one poll batch into fewer <c>pmc_rdpmcrng</c> calls (Gitea #266).
|
||||
/// On by default: a batch with a single PMC tag per area is unaffected, and a coalesced read the CNC
|
||||
/// rejects falls back to per-tag reads, so enabling it cannot make a readable tag unreadable.
|
||||
/// </summary>
|
||||
public sealed class FocasPmcCoalesceOptions
|
||||
{
|
||||
/// <summary>Gets a value indicating whether PMC coalescing is on.</summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Largest range, in bytes, one coalesced read may cover. Bounds the response size and the
|
||||
/// blast radius of a rejected range. Default 128.
|
||||
/// </summary>
|
||||
[Range(1, 1024)]
|
||||
public int MaxSpanBytes { get; init; } = 128;
|
||||
|
||||
/// <summary>
|
||||
/// Largest run of bytes nobody asked for that may sit between two merged tags. Reading a few
|
||||
/// spare bytes is cheaper than a second round-trip, but every spare byte must be a valid PMC
|
||||
/// address on the CNC. Default 8; 0 merges only strictly adjacent tags.
|
||||
/// </summary>
|
||||
[Range(0, 1024)]
|
||||
public int MaxGapBytes { get; init; } = 8;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fixed-node tree exposed by FOCAS per <c>docs/v2/driver-specs.md §7</c> —
|
||||
/// <c>Identity/</c>, <c>Axes/{name}/</c>, etc. populated from
|
||||
|
||||
@@ -498,8 +498,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
|
||||
// #266 — merge adjacent / near PMC reads of one device into fewer pmc_rdpmcrng calls. Indices it
|
||||
// serves are marked here and skipped by the per-tag loop below; everything else (including every
|
||||
// single-tag range and any range the CNC rejected) takes the unchanged per-tag path.
|
||||
var served = _options.PmcCoalesce.Enabled
|
||||
? await CoalescePmcReadsAsync(fullReferences, results, now, cancellationToken).ConfigureAwait(false)
|
||||
: null;
|
||||
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
if (served is not null && served[i]) continue;
|
||||
var reference = fullReferences[i];
|
||||
|
||||
// Fixed-tree T1 — fixed-tree references are synthesized from the cached
|
||||
@@ -581,6 +589,127 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PMC coalescing pre-pass (Gitea #266). Groups the batch's PMC tags by device, plans merged ranges
|
||||
/// (<see cref="FocasPmcCoalescer"/>) and serves every multi-member range with ONE
|
||||
/// <c>pmc_rdpmcrng</c>, slicing each tag's value out of the returned slots.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Failure handling is the point of the design. A rejected range is NOT mis-attributed to its members:
|
||||
/// <list type="bullet">
|
||||
/// <item>a comms-level failure (timeout / transport) fails every member with that status — the
|
||||
/// link is down, so re-reading each member would multiply timeouts;</item>
|
||||
/// <item>any other non-Good status (typically a spare "gap" byte that is not a valid PMC address)
|
||||
/// leaves the members UNSERVED, so the per-tag loop reads them individually and each gets its own
|
||||
/// true status — and the range is remembered so later polls skip the doomed merged read.</item>
|
||||
/// </list>
|
||||
/// Returns null when nothing was served.
|
||||
/// </remarks>
|
||||
private async Task<bool[]?> CoalescePmcReadsAsync(
|
||||
IReadOnlyList<string> references, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
|
||||
{
|
||||
Dictionary<DeviceState, List<FocasPmcReadItem>>? byDevice = null;
|
||||
for (var i = 0; i < references.Count; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reference = references[i];
|
||||
if (_fixedTreeActive && TryReadFixedTree(reference, now) is not null) continue;
|
||||
if (!_resolver.TryResolve(reference, out var def)) continue;
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
|
||||
var parsed = ResolveParsedAddress(def);
|
||||
if (parsed.Kind != FocasAreaKind.Pmc || parsed.PmcLetter is null) continue;
|
||||
(byDevice ??= []).TryAdd(device, []);
|
||||
byDevice[device].Add(new FocasPmcReadItem(i, parsed.PmcLetter, parsed.Number, def.DataType, parsed.BitIndex));
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
/* malformed / unresolvable — the per-tag loop reports it with its usual status */
|
||||
}
|
||||
}
|
||||
if (byDevice is null) return null;
|
||||
|
||||
bool[]? served = null;
|
||||
foreach (var (device, items) in byDevice)
|
||||
{
|
||||
if (items.Count < 2) continue;
|
||||
var plans = FocasPmcCoalescer.Plan(items, _options.PmcCoalesce.MaxSpanBytes, _options.PmcCoalesce.MaxGapBytes);
|
||||
if (!plans.Any(p => p.Members.Count >= 2 && !device.CoalesceRejected.ContainsKey(p.Key))) continue;
|
||||
|
||||
// One connect attempt per device per batch: if it fails, every PMC tag of the device fails with
|
||||
// that status here instead of paying a connect attempt per plan and again per tag.
|
||||
IFocasClient client;
|
||||
try { client = await EnsureConnectedAsync(device, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch (Exception)
|
||||
{
|
||||
FailMembers(items, FocasStatusMapper.BadCommunicationError, references, results, now, ref served,
|
||||
$"FOCAS connect failed reading PMC tags of {device.Options.HostAddress}");
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var plan in plans)
|
||||
{
|
||||
if (plan.Members.Count < 2) continue;
|
||||
if (device.CoalesceRejected.ContainsKey(plan.Key)) continue;
|
||||
|
||||
var members = plan.Members;
|
||||
try
|
||||
{
|
||||
var (slots, status) = await client
|
||||
.ReadPmcSlotsAsync(plan.Letter, plan.SlotType, plan.Start, plan.End, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (status == FocasStatusMapper.Good && slots is not null)
|
||||
{
|
||||
foreach (var m in members)
|
||||
{
|
||||
var slot = plan.SlotOf(m);
|
||||
results[m.Index] = slot < slots.Count
|
||||
? new DataValueSnapshot(FocasPmcCoalescer.MapSlot(slots[slot], m.Type, m.BitIndex), status, now, now)
|
||||
: new DataValueSnapshot(null, FocasStatusMapper.BadOutOfRange, now, now);
|
||||
(served ??= new bool[references.Count])[m.Index] = true;
|
||||
}
|
||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, now, null));
|
||||
}
|
||||
else
|
||||
{
|
||||
// The CNC REFUSED the merged range (any return code, mapped or not). Don't guess which
|
||||
// member is at fault: let the per-tag loop give each its own answer. Remember the range
|
||||
// so later polls skip the doomed read — except for a transient refusal (EW_BUSY maps to
|
||||
// BadDeviceFailure), which says nothing about the range.
|
||||
if (status != FocasStatusMapper.BadDeviceFailure)
|
||||
device.CoalesceRejected[plan.Key] = 0;
|
||||
_logger.LogDebug(
|
||||
"FOCAS PMC range {Key} on {Host} rejected (0x{Status:X8}) — reading its {Count} tags individually",
|
||||
plan.Key, device.Options.HostAddress, status, members.Count);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch (Exception)
|
||||
{
|
||||
// Transport failure / timeout: the link is suspect, so fail the members without per-tag retries.
|
||||
FailMembers(members, FocasStatusMapper.BadCommunicationError, references, results, now, ref served,
|
||||
$"FOCAS transport failure reading PMC range {plan.Key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
return served;
|
||||
|
||||
void FailMembers(IEnumerable<FocasPmcReadItem> failed, uint status, IReadOnlyList<string> refs,
|
||||
DataValueSnapshot[] res, DateTime stamp, ref bool[]? servedRef, string reason)
|
||||
{
|
||||
foreach (var m in failed)
|
||||
{
|
||||
res[m.Index] = new DataValueSnapshot(null, status, null, stamp);
|
||||
(servedRef ??= new bool[refs.Count])[m.Index] = true;
|
||||
}
|
||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
|
||||
Volatile.Read(ref _health).LastSuccessfulRead,
|
||||
$"{reason} (0x{status:X8})"));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -751,6 +880,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
EmitFixedVariable(opMode, "OperationMode", "ModeText", DriverDataType.String);
|
||||
}
|
||||
|
||||
// Fixed-tree T2.5 — Status subtree (cnc_statinfo flags, #257), gated on its own probe.
|
||||
if (cache.Capabilities.Status)
|
||||
{
|
||||
var status = deviceFolder.Folder("Status", "Status");
|
||||
foreach (var field in StatusFields)
|
||||
EmitFixedVariable(status, "Status", field, DriverDataType.Int32);
|
||||
EmitFixedVariable(status, "Status", "RunText", DriverDataType.String);
|
||||
}
|
||||
|
||||
// Fixed-tree T3 — Timers subtree (power-on / operating / cutting / cycle).
|
||||
if (cache.Capabilities.Timers)
|
||||
{
|
||||
@@ -1008,6 +1146,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var servoLoads = await SafeProbe(() => client.GetServoLoadsAsync(ct), []);
|
||||
var programInfo = await SafeTryProbe(() => client.GetProgramInfoAsync(ct));
|
||||
var timer = await SafeTryProbe(() => client.GetTimerAsync(FocasTimerKind.PowerOn, ct));
|
||||
var statusProbe = await SafeTryProbe(() => client.GetStatusAsync(ct));
|
||||
var spindleLoad = await SafeProbe(() => client.GetSpindleLoadsAsync(ct), []);
|
||||
|
||||
var caps = new FocasFixedTreeCapabilities(
|
||||
@@ -1016,7 +1155,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
SpindleMotorSpeed: spindleMotorSpeeds.Count > 0,
|
||||
ServoLoad: servoLoads.Count > 0,
|
||||
ProgramInfo: programInfo is not null,
|
||||
Timers: timer is not null);
|
||||
Timers: timer is not null,
|
||||
Status: statusProbe is not null);
|
||||
state.LastStatus = statusProbe;
|
||||
|
||||
state.FixedTreeCache = new FocasFixedTreeCache(sys, [.. axes], [.. spindles], caps);
|
||||
// The bootstrap IS an answer from the device, and Identity comes entirely from it —
|
||||
@@ -1054,6 +1195,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
var programPollDue = DateTime.MinValue;
|
||||
var timerPollDue = DateTime.MinValue;
|
||||
var statusPollDue = DateTime.MinValue;
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
@@ -1129,6 +1271,20 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
programPollDue = DateTime.UtcNow + programInterval;
|
||||
}
|
||||
|
||||
// Status flags (cnc_statinfo, #257) — same cadence as program/mode: run/emergency/alarm
|
||||
// transitions are operator-timescale events and one call returns all of them.
|
||||
if (cache.Capabilities.Status
|
||||
&& programInterval > TimeSpan.Zero && DateTime.UtcNow >= statusPollDue)
|
||||
{
|
||||
try { state.LastStatus = await client.GetStatusAsync(ct).ConfigureAwait(false); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
/* transient — keep the last value; staleness is dated by LastFixedPollUtc */
|
||||
_logger.LogDebug(ex, "FOCAS status poll failed for {Host}", state.Options.HostAddress);
|
||||
}
|
||||
statusPollDue = DateTime.UtcNow + programInterval;
|
||||
}
|
||||
|
||||
// Timers — slowest cadence. Fires 4 FWLIB calls per tick (one per kind).
|
||||
var timerInterval = _options.FixedTree.TimerPollInterval;
|
||||
if (cache.Capabilities.Timers
|
||||
@@ -1245,6 +1401,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly string[] StatusFields = ["Auto", "Run", "Motion", "Mstb", "Emergency", "Alarm", "Edit", "TmMode"];
|
||||
|
||||
private static object? TimerValue(DeviceState state, FocasTimerKind kind) =>
|
||||
state.LastTimers.TryGetValue(kind, out var t) ? (object)t.TotalSeconds : null;
|
||||
|
||||
@@ -1385,6 +1543,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
"OperationMode/Mode" => state.LastProgramInfo?.Mode,
|
||||
"OperationMode/ModeText" => state.LastProgramInfo?.Mode is { } mode
|
||||
? FocasOpMode.ToText(mode) : null,
|
||||
"Status/Auto" => state.LastStatus?.Auto,
|
||||
"Status/Run" => state.LastStatus?.Run,
|
||||
"Status/Motion" => state.LastStatus?.Motion,
|
||||
"Status/Mstb" => state.LastStatus?.Mstb,
|
||||
"Status/Emergency" => state.LastStatus?.Emergency,
|
||||
"Status/Alarm" => state.LastStatus?.Alarm,
|
||||
"Status/Edit" => state.LastStatus?.Edit,
|
||||
"Status/TmMode" => state.LastStatus?.TmMode,
|
||||
"Status/RunText" => state.LastStatus is { } st ? FocasRunState.ToText(st.Run) : null,
|
||||
"Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn),
|
||||
"Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating),
|
||||
"Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting),
|
||||
@@ -1618,7 +1785,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
bool SpindleMotorSpeed, // cnc_rdspmeter type 1 (motor speed) bootstrap probe succeeded
|
||||
bool ServoLoad, // cnc_rdsvmeter bootstrap probe returned data
|
||||
bool ProgramInfo, // cnc_exeprgname2 + cnc_rdblkcount + cnc_rdopmode work
|
||||
bool Timers); // cnc_rdtimer works for at least PowerOn
|
||||
bool Timers, // cnc_rdtimer works for at least PowerOn
|
||||
bool Status = false); // cnc_statinfo answered at bootstrap (#257)
|
||||
|
||||
internal sealed class DeviceState(FocasHostAddress parsedAddress, FocasDeviceOptions options)
|
||||
{
|
||||
@@ -1683,6 +1851,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// holding no value.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<string, double> LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>
|
||||
/// PMC ranges (<see cref="FocasPmcRangePlan.Key"/>) the CNC rejected as a merged read, so later polls skip
|
||||
/// straight to per-tag reads instead of paying a doomed round-trip every cycle (#266).
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<string, byte> CoalesceRejected { get; } = new(StringComparer.Ordinal);
|
||||
/// <summary>Gets or sets the last <c>cnc_statinfo</c> snapshot (#257); null until the first answer.</summary>
|
||||
public FocasStatus? LastStatus { get; set; }
|
||||
/// <summary>Gets or sets the last program information snapshot.</summary>
|
||||
public FocasProgramInfo? LastProgramInfo { get; set; }
|
||||
/// <summary>Gets or sets the cached first-axis dynamic snapshot — feeds Program/Number, /MainNumber, /Sequence.</summary>
|
||||
|
||||
@@ -115,6 +115,7 @@ public static class FocasDriverFactoryExtensions
|
||||
FixedTree = BuildFixedTree(dto.FixedTree),
|
||||
AlarmProjection = BuildAlarmProjection(dto.AlarmProjection),
|
||||
HandleRecycle = BuildHandleRecycle(dto.HandleRecycle),
|
||||
PmcCoalesce = BuildPmcCoalesce(dto.PmcCoalesce),
|
||||
};
|
||||
|
||||
return new FocasDriverBinding(options, BuildClientFactory(dto, driverInstanceId, loggerFactory));
|
||||
@@ -206,6 +207,19 @@ public static class FocasDriverFactoryExtensions
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Map the optional <c>PmcCoalesce</c> section; a missing section or key keeps the default.</summary>
|
||||
private static FocasPmcCoalesceOptions BuildPmcCoalesce(FocasPmcCoalesceDto? dto)
|
||||
{
|
||||
var defaults = new FocasPmcCoalesceOptions();
|
||||
if (dto is null) return defaults;
|
||||
return new FocasPmcCoalesceOptions
|
||||
{
|
||||
Enabled = dto.Enabled ?? defaults.Enabled,
|
||||
MaxSpanBytes = dto.MaxSpanBytes is > 0 ? dto.MaxSpanBytes.Value : defaults.MaxSpanBytes,
|
||||
MaxGapBytes = dto.MaxGapBytes is >= 0 ? dto.MaxGapBytes.Value : defaults.MaxGapBytes,
|
||||
};
|
||||
}
|
||||
|
||||
private static FocasCncSeries ParseSeries(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return FocasCncSeries.Unknown;
|
||||
@@ -284,6 +298,9 @@ public static class FocasDriverFactoryExtensions
|
||||
|
||||
/// <summary>Gets or sets the handle recycle configuration options.</summary>
|
||||
public FocasHandleRecycleDto? HandleRecycle { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the PMC range-coalescing configuration options.</summary>
|
||||
public FocasPmcCoalesceDto? PmcCoalesce { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class FocasDeviceDto
|
||||
@@ -348,6 +365,17 @@ public static class FocasDriverFactoryExtensions
|
||||
public TimeSpan? PollInterval { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Optional <c>PmcCoalesce</c> config section (#266).</summary>
|
||||
internal sealed class FocasPmcCoalesceDto
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether coalescing is on.</summary>
|
||||
public bool? Enabled { get; init; }
|
||||
/// <summary>Gets or sets the largest coalesced range in bytes.</summary>
|
||||
public int? MaxSpanBytes { get; init; }
|
||||
/// <summary>Gets or sets the largest tolerated gap in bytes.</summary>
|
||||
public int? MaxGapBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Optional <c>HandleRecycle</c> config section.</summary>
|
||||
internal sealed class FocasHandleRecycleDto
|
||||
{
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>One PMC read the coalescer may merge — the caller's batch index plus the parsed address and type.</summary>
|
||||
/// <param name="Index">Position in the caller's batch; results are sliced back to it.</param>
|
||||
/// <param name="Letter">PMC area letter (<c>R</c>, <c>X</c>, ...), upper-case.</param>
|
||||
/// <param name="Number">Byte address.</param>
|
||||
/// <param name="Type">The tag's declared data type (decides slot width and decode).</param>
|
||||
/// <param name="BitIndex">Bit index for a <see cref="FocasDataType.Bit"/> tag.</param>
|
||||
internal readonly record struct FocasPmcReadItem(int Index, string Letter, int Number, FocasDataType Type, int? BitIndex);
|
||||
|
||||
/// <summary>
|
||||
/// One planned <c>pmc_rdpmcrng</c> read covering <see cref="Members"/>. <see cref="End"/> is inclusive and is a
|
||||
/// whole number of <see cref="SlotWidth"/>-byte slots from <see cref="Start"/>.
|
||||
/// </summary>
|
||||
internal sealed record FocasPmcRangePlan(
|
||||
string Letter,
|
||||
FocasDataType SlotType,
|
||||
int SlotWidth,
|
||||
int Start,
|
||||
int End,
|
||||
IReadOnlyList<FocasPmcReadItem> Members)
|
||||
{
|
||||
/// <summary>Stable key for the "this range was rejected, read members individually" memo.</summary>
|
||||
public string Key => $"{Letter}:{SlotType}:{Start}-{End}";
|
||||
|
||||
/// <summary>Slot index (into the decoded range) that holds <paramref name="item"/>'s value.</summary>
|
||||
/// <param name="item">A member of this plan.</param>
|
||||
/// <returns>The zero-based slot index.</returns>
|
||||
public int SlotOf(FocasPmcReadItem item) => (item.Number - Start) / SlotWidth;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges adjacent / near PMC reads in one poll batch into fewer <c>pmc_rdpmcrng</c> calls (Gitea #266).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Items are grouped by <c>(letter, slot width class)</c> and merged in address order while (a) the gap to
|
||||
/// the previous member is at most <c>maxGapBytes</c> and (b) the whole range stays within <c>maxSpanBytes</c>.
|
||||
/// A multi-byte member only joins a range when its offset from the range start is a multiple of its width — the
|
||||
/// CNC splits a range into width-sized slots from the start, so an unaligned member would be decoded from the
|
||||
/// wrong bytes. Members that cannot merge become single-member plans; the driver reads those through the
|
||||
/// ordinary per-tag path, so the common "one tag per area" case is byte-for-byte unchanged.</para>
|
||||
/// <para>Pure and deterministic: no I/O, no clock.</para>
|
||||
/// </remarks>
|
||||
internal static class FocasPmcCoalescer
|
||||
{
|
||||
/// <summary>Plans the reads for a batch.</summary>
|
||||
/// <param name="items">The PMC reads in the batch (any order).</param>
|
||||
/// <param name="maxSpanBytes">Largest range, in bytes, one coalesced read may cover (>= 1).</param>
|
||||
/// <param name="maxGapBytes">Largest run of unrequested bytes tolerated between two members (>= 0).</param>
|
||||
/// <returns>Range plans; every input item appears in exactly one plan.</returns>
|
||||
public static IReadOnlyList<FocasPmcRangePlan> Plan(
|
||||
IEnumerable<FocasPmcReadItem> items, int maxSpanBytes, int maxGapBytes)
|
||||
{
|
||||
maxSpanBytes = Math.Max(1, maxSpanBytes);
|
||||
maxGapBytes = Math.Max(0, maxGapBytes);
|
||||
var plans = new List<FocasPmcRangePlan>();
|
||||
|
||||
var groups = items
|
||||
.GroupBy(i => (Letter: i.Letter.ToUpperInvariant(), Slot: SlotTypeOf(i.Type)))
|
||||
.OrderBy(g => g.Key.Letter, StringComparer.Ordinal).ThenBy(g => g.Key.Slot);
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var width = FocasWireClient.PmcByteWidth((short)FocasPmcDataTypeLookup.FromFocasDataType(group.Key.Slot));
|
||||
var sorted = group.OrderBy(i => i.Number).ThenBy(i => i.Index).ToList();
|
||||
|
||||
List<FocasPmcReadItem>? members = null;
|
||||
var start = 0;
|
||||
var end = 0; // inclusive
|
||||
|
||||
void Flush()
|
||||
{
|
||||
if (members is null) return;
|
||||
// Round the range up to a whole number of slots so the CNC returns no partial trailing slot.
|
||||
var bytes = end - start + 1;
|
||||
var rounded = (bytes + width - 1) / width * width;
|
||||
plans.Add(new FocasPmcRangePlan(group.Key.Letter, group.Key.Slot, width, start, start + rounded - 1, members));
|
||||
members = null;
|
||||
}
|
||||
|
||||
foreach (var item in sorted)
|
||||
{
|
||||
var itemEnd = item.Number + width - 1;
|
||||
if (members is not null)
|
||||
{
|
||||
var gap = Math.Max(0, item.Number - (end + 1));
|
||||
var aligned = (item.Number - start) % width == 0;
|
||||
var newEnd = Math.Max(end, itemEnd);
|
||||
// The span check is on the ROUNDED length a flush would produce.
|
||||
var roundedSpan = (newEnd - start + 1 + width - 1) / width * width;
|
||||
if (aligned && gap <= maxGapBytes && roundedSpan <= maxSpanBytes)
|
||||
{
|
||||
members.Add(item);
|
||||
end = newEnd;
|
||||
continue;
|
||||
}
|
||||
Flush();
|
||||
}
|
||||
members = [item];
|
||||
start = item.Number;
|
||||
end = itemEnd;
|
||||
}
|
||||
Flush();
|
||||
}
|
||||
return plans;
|
||||
}
|
||||
|
||||
/// <summary>The data type whose wire width a read of <paramref name="type"/> uses (Bit and Byte share one).</summary>
|
||||
/// <param name="type">The tag's declared type.</param>
|
||||
/// <returns>The representative slot type.</returns>
|
||||
public static FocasDataType SlotTypeOf(FocasDataType type) => type switch
|
||||
{
|
||||
FocasDataType.Bit or FocasDataType.Byte => FocasDataType.Byte,
|
||||
_ => type,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Decodes one raw PMC slot (as <c>pmc_rdpmcrng</c> parsed it) into the tag's published value. The single
|
||||
/// decode authority for both the per-tag and the coalesced path.
|
||||
/// </summary>
|
||||
/// <param name="raw">The slot value.</param>
|
||||
/// <param name="type">The tag's declared type.</param>
|
||||
/// <param name="bitIndex">Bit index for a Bit tag.</param>
|
||||
/// <returns>The boxed value.</returns>
|
||||
public static object MapSlot(long raw, FocasDataType type, int? bitIndex) => type switch
|
||||
{
|
||||
FocasDataType.Bit => (object)(((raw >> (bitIndex ?? 0)) & 1L) != 0),
|
||||
FocasDataType.Byte => (object)(sbyte)(raw & 0xFFL),
|
||||
FocasDataType.Int16 => (object)(short)raw,
|
||||
FocasDataType.Int32 => (object)(int)raw,
|
||||
FocasDataType.Float32 => (object)BitConverter.Int32BitsToSingle((int)raw),
|
||||
FocasDataType.Float64 => (object)BitConverter.Int64BitsToDouble(raw),
|
||||
_ => (object)raw,
|
||||
};
|
||||
}
|
||||
@@ -73,6 +73,24 @@ public interface IFocasClient : IDisposable
|
||||
/// <returns>The list of currently active alarms; empty when none are active.</returns>
|
||||
Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read one PMC range (<c>pmc_rdpmcrng</c>) as raw slots: <c>(end - start + 1)</c> bytes split into
|
||||
/// slots of <paramref name="slotType"/>'s width, from <paramref name="start"/>. The primitive behind PMC
|
||||
/// range coalescing (Gitea #266) — the driver slices individual tag values out of the returned slots with
|
||||
/// the same decode a per-tag read uses.
|
||||
/// </summary>
|
||||
/// <param name="letter">PMC area letter.</param>
|
||||
/// <param name="slotType">Decides the slot width (Bit/Byte = 1, Int16 = 2, Int32/Float32 = 4, Float64 = 8).</param>
|
||||
/// <param name="start">First byte address.</param>
|
||||
/// <param name="end">Last byte address, inclusive; a whole number of slots from <paramref name="start"/>.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The raw slot values (null when not Good) and the mapped OPC UA status. A returned non-Good status is
|
||||
/// always a CNC return-code refusal; a transport failure or timeout throws instead.
|
||||
/// </returns>
|
||||
Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(
|
||||
string letter, FocasDataType slotType, int start, int end, CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T1 (identity + axis discovery + fast-poll dynamic bundle) ----
|
||||
|
||||
/// <summary>
|
||||
@@ -125,6 +143,17 @@ public interface IFocasClient : IDisposable
|
||||
/// <returns>The aggregate program + operation-mode snapshot.</returns>
|
||||
Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T2.5 (CNC status flags, Gitea #257) ----
|
||||
|
||||
/// <summary>
|
||||
/// Read the CNC status flags via <c>cnc_statinfo</c> (<c>ODBST</c>). Populates the
|
||||
/// <c>Status/*</c> subtree. Throws when the CNC refuses the call, which the driver's
|
||||
/// bootstrap probe reads as "this series has no status block" and suppresses the subtree.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The CNC status flag snapshot.</returns>
|
||||
Task<FocasStatus> GetStatusAsync(CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T3 (timers) ----
|
||||
|
||||
/// <summary>
|
||||
@@ -172,6 +201,38 @@ public interface IFocasClient : IDisposable
|
||||
Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CNC status flags from <c>cnc_statinfo</c> (<c>ODBST</c>) — raw FANUC codes, published verbatim as
|
||||
/// Int32 so a client sees exactly what the control reported. <see cref="Run"/> additionally has a
|
||||
/// text projection (<see cref="FocasRunState.ToText"/>).
|
||||
/// </summary>
|
||||
/// <param name="Auto">Automatic-operation mode code.</param>
|
||||
/// <param name="Run">Automatic-operation state (0 RESET, 1 STOP, 2 HOLD, 3 START, 4 MSTR).</param>
|
||||
/// <param name="Motion">Axis motion (0 none, 1 motion, 2 dwell).</param>
|
||||
/// <param name="Mstb">M/S/T/B function state (0 none, 1 executing).</param>
|
||||
/// <param name="Emergency">Emergency stop (0 not, 1 emergency, 2 reset).</param>
|
||||
/// <param name="Alarm">Alarm state (0 none, 1 alarm, 2 battery-low, 3 fan).</param>
|
||||
/// <param name="Edit">Program-edit state.</param>
|
||||
/// <param name="TmMode">T/M mode as reported by the control.</param>
|
||||
public sealed record FocasStatus(int Auto, int Run, int Motion, int Mstb, int Emergency, int Alarm, int Edit, int TmMode);
|
||||
|
||||
/// <summary>Human-readable text for <see cref="FocasStatus.Run"/>.</summary>
|
||||
public static class FocasRunState
|
||||
{
|
||||
/// <summary>Converts the <c>ODBST.run</c> code to its operator-facing label.</summary>
|
||||
/// <param name="run">The run-state code.</param>
|
||||
/// <returns>The label, or <c>Run{n}</c> for a code outside the documented set.</returns>
|
||||
public static string ToText(int run) => run switch
|
||||
{
|
||||
0 => "RESET",
|
||||
1 => "STOP",
|
||||
2 => "HOLD",
|
||||
3 => "START",
|
||||
4 => "MSTR",
|
||||
_ => $"Run{run}",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One servo-meter entry — one axis's current load percentage; null when the CNC's record
|
||||
/// described no usable value (never a fabricated 0).
|
||||
|
||||
@@ -61,6 +61,11 @@ public sealed class SynchronizedFocasClient : IFocasClient
|
||||
FocasAddress address, FocasDataType type, CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.ReadAsync(address, type, ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(
|
||||
string letter, FocasDataType slotType, int start, int end, CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.ReadPmcSlotsAsync(letter, slotType, start, end, ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken) =>
|
||||
@@ -90,6 +95,10 @@ public sealed class SynchronizedFocasClient : IFocasClient
|
||||
public Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.GetProgramInfoAsync(ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<FocasStatus> GetStatusAsync(CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.GetStatusAsync(ct), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken cancellationToken) =>
|
||||
RunBoundedAsync(ct => _inner.GetTimerAsync(kind, ct), cancellationToken);
|
||||
|
||||
@@ -67,6 +67,33 @@ public sealed class WireFocasClient : IFocasClient
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(
|
||||
string letter, FocasDataType slotType, int start, int end, CancellationToken cancellationToken)
|
||||
{
|
||||
// Contract: a RETURNED status is always a CNC return code (a refusal); a transport failure THROWS.
|
||||
// The coalescing caller needs that distinction — it falls back per tag on a refusal but must not
|
||||
// re-hit a dead link (#266).
|
||||
RequireConnected();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var area = FocasPmcAreaLookup.FromLetter(letter);
|
||||
if (area is null) return (null, FocasStatusMapper.BadNodeIdUnknown);
|
||||
var dataType = FocasPmcDataTypeLookup.FromFocasDataType(slotType);
|
||||
try
|
||||
{
|
||||
var result = await _wire.ReadPmcRangeAsync(area.Value, dataType, (ushort)start, (ushort)end, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!result.IsOk || result.Value is null)
|
||||
return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
|
||||
return (result.Value.Values, FocasStatusMapper.Good);
|
||||
}
|
||||
catch (FocasWireException ex) when (ex.Rc is short rc)
|
||||
{
|
||||
return (null, FocasStatusMapper.MapFocasReturn(rc));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
|
||||
@@ -129,6 +156,18 @@ public sealed class WireFocasClient : IFocasClient
|
||||
AxesCount: axesCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FocasStatus> GetStatusAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
RequireConnected();
|
||||
var result = await _wire.ReadStatusAsync(cancellationToken).ConfigureAwait(false);
|
||||
ThrowIfRcNonZero(result.Rc, "cnc_statinfo", result.IsOk);
|
||||
return MapStatus(result.Value!);
|
||||
}
|
||||
|
||||
internal static FocasStatus MapStatus(WireStatus s) =>
|
||||
new(s.Auto, s.Run, s.Motion, s.Mstb, s.Emergency, s.Alarm, s.Edit, s.TmMode);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -313,17 +352,8 @@ public sealed class WireFocasClient : IFocasClient
|
||||
return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
|
||||
var values = result.Value.Values;
|
||||
if (values.Count == 0) return (null, FocasStatusMapper.BadOutOfRange);
|
||||
var raw = values[0];
|
||||
var mapped = type switch
|
||||
{
|
||||
FocasDataType.Bit => (object)(((long)raw >> (address.BitIndex ?? 0) & 1L) != 0),
|
||||
FocasDataType.Byte => (object)(sbyte)(raw & 0xFFL),
|
||||
FocasDataType.Int16 => (object)(short)raw,
|
||||
FocasDataType.Int32 => (object)(int)raw,
|
||||
FocasDataType.Float32 => (object)BitConverter.Int32BitsToSingle((int)raw),
|
||||
FocasDataType.Float64 => (object)BitConverter.Int64BitsToDouble(raw),
|
||||
_ => (object)raw,
|
||||
};
|
||||
// One decode authority shared with the coalesced path (FocasPmcCoalescer.MapSlot).
|
||||
var mapped = FocasPmcCoalescer.MapSlot(values[0], type, address.BitIndex);
|
||||
return (mapped, FocasStatusMapper.Good);
|
||||
}
|
||||
catch (FocasWireException ex)
|
||||
|
||||
@@ -50,18 +50,13 @@ public sealed partial class MTConnectDriver : IAlarmSource
|
||||
/// <summary>
|
||||
/// ConditionId (RawPath) → what the server was last told. Guarded by <see cref="_alarmLock"/>. Survives a
|
||||
/// re-subscribe with the SAME reference set (a reconnect) and lifecycle restarts — the server's condition
|
||||
/// nodes outlive both, and re-raising there would un-acknowledge. Cleared WHOLE when the subscribed set
|
||||
/// changes: only an alarm-bearing raw-tag delta changes it, that delta is a full address-space rebuild,
|
||||
/// and a rebuild recreates every condition node inactive — so everything must be told again.
|
||||
/// nodes outlive both, and re-raising there would un-acknowledge. When the subscribed set changes (an
|
||||
/// alarm-bearing raw-tag delta, i.e. a full rebuild) only the entries that LEFT the set are dropped: the
|
||||
/// server carries every surviving condition's state across the rebuild (Gitea #641), so a survivor needs
|
||||
/// no re-tell, while a condition that comes back later was materialised fresh and is told again.
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, MTConnectToldCondition> _deliveredConditions = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// The reference set of the last <see cref="SubscribeAlarmsAsync"/>, kept across an unsubscribe so a
|
||||
/// release-then-resubscribe of an identical set is still recognised. Guarded by <see cref="_alarmLock"/>.
|
||||
/// </summary>
|
||||
private HashSet<string>? _lastAlarmReferences;
|
||||
|
||||
/// <summary>The CONDITION tracker for the current session; replaced alongside the observation index.</summary>
|
||||
private MTConnectConditionTracker _conditions = new();
|
||||
|
||||
@@ -108,16 +103,15 @@ public sealed partial class MTConnectDriver : IAlarmSource
|
||||
|
||||
lock (_alarmLock)
|
||||
{
|
||||
// A CHANGED set means an alarm-bearing delta, which the server applies as a full rebuild: every
|
||||
// condition node — including ones still in the set — was recreated inactive and has been told
|
||||
// nothing. Re-tell all of them. The SAME set is a reconnect: keep the record, or every still-active
|
||||
// alarm would be re-raised and lose its acknowledgement.
|
||||
if (_lastAlarmReferences is null || !_lastAlarmReferences.SetEquals(references))
|
||||
// Forget only the conditions that LEFT the set (Gitea #641). A changed set is an alarm-bearing delta,
|
||||
// which the server applies as a full rebuild — but the server carries every surviving condition's
|
||||
// state (acknowledgement included) across that rebuild, so re-raising a survivor would only
|
||||
// un-acknowledge it. A condition that left and later comes back was materialised fresh and has no
|
||||
// record, so it is told again. The SAME set (a reconnect) prunes nothing.
|
||||
foreach (var conditionId in _deliveredConditions.Keys.Where(k => !references.Contains(k)).ToList())
|
||||
{
|
||||
_deliveredConditions.Clear();
|
||||
_deliveredConditions.Remove(conditionId);
|
||||
}
|
||||
|
||||
_lastAlarmReferences = references;
|
||||
}
|
||||
|
||||
EmitConditionTransitions(touchedDataItemIds: null, resync: true);
|
||||
|
||||
@@ -92,6 +92,29 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
// ---- IAlarmSource state ----
|
||||
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, RemoteAlarmSubscription> _alarmSubscriptions = new();
|
||||
|
||||
/// <summary>#619: RawPath → the upstream condition NodeId last seen for it. The host acknowledges by
|
||||
/// RawPath (its only identity for the condition) but Part 9 Acknowledge must be called on the CONDITION
|
||||
/// node, which differs from the authored source node.</summary>
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, AlarmAckTarget> _alarmConditionByRawPath =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>#619 review: the (Active, Acked) state last told to the host per reported condition id, from
|
||||
/// which <see cref="AlarmTransitions"/> picks the Kind sequence that moves the host's projector to the
|
||||
/// upstream event's state.</summary>
|
||||
private readonly Dictionary<string, AlarmToldState> _alarmStateByRawPath = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>#619 re-review: serialises the read-compute-emit over <see cref="_alarmStateByRawPath"/>. Event
|
||||
/// notifications arrive on SDK publish threads (and, across a resubscribe, from two subscriptions at once), so
|
||||
/// a lock-free prior/target exchange could emit two kind sequences computed from the same prior.</summary>
|
||||
private readonly object _alarmStateLock = new();
|
||||
|
||||
/// <summary>#619 re-review: the ref set the last <see cref="SubscribeAlarmsAsync"/> carried. Survives
|
||||
/// <see cref="ShutdownAsync"/> deliberately — see <see cref="BeginAlarmSubscription"/>.</summary>
|
||||
private HashSet<string>? _lastAlarmRefSet;
|
||||
|
||||
/// <summary>Latched once the upstream server has refused ConditionRefresh as unsupported (logged once).</summary>
|
||||
private int _conditionRefreshUnsupportedLogged;
|
||||
private long _nextAlarmSubscriptionId;
|
||||
|
||||
/// <summary>Occurs when an alarm event is received from the remote OPC UA server.</summary>
|
||||
@@ -879,6 +902,11 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
_alarmSubscriptions.Clear();
|
||||
// #619 re-review: the last-told alarm states (and ack targets) deliberately SURVIVE a shutdown. A
|
||||
// ReinitializeAsync (comms-loss watchdog / Force Reconnect) is followed by the host re-subscribing the
|
||||
// same set over condition nodes it did NOT rebuild; forgetting here would make the post-subscribe
|
||||
// ConditionRefresh re-raise every active+acked alarm (an un-ack flicker). BeginAlarmSubscription forgets
|
||||
// them when the set changes, which is when the host did rebuild.
|
||||
|
||||
await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
_namespaceMap = null;
|
||||
@@ -1621,6 +1649,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
private const int AlarmFieldSeverity = 4;
|
||||
private const int AlarmFieldTime = 5;
|
||||
private const int AlarmFieldConditionId = 6;
|
||||
private const int AlarmFieldActiveState = 7;
|
||||
private const int AlarmFieldAckedState = 8;
|
||||
private const int AlarmFieldBranchId = 9;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
@@ -1635,11 +1666,24 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
|
||||
// Pre-resolve the source-node filter set so the per-event notification handler can
|
||||
// match in O(1) without re-parsing on every event.
|
||||
var sourceFilter = new HashSet<string>(sourceNodeIds, StringComparer.Ordinal);
|
||||
// #619: under v3 the host passes RawPaths, never upstream node ids. Translate each to the upstream
|
||||
// source id its tag authored (TagConfig.nodeId) so the per-event match compares like with like.
|
||||
// Keys are server-stable (AlarmIndexKey) so a namespace reorder across an in-place SDK reconnect —
|
||||
// which transfers this subscription without re-running SubscribeAlarmsAsync — cannot orphan them.
|
||||
var sessionForResolve = Session;
|
||||
var mapForResolve = _namespaceMap;
|
||||
var sourceFilter = BuildAlarmSourceIndex(sourceNodeIds, raw =>
|
||||
{
|
||||
if (sessionForResolve is null) return null;
|
||||
if (!TryResolveTagNode(sessionForResolve, raw, out var live)
|
||||
&& !NamespaceMap.TryResolve(sessionForResolve, raw, out live)) return null;
|
||||
return AlarmIndexKey(mapForResolve, live);
|
||||
});
|
||||
BeginAlarmSubscription(sourceNodeIds);
|
||||
|
||||
var subscription = new Subscription(telemetry: null!, new SubscriptionOptions
|
||||
{
|
||||
DisplayName = $"opcua-alarm-sub-{id}",
|
||||
DisplayName = $"{AlarmSubscriptionNamePrefix}{id}",
|
||||
PublishingInterval = 500, // 500ms — alarms don't need fast polling; the server pushes
|
||||
KeepAliveCount = _options.SubscriptionKeepAliveCount,
|
||||
LifetimeCount = _options.SubscriptionLifetimeCount,
|
||||
@@ -1674,6 +1718,28 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
BrowsePath = [], // empty path = the condition node itself
|
||||
AttributeId = Attributes.NodeId,
|
||||
});
|
||||
// #619 review: the host derives Active/Acked solely from AlarmEventArgs.Kind, so the condition state
|
||||
// must be selected. TWO-element browse paths — a single "ActiveState/Id" QualifiedName names nothing.
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand
|
||||
{
|
||||
TypeDefinitionId = ObjectTypeIds.AlarmConditionType,
|
||||
BrowsePath = [new QualifiedName(BrowseNames.ActiveState), new QualifiedName(BrowseNames.Id)],
|
||||
AttributeId = Attributes.Value,
|
||||
});
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand
|
||||
{
|
||||
TypeDefinitionId = ObjectTypeIds.AcknowledgeableConditionType,
|
||||
BrowsePath = [new QualifiedName(BrowseNames.AckedState), new QualifiedName(BrowseNames.Id)],
|
||||
AttributeId = Attributes.Value,
|
||||
});
|
||||
// #619 re-review: BranchId — a non-null one marks a condition BRANCH event (a previous state, with its own
|
||||
// EventId). Only the trunk (null BranchId) is the condition's current state.
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand
|
||||
{
|
||||
TypeDefinitionId = ObjectTypeIds.ConditionType,
|
||||
BrowsePath = [new QualifiedName(BrowseNames.BranchId)],
|
||||
AttributeId = Attributes.Value,
|
||||
});
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
@@ -1710,6 +1776,11 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_alarmSubscriptions[id] = new RemoteAlarmSubscription(subscription, handle, eventItem, notifHandler);
|
||||
|
||||
// #619: a new event item only reports FUTURE transitions — a condition already active upstream would
|
||||
// stay invisible until it next changed. ConditionRefresh re-delivers every retained condition's
|
||||
// current state through the normal path (deduplicated against the last-told state).
|
||||
await TryConditionRefreshAsync(subscription, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
|
||||
@@ -1765,15 +1836,30 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
var callRequests = new CallMethodRequestCollection();
|
||||
foreach (var ack in acknowledgements)
|
||||
{
|
||||
// ConditionId is an upstream condition-node id captured from the alarm event (not a
|
||||
// tag RawPath), so it re-binds directly against the session via the static resolve.
|
||||
if (!NamespaceMap.TryResolve(session, ack.ConditionId, out var conditionId)) continue;
|
||||
// #619: the host acks by RawPath; Acknowledge must hit the upstream CONDITION node.
|
||||
// The remembered target pairs the condition node with the EventId of the event that named it,
|
||||
// so the ack can never mix one condition's node with another's EventId. Nothing seen yet ⇒ the
|
||||
// tag's authored node (correct when the tag was authored on the condition node), else the id
|
||||
// verbatim (pre-v3 callers that pass an upstream condition id directly).
|
||||
NodeId conditionId;
|
||||
byte[] eventId;
|
||||
if (_alarmConditionByRawPath.TryGetValue(ack.ConditionId, out var seen))
|
||||
{
|
||||
if (!NamespaceMap.TryResolve(session, seen.ConditionRef, out conditionId)) continue;
|
||||
eventId = seen.EventId;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!TryResolveTagNode(session, ack.ConditionId, out conditionId)
|
||||
&& !NamespaceMap.TryResolve(session, ack.ConditionId, out conditionId)) continue;
|
||||
eventId = Array.Empty<byte>();
|
||||
}
|
||||
callRequests.Add(new CallMethodRequest
|
||||
{
|
||||
ObjectId = conditionId,
|
||||
MethodId = MethodIds.AcknowledgeableConditionType_Acknowledge,
|
||||
InputArguments = [
|
||||
new Variant(Array.Empty<byte>()), // EventId — server-side best-effort; empty resolves to 'most recent'
|
||||
new Variant(eventId), // Part 9 §5.7.3: the EventId being acknowledged (empty only when none seen)
|
||||
new Variant(new LocalizedText(ack.Comment ?? string.Empty)),
|
||||
],
|
||||
});
|
||||
@@ -1831,32 +1917,339 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
|
||||
private void OnEventNotification(
|
||||
OpcUaAlarmSubscriptionHandle handle,
|
||||
HashSet<string> sourceFilter,
|
||||
IReadOnlyDictionary<string, string> sourceFilter,
|
||||
MonitoredItem item,
|
||||
MonitoredItemNotificationEventArgs args)
|
||||
{
|
||||
if (args.NotificationValue is not EventFieldList efl) return;
|
||||
if (efl.EventFields.Count <= AlarmFieldConditionId) return;
|
||||
RouteAlarmEvent(efl.EventFields, sourceFilter, handle);
|
||||
}
|
||||
|
||||
var sourceNode = efl.EventFields[AlarmFieldSourceNode].Value?.ToString() ?? string.Empty;
|
||||
if (sourceFilter.Count > 0 && !sourceFilter.Contains(sourceNode)) return;
|
||||
/// <summary>
|
||||
/// #619 re-review: open (or re-open) the host's alarm subscription over <paramref name="sourceNodeIds"/>.
|
||||
/// A CHANGED set means the host rebuilt its address space (an alarm-bearing delta always does), recreating
|
||||
/// every condition node inactive+acked — so every last-told state is forgotten and the post-subscribe
|
||||
/// ConditionRefresh re-reports from that default. The SAME set (a reinit / reconnect re-subscribe) keeps
|
||||
/// them, so the refresh copy of an already-reported state emits nothing. Ack targets of refs no longer
|
||||
/// subscribed are pruned either way.
|
||||
/// </summary>
|
||||
/// <param name="sourceNodeIds">The refs the host is subscribing.</param>
|
||||
internal void BeginAlarmSubscription(IReadOnlyList<string> sourceNodeIds)
|
||||
{
|
||||
var set = new HashSet<string>(sourceNodeIds, StringComparer.Ordinal);
|
||||
lock (_alarmStateLock)
|
||||
{
|
||||
if (_lastAlarmRefSet is null || !_lastAlarmRefSet.SetEquals(set)) _alarmStateByRawPath.Clear();
|
||||
_lastAlarmRefSet = set;
|
||||
foreach (var known in _alarmConditionByRawPath.Keys)
|
||||
if (!set.Contains(known)) _alarmConditionByRawPath.TryRemove(known, out _);
|
||||
}
|
||||
}
|
||||
|
||||
var eventType = efl.EventFields[AlarmFieldEventType].Value?.ToString() ?? "BaseEventType";
|
||||
var message = (efl.EventFields[AlarmFieldMessage].Value as LocalizedText)?.Text ?? string.Empty;
|
||||
var severity = efl.EventFields[AlarmFieldSeverity].Value is ushort sev ? sev : (ushort)0;
|
||||
var time = efl.EventFields[AlarmFieldTime].Value is DateTime t ? t : DateTime.UtcNow;
|
||||
var conditionId = efl.EventFields[AlarmFieldConditionId].Value?.ToString() ?? string.Empty;
|
||||
/// <summary>
|
||||
/// #619 — route one upstream event: drop refresh markers (a RefreshRequired asks for a new refresh), project
|
||||
/// it, and emit the kinds that move the host from the state last told to the event's. An event that changes
|
||||
/// nothing the host shows (a ConditionRefresh copy) emits nothing.
|
||||
/// </summary>
|
||||
/// <param name="fields">The event's fields, in select-clause order.</param>
|
||||
/// <param name="sourceFilter">The subscription's upstream-key → RawPath index.</param>
|
||||
/// <param name="handle">The subscription handle to stamp.</param>
|
||||
internal void RouteAlarmEvent(
|
||||
IReadOnlyList<Variant> fields, IReadOnlyDictionary<string, string> sourceFilter, IAlarmSubscriptionHandle handle)
|
||||
{
|
||||
if (IsRefreshRequired(fields))
|
||||
{
|
||||
// Part 9 §5.11.7: the server lost events (queue overflow / restart) and asks for a refresh.
|
||||
_ = Task.Run(() => RefreshAllAlarmSubscriptionsAsync(CancellationToken.None));
|
||||
return;
|
||||
}
|
||||
|
||||
OnAlarmEvent?.Invoke(this, new AlarmEventArgs(
|
||||
// Projection, the per-RawPath bookkeeping AND the emission run under one lock, so two notifications for
|
||||
// one condition (two publish threads; two subscriptions across a resubscribe) can never compute their
|
||||
// kinds from the same prior, nor reach the host out of order. The host's handler only marshals.
|
||||
lock (_alarmStateLock)
|
||||
{
|
||||
var e = ProjectEvent(fields, sourceFilter, handle,
|
||||
n => AlarmIndexKey(_namespaceMap, n), (raw, target) => _alarmConditionByRawPath[raw] = target,
|
||||
out var state);
|
||||
if (e is null) return;
|
||||
if (state is not { } target)
|
||||
{
|
||||
// Not a condition (no ActiveState/AckedState) — a message/severity-only event, as before.
|
||||
OnAlarmEvent?.Invoke(this, e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fresh prior = the host projector's own default (inactive, acked).
|
||||
var told = new AlarmToldState(target.Active, target.Acked, e.Message, e.OpcUaSeverity);
|
||||
var prior = _alarmStateByRawPath.TryGetValue(e.ConditionId, out var p)
|
||||
? p
|
||||
: new AlarmToldState(false, true, null, null);
|
||||
_alarmStateByRawPath[e.ConditionId] = told;
|
||||
|
||||
var kinds = AlarmTransitions((prior.Active, prior.Acked), target);
|
||||
if (kinds.Count == 0)
|
||||
{
|
||||
// Same state: a message/severity change still reaches the host; an identical copy does not.
|
||||
if (prior == told) return;
|
||||
kinds = [AlarmTransitionKind.Unspecified];
|
||||
}
|
||||
foreach (var kind in kinds)
|
||||
OnAlarmEvent?.Invoke(this, e with { Kind = kind });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>#619 re-review — what the host was last told about one condition: its state plus the message and
|
||||
/// exact severity, so a refresh copy is recognised as a duplicate.</summary>
|
||||
/// <param name="Active">ActiveState/Id last told.</param>
|
||||
/// <param name="Acked">AckedState/Id last told.</param>
|
||||
/// <param name="Message">Message last told (null before any event).</param>
|
||||
/// <param name="Severity">Exact severity last told (null before any event).</param>
|
||||
private readonly record struct AlarmToldState(bool Active, bool Acked, string? Message, int? Severity);
|
||||
|
||||
/// <summary>True for a <c>RefreshRequiredEventType</c> event (the server asks the client to ConditionRefresh).</summary>
|
||||
/// <param name="fields">The event's fields.</param>
|
||||
/// <returns>Whether the event is a RefreshRequired marker.</returns>
|
||||
internal static bool IsRefreshRequired(IReadOnlyList<Variant> fields) =>
|
||||
fields.Count > AlarmFieldEventType && fields[AlarmFieldEventType].Value is NodeId t
|
||||
&& t == ObjectTypeIds.RefreshRequiredEventType;
|
||||
|
||||
/// <summary>True for the three ConditionRefresh marker event types — never alarms.</summary>
|
||||
/// <param name="eventType">The event's EventType.</param>
|
||||
/// <returns>Whether it is a refresh marker.</returns>
|
||||
private static bool IsRefreshMarker(object? eventType) =>
|
||||
eventType is NodeId t
|
||||
&& (t == ObjectTypeIds.RefreshStartEventType || t == ObjectTypeIds.RefreshEndEventType
|
||||
|| t == ObjectTypeIds.RefreshRequiredEventType);
|
||||
|
||||
/// <summary>A ConditionRefresh refusal meaning "this server does not do refresh" (tolerated, logged once).</summary>
|
||||
/// <param name="code">The refusal status.</param>
|
||||
/// <returns>Whether the server simply lacks ConditionRefresh.</returns>
|
||||
internal static bool IsConditionRefreshUnsupported(StatusCode code) =>
|
||||
code == StatusCodes.BadNotSupported || code == StatusCodes.BadMethodInvalid
|
||||
|| code == StatusCodes.BadNotImplemented || code == StatusCodes.BadServiceUnsupported
|
||||
|| code == StatusCodes.BadNodeIdUnknown;
|
||||
|
||||
/// <summary>
|
||||
/// #619 — call ConditionRefresh for <paramref name="subscription"/> so conditions already active upstream
|
||||
/// are reported. Never throws: an unsupported server is logged once, any other failure warns.
|
||||
/// </summary>
|
||||
/// <param name="subscription">The (created) alarm subscription.</param>
|
||||
/// <param name="cancellationToken">Cancellation.</param>
|
||||
/// <returns>A task completing when the refresh call returns.</returns>
|
||||
private async Task TryConditionRefreshAsync(Subscription subscription, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await subscription.ConditionRefreshAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (ServiceResultException ex) when (IsConditionRefreshUnsupported(ex.StatusCode))
|
||||
{
|
||||
if (Interlocked.Exchange(ref _conditionRefreshUnsupportedLogged, 1) == 0)
|
||||
_logger.LogInformation(
|
||||
"OpcUaClientDriver '{DriverInstanceId}': upstream server does not support ConditionRefresh " +
|
||||
"({StatusCode}); conditions already active are reported only on their next transition.",
|
||||
_driverInstanceId, ex.StatusCode);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"OpcUaClientDriver '{DriverInstanceId}': ConditionRefresh on alarm subscription {SubscriptionId} failed; " +
|
||||
"conditions already active upstream may not be reported until their next transition.",
|
||||
_driverInstanceId, subscription.Id);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// The caller cancelled; the subscription itself is in place.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #619 — ConditionRefresh every alarm subscription on the current session: after an in-place reconnect
|
||||
/// (transferred or SDK-recreated subscriptions — matched by display name, since a recreate clones the
|
||||
/// <see cref="Subscription"/> objects) and on a RefreshRequired event. Events missed during the outage
|
||||
/// reach the host through the normal deduplicated path.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation.</param>
|
||||
/// <returns>A task completing when every refresh call has returned.</returns>
|
||||
private async Task RefreshAllAlarmSubscriptionsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_alarmSubscriptions.IsEmpty) return;
|
||||
try
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (ObjectDisposedException) { return; }
|
||||
try
|
||||
{
|
||||
var session = Session;
|
||||
if (session is null) return;
|
||||
foreach (var sub in session.Subscriptions.ToList())
|
||||
{
|
||||
if (sub.DisplayName?.StartsWith(AlarmSubscriptionNamePrefix, StringComparison.Ordinal) == true && sub.Created)
|
||||
await TryConditionRefreshAsync(sub, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "OpcUaClientDriver '{DriverInstanceId}': alarm ConditionRefresh sweep failed.",
|
||||
_driverInstanceId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { _gate.Release(); } catch (ObjectDisposedException) { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Display-name prefix of every alarm subscription (how a refresh sweep finds them on a session).</summary>
|
||||
private const string AlarmSubscriptionNamePrefix = "opcua-alarm-sub-";
|
||||
|
||||
/// <summary>
|
||||
/// #619 review — the <see cref="AlarmTransitionKind"/> sequence that moves the host's
|
||||
/// <c>NativeAlarmProjector</c> (which knows only kinds: Raise ⇒ active+unacked, Acknowledge ⇒ acked,
|
||||
/// Clear ⇒ inactive keeping acked) from <paramref name="prior"/> to <paramref name="target"/>. A single
|
||||
/// upstream event can need two (an alarm that went active and was acknowledged between two notifications).
|
||||
/// No state change ⇒ empty (the caller decides whether a message/severity change warrants an Unspecified).
|
||||
/// </summary>
|
||||
/// <param name="prior">The state last told to the host.</param>
|
||||
/// <param name="target">The upstream event's ActiveState/Id + AckedState/Id.</param>
|
||||
/// <returns>The kinds to emit, in order.</returns>
|
||||
internal static IReadOnlyList<AlarmTransitionKind> AlarmTransitions(
|
||||
(bool Active, bool Acked) prior, (bool Active, bool Acked) target)
|
||||
{
|
||||
if (prior == target) return [];
|
||||
return target switch
|
||||
{
|
||||
// Active+unacked: a fresh raise, or a re-arm of an active-acked condition.
|
||||
(true, false) => [prior.Active ? AlarmTransitionKind.Retrigger : AlarmTransitionKind.Raise],
|
||||
// Active+acked: raise first when it was inactive (Raise always lands unacked).
|
||||
(true, true) => prior.Active
|
||||
? [AlarmTransitionKind.Acknowledge]
|
||||
: [AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge],
|
||||
// Inactive+acked: Clear keeps the prior ack, so ack after it when that was unacked.
|
||||
(false, true) => prior.Active
|
||||
? prior.Acked ? [AlarmTransitionKind.Clear] : [AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge]
|
||||
: [AlarmTransitionKind.Acknowledge],
|
||||
// Inactive+UNacked (went active and cleared unacknowledged — e.g. first seen by a ConditionRefresh):
|
||||
// the only kinds that un-ack are Raise/Retrigger, so pass through active, then Clear.
|
||||
(false, false) => prior.Active
|
||||
? prior.Acked ? [AlarmTransitionKind.Retrigger, AlarmTransitionKind.Clear] : [AlarmTransitionKind.Clear]
|
||||
: [AlarmTransitionKind.Raise, AlarmTransitionKind.Clear],
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #619 — index an alarm subscription's source refs by UPSTREAM source id. The host (v3) subscribes with
|
||||
/// the alarm tags' <b>RawPaths</b>; an upstream event carries its own source NodeId, so the match key is
|
||||
/// the upstream id the tag authored and the value is the RawPath to report back. A ref
|
||||
/// <paramref name="resolveUpstream"/> cannot resolve is kept verbatim (direct upstream-id use).
|
||||
/// </summary>
|
||||
/// <param name="refs">The refs handed to <c>SubscribeAlarmsAsync</c>.</param>
|
||||
/// <param name="resolveUpstream">RawPath → serialized live upstream NodeId, or null when unknown.</param>
|
||||
/// <returns>Upstream source id → ref to report.</returns>
|
||||
internal static Dictionary<string, string> BuildAlarmSourceIndex(
|
||||
IReadOnlyList<string> refs, Func<string, string?> resolveUpstream)
|
||||
{
|
||||
var index = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var r in refs)
|
||||
index[resolveUpstream(r) ?? r] = r;
|
||||
return index;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #619 — project one upstream event into an <see cref="AlarmEventArgs"/>. <c>ConditionId</c> and
|
||||
/// <c>SourceNodeId</c> are the authored RawPath, because <c>DriverHostActor</c> routes native alarms on
|
||||
/// (driver, RawPath); the upstream condition node (needed to acknowledge) is handed to
|
||||
/// <paramref name="rememberCondition"/>. Null when the event's source is not an authored one.
|
||||
/// </summary>
|
||||
internal static AlarmEventArgs? ProjectEvent(
|
||||
IReadOnlyList<Variant> fields,
|
||||
IReadOnlyDictionary<string, string> sourceIndex,
|
||||
IAlarmSubscriptionHandle handle,
|
||||
Func<NodeId, string> keyOf,
|
||||
Action<string, AlarmAckTarget> rememberCondition) =>
|
||||
ProjectEvent(fields, sourceIndex, handle, keyOf, rememberCondition, out _);
|
||||
|
||||
/// <summary>As the five-argument overload, also returning the event's (ActiveState/Id, AckedState/Id) through
|
||||
/// <c>state</c> when it is an alarm condition (null otherwise).</summary>
|
||||
internal static AlarmEventArgs? ProjectEvent(
|
||||
IReadOnlyList<Variant> fields,
|
||||
IReadOnlyDictionary<string, string> sourceIndex,
|
||||
IAlarmSubscriptionHandle handle,
|
||||
Func<NodeId, string> keyOf,
|
||||
Action<string, AlarmAckTarget> rememberCondition,
|
||||
out (bool Active, bool Acked)? state)
|
||||
{
|
||||
state = null;
|
||||
if (fields.Count <= AlarmFieldConditionId) return null;
|
||||
// ConditionRefresh brackets its re-delivery with RefreshStart/RefreshEnd — markers, never alarms.
|
||||
if (IsRefreshMarker(fields[AlarmFieldEventType].Value)) return null;
|
||||
// A condition BRANCH event reports a previous state with its own EventId: projecting it would flip the
|
||||
// trunk's state, remembering it would make Acknowledge ack the branch.
|
||||
if (fields.Count > AlarmFieldBranchId && fields[AlarmFieldBranchId].Value is NodeId branch && !NodeId.IsNull(branch))
|
||||
return null;
|
||||
if (fields.Count > AlarmFieldAckedState
|
||||
&& fields[AlarmFieldActiveState].Value is bool active
|
||||
&& fields[AlarmFieldAckedState].Value is bool acked)
|
||||
state = (active, acked);
|
||||
|
||||
// Pass-through (no authored refs) keeps the raw serialized ids; an authored index compares
|
||||
// server-stable keys (see AlarmIndexKey), matching the CONDITION node first — the only way to tell
|
||||
// several conditions on one source apart — and then the source node.
|
||||
var sourceField = fields[AlarmFieldSourceNode].Value;
|
||||
var conditionField = fields[AlarmFieldConditionId].Value;
|
||||
string reported, upstreamCondition;
|
||||
if (sourceIndex.Count == 0)
|
||||
{
|
||||
reported = sourceField?.ToString() ?? string.Empty;
|
||||
upstreamCondition = conditionField?.ToString() ?? string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
upstreamCondition = conditionField is NodeId cn && !NodeId.IsNull(cn) ? keyOf(cn) : string.Empty;
|
||||
var sourceKey = sourceField is NodeId sn && !NodeId.IsNull(sn) ? keyOf(sn) : string.Empty;
|
||||
if (!(upstreamCondition.Length > 0 && sourceIndex.TryGetValue(upstreamCondition, out reported!))
|
||||
&& !(sourceKey.Length > 0 && sourceIndex.TryGetValue(sourceKey, out reported!)))
|
||||
return null;
|
||||
}
|
||||
|
||||
var eventType = fields[AlarmFieldEventType].Value?.ToString() ?? "BaseEventType";
|
||||
var message = (fields[AlarmFieldMessage].Value as LocalizedText)?.Text ?? string.Empty;
|
||||
var severity = fields[AlarmFieldSeverity].Value is ushort sev ? sev : (ushort)0;
|
||||
var time = fields[AlarmFieldTime].Value is DateTime t ? t : DateTime.UtcNow;
|
||||
|
||||
if (sourceIndex.Count != 0 && upstreamCondition.Length > 0)
|
||||
rememberCondition(reported, new AlarmAckTarget(
|
||||
upstreamCondition,
|
||||
// A copy: the decoded notification's array is not ours to alias for the ack's lifetime.
|
||||
(fields[AlarmFieldEventId].Value as byte[])?.ToArray() ?? Array.Empty<byte>()));
|
||||
|
||||
return new AlarmEventArgs(
|
||||
SubscriptionHandle: handle,
|
||||
SourceNodeId: sourceNode,
|
||||
ConditionId: conditionId,
|
||||
SourceNodeId: reported,
|
||||
ConditionId: sourceIndex.Count == 0 ? upstreamCondition : reported,
|
||||
AlarmType: eventType,
|
||||
Message: message,
|
||||
Severity: MapSeverity(severity),
|
||||
SourceTimestampUtc: time));
|
||||
SourceTimestampUtc: time,
|
||||
// #626: the upstream severity is already the exact Part 9 value — carry it, don't re-bucket.
|
||||
OpcUaSeverity: severity == 0 ? null : severity);
|
||||
}
|
||||
|
||||
/// <summary>#619 — what Acknowledge needs for one RawPath: the upstream condition node (server-stable
|
||||
/// reference) and the EventId of the event that named it.</summary>
|
||||
/// <param name="ConditionRef">Upstream condition NodeId, rendered by <see cref="AlarmIndexKey"/>.</param>
|
||||
/// <param name="EventId">EventId of the most recent event seen for that condition.</param>
|
||||
internal readonly record struct AlarmAckTarget(string ConditionRef, byte[] EventId);
|
||||
|
||||
/// <summary>#619 — the key both sides of the alarm index use: server-stable (<c>nsu=</c>) when a map is
|
||||
/// available, so a remote namespace-table reorder across an in-place reconnect cannot orphan it.</summary>
|
||||
/// <param name="map">The current session's namespace map, or null.</param>
|
||||
/// <param name="nodeId">The NodeId to key.</param>
|
||||
/// <returns>The index key.</returns>
|
||||
internal static string AlarmIndexKey(NamespaceMap? map, NodeId nodeId) =>
|
||||
map?.ToStableReference(nodeId) ?? nodeId.ToString() ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Map an OPC UA <c>BaseEventType.Severity</c> (1..1000) to our coarse-grained
|
||||
/// <see cref="AlarmSeverity"/> bucket. Thresholds match the OPC UA A&C Part 9
|
||||
@@ -2364,6 +2757,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
TransitionTo(HostState.Running);
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
_diag.Record(OpcUaClientDiagnostics.ReconnectSuccess);
|
||||
// #619: transitions during the outage were missed (a recreated subscription starts empty; a transferred
|
||||
// one may have overflowed). ConditionRefresh re-reports current states; unchanged ones dedup to nothing.
|
||||
if (!_alarmSubscriptions.IsEmpty)
|
||||
_ = Task.Run(() => RefreshAllAlarmSubscriptionsAsync(CancellationToken.None));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class S7Driver
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, IBatchSubscribable, IHostConnectivityProbe, ISkippedTagReporter, IDisposable, IAsyncDisposable
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, IBatchSubscribable, IHostConnectivityProbe, ISkippedTagReporter, IDriverDiagnostics, IDisposable, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541).</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
@@ -463,6 +463,19 @@ public sealed class S7Driver
|
||||
["S7.NegotiatedPduSize"] = NegotiatedPduSize,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IDriverDiagnostics"/> view of the same facts, as strings, for the <c>/hosts</c> page
|
||||
/// (Gitea #640). Empty before the first connect (PDU size 0 is "not negotiated yet", not a size).
|
||||
/// </summary>
|
||||
/// <returns>The diagnostics bag.</returns>
|
||||
IReadOnlyDictionary<string, string> IDriverDiagnostics.GetDiagnostics()
|
||||
{
|
||||
var pdu = NegotiatedPduSize;
|
||||
return pdu > 0
|
||||
? new Dictionary<string, string> { ["S7.NegotiatedPduSize"] = pdu.ToString(System.Globalization.CultureInfo.InvariantCulture) }
|
||||
: new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
private static readonly HashSet<S7DataType> ByteAnchoredDataTypes = new()
|
||||
{
|
||||
S7DataType.Int64,
|
||||
@@ -1555,14 +1568,21 @@ public sealed class S7Driver
|
||||
// ReadScalarBlockAsync read, so WriteBytesAsync writes the whole reserved STRING. A value
|
||||
// longer than StringLength throws ArgumentException (S7.Net rejects overflow; we do NOT
|
||||
// silently truncate). A null value encodes as the empty string.
|
||||
return global::S7.Net.Types.S7String.ToByteArray(Convert.ToString(value) ?? "", tag.StringLength);
|
||||
// #632: surfaced as S7ValueRangeException (→ BadOutOfRange) like the WSTRING codec — a
|
||||
// client value error, not a comms fault (the raw ArgumentException used to land in the
|
||||
// generic catch as BadCommunicationError + Degraded).
|
||||
try { return global::S7.Net.Types.S7String.ToByteArray(Convert.ToString(value) ?? "", tag.StringLength); }
|
||||
catch (ArgumentException ex) { throw new S7ValueRangeException($"S7 STRING value does not fit the declared {tag.StringLength}-character field: {ex.Message}"); }
|
||||
|
||||
case S7DataType.DateTime:
|
||||
// S7.Net's DateTime.ToByteArray builds the 8-byte DT BCD block. It validates the
|
||||
// 1990–2089 DT range and throws ArgumentOutOfRangeException for an out-of-range year —
|
||||
// we surface that (no silent clamp). Value coerced via Convert.ToDateTime (accepts a
|
||||
// System.DateTime or a parseable timestamp string).
|
||||
return global::S7.Net.Types.DateTime.ToByteArray(Convert.ToDateTime(value));
|
||||
// #632: the range refusal maps to BadOutOfRange (see STRING above).
|
||||
var dt = Convert.ToDateTime(value);
|
||||
try { return global::S7.Net.Types.DateTime.ToByteArray(dt); }
|
||||
catch (ArgumentOutOfRangeException ex) { throw new S7ValueRangeException($"S7 DATE_AND_TIME value {dt:O} is outside the DT range (1990–2089): {ex.Message}"); }
|
||||
|
||||
// Writes refuse (S7ValueRangeException → BadOutOfRange) rather than truncate or clamp.
|
||||
case S7DataType.WString: return S7ValueCodecs.EncodeWString(value, tag.StringLength);
|
||||
|
||||
@@ -57,7 +57,8 @@
|
||||
<tr>
|
||||
<td><code>@Short(d.DeploymentId)</code></td>
|
||||
<td><code>@DisplayText.Abbreviate(d.RevisionHash, 12)</code></td>
|
||||
<td>@d.Status</td>
|
||||
@* #601: Sealed + a reason = sealed with a degraded address-space apply on some node(s). *@
|
||||
<td title="@(d.FailureReason ?? "")">@(d.Status == DeploymentStatus.Sealed && !string.IsNullOrEmpty(d.FailureReason) ? "Sealed (degraded)" : d.Status.ToString())</td>
|
||||
<td>@d.CreatedBy</td>
|
||||
<td>@d.CreatedAtUtc.ToString("u")</td>
|
||||
<td>@(d.SealedAtUtc?.ToString("u") ?? "—")</td>
|
||||
@@ -77,7 +78,8 @@
|
||||
DeploymentStatus Status,
|
||||
string CreatedBy,
|
||||
DateTime CreatedAtUtc,
|
||||
DateTime? SealedAtUtc);
|
||||
DateTime? SealedAtUtc,
|
||||
string? FailureReason);
|
||||
|
||||
private IReadOnlyList<DeploymentRow> _deployments = Array.Empty<DeploymentRow>();
|
||||
private bool _busy;
|
||||
@@ -102,7 +104,8 @@
|
||||
d.Status,
|
||||
d.CreatedBy,
|
||||
d.CreatedAtUtc,
|
||||
d.SealedAtUtc))
|
||||
d.SealedAtUtc,
|
||||
d.FailureReason))
|
||||
.Take(50)
|
||||
.ToListAsync();
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ else
|
||||
<span class="chip chip-idle me-1">@role</span>
|
||||
}
|
||||
</td>
|
||||
<td><span class="chip @StatusChipClass(r.Status)">@StatusLabel(r.Status)</span></td>
|
||||
<td><span class="chip @StatusChipClass(r.Status, r.FailureReason)">@StatusLabel(r.Status, r.FailureReason)</span></td>
|
||||
<td>@(r.AppliedAtUtc?.ToString("u") ?? "—")</td>
|
||||
<td>@(r.StartedAtUtc?.ToString("u") ?? "—")</td>
|
||||
<td title="@(r.FailureReason ?? "")">@(string.IsNullOrEmpty(r.FailureReason) ? "" : DisplayText.Abbreviate(r.FailureReason, 80))</td>
|
||||
@@ -184,15 +184,22 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
private static string StatusChipClass(NodeDeploymentStatus? status) => status switch
|
||||
{
|
||||
NodeDeploymentStatus.Applied => "chip-ok",
|
||||
NodeDeploymentStatus.Applying => "chip-caution",
|
||||
NodeDeploymentStatus.Failed => "chip-alert",
|
||||
_ => "chip-idle",
|
||||
};
|
||||
// #601 — an Applied row carrying a reason is a DEGRADED apply (the node runs the revision but its address
|
||||
// space may be partial); it must not render as the same green chip as a clean apply.
|
||||
private static bool IsDegraded(NodeDeploymentStatus? status, string? failureReason) =>
|
||||
status is { } s && ZB.MOM.WW.OtOpcUa.ControlPlane.Coordinators.ConfigPublishCoordinator.IsDegraded(s, failureReason);
|
||||
|
||||
private static string StatusLabel(NodeDeploymentStatus? status) => status?.ToString() ?? "waiting";
|
||||
private static string StatusChipClass(NodeDeploymentStatus? status, string? failureReason) =>
|
||||
IsDegraded(status, failureReason) ? "chip-caution" : status switch
|
||||
{
|
||||
NodeDeploymentStatus.Applied => "chip-ok",
|
||||
NodeDeploymentStatus.Applying => "chip-caution",
|
||||
NodeDeploymentStatus.Failed => "chip-alert",
|
||||
_ => "chip-idle",
|
||||
};
|
||||
|
||||
private static string StatusLabel(NodeDeploymentStatus? status, string? failureReason) =>
|
||||
IsDegraded(status, failureReason) ? "Applied (degraded)" : status?.ToString() ?? "waiting";
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
|
||||
@@ -189,6 +189,17 @@ else
|
||||
@* Own line — inline after the name the two identifiers ran together. *@
|
||||
<div class="mono small text-muted">@d.DriverInstanceId</div>
|
||||
}
|
||||
@if (d.DiagnosticItems.Count > 0)
|
||||
{
|
||||
@* Driver-reported diagnostics (Gitea #640), e.g. S7 negotiated PDU size.
|
||||
Renders nothing for a driver with none. *@
|
||||
<div class="small text-muted" data-testid="driver-diagnostics">
|
||||
@foreach (var kv in d.DiagnosticItems)
|
||||
{
|
||||
<div class="mono">@kv.Key: @kv.Value</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>@(d.DriverType ?? "—")</td>
|
||||
<td><span class="chip @DriverChipClass(d.State)">@d.State</span></td>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Uns.TagEditors
|
||||
@using ZB.MOM.WW.OtOpcUa.Commons.Types
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject IRawTreeService Svc
|
||||
@@ -90,6 +91,9 @@
|
||||
<div class="form-text">Schemaless per driver type. Validated server-side at deploy.</div>
|
||||
}
|
||||
</div>
|
||||
<NativeAlarmEditor ConfigJson="@_form.TagConfig"
|
||||
ConfigJsonChanged="@(v => _form.TagConfig = v)"
|
||||
DriverType="@DriverType" />
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_error))
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
@* Shared native-alarm section (Gitea #642): authors the driver-agnostic TagConfig `alarm` object
|
||||
(alarmType / severity / historizeToAveva) for a raw tag. A thin shell over NativeAlarmModel — all rules
|
||||
live there. It sits OUTSIDE the per-driver typed editor (Galaxy has none) and writes the same ConfigJson.
|
||||
|
||||
Order-safety: every change goes through NativeAlarmModel.Edit(ConfigJson, ...), which re-parses the
|
||||
LATEST json, so it can never clobber a key the driver editor just wrote; the driver models in turn
|
||||
preserve the `alarm` key (pinned by NativeAlarmEditorModelTests).
|
||||
|
||||
Plain input/select + @onchange (no InputNumber/EditForm binding — see the AdminUI binding pitfalls). *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
|
||||
@if (_m.IsAlarm || _supported)
|
||||
{
|
||||
<div class="mb-3 border rounded p-2" data-testid="native-alarm-editor">
|
||||
@if (!_supported)
|
||||
{
|
||||
@* Never hide an authored alarm silently: show it and offer removal rather than dropping it. *@
|
||||
<div class="text-warning small">
|
||||
This tag carries an <span class="mono">alarm</span> object, but driver type
|
||||
<span class="mono">@DriverType</span> does not raise native alarms, so it has no effect.
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" id="native-alarm-remove"
|
||||
@onclick="@(() => Update(m => m.IsAlarm = false))">Remove alarm object</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="form-check form-switch">
|
||||
<input id="native-alarm-enabled" type="checkbox" class="form-check-input"
|
||||
checked="@_m.IsAlarm"
|
||||
@onchange="@(e => Update(m => m.IsAlarm = e.Value is true))" />
|
||||
<label class="form-check-label" for="native-alarm-enabled">Native alarm (Part 9 condition)</label>
|
||||
</div>
|
||||
@if (_m.IsAlarm)
|
||||
{
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="native-alarm-type">Alarm type</label>
|
||||
<select id="native-alarm-type" class="form-select form-select-sm" value="@_m.AlarmType"
|
||||
@onchange="@(e => Update(m => m.AlarmType = e.Value?.ToString() ?? "AlarmCondition"))">
|
||||
@foreach (var t in NativeAlarmModel.KnownAlarmTypes) { <option value="@t">@t</option> }
|
||||
@if (!NativeAlarmModel.KnownAlarmTypes.Contains(_m.AlarmType))
|
||||
{
|
||||
<option value="@_m.AlarmType">@_m.AlarmType (custom)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="native-alarm-severity">Severity (1..1000)</label>
|
||||
<input id="native-alarm-severity" type="number" min="1" max="1000" class="form-control form-control-sm"
|
||||
value="@_m.Severity"
|
||||
@onchange="@(e => Update(m => m.Severity = int.TryParse(e.Value?.ToString(), out var s) ? s : 500))" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="native-alarm-historize">Historize to AVEVA</label>
|
||||
<select id="native-alarm-historize" class="form-select form-select-sm"
|
||||
value="@HistorizeValue(_m.HistorizeToAveva)"
|
||||
@onchange="@(e => Update(m => m.HistorizeToAveva = ParseHistorize(e.Value?.ToString())))">
|
||||
<option value="default">Default (yes)</option>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No (live alerts only)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@foreach (var w in _m.Warnings())
|
||||
{
|
||||
<div class="text-warning small mt-1">@w</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>The tag's raw TagConfig JSON (supports <c>@bind-ConfigJson</c>).</summary>
|
||||
[Parameter] public string? ConfigJson { get; set; }
|
||||
/// <summary>Two-way config callback.</summary>
|
||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||
/// <summary>Driver type of the owning device; gates whether the editor is offered.</summary>
|
||||
[Parameter] public string? DriverType { get; set; }
|
||||
|
||||
private NativeAlarmModel _m = new();
|
||||
private bool _supported;
|
||||
private string? _lastConfigJson;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_supported = NativeAlarmModel.SupportsNativeAlarms(DriverType);
|
||||
if (ConfigJson == _lastConfigJson) { return; }
|
||||
_lastConfigJson = ConfigJson;
|
||||
_m = NativeAlarmModel.FromJson(ConfigJson);
|
||||
}
|
||||
|
||||
private static string HistorizeValue(bool? v) => v is null ? "default" : v.Value ? "yes" : "no";
|
||||
|
||||
private static bool? ParseHistorize(string? v) => v switch { "yes" => true, "no" => false, _ => null };
|
||||
|
||||
private async Task Update(Action<NativeAlarmModel> apply)
|
||||
{
|
||||
var json = NativeAlarmModel.Edit(ConfigJson, apply);
|
||||
_lastConfigJson = json;
|
||||
_m = NativeAlarmModel.FromJson(json);
|
||||
await ConfigJsonChanged.InvokeAsync(json);
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,8 @@ public sealed record HostsDriverInstanceInfo(
|
||||
/// is no answer — the driver cannot report skips, or it faulted before building a tag table. Lets
|
||||
/// "ignoring 20 of 22 tags" show even while every other column on the row — state, last read, error
|
||||
/// count, last error — reads perfectly healthy (Gitea #541).</param>
|
||||
/// <param name="Diagnostics">Driver-reported diagnostics (e.g. S7 negotiated PDU size); null or empty when
|
||||
/// the driver has none to show (Gitea #640).</param>
|
||||
/// <param name="Enabled">Whether the matching configured instance is authored enabled; <see langword="true"/>
|
||||
/// when no instance matched. Fed to the row's expandable <c>DriverStatusPanel</c> (Gitea #546). An
|
||||
/// unmatched row defaults to enabled on purpose: the row only exists because a live snapshot arrived,
|
||||
@@ -62,8 +64,16 @@ public sealed record HostsDriverRow(
|
||||
DateTime? RediscoveryNeededUtc = null, string? RediscoveryReason = null,
|
||||
IReadOnlyList<HostConnectivityStatus>? HostStatuses = null,
|
||||
SkippedTagReport? SkippedTags = null,
|
||||
bool Enabled = true)
|
||||
bool Enabled = true,
|
||||
IReadOnlyDictionary<string, string>? Diagnostics = null)
|
||||
{
|
||||
/// <summary>Diagnostics as ordered key/value pairs for rendering; empty when there are none, in which
|
||||
/// case the page renders nothing.</summary>
|
||||
public IReadOnlyList<KeyValuePair<string, string>> DiagnosticItems { get; } =
|
||||
(Diagnostics ?? new Dictionary<string, string>())
|
||||
.OrderBy(k => k.Key, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
/// <summary>Whether this driver is ignoring at least one authored tag — the condition the whole
|
||||
/// column exists to make visible.</summary>
|
||||
public bool HasSkippedTags => SkippedTags is { SkippedCount: > 0 };
|
||||
@@ -177,7 +187,8 @@ public static class HostsDriverView
|
||||
s.RediscoveryReason,
|
||||
s.HostStatuses,
|
||||
s.SkippedTags,
|
||||
instance?.Enabled ?? true);
|
||||
instance?.Enabled ?? true,
|
||||
s.Diagnostics);
|
||||
})
|
||||
.OrderBy(d => d.Name ?? d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(d => d.DriverInstanceId, StringComparer.OrdinalIgnoreCase)
|
||||
|
||||
@@ -90,14 +90,72 @@ public sealed class NativeAlarmModel
|
||||
// Re-attach (no-op when it was already the same node; required when we created a fresh one).
|
||||
_root["alarm"] = alarm;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Toggled off (#642): the alarm object must go, or a "disabled" tag would still deploy as a condition.
|
||||
_root.Remove("alarm");
|
||||
}
|
||||
|
||||
return TagConfigJson.Serialize(_root);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
/// <returns>An error message describing the validation failure, or null when the model is valid.</returns>
|
||||
/// <remarks>Deliberately never errors: the deploy gate has no <c>alarm</c> rule and
|
||||
/// <c>TagConfigIntent.ParseAlarm</c> accepts any alarmType string and any severity (clamped 1..1000 at
|
||||
/// publish), so rejecting here would be stricter than the gate. Out-of-range input is advisory —
|
||||
/// see <see cref="Warnings"/>.</remarks>
|
||||
public string? Validate() => null;
|
||||
|
||||
/// <summary>Advisory messages for values the server will accept but silently normalise.</summary>
|
||||
/// <returns>Zero or more warning strings.</returns>
|
||||
public IEnumerable<string> Warnings()
|
||||
{
|
||||
if (!IsAlarm) { yield break; }
|
||||
if (Severity is < 1 or > 1000)
|
||||
{
|
||||
yield return $"Severity {Severity} is outside 1..1000; the server clamps it.";
|
||||
}
|
||||
if (!KnownAlarmTypes.Contains(AlarmType))
|
||||
{
|
||||
yield return $"Alarm type '{AlarmType}' is not one of {string.Join("/", KnownAlarmTypes)}; the server treats it as AlarmCondition.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The Part 9 condition subtypes the server distinguishes (OffNormal/Discrete; the rest map to the base condition).</summary>
|
||||
public static readonly IReadOnlyList<string> KnownAlarmTypes =
|
||||
["AlarmCondition", "OffNormalAlarm", "DiscreteAlarm", "LimitAlarm"];
|
||||
|
||||
/// <summary>Driver types that implement <c>IAlarmSource</c> (so a TagConfig <c>alarm</c> object is
|
||||
/// meaningful). The alarm object itself is driver-agnostic; this only gates the editor's visibility.</summary>
|
||||
private static readonly HashSet<string> AlarmDriverTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
Core.Abstractions.DriverTypeNames.MTConnect, Core.Abstractions.DriverTypeNames.AbCip,
|
||||
Core.Abstractions.DriverTypeNames.FOCAS, Core.Abstractions.DriverTypeNames.OpcUaClient,
|
||||
Core.Abstractions.DriverTypeNames.Galaxy,
|
||||
};
|
||||
|
||||
/// <summary>True when the driver type can emit native alarms.</summary>
|
||||
/// <param name="driverType">Driver type name.</param>
|
||||
/// <returns>Whether the native-alarm editor applies.</returns>
|
||||
public static bool SupportsNativeAlarms(string? driverType)
|
||||
=> driverType is not null && AlarmDriverTypes.Contains(driverType);
|
||||
|
||||
/// <summary>
|
||||
/// Order-safe edit: re-parses the LATEST <paramref name="json"/> (so a keystroke in the per-driver
|
||||
/// editor is never overwritten by a stale cached copy), applies <paramref name="mutate"/>, serialises.
|
||||
/// Only the <c>alarm</c> key is touched; every other key is preserved verbatim.
|
||||
/// </summary>
|
||||
/// <param name="json">Current TagConfig JSON.</param>
|
||||
/// <param name="mutate">Edit to apply to the freshly-parsed model.</param>
|
||||
/// <returns>The new TagConfig JSON.</returns>
|
||||
public static string Edit(string? json, Action<NativeAlarmModel> mutate)
|
||||
{
|
||||
var m = FromJson(json);
|
||||
mutate(m);
|
||||
return m.ToJson();
|
||||
}
|
||||
|
||||
/// <summary>Default <c>alarmType</c> seeded when an alarm-bearing attribute is picked but the
|
||||
/// TagConfig has no <c>alarm</c> object yet. Galaxy's <c>AlarmExtension</c> is a boolean
|
||||
/// off-normal condition, so <c>OffNormalAlarm</c> is the natural Part 9 subtype.</summary>
|
||||
|
||||
@@ -57,7 +57,9 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
|
||||
{
|
||||
public const string DeploymentsTopic = "deployments";
|
||||
public const string DeploymentAcksTopic = "deployment-acks";
|
||||
public static readonly TimeSpan DefaultApplyDeadline = TimeSpan.FromMinutes(2);
|
||||
/// <summary>Central's apply deadline — the shared <see cref="DeployTimings.ApplyDeadline"/>, which the node's
|
||||
/// apply-outcome timeout is derived from (Gitea #601).</summary>
|
||||
public static readonly TimeSpan DefaultApplyDeadline = DeployTimings.ApplyDeadline;
|
||||
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
|
||||
private readonly TimeSpan _applyDeadline;
|
||||
@@ -83,6 +85,30 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
|
||||
private static readonly IEqualityComparer<NodeId> NodeIdComparer = new CaseInsensitiveNodeIdComparer();
|
||||
private readonly Dictionary<NodeId, ApplyAckOutcome> _acks = new(NodeIdComparer);
|
||||
|
||||
/// <summary>Gitea #601 — the expected nodes whose Applied ack was DEGRADED (address-space apply partial).
|
||||
/// Recovered from the DB on failover via <see cref="IsDegraded"/>.</summary>
|
||||
private readonly HashSet<NodeId> _degraded = new(NodeIdComparer);
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #601 — prefix of <c>NodeDeploymentState.FailureReason</c> on an <b>Applied</b> row whose apply
|
||||
/// was degraded. The status model is deliberately unchanged (no new enum value, no migration): a
|
||||
/// degraded node DID apply the revision, so its status stays <see cref="NodeDeploymentStatus.Applied"/>
|
||||
/// and the degradation rides the existing reason column, which <c>/fleet</c> already renders.
|
||||
/// </summary>
|
||||
public const string DegradedReasonPrefix = ApplyAck.DegradedReasonPrefix;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #601 — whether a <c>NodeDeploymentState</c> row records a degraded apply: status
|
||||
/// <see cref="NodeDeploymentStatus.Applied"/> with a non-empty <c>FailureReason</c>. A clean Applied row
|
||||
/// never carries a reason (neither this coordinator nor <c>DriverHostActor</c> writes one), so an
|
||||
/// old node's row reads as clean exactly as before.
|
||||
/// </summary>
|
||||
/// <param name="status">The row's status.</param>
|
||||
/// <param name="failureReason">The row's failure reason.</param>
|
||||
/// <returns><see langword="true"/> for an Applied-but-degraded row.</returns>
|
||||
public static bool IsDegraded(NodeDeploymentStatus status, string? failureReason) =>
|
||||
status == NodeDeploymentStatus.Applied && !string.IsNullOrEmpty(failureReason);
|
||||
|
||||
private DeploymentId? _current;
|
||||
private HashSet<NodeId> _expectedAcks = new(NodeIdComparer);
|
||||
|
||||
@@ -149,9 +175,12 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
|
||||
|
||||
_expectedAcks = nodeStates.Select(s => NodeId.Parse(s.NodeId)).ToHashSet(NodeIdComparer);
|
||||
foreach (var s in nodeStates.Where(s => s.Status != NodeDeploymentStatus.Applying))
|
||||
{
|
||||
_acks[NodeId.Parse(s.NodeId)] = s.Status == NodeDeploymentStatus.Applied
|
||||
? ApplyAckOutcome.Applied
|
||||
: ApplyAckOutcome.Failed;
|
||||
if (IsDegraded(s.Status, s.FailureReason)) _degraded.Add(NodeId.Parse(s.NodeId));
|
||||
}
|
||||
|
||||
// Resume the deadline timer using the remaining time. The deadline runs from when the
|
||||
// deployment was first marked AwaitingApplyAcks (Deployment.CreatedAtUtc is a close enough
|
||||
@@ -174,6 +203,7 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
|
||||
{
|
||||
_current = msg.DeploymentId;
|
||||
_acks.Clear();
|
||||
_degraded.Clear();
|
||||
|
||||
// Seed NodeDeploymentState rows so a failover knows which nodes were expected to ack.
|
||||
using (var db = _dbFactory.CreateDbContext())
|
||||
@@ -237,6 +267,9 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
|
||||
}
|
||||
|
||||
_acks[msg.NodeId] = msg.Outcome;
|
||||
// #601 — an old node never sets Degraded, so its ack takes the clean branch exactly as before.
|
||||
if (IsDegradedAck(msg)) _degraded.Add(msg.NodeId);
|
||||
else _degraded.Remove(msg.NodeId);
|
||||
PersistNodeAck(msg);
|
||||
|
||||
if (_acks.Count < _expectedAcks.Count) return;
|
||||
@@ -258,15 +291,51 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
|
||||
? NodeDeploymentStatus.Applied
|
||||
: NodeDeploymentStatus.Failed;
|
||||
row.AppliedAtUtc = DateTime.UtcNow;
|
||||
row.FailureReason = msg.FailureReason;
|
||||
// #601 — an Applied+degraded ack keeps status Applied and records WHY in the reason column, so the row
|
||||
// reads as degraded (IsDegraded) rather than clean. A clean or Failed ack writes what it always did.
|
||||
row.FailureReason = IsDegradedAck(msg)
|
||||
? Truncate(DegradedReasonPrefix + (string.IsNullOrWhiteSpace(msg.DegradedReason)
|
||||
? "address-space apply degraded (no reason given)"
|
||||
: msg.DegradedReason))
|
||||
: msg.FailureReason;
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static bool IsDegradedAck(ApplyAck msg) => msg.Outcome == ApplyAckOutcome.Applied && msg.Degraded;
|
||||
|
||||
// FailureReason is nvarchar(2048) on both tables (OtOpcUaConfigDbContext); a reason built from a node's
|
||||
// free text must never fail the SaveChanges that records the ack.
|
||||
private const int FailureReasonMaxLength = 2048;
|
||||
|
||||
private static string Truncate(string s) =>
|
||||
s.Length <= FailureReasonMaxLength ? s : s[..(FailureReasonMaxLength - 1)] + "…";
|
||||
|
||||
private void SealDeployment()
|
||||
{
|
||||
if (_current is null) return;
|
||||
using var db = _dbFactory.CreateDbContext();
|
||||
UpdateDeploymentStatus(db, _current.Value, DeploymentStatus.Sealed, sealNow: true);
|
||||
if (_degraded.Count > 0)
|
||||
{
|
||||
// #601 — still Sealed: every node DID apply this revision and is running it, and "latest sealed" is
|
||||
// what drift detection and the publish actor's fallback key on, so a degraded deploy that was not
|
||||
// sealed would make the fleet look one revision behind what it actually runs. Sealed is not the
|
||||
// same as clean, though: the deployment's reason names the degraded nodes (per-node detail is on
|
||||
// each NodeDeploymentState row) and the seal logs at Warning.
|
||||
var nodes = _degraded.Select(n => n.Value).Order(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var d = db.Deployments.FirstOrDefault(x => x.DeploymentId == _current.Value.Value);
|
||||
if (d is not null)
|
||||
d.FailureReason = Truncate(
|
||||
$"Sealed with a degraded address-space apply on {nodes.Count} node(s): {string.Join(", ", nodes)}. " +
|
||||
"See each node's NodeDeploymentState.FailureReason; re-deploying the same revision re-applies it.");
|
||||
db.SaveChanges();
|
||||
_log.Warning(
|
||||
"Deployment {Id} sealed DEGRADED (acks={Count}) — the address-space apply was degraded on: {Nodes}",
|
||||
_current.Value, _acks.Count, string.Join(", ", nodes));
|
||||
ResetForNext();
|
||||
return;
|
||||
}
|
||||
|
||||
db.SaveChanges();
|
||||
_log.Info("Deployment {Id} sealed (acks={Count})", _current.Value, _acks.Count);
|
||||
ResetForNext();
|
||||
@@ -318,6 +387,7 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
|
||||
_current = null;
|
||||
_expectedAcks.Clear();
|
||||
_acks.Clear();
|
||||
_degraded.Clear();
|
||||
}
|
||||
|
||||
private const string DeadlineTimerKey = "apply-deadline";
|
||||
|
||||
@@ -130,7 +130,8 @@ public static class TelemetryProtoMapCentral
|
||||
RediscoveryNeededUtc: msg.RediscoveryNeededUtc?.ToDateTime(),
|
||||
RediscoveryReason: msg.HasRediscoveryReason ? msg.RediscoveryReason : null,
|
||||
HostStatuses: ToHostStatuses(msg),
|
||||
SkippedTags: ToSkippedTags(msg));
|
||||
SkippedTags: ToSkippedTags(msg),
|
||||
Diagnostics: msg.HasDiagnostics ? new Dictionary<string, string>(msg.Diagnostics) : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -164,6 +164,14 @@ public static class TelemetryProtoMapNode
|
||||
msg.SkippedTags = payload;
|
||||
}
|
||||
|
||||
// Gitea #640. Presence flag first - an empty map cannot say whether the driver has the capability.
|
||||
if (e.Diagnostics is not null)
|
||||
{
|
||||
msg.HasDiagnostics = true;
|
||||
foreach (var kv in e.Diagnostics)
|
||||
msg.Diagnostics[kv.Key ?? ""] = kv.Value ?? "";
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// a native re-materialise adds, and <see cref="RebuildAddressSpace"/> clears it alongside
|
||||
/// <see cref="_alarmConditions"/> so a re-materialise as the other kind is correct.</summary>
|
||||
private readonly HashSet<RealmKey> _nativeAlarmNodeIds = new();
|
||||
/// <summary>Gitea #641: every NATIVE condition's live state, captured by <see cref="RebuildAddressSpace"/>
|
||||
/// just before it clears <see cref="_alarmConditions"/>, waiting for <see cref="MaterialiseAlarmCondition"/>
|
||||
/// to re-create the same id as native in the same apply. Whatever is unclaimed when
|
||||
/// <see cref="CompleteAddressSpaceApply"/> runs is discarded. Guarded by <c>Lock</c>.</summary>
|
||||
private readonly Dictionary<RealmKey, NativeConditionCarryOver> _rebuildCarryOver = new();
|
||||
/// <summary>Gitea #641: conditions whose non-normal state a re-materialise restored, owed ONE condition
|
||||
/// event at <see cref="CompleteAddressSpaceApply"/> (deferred until then so every notifier folder the
|
||||
/// apply wires receives it). Each entry pins the instance restored, so a condition replaced in between is
|
||||
/// not reported. Guarded by <c>Lock</c>.</summary>
|
||||
private readonly List<(RealmKey Key, AlarmConditionState Alarm)> _restoredConditionReports = new();
|
||||
/// <summary>Phase C: NodeId → resolved historian tagname for every variable materialised
|
||||
/// Historizing. Populated by <see cref="EnsureVariable"/> when a historian tagname is supplied; the
|
||||
/// (later) HistoryRead override resolves a HistoryRead request's NodeId against this map. Cleared on
|
||||
@@ -1432,9 +1442,109 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// H6a: record native (driver-fed) conditions so a later task can route their inbound
|
||||
// Acknowledge to the driver rather than the scripted engine.
|
||||
if (isNative) _nativeAlarmNodeIds.Add(conditionKey);
|
||||
|
||||
// Gitea #641: a native condition the rebuild in this apply discarded gets its live state back. Removed
|
||||
// whatever the kind, so a native→scripted swap on the same id drops the capture instead of leaving it
|
||||
// for a later re-add to resurrect.
|
||||
if (_rebuildCarryOver.Remove(conditionKey, out var carried) && isNative)
|
||||
RestoreCarryOver(alarm, conditionKey, carried);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 — one native condition's live Part 9 state, carried across a full
|
||||
/// <see cref="RebuildAddressSpace"/>.
|
||||
/// </summary>
|
||||
/// <param name="State">The gate-relevant fields, read off the node as <see cref="ReadConditionDelta"/> does.</param>
|
||||
/// <param name="Time">The condition's <c>Time</c> — the instant its current state came about.</param>
|
||||
/// <param name="ActiveTransitionTime">When it last changed Active, if the SDK recorded it.</param>
|
||||
/// <param name="QualityTimestamp">The source timestamp of its Quality annotation, if present.</param>
|
||||
/// <param name="Comment">The condition's <c>Comment</c> (a client's ack / AddComment text), if any.</param>
|
||||
/// <param name="ClientUserId">Who left that comment, if recorded.</param>
|
||||
private sealed record NativeConditionCarryOver(
|
||||
AlarmConditionDelta State, DateTime Time, DateTime? ActiveTransitionTime, DateTime? QualityTimestamp,
|
||||
LocalizedText? Comment = null, string? ClientUserId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 — read what a native condition node holds right now, so the rebuild can put it back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why the NODE, not a host-side cache.</b> The node is the only place that knows the true state:
|
||||
/// a client's Acknowledge (and its comment) is applied to the node by the SDK and never passes through
|
||||
/// the driver or <c>NativeAlarmProjector</c>. Replaying from any host-side record would un-acknowledge it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why it is needed at all.</b> A rebuild recreates every condition inactive + acknowledged. When the
|
||||
/// alarm-tag set is unchanged — the common case: adding a <c>UnsTagReference</c> to an alarm-bearing raw
|
||||
/// tag is enough to force one — the host does not re-subscribe the driver's alarms, and even a
|
||||
/// re-subscribe would not help a transition-only source (Galaxy, OpcUaClient) with nothing to replay.
|
||||
/// Without this, every active alarm read normal until its next device transition.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="condition">The live native condition, about to be discarded.</param>
|
||||
/// <returns>Its carried state.</returns>
|
||||
private static NativeConditionCarryOver CaptureCarryOver(AlarmConditionState condition) => new(
|
||||
ReadConditionDelta(condition),
|
||||
condition.Time?.Value ?? DateTime.UtcNow,
|
||||
condition.ActiveState?.TransitionTime?.Value,
|
||||
condition.Quality?.SourceTimestamp?.Value,
|
||||
condition.Comment?.Value,
|
||||
condition.ClientUserId?.Value);
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 — put a carried state back onto the freshly materialised native condition. MUST be called
|
||||
/// under <c>Lock</c>, after the condition is registered.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Quality always; the rest only off-normal.</b> Source quality is annotated only on a driver's
|
||||
/// connectivity edge, so a rebuild otherwise left every native condition — normal ones included —
|
||||
/// reading <c>BadWaitingForInitialData</c> until the driver next flapped; it is restored silently. A
|
||||
/// condition in the no-event position (inactive + acknowledged) otherwise keeps exactly what the
|
||||
/// materialise built, message and authored severity included, and reports nothing — the same rule the
|
||||
/// scripted re-assert follows (#487).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Reported later, once.</b> The state is applied now, so a Read or ConditionRefresh in the window
|
||||
/// already sees it, but the one condition event waits for <see cref="CompleteAddressSpaceApply"/>: the
|
||||
/// equipment folders the condition reports through are wired AFTER this call
|
||||
/// (<see cref="WireAlarmNotifiers"/>), and reporting now would miss them. Native conditions refuse
|
||||
/// Confirm, shelving and Enable/Disable (#587), so Enabled and Shelving are not carried.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="alarm">The new condition instance.</param>
|
||||
/// <param name="conditionKey">Its key.</param>
|
||||
/// <param name="carried">What the discarded instance held.</param>
|
||||
private void RestoreCarryOver(AlarmConditionState alarm, RealmKey conditionKey, NativeConditionCarryOver carried)
|
||||
{
|
||||
var state = carried.State;
|
||||
if (alarm.Quality is not null)
|
||||
{
|
||||
alarm.Quality.Value = state.Quality;
|
||||
if (alarm.Quality.SourceTimestamp is not null && carried.QualityTimestamp is { } qualityAt)
|
||||
alarm.Quality.SourceTimestamp.Value = qualityAt;
|
||||
}
|
||||
|
||||
var retained = state.Active || !state.Acknowledged;
|
||||
if (!retained) return;
|
||||
|
||||
alarm.SetActiveState(SystemContext, state.Active);
|
||||
if (carried.ActiveTransitionTime is { } activeAt && alarm.ActiveState?.TransitionTime is { } transitionTime)
|
||||
transitionTime.Value = activeAt;
|
||||
alarm.SetAcknowledgedState(SystemContext, state.Acknowledged);
|
||||
if (alarm.ConfirmedState is not null) alarm.SetConfirmedState(SystemContext, state.Confirmed);
|
||||
alarm.SetSeverity(SystemContext, (EventSeverity)state.MappedSeverity);
|
||||
alarm.Message.Value = new LocalizedText(state.Message);
|
||||
alarm.Retain.Value = true;
|
||||
if (alarm.Comment is not null && carried.Comment is not null) alarm.Comment.Value = carried.Comment;
|
||||
if (alarm.ClientUserId is not null && carried.ClientUserId is not null) alarm.ClientUserId.Value = carried.ClientUserId;
|
||||
alarm.Time.Value = carried.Time;
|
||||
alarm.ReceiveTime.Value = carried.Time;
|
||||
|
||||
_restoredConditionReports.Add((conditionKey, alarm));
|
||||
}
|
||||
|
||||
/// <summary>One materialised native-alarm condition together with the equipment folders wired as extra
|
||||
/// event-notifier roots for it. The <see cref="Folders"/> list is mutated in place under <c>Lock</c> as
|
||||
/// notifiers are wired / torn down; <see cref="Alarm"/> is the concrete <see cref="AlarmConditionState"/>
|
||||
@@ -3207,6 +3317,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
_historizedTagnames.Clear();
|
||||
_arrayOnScalarRefused.Clear();
|
||||
|
||||
// Gitea #641: capture every NATIVE condition's live state before the conditions are discarded, so the
|
||||
// re-materialise that follows in this same apply can put it back (see CaptureCarryOver). Scripted
|
||||
// conditions are not captured — their engine re-asserts them after the reload (#487).
|
||||
_rebuildCarryOver.Clear();
|
||||
_restoredConditionReports.Clear();
|
||||
foreach (var (key, alarm) in _alarmConditions)
|
||||
{
|
||||
if (_nativeAlarmNodeIds.Contains(key)) _rebuildCarryOver[key] = CaptureCarryOver(alarm);
|
||||
}
|
||||
|
||||
foreach (var alarm in _alarmConditions.Values)
|
||||
{
|
||||
alarm.Parent?.RemoveChild(alarm);
|
||||
@@ -3251,6 +3371,41 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IOpcUaAddressSpaceSink.CompleteAddressSpaceApply"/>
|
||||
/// <remarks>
|
||||
/// Gitea #641: reports ONE condition event for each native condition whose off-normal state the rebuild
|
||||
/// carried over (stamped with the instant that state came about, not the deploy time — as the scripted
|
||||
/// re-assert does), then discards whatever captured state no materialise claimed. A condition the apply
|
||||
/// replaced again after restoring it is skipped: the instance reported must be the one restored.
|
||||
/// </remarks>
|
||||
public void CompleteAddressSpaceApply()
|
||||
{
|
||||
int reported = 0, discarded;
|
||||
lock (Lock)
|
||||
{
|
||||
foreach (var (key, alarm) in _restoredConditionReports)
|
||||
{
|
||||
if (!_alarmConditions.TryGetValue(key, out var live) || !ReferenceEquals(live, alarm)) continue;
|
||||
// The node's CURRENT Time: the carried instant when nothing reported since the restore, or the
|
||||
// newer instant of a report that did (a client method call on an SDK thread can land between the
|
||||
// restore and here) — never roll the condition's Time back.
|
||||
ReportConditionEvent(alarm, alarm.Time.Value);
|
||||
reported++;
|
||||
}
|
||||
|
||||
discarded = _rebuildCarryOver.Count;
|
||||
_restoredConditionReports.Clear();
|
||||
_rebuildCarryOver.Clear();
|
||||
}
|
||||
|
||||
if (reported > 0 || discarded > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"OtOpcUaNodeManager: rebuild carried {Reported} active/unacknowledged native condition(s) over; discarded {Discarded} captured condition(s) the apply did not re-create",
|
||||
reported, discarded);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/>
|
||||
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
|
||||
{
|
||||
|
||||
@@ -84,6 +84,9 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
|
||||
/// <inheritdoc />
|
||||
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void CompleteAddressSpaceApply() => _nodeManager.CompleteAddressSpaceApply();
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId, realm);
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher
|
||||
DateTime? rediscoveryNeededUtc = null,
|
||||
string? rediscoveryReason = null,
|
||||
IReadOnlyList<HostConnectivityStatus>? hostStatuses = null,
|
||||
SkippedTagReport? skippedTags = null)
|
||||
SkippedTagReport? skippedTags = null,
|
||||
IReadOnlyDictionary<string, string>? diagnostics = null)
|
||||
{
|
||||
var msg = new DriverHealthChanged(
|
||||
clusterId,
|
||||
@@ -51,7 +52,8 @@ public sealed class AkkaDriverHealthPublisher : IDriverHealthPublisher
|
||||
rediscoveryNeededUtc,
|
||||
rediscoveryReason,
|
||||
hostStatuses,
|
||||
skippedTags);
|
||||
skippedTags,
|
||||
diagnostics);
|
||||
DistributedPubSub.Get(_system).Mediator.Tell(new Publish(TopicName, msg));
|
||||
// Phase 5: fan the same snapshot into the node-local live-telemetry hub (no-op until a gRPC
|
||||
// client subscribes). The DPS publish above is unchanged — the hub is a strictly additive tap.
|
||||
|
||||
@@ -84,6 +84,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// </remarks>
|
||||
public static readonly TimeSpan DefaultApplyWedgeTimeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #601 — how long an Applied ack waits for <c>OpcUaPublishActor</c>'s address-space apply
|
||||
/// outcome (<see cref="OpcUa.OpcUaPublishActor.RebuildCompleted"/>) before it is sent anyway.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Derived from central's apply deadline (<see cref="DeployTimings.ApplyDeadline"/>, 2 min — the same
|
||||
/// constant <c>ConfigPublishCoordinator.DefaultApplyDeadline</c> reads) minus
|
||||
/// <see cref="DeployTimings.ApplyOutcomeSafetyMargin"/> (15 s) = 105 s: long enough that a
|
||||
/// slow-but-clean rebuild of a large address space is not labelled degraded, short enough that the ack
|
||||
/// still lands before central times the deployment out. An ack sent on this timeout is marked
|
||||
/// <b>degraded</b> with a reason starting <see cref="OutcomeNotReportedReasonPrefix"/>, never clean —
|
||||
/// the revision did advance, but nobody has confirmed the address space matches it.
|
||||
/// </remarks>
|
||||
public static readonly TimeSpan DefaultApplyOutcomeTimeout = DeployTimings.ApplyOutcomeTimeout;
|
||||
|
||||
/// <summary>Gitea #601 — the reason prefix of a degraded ack sent because the outcome TIMED OUT, so an
|
||||
/// operator can tell "not reported in time" from a real sink failure.</summary>
|
||||
public const string OutcomeNotReportedReasonPrefix = "address-space apply outcome not reported in time";
|
||||
|
||||
/// <summary>Minimum interval between two supervision reprovisions of the SAME driver child (archreview
|
||||
/// review F3). Akka's default decider restarts a faulting child immediately and without limit, and the
|
||||
/// reprovision hook makes every restart do ShutdownAsync + InitializeAsync + SubscribeAsync — so an
|
||||
@@ -244,6 +263,21 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
private IActorRef? _scriptedAlarmHost;
|
||||
|
||||
private RevisionHash? _currentRevision;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #601 — the last address-space apply of <see cref="_currentRevision"/> reported DEGRADED (or no
|
||||
/// outcome in time). A re-dispatch of that same revision then RE-APPLIES instead of taking the
|
||||
/// idempotent "already at this revision" short-circuit: the short-circuit would ack a clean Applied
|
||||
/// for an address space known to be partial, and the re-apply is what repairs it (a degraded
|
||||
/// non-rebuild apply leaves the publish actor's composition un-advanced, so it re-diffs the missing
|
||||
/// nodes). Reset by every clean outcome and by every boot path that restores a revision.
|
||||
/// </summary>
|
||||
private bool _currentRevisionDegraded;
|
||||
|
||||
/// <summary>Gitea #601 — Applied acks held until the address-space apply reports its outcome, keyed by
|
||||
/// the apply's correlation id (echoed back in <see cref="OpcUa.OpcUaPublishActor.RebuildCompleted"/>).</summary>
|
||||
private readonly Dictionary<CorrelationId, PendingAck> _pendingAcks = new();
|
||||
|
||||
private DeploymentId? _applyingDeploymentId;
|
||||
|
||||
private readonly Dictionary<string, ChildEntry> _children = new(StringComparer.Ordinal);
|
||||
@@ -431,6 +465,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// </summary>
|
||||
private readonly Func<CommonsNodeId, bool>? _sharesHistorizationScopeWithLocal;
|
||||
|
||||
/// <summary>Gitea #601 — how long an Applied ack waits for the address-space outcome (see
|
||||
/// <see cref="DefaultApplyOutcomeTimeout"/>).</summary>
|
||||
private readonly TimeSpan _applyOutcomeTimeout;
|
||||
|
||||
/// <summary>This host's <see cref="Applying"/>-wedge watchdog delay (see
|
||||
/// <see cref="DefaultApplyWedgeTimeout"/>).</summary>
|
||||
private readonly TimeSpan _applyWedgeTimeout;
|
||||
@@ -595,6 +633,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
/// <param name="commsRecoveryAuditDelay">Archreview 03/S15: how long after a driver reconnects the
|
||||
/// recovery audit waits before reporting refs still latched <c>BadNoCommunication</c>. Defaults to
|
||||
/// <see cref="DefaultCommsRecoveryAuditDelay"/>; tests shorten it.</param>
|
||||
/// <param name="applyOutcomeTimeout">Gitea #601: how long an Applied ack waits for the address-space
|
||||
/// apply outcome before it is sent anyway, marked degraded ("outcome unknown"). Defaults to
|
||||
/// <see cref="DefaultApplyOutcomeTimeout"/>; tests shorten it.</param>
|
||||
/// <returns>The Akka.NET <see cref="Akka.Actor.Props"/> used to spawn this actor.</returns>
|
||||
public static Props Props(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
|
||||
@@ -626,7 +667,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
TimeSpan? applyWedgeTimeout = null,
|
||||
TimeSpan? reprovisionBackoff = null,
|
||||
TimeSpan? commsRecoveryAuditDelay = null,
|
||||
Func<CommonsNodeId, bool>? sharesAlarmQueueWithLocal = null) =>
|
||||
Func<CommonsNodeId, bool>? sharesAlarmQueueWithLocal = null,
|
||||
TimeSpan? applyOutcomeTimeout = null) =>
|
||||
// WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an
|
||||
// expression tree. Six IActorRef? parameters and several interface-typed ones mean a
|
||||
// mis-ordered argument is usually type-compatible and therefore compiles clean, then binds
|
||||
@@ -639,7 +681,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher,
|
||||
alarmStateStore, telemetryHub, primaryRoleSource, historizationRoleView,
|
||||
sharesHistorizationScopeWithLocal, applyWedgeTimeout, reprovisionBackoff, commsRecoveryAuditDelay,
|
||||
sharesAlarmQueueWithLocal));
|
||||
sharesAlarmQueueWithLocal, applyOutcomeTimeout));
|
||||
|
||||
/// <summary>Initializes a new DriverHostActor with the specified dependencies.</summary>
|
||||
/// <param name="dbFactory">Database context factory for configuration database access.</param>
|
||||
@@ -737,8 +779,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
TimeSpan? applyWedgeTimeout = null,
|
||||
TimeSpan? reprovisionBackoff = null,
|
||||
TimeSpan? commsRecoveryAuditDelay = null,
|
||||
Func<CommonsNodeId, bool>? sharesAlarmQueueWithLocal = null)
|
||||
Func<CommonsNodeId, bool>? sharesAlarmQueueWithLocal = null,
|
||||
TimeSpan? applyOutcomeTimeout = null)
|
||||
{
|
||||
_applyOutcomeTimeout = applyOutcomeTimeout ?? DefaultApplyOutcomeTimeout;
|
||||
_sharesAlarmQueueWithLocal = sharesAlarmQueueWithLocal;
|
||||
_applyWedgeTimeout = applyWedgeTimeout ?? DefaultApplyWedgeTimeout;
|
||||
_reprovisionBackoff = reprovisionBackoff ?? DefaultReprovisionBackoff;
|
||||
@@ -1288,6 +1332,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
private void Steady()
|
||||
{
|
||||
Receive<DispatchDeployment>(HandleDispatchFromSteady);
|
||||
// #601 — the held Applied ack is released by the address-space outcome (or its timeout); both can
|
||||
// land in any behaviour, so all three register them.
|
||||
Receive<OpcUa.OpcUaPublishActor.RebuildCompleted>(HandleRebuildCompleted);
|
||||
Receive<ApplyOutcomeTimedOut>(HandleApplyOutcomeTimedOut);
|
||||
Receive<ArtifactCached>(HandleArtifactCached);
|
||||
Receive<ArtifactCacheFailed>(HandleArtifactCacheFailed);
|
||||
// A wedge tick already in the mailbox when the apply finished — dropped at Debug, see the handler.
|
||||
@@ -1348,6 +1396,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
// Direct ConfigDb read (BeginFetchAndCacheApply / BeginDirectApply ran in Steady, then
|
||||
// Become(Applying)); completing the apply transitions back to Steady.
|
||||
Receive<ArtifactForApply>(HandleArtifactForApply);
|
||||
Receive<OpcUa.OpcUaPublishActor.RebuildCompleted>(HandleRebuildCompleted);
|
||||
Receive<ApplyOutcomeTimedOut>(HandleApplyOutcomeTimedOut);
|
||||
// The cache write is piped back too, and it outlives the apply that started it by design — so
|
||||
// its completion can land in EITHER state and both must handle it or it dead-letters.
|
||||
Receive<ArtifactCached>(HandleArtifactCached);
|
||||
@@ -1793,9 +1843,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
// #626 — a QUALITY-ONLY annotation (an MTConnect condition gone Unavailable, or back) is not an alarm
|
||||
// transition. It rides the SAME quality-only node path the #477 connectivity annotation uses
|
||||
// (AlarmQualityUpdate → WriteAlarmQuality, which touches Quality and nothing else), never a projected
|
||||
// snapshot: the projector's prior state is cleared on every apply, so a projected "keep the state"
|
||||
// after a deploy would default to inactive+acked and silently acknowledge a retained, unacked
|
||||
// condition — and would rewrite its message and severity. Ungated by role (a Secondary keeps quality
|
||||
// snapshot: the projector never sees a client's acknowledgement (that is applied to the node by the
|
||||
// SDK) and forgets a condition a deploy removed, so a projected "keep the state" can disagree with
|
||||
// the node — silently acknowledging a retained, unacked condition or rewriting its message and
|
||||
// severity. Ungated by role (a Secondary keeps quality
|
||||
// warm, as for connectivity) and no alerts row.
|
||||
if (msg.Args is { IsQualityOnly: true, QualityStatusCode: { } quality })
|
||||
{
|
||||
@@ -2017,6 +2068,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
return;
|
||||
}
|
||||
|
||||
// Gitea #641 review — the node is already acknowledged (the SDK applies the client's ack); tell the
|
||||
// projector too, or the next driver Clear projects (Active=false, Acked=prev=false) and un-acknowledges it.
|
||||
// Most sources (MTConnect, FOCAS) send no Acknowledge transition back, and the projector now survives a
|
||||
// deploy rather than resetting to its acked default.
|
||||
_nativeAlarmProjector.MarkAcknowledged(msg.ConditionNodeId);
|
||||
|
||||
if (!_children.TryGetValue(target.DriverInstanceId, out var entry))
|
||||
{
|
||||
_log.Debug("DriverHost {Node}: driver {Driver} not running for alarm ack of {Cond} — dropped",
|
||||
@@ -2173,6 +2230,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
// The redundancy-state topic also carries OpcUaProbeResult (OpcUaPublishActor peer-probes).
|
||||
// We don't consume it here — drop it so it doesn't dead-letter (matches PeerProbeSupervisor).
|
||||
Receive<ZB.MOM.WW.OtOpcUa.Runtime.Health.PeerOpcUaProbeActor.OpcUaProbeResult>(_ => { });
|
||||
// #601 — an apply that completed just before the host went Stale still owes its ack; the
|
||||
// coordinator is not behind the ConfigDb, so release it exactly as in Steady.
|
||||
Receive<OpcUa.OpcUaPublishActor.RebuildCompleted>(HandleRebuildCompleted);
|
||||
Receive<ApplyOutcomeTimedOut>(HandleApplyOutcomeTimedOut);
|
||||
// An inbound operator write can't be serviced while the config DB is unreachable — fast-fail so the
|
||||
// node-manager's bounded Ask gets an immediate clear status instead of dead-lettering into a timeout.
|
||||
Receive<RouteNodeWrite>(_ =>
|
||||
@@ -2219,7 +2280,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
|
||||
private void HandleDispatchFromSteady(DispatchDeployment msg)
|
||||
{
|
||||
if (_currentRevision is { } cur && cur == msg.RevisionHash)
|
||||
// #601: the short-circuit claims a CLEAN Applied, so it is only honest while the current revision's
|
||||
// address space is known-good — not after a degraded apply, and not while its outcome is still
|
||||
// pending. Either way the dispatch re-applies instead, which is also what repairs a degraded apply.
|
||||
var currentRevisionUnconfirmed = _currentRevisionDegraded
|
||||
|| _pendingAcks.Values.Any(p => p.Revision == msg.RevisionHash);
|
||||
if (_currentRevision is { } cur && cur == msg.RevisionHash && !currentRevisionUnconfirmed)
|
||||
{
|
||||
// Idempotent — already at this rev. Ack and stay Steady.
|
||||
_log.Debug("DriverHost {Node}: dispatch {Id} matches current rev {Rev}; immediate ACK",
|
||||
@@ -2228,6 +2294,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentRevision is { } same && same == msg.RevisionHash)
|
||||
_log.Info(
|
||||
"DriverHost {Node}: dispatch {Id} matches current rev {Rev}, but its address-space apply was degraded or is unconfirmed — re-applying rather than acking clean",
|
||||
_localNode, msg.DeploymentId, msg.RevisionHash);
|
||||
|
||||
if (_fetchAndCacheMode)
|
||||
{
|
||||
BeginFetchAndCacheApply(msg.DeploymentId, msg.RevisionHash, msg.CorrelationId);
|
||||
@@ -2387,7 +2458,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
|
||||
_currentRevision = msg.Revision;
|
||||
UpsertNodeDeploymentState(msg.DeploymentId, NodeDeploymentStatus.Applied, failureReason: null);
|
||||
SendAck(msg.DeploymentId, ApplyAckOutcome.Applied, failureReason: null, msg.Correlation);
|
||||
// Gitea #601: the Applied ack is NOT sent here any more. It used to go out right now, before the
|
||||
// address-space apply below had even been dequeued, so a degraded apply (swallowed sink faults,
|
||||
// _lastApplied not advanced) still sealed the deployment green fleet-wide. The ack is now held
|
||||
// until OpcUaPublishActor replies RebuildCompleted (HandleRebuildCompleted) or the outcome
|
||||
// timeout fires (sent degraded, "outcome unknown"). With no publish actor there is no address
|
||||
// space to wait for, so the ack goes out clean immediately — exactly the old behaviour.
|
||||
AwaitAddressSpaceOutcomeThenAck(msg.DeploymentId, msg.Revision, msg.Correlation);
|
||||
// Rebuild the address space + push subscriptions FROM the bytes this apply already has, so
|
||||
// nothing downstream re-reads the artifact: under FetchAndCache OpcUaPublishActor has no
|
||||
// central SQL to read at all, and under Direct a bytes-less RebuildAddressSpace would make
|
||||
@@ -2399,8 +2476,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
// different actor with its own mailbox, and ParsedDeploymentArtifact is a disposable,
|
||||
// non-thread-safe object owned by this handler (disposed in the finally below, likely before
|
||||
// the other actor even dequeues). It opens its own single parse on the far side.
|
||||
// ReportOutcome: the reply (to Self — this Tell runs inside a receive, so the implicit sender is
|
||||
// this actor) releases the held ack. Plain Tell + reply message, no Ask/await, so nothing here
|
||||
// resumes off the actor thread.
|
||||
_opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.RebuildAddressSpace(
|
||||
msg.Correlation, msg.DeploymentId, appliedBlob));
|
||||
msg.Correlation, msg.DeploymentId, appliedBlob, ReportOutcome: true));
|
||||
// SubscribeBulk pass: hand each driver its desired tag references so live values flow into
|
||||
// the just-rebuilt address space instead of staying BadWaitingForInitialData.
|
||||
PushDesiredSubscriptionsFromParsed(msg.DeploymentId, parsed);
|
||||
@@ -2420,6 +2500,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// #601: a throw after the Applied ack was put on hold — the Failed ack below is this apply's one
|
||||
// answer, so the held Applied must never follow it when the address-space outcome lands.
|
||||
DropPendingAck(msg.Correlation);
|
||||
UpsertNodeDeploymentState(msg.DeploymentId, NodeDeploymentStatus.Failed, ex.Message);
|
||||
SendAck(msg.DeploymentId, ApplyAckOutcome.Failed, ex.Message, msg.Correlation);
|
||||
OtOpcUaTelemetry.DeploymentApplied.Add(1, new KeyValuePair<string, object?>("outcome", "reject"));
|
||||
@@ -2440,6 +2523,99 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gitea #601 — an Applied ack held until the address-space apply reports its outcome.</summary>
|
||||
private sealed record PendingAck(DeploymentId DeploymentId, RevisionHash Revision, CorrelationId Correlation);
|
||||
|
||||
/// <summary>Gitea #601 — self-timer: the address-space outcome for this correlation did not arrive in time.</summary>
|
||||
private sealed record ApplyOutcomeTimedOut(CorrelationId Correlation);
|
||||
|
||||
private static string ApplyOutcomeTimerKey(CorrelationId correlation) => $"apply-outcome-{correlation}";
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #601 — holds the Applied ack for an apply whose revision has just advanced until the
|
||||
/// address-space apply outcome is known, arming <see cref="_applyOutcomeTimeout"/> as the backstop.
|
||||
/// With no publish actor wired (tests, a node with no OPC UA data plane) there is no outcome to wait
|
||||
/// for, so the ack is sent clean at once — byte-for-byte the pre-#601 behaviour.
|
||||
/// </summary>
|
||||
private void AwaitAddressSpaceOutcomeThenAck(DeploymentId deploymentId, RevisionHash revision, CorrelationId correlation)
|
||||
{
|
||||
if (_opcUaPublishActor is null)
|
||||
{
|
||||
_currentRevisionDegraded = false;
|
||||
SendAck(deploymentId, ApplyAckOutcome.Applied, failureReason: null, correlation);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_pendingAcks.Remove(correlation, out var superseded))
|
||||
_log.Warning(
|
||||
"DriverHost {Node}: a second apply reused correlation {Correlation} while deployment {Old}'s ack was still waiting for its address-space outcome — that ack is superseded",
|
||||
_localNode, correlation, superseded.DeploymentId);
|
||||
_pendingAcks[correlation] = new PendingAck(deploymentId, revision, correlation);
|
||||
Timers.StartSingleTimer(ApplyOutcomeTimerKey(correlation), new ApplyOutcomeTimedOut(correlation), _applyOutcomeTimeout);
|
||||
}
|
||||
|
||||
/// <summary>Gitea #601 — forgets a held ack (its apply failed after all, and acks Failed instead).</summary>
|
||||
private void DropPendingAck(CorrelationId correlation)
|
||||
{
|
||||
if (_pendingAcks.Remove(correlation))
|
||||
Timers.Cancel(ApplyOutcomeTimerKey(correlation));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #601 — the address-space apply reported its outcome: release the held ack, clean or
|
||||
/// degraded. Registered in every behaviour (Steady, Applying, Stale) because the reply can land in
|
||||
/// any of them — the host returns to Steady before the publish actor has even dequeued the rebuild.
|
||||
/// </summary>
|
||||
private void HandleRebuildCompleted(OpcUa.OpcUaPublishActor.RebuildCompleted msg)
|
||||
{
|
||||
if (!_pendingAcks.Remove(msg.Correlation, out var pending))
|
||||
{
|
||||
_log.Debug("DriverHost {Node}: address-space outcome for {Correlation} has no held ack (already timed out or superseded); dropping",
|
||||
_localNode, msg.Correlation);
|
||||
return;
|
||||
}
|
||||
|
||||
Timers.Cancel(ApplyOutcomeTimerKey(msg.Correlation));
|
||||
ReleasePendingAck(pending, msg.Degraded, msg.DegradedReason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #601 — the address-space outcome did not arrive within <see cref="_applyOutcomeTimeout"/>.
|
||||
/// The ack is sent anyway so the coordinator does not time the deployment out on a node that DID
|
||||
/// advance its revision — but degraded, never clean: nobody has confirmed the address space.
|
||||
/// </summary>
|
||||
private void HandleApplyOutcomeTimedOut(ApplyOutcomeTimedOut msg)
|
||||
{
|
||||
if (!_pendingAcks.Remove(msg.Correlation, out var pending)) return;
|
||||
|
||||
ReleasePendingAck(pending, degraded: true,
|
||||
$"{OutcomeNotReportedReasonPrefix} ({_applyOutcomeTimeout.TotalSeconds:0}s) — no sink failure was observed; the apply may still be running (slow) or its outcome was lost");
|
||||
}
|
||||
|
||||
private void ReleasePendingAck(PendingAck pending, bool degraded, string? reason)
|
||||
{
|
||||
// Only the CURRENT revision's flag is ours to set — an older apply's late outcome must not mark a
|
||||
// newer revision degraded (or clean).
|
||||
if (_currentRevision is { } cur && cur == pending.Revision)
|
||||
_currentRevisionDegraded = degraded;
|
||||
|
||||
if (degraded)
|
||||
{
|
||||
// #601 review — the apply wrote this node's own row Applied/null before the outcome was known. Central
|
||||
// adds the reason from the ack only when it ACCEPTS the ack (a timed-out or superseded deployment's ack
|
||||
// is discarded), so a node with a ConfigDb records it itself; a driver-only node no-ops here.
|
||||
UpsertNodeDeploymentState(pending.DeploymentId, NodeDeploymentStatus.Applied,
|
||||
ApplyAck.DegradedReasonPrefix + (reason ?? "address-space apply degraded"));
|
||||
OtOpcUaTelemetry.DeploymentApplied.Add(1, new KeyValuePair<string, object?>("outcome", "degraded"));
|
||||
_log.Warning(
|
||||
"DriverHost {Node}: deployment {Id} (rev {Rev}) applied DEGRADED — {Reason}. Acking Applied+degraded; re-dispatching the same revision re-applies it",
|
||||
_localNode, pending.DeploymentId, pending.Revision, reason);
|
||||
}
|
||||
|
||||
SendAck(pending.DeploymentId, ApplyAckOutcome.Applied, failureReason: null, pending.Correlation,
|
||||
degraded, degraded ? reason ?? "address-space apply degraded" : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Self-timer telling this host it has been in <see cref="Applying"/> for
|
||||
/// <see cref="_applyWedgeTimeout"/> without finishing.
|
||||
@@ -3078,7 +3254,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
_alarmNodeIdByDriverRef.Clear();
|
||||
_driverRefByAlarmNodeId.Clear();
|
||||
_alarmMetaByNodeId.Clear();
|
||||
_nativeAlarmProjector.Clear();
|
||||
// NOT _nativeAlarmProjector.Clear() (Gitea #641): it is pruned to the still-deployed conditions after the
|
||||
// maps are rebuilt below, so a redeploy that keeps an alarm keeps its projected prior state.
|
||||
// The per-driver secondary indexes are rebuilt WHOLE alongside their primaries below — same pass,
|
||||
// same clear — so no incremental maintenance exists to get out of step with them.
|
||||
_valueRefsByDriver.Clear();
|
||||
@@ -3132,6 +3309,13 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
}
|
||||
}
|
||||
|
||||
// Gitea #641 — forget the projected prior state of conditions this composition no longer carries, and keep
|
||||
// the rest. Clearing it whole on every apply made the next driver Acknowledge of a still-active alarm
|
||||
// project Active=false (the default prior), and the next Clear of an unacknowledged one project
|
||||
// Acked=true — on any deploy, rebuild or not. The node manager carries the NODE's state across a rebuild,
|
||||
// so the two stay in step. Keyed by the bare condition NodeId, exactly as ForwardNativeAlarm projects.
|
||||
_nativeAlarmProjector.RetainOnly(_driverRefByAlarmNodeId.Keys.ToHashSet(StringComparer.Ordinal));
|
||||
|
||||
// Cache the per-driver payload as THE authority, in the same clear-and-repopulate pass as the routing
|
||||
// maps above so a rename/removal can never leave a stale entry behind. Keyed over the UNION of the two
|
||||
// ref maps AND the running children: a child with no authored tags must get an explicit EMPTY set (which
|
||||
@@ -3662,9 +3846,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
|
||||
}
|
||||
}
|
||||
|
||||
private void SendAck(DeploymentId deploymentId, ApplyAckOutcome outcome, string? failureReason, CorrelationId correlation)
|
||||
private void SendAck(
|
||||
DeploymentId deploymentId, ApplyAckOutcome outcome, string? failureReason, CorrelationId correlation,
|
||||
bool degraded = false, string? degradedReason = null)
|
||||
{
|
||||
var ack = new ApplyAck(deploymentId, _localNode, outcome, failureReason, correlation);
|
||||
var ack = new ApplyAck(deploymentId, _localNode, outcome, failureReason, correlation, degraded, degradedReason);
|
||||
if (_ackRouter is not null)
|
||||
{
|
||||
// Either a direct coordinator handle (tests) or, under MeshTransport:Mode = Grpc, the
|
||||
|
||||
@@ -1153,6 +1153,28 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Bounded driver diagnostics, or null when the driver is not an <see cref="IDriverDiagnostics"/>
|
||||
/// (Gitea #640). Pulled fresh and called UNGUARDED for the same reasons as
|
||||
/// <see cref="CurrentSkippedTags"/>; a throwing implementation loses only the diagnostics.</summary>
|
||||
private IReadOnlyDictionary<string, string>? CurrentDiagnostics()
|
||||
{
|
||||
if (_driver is not IDriverDiagnostics diag) return null;
|
||||
try
|
||||
{
|
||||
return DriverDiagnostics.Bound(diag.GetDiagnostics());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "DriverInstance {Id}: GetDiagnostics threw during health publish; omitting diagnostics", _driverInstanceId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Value digest of the diagnostics bag for the publish fingerprint. Null in, null out. The bag is
|
||||
/// already in ordinal key order (<see cref="DriverDiagnostics.Bound"/>), so equal facts digest equally.</summary>
|
||||
private static string? DiagnosticsDigest(IReadOnlyDictionary<string, string>? d) =>
|
||||
d is null ? null : string.Join('|', d.Select(kv => $"{kv.Key}={kv.Value}"));
|
||||
|
||||
/// <summary>Records the driver's rediscovery raise and re-publishes health so the signal reaches the
|
||||
/// AdminUI promptly rather than waiting for the next 30 s heartbeat.
|
||||
/// <para><b>Advisory only.</b> The served address space is deliberately NOT rebuilt: v3 authors raw tags
|
||||
@@ -1357,6 +1379,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
var errorCount = ErrorCount5Min();
|
||||
var hostStatuses = CurrentHostStatuses();
|
||||
var skippedTags = CurrentSkippedTags();
|
||||
var diagnostics = CurrentDiagnostics();
|
||||
// _rediscoveryNeededUtc is PART OF THE FINGERPRINT on purpose. A rediscovery raise on an
|
||||
// otherwise-unchanged Healthy driver leaves (state, lastSuccess, lastError, errorCount)
|
||||
// identical, so without it the dedup below would swallow the very publish that carries the
|
||||
@@ -1375,14 +1398,20 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
// is identical to a clean driver's and the publish that carries the signal is exactly the one
|
||||
// dedup would swallow. Flattened to a STRING for the same reason as the host digest — the
|
||||
// report holds an IReadOnlyList, which a tuple compares by reference.
|
||||
//
|
||||
// The diagnostics digest (Gitea #640) is the fourth instance: a diagnostic fact (S7's PDU size
|
||||
// changing across a reconnect) moves none of the other components, so without it the publish
|
||||
// that carries it is deduped away. Flattened to a STRING - the bag is a dictionary, which a
|
||||
// tuple compares by reference.
|
||||
var fingerprint = (health.State, health.LastSuccessfulRead, health.LastError, errorCount,
|
||||
_rediscoveryNeededUtc, HostStatusDigest(hostStatuses), SkippedTagDigest(skippedTags));
|
||||
_rediscoveryNeededUtc, HostStatusDigest(hostStatuses), SkippedTagDigest(skippedTags),
|
||||
DiagnosticsDigest(diagnostics));
|
||||
if (_lastPublishedFingerprint is { } prev && prev.Equals(fingerprint))
|
||||
return observed;
|
||||
_lastPublishedFingerprint = fingerprint;
|
||||
_healthPublisher.Publish(
|
||||
_clusterId, _driverInstanceId, health, errorCount, _rediscoveryNeededUtc, _rediscoveryReason,
|
||||
hostStatuses, skippedTags);
|
||||
hostStatuses, skippedTags, diagnostics);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1418,7 +1447,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
|
||||
/// <summary>Fingerprint of the last <see cref="PublishHealthSnapshot"/> call; null until first publish.</summary>
|
||||
private (DriverState State, DateTime? LastSuccess, string? LastError, int ErrorCount,
|
||||
DateTime? RediscoveryNeededUtc, string? HostStatusDigest, string? SkippedTagDigest)? _lastPublishedFingerprint;
|
||||
DateTime? RediscoveryNeededUtc, string? HostStatusDigest, string? SkippedTagDigest,
|
||||
string? DiagnosticsDigest)? _lastPublishedFingerprint;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void PostStop()
|
||||
|
||||
@@ -40,9 +40,36 @@ public sealed class NativeAlarmProjector
|
||||
StatusCode: e.QualityStatusCode ?? OpcUaStatus.Good);
|
||||
}
|
||||
|
||||
/// <summary>Clears all tracked per-node state (call on address-space rebuild).</summary>
|
||||
/// <summary>
|
||||
/// Gitea #641 review — record a client acknowledgement the node manager applied to <paramref name="nodeId"/>
|
||||
/// and routed toward the driver. The projector otherwise never sees it, and a later Clear would project the
|
||||
/// stale unacknowledged prior. An untracked condition already defaults to acknowledged.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The acknowledged condition node id.</param>
|
||||
public void MarkAcknowledged(string nodeId)
|
||||
{
|
||||
if (_prior.TryGetValue(nodeId, out var p)) _prior[nodeId] = (p.Active, true);
|
||||
}
|
||||
|
||||
/// <summary>Clears all tracked per-node state.</summary>
|
||||
public void Clear() => _prior.Clear();
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 — drop the tracked state of every condition NOT in <paramref name="deployedNodeIds"/>, keeping
|
||||
/// the rest. Called on every apply instead of <see cref="Clear"/>: a condition that is still deployed keeps
|
||||
/// its state across the deploy (the node manager carries the node's state across a rebuild too), so the
|
||||
/// next Acknowledge does not project <c>Active = false</c> onto an active alarm and the next Clear does not
|
||||
/// project <c>Acked = true</c> onto an unacknowledged one. A removed condition is forgotten, so a later
|
||||
/// re-add starts from the default.
|
||||
/// </summary>
|
||||
/// <param name="deployedNodeIds">The condition node ids the new composition still materialises.</param>
|
||||
public void RetainOnly(IReadOnlySet<string> deployedNodeIds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(deployedNodeIds);
|
||||
foreach (var nodeId in _prior.Keys.Where(k => !deployedNodeIds.Contains(k)).ToList())
|
||||
_prior.Remove(nodeId);
|
||||
}
|
||||
|
||||
private static ushort MapSeverity(AlarmSeverity s) => s switch
|
||||
{
|
||||
AlarmSeverity.Low => 200,
|
||||
|
||||
@@ -100,8 +100,31 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
/// that cannot succeed, leaving the rebuild a no-op and clients browsing an empty server. Null
|
||||
/// on the normal path, where loading from the ConfigDb by id is correct.
|
||||
/// </param>
|
||||
/// <param name="ReportOutcome">
|
||||
/// Gitea #601 — when <see langword="true"/>, the actor replies to the sender with a
|
||||
/// <see cref="RebuildCompleted"/> once the apply outcome is known (clean or degraded), on EVERY path
|
||||
/// through the handler. <c>DriverHostActor</c> sets it on the deploy path and sends the deployment's
|
||||
/// ack only after that reply, so a degraded apply can no longer seal a deployment green. Off for the
|
||||
/// boot/restore paths, which re-ack nothing.
|
||||
/// </param>
|
||||
public sealed record RebuildAddressSpace(
|
||||
CorrelationId Correlation, DeploymentId? DeploymentId = null, byte[]? Artifact = null);
|
||||
CorrelationId Correlation, DeploymentId? DeploymentId = null, byte[]? Artifact = null,
|
||||
bool ReportOutcome = false);
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #601 — the reply to a <see cref="RebuildAddressSpace"/> carrying
|
||||
/// <see cref="RebuildAddressSpace.ReportOutcome"/>: whether the address-space apply was clean.
|
||||
/// </summary>
|
||||
/// <param name="Correlation">The rebuild's correlation id, echoed.</param>
|
||||
/// <param name="DeploymentId">The rebuild's deployment id, echoed (null for a legacy caller).</param>
|
||||
/// <param name="Degraded">
|
||||
/// <see langword="true"/> when the apply swallowed per-node sink faults, its rebuild failed, the
|
||||
/// pipeline threw, or there was no usable artifact — i.e. the served address space may be partial or
|
||||
/// stale. An empty plan (nothing changed) is clean.
|
||||
/// </param>
|
||||
/// <param name="DegradedReason">A short operator-readable reason; null when clean.</param>
|
||||
public sealed record RebuildCompleted(
|
||||
CorrelationId Correlation, DeploymentId? DeploymentId, bool Degraded, string? DegradedReason);
|
||||
|
||||
public sealed record ServiceLevelChanged(byte ServiceLevel);
|
||||
|
||||
@@ -134,6 +157,15 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
/// protecting. Distinguishes "the artifact went missing under a running server" (issue #485 — warn, and
|
||||
/// hold what is materialised) from "nothing has been deployed here yet" (a quiet no-op at boot).</summary>
|
||||
private bool _hasAppliedComposition;
|
||||
/// <summary>
|
||||
/// Gitea #601 review — <see cref="_lastApplied"/> was advanced by a DEGRADED rebuild (a rebuild advances
|
||||
/// even with swallowed failures, see <see cref="HandleRebuildCore"/>). A re-deploy of the same revision
|
||||
/// then diffs to an EMPTY plan; without this flag that empty plan reported clean, so the "re-deploy
|
||||
/// re-applies" repair path sealed green over an address space still known to be partial. While set, an
|
||||
/// empty plan re-materialises the whole composition (idempotent ensures) and reports clean only when
|
||||
/// that pass is itself clean.
|
||||
/// </summary>
|
||||
private bool _lastAppliedDegraded;
|
||||
private DbHealthProbeActor.DbHealthStatus? _lastDbHealth;
|
||||
private RedundancyStateChanged? _lastSnapshot;
|
||||
private (bool Ok, DateTime At)? _probeAboutMe;
|
||||
@@ -437,6 +469,36 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
|
||||
private void HandleRebuild(RebuildAddressSpace msg)
|
||||
{
|
||||
// Captured up front: the pipeline below is synchronous on this actor's thread today, but the reply
|
||||
// must reach whoever asked even if a future edit introduces an await into it.
|
||||
var requester = Sender;
|
||||
(bool Degraded, string? Reason) outcome;
|
||||
try
|
||||
{
|
||||
outcome = HandleRebuildCore(msg);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Gitea #641 — close the apply on EVERY exit, early returns and a throwing pass included: the node
|
||||
// manager reports here the native conditions a rebuild carried over (after the notifier wiring the
|
||||
// passes did) and discards any captured state no materialise claimed. Skipping it on a throw would
|
||||
// leave that state for a later surgical re-add of the same id to resurrect. No-op when nothing is
|
||||
// pending; never throws out of the handler.
|
||||
try { _sink.CompleteAddressSpaceApply(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "OpcUaPublish: sink.CompleteAddressSpaceApply threw (correlation={Correlation})", msg.Correlation);
|
||||
}
|
||||
}
|
||||
|
||||
// Gitea #601 — the outcome goes out only after the apply (including #641's completion step) is done.
|
||||
if (msg.ReportOutcome)
|
||||
requester.Tell(new RebuildCompleted(msg.Correlation, msg.DeploymentId, outcome.Degraded, outcome.Reason));
|
||||
}
|
||||
|
||||
/// <summary>The rebuild pipeline. Returns whether the apply was degraded, and why (Gitea #601).</summary>
|
||||
private (bool Degraded, string? Reason) HandleRebuildCore(RebuildAddressSpace msg)
|
||||
{
|
||||
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
|
||||
span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString());
|
||||
@@ -460,8 +522,9 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
_log.Error(ex, "OpcUaPublish: sink.RebuildAddressSpace threw (correlation={Correlation})",
|
||||
msg.Correlation);
|
||||
return (true, $"address-space rebuild threw: {ex.Message}");
|
||||
}
|
||||
return;
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
try
|
||||
@@ -499,7 +562,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
_log.Debug(
|
||||
"OpcUaPublish: no artifact to materialise yet (deployment={Id}, correlation={Correlation})",
|
||||
msg.DeploymentId, msg.Correlation);
|
||||
return;
|
||||
return (true, "no usable artifact reached the address-space apply; the previously served address space was kept");
|
||||
}
|
||||
|
||||
// Parse the artifact ONCE for this rebuild. Every consumer below (data-plane authority, then the
|
||||
@@ -530,9 +593,31 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
|
||||
if (plan.IsEmpty)
|
||||
{
|
||||
if (_lastAppliedDegraded)
|
||||
{
|
||||
// #601 review — the composition is unchanged but the last apply of it was a degraded
|
||||
// rebuild: re-ensure every node (each Ensure* no-ops on a node that stands) so the
|
||||
// re-deploy actually repairs what that rebuild could not place.
|
||||
var repairFailed = _applier.MaterialiseHierarchy(composition)
|
||||
+ MaterialiseNodes(plan, composition, fullComposition: true);
|
||||
if (repairFailed > 0)
|
||||
{
|
||||
OtOpcUaTelemetry.OpcUaApplyFailed.Add(1, new KeyValuePair<string, object?>("kind", "nodes"));
|
||||
_log.Error(
|
||||
"OpcUaPublish: re-apply of a degraded composition is STILL degraded (correlation={Correlation}, failedNodes={FailedNodes})",
|
||||
msg.Correlation, repairFailed);
|
||||
return (true, $"re-apply of a previously degraded address space is still degraded (failedNodes={repairFailed})");
|
||||
}
|
||||
|
||||
_lastAppliedDegraded = false;
|
||||
_log.Info("OpcUaPublish: re-apply repaired the previously degraded address space (correlation={Correlation})",
|
||||
msg.Correlation);
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
_log.Debug("OpcUaPublish: rebuild requested but plan is empty (correlation={Correlation})",
|
||||
msg.Correlation);
|
||||
return;
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
var outcome = _applier.Apply(plan);
|
||||
@@ -575,6 +660,9 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
_lastApplied = composition;
|
||||
_hasAppliedComposition = true;
|
||||
// #601 review — advanced despite failures only on a rebuild; remember it so an identical
|
||||
// re-deploy repairs instead of short-circuiting clean on the empty plan.
|
||||
_lastAppliedDegraded = outcome.RebuildFailed || failedNodes > 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -608,16 +696,19 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
"OpcUaPublish: address-space apply DEGRADED (correlation={Correlation}, rebuildFailed={RebuildFailed}, failedNodes={FailedNodes}, added={Added}, removed={Removed}, changed={Changed}) — the running address space may be stale or partial",
|
||||
msg.Correlation, outcome.RebuildFailed, failedNodes,
|
||||
outcome.AddedNodes, outcome.RemovedNodes, outcome.ChangedNodes);
|
||||
return (true, outcome.RebuildFailed
|
||||
? $"address-space rebuild failed (failedNodes={failedNodes})"
|
||||
: $"address-space apply degraded (failedNodes={failedNodes}); not recorded as applied, the next deploy of this revision re-diffs and repairs it");
|
||||
}
|
||||
else
|
||||
{
|
||||
_log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
|
||||
msg.Correlation, outcome.AddedNodes, outcome.RemovedNodes, outcome.ChangedNodes, outcome.RebuildCalled);
|
||||
}
|
||||
|
||||
_log.Info("OpcUaPublish: applied rebuild (correlation={Correlation}, added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
|
||||
msg.Correlation, outcome.AddedNodes, outcome.RemovedNodes, outcome.ChangedNodes, outcome.RebuildCalled);
|
||||
return (false, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "OpcUaPublish: rebuild pipeline threw (correlation={Correlation})", msg.Correlation);
|
||||
return (true, $"address-space apply threw: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -201,6 +201,61 @@ public class MeshTransportDtoMapperTests
|
||||
back.FailureReason.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyAck_Degraded_RoundTrips_WithItsReason()
|
||||
{
|
||||
// #601: an Applied ack whose address-space apply was DEGRADED must reach central as degraded —
|
||||
// a node that loses the flag on the wire reports a clean apply it did not have.
|
||||
var domain = new ApplyAck(
|
||||
new DeploymentId(DeploymentGuid),
|
||||
new NodeId("site-a-1"),
|
||||
ApplyAckOutcome.Applied,
|
||||
null,
|
||||
new CorrelationId(CorrelationGuid),
|
||||
Degraded: true,
|
||||
DegradedReason: "failedNodes=3");
|
||||
|
||||
var back = MeshTransportDtoMapper.FromProto(Wire(MeshTransportDtoMapper.ToProto(domain)));
|
||||
|
||||
back.ShouldBe(domain);
|
||||
back.Degraded.ShouldBeTrue();
|
||||
back.DegradedReason.ShouldBe("failedNodes=3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyAck_Clean_EncodesNoDegradedFields()
|
||||
{
|
||||
var proto = MeshTransportDtoMapper.ToProto(new ApplyAck(
|
||||
new DeploymentId(DeploymentGuid), new NodeId("site-a-1"), ApplyAckOutcome.Applied, null,
|
||||
new CorrelationId(CorrelationGuid)));
|
||||
|
||||
proto.Degraded.ShouldBeFalse();
|
||||
proto.HasDegradedReason.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyAck_FromAnOldNode_WithoutTheDegradedFields_DecodesAsTodaysCleanAck()
|
||||
{
|
||||
// #601 compatibility: a node built before the degraded fields existed sends exactly fields 1-5.
|
||||
// Its ack must decode exactly as it always did — not degraded, no reason.
|
||||
var oldNodeBytes = new ApplyAckMessage
|
||||
{
|
||||
DeploymentId = DeploymentGuid.ToString("N"),
|
||||
NodeId = "site-a-1",
|
||||
Outcome = ApplyOutcome.Applied,
|
||||
CorrelationId = CorrelationGuid.ToString("N"),
|
||||
}.ToByteArray();
|
||||
|
||||
var back = MeshTransportDtoMapper.FromProto(ApplyAckMessage.Parser.ParseFrom(oldNodeBytes));
|
||||
|
||||
back.Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||
back.Degraded.ShouldBeFalse();
|
||||
back.DegradedReason.ShouldBeNull();
|
||||
back.ShouldBe(new ApplyAck(
|
||||
new DeploymentId(DeploymentGuid), new NodeId("site-a-1"), ApplyAckOutcome.Applied, null,
|
||||
new CorrelationId(CorrelationGuid)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyAck_UnspecifiedOutcome_Throws_RatherThanDecodingToAMeaningfulValue()
|
||||
{
|
||||
|
||||
+5
-3
@@ -80,11 +80,13 @@ public class DeferredSinkForwardingReflectionTests
|
||||
// v3 B4-WP2 dual-namespace invariant: a node id alone is no longer globally unique across the two
|
||||
// namespaces (Raw vs UNS), so every sink method that NAMES a node must carry an AddressSpaceRealm
|
||||
// discriminator — the sink resolves the namespace from the realm rather than parsing the id string.
|
||||
// RebuildAddressSpace is the sole node-naming-free method (it clears BOTH realms). This guard locks in
|
||||
// the realm surface so a future method can't be added that names a node without a realm.
|
||||
// RebuildAddressSpace (it clears BOTH realms) and CompleteAddressSpaceApply (it closes an apply pass,
|
||||
// Gitea #641) are the only node-naming-free methods. This guard locks in the realm surface so a future
|
||||
// method can't be added that names a node without a realm.
|
||||
foreach (var method in ForwardingInterfaces.SelectMany(i => i.GetMethods()))
|
||||
{
|
||||
if (method.Name == nameof(IOpcUaAddressSpaceSink.RebuildAddressSpace)) continue;
|
||||
if (method.Name is nameof(IOpcUaAddressSpaceSink.RebuildAddressSpace)
|
||||
or nameof(IOpcUaAddressSpaceSink.CompleteAddressSpaceApply)) continue;
|
||||
|
||||
method.GetParameters().Any(p => CarriesRealm(p.ParameterType)).ShouldBeTrue(
|
||||
$"{method.DeclaringType!.Name}.{method.Name} names a node but has no AddressSpaceRealm parameter — " +
|
||||
|
||||
@@ -189,6 +189,80 @@ public sealed class AbCipAlarmProjectionTests
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
// Gitea #641 (item 4): the host routes a native transition on ConditionId == the subscribed RawPath and
|
||||
// projects it from Kind. The projection used to emit "{ref}#active" with an Unspecified kind, so every
|
||||
// ALMD transition was dropped as "no alarm condition" — and would have projected no state change even if
|
||||
// it had routed.
|
||||
AlarmEventArgs raised, cleared;
|
||||
lock (events)
|
||||
{
|
||||
raised = events.First(e => e.Message.Contains("raised"));
|
||||
cleared = events.First(e => e.Message.Contains("cleared"));
|
||||
}
|
||||
raised.ConditionId.ShouldBe("HighTemp");
|
||||
raised.Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||
cleared.ConditionId.ShouldBe("HighTemp");
|
||||
cleared.Kind.ShouldBe(AlarmTransitionKind.Clear);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 review — the server carries every surviving native condition's state (a client's
|
||||
/// acknowledgement included) across an address-space rebuild, and the host re-subscribes alarms on every
|
||||
/// reconnect and every alarm-set change. A re-subscribe used to start from an empty "last seen" record, so
|
||||
/// a still-faulted ALMD was re-RAISED, which the host projects as Active+UNacked — un-acknowledging an alarm
|
||||
/// an operator had already acknowledged. The record is the driver's, kept for refs that stay in the set.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_resubscribe_does_not_re_raise_a_still_faulted_alarm()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var raises = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { if (e.Kind == AlarmTransitionKind.Raise) lock (raises) raises.Add(e); };
|
||||
|
||||
var sub1 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
await WaitForTagCreation(factory, "HighTemp");
|
||||
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
|
||||
await WaitForEventAsync(raises, _ => true);
|
||||
|
||||
// A reconnect / alarm-set change: the same ref, still faulted.
|
||||
var sub2 = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
await WaitForMoreReadsAsync(factory, "HighTemp", 6);
|
||||
|
||||
lock (raises) raises.Count.ShouldBe(1, "a re-subscribe must not re-raise (and so un-acknowledge) a still-faulted alarm");
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(sub1, CancellationToken.None);
|
||||
await drv.UnsubscribeAlarmsAsync(sub2, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Gitea #641 review — positive control: a ref that LEFT the subscribed set was materialised fresh
|
||||
/// (inactive) when it came back, so it is raised again.</summary>
|
||||
[Fact]
|
||||
public async Task A_ref_that_left_the_set_and_came_back_is_raised_again()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(Options(enabled: true), "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var raises = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { if (e.Kind == AlarmTransitionKind.Raise) lock (raises) raises.Add(e); };
|
||||
|
||||
await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
await WaitForTagCreation(factory, "HighTemp");
|
||||
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
|
||||
factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
|
||||
await WaitForEventAsync(raises, _ => true);
|
||||
|
||||
await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
|
||||
await WaitForEventAsync(raises, _ => raises.Count >= 2);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unsubscribing stops the alarm poll loop.</summary>
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// #646 against <c>ab_server</c>: <c>N7:[N7:0]</c> reads the index word, then the word it names, on both
|
||||
/// transports. Writes N7:0 and N7:5 (simulator-only, gated by <see cref="IntegrationWriteGate"/>).
|
||||
/// </summary>
|
||||
[Collection(AbLegacyServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Simulator", "ab_server-PCCC")]
|
||||
public sealed class AbLegacyIndirectAddressingTests(AbLegacyServerFixture sim)
|
||||
{
|
||||
/// <summary>Seeds N7:0=5 and N7:5=42, then reads and writes through <c>N7:[N7:0]</c>.</summary>
|
||||
/// <param name="transport">The transport stack the driver runs on.</param>
|
||||
[AbLegacyTheory]
|
||||
[MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))]
|
||||
public async Task Slc500_indirect_word_read_and_write_resolve_through_the_index(AbLegacyTransport transport)
|
||||
{
|
||||
if (sim.SkipReasonFor(KnownProfiles.Slc500) is { } skip) Assert.Skip(skip);
|
||||
IntegrationWriteGate.SkipUnlessWritesAllowed();
|
||||
|
||||
var deviceUri = $"ab://{sim.Host}:{sim.PortFor(KnownProfiles.Slc500)}/{sim.CipPath}";
|
||||
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(deviceUri, AbLegacyPlcFamily.Slc500)],
|
||||
RawTags = AbLegacyRawTags.Entries(
|
||||
new AbLegacyTagDefinition("Index", deviceUri, "N7:0", AbLegacyDataType.Int, Writable: true),
|
||||
new AbLegacyTagDefinition("Target", deviceUri, "N7:5", AbLegacyDataType.Int, Writable: true),
|
||||
new AbLegacyTagDefinition("Indirect", deviceUri, "N7:[N7:0]", AbLegacyDataType.Int, Writable: true)),
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
Transport = transport,
|
||||
}, driverInstanceId: $"ablegacy-indirect-{transport}");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
|
||||
var seeded = await drv.WriteAsync(
|
||||
[new WriteRequest("Index", (short)5), new WriteRequest("Target", (short)42)], ct);
|
||||
seeded.ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good);
|
||||
|
||||
var read = (await drv.ReadAsync(["Indirect"], ct)).Single();
|
||||
read.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
Convert.ToInt32(read.Value).ShouldBe(42);
|
||||
|
||||
// Write through the indirection lands on N7:5.
|
||||
(await drv.WriteAsync([new WriteRequest("Indirect", (short)77)], ct)).Single()
|
||||
.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
var direct = (await drv.ReadAsync(["Target"], ct)).Single();
|
||||
Convert.ToInt32(direct.Value).ShouldBe(77);
|
||||
}
|
||||
}
|
||||
-22
@@ -181,28 +181,6 @@ public sealed class AbLegacyFamilyAddressingTests
|
||||
factory.Tags["I:15/15"].ReadCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>An indirect tag is answered BadNotSupported on read and write, and no runtime is ever created for it.</summary>
|
||||
[Fact]
|
||||
public async Task Indirect_tag_is_BadNotSupported_and_creates_no_runtime()
|
||||
{
|
||||
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
|
||||
new AbLegacyTagDefinition("Ind", "ab://10.0.0.5/1,0", "N7:[N7:0]", AbLegacyDataType.Int, Writable: true),
|
||||
new AbLegacyTagDefinition("Ok", "ab://10.0.0.5/1,0", "N7:1", AbLegacyDataType.Int, Writable: true));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 5 };
|
||||
|
||||
var read = await drv.ReadAsync(["Ind", "Ok"], CancellationToken.None);
|
||||
var write = await drv.WriteAsync(
|
||||
[new WriteRequest("Ind", 1), new WriteRequest("Ok", 1)], CancellationToken.None);
|
||||
|
||||
read[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
write[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
// positive control: the neighbouring direct tag in the same batch still works, so the refusal is per-tag
|
||||
read[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
write[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
factory.Tags.Keys.ShouldNotContain(k => k.Contains('['));
|
||||
}
|
||||
|
||||
/// <summary>A MicroLogix function-file tag reaches the transport under its libplctag name.</summary>
|
||||
[Fact]
|
||||
public async Task MicroLogix_function_file_tag_reaches_the_transport()
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>#646: indirect addresses (N7:[N7:0], N[N7:0]:5) resolve at read/write time.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyIndirectAddressingTests
|
||||
{
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
|
||||
private static async Task<(AbLegacyDriver drv, FakeAbLegacyTagFactory factory)> NewDriverAsync(
|
||||
params AbLegacyTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500)],
|
||||
RawTags = AbLegacyRawTags.Entries(tags),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
private static void Seed(FakeAbLegacyTagFactory f, params (string name, object value)[] values)
|
||||
{
|
||||
var map = values.ToDictionary(v => v.name, v => v.value, StringComparer.OrdinalIgnoreCase);
|
||||
f.Customise = p => new FakeAbLegacyTag(p) { Value = map.TryGetValue(p.TagName, out var v) ? v : 0 };
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Word_indirect_reads_index_then_composed_address()
|
||||
{
|
||||
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
|
||||
Seed(f, ("N7:0", (short)5), ("N7:5", (short)42));
|
||||
|
||||
var r = (await drv.ReadAsync(["Ind"], CancellationToken.None)).Single();
|
||||
|
||||
r.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
r.Value.ShouldBe((short)42);
|
||||
f.Tags["N7:0"].ReadCount.ShouldBe(1);
|
||||
f.Tags["N7:5"].ReadCount.ShouldBe(1);
|
||||
f.Tags.Keys.ShouldNotContain(k => k.Contains('['));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task File_indirect_resolves_the_file_number()
|
||||
{
|
||||
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N[N7:0]:5", AbLegacyDataType.Int));
|
||||
Seed(f, ("N7:0", (short)9), ("N9:5", (short)77));
|
||||
|
||||
var r = (await drv.ReadAsync(["Ind"], CancellationToken.None)).Single();
|
||||
|
||||
r.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
r.Value.ShouldBe((short)77);
|
||||
f.Tags.ContainsKey("N9:5").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Index_is_re_read_every_poll_never_cached()
|
||||
{
|
||||
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
|
||||
Seed(f, ("N7:0", (short)5), ("N7:5", (short)42), ("N7:6", (short)43));
|
||||
|
||||
(await drv.ReadAsync(["Ind"], CancellationToken.None)).Single().Value.ShouldBe((short)42);
|
||||
f.Tags["N7:0"].Value = (short)6;
|
||||
(await drv.ReadAsync(["Ind"], CancellationToken.None)).Single().Value.ShouldBe((short)43);
|
||||
|
||||
f.Tags["N7:0"].ReadCount.ShouldBe(2);
|
||||
// the stale resolution's handle is released when the index moves
|
||||
f.Tags["N7:5"].Disposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1)]
|
||||
[InlineData(256)]
|
||||
[InlineData(30000)]
|
||||
public async Task Out_of_range_index_is_BadOutOfRange_without_health_impact(int index)
|
||||
{
|
||||
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
|
||||
Seed(f, ("N7:0", (short)index));
|
||||
|
||||
var r = (await drv.ReadAsync(["Ind"], CancellationToken.None)).Single();
|
||||
|
||||
r.StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
|
||||
drv.GetHealth().State.ShouldNotBe(DriverState.Degraded);
|
||||
f.Tags.Keys.ShouldNotContain(k => k.StartsWith("N7:") && k != "N7:0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Inner_read_failure_propagates_its_status()
|
||||
{
|
||||
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
|
||||
f.Customise = p => new FakeAbLegacyTag(p) { Value = (short)5, Status = p.TagName == "N7:0" ? -14 : 0 };
|
||||
|
||||
var r = (await drv.ReadAsync(["Ind"], CancellationToken.None)).Single();
|
||||
|
||||
r.StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good);
|
||||
r.StatusCode.ShouldNotBe(AbLegacyStatusMapper.BadOutOfRange);
|
||||
f.Tags.ContainsKey("N7:5").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_resolves_index_then_writes_the_composed_address()
|
||||
{
|
||||
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int, Writable: true));
|
||||
Seed(f, ("N7:0", (short)5));
|
||||
|
||||
var w = (await drv.WriteAsync([new WriteRequest("Ind", 99)], CancellationToken.None)).Single();
|
||||
|
||||
w.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
f.Tags["N7:5"].WriteCount.ShouldBe(1);
|
||||
f.Tags["N7:5"].Value.ShouldBe((short)99);
|
||||
f.Tags["N7:0"].WriteCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_with_bad_index_is_BadOutOfRange_and_writes_nothing()
|
||||
{
|
||||
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int, Writable: true));
|
||||
Seed(f, ("N7:0", (short)-3));
|
||||
|
||||
var w = (await drv.WriteAsync([new WriteRequest("Ind", 1)], CancellationToken.None)).Single();
|
||||
|
||||
w.StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
|
||||
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series;
|
||||
|
||||
/// <summary>
|
||||
/// Live-wire coverage (focas-mock) for #257 (<c>cnc_statinfo</c> status flags) and #266 (PMC
|
||||
/// range coalescing). Skips cleanly when <c>OTOPCUA_FOCAS_SIM_ENDPOINT</c> (default
|
||||
/// <c>localhost:8193</c>) is unreachable. Read-only: no write path is exercised.
|
||||
/// </summary>
|
||||
[Collection(FocasSimCollection.Name)]
|
||||
public sealed class WireBackendStatusAndPmcTests
|
||||
{
|
||||
private readonly FocasSimFixture _fx;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="WireBackendStatusAndPmcTests"/> class.</summary>
|
||||
/// <param name="fx">The FOCAS simulator fixture.</param>
|
||||
public WireBackendStatusAndPmcTests(FocasSimFixture fx) => _fx = fx;
|
||||
|
||||
private string DeviceHost => _fx.DeviceUri;
|
||||
|
||||
/// <summary>The mock's <c>cnc_statinfo</c> flags surface through the Status/ subtree and an authored FIXED: tag.</summary>
|
||||
[Fact]
|
||||
public async Task Status_flags_populate_via_wire_backend()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
sysinfo = new
|
||||
{
|
||||
addinfo = 0, max_axis = 8, cnc_type = "M ", mt_type = "M ",
|
||||
series = "30i ", version = "A1.0", axes = "1 ",
|
||||
},
|
||||
axis_names = new[] { "X" },
|
||||
statinfo = new { aut = 1, run = 3, motion = 1, mstb = 0, emergency = 0, alarm = 1, edit = 0 },
|
||||
}, ct);
|
||||
|
||||
var driver = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
RawTags = FocasRawTags.Entries(
|
||||
new FocasTagDefinition("RunFlag", DeviceHost, "FIXED:Status/Run", FocasDataType.Int32, Writable: false)),
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
FixedTree = new FocasFixedTreeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PollInterval = TimeSpan.FromMilliseconds(100),
|
||||
ProgramPollInterval = TimeSpan.FromMilliseconds(200),
|
||||
},
|
||||
}, driverInstanceId: "focas-wire-status", clientFactory: new WireFocasClientFactory());
|
||||
|
||||
await using (driver)
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
await WaitFor(() => driver.GetDeviceState(DeviceHost) is { LastStatus: not null }, TimeSpan.FromSeconds(5));
|
||||
|
||||
var state = driver.GetDeviceState(DeviceHost);
|
||||
state.ShouldNotBeNull();
|
||||
state.LastStatus.ShouldNotBeNull();
|
||||
state.LastStatus.Run.ShouldBe(3);
|
||||
state.LastStatus.Alarm.ShouldBe(1);
|
||||
|
||||
var snaps = await driver.ReadAsync(["RunFlag", $"{DeviceHost}/Status/Alarm", $"{DeviceHost}/Status/RunText"], ct);
|
||||
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
|
||||
snaps[0].Value.ShouldBe(3);
|
||||
snaps[1].Value.ShouldBe(1);
|
||||
snaps[2].Value.ShouldBe("START");
|
||||
|
||||
// The flags follow the machine.
|
||||
await _fx.PatchStateAsync(new { statinfo = new { run = 1, emergency = 1 } }, ct);
|
||||
await WaitFor(() => driver.GetDeviceState(DeviceHost) is { LastStatus.Run: 1 }, TimeSpan.FromSeconds(5));
|
||||
driver.GetDeviceState(DeviceHost)!.LastStatus!.Emergency.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A poll batch of adjacent PMC bytes is served by ONE range read against the live wire, every tag gets
|
||||
/// its own value, and a tag outside the merge window still reads through the per-tag path. Seeds are
|
||||
/// byte-typed: the mock serves one entry per ADDRESS for every PMC type, which matches the real
|
||||
/// byte-addressed range only for width 1.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Adjacent_pmc_bytes_coalesce_into_one_range_read_via_wire_backend()
|
||||
{
|
||||
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
await _fx.LoadProfileAsync("FWLIB64", ct);
|
||||
await _fx.PatchStateAsync(new
|
||||
{
|
||||
pmc = new { R = new Dictionary<string, object>
|
||||
{
|
||||
["200"] = new { type = "byte", value = 17 },
|
||||
["201"] = new { type = "byte", value = 200 },
|
||||
["202"] = new { type = "byte", value = 0b0000_0100 },
|
||||
["203"] = new { type = "byte", value = 9 },
|
||||
["900"] = new { type = "byte", value = 33 },
|
||||
}},
|
||||
}, ct);
|
||||
|
||||
var counting = new CountingFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(DeviceHost)],
|
||||
RawTags = FocasRawTags.Entries(
|
||||
new FocasTagDefinition("R200", DeviceHost, "R200", FocasDataType.Byte, Writable: false),
|
||||
new FocasTagDefinition("R201", DeviceHost, "R201", FocasDataType.Byte, Writable: false),
|
||||
new FocasTagDefinition("R202b2", DeviceHost, "R202.2", FocasDataType.Bit, Writable: false),
|
||||
new FocasTagDefinition("R203", DeviceHost, "R203", FocasDataType.Byte, Writable: false),
|
||||
new FocasTagDefinition("R900", DeviceHost, "R900", FocasDataType.Byte, Writable: false)),
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, driverInstanceId: "wire-pmc-coalesce", clientFactory: counting);
|
||||
|
||||
await using (drv)
|
||||
{
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
var snaps = await drv.ReadAsync(["R200", "R201", "R202b2", "R203", "R900"], ct);
|
||||
|
||||
snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
|
||||
Convert.ToInt32(snaps[0].Value).ShouldBe(17);
|
||||
Convert.ToInt32(snaps[1].Value).ShouldBe(-56); // 200 as a signed byte
|
||||
snaps[2].Value.ShouldBe(true);
|
||||
Convert.ToInt32(snaps[3].Value).ShouldBe(9);
|
||||
Convert.ToInt32(snaps[4].Value).ShouldBe(33);
|
||||
|
||||
counting.Client.ShouldNotBeNull();
|
||||
counting.Client.RangeReads.ShouldBe(1, "R200..R203 merge into one pmc_rdpmcrng");
|
||||
counting.Client.SingleReads.ShouldBe(1, "R900 is outside the gap window and reads on its own");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CountingFactory : IFocasClientFactory
|
||||
{
|
||||
private readonly WireFocasClientFactory _inner = new();
|
||||
public CountingClient? Client { get; private set; }
|
||||
public void EnsureUsable() => _inner.EnsureUsable();
|
||||
public IFocasClient Create() => Client = new CountingClient(_inner.Create());
|
||||
}
|
||||
|
||||
private sealed class CountingClient(IFocasClient inner) : IFocasClient
|
||||
{
|
||||
public int RangeReads { get; private set; }
|
||||
public int SingleReads { get; private set; }
|
||||
public bool IsConnected => inner.IsConnected;
|
||||
public Task ConnectAsync(FocasHostAddress a, TimeSpan t, CancellationToken ct) => inner.ConnectAsync(a, t, ct);
|
||||
public Task<(object? value, uint status)> ReadAsync(FocasAddress a, FocasDataType t, CancellationToken ct)
|
||||
{ SingleReads++; return inner.ReadAsync(a, t, ct); }
|
||||
public Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(string l, FocasDataType t, int s, int e, CancellationToken ct)
|
||||
{ RangeReads++; return inner.ReadPmcSlotsAsync(l, t, s, e, ct); }
|
||||
public Task<uint> WriteAsync(FocasAddress a, FocasDataType t, object? v, CancellationToken ct) => inner.WriteAsync(a, t, v, ct);
|
||||
public Task<bool> ProbeAsync(CancellationToken ct) => inner.ProbeAsync(ct);
|
||||
public Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct) => inner.ReadAlarmsAsync(ct);
|
||||
public Task<FocasSysInfo> GetSysInfoAsync(CancellationToken ct) => inner.GetSysInfoAsync(ct);
|
||||
public Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken ct) => inner.GetAxisNamesAsync(ct);
|
||||
public Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken ct) => inner.GetSpindleNamesAsync(ct);
|
||||
public Task<FocasDynamicSnapshot> ReadDynamicAsync(int i, CancellationToken ct) => inner.ReadDynamicAsync(i, ct);
|
||||
public Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) => inner.GetProgramInfoAsync(ct);
|
||||
public Task<FocasStatus> GetStatusAsync(CancellationToken ct) => inner.GetStatusAsync(ct);
|
||||
public Task<FocasTimer> GetTimerAsync(FocasTimerKind k, CancellationToken ct) => inner.GetTimerAsync(k, ct);
|
||||
public Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken ct) => inner.GetServoLoadsAsync(ct);
|
||||
public Task<IReadOnlyList<double?>> GetSpindleLoadsAsync(CancellationToken ct) => inner.GetSpindleLoadsAsync(ct);
|
||||
public Task<IReadOnlyList<double?>> GetSpindleMotorSpeedsAsync(CancellationToken ct) => inner.GetSpindleMotorSpeedsAsync(ct);
|
||||
public void Dispose() => inner.Dispose();
|
||||
}
|
||||
|
||||
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (pred()) return;
|
||||
await Task.Delay(50);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,51 @@ internal class FakeFocasClient : IFocasClient
|
||||
return Task.FromResult((value, status));
|
||||
}
|
||||
|
||||
/// <summary>PMC byte memory the range read serves, keyed by (upper-case letter, byte address); absent = 0.</summary>
|
||||
public Dictionary<(string Letter, int Address), byte> PmcMemory { get; } = new();
|
||||
/// <summary>Gets the log of range reads issued (letter, slot type, start, end).</summary>
|
||||
public List<(string Letter, FocasDataType SlotType, int Start, int End)> PmcRangeReads { get; } = new();
|
||||
/// <summary>Ranges (letter, start, end) the fake CNC rejects with <see cref="PmcRangeRejectStatus"/>.</summary>
|
||||
public List<Func<string, int, int, bool>> PmcRangeRejectors { get; } = new();
|
||||
/// <summary>The status a rejected range returns.</summary>
|
||||
public uint PmcRangeRejectStatus { get; set; } = FocasStatusMapper.BadOutOfRange;
|
||||
/// <summary>When true every range read throws (transport failure).</summary>
|
||||
public bool ThrowOnPmcRange { get; set; }
|
||||
|
||||
/// <summary>Reads a PMC range as raw big-endian slots from <see cref="PmcMemory"/>.</summary>
|
||||
/// <param name="letter">PMC letter.</param>
|
||||
/// <param name="slotType">Slot type.</param>
|
||||
/// <param name="start">First byte.</param>
|
||||
/// <param name="end">Last byte, inclusive.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public virtual Task<(IReadOnlyList<long>? slots, uint status)> ReadPmcSlotsAsync(
|
||||
string letter, FocasDataType slotType, int start, int end, CancellationToken ct)
|
||||
{
|
||||
PmcRangeReads.Add((letter, slotType, start, end));
|
||||
if (ThrowOnPmcRange) throw new InvalidOperationException("transport down");
|
||||
if (PmcRangeRejectors.Any(r => r(letter, start, end)))
|
||||
return Task.FromResult<(IReadOnlyList<long>?, uint)>((null, PmcRangeRejectStatus));
|
||||
|
||||
var width = slotType switch
|
||||
{
|
||||
FocasDataType.Int16 => 2,
|
||||
FocasDataType.Int32 or FocasDataType.Float32 => 4,
|
||||
FocasDataType.Float64 => 8,
|
||||
_ => 1,
|
||||
};
|
||||
var slots = new List<long>();
|
||||
for (var o = start; o + width - 1 <= end; o += width)
|
||||
{
|
||||
long v = 0;
|
||||
for (var k = 0; k < width; k++)
|
||||
v = (v << 8) | (PmcMemory.TryGetValue((letter, o + k), out var b) ? b : (byte)0);
|
||||
// Sign-extend like the wire parser (Int16/Int32/Int64 big-endian); width 1 stays 0..255.
|
||||
v = width switch { 2 => (short)v, 4 => (int)v, _ => v };
|
||||
slots.Add(v);
|
||||
}
|
||||
return Task.FromResult<(IReadOnlyList<long>?, uint)>((slots, FocasStatusMapper.Good));
|
||||
}
|
||||
|
||||
/// <summary>Writes a value to a FOCAS address asynchronously.</summary>
|
||||
/// <param name="address">The FOCAS address to write to.</param>
|
||||
/// <param name="type">The data type of the value.</param>
|
||||
@@ -121,6 +166,21 @@ internal class FakeFocasClient : IFocasClient
|
||||
public virtual Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) =>
|
||||
Task.FromResult(ProgramInfo);
|
||||
|
||||
/// <summary>Gets or sets the cnc_statinfo status snapshot.</summary>
|
||||
public FocasStatus Status { get; set; } = new(1, 3, 1, 0, 0, 0, 0, 0);
|
||||
/// <summary>Gets or sets a value indicating whether <see cref="GetStatusAsync"/> throws (older series / refused call).</summary>
|
||||
public bool ThrowOnStatus { get; set; }
|
||||
/// <summary>Gets the number of status reads served.</summary>
|
||||
public int StatusReads { get; private set; }
|
||||
/// <summary>Gets the CNC status flags asynchronously.</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public virtual Task<FocasStatus> GetStatusAsync(CancellationToken ct)
|
||||
{
|
||||
StatusReads++;
|
||||
if (ThrowOnStatus) throw new InvalidOperationException("cnc_statinfo refused");
|
||||
return Task.FromResult(Status);
|
||||
}
|
||||
|
||||
/// <summary>Gets the dictionary of timers keyed by timer kind.</summary>
|
||||
public Dictionary<FocasTimerKind, FocasTimer> Timers { get; } = [];
|
||||
/// <summary>Gets timer data asynchronously.</summary>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>Gitea #266 — the pure planner behind PMC range coalescing.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasPmcCoalescerTests
|
||||
{
|
||||
private static FocasPmcReadItem B(int index, string letter, int number, FocasDataType t = FocasDataType.Byte, int? bit = null) =>
|
||||
new(index, letter, number, t, bit);
|
||||
|
||||
[Fact]
|
||||
public void Adjacent_bytes_merge_into_one_range()
|
||||
{
|
||||
var plans = FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 101), B(2, "R", 102)], 128, 8);
|
||||
var p = plans.ShouldHaveSingleItem();
|
||||
(p.Letter, p.Start, p.End).ShouldBe(("R", 100, 102));
|
||||
p.Members.Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_gap_within_the_threshold_merges_and_beyond_it_splits()
|
||||
{
|
||||
FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 108)], 128, 8).Count.ShouldBe(1); // 7 byte gap
|
||||
FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 110)], 128, 8).Count.ShouldBe(2); // 9 byte gap
|
||||
FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 102)], 128, 0).Count.ShouldBe(2); // gap budget 0
|
||||
FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "R", 101)], 128, 0).Count.ShouldBe(1); // adjacent still fine
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_span_cap_is_a_hard_limit_on_range_length()
|
||||
{
|
||||
var items = Enumerable.Range(0, 10).Select(i => B(i, "R", 100 + i)).ToList();
|
||||
var plans = FocasPmcCoalescer.Plan(items, maxSpanBytes: 4, maxGapBytes: 8);
|
||||
plans.Select(p => p.End - p.Start + 1).ShouldAllBe(len => len <= 4);
|
||||
plans.Sum(p => p.Members.Count).ShouldBe(10);
|
||||
plans.Count.ShouldBe(3); // 4 + 4 + 2
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_letters_never_merge()
|
||||
{
|
||||
var plans = FocasPmcCoalescer.Plan([B(0, "R", 100), B(1, "D", 101), B(2, "R", 101)], 128, 8);
|
||||
plans.Count.ShouldBe(2);
|
||||
plans.Single(p => p.Letter == "R").Members.Count.ShouldBe(2);
|
||||
plans.Single(p => p.Letter == "D").Members.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bits_of_one_byte_share_a_slot_and_bits_and_bytes_share_a_range()
|
||||
{
|
||||
var plans = FocasPmcCoalescer.Plan(
|
||||
[B(0, "X", 0, FocasDataType.Bit, 0), B(1, "X", 0, FocasDataType.Bit, 7), B(2, "X", 1)], 128, 8);
|
||||
var p = plans.ShouldHaveSingleItem();
|
||||
(p.Start, p.End).ShouldBe((0, 1));
|
||||
p.SlotOf(p.Members.Single(m => m.Index == 2)).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Words_merge_only_when_aligned_to_the_range_start_and_the_range_is_whole_slots()
|
||||
{
|
||||
// 100,102 aligned -> one range [100,103]; 105 is unaligned (offset 5) -> its own range.
|
||||
var plans = FocasPmcCoalescer.Plan(
|
||||
[B(0, "D", 100, FocasDataType.Int16), B(1, "D", 102, FocasDataType.Int16), B(2, "D", 105, FocasDataType.Int16)], 128, 8);
|
||||
plans.Count.ShouldBe(2);
|
||||
var first = plans.Single(p => p.Start == 100);
|
||||
(first.End, first.SlotWidth, first.Members.Count).ShouldBe((103, 2, 2));
|
||||
first.SlotOf(first.Members.Single(m => m.Number == 102)).ShouldBe(1);
|
||||
plans.Single(p => p.Start == 105).Members.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_word_range_with_a_gap_is_rounded_up_to_whole_slots()
|
||||
{
|
||||
// 100 (word) + 103 (word): offset 3 is unaligned -> split; 100 + 104 aligned, gap 2 -> merged, end 105.
|
||||
var plans = FocasPmcCoalescer.Plan(
|
||||
[B(0, "D", 100, FocasDataType.Int16), B(1, "D", 104, FocasDataType.Int16)], 128, 8);
|
||||
var p = plans.ShouldHaveSingleItem();
|
||||
(p.Start, p.End).ShouldBe((100, 105));
|
||||
((p.End - p.Start + 1) % p.SlotWidth).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_widths_on_one_letter_do_not_merge()
|
||||
{
|
||||
FocasPmcCoalescer.Plan([B(0, "D", 100, FocasDataType.Byte), B(1, "D", 101, FocasDataType.Int16)], 128, 8)
|
||||
.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_input_appears_in_exactly_one_plan_and_input_order_is_irrelevant()
|
||||
{
|
||||
var items = new[] { B(0, "R", 300), B(1, "R", 100), B(2, "R", 102), B(3, "R", 101), B(4, "R", 104), B(5, "R", 300) };
|
||||
var a = FocasPmcCoalescer.Plan(items, 128, 8);
|
||||
var b = FocasPmcCoalescer.Plan(items.Reverse(), 128, 8);
|
||||
a.SelectMany(p => p.Members).Select(m => m.Index).OrderBy(x => x).ShouldBe([0, 1, 2, 3, 4, 5]);
|
||||
a.Select(p => (p.Start, p.End)).ShouldBe(b.Select(p => (p.Start, p.End)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Slot_decode_matches_the_per_tag_mapping()
|
||||
{
|
||||
FocasPmcCoalescer.MapSlot(0b0000_0100, FocasDataType.Bit, 2).ShouldBe(true);
|
||||
FocasPmcCoalescer.MapSlot(0b0000_0100, FocasDataType.Bit, 3).ShouldBe(false);
|
||||
FocasPmcCoalescer.MapSlot(0xFF, FocasDataType.Byte, null).ShouldBe((sbyte)-1);
|
||||
FocasPmcCoalescer.MapSlot(-2, FocasDataType.Int16, null).ShouldBe((short)-2);
|
||||
FocasPmcCoalescer.MapSlot(BitConverter.SingleToInt32Bits(1.5f), FocasDataType.Float32, null).ShouldBe(1.5f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>Gitea #266 — driver-level behaviour of PMC range coalescing in a poll batch.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasPmcCoalescingDriverTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
|
||||
private static FocasTagDefinition Tag(string name, string addr, FocasDataType t) =>
|
||||
new(name, Host, addr, t, Writable: false);
|
||||
|
||||
private static async Task<(FocasDriver drv, FakeFocasClient client)> StartAsync(
|
||||
FocasPmcCoalesceOptions? opts, params FocasTagDefinition[] tags)
|
||||
{
|
||||
var client = new FakeFocasClient();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
RawTags = FocasRawTags.Entries(tags),
|
||||
PmcCoalesce = opts ?? new FocasPmcCoalesceOptions(),
|
||||
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
return (drv, client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Adjacent_pmc_tags_are_served_by_one_range_read_and_sliced_back_per_tag()
|
||||
{
|
||||
var (drv, c) = await StartAsync(null,
|
||||
Tag("A", "R100", FocasDataType.Byte),
|
||||
Tag("B", "R101", FocasDataType.Byte),
|
||||
Tag("Bit", "R102.3", FocasDataType.Bit),
|
||||
Tag("Far", "R900", FocasDataType.Byte));
|
||||
c.PmcMemory[("R", 100)] = 0x11;
|
||||
c.PmcMemory[("R", 101)] = 0xFF;
|
||||
c.PmcMemory[("R", 102)] = 0b0000_1000;
|
||||
c.Values["R900"] = (sbyte)7;
|
||||
|
||||
var r = await drv.ReadAsync(["A", "B", "Bit", "Far"], CancellationToken.None);
|
||||
|
||||
c.PmcRangeReads.ShouldBe([("R", FocasDataType.Byte, 100, 102)]);
|
||||
r[0].Value.ShouldBe((sbyte)0x11);
|
||||
r[1].Value.ShouldBe((sbyte)-1);
|
||||
r[2].Value.ShouldBe(true);
|
||||
r[3].Value.ShouldBe((sbyte)7); // lone tag: ordinary per-tag path, no range read of its own
|
||||
r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Aligned_words_decode_from_their_own_slot()
|
||||
{
|
||||
var (drv, c) = await StartAsync(null,
|
||||
Tag("W0", "D200", FocasDataType.Int16),
|
||||
Tag("W1", "D202", FocasDataType.Int16));
|
||||
c.PmcMemory[("D", 200)] = 0x01; c.PmcMemory[("D", 201)] = 0x02; // 0x0102 = 258
|
||||
c.PmcMemory[("D", 202)] = 0xFF; c.PmcMemory[("D", 203)] = 0xFE; // -2
|
||||
|
||||
var r = await drv.ReadAsync(["W0", "W1"], CancellationToken.None);
|
||||
|
||||
c.PmcRangeReads.ShouldBe([("D", FocasDataType.Int16, 200, 203)]);
|
||||
r[0].Value.ShouldBe((short)258);
|
||||
r[1].Value.ShouldBe((short)-2);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_single_pmc_tag_never_issues_a_range_read()
|
||||
{
|
||||
var (drv, c) = await StartAsync(null, Tag("A", "R100", FocasDataType.Byte));
|
||||
c.Values["R100"] = (sbyte)5;
|
||||
|
||||
var r = await drv.ReadAsync(["A"], CancellationToken.None);
|
||||
|
||||
c.PmcRangeReads.ShouldBeEmpty();
|
||||
r[0].Value.ShouldBe((sbyte)5);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabling_coalescing_restores_per_tag_reads()
|
||||
{
|
||||
var (drv, c) = await StartAsync(new FocasPmcCoalesceOptions { Enabled = false },
|
||||
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
|
||||
c.Values["R100"] = (sbyte)1; c.Values["R101"] = (sbyte)2;
|
||||
|
||||
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
||||
|
||||
c.PmcRangeReads.ShouldBeEmpty();
|
||||
r[0].Value.ShouldBe((sbyte)1);
|
||||
r[1].Value.ShouldBe((sbyte)2);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_span_cap_and_gap_threshold_from_options_are_honoured()
|
||||
{
|
||||
var (drv, c) = await StartAsync(new FocasPmcCoalesceOptions { MaxSpanBytes = 2, MaxGapBytes = 0 },
|
||||
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte), Tag("C", "R102", FocasDataType.Byte));
|
||||
c.Values["R102"] = (sbyte)9;
|
||||
|
||||
var r = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
||||
|
||||
c.PmcRangeReads.ShouldBe([("R", FocasDataType.Byte, 100, 101)]); // C is alone -> per-tag
|
||||
r[2].Value.ShouldBe((sbyte)9);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_range_the_cnc_rejects_falls_back_to_per_tag_reads_with_their_own_status_and_is_remembered()
|
||||
{
|
||||
var (drv, c) = await StartAsync(null,
|
||||
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R106", FocasDataType.Byte));
|
||||
c.PmcRangeRejectors.Add((_, s, e) => e - s + 1 > 2); // the spare gap bytes are not valid addresses
|
||||
c.Values["R100"] = (sbyte)1;
|
||||
c.Values["R106"] = (sbyte)2;
|
||||
c.ReadStatuses["R106"] = FocasStatusMapper.BadNodeIdUnknown; // B is genuinely bad; A must NOT inherit that
|
||||
|
||||
var r1 = await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
||||
r1[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
r1[0].Value.ShouldBe((sbyte)1);
|
||||
r1[1].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
|
||||
c.PmcRangeReads.Count.ShouldBe(1);
|
||||
|
||||
await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
||||
c.PmcRangeReads.Count.ShouldBe(1, "a rejected range is remembered; later polls go straight to per-tag reads");
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refusal_with_an_unmapped_rc_that_maps_to_BadCommunicationError_still_falls_back_per_tag()
|
||||
{
|
||||
var (drv, c) = await StartAsync(null,
|
||||
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
|
||||
c.PmcRangeRejectors.Add((_, _, _) => true);
|
||||
c.PmcRangeRejectStatus = FocasStatusMapper.BadCommunicationError; // what an unlisted rc / EW_PARITY / EW_SOCKET maps to
|
||||
c.Values["R100"] = (sbyte)1; c.Values["R101"] = (sbyte)2;
|
||||
|
||||
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
||||
r.Select(s => s.Value).ShouldBe([(object)(sbyte)1, (sbyte)2]);
|
||||
r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
|
||||
|
||||
await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
||||
c.PmcRangeReads.Count.ShouldBe(1, "a deterministic rc refusal is memoised");
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_transient_busy_refusal_is_not_memoised()
|
||||
{
|
||||
var (drv, c) = await StartAsync(null,
|
||||
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
|
||||
var busy = true;
|
||||
c.PmcRangeRejectors.Add((_, _, _) => busy);
|
||||
c.PmcRangeRejectStatus = FocasStatusMapper.BadDeviceFailure; // EW_BUSY
|
||||
c.Values["R100"] = (sbyte)1; c.Values["R101"] = (sbyte)2;
|
||||
|
||||
(await drv.ReadAsync(["A", "B"], CancellationToken.None)).ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
|
||||
busy = false;
|
||||
c.PmcMemory[("R", 100)] = 5; c.PmcMemory[("R", 101)] = 6;
|
||||
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
||||
|
||||
c.PmcRangeReads.Count.ShouldBe(2, "the range is retried once the CNC is no longer busy");
|
||||
r.Select(s => s.Value).ShouldBe([(object)(sbyte)5, (sbyte)6]);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_down_device_costs_one_connect_attempt_per_batch_not_one_per_plan_and_tag()
|
||||
{
|
||||
var client = new FakeFocasClient { ThrowOnConnect = true };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
RawTags = FocasRawTags.Entries(new[]
|
||||
{
|
||||
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte),
|
||||
Tag("C", "R900", FocasDataType.Byte), Tag("D", "R2000", FocasDataType.Byte),
|
||||
}),
|
||||
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var before = client.ConnectCount;
|
||||
|
||||
var r = await drv.ReadAsync(["A", "B", "C", "D"], CancellationToken.None);
|
||||
|
||||
r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
|
||||
(client.ConnectCount - before).ShouldBe(1);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_transport_failure_fails_every_member_without_per_tag_retries()
|
||||
{
|
||||
var (drv, c) = await StartAsync(null,
|
||||
Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
|
||||
c.ThrowOnPmcRange = true;
|
||||
c.Values["R100"] = (sbyte)1; // would read Good if the driver wrongly fell back
|
||||
|
||||
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
||||
|
||||
r.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
|
||||
r.ShouldAllBe(s => s.Value == null);
|
||||
c.PmcRangeReads.Count.ShouldBe(1);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Result_order_follows_the_request_when_pmc_and_non_pmc_tags_are_mixed()
|
||||
{
|
||||
var (drv, c) = await StartAsync(null,
|
||||
Tag("P", "PARAM:6711", FocasDataType.Int32),
|
||||
Tag("A", "R100", FocasDataType.Byte),
|
||||
Tag("B", "R101", FocasDataType.Byte));
|
||||
c.Values["PARAM:6711"] = 1234;
|
||||
c.PmcMemory[("R", 100)] = 3; c.PmcMemory[("R", 101)] = 4;
|
||||
|
||||
var r = await drv.ReadAsync(["B", "P", "A"], CancellationToken.None);
|
||||
|
||||
r[0].Value.ShouldBe((sbyte)4);
|
||||
r[1].Value.ShouldBe(1234);
|
||||
r[2].Value.ShouldBe((sbyte)3);
|
||||
c.PmcRangeReads.Count.ShouldBe(1);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Duplicate_references_in_one_batch_each_get_their_value()
|
||||
{
|
||||
var (drv, c) = await StartAsync(null, Tag("A", "R100", FocasDataType.Byte), Tag("B", "R101", FocasDataType.Byte));
|
||||
c.PmcMemory[("R", 100)] = 3; c.PmcMemory[("R", 101)] = 4;
|
||||
|
||||
var r = await drv.ReadAsync(["A", "B", "A"], CancellationToken.None);
|
||||
|
||||
r.Select(s => s.Value).ShouldBe([(object)(sbyte)3, (sbyte)4, (sbyte)3]);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #257 — <c>cnc_statinfo</c> (Auto / Run / Motion / Mstb / Emergency / Alarm / Edit / TmMode)
|
||||
/// surfaces as a read-only <c>Status/</c> fixed-tree subtree, authorable as <c>FIXED:Status/Run</c>.
|
||||
/// The wire client already decoded the call (<c>FocasWireClient.ReadStatusAsync</c>) but nothing consumed it.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasStatusFlagsTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
|
||||
private static FocasDriver NewDriver(FakeFocasClient client, params FocasTagDefinition[] tags) =>
|
||||
new(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
RawTags = FocasRawTags.Entries(tags),
|
||||
FixedTree = new FocasFixedTreeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
PollInterval = TimeSpan.FromMilliseconds(10),
|
||||
ProgramPollInterval = TimeSpan.FromMilliseconds(10),
|
||||
},
|
||||
}, "drv-1", new FakeFocasClientFactory { Customise = () => client });
|
||||
|
||||
private static async Task WaitAsync(Func<bool> predicate)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTime.UtcNow < deadline && !predicate()) await Task.Delay(15);
|
||||
predicate().ShouldBeTrue("condition never became true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_wire_status_block_maps_field_for_field()
|
||||
{
|
||||
var s = Wire.WireFocasClient.MapStatus(new Wire.WireStatus(Auto: 1, Run: 2, Motion: 3, Mstb: 4, Emergency: 5, Alarm: 6, Edit: 7, TmMode: 8));
|
||||
s.ShouldBe(new FocasStatus(1, 2, 3, 4, 5, 6, 7, 8));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, "RESET")]
|
||||
[InlineData(1, "STOP")]
|
||||
[InlineData(2, "HOLD")]
|
||||
[InlineData(3, "START")]
|
||||
[InlineData(4, "MSTR")]
|
||||
[InlineData(9, "Run9")]
|
||||
public void Run_state_text(int run, string text) => FocasRunState.ToText(run).ShouldBe(text);
|
||||
|
||||
[Fact]
|
||||
public async Task Status_flags_read_good_through_the_fixed_tree_references()
|
||||
{
|
||||
var client = new FakeFocasClient { Status = new FocasStatus(Auto: 1, Run: 3, Motion: 1, Mstb: 2, Emergency: 0, Alarm: 1, Edit: 0, TmMode: 4) };
|
||||
var drv = NewDriver(client);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus is not null);
|
||||
|
||||
string[] fields = ["Auto", "Run", "Motion", "Mstb", "Emergency", "Alarm", "Edit", "TmMode"];
|
||||
int[] expected = [1, 3, 1, 2, 0, 1, 0, 4];
|
||||
var read = await drv.ReadAsync([.. fields.Select(f => $"{Host}/Status/{f}")], CancellationToken.None);
|
||||
for (var i = 0; i < fields.Length; i++)
|
||||
{
|
||||
read[i].StatusCode.ShouldBe(FocasStatusMapper.Good, fields[i]);
|
||||
read[i].Value.ShouldBe(expected[i], fields[i]);
|
||||
}
|
||||
|
||||
var text = await drv.ReadAsync([$"{Host}/Status/RunText"], CancellationToken.None);
|
||||
text[0].Value.ShouldBe("START");
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_authored_FIXED_Status_tag_reads_the_flag()
|
||||
{
|
||||
var client = new FakeFocasClient { Status = new FocasStatus(1, 3, 1, 0, 1, 0, 0, 0) };
|
||||
var drv = NewDriver(client,
|
||||
new FocasTagDefinition("EStop", Host, "FIXED:Status/Emergency", FocasDataType.Int32, Writable: false));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus is not null);
|
||||
|
||||
var snap = (await drv.ReadAsync(["EStop"], CancellationToken.None))[0];
|
||||
snap.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snap.Value.ShouldBe(1);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_updates_follow_the_machine()
|
||||
{
|
||||
var client = new FakeFocasClient { Status = new FocasStatus(1, 0, 0, 0, 0, 0, 0, 0) };
|
||||
var drv = NewDriver(client);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus is not null);
|
||||
|
||||
client.Status = new FocasStatus(1, 3, 1, 0, 0, 0, 0, 0);
|
||||
await WaitAsync(() => drv.GetDeviceState(Host)?.LastStatus?.Run == 3);
|
||||
(await drv.ReadAsync([$"{Host}/Status/Run"], CancellationToken.None))[0].Value.ShouldBe(3);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_cnc_that_refuses_statinfo_gets_no_Status_node_and_the_rest_of_the_tree_survives()
|
||||
{
|
||||
var client = new FakeFocasClient { ThrowOnStatus = true };
|
||||
var drv = NewDriver(client);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitAsync(() => drv.GetDeviceState(Host)?.FixedTreeCache is not null);
|
||||
|
||||
var builder = new RecordingBuilder();
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldNotContain(f => f.BrowseName == "Status");
|
||||
builder.Variables.ShouldContain(v => v.Info.FullName == "FIXED:Identity/SeriesNumber");
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discovery_emits_every_status_leaf_with_a_parseable_read_only_address()
|
||||
{
|
||||
var drv = NewDriver(new FakeFocasClient());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitAsync(() => drv.GetDeviceState(Host)?.FixedTreeCache is not null);
|
||||
|
||||
var builder = new RecordingBuilder();
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Status");
|
||||
var leaves = builder.Variables.Where(v => v.Info.FullName.StartsWith("FIXED:Status/", StringComparison.Ordinal)).ToList();
|
||||
leaves.Select(l => l.BrowseName).ShouldBe(
|
||||
["Auto", "Run", "Motion", "Mstb", "Emergency", "Alarm", "Edit", "TmMode", "RunText"], ignoreOrder: true);
|
||||
foreach (var (_, info) in leaves)
|
||||
{
|
||||
FocasAddress.TryParse(info.FullName).ShouldNotBeNull();
|
||||
info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string name, DriverDataType type, object? value) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
|
||||
private sealed class NullSink : IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-9
@@ -151,27 +151,31 @@ public sealed class MTConnectAlarmSourceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_changed_alarm_set_re_tells_every_condition_and_the_same_set_does_not()
|
||||
public async Task A_changed_alarm_set_re_tells_only_conditions_that_left_and_came_back()
|
||||
{
|
||||
// Review MAJOR 2: a changed set is an alarm-bearing delta ⇒ full rebuild ⇒ every condition node was
|
||||
// recreated inactive, so an active condition still in the set must be re-asserted onto it.
|
||||
// Gitea #641: a changed set is an alarm-bearing delta ⇒ a full rebuild, but the server now carries every
|
||||
// surviving condition's state (acknowledgement included) across it. Re-raising a survivor would
|
||||
// UN-acknowledge it, so only the record of a condition that LEFT the set is dropped: one that comes back
|
||||
// was materialised fresh and must be told again.
|
||||
var (driver, agent, events) = await AlarmDriverAsync();
|
||||
await driver.SubscribeAlarmsAsync([ConditionRawPath], Ct);
|
||||
await agent.PumpAsync(Chunk(108, 109, Cond("Fault", "E17"))).WaitAsync(Watchdog, Ct);
|
||||
events.ShouldHaveSingleItem().Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||
|
||||
await driver.SubscribeAlarmsAsync([ConditionRawPath, OtherConditionRawPath], Ct);
|
||||
events.Count.ShouldBe(2, "A is re-raised onto its rebuilt node");
|
||||
await driver.SubscribeAlarmsAsync([ConditionRawPath, OtherConditionRawPath], Ct); // A survives: silent
|
||||
await driver.SubscribeAlarmsAsync([OtherConditionRawPath, ConditionRawPath], Ct); // same set: silent
|
||||
events.Count.ShouldBe(1, "a surviving condition is not re-raised");
|
||||
|
||||
await driver.SubscribeAlarmsAsync([OtherConditionRawPath], Ct); // A removed
|
||||
await driver.SubscribeAlarmsAsync([OtherConditionRawPath, ConditionRawPath], Ct); // A re-added: fresh node
|
||||
events.Count.ShouldBe(2, "a condition that left the set and came back is told again");
|
||||
events.Last().ConditionId.ShouldBe(ConditionRawPath);
|
||||
events.Last().Kind.ShouldBe(AlarmTransitionKind.Raise);
|
||||
|
||||
await driver.SubscribeAlarmsAsync([OtherConditionRawPath, ConditionRawPath], Ct);
|
||||
|
||||
// Positive control: the next real transition is the very next event.
|
||||
await agent.PumpAsync(Chunk(109, 110, Cond("Normal"))).WaitAsync(Watchdog, Ct);
|
||||
events.Select(e => e.Kind).ShouldBe(
|
||||
[AlarmTransitionKind.Raise, AlarmTransitionKind.Raise, AlarmTransitionKind.Clear],
|
||||
"the same set (a reconnect) must re-raise nothing");
|
||||
[AlarmTransitionKind.Raise, AlarmTransitionKind.Raise, AlarmTransitionKind.Clear]);
|
||||
|
||||
await driver.ShutdownAsync(Ct);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ public sealed class MqttConnectionTests
|
||||
/// actually wedges a client — a refused port returns ECONNREFUSED instantly and would pass
|
||||
/// whether or not any deadline logic existed.
|
||||
/// </summary>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task ConnectAsync_BlackholeBroker_FailsWithinDeadline_NoHang()
|
||||
{
|
||||
@@ -95,6 +96,7 @@ public sealed class MqttConnectionTests
|
||||
/// <c>IMqttClient</c> held across failures. A failed attempt must therefore leave the
|
||||
/// connection usable rather than poisoned.
|
||||
/// </summary>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task ConnectAsync_CanBeRetriedOnTheSameInstanceAfterAFailedAttempt()
|
||||
{
|
||||
@@ -592,6 +594,7 @@ public sealed class MqttConnectionTests
|
||||
/// <c>InitializeAsync</c>) — it must not silently leave a background reconnect worker
|
||||
/// hammering an endpoint the caller has already given up on.
|
||||
/// </summary>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task ConnectAsync_InitialAttemptFails_LeavesNoBackgroundReconnectStorm()
|
||||
{
|
||||
@@ -729,6 +732,7 @@ public sealed class MqttConnectionTests
|
||||
/// <c>Faulted</c>) — and the backoff keeps the retry rate low enough that a handful of
|
||||
/// attempts, not hundreds, land in the observation window.
|
||||
/// </summary>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task BrokerStaysDown_RetriesUnderBackoff_WithoutHotLooping()
|
||||
{
|
||||
@@ -759,6 +763,7 @@ public sealed class MqttConnectionTests
|
||||
/// Dispose during a reconnect backoff must stop the supervisor dead — no leaked task, no
|
||||
/// later attempt resurrecting a connection the caller has torn down.
|
||||
/// </summary>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task Dispose_DuringReconnectBackoff_StopsTheSupervisor_NoFurtherAttempts()
|
||||
{
|
||||
@@ -875,6 +880,7 @@ public sealed class MqttConnectionTests
|
||||
/// against a perfectly healthy connection, at Debug level, inflating <c>attempt</c> so the
|
||||
/// NEXT genuine outage waits max backoff instead of min.
|
||||
/// </summary>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task CallerReconnectsWhileTheSupervisorIsInBackoff_SupervisorStandsDownSilently()
|
||||
{
|
||||
@@ -917,6 +923,7 @@ public sealed class MqttConnectionTests
|
||||
/// A stood-down supervisor answers it one min-backoff (2 s) later. The 5 s window separates the
|
||||
/// two by a clear margin; widening the burn only widens the separation.
|
||||
/// </remarks>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task AfterACallerWinsTheRace_TheNextGenuineOutageStillRecoversAtMinBackoff()
|
||||
{
|
||||
@@ -971,6 +978,7 @@ public sealed class MqttConnectionTests
|
||||
/// dispose-elapsed assertion cannot tell those apart — <c>DisposeAsync</c>'s own bounded join
|
||||
/// returns promptly either way, leaving the supervisor alive behind it.
|
||||
/// </remarks>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task ReSubscribeThatHangsIgnoringItsToken_DoesNotParkTheSupervisor()
|
||||
{
|
||||
@@ -1085,6 +1093,7 @@ public sealed class MqttConnectionTests
|
||||
/// An unrecoverable rejection must not leave a supervisor grinding away every backoff period
|
||||
/// against a broker that will never accept these credentials.
|
||||
/// </summary>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task ConnectAsync_RefusedCredentials_StartsNoReconnectSupervisor()
|
||||
{
|
||||
@@ -1156,6 +1165,7 @@ public sealed class MqttConnectionTests
|
||||
/// The supervisor's own attempts go through the same CONNACK check. A broker that starts
|
||||
/// refusing mid-life (credentials revoked, client banned) must stop the loop, not feed it.
|
||||
/// </summary>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task Reconnect_BrokerStartsRefusing_FaultsAndStopsTheSupervisor()
|
||||
{
|
||||
@@ -1179,6 +1189,7 @@ public sealed class MqttConnectionTests
|
||||
}
|
||||
|
||||
/// <summary>The mirror case: a transient refusal keeps the supervisor retrying under backoff.</summary>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task Reconnect_TransientRefusal_KeepsRetryingUnderBackoff()
|
||||
{
|
||||
|
||||
@@ -211,6 +211,7 @@ public sealed class MqttDriverDiscoveryTests
|
||||
/// fails must land <see cref="DriverState.Faulted"/> rather than letting the failure escape
|
||||
/// with the health surface still claiming Healthy.
|
||||
/// </summary>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task ReinitializeAsync_SessionChangingDelta_Rebuilds_AndFaultsOnUnreachableBroker()
|
||||
{
|
||||
|
||||
@@ -83,6 +83,7 @@ public sealed class MqttDriverSessionLifecycleTests
|
||||
/// The control for the test above: the normalization must not quietly rewrite a <i>legal</i>
|
||||
/// value, or a deployment's authored connect budget would silently become the default.
|
||||
/// </summary>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task InitializeAsync_WithAnAuthoredConnectTimeout_KeepsIt()
|
||||
{
|
||||
@@ -126,6 +127,7 @@ public sealed class MqttDriverSessionLifecycleTests
|
||||
/// can drop a session (which is what this test needs from it) but cannot PUBLISH.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task ABrokerDrop_MarksCachedRefsBadNoCommunication_AndTheIdenticalReplayRecovers()
|
||||
{
|
||||
|
||||
@@ -243,6 +243,7 @@ public sealed class SparkplugPrimaryHostTests
|
||||
/// one-second role sample, forever. It now backs off. The clock is frozen, so the real one-second
|
||||
/// loop ticks several times without the backoff window ever opening; advancing the clock opens it.
|
||||
/// </summary>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task A_failing_claim_backs_off_instead_of_redialling_every_tick()
|
||||
{
|
||||
@@ -313,6 +314,7 @@ public sealed class SparkplugPrimaryHostTests
|
||||
/// Deterministic rather than timing-dependent: the gate is taken before the connect starts, so
|
||||
/// the broker having accepted the socket proves the claim is inside it when dispose runs.
|
||||
/// </remarks>
|
||||
[Trait("Category", "SlowUnit")]
|
||||
[Fact]
|
||||
public async Task Disposing_while_a_reconcile_is_in_flight_does_not_fault_on_a_disposed_gate()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
@@ -71,6 +72,349 @@ public sealed class OpcUaClientAlarmTests
|
||||
await drv.AcknowledgeAsync([], TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
// ---- #619: v3 RawPath <-> upstream-source translation on the native-alarm path ----
|
||||
|
||||
private static VariantCollection EventFields(string sourceNode, string conditionNode) =>
|
||||
[
|
||||
new Variant(new byte[] { 1 }), // EventId
|
||||
new Variant(new NodeId("TripAlarmType", 3)), // EventType
|
||||
new Variant(NodeId.Parse(sourceNode)), // SourceNode
|
||||
new Variant(new LocalizedText("tripped")), // Message
|
||||
new Variant((ushort)700), // Severity
|
||||
new Variant(DateTime.UtcNow), // Time
|
||||
new Variant(NodeId.Parse(conditionNode)), // ConditionId
|
||||
];
|
||||
|
||||
/// <summary>The host hands SubscribeAlarmsAsync RawPaths; the index must be keyed by the UPSTREAM source id.</summary>
|
||||
[Fact]
|
||||
public void BuildAlarmSourceIndex_keys_the_upstream_source_id_to_the_RawPath()
|
||||
{
|
||||
var index = OpcUaClientDriver.BuildAlarmSourceIndex(
|
||||
["Plant/opc/plc/Trip1"],
|
||||
raw => raw == "Plant/opc/plc/Trip1" ? "ns=3;s=Alarms.Trip1" : null);
|
||||
|
||||
index["ns=3;s=Alarms.Trip1"].ShouldBe("Plant/opc/plc/Trip1");
|
||||
}
|
||||
|
||||
/// <summary>A ref the authored table cannot resolve is kept verbatim (direct upstream-id use stays valid).</summary>
|
||||
[Fact]
|
||||
public void BuildAlarmSourceIndex_keeps_an_unresolvable_ref_as_its_own_key()
|
||||
{
|
||||
var index = OpcUaClientDriver.BuildAlarmSourceIndex(["ns=3;s=Direct"], _ => null);
|
||||
index["ns=3;s=Direct"].ShouldBe("ns=3;s=Direct");
|
||||
}
|
||||
|
||||
/// <summary>The emitted ConditionId must be the RawPath - DriverHostActor routes on (driver, RawPath).</summary>
|
||||
[Fact]
|
||||
public void ProjectEvent_reports_the_RawPath_as_ConditionId_and_remembers_the_condition_node()
|
||||
{
|
||||
var index = new Dictionary<string, string> { ["ns=3;s=Alarms.Trip1"] = "Plant/opc/plc/Trip1" };
|
||||
string? rememberedRaw = null, rememberedCond = null;
|
||||
|
||||
var e = OpcUaClientDriver.ProjectEvent(
|
||||
EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond"), index, new FakeAlarmHandle(),
|
||||
n => n.ToString(), (raw, target) => { rememberedRaw = raw; rememberedCond = target.ConditionRef; });
|
||||
|
||||
e.ShouldNotBeNull();
|
||||
e.ConditionId.ShouldBe("Plant/opc/plc/Trip1");
|
||||
e.SourceNodeId.ShouldBe("Plant/opc/plc/Trip1");
|
||||
rememberedRaw.ShouldBe("Plant/opc/plc/Trip1");
|
||||
rememberedCond.ShouldBe("ns=3;s=Alarms.Trip1.Cond");
|
||||
}
|
||||
|
||||
/// <summary>An event from a source nobody authored is filtered out.</summary>
|
||||
[Fact]
|
||||
public void ProjectEvent_drops_an_unauthored_source()
|
||||
{
|
||||
var index = new Dictionary<string, string> { ["ns=3;s=Alarms.Trip1"] = "Plant/opc/plc/Trip1" };
|
||||
OpcUaClientDriver.ProjectEvent(
|
||||
EventFields("ns=3;s=Other", "ns=3;s=Other.Cond"), index, new FakeAlarmHandle(), n => n.ToString(), (_, _) => { })
|
||||
.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A remote namespace-table reorder across an in-place SDK reconnect must not orphan the alarm index:
|
||||
/// it is built once at subscribe time, while events keep arriving on the (transferred) subscription with
|
||||
/// the NEW namespace indexes. Keys are therefore server-stable (<c>nsu=</c>) on both sides.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ProjectEvent_matches_across_a_namespace_reorder()
|
||||
{
|
||||
var before = new NamespaceTable(); before.Append("urn:other"); before.Append("urn:plc"); // urn:plc = 2
|
||||
var after = new NamespaceTable(); after.Append("urn:new"); after.Append("urn:other"); after.Append("urn:plc"); // urn:plc = 3
|
||||
var mapBefore = NamespaceMap.FromTable(before);
|
||||
var mapAfter = NamespaceMap.FromTable(after);
|
||||
|
||||
var index = OpcUaClientDriver.BuildAlarmSourceIndex(
|
||||
["Plant/opc/plc/Trip1"], _ => OpcUaClientDriver.AlarmIndexKey(mapBefore, new NodeId("Alarms.Trip1", 2)));
|
||||
|
||||
var e = OpcUaClientDriver.ProjectEvent(
|
||||
EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond"), index, new FakeAlarmHandle(),
|
||||
n => OpcUaClientDriver.AlarmIndexKey(mapAfter, n), (_, _) => { });
|
||||
|
||||
e.ShouldNotBeNull();
|
||||
e.ConditionId.ShouldBe("Plant/opc/plc/Trip1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A source with several conditions (Hi + HiHi on one variable) can only be told apart by the CONDITION
|
||||
/// node, so a tag authored on the condition node matches on the event's ConditionId — 1:1, no collapse.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ProjectEvent_matches_a_tag_authored_on_the_condition_node()
|
||||
{
|
||||
var index = new Dictionary<string, string>
|
||||
{
|
||||
["ns=3;s=Tank.HiHi"] = "Plant/opc/plc/HiHi",
|
||||
["ns=3;s=Tank.Hi"] = "Plant/opc/plc/Hi",
|
||||
};
|
||||
|
||||
var e = OpcUaClientDriver.ProjectEvent(
|
||||
EventFields("ns=3;s=Tank", "ns=3;s=Tank.HiHi"), index, new FakeAlarmHandle(), n => n.ToString(), (_, _) => { });
|
||||
|
||||
e.ShouldNotBeNull();
|
||||
e.ConditionId.ShouldBe("Plant/opc/plc/HiHi");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ack target pairs the condition node WITH the EventId of the event that named it, so Acknowledge
|
||||
/// carries the real EventId (Part 9 §5.7.3) instead of an empty one a strict server rejects
|
||||
/// <c>BadEventIdUnknown</c>, and the two can never come from different conditions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ProjectEvent_remembers_the_condition_with_its_EventId()
|
||||
{
|
||||
var index = new Dictionary<string, string> { ["ns=3;s=Alarms.Trip1"] = "Plant/opc/plc/Trip1" };
|
||||
OpcUaClientDriver.AlarmAckTarget? target = null;
|
||||
|
||||
OpcUaClientDriver.ProjectEvent(
|
||||
EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond"), index, new FakeAlarmHandle(),
|
||||
n => n.ToString(), (_, t) => target = t);
|
||||
|
||||
target.ShouldNotBeNull();
|
||||
target.Value.ConditionRef.ShouldBe("ns=3;s=Alarms.Trip1.Cond");
|
||||
target.Value.EventId.ShouldBe(new byte[] { 1 });
|
||||
}
|
||||
|
||||
// ---- #619 review: the host's NativeAlarmProjector derives Active/Acked ONLY from AlarmEventArgs.Kind; an
|
||||
// Unspecified kind keeps the prior state (inactive+acked when fresh), so without a Kind no upstream alarm
|
||||
// could ever show active. ----
|
||||
|
||||
/// <summary>ActiveState/Id and AckedState/Id are read from the two trailing select clauses.</summary>
|
||||
[Fact]
|
||||
public void ProjectEvent_reads_the_condition_state()
|
||||
{
|
||||
var index = new Dictionary<string, string> { ["ns=3;s=Alarms.Trip1"] = "Plant/opc/plc/Trip1" };
|
||||
var fields = EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond");
|
||||
fields.Add(new Variant(true)); // ActiveState/Id
|
||||
fields.Add(new Variant(false)); // AckedState/Id
|
||||
|
||||
OpcUaClientDriver.ProjectEvent(fields, index, new FakeAlarmHandle(), n => n.ToString(), (_, _) => { },
|
||||
out var state);
|
||||
|
||||
state.ShouldBe((true, false));
|
||||
}
|
||||
|
||||
/// <summary>The transition sequence drives a projector from the driver's last-told state to the event's.</summary>
|
||||
[Theory]
|
||||
[InlineData(false, true, true, false, new[] { AlarmTransitionKind.Raise })]
|
||||
[InlineData(false, true, true, true, new[] { AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge })]
|
||||
[InlineData(true, false, true, true, new[] { AlarmTransitionKind.Acknowledge })]
|
||||
[InlineData(true, false, false, false, new[] { AlarmTransitionKind.Clear })]
|
||||
[InlineData(true, true, false, true, new[] { AlarmTransitionKind.Clear })]
|
||||
[InlineData(false, false, false, true, new[] { AlarmTransitionKind.Acknowledge })]
|
||||
[InlineData(true, false, false, true, new[] { AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge })]
|
||||
[InlineData(true, true, true, false, new[] { AlarmTransitionKind.Retrigger })]
|
||||
[InlineData(true, false, true, false, new AlarmTransitionKind[0])]
|
||||
public void AlarmTransitions_move_prior_to_target(
|
||||
bool priorActive, bool priorAcked, bool active, bool acked, AlarmTransitionKind[] expected)
|
||||
{
|
||||
OpcUaClientDriver.AlarmTransitions((priorActive, priorAcked), (active, acked)).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---- #619 re-review: the transition table must reach EVERY target from EVERY prior. ----
|
||||
|
||||
/// <summary>The host projector's semantics, restated: the only thing the driver's kinds act upon.</summary>
|
||||
private static (bool Active, bool Acked) Simulate((bool Active, bool Acked) s, AlarmTransitionKind k) => k switch
|
||||
{
|
||||
AlarmTransitionKind.Raise or AlarmTransitionKind.Retrigger => (true, false),
|
||||
AlarmTransitionKind.Acknowledge => (s.Active, true),
|
||||
AlarmTransitionKind.Clear => (false, s.Acked),
|
||||
_ => s,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Exhaustive over all 16 (prior, target) pairs: replaying the emitted kinds through the projector's
|
||||
/// semantics lands on the target, and an unchanged state emits nothing. The table this replaced reached
|
||||
/// (inactive, acked) from (active, acked) → (inactive, UNACKED) wrongly, and could not express a
|
||||
/// cleared-but-unacked condition first seen by a ConditionRefresh at all.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AlarmTransitions_reach_every_target_from_every_prior()
|
||||
{
|
||||
bool[] b = [false, true];
|
||||
foreach (var pa in b) foreach (var pk in b) foreach (var ta in b) foreach (var tk in b)
|
||||
{
|
||||
var kinds = OpcUaClientDriver.AlarmTransitions((pa, pk), (ta, tk));
|
||||
var s = (pa, pk);
|
||||
foreach (var k in kinds) s = Simulate(s, k);
|
||||
s.ShouldBe((ta, tk), $"prior ({pa},{pk}) -> target ({ta},{tk}) via [{string.Join(",", kinds)}]");
|
||||
if ((pa, pk) == (ta, tk)) kinds.ShouldBeEmpty();
|
||||
kinds.ShouldNotContain(AlarmTransitionKind.Unspecified);
|
||||
}
|
||||
}
|
||||
|
||||
private static VariantCollection ConditionFields(
|
||||
bool active, bool acked, string message = "tripped", ushort severity = 700, byte[]? eventId = null,
|
||||
NodeId? branchId = null, NodeId? eventType = null)
|
||||
{
|
||||
var f = EventFields("ns=3;s=Alarms.Trip1", "ns=3;s=Alarms.Trip1.Cond");
|
||||
f[0] = new Variant(eventId ?? new byte[] { 1 });
|
||||
if (eventType is not null) f[1] = new Variant(eventType);
|
||||
f[3] = new Variant(new LocalizedText(message));
|
||||
f[4] = new Variant(severity);
|
||||
f.Add(new Variant(active));
|
||||
f.Add(new Variant(acked));
|
||||
f.Add(branchId is null ? Variant.Null : new Variant(branchId));
|
||||
return f;
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, string> TripIndex = new() { ["ns=3;s=Alarms.Trip1"] = "Plant/opc/plc/Trip1" };
|
||||
|
||||
private static List<AlarmEventArgs> Record(OpcUaClientDriver drv)
|
||||
{
|
||||
var seen = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (seen) seen.Add(e); };
|
||||
return seen;
|
||||
}
|
||||
|
||||
/// <summary>The remembered EventId is a COPY — the decoded notification's array is not ours to alias.</summary>
|
||||
[Fact]
|
||||
public void ProjectEvent_copies_the_EventId()
|
||||
{
|
||||
var raw = new byte[] { 7, 8, 9 };
|
||||
OpcUaClientDriver.AlarmAckTarget? target = null;
|
||||
OpcUaClientDriver.ProjectEvent(ConditionFields(true, false, eventId: raw), TripIndex, new FakeAlarmHandle(),
|
||||
n => n.ToString(), (_, t) => target = t);
|
||||
|
||||
raw[0] = 0;
|
||||
target!.Value.EventId.ShouldBe(new byte[] { 7, 8, 9 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A condition BRANCH event (BranchId non-null) reports a previous state of the condition and carries its
|
||||
/// own EventId — projecting it would flip the trunk's state, and remembering it would ack the branch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ProjectEvent_ignores_a_condition_branch_event()
|
||||
{
|
||||
var remembered = false;
|
||||
OpcUaClientDriver.ProjectEvent(
|
||||
ConditionFields(false, false, branchId: new NodeId(Guid.NewGuid(), 3)), TripIndex, new FakeAlarmHandle(),
|
||||
n => n.ToString(), (_, _) => remembered = true)
|
||||
.ShouldBeNull();
|
||||
remembered.ShouldBeFalse();
|
||||
|
||||
// Positive control: the same event with a null BranchId (the trunk) projects.
|
||||
OpcUaClientDriver.ProjectEvent(ConditionFields(false, false), TripIndex, new FakeAlarmHandle(),
|
||||
n => n.ToString(), (_, _) => { })
|
||||
.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>RefreshStart/RefreshEnd are markers around a ConditionRefresh, never alarms — even pass-through.</summary>
|
||||
[Fact]
|
||||
public void Refresh_markers_are_never_projected_as_alarms()
|
||||
{
|
||||
foreach (var type in new[] { ObjectTypeIds.RefreshStartEventType, ObjectTypeIds.RefreshEndEventType, ObjectTypeIds.RefreshRequiredEventType })
|
||||
{
|
||||
var fields = EventFields("i=2253", "i=0");
|
||||
fields[1] = new Variant(type);
|
||||
OpcUaClientDriver.ProjectEvent(fields, new Dictionary<string, string>(), new FakeAlarmHandle(),
|
||||
n => n.ToString(), (_, _) => { }).ShouldBeNull($"{type} is a refresh marker");
|
||||
}
|
||||
OpcUaClientDriver.IsRefreshRequired(EventFieldsOfType(ObjectTypeIds.RefreshRequiredEventType)).ShouldBeTrue();
|
||||
OpcUaClientDriver.IsRefreshRequired(EventFieldsOfType(ObjectTypeIds.RefreshEndEventType)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static VariantCollection EventFieldsOfType(NodeId type)
|
||||
{
|
||||
var f = EventFields("i=2253", "i=0");
|
||||
f[1] = new Variant(type);
|
||||
return f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A ConditionRefresh re-delivers every retained condition's CURRENT state. One the host was already told
|
||||
/// must emit nothing — no re-raise (which would un-ack an acked alarm) and no Unspecified (which a host
|
||||
/// projector reset by a redeploy would project from its inactive default).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_refresh_of_an_already_reported_state_emits_nothing()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-dedup");
|
||||
var seen = Record(drv);
|
||||
var handle = new FakeAlarmHandle();
|
||||
|
||||
drv.RouteAlarmEvent(ConditionFields(true, false), TripIndex, handle);
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
|
||||
seen.Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
|
||||
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle); // the refresh copy
|
||||
seen.Count.ShouldBe(2);
|
||||
|
||||
// Positive control: a genuine message change on an unchanged state still reaches the host.
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true, message: "worse"), TripIndex, handle);
|
||||
seen.Count.ShouldBe(3);
|
||||
seen[2].Kind.ShouldBe(AlarmTransitionKind.Unspecified);
|
||||
seen[2].Message.ShouldBe("worse");
|
||||
}
|
||||
|
||||
/// <summary>The exact upstream severity rides OpcUaSeverity (#626) so the host does not re-bucket it.</summary>
|
||||
[Fact]
|
||||
public void The_exact_upstream_severity_is_carried()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-sev");
|
||||
var seen = Record(drv);
|
||||
drv.RouteAlarmEvent(ConditionFields(true, false, severity: 650), TripIndex, new FakeAlarmHandle());
|
||||
seen.Single().OpcUaSeverity.ShouldBe(650);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A driver re-initialise (comms-loss watchdog, Force Reconnect) re-subscribes the SAME set over nodes the
|
||||
/// host did not rebuild, so the last-told state must survive it — or the refresh after it would re-raise
|
||||
/// every acked active alarm (un-ack). A CHANGED set means the host rebuilt (nodes recreated inactive), so
|
||||
/// there the state is forgotten and the refresh re-raises from scratch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Last_told_state_survives_a_reinit_of_the_same_set_but_not_a_set_change()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-alarm-reinit");
|
||||
var seen = Record(drv);
|
||||
var handle = new FakeAlarmHandle();
|
||||
drv.BeginAlarmSubscription(["Plant/opc/plc/Trip1"]);
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
|
||||
seen.Count.ShouldBe(2);
|
||||
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
drv.BeginAlarmSubscription(["Plant/opc/plc/Trip1"]);
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
|
||||
seen.Count.ShouldBe(2, "same set after a reinit: the refresh copy is a duplicate");
|
||||
|
||||
drv.BeginAlarmSubscription(["Plant/opc/plc/Trip1", "Plant/opc/plc/Trip2"]);
|
||||
drv.RouteAlarmEvent(ConditionFields(true, true), TripIndex, handle);
|
||||
seen.Skip(2).Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge]);
|
||||
}
|
||||
|
||||
/// <summary>A server without ConditionRefresh is tolerated; anything else is a real failure.</summary>
|
||||
[Fact]
|
||||
public void ConditionRefresh_unsupported_status_codes()
|
||||
{
|
||||
OpcUaClientDriver.IsConditionRefreshUnsupported(StatusCodes.BadNotSupported).ShouldBeTrue();
|
||||
OpcUaClientDriver.IsConditionRefreshUnsupported(StatusCodes.BadMethodInvalid).ShouldBeTrue();
|
||||
OpcUaClientDriver.IsConditionRefreshUnsupported(StatusCodes.BadNotImplemented).ShouldBeTrue();
|
||||
OpcUaClientDriver.IsConditionRefreshUnsupported(StatusCodes.BadTimeout).ShouldBeFalse();
|
||||
}
|
||||
|
||||
private sealed class FakeAlarmHandle : IAlarmSubscriptionHandle
|
||||
{
|
||||
/// <summary>Gets the diagnostic identifier for this alarm handle.</summary>
|
||||
|
||||
@@ -39,7 +39,7 @@ docker compose --profile s7_1500 up -d --build --force-recreate
|
||||
```
|
||||
|
||||
Confirm it took: `docker logs otopcua-python-snap7-s7_1500` prints
|
||||
`seeded DB1 size=1024 seeds=<n>` at startup — check `<n>` matches the seed count
|
||||
`seeded DB1 size=2048 seeds=<n>` at startup — check `<n>` matches the seed count
|
||||
you just wrote. This is the fastest way to tell a stale image from a bad address.
|
||||
|
||||
On the shared Docker host the deployment mirror at `/opt/otopcua-s7/` is
|
||||
@@ -78,7 +78,7 @@ without Docker running.
|
||||
|
||||
## What's encoded in `profiles/s7_1500.json`
|
||||
|
||||
DB1 (1024 bytes) + MB (256 bytes) + TM0/CT0 (64 bytes each) with typed seeds at
|
||||
DB1 (2048 bytes) + MB (256 bytes) + TM0/CT0 (64 bytes each) with typed seeds at
|
||||
known offsets:
|
||||
|
||||
| Address | Type | Seed | Purpose |
|
||||
@@ -93,7 +93,7 @@ known offsets:
|
||||
| `MW0` | u16 | `1` | `S7ProbeOptions.ProbeAddress` default |
|
||||
|
||||
Seed types supported: `u8`, `i8`, `u16`, `i16`, `u32`, `i32`, `f32`,
|
||||
`bool` (with `"bit": 0..7`), `ascii` (S7 STRING).
|
||||
`bool` (with `"bit": 0..7`), `ascii` (S7 STRING), `hex` (raw bytes).
|
||||
|
||||
### Span-planner gate seeds (offsets 300+, `S7_1500SpanReadTests`)
|
||||
|
||||
@@ -119,6 +119,32 @@ inside the holes cluster A's span reads through. No tag maps to them; they are
|
||||
there so a mis-computed slice offset returns garbage instead of a zero that could
|
||||
match by luck. Every seed above is distinct and non-zero for the same reason.
|
||||
|
||||
### Codec gate seeds (offsets 800+, `S7_1500CodecTests`, #632/#638)
|
||||
|
||||
DB1 is 2048 bytes. Seeds use the `hex` seed type (raw big-endian bytes) for WSTRING/DTL/undecodable
|
||||
headers. All distinct and non-zero. Writes go to the **unseeded** 1100..1199 scratch area
|
||||
(`S7_SIM_ALLOW_WRITES=1` required).
|
||||
|
||||
| Address | Type | Seed | Decodes to |
|
||||
|---|---|---|---|
|
||||
| `DB1.DBB800` | STRING max 32 | `Siemens S7` | `"Siemens S7"` |
|
||||
| `DB1.DBB840` | WSTRING max 8 | `0008 0005 0047 0072 00fc 00df 0065` | `"Grüße"` |
|
||||
| `DB1.DBB870` | CHAR | `0x51` | `"Q"` |
|
||||
| `DB1.DBB872` | WCHAR | `0x20AC` | `"€"` |
|
||||
| `DB1.DBB880` | DTL | `07e8 03 0f 06 0d 2d 38 2f075f4c` | 2024-03-15 13:45:56.7890123 |
|
||||
| `DB1.DBW900` | DATE | `11488` | 2021-06-15 |
|
||||
| `DB1.DBD904` | TOD | `49556789` | 49 556 789 ms (13:45:56.789) |
|
||||
| `DB1.DBD910` / `914` | TIME | `123456789` / `-86400123` | same, ms |
|
||||
| `DB1.DBW920` / `922` | S5TIME | `0x2789` / `0x0123` | 789 000 ms / 1 230 ms |
|
||||
| `DB1.DBW960` | DATE | `0xFFFF` | 2169-06-06 |
|
||||
| `DB1.DBB930` | DTL | all zero (unseeded) | BadOutOfRange (uninitialised) |
|
||||
| `DB1.DBW940` | S5TIME | `0x0A5F` | BadOutOfRange (non-BCD nibble) |
|
||||
| `DB1.DBD944` | TOD | `0xFFFFFFFF` | BadOutOfRange (past midnight) |
|
||||
| `DB1.DBB950` | WSTRING max 3 | `0003 0009` | BadOutOfRange (cur > max) |
|
||||
|
||||
snap7 does not validate STRING/WSTRING headers or TSAPs, so the TSAP-override theory proves the keys
|
||||
reach the wire and the connection still works — not TSAP semantics (hardware only).
|
||||
|
||||
### Timer / Counter (TM / CT) areas
|
||||
|
||||
`TM0` and `CT0` are 64 bytes each — **32 device cells**, since a timer is a 2-byte
|
||||
|
||||
+26
-3
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"_description": "S7-1500 profile — DB1 (1024 bytes) + MB (256 bytes) + TM/CT (64 bytes each) with well-known seeds at named offsets for the smoke + byte-order + string + Timer/Counter tests. Big-endian Siemens wire order throughout.",
|
||||
"_description": "S7-1500 profile — DB1 (2048 bytes) + MB (256 bytes) + TM/CT (64 bytes each) with well-known seeds at named offsets for the smoke + byte-order + string + Timer/Counter tests. Big-endian Siemens wire order throughout.",
|
||||
"areas": [
|
||||
{
|
||||
"area": "DB",
|
||||
"index": 1,
|
||||
"size": 1024,
|
||||
"size": 2048,
|
||||
"seeds": [
|
||||
{ "_desc": "DB1.DBW0 — read-back probe, S7Driver default ProbeAddress target is MW0; this shadows it",
|
||||
"offset": 0, "type": "u16", "value": 4242 },
|
||||
@@ -46,7 +46,30 @@
|
||||
{ "_desc": "DB1.DBW500 — isolated narrow scalar: its own single-member span, demoted by PrefersPerTagRead into a leftover that rides the multi-var pass",
|
||||
"offset": 500, "type": "u16", "value": 60001 },
|
||||
{ "_desc": "DB1.DBW600 — second isolated narrow scalar, likewise a multi-var leftover",
|
||||
"offset": 600, "type": "i16", "value": -32001 }
|
||||
"offset": 600, "type": "i16", "value": -32001 },
|
||||
|
||||
{ "_desc": "CODEC GATE (S7_1500CodecTests, #632/#638). Every seed from 800 to 969 is distinct and non-zero so a decode that returns zero/default can never pass. 800: STRING max 32 cur 10 'Siemens S7'",
|
||||
"offset": 800, "type": "ascii", "value": "Siemens S7", "max_len": 32 },
|
||||
{ "_desc": "DB1 @840 — WSTRING max 8 cur 5 'Grüße' (ü=00FC, ß=00DF) — non-ASCII proves UTF-16BE, not Latin-1",
|
||||
"offset": 840, "type": "hex", "value": "0008 0005 0047 0072 00fc 00df 0065" },
|
||||
{ "_desc": "DB1 @870 — CHAR 'Q'", "offset": 870, "type": "u8", "value": 81 },
|
||||
{ "_desc": "DB1 @872 — WCHAR U+20AC '€' (does not fit Latin-1)", "offset": 872, "type": "u16", "value": 8364 },
|
||||
{ "_desc": "DB1 @880 — DTL 2024-03-15 13:45:56.7890123 (weekday 6 = Friday, ns 789012300)",
|
||||
"offset": 880, "type": "hex", "value": "07e8 03 0f 06 0d 2d 38 2f075f4c" },
|
||||
{ "_desc": "DB1 @900 — DATE 2021-06-15 = 11488 days since 1990-01-01", "offset": 900, "type": "u16", "value": 11488 },
|
||||
{ "_desc": "DB1 @904 — TOD 13:45:56.789 = 49556789 ms", "offset": 904, "type": "u32", "value": 49556789 },
|
||||
{ "_desc": "DB1 @910 — TIME +123456789 ms", "offset": 910, "type": "i32", "value": 123456789 },
|
||||
{ "_desc": "DB1 @914 — TIME -86400123 ms (negative duration)", "offset": 914, "type": "i32", "value": -86400123 },
|
||||
{ "_desc": "DB1 @920 — S5TIME 0x2789: base 2 (x1 s), BCD 789 = 789000 ms", "offset": 920, "type": "u16", "value": 10121 },
|
||||
{ "_desc": "DB1 @922 — S5TIME 0x0123: base 0 (x10 ms), BCD 123 = 1230 ms", "offset": 922, "type": "u16", "value": 291 },
|
||||
|
||||
{ "_desc": "UNDECODABLE buffers — reads must surface BadOutOfRange, not a value. @930 DTL stays all-zero (uninitialised) on purpose. @940: S5TIME 0x0A5F has a non-BCD nibble",
|
||||
"offset": 940, "type": "u16", "value": 2655 },
|
||||
{ "_desc": "DB1 @944 — TOD 0xFFFFFFFF, past midnight", "offset": 944, "type": "u32", "value": 4294967295 },
|
||||
{ "_desc": "DB1 @950 — WSTRING max 3 cur 9 (current length exceeds reserved)",
|
||||
"offset": 950, "type": "hex", "value": "0003 0009" },
|
||||
{ "_desc": "DB1 @960 — DATE 0xFFFF = 65535 days = 2169-06-06, the far end of the DATE range (still a valid decode)",
|
||||
"offset": 960, "type": "u16", "value": 65535 }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -45,7 +45,7 @@ def seed_buffer(buf: bytearray, seeds: list[dict]) -> None:
|
||||
"""Poke seed values into the area buffer at declared byte offsets.
|
||||
|
||||
Each seed is {"offset": int, "type": str, "value": int|float|bool|str}
|
||||
where type ∈ {u8, i8, u16, i16, u32, i32, f32, bool, ascii}. Endianness is
|
||||
where type ∈ {u8, i8, u16, i16, u32, i32, f32, bool, ascii, hex}. Endianness is
|
||||
big-endian (Siemens wire format).
|
||||
"""
|
||||
for seed in seeds:
|
||||
@@ -73,6 +73,11 @@ def seed_buffer(buf: bytearray, seeds: list[dict]) -> None:
|
||||
buf[off] |= (1 << bit)
|
||||
else:
|
||||
buf[off] &= ~(1 << bit) & 0xFF
|
||||
elif t == "hex":
|
||||
# Raw bytes, big-endian wire order as written. Used for the multi-byte S7 types the
|
||||
# scalar seeds cannot express (WSTRING, DTL, ...): {"type": "hex", "value": "07e8030f..."}.
|
||||
raw = bytes.fromhex(str(v).replace(" ", ""))
|
||||
buf[off:off + len(raw)] = raw
|
||||
elif t == "ascii":
|
||||
# Siemens STRING type: byte 0 = max length, byte 1 = current length,
|
||||
# bytes 2+ = payload. Seeds supply the payload text; we fill max/cur.
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
||||
|
||||
/// <summary>
|
||||
/// #610 item 2 (S7-016): array write round trip against the python-snap7 S7-1500 profile. Skips
|
||||
/// cleanly when the sim is absent. Uses DB1 bytes 700+ (the profile's 1024-byte DB1 has no seed
|
||||
/// cleanly when the sim is absent. Uses DB1 bytes 700+ (the profile's 2048-byte DB1 has no seed
|
||||
/// there), so the writes cannot disturb another test's seeds. Writes also require
|
||||
/// <c>S7_SIM_ALLOW_WRITES=1</c>.
|
||||
/// </summary>
|
||||
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #632 / #638 live coverage: the S7 STRING / WSTRING / CHAR / WCHAR / DTL / DATE / TOD / TIME /
|
||||
/// S5TIME codecs against the python-snap7 S7-1500 profile, over real ISO-on-TCP + S7comm, plus the
|
||||
/// TSAP override keys (<c>LocalTsap</c> / <c>RemoteTsap</c> / <c>ConnectionType</c>) and the negotiated
|
||||
/// PDU size. Wide/date-time types are byte-anchored (DBB) by driver rule. Read seeds live at DB1 800..960 in <c>Docker/profiles/s7_1500.json</c> and are all
|
||||
/// distinct and non-zero, so a decode that returned zero/default cannot pass. Write tests use the
|
||||
/// unseeded DB1 1100..1199 scratch area and additionally need <c>S7_SIM_ALLOW_WRITES=1</c>. Everything
|
||||
/// skips cleanly when <c>S7_SIM_ENDPOINT</c> (default the shared Docker host) is unreachable.
|
||||
/// </summary>
|
||||
[Collection(Snap7ServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "S7_1500")]
|
||||
public sealed class S7_1500CodecTests(Snap7ServerFixture sim)
|
||||
{
|
||||
private const uint BadOutOfRange = 0x803C0000u;
|
||||
|
||||
private static S7DriverOptions Options(string host, int port, params S7TagDefinition[] tags) => new()
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
CpuType = S7CpuType.S71500,
|
||||
Rack = 0,
|
||||
Slot = 0,
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
RawTags = S7RawTags.Entries(tags),
|
||||
};
|
||||
|
||||
private static S7DriverOptions WithTsap(S7DriverOptions o, string? local, string? remote, S7ConnectionType? ct) => new()
|
||||
{
|
||||
Host = o.Host, Port = o.Port, CpuType = o.CpuType, Rack = o.Rack, Slot = o.Slot, Timeout = o.Timeout,
|
||||
Probe = o.Probe, RawTags = o.RawTags,
|
||||
LocalTsap = local, RemoteTsap = remote, ConnectionType = ct,
|
||||
};
|
||||
|
||||
private void RequireSim()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
}
|
||||
|
||||
private static void RequireWrites()
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable("S7_SIM_ALLOW_WRITES") != "1")
|
||||
Assert.Skip("Write test: set S7_SIM_ALLOW_WRITES=1 to allow writes to the endpoint under test (simulator only).");
|
||||
}
|
||||
|
||||
// ---- reads ----
|
||||
|
||||
/// <summary>Every codec decodes its seeded, distinct non-zero wire bytes through real S7comm.</summary>
|
||||
[Fact]
|
||||
public async Task Codecs_decode_seeded_bytes_through_real_S7comm()
|
||||
{
|
||||
RequireSim();
|
||||
var o = Options(sim.Host, sim.Port,
|
||||
new S7TagDefinition("Str", "DB1.DBB800", S7DataType.String, StringLength: 32),
|
||||
new S7TagDefinition("WStr", "DB1.DBB840", S7DataType.WString, StringLength: 8),
|
||||
new S7TagDefinition("Chr", "DB1.DBB870", S7DataType.Char),
|
||||
new S7TagDefinition("WChr", "DB1.DBB872", S7DataType.WChar),
|
||||
new S7TagDefinition("Dtl", "DB1.DBB880", S7DataType.Dtl),
|
||||
new S7TagDefinition("Date", "DB1.DBB900", S7DataType.Date),
|
||||
new S7TagDefinition("Tod", "DB1.DBB904", S7DataType.Tod),
|
||||
new S7TagDefinition("TimePos", "DB1.DBB910", S7DataType.Time),
|
||||
new S7TagDefinition("TimeNeg", "DB1.DBB914", S7DataType.Time),
|
||||
new S7TagDefinition("S5Sec", "DB1.DBB920", S7DataType.S5Time),
|
||||
new S7TagDefinition("S5Cs", "DB1.DBB922", S7DataType.S5Time),
|
||||
new S7TagDefinition("DateMax", "DB1.DBB960", S7DataType.Date));
|
||||
await using var drv = new S7Driver(o, "s7-codec-read");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var names = o.RawTags.Select(t => t.RawPath).ToArray();
|
||||
var r = await drv.ReadAsync(names, TestContext.Current.CancellationToken);
|
||||
r.Count.ShouldBe(names.Length);
|
||||
for (var i = 0; i < r.Count; i++) r[i].StatusCode.ShouldBe(0u, names[i]);
|
||||
|
||||
object? V(string n) => r[Array.IndexOf(names, n)].Value;
|
||||
V("Str").ShouldBe("Siemens S7");
|
||||
V("WStr").ShouldBe("Grüße");
|
||||
V("Chr").ShouldBe("Q");
|
||||
V("WChr").ShouldBe("€");
|
||||
V("Dtl").ShouldBe(new DateTime(2024, 3, 15, 13, 45, 56).AddTicks(7_890_123));
|
||||
V("Date").ShouldBe(new DateTime(2021, 6, 15));
|
||||
V("Tod").ShouldBe(49_556_789);
|
||||
V("TimePos").ShouldBe(123_456_789);
|
||||
V("TimeNeg").ShouldBe(-86_400_123);
|
||||
V("S5Sec").ShouldBe(789_000);
|
||||
V("S5Cs").ShouldBe(1_230);
|
||||
V("DateMax").ShouldBe(new DateTime(2169, 6, 6));
|
||||
}
|
||||
|
||||
/// <summary>Undecodable buffers surface BadOutOfRange per tag, leave siblings Good, and do not degrade health.</summary>
|
||||
[Fact]
|
||||
public async Task Undecodable_buffers_read_BadOutOfRange_without_degrading_health()
|
||||
{
|
||||
RequireSim();
|
||||
var o = Options(sim.Host, sim.Port,
|
||||
new S7TagDefinition("DtlUninit", "DB1.DBB930", S7DataType.Dtl),
|
||||
new S7TagDefinition("S5NonBcd", "DB1.DBB940", S7DataType.S5Time),
|
||||
new S7TagDefinition("TodPastMidnight", "DB1.DBB944", S7DataType.Tod),
|
||||
new S7TagDefinition("WStrCurOverMax", "DB1.DBB950", S7DataType.WString, StringLength: 3),
|
||||
new S7TagDefinition("Good", "DB1.DBB904", S7DataType.Tod));
|
||||
await using var drv = new S7Driver(o, "s7-codec-bad");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var r = await drv.ReadAsync(
|
||||
["DtlUninit", "S5NonBcd", "TodPastMidnight", "WStrCurOverMax", "Good"],
|
||||
TestContext.Current.CancellationToken);
|
||||
for (var i = 0; i < 4; i++) r[i].StatusCode.ShouldBe(BadOutOfRange, $"tag #{i}");
|
||||
r[4].StatusCode.ShouldBe(0u);
|
||||
r[4].Value.ShouldBe(49_556_789);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a PLC buffer the codec refuses is data, not a comms fault");
|
||||
}
|
||||
|
||||
// ---- writes ----
|
||||
|
||||
private static readonly S7TagDefinition[] ScratchTags =
|
||||
[
|
||||
new("Str", "DB1.DBB1100", S7DataType.String, StringLength: 8),
|
||||
new("WStr", "DB1.DBB1120", S7DataType.WString, StringLength: 6),
|
||||
new("Chr", "DB1.DBB1150", S7DataType.Char),
|
||||
new("WChr", "DB1.DBB1152", S7DataType.WChar),
|
||||
new("Dtl", "DB1.DBB1160", S7DataType.Dtl),
|
||||
new("Date", "DB1.DBB1180", S7DataType.Date),
|
||||
new("Tod", "DB1.DBB1184", S7DataType.Tod),
|
||||
new("Time", "DB1.DBB1188", S7DataType.Time),
|
||||
new("S5", "DB1.DBB1192", S7DataType.S5Time),
|
||||
];
|
||||
|
||||
/// <summary>Write each codec's value, read it back through the driver (distinct non-zero values).</summary>
|
||||
[Fact]
|
||||
public async Task Codec_writes_round_trip_through_real_S7comm()
|
||||
{
|
||||
RequireSim();
|
||||
RequireWrites();
|
||||
await using var drv = new S7Driver(Options(sim.Host, sim.Port, ScratchTags), "s7-codec-write");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var dtl = new DateTime(2031, 11, 30, 23, 59, 58).AddTicks(1_234_567);
|
||||
(string Tag, object Value)[] cases =
|
||||
[
|
||||
("Str", "Plant-7"),
|
||||
("WStr", "Ünï€9"),
|
||||
("Chr", "Z"),
|
||||
("WChr", "ß"),
|
||||
("Dtl", dtl),
|
||||
("Date", new DateTime(2077, 2, 28)),
|
||||
("Tod", 3_723_456),
|
||||
("Time", -7_654_321),
|
||||
("S5", 456_000),
|
||||
];
|
||||
var res = await drv.WriteAsync(
|
||||
[.. cases.Select(c => new WriteRequest(c.Tag, c.Value))], TestContext.Current.CancellationToken);
|
||||
for (var i = 0; i < res.Count; i++) res[i].StatusCode.ShouldBe(0u, cases[i].Tag);
|
||||
|
||||
var read = await drv.ReadAsync([.. cases.Select(c => c.Tag)], TestContext.Current.CancellationToken);
|
||||
for (var i = 0; i < read.Count; i++)
|
||||
{
|
||||
read[i].StatusCode.ShouldBe(0u, cases[i].Tag);
|
||||
read[i].Value.ShouldBe(cases[i].Value, cases[i].Tag);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unrepresentable writes are refused BadOutOfRange with nothing sent (the previously written value
|
||||
/// survives) and without degrading driver health. Covers classic STRING + DT (fixed in this change:
|
||||
/// they used to map to BadCommunicationError) and every #632 codec.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Unrepresentable_writes_are_BadOutOfRange_and_leave_the_PLC_untouched()
|
||||
{
|
||||
RequireSim();
|
||||
RequireWrites();
|
||||
var tags = ScratchTags.Append(new S7TagDefinition("Dt", "DB1.DBB1196", S7DataType.DateTime)).ToArray();
|
||||
await using var drv = new S7Driver(Options(sim.Host, sim.Port, tags), "s7-codec-range");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Known-good baseline so "nothing was sent" is observable as an unchanged value.
|
||||
var good = new Dictionary<string, object>
|
||||
{
|
||||
["Str"] = "Keep-8ch", ["WStr"] = "Keep-6", ["Chr"] = "K", ["WChr"] = "W",
|
||||
["Dtl"] = new DateTime(2025, 5, 6, 7, 8, 9), ["Date"] = new DateTime(2000, 1, 2),
|
||||
["Tod"] = 1_000_001, ["Time"] = 2_000_002, ["S5"] = 3_000,
|
||||
};
|
||||
(await drv.WriteAsync([.. good.Select(g => new WriteRequest(g.Key, g.Value))], ct))
|
||||
.ShouldAllBe(x => x.StatusCode == 0u);
|
||||
|
||||
(string Tag, object Bad)[] bad =
|
||||
[
|
||||
("Str", "123456789"), // 9 > max 8
|
||||
("WStr", "1234567"), // 7 > max 6
|
||||
("Chr", "€"), // U+20AC does not fit Latin-1
|
||||
("Dtl", new DateTime(1960, 1, 1)), // before 1970
|
||||
("Date", new DateTime(1980, 1, 1)), // before 1990
|
||||
("Tod", 86_400_000), // past midnight
|
||||
("Time", 3_000_000_000L), // > Int32
|
||||
("S5", 15), // fits no S5TIME time base exactly
|
||||
("Dt", new DateTime(1980, 1, 1)), // outside the 1990-2089 DT range
|
||||
];
|
||||
var res = await drv.WriteAsync([.. bad.Select(b => new WriteRequest(b.Tag, b.Bad))], ct);
|
||||
for (var i = 0; i < res.Count; i++) res[i].StatusCode.ShouldBe(BadOutOfRange, bad[i].Tag);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
|
||||
var back = await drv.ReadAsync([.. good.Keys], ct);
|
||||
var keys = good.Keys.ToArray();
|
||||
for (var i = 0; i < back.Count; i++)
|
||||
{
|
||||
back[i].StatusCode.ShouldBe(0u, keys[i]);
|
||||
back[i].Value.ShouldBe(good[keys[i]], $"{keys[i]} must be unchanged after the refused write");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- TSAP override + PDU ----
|
||||
|
||||
/// <summary>
|
||||
/// The TSAP override keys reach the wire. snap7's server accepts any TSAP, so this proves the
|
||||
/// override is applied and the connection still works — NOT TSAP semantics (a real CPU rejects a
|
||||
/// wrong TSAP; only hardware can prove the values themselves).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("01.00", "03.01", null)]
|
||||
[InlineData("4D.57", null, null)]
|
||||
[InlineData(null, "02.01", null)]
|
||||
[InlineData(null, null, S7ConnectionType.PG)]
|
||||
[InlineData(null, null, S7ConnectionType.OP)]
|
||||
public async Task TSAP_override_keys_connect_and_read_through_real_S7comm(
|
||||
string? local, string? remote, S7ConnectionType? connType)
|
||||
{
|
||||
RequireSim();
|
||||
var o = WithTsap(
|
||||
Options(sim.Host, sim.Port, new S7TagDefinition("Probe", "DB1.DBW0", S7DataType.UInt16)),
|
||||
local, remote, connType);
|
||||
await using var drv = new S7Driver(o, "s7-tsap");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var r = await drv.ReadAsync(["Probe"], TestContext.Current.CancellationToken);
|
||||
r[0].StatusCode.ShouldBe(0u);
|
||||
Convert.ToInt32(r[0].Value).ShouldBe(S7_1500Profile.ProbeSeedValue);
|
||||
}
|
||||
|
||||
/// <summary>The negotiated PDU size is a real S7comm value after connect and 0 after shutdown.</summary>
|
||||
[Fact]
|
||||
public async Task Negotiated_PDU_size_is_observed_after_connect()
|
||||
{
|
||||
RequireSim();
|
||||
var o = Options(sim.Host, sim.Port, new S7TagDefinition("Probe", "DB1.DBW0", S7DataType.UInt16));
|
||||
await using var drv = new S7Driver(o, "s7-pdu");
|
||||
drv.NegotiatedPduSize.ShouldBe(0, "no connect yet");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
// S7comm PDUs are 240 (S7-300) / 480 (S7-1200/1500 default) / up to 960 bytes.
|
||||
drv.NegotiatedPduSize.ShouldBeInRange(240, 960);
|
||||
drv.GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe(drv.NegotiatedPduSize);
|
||||
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
drv.NegotiatedPduSize.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -299,7 +299,7 @@ public sealed class S7ScalarBlockTests
|
||||
{
|
||||
var tag = Tag(S7DataType.String, stringLength: 5);
|
||||
// 6 chars into a 5-char reserved field — S7.Net throws ArgumentException.
|
||||
Should.Throw<ArgumentException>(() => S7Driver.EncodeScalarBlock(tag, "ABCDEF"));
|
||||
Should.Throw<S7ValueRangeException>(() => S7Driver.EncodeScalarBlock(tag, "ABCDEF"));
|
||||
}
|
||||
|
||||
// ── String round-trip identity (encode → decode) ──────────────────────────────────────
|
||||
@@ -376,7 +376,7 @@ public sealed class S7ScalarBlockTests
|
||||
{
|
||||
var tag = Tag(S7DataType.DateTime);
|
||||
var value = new System.DateTime(year, 1, 1, 0, 0, 0);
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => S7Driver.EncodeScalarBlock(tag, value));
|
||||
Should.Throw<S7ValueRangeException>(() => S7Driver.EncodeScalarBlock(tag, value));
|
||||
}
|
||||
|
||||
// ── DateTime round-trip identity (encode → decode) ────────────────────────────────────
|
||||
|
||||
@@ -140,13 +140,16 @@ public sealed class S7TsapAndPduTests
|
||||
using var drv = new S7Driver(opts, "s7-pdu", factory);
|
||||
drv.NegotiatedPduSize.ShouldBe(0);
|
||||
drv.GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe(0);
|
||||
((IDriverDiagnostics)drv).GetDiagnostics().ShouldBeEmpty("0 is 'not negotiated yet', not a size (#640)");
|
||||
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
drv.NegotiatedPduSize.ShouldBe(480);
|
||||
drv.GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe(480);
|
||||
((IDriverDiagnostics)drv).GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe("480");
|
||||
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
drv.NegotiatedPduSize.ShouldBe(0);
|
||||
((IDriverDiagnostics)drv).GetDiagnostics().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -407,6 +407,35 @@ public sealed class S7ValueCodecTests
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #632 live-coverage finding: classic STRING and DT writes use S7.Net's own encoders, which throw
|
||||
/// <see cref="ArgumentException"/> / <see cref="ArgumentOutOfRangeException"/> for an unrepresentable
|
||||
/// value. That fell into the generic catch (BadCommunicationError + Degraded health) while the
|
||||
/// sibling WSTRING/DATE codecs return BadOutOfRange — a client error must not look like a comms fault.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Classic_STRING_and_DT_unrepresentable_writes_are_BadOutOfRange_without_degrading_health()
|
||||
{
|
||||
var plc = new MemoryS7Plc();
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
RawTags = S7RawTags.Entries(
|
||||
new S7TagDefinition("Str", "DB1.DBB0", S7DataType.String, StringLength: 4),
|
||||
new S7TagDefinition("Dt", "DB1.DBB20", S7DataType.DateTime)),
|
||||
};
|
||||
using var drv = new S7Driver(opts, "s7-classic-range", new MemoryS7PlcFactory(plc));
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
(await drv.WriteAsync([new WriteRequest("Str", "abcd")], ct))[0].StatusCode.ShouldBe(0u);
|
||||
(await drv.WriteAsync([new WriteRequest("Str", "abcde")], ct))[0].StatusCode.ShouldBe(0x803C0000u);
|
||||
(await drv.WriteAsync([new WriteRequest("Dt", new DateTime(1980, 1, 1))], ct))[0].StatusCode.ShouldBe(0x803C0000u);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
(await drv.ReadAsync(["Str"], ct))[0].Value.ShouldBe("abcd");
|
||||
}
|
||||
|
||||
// ---- fakes ----
|
||||
|
||||
private sealed class MemoryS7PlcFactory(MemoryS7Plc plc) : IS7PlcFactory
|
||||
|
||||
@@ -272,6 +272,24 @@ public sealed class HostsDriverViewTests
|
||||
row.SkippedTags!.SkippedCount.ShouldBe(20);
|
||||
}
|
||||
|
||||
/// <summary>#640: diagnostics ride the snapshot onto the row, ordered by key; none renders nothing.</summary>
|
||||
[Fact]
|
||||
public void Diagnostics_reach_the_row_ordered_and_absent_diagnostics_yield_no_items()
|
||||
{
|
||||
var with = Snap("MAIN", "s7-a") with
|
||||
{
|
||||
Diagnostics = new Dictionary<string, string> { ["Z"] = "2", ["S7.NegotiatedPduSize"] = "480" },
|
||||
};
|
||||
var without = Snap("MAIN", "s7-b");
|
||||
|
||||
var rows = HostsDriverView.Build([with, without], nodes: null, instances: null).Single().Drivers;
|
||||
|
||||
var a = rows.Single(r => r.DriverInstanceId == "s7-a");
|
||||
a.DiagnosticItems.Select(k => k.Key).ShouldBe(["S7.NegotiatedPduSize", "Z"]);
|
||||
a.DiagnosticItems[0].Value.ShouldBe("480");
|
||||
rows.Single(r => r.DriverInstanceId == "s7-b").DiagnosticItems.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A truncated sample must SAY it is truncated. Without the note a capped list reads as the whole
|
||||
/// list, and an operator who fixes the twenty named tags believes the job is done.
|
||||
|
||||
@@ -28,10 +28,10 @@ public sealed class AbLegacyFamilyAddressGateTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_warns_about_indirect_addresses_which_never_read()
|
||||
public void Gate_does_not_warn_about_indirect_addresses_which_the_driver_resolves()
|
||||
{
|
||||
EquipmentTagConfigInspector.Inspect(DriverTypeNames.AbLegacy, Cfg("N7:[N7:0]"), "{\"PlcFamily\":\"Slc500\"}")
|
||||
.ShouldContain(x => x.Contains("indirect"));
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Reflection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #642 guard: <see cref="NativeAlarmModel.SupportsNativeAlarms"/> is a hand-maintained set; it must equal
|
||||
/// the drivers whose concrete types implement <see cref="IAlarmSource"/>. Discovery is by reflection over the
|
||||
/// driver assemblies deployed beside this test; an assembly's driver type is the <see cref="DriverTypeNames"/>
|
||||
/// constant whose FIELD NAME equals the assembly suffix (Driver.Galaxy -> Galaxy = "GalaxyMxGateway").
|
||||
/// </summary>
|
||||
public sealed class NativeAlarmDriverSetGuardTests
|
||||
{
|
||||
private static readonly string[] NonDriverSuffixes = [".Contracts", ".Addressing", ".Cli", ".Historian.Gateway"];
|
||||
|
||||
[Fact]
|
||||
public void SupportsNativeAlarms_matches_the_drivers_implementing_IAlarmSource()
|
||||
{
|
||||
var binDir = Path.GetDirectoryName(typeof(NativeAlarmDriverSetGuardTests).Assembly.Location)!;
|
||||
var constants = typeof(DriverTypeNames).GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
|
||||
.ToDictionary(f => f.Name, f => (string)f.GetRawConstantValue()!, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
const string prefix = "ZB.MOM.WW.OtOpcUa.Driver.";
|
||||
var implementers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var scanned = 0;
|
||||
foreach (var dll in Directory.GetFiles(binDir, prefix + "*.dll"))
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(dll);
|
||||
if (NonDriverSuffixes.Any(s => name.EndsWith(s, StringComparison.Ordinal))) { continue; }
|
||||
Type[] types;
|
||||
try { types = Assembly.LoadFrom(dll).GetTypes(); }
|
||||
catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(t => t is not null).ToArray()!; }
|
||||
scanned++;
|
||||
if (!types.Any(t => t.IsClass && !t.IsAbstract && typeof(IAlarmSource).IsAssignableFrom(t))) { continue; }
|
||||
var suffix = name[prefix.Length..];
|
||||
constants.TryGetValue(suffix, out var typeName).ShouldBeTrue($"no DriverTypeNames constant named '{suffix}' for {name}");
|
||||
implementers.Add(typeName!);
|
||||
}
|
||||
|
||||
scanned.ShouldBeGreaterThan(5, "driver assemblies were not deployed to the test output; the guard would be vacuous");
|
||||
implementers.Count.ShouldBeGreaterThan(0);
|
||||
|
||||
foreach (var typeName in constants.Values)
|
||||
{
|
||||
NativeAlarmModel.SupportsNativeAlarms(typeName).ShouldBe(implementers.Contains(typeName),
|
||||
$"NativeAlarmModel.AlarmDriverTypes disagrees with IAlarmSource for driver type '{typeName}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>Gitea #642 — the shared NativeAlarmEditor's pure-model behaviour: remove-on-off, composition
|
||||
/// with the per-driver editors' models, validation parity with the deploy gate, driver-type gating.</summary>
|
||||
public sealed class NativeAlarmEditorModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToJson_with_IsAlarm_false_removes_the_alarm_key_and_keeps_the_rest()
|
||||
{
|
||||
var m = NativeAlarmModel.FromJson("""{"FullName":"X.Y","alarm":{"alarmType":"LimitAlarm","severity":300,"extra":1}}""");
|
||||
m.IsAlarm = false;
|
||||
|
||||
var o = JsonNode.Parse(m.ToJson())!.AsObject();
|
||||
o.ContainsKey("alarm").ShouldBeFalse();
|
||||
o["FullName"]!.GetValue<string>().ShouldBe("X.Y");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Toggle_off_then_on_again_starts_from_defaults()
|
||||
{
|
||||
var off = NativeAlarmModel.Edit("""{"alarm":{"severity":300}}""", m => m.IsAlarm = false);
|
||||
var on = NativeAlarmModel.Edit(off, m => m.IsAlarm = true);
|
||||
var back = NativeAlarmModel.FromJson(on);
|
||||
back.IsAlarm.ShouldBeTrue();
|
||||
back.Severity.ShouldBe(500);
|
||||
back.AlarmType.ShouldBe("AlarmCondition");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Edit_preserves_unknown_keys_at_both_levels()
|
||||
{
|
||||
var json = NativeAlarmModel.Edit("""{"FullName":"A","zz":1,"alarm":{"alarmType":"OffNormalAlarm","custom":"k"}}""",
|
||||
m => m.Severity = 900);
|
||||
var o = JsonNode.Parse(json)!.AsObject();
|
||||
o["zz"]!.GetValue<int>().ShouldBe(1);
|
||||
o["alarm"]!["custom"]!.GetValue<string>().ShouldBe("k");
|
||||
o["alarm"]!["severity"]!.GetValue<int>().ShouldBe(900);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Alarm_and_mtconnect_driver_editor_compose_in_either_order()
|
||||
{
|
||||
const string start = """{"fullName":"Xact"}""";
|
||||
|
||||
// alarm first, then driver editor (driver model re-parses the alarm-bearing JSON)
|
||||
var a1 = NativeAlarmModel.Edit(start, m => { m.IsAlarm = true; m.Severity = 800; });
|
||||
var d1 = MTConnectTagConfigModel.FromJson(a1);
|
||||
d1.FullName = "Yact";
|
||||
var r1 = d1.ToJson();
|
||||
|
||||
// driver editor first, then alarm
|
||||
var d2 = MTConnectTagConfigModel.FromJson(start);
|
||||
d2.FullName = "Yact";
|
||||
var r2 = NativeAlarmModel.Edit(d2.ToJson(), m => { m.IsAlarm = true; m.Severity = 800; });
|
||||
|
||||
foreach (var r in new[] { r1, r2 })
|
||||
{
|
||||
var o = JsonNode.Parse(r)!.AsObject();
|
||||
o["fullName"]!.GetValue<string>().ShouldBe("Yact");
|
||||
o["alarm"]!["severity"]!.GetValue<int>().ShouldBe(800);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Modbus")]
|
||||
[InlineData("S7")]
|
||||
[InlineData("AbCip")]
|
||||
[InlineData("AbLegacy")]
|
||||
[InlineData("TwinCAT")]
|
||||
[InlineData("FOCAS")]
|
||||
[InlineData("OpcUaClient")]
|
||||
[InlineData("Sql")]
|
||||
[InlineData("Mqtt")]
|
||||
[InlineData("MTConnect")]
|
||||
public void Every_typed_driver_model_preserves_an_alarm_object(string driverType)
|
||||
{
|
||||
const string json = """{"alarm":{"alarmType":"LimitAlarm","severity":123}}""";
|
||||
var round = TagConfigRoundTrip.Through(driverType, json);
|
||||
NativeAlarmModel.FromJson(round).Severity.ShouldBe(123);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_is_not_stricter_than_the_deploy_gate()
|
||||
{
|
||||
// The gate/parser accepts any severity (clamped 1..1000 at publish) and any alarmType string.
|
||||
var m = NativeAlarmModel.FromJson("""{"alarm":{"alarmType":"Whatever","severity":5000}}""");
|
||||
m.Validate().ShouldBeNull();
|
||||
m.Warnings().ShouldNotBeEmpty(); // advisory only
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SupportsNativeAlarms_covers_exactly_the_IAlarmSource_drivers()
|
||||
{
|
||||
foreach (var t in new[] { DriverTypeNames.MTConnect, DriverTypeNames.AbCip, DriverTypeNames.FOCAS,
|
||||
DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy })
|
||||
NativeAlarmModel.SupportsNativeAlarms(t).ShouldBeTrue(t);
|
||||
foreach (var t in new[] { DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.Mqtt, null })
|
||||
NativeAlarmModel.SupportsNativeAlarms(t).ShouldBeFalse(t);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>Test helper: FromJson→ToJson through the driver's typed TagConfig model.</summary>
|
||||
internal static class TagConfigRoundTrip
|
||||
{
|
||||
public static string Through(string driverType, string json) => driverType switch
|
||||
{
|
||||
DriverTypeNames.Modbus => ModbusTagConfigModel.FromJson(json).ToJson(),
|
||||
DriverTypeNames.S7 => S7TagConfigModel.FromJson(json).ToJson(),
|
||||
DriverTypeNames.AbCip => AbCipTagConfigModel.FromJson(json).ToJson(),
|
||||
DriverTypeNames.AbLegacy => AbLegacyTagConfigModel.FromJson(json).ToJson(),
|
||||
DriverTypeNames.TwinCAT => TwinCATTagConfigModel.FromJson(json).ToJson(),
|
||||
DriverTypeNames.FOCAS => FocasTagConfigModel.FromJson(json).ToJson(),
|
||||
DriverTypeNames.OpcUaClient => OpcUaClientTagConfigModel.FromJson(json).ToJson(),
|
||||
DriverTypeNames.Sql => SqlTagConfigModel.FromJson(json).ToJson(),
|
||||
DriverTypeNames.Mqtt => MqttTagConfigModel.FromJson(json).ToJson(),
|
||||
DriverTypeNames.MTConnect => MTConnectTagConfigModel.FromJson(json).ToJson(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(driverType)),
|
||||
};
|
||||
}
|
||||
@@ -40,6 +40,10 @@
|
||||
<!-- #576: the AB form tests deserialise the saved JSON through the drivers' own factory parse. -->
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
using System.Text;
|
||||
using Akka.Actor;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Coordinators;
|
||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #601 — central's side of the honest deploy ack. A node that applied a deployment but whose
|
||||
/// address-space apply was DEGRADED acks <see cref="ApplyAckOutcome.Applied"/> with
|
||||
/// <see cref="ApplyAck.Degraded"/>. The deployment still SEALS (the revision is what the node now runs, and
|
||||
/// "latest sealed" drives drift + the publish actor's fallback) but never looks clean: the node's
|
||||
/// <c>NodeDeploymentState</c> row keeps status Applied with its degraded reason in <c>FailureReason</c>,
|
||||
/// and the <c>Deployment</c> row's <c>FailureReason</c> names the degraded nodes. An ack from an old node
|
||||
/// (no degraded field) is handled exactly as before.
|
||||
/// </summary>
|
||||
public sealed class ConfigPublishCoordinatorDegradedAckTests : ControlPlaneActorTestBase
|
||||
{
|
||||
private static readonly RevisionHash TestRevision = RevisionHash.Parse(new string('c', 64));
|
||||
private static readonly TimeSpan NoTimeout = TimeSpan.FromMinutes(5);
|
||||
private const string NodeA = "site-a-1:4053";
|
||||
private const string NodeB = "site-a-2:4053";
|
||||
|
||||
[Fact]
|
||||
public void A_degraded_ack_seals_but_is_recorded_as_degraded_on_the_node_and_the_deployment()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDispatchingDeployment(dbFactory);
|
||||
SeedNodes(dbFactory, NodeA, NodeB);
|
||||
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
|
||||
actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));
|
||||
AwaitStatus(dbFactory, DeploymentStatus.AwaitingApplyAcks);
|
||||
|
||||
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse(NodeA), ApplyAckOutcome.Applied, null,
|
||||
CorrelationId.NewId()));
|
||||
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse(NodeB), ApplyAckOutcome.Applied, null,
|
||||
CorrelationId.NewId(), Degraded: true, DegradedReason: "address-space apply degraded (failedNodes=3)"));
|
||||
|
||||
AwaitStatus(dbFactory, DeploymentStatus.Sealed);
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
|
||||
var deployment = db.Deployments.Single();
|
||||
deployment.FailureReason.ShouldNotBeNull();
|
||||
deployment.FailureReason.ShouldContain("degraded");
|
||||
deployment.FailureReason.ShouldContain(NodeB);
|
||||
deployment.FailureReason.ShouldNotContain(NodeA);
|
||||
|
||||
var rowB = db.NodeDeploymentStates.Single(s => s.NodeId == NodeB);
|
||||
rowB.Status.ShouldBe(NodeDeploymentStatus.Applied);
|
||||
rowB.FailureReason.ShouldNotBeNull();
|
||||
rowB.FailureReason.ShouldStartWith(ConfigPublishCoordinator.DegradedReasonPrefix);
|
||||
rowB.FailureReason.ShouldContain("failedNodes=3");
|
||||
ConfigPublishCoordinator.IsDegraded(rowB.Status, rowB.FailureReason).ShouldBeTrue();
|
||||
|
||||
var rowA = db.NodeDeploymentStates.Single(s => s.NodeId == NodeA);
|
||||
rowA.Status.ShouldBe(NodeDeploymentStatus.Applied);
|
||||
rowA.FailureReason.ShouldBeNull();
|
||||
ConfigPublishCoordinator.IsDegraded(rowA.Status, rowA.FailureReason).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Old_node_acks_without_the_degraded_field_seal_clean_exactly_as_before()
|
||||
{
|
||||
// The five-argument constructor IS the pre-#601 ack shape: Degraded defaults to false.
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDispatchingDeployment(dbFactory);
|
||||
SeedNodes(dbFactory, NodeA, NodeB);
|
||||
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
|
||||
actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));
|
||||
AwaitStatus(dbFactory, DeploymentStatus.AwaitingApplyAcks);
|
||||
|
||||
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse(NodeA), ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
|
||||
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse(NodeB), ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
|
||||
|
||||
AwaitStatus(dbFactory, DeploymentStatus.Sealed);
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
db.Deployments.Single().FailureReason.ShouldBeNull();
|
||||
db.NodeDeploymentStates.All(s => s.Status == NodeDeploymentStatus.Applied && s.FailureReason == null)
|
||||
.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_degraded_ack_alongside_a_failed_one_is_still_PartiallyFailed()
|
||||
{
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDispatchingDeployment(dbFactory);
|
||||
SeedNodes(dbFactory, NodeA, NodeB);
|
||||
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
|
||||
actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId()));
|
||||
AwaitStatus(dbFactory, DeploymentStatus.AwaitingApplyAcks);
|
||||
|
||||
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse(NodeA), ApplyAckOutcome.Failed, "boom", CorrelationId.NewId()));
|
||||
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse(NodeB), ApplyAckOutcome.Applied, null,
|
||||
CorrelationId.NewId(), Degraded: true, DegradedReason: "x"));
|
||||
|
||||
AwaitStatus(dbFactory, DeploymentStatus.PartiallyFailed);
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
db.NodeDeploymentStates.Single(s => s.NodeId == NodeA).FailureReason.ShouldBe("boom");
|
||||
db.NodeDeploymentStates.Single(s => s.NodeId == NodeA).Status.ShouldBe(NodeDeploymentStatus.Failed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_recovered_coordinator_remembers_a_degraded_ack_that_landed_before_failover()
|
||||
{
|
||||
// Node B's degraded ack was persisted by a previous singleton instance; this instance recovers the
|
||||
// in-flight deployment from the DB and must still seal it as degraded when node A acks clean.
|
||||
var dbFactory = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDispatchingDeployment(dbFactory, DeploymentStatus.AwaitingApplyAcks);
|
||||
SeedNodes(dbFactory, NodeA, NodeB);
|
||||
using (var db = dbFactory.CreateDbContext())
|
||||
{
|
||||
db.NodeDeploymentStates.Add(new NodeDeploymentState
|
||||
{
|
||||
NodeId = NodeA, DeploymentId = deploymentId.Value, Status = NodeDeploymentStatus.Applying,
|
||||
});
|
||||
db.NodeDeploymentStates.Add(new NodeDeploymentState
|
||||
{
|
||||
NodeId = NodeB, DeploymentId = deploymentId.Value, Status = NodeDeploymentStatus.Applied,
|
||||
FailureReason = ConfigPublishCoordinator.DegradedReasonPrefix + "failedNodes=1",
|
||||
AppliedAtUtc = DateTime.UtcNow,
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
|
||||
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse(NodeA), ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
|
||||
|
||||
AwaitStatus(dbFactory, DeploymentStatus.Sealed);
|
||||
using var verify = dbFactory.CreateDbContext();
|
||||
var reason = verify.Deployments.Single().FailureReason;
|
||||
reason.ShouldNotBeNull();
|
||||
reason.ShouldContain(NodeB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_old_nodes_DPS_ack_without_the_degraded_fields_deserializes_as_a_clean_ack()
|
||||
{
|
||||
// The DPS ack path rides Akka's default serializer for the record. Serialize a NEW ack, strip the two
|
||||
// #601 members from the payload (what an old node sends), and decode it with this build.
|
||||
var ack = new ApplyAck(DeploymentId.NewId(), NodeId.Parse(NodeA), ApplyAckOutcome.Applied, null,
|
||||
CorrelationId.NewId(), Degraded: true, DegradedReason: "will be stripped");
|
||||
var serializer = Sys.Serialization.FindSerializerFor(ack);
|
||||
var json = JObject.Parse(Encoding.UTF8.GetString(serializer.ToBinary(ack)));
|
||||
json.Remove("Degraded").ShouldBeTrue();
|
||||
json.Remove("DegradedReason").ShouldBeTrue();
|
||||
|
||||
var back = (ApplyAck)serializer.FromBinary(
|
||||
Encoding.UTF8.GetBytes(json.ToString(Newtonsoft.Json.Formatting.None)), typeof(ApplyAck));
|
||||
|
||||
back.Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||
back.Degraded.ShouldBeFalse();
|
||||
back.DegradedReason.ShouldBeNull();
|
||||
back.DeploymentId.ShouldBe(ack.DeploymentId);
|
||||
|
||||
// ...and the new fields survive the same serializer when present.
|
||||
var full = (ApplyAck)serializer.FromBinary(serializer.ToBinary(ack), typeof(ApplyAck));
|
||||
full.ShouldBe(ack);
|
||||
}
|
||||
|
||||
private void AwaitStatus(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, DeploymentStatus expected) =>
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
db.Deployments.Single().Status.ShouldBe(expected);
|
||||
}, duration: TimeSpan.FromSeconds(5));
|
||||
|
||||
private static void SeedNodes(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, params string[] nodes)
|
||||
{
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
db.ServerClusters.Add(new ServerCluster
|
||||
{
|
||||
ClusterId = "SITE-A",
|
||||
Name = "Site A",
|
||||
Enterprise = "zb",
|
||||
Site = "site-a",
|
||||
NodeCount = 2,
|
||||
RedundancyMode = RedundancyMode.Warm,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
foreach (var nodeId in nodes)
|
||||
{
|
||||
db.ClusterNodes.Add(new ClusterNode
|
||||
{
|
||||
NodeId = nodeId,
|
||||
ClusterId = "SITE-A",
|
||||
Host = nodeId.Split(':')[0],
|
||||
ApplicationUri = $"urn:OtOpcUa:{nodeId}",
|
||||
Enabled = true,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
}
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static DeploymentId SeedDispatchingDeployment(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
DeploymentStatus status = DeploymentStatus.Dispatching)
|
||||
{
|
||||
var id = DeploymentId.NewId();
|
||||
using var db = dbFactory.CreateDbContext();
|
||||
db.Deployments.Add(new Deployment
|
||||
{
|
||||
DeploymentId = id.Value,
|
||||
RevisionHash = TestRevision.Value,
|
||||
Status = status,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
db.SaveChanges();
|
||||
return id;
|
||||
}
|
||||
}
|
||||
+37
@@ -276,6 +276,43 @@ public sealed class TelemetryProtoMapCentralTests
|
||||
TelemetryProtoMapCentral.ToHealth(absentSkips).SkippedTags.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #640: the diagnostics bag round-trips with the null vs empty distinction intact, and a pre-#640
|
||||
/// node (field never set) decodes to null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ToHealth_diagnostics_round_trip_with_the_null_vs_empty_distinction_intact()
|
||||
{
|
||||
var populated = new DriverHealth
|
||||
{
|
||||
ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy",
|
||||
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
|
||||
HasDiagnostics = true,
|
||||
Diagnostics = { ["S7.NegotiatedPduSize"] = "480", ["Other"] = "x" },
|
||||
};
|
||||
var mapped = TelemetryProtoMapCentral.ToHealth(populated).Diagnostics;
|
||||
mapped.ShouldNotBeNull();
|
||||
mapped!.Count.ShouldBe(2);
|
||||
mapped["S7.NegotiatedPduSize"].ShouldBe("480");
|
||||
|
||||
var empty = new DriverHealth
|
||||
{
|
||||
ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy",
|
||||
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
|
||||
HasDiagnostics = true,
|
||||
};
|
||||
var mappedEmpty = TelemetryProtoMapCentral.ToHealth(empty).Diagnostics;
|
||||
mappedEmpty.ShouldNotBeNull();
|
||||
mappedEmpty!.ShouldBeEmpty();
|
||||
|
||||
var absent = new DriverHealth
|
||||
{
|
||||
ClusterId = "c1", DriverInstanceId = "d1", State = "Healthy",
|
||||
PublishedUtc = Timestamp.FromDateTime(SampleUtc),
|
||||
};
|
||||
TelemetryProtoMapCentral.ToHealth(absent).Diagnostics.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An unparseable host state degrades to <see cref="HostState.Unknown"/> rather than throwing. A node
|
||||
/// running a newer build that added an enum member must not be able to kill central's telemetry
|
||||
|
||||
@@ -248,6 +248,33 @@ public sealed class TelemetryStreamGrpcServiceTests
|
||||
.DriverHealth.HasHostStatuses.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Node side of the #640 diagnostics carry: presence flag + entries written; a capability-less driver
|
||||
/// (null) leaves the flag clear, and an empty bag still sets it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ToProto_health_writes_the_diagnostics_presence_flag_and_entries()
|
||||
{
|
||||
var withDiag = new DriverHealthChanged(
|
||||
"cluster-a", "drv-1", "Healthy", null, null, 0, DateTime.UtcNow,
|
||||
Diagnostics: new Dictionary<string, string> { ["S7.NegotiatedPduSize"] = "480" });
|
||||
var evt = TelemetryProtoMapNode.ToProto(new TelemetryItem.Health(withDiag), "c");
|
||||
evt.DriverHealth.HasDiagnostics.ShouldBeTrue();
|
||||
evt.DriverHealth.Diagnostics["S7.NegotiatedPduSize"].ShouldBe("480");
|
||||
|
||||
var empty = new DriverHealthChanged(
|
||||
"cluster-a", "drv-1", "Healthy", null, null, 0, DateTime.UtcNow,
|
||||
Diagnostics: new Dictionary<string, string>());
|
||||
var emptyEvt = TelemetryProtoMapNode.ToProto(new TelemetryItem.Health(empty), "c");
|
||||
emptyEvt.DriverHealth.HasDiagnostics.ShouldBeTrue();
|
||||
emptyEvt.DriverHealth.Diagnostics.ShouldBeEmpty();
|
||||
|
||||
var none = new DriverHealthChanged("cluster-a", "drv-1", "Healthy", null, null, 0, DateTime.UtcNow);
|
||||
var noneEvt = TelemetryProtoMapNode.ToProto(new TelemetryItem.Health(none), "c");
|
||||
noneEvt.DriverHealth.HasDiagnostics.ShouldBeFalse();
|
||||
noneEvt.DriverHealth.Diagnostics.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_disconnect_mid_stream_ends_cleanly_without_leaking_a_slot()
|
||||
{
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 over the wire — a full address-space rebuild that leaves the native alarm set unchanged must not
|
||||
/// leave an active condition reading inactive. A Server-object (<c>i=2253</c>) item, which outlives the rebuild
|
||||
/// (#586), receives exactly ONE condition event carrying the restored state — and an acknowledgement a client
|
||||
/// applied before the rebuild survives it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The rebuild is driven exactly as <c>OpcUaPublishActor</c> drives one: <c>RebuildAddressSpace</c>, the
|
||||
/// re-materialise + notifier wiring, then <c>CompleteAddressSpaceApply</c>. The count is read after a later
|
||||
/// sentinel transition arrives on the same item, so a duplicate would already be queued ahead of it.
|
||||
/// The native ack router echoes nothing back, like MTConnect and FOCAS: the acknowledgement lives only on the node.
|
||||
/// </remarks>
|
||||
public sealed class NativeAlarmRebuildCarryOverWireTests
|
||||
{
|
||||
private const NodePermissions All =
|
||||
NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead
|
||||
| NodePermissions.AlarmAcknowledge | NodePermissions.MethodCall;
|
||||
|
||||
private static AlarmConditionSnapshot Fault(string message) => new(
|
||||
Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: 800, Message: message);
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task A_rebuild_with_an_unchanged_alarm_set_reports_the_restored_state_once(bool clientAcked)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync($"Rebuild641{clientAcked}", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
||||
rig.Nm.NativeAlarmAckRouter = _ => { }; // a driver whose ack is a no-op: nothing echoes back
|
||||
rig.MaterialiseNative();
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var serverItem = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Project(native: true, Fault("fault"));
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "fault"));
|
||||
var expectedBefore = 1;
|
||||
|
||||
if (clientAcked)
|
||||
{
|
||||
await session.CallAsync(
|
||||
ConditionNodeId(session, native: true), MethodIds.AcknowledgeableConditionType_Acknowledge, ct,
|
||||
events.LatestEventId(serverItem, "fault"), new LocalizedText("ack"));
|
||||
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(serverItem, "fault").Count == 2);
|
||||
expectedBefore = 2;
|
||||
}
|
||||
|
||||
// The deploy: a rebuild whose composition carries the same alarm (e.g. one UnsTagReference added).
|
||||
rig.Sink.RebuildAddressSpace();
|
||||
rig.MaterialiseNative();
|
||||
rig.Sink.CompleteAddressSpaceApply();
|
||||
|
||||
rig.Project(native: true, Fault("sentinel") with { Acknowledged = clientAcked });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "sentinel"));
|
||||
|
||||
var faultEvents = events.ConditionEvents(serverItem, "fault");
|
||||
faultEvents.Count.ShouldBe(expectedBefore + 1, "the pre-rebuild event(s) plus exactly one restored event");
|
||||
var restored = faultEvents[^1];
|
||||
restored[RecordedEvents.ActiveField].Value.ShouldBe(true);
|
||||
restored[RecordedEvents.AckedField].Value.ShouldBe(clientAcked);
|
||||
restored[RecordedEvents.RetainField].Value.ShouldBe(true);
|
||||
restored[RecordedEvents.SeverityField].Value.ShouldBe((ushort)800);
|
||||
|
||||
// And a ConditionRefresh-style read of the live node agrees.
|
||||
var active = await session.ReadValueAsync(
|
||||
new NodeId($"{RawAlarmPath}/ActiveState/Id", ConditionNodeId(session, native: true).NamespaceIndex), ct);
|
||||
active.Value.ShouldBe(true);
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>Falsification control: a NORMAL native condition produces no event across the rebuild.</summary>
|
||||
[Fact]
|
||||
public async Task A_rebuild_reports_nothing_for_a_normal_condition()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await StartAsync("Rebuild641Normal", encrypted: false, ct);
|
||||
try
|
||||
{
|
||||
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", All));
|
||||
rig.MaterialiseNative();
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var events = new RecordedEvents();
|
||||
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
||||
var serverItem = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
|
||||
rig.Project(native: true, Fault("cleared") with { Active = false, Acknowledged = true });
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "cleared"));
|
||||
|
||||
rig.Sink.RebuildAddressSpace();
|
||||
rig.MaterialiseNative();
|
||||
rig.Sink.CompleteAddressSpaceApply();
|
||||
|
||||
rig.Project(native: true, Fault("sentinel"));
|
||||
await RecordedEvents.WaitUntilAsync(() => events.Has(serverItem, "sentinel"));
|
||||
|
||||
events.ConditionEvents(serverItem, "cleared").Count.ShouldBe(1);
|
||||
events.ConditionEvents(serverItem, "temp_hi").ShouldBeEmpty("the fresh node is not reported");
|
||||
|
||||
await subscription.DeleteAsync(true, ct);
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// HistoryRead leg of the OpcUaClient driver's wire coverage (Gitea #622): the real
|
||||
/// <see cref="OpcUaClientDriver"/> issues Raw / Processed / AtTime reads over loopback to the in-process
|
||||
/// OtOpcUa server, whose node manager dispatches to an injected deterministic
|
||||
/// <see cref="IHistorianDataSource"/> (<see cref="FakeHistorian"/>) behind a historized raw variable.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class OpcUaClientDriverHistoryWireTests
|
||||
{
|
||||
private const string HistRef = "Sim/dev1/Hist";
|
||||
private const string HistTag = "Plant.Hist.Series";
|
||||
private const uint Uncertain = 0x40000000u;
|
||||
|
||||
private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static DateTime At(int seconds) => T0.AddSeconds(seconds);
|
||||
|
||||
/// <summary>Five samples, one per second, value = 1..5; the third is Uncertain.</summary>
|
||||
private static readonly DataValueSnapshot[] Series =
|
||||
[
|
||||
new(1.0, 0u, At(0), At(0)),
|
||||
new(2.0, 0u, At(1), At(1)),
|
||||
new(3.0, Uncertain, At(2), At(2)),
|
||||
new(4.0, 0u, At(3), At(3)),
|
||||
new(5.0, 0u, At(4), At(4)),
|
||||
];
|
||||
|
||||
private sealed class FakeHistorian : IHistorianDataSource
|
||||
{
|
||||
public List<string> Calls { get; } = [];
|
||||
|
||||
public Task<HistoryReadResult> ReadRawAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken ct)
|
||||
{
|
||||
lock (Calls) Calls.Add($"raw:{fullReference}");
|
||||
var hits = Series.Where(s => s.SourceTimestampUtc >= startUtc && s.SourceTimestampUtc < endUtc);
|
||||
if (maxValuesPerNode > 0) hits = hits.Take((int)maxValuesPerNode);
|
||||
return Task.FromResult(new HistoryReadResult([.. hits], null));
|
||||
}
|
||||
|
||||
public Task<HistoryReadResult> ReadProcessedAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||
HistoryAggregateType aggregate, CancellationToken ct)
|
||||
{
|
||||
lock (Calls) Calls.Add($"processed:{fullReference}:{aggregate}");
|
||||
if (aggregate != HistoryAggregateType.Average)
|
||||
throw new HistoryAggregateNotSupportedException(aggregate);
|
||||
|
||||
var buckets = new List<DataValueSnapshot>();
|
||||
for (var b = startUtc; b < endUtc; b += interval)
|
||||
{
|
||||
var inBucket = Series.Where(s => s.SourceTimestampUtc >= b && s.SourceTimestampUtc < b + interval).ToList();
|
||||
if (inBucket.Count == 0) continue;
|
||||
buckets.Add(new DataValueSnapshot(inBucket.Average(s => (double)s.Value!), 0u, b, b));
|
||||
}
|
||||
return Task.FromResult(new HistoryReadResult(buckets, null));
|
||||
}
|
||||
|
||||
public Task<HistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken ct)
|
||||
{
|
||||
lock (Calls) Calls.Add($"attime:{fullReference}");
|
||||
// Step semantics: the last sample at or before the requested instant; BadNoData before the first.
|
||||
var result = timestampsUtc.Select(t =>
|
||||
{
|
||||
var prior = Series.LastOrDefault(s => s.SourceTimestampUtc <= t);
|
||||
return prior is null
|
||||
? new DataValueSnapshot(null, 0x809B0000u /* BadNoData */, t, t)
|
||||
: new DataValueSnapshot(prior.Value, prior.StatusCode, t, t);
|
||||
}).ToList();
|
||||
return Task.FromResult(new HistoryReadResult(result, null));
|
||||
}
|
||||
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken ct) =>
|
||||
Task.FromResult(new HistoricalEventsResult([], null));
|
||||
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() =>
|
||||
new(0, 0, 0, 0, null, null, null, false, false, null, null, []);
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private static async Task<(ServerEventRig Rig, FakeHistorian Fake, OpcUaClientDriver Drv)> StartAsync(
|
||||
string name, CancellationToken ct)
|
||||
{
|
||||
var rig = await OpcUaClientDriverWireTests.StartRigAsync(name, ct);
|
||||
var fake = new FakeHistorian();
|
||||
rig.Nm.HistorianDataSource = fake;
|
||||
var id = rig.EnsureRawVariable("Hist", "Double", writable: false, historianTagname: HistTag);
|
||||
OpcUaClientDriverWireTests.Publish(rig, id, 5.0);
|
||||
|
||||
var drv = OpcUaClientDriverWireTests.NewDriver(
|
||||
rig, name,
|
||||
extraTags: [OpcUaClientDriverWireTests.Tag(HistRef, ServerEventRig.RawNsu(id))]);
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
return (rig, fake, drv);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadRaw_returns_the_historians_samples_with_values_timestamps_and_statuses()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (rig, fake, drv) = await StartAsync("hist-raw", ct);
|
||||
await using var _r = rig;
|
||||
await using var _d = drv;
|
||||
|
||||
var result = await drv.ReadRawAsync(HistRef, At(0), At(10), 0, ct);
|
||||
|
||||
result.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
result.Samples.Select(s => s.SourceTimestampUtc).ShouldBe(Series.Select(s => s.SourceTimestampUtc));
|
||||
result.Samples.Select(s => s.StatusCode).ShouldBe([0u, 0u, Uncertain, 0u, 0u]);
|
||||
result.ContinuationPoint.ShouldBeNull("the whole window fit in one page");
|
||||
lock (fake.Calls) fake.Calls.ShouldContain($"raw:{HistTag}", "the read must reach the historian under the node's tagname");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadRaw_honours_the_window_and_pages_with_a_continuation_point_when_the_cap_is_hit()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (rig, _, drv) = await StartAsync("hist-raw-page", ct);
|
||||
await using var _r = rig;
|
||||
await using var _d = drv;
|
||||
|
||||
// Window [1s, 4s) holds samples 2,3,4 (end exclusive).
|
||||
var window = await drv.ReadRawAsync(HistRef, At(1), At(4), 0, ct);
|
||||
window.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([2.0, 3.0, 4.0]);
|
||||
|
||||
// A cap of 3 against 5 available: first three samples and a continuation point.
|
||||
var paged = await drv.ReadRawAsync(HistRef, At(0), At(10), 3, ct);
|
||||
paged.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([1.0, 2.0, 3.0]);
|
||||
paged.ContinuationPoint.ShouldNotBeNull("a full page must carry a continuation point");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAtTime_returns_one_sample_per_requested_instant_with_the_prior_value()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (rig, fake, drv) = await StartAsync("hist-attime", ct);
|
||||
await using var _r = rig;
|
||||
await using var _d = drv;
|
||||
|
||||
var result = await drv.ReadAtTimeAsync(HistRef, [At(-1), At(1), At(2), At(3).AddMilliseconds(500)], ct);
|
||||
|
||||
result.Samples.Count.ShouldBe(4);
|
||||
result.Samples[0].StatusCode.ShouldNotBe(0u, "before the first sample there is no value");
|
||||
Convert.ToDouble(result.Samples[1].Value).ShouldBe(2.0);
|
||||
result.Samples[1].StatusCode.ShouldBe(0u);
|
||||
Convert.ToDouble(result.Samples[2].Value).ShouldBe(3.0);
|
||||
result.Samples[2].StatusCode.ShouldBe(Uncertain);
|
||||
Convert.ToDouble(result.Samples[3].Value).ShouldBe(4.0);
|
||||
result.Samples.Skip(1).Select(s => s.SourceTimestampUtc)
|
||||
.ShouldBe([At(1), At(2), At(3).AddMilliseconds(500)]);
|
||||
lock (fake.Calls) fake.Calls.ShouldContain($"attime:{HistTag}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadProcessed_serves_a_supported_aggregate_and_returns_nothing_for_an_unsupported_one()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (rig, fake, drv) = await StartAsync("hist-processed", ct);
|
||||
await using var _r = rig;
|
||||
await using var _d = drv;
|
||||
|
||||
// Average over 2s buckets across [0s,6s): {1,2}=1.5, {3,4}=3.5, {5}=5.
|
||||
var avg = await drv.ReadProcessedAsync(HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Average, ct);
|
||||
avg.Samples.Select(s => Convert.ToDouble(s.Value)).ShouldBe([1.5, 3.5, 5.0]);
|
||||
avg.Samples.Select(s => s.SourceTimestampUtc).ShouldBe([At(0), At(2), At(4)]);
|
||||
|
||||
// The backend refuses Count: the server maps that to BadAggregateNotSupported for the node, which the
|
||||
// driver surfaces as no samples (it does not expose the per-node status). The Average above is the
|
||||
// positive control that the same path serves data, and the call must have REACHED the backend.
|
||||
var count = await drv.ReadProcessedAsync(HistRef, At(0), At(6), TimeSpan.FromSeconds(2), HistoryAggregateType.Count, ct);
|
||||
count.Samples.ShouldBeEmpty();
|
||||
lock (fake.Calls) fake.Calls.ShouldContain($"processed:{HistTag}:Count");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_read_of_a_non_historized_node_returns_nothing_and_never_reaches_the_historian()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var (rig, fake, drv) = await StartAsync("hist-none", ct);
|
||||
await using var _r = rig;
|
||||
await using var _d = drv;
|
||||
|
||||
var result = await drv.ReadRawAsync(OpcUaClientDriverWireTests.SpeedRef, At(0), At(10), 0, ct);
|
||||
|
||||
result.Samples.ShouldBeEmpty();
|
||||
// Positive control: the historized sibling does reach the historian on the same session.
|
||||
(await drv.ReadRawAsync(HistRef, At(0), At(10), 0, ct)).Samples.Count.ShouldBe(5);
|
||||
lock (fake.Calls) fake.Calls.Count.ShouldBe(1, "only the historized read dispatches");
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Reconnect + resume leg of the OpcUaClient driver's wire coverage (Gitea #622): the in-process server is
|
||||
/// stopped and a fresh one is booted on the SAME port / application URI / PKI store — a process restart as the
|
||||
/// driver sees it, with an empty address space the test then re-creates. The driver must detect the loss,
|
||||
/// rebuild a session, and its existing subscription must deliver again with no caller action.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class OpcUaClientDriverReconnectWireTests
|
||||
{
|
||||
private static async Task WaitAsync(Func<bool> condition, string because, TimeSpan? timeout = null)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(60));
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (condition()) return;
|
||||
await Task.Delay(50);
|
||||
}
|
||||
condition().ShouldBeTrue(because);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task After_a_server_restart_the_driver_reconnects_and_the_subscription_resumes_delivering()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var rig = await OpcUaClientDriverWireTests.StartRigAsync("drv-reconnect", ct);
|
||||
await using var _rig = rig;
|
||||
OpcUaClientDriverWireTests.Publish(rig, OpcUaClientDriverWireTests.SpeedId, 10.0f);
|
||||
|
||||
await using var drv = OpcUaClientDriverWireTests.NewDriver(
|
||||
rig, "wire-reconnect", reconnectPeriod: TimeSpan.FromSeconds(1));
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
|
||||
var seen = new List<float>();
|
||||
drv.OnDataChange += (_, e) =>
|
||||
{
|
||||
if (e.FullReference == OpcUaClientDriverWireTests.SpeedRef && e.Snapshot.StatusCode == 0u)
|
||||
lock (seen) seen.Add(Convert.ToSingle(e.Snapshot.Value));
|
||||
};
|
||||
var states = new List<HostState>();
|
||||
drv.OnHostStatusChanged += (_, e) => { lock (states) states.Add(e.NewState); };
|
||||
|
||||
await drv.SubscribeAsync([OpcUaClientDriverWireTests.SpeedRef], TimeSpan.FromMilliseconds(100), ct);
|
||||
await WaitAsync(() => { lock (seen) return seen.Contains(10.0f); }, "initial value must arrive before the outage");
|
||||
|
||||
// Outage: the loss must be DETECTED (host leaves Running) while the server is genuinely down.
|
||||
await rig.RestartAsync(ct, whileDown: async () =>
|
||||
{
|
||||
await WaitAsync(
|
||||
() => { lock (states) return states.Any(s => s != HostState.Running); },
|
||||
"the driver must notice the server is gone", TimeSpan.FromSeconds(30));
|
||||
});
|
||||
|
||||
// The new server is empty: redeploy the nodes and publish a value no earlier delivery could have carried.
|
||||
rig.EnsureRawVariable("Speed", "Float", writable: true);
|
||||
rig.EnsureRawVariable("Counter", "Int32", writable: false);
|
||||
OpcUaClientDriverWireTests.Publish(rig, OpcUaClientDriverWireTests.SpeedId, 77.0f);
|
||||
|
||||
await WaitAsync(() => drv.GetHostStatuses().Single().State == HostState.Running,
|
||||
"the driver must reconnect after the server is back");
|
||||
await WaitAsync(() => { lock (seen) return seen.Contains(77.0f); },
|
||||
"the pre-outage subscription must resume and deliver the post-restart value");
|
||||
|
||||
// Ongoing delivery, not just a one-off initial value.
|
||||
OpcUaClientDriverWireTests.Publish(rig, OpcUaClientDriverWireTests.SpeedId, 88.0f);
|
||||
await WaitAsync(() => { lock (seen) return seen.Contains(88.0f); },
|
||||
"a later change must also reach the resumed subscription");
|
||||
|
||||
// And the data plane works for plain reads on the new session.
|
||||
var back = await drv.ReadAsync([OpcUaClientDriverWireTests.SpeedRef], ct);
|
||||
back[0].StatusCode.ShouldBe(0u);
|
||||
Convert.ToSingle(back[0].Value).ShouldBe(88.0f);
|
||||
}
|
||||
}
|
||||
+76
-12
@@ -13,19 +13,17 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
/// fixture" scaffolds in <c>Driver.OpcUaClient.Tests</c> (Gitea #622). No opc-plc and no Docker: every test
|
||||
/// here runs a real secure-channel + session + subscription exchange over loopback. The driver authenticates
|
||||
/// with the rig's API-key credential so writes carry the <c>WriteOperate</c> role.
|
||||
/// <para>
|
||||
/// HistoryRead is NOT covered here (deferred): the server-side leg needs an <c>IHistorianDataSource</c>
|
||||
/// stub plus a historized-variable rig seam, and the driver's passthrough shapes are unit-tested already.
|
||||
/// </para>
|
||||
/// HistoryRead lives in <see cref="OpcUaClientDriverHistoryWireTests"/>; reconnect/resume in
|
||||
/// <see cref="OpcUaClientDriverReconnectWireTests"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class OpcUaClientDriverWireTests
|
||||
{
|
||||
private const string SpeedRef = "Sim/dev1/Speed";
|
||||
private const string RoRef = "Sim/dev1/Counter";
|
||||
internal const string SpeedRef = "Sim/dev1/Speed";
|
||||
internal const string RoRef = "Sim/dev1/Counter";
|
||||
private const string MissingRef = "Sim/dev1/Missing";
|
||||
private const string SpeedId = ServerEventRig.RawDeviceFolder + "/Speed";
|
||||
private const string CounterId = ServerEventRig.RawDeviceFolder + "/Counter";
|
||||
internal const string SpeedId = ServerEventRig.RawDeviceFolder + "/Speed";
|
||||
internal const string CounterId = ServerEventRig.RawDeviceFolder + "/Counter";
|
||||
|
||||
private sealed class RecordingWriteGateway(bool succeed) : IOpcUaNodeWriteGateway
|
||||
{
|
||||
@@ -38,10 +36,12 @@ public sealed class OpcUaClientDriverWireTests
|
||||
}
|
||||
}
|
||||
|
||||
private static RawTagEntry Tag(string rawPath, string nodeId) =>
|
||||
internal static RawTagEntry Tag(string rawPath, string nodeId) =>
|
||||
new(rawPath, JsonSerializer.Serialize(new { nodeId }), WriteIdempotent: true);
|
||||
|
||||
private static OpcUaClientDriver NewDriver(ServerEventRig rig, string id, string? browseRoot = null) =>
|
||||
internal static OpcUaClientDriver NewDriver(
|
||||
ServerEventRig rig, string id, string? browseRoot = null, IReadOnlyList<RawTagEntry>? extraTags = null,
|
||||
TimeSpan? reconnectPeriod = null) =>
|
||||
new(new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrl = rig.EndpointUrl,
|
||||
@@ -50,6 +50,7 @@ public sealed class OpcUaClientDriverWireTests
|
||||
Tag(SpeedRef, ServerEventRig.RawNsu(SpeedId)),
|
||||
Tag(RoRef, ServerEventRig.RawNsu(CounterId)),
|
||||
Tag(MissingRef, ServerEventRig.RawNsu($"{ServerEventRig.RawDeviceFolder}/NoSuchNode")),
|
||||
.. extraTags ?? [],
|
||||
],
|
||||
SecurityPolicy = OpcUaSecurityPolicy.None,
|
||||
SecurityMode = OpcUaSecurityMode.None,
|
||||
@@ -62,9 +63,10 @@ public sealed class OpcUaClientDriverWireTests
|
||||
SessionTimeout = TimeSpan.FromSeconds(30),
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(1),
|
||||
BrowseRoot = browseRoot,
|
||||
ReconnectPeriod = reconnectPeriod ?? TimeSpan.FromSeconds(5),
|
||||
}, id);
|
||||
|
||||
private static async Task<ServerEventRig> StartRigAsync(string name, CancellationToken ct)
|
||||
internal static async Task<ServerEventRig> StartRigAsync(string name, CancellationToken ct)
|
||||
{
|
||||
var rig = await ServerEventRig.StartAsync(name, encrypted: false, ct);
|
||||
rig.EnsureRawVariable("Speed", "Float", writable: true);
|
||||
@@ -72,7 +74,7 @@ public sealed class OpcUaClientDriverWireTests
|
||||
return rig;
|
||||
}
|
||||
|
||||
private static void Publish(ServerEventRig rig, string id, object value) =>
|
||||
internal static void Publish(ServerEventRig rig, string id, object value) =>
|
||||
rig.Sink.WriteValue(id, value, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
|
||||
/// <summary>Reads round-trip through the real wire; a node the server lacks does not read Good.</summary>
|
||||
@@ -253,6 +255,68 @@ public sealed class OpcUaClientDriverWireTests
|
||||
"the writable variable and the read-only one must classify differently");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #619 — ConditionRefresh over the wire: a condition already active+unacked BEFORE the driver subscribes is
|
||||
/// reported (Raise, keyed by the RawPath) right after subscribe, not only on its next transition. A second
|
||||
/// subscribe of the same set refreshes again and must emit nothing (no re-raise); a positive control then
|
||||
/// proves the feed is still live by acknowledging upstream and seeing exactly the Acknowledge arrive.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_condition_already_active_before_subscribe_is_reported_by_ConditionRefresh()
|
||||
{
|
||||
const string AlarmRef = "Sim/dev1/TempHi";
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await StartRigAsync("drv-alarm-refresh", ct);
|
||||
rig.MaterialiseNative();
|
||||
rig.Project(native: true, new AlarmConditionSnapshot(Active: true, Acknowledged: false, Confirmed: true,
|
||||
Enabled: true, AlarmShelvingKind.Unshelved, 800, "already hot"));
|
||||
|
||||
await using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrl = rig.EndpointUrl,
|
||||
RawTags = [Tag(AlarmRef, ServerEventRig.RawNsu(ServerEventRig.RawAlarmPath))],
|
||||
SecurityPolicy = OpcUaSecurityPolicy.None,
|
||||
SecurityMode = OpcUaSecurityMode.None,
|
||||
AuthType = OpcUaAuthType.Username,
|
||||
Username = "apikey",
|
||||
Password = ServerEventRig.KeyPassword,
|
||||
AutoAcceptCertificates = true,
|
||||
TargetNamespaceKind = OpcUaTargetNamespaceKind.SystemPlatform,
|
||||
Timeout = TimeSpan.FromSeconds(10),
|
||||
SessionTimeout = TimeSpan.FromSeconds(30),
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(1),
|
||||
}, "wire-alarm-refresh");
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
|
||||
var seen = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (seen) seen.Add(e); };
|
||||
AlarmEventArgs[] Seen() { lock (seen) return [.. seen]; }
|
||||
|
||||
var first = await drv.SubscribeAlarmsAsync([AlarmRef], ct);
|
||||
await RecordedEvents.WaitUntilAsync(() => Seen().Any(e => e.Kind == AlarmTransitionKind.Raise));
|
||||
|
||||
var raise = Seen().Single(e => e.Kind == AlarmTransitionKind.Raise);
|
||||
raise.ConditionId.ShouldBe(AlarmRef);
|
||||
raise.Message.ShouldBe("already hot");
|
||||
raise.OpcUaSeverity.ShouldBe(800);
|
||||
Seen().ShouldAllBe(e => e.ConditionId == AlarmRef, "refresh markers must never surface as alarms");
|
||||
|
||||
// Same set again, released first exactly as DriverInstanceActor does → a fresh subscription and another
|
||||
// ConditionRefresh; the identical state must not re-raise.
|
||||
var before = Seen().Length;
|
||||
await drv.UnsubscribeAlarmsAsync(first, ct);
|
||||
await drv.SubscribeAlarmsAsync([AlarmRef], ct);
|
||||
await Task.Delay(1500, ct); // let the second refresh's events land before the control transition
|
||||
|
||||
// Positive control: an upstream acknowledge arrives as exactly one Acknowledge, after which nothing else.
|
||||
rig.Project(native: true, new AlarmConditionSnapshot(Active: true, Acknowledged: true, Confirmed: true,
|
||||
Enabled: true, AlarmShelvingKind.Unshelved, 800, "already hot"));
|
||||
await RecordedEvents.WaitUntilAsync(() => Seen().Any(e => e.Kind == AlarmTransitionKind.Acknowledge));
|
||||
await Task.Delay(500, ct);
|
||||
Seen().Skip(before).Select(e => e.Kind).ShouldBe([AlarmTransitionKind.Acknowledge],
|
||||
"the second refresh re-delivered an unchanged state and must have emitted nothing");
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
|
||||
|
||||
@@ -38,6 +38,7 @@ internal sealed class ServerEventRig : IAsyncDisposable
|
||||
private OpcUaApplicationHost _host = null!;
|
||||
private OtOpcUaSdkServer _server = null!;
|
||||
private int _port;
|
||||
private OpcUaApplicationHostOptions _hostOptions = null!;
|
||||
|
||||
private ServerEventRig(string pkiRoot) => _pkiRoot = pkiRoot;
|
||||
|
||||
@@ -51,28 +52,44 @@ internal sealed class ServerEventRig : IAsyncDisposable
|
||||
{
|
||||
var rig = new ServerEventRig(Path.Combine(Path.GetTempPath(), $"otopcua-{name}-{Guid.NewGuid():N}"));
|
||||
rig._port = AllocateFreePort();
|
||||
rig._server = new OtOpcUaSdkServer();
|
||||
rig._host = new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = $"OtOpcUa.{name}",
|
||||
ApplicationUri = $"urn:OtOpcUa.{name}.{Guid.NewGuid():N}",
|
||||
OpcUaPort = rig._port,
|
||||
PublicHostname = "127.0.0.1",
|
||||
PkiStoreRoot = rig._pkiRoot,
|
||||
EnabledSecurityProfiles = encrypted
|
||||
? [OpcUaSecurityProfile.None, OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt]
|
||||
: [OpcUaSecurityProfile.None],
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance,
|
||||
new KeyPresentingAuthenticator());
|
||||
|
||||
await rig._host.StartAsync(rig._server, ct);
|
||||
rig.Sink = new SdkAddressSpaceSink(rig.Nm);
|
||||
rig._hostOptions = new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = $"OtOpcUa.{name}",
|
||||
ApplicationUri = $"urn:OtOpcUa.{name}.{Guid.NewGuid():N}",
|
||||
OpcUaPort = rig._port,
|
||||
PublicHostname = "127.0.0.1",
|
||||
PkiStoreRoot = rig._pkiRoot,
|
||||
EnabledSecurityProfiles = encrypted
|
||||
? [OpcUaSecurityProfile.None, OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt]
|
||||
: [OpcUaSecurityProfile.None],
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
};
|
||||
await rig.BootAsync(ct);
|
||||
return rig;
|
||||
}
|
||||
|
||||
private async Task BootAsync(CancellationToken ct)
|
||||
{
|
||||
_server = new OtOpcUaSdkServer();
|
||||
_host = new OpcUaApplicationHost(
|
||||
_hostOptions, NullLogger<OpcUaApplicationHost>.Instance, new KeyPresentingAuthenticator());
|
||||
await _host.StartAsync(_server, ct);
|
||||
Sink = new SdkAddressSpaceSink(Nm);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the server and boots a fresh one on the SAME port, application URI and PKI store (a process
|
||||
/// restart as a client sees it). The address space is EMPTY afterwards: the caller re-creates its nodes and
|
||||
/// re-publishes values, as a redeploy would. <paramref name="whileDown"/> runs once the old server is gone
|
||||
/// and before the new one binds, so a test can observe the outage.
|
||||
/// </summary>
|
||||
public async Task RestartAsync(CancellationToken ct, Func<Task>? whileDown = null)
|
||||
{
|
||||
await _host.DisposeAsync();
|
||||
if (whileDown is not null) await whileDown();
|
||||
await BootAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Wires the authorizer over the given keys and grants, enforcing or not.</summary>
|
||||
public DataPlaneAuthorityState Enforce(bool enabled, ApiKey[] keys, params NodeAcl[] grants) =>
|
||||
Enforce(enabled, keys, ScopePaths(), grants);
|
||||
@@ -148,11 +165,11 @@ internal sealed class ServerEventRig : IAsyncDisposable
|
||||
/// Adds a raw value variable under <see cref="RawDeviceFolder"/> (the folder is ensured), for tests that
|
||||
/// drive a real client against plain values rather than alarm conditions. Additive to the alarm helpers.
|
||||
/// </summary>
|
||||
public string EnsureRawVariable(string name, string dataType, bool writable)
|
||||
public string EnsureRawVariable(string name, string dataType, bool writable, string? historianTagname = null)
|
||||
{
|
||||
Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||
var id = $"{RawDeviceFolder}/{name}";
|
||||
Sink.EnsureVariable(id, RawDeviceFolder, name, dataType, writable, AddressSpaceRealm.Raw);
|
||||
Sink.EnsureVariable(id, RawDeviceFolder, name, dataType, writable, AddressSpaceRealm.Raw, historianTagname);
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 — a full <see cref="OtOpcUaNodeManager.RebuildAddressSpace"/> recreates every native condition
|
||||
/// inactive, and when the alarm-tag set is unchanged nothing tells the new node its state again (the driver
|
||||
/// does not re-subscribe, and Galaxy / OpcUaClient have nothing to replay anyway). The node manager therefore
|
||||
/// carries each NATIVE condition's live state across the rebuild: captured before the clear, restored when
|
||||
/// the same id is re-materialised native in the same apply, and reported as ONE condition event when the
|
||||
/// apply completes (<see cref="OtOpcUaNodeManager.CompleteAddressSpaceApply"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// "One event" is observed here through the condition's <c>EventId</c>, which every report stamps fresh.
|
||||
/// The exact count over the wire is pinned by <c>NativeAlarmRebuildCarryOverWireTests</c>.
|
||||
/// </remarks>
|
||||
public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable
|
||||
{
|
||||
private const string Device = "Plant/MTConnect/dev1";
|
||||
private const string Alarm = "Plant/MTConnect/dev1/Xtravel";
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-641-{Guid.NewGuid():N}");
|
||||
|
||||
private static AlarmConditionSnapshot Fault(bool acked) => new(
|
||||
Active: true, Acknowledged: acked, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: 800, Message: "Xtravel FAULT 1001",
|
||||
StatusCode: OpcUaStatus.Good);
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task An_active_unacked_native_condition_survives_a_rebuild_and_is_reported_once()
|
||||
{
|
||||
var (host, nm) = await BootAsync();
|
||||
var raisedAt = new DateTime(2026, 10, 8, 9, 0, 0, DateTimeKind.Utc);
|
||||
Materialise(nm, native: true);
|
||||
nm.WriteAlarmCondition(Alarm, Fault(acked: false), raisedAt, AddressSpaceRealm.Raw);
|
||||
|
||||
nm.RebuildAddressSpace();
|
||||
Materialise(nm, native: true);
|
||||
var restored = Condition(nm);
|
||||
var idBeforeComplete = (byte[]?)restored.EventId.Value?.Clone();
|
||||
|
||||
// State is back as soon as the node exists — a Read or a ConditionRefresh in between already sees it.
|
||||
restored.ActiveState.Id.Value.ShouldBeTrue();
|
||||
restored.AckedState.Id.Value.ShouldBeFalse();
|
||||
restored.Retain.Value.ShouldBeTrue();
|
||||
restored.Severity.Value.ShouldBe((ushort)800);
|
||||
restored.Message.Value.Text.ShouldBe("Xtravel FAULT 1001");
|
||||
restored.Quality.Value.Code.ShouldBe(StatusCodes.Good);
|
||||
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
// The report happens at the END of the apply (after the notifier wiring), stamped with the instant the
|
||||
// state came about, not the deploy time.
|
||||
restored.EventId.Value.ShouldNotBe(idBeforeComplete);
|
||||
restored.Time.Value.ShouldBe(raisedAt);
|
||||
|
||||
// ...and only once: a second completion with nothing pending reports nothing.
|
||||
var idAfterComplete = (byte[]?)restored.EventId.Value?.Clone();
|
||||
nm.CompleteAddressSpaceApply();
|
||||
restored.EventId.Value.ShouldBe(idAfterComplete);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The acknowledgement a CLIENT applied lives only on the node (the SDK applied it; no driver or projector
|
||||
/// saw it) — the reason the carry-over reads the node rather than any host-side cache.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task A_client_acknowledgement_survives_the_rebuild()
|
||||
{
|
||||
var (host, nm) = await BootAsync();
|
||||
Materialise(nm, native: true);
|
||||
nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
// What the SDK's Acknowledge handler does to the node; nothing else hears of it.
|
||||
Condition(nm).SetAcknowledgedState(nm.SystemContext, true);
|
||||
|
||||
nm.RebuildAddressSpace();
|
||||
Materialise(nm, native: true);
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
var restored = Condition(nm);
|
||||
restored.ActiveState.Id.Value.ShouldBeTrue();
|
||||
restored.AckedState.Id.Value.ShouldBeTrue();
|
||||
restored.Retain.Value.ShouldBeTrue();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 review — the comment a client left with its acknowledgement is condition state too (native
|
||||
/// conditions support AddComment), and an event the condition reported AFTER the restore (a client method
|
||||
/// call landing on an SDK thread before the apply completes) must not have its newer <c>Time</c> rolled
|
||||
/// back by the deferred restore report.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task The_comment_is_carried_and_a_newer_report_is_not_rolled_back()
|
||||
{
|
||||
var (host, nm) = await BootAsync();
|
||||
var raisedAt = new DateTime(2026, 10, 8, 9, 0, 0, DateTimeKind.Utc);
|
||||
Materialise(nm, native: true);
|
||||
nm.WriteAlarmCondition(Alarm, Fault(acked: false), raisedAt, AddressSpaceRealm.Raw);
|
||||
Condition(nm).Comment.Value = new LocalizedText("on it");
|
||||
|
||||
nm.RebuildAddressSpace();
|
||||
Materialise(nm, native: true);
|
||||
var later = raisedAt.AddMinutes(5);
|
||||
nm.WriteAlarmQuality(Alarm, StatusCodes.BadNoCommunication, later, AddressSpaceRealm.Raw); // reports at `later`
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
var restored = Condition(nm);
|
||||
restored.Comment.Value.Text.ShouldBe("on it");
|
||||
restored.Time.Value.ShouldBe(later, "the restore report must not stamp the condition back to the raise time");
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Falsification control: a condition in the no-event position comes back exactly as a fresh
|
||||
/// materialise builds it, and reports nothing.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task A_normal_native_condition_reports_nothing_and_keeps_the_fresh_state()
|
||||
{
|
||||
var (host, nm) = await BootAsync();
|
||||
Materialise(nm, native: true);
|
||||
nm.WriteAlarmCondition(Alarm, Fault(acked: false) with { Active = false, Acknowledged = true, Message = "cleared" },
|
||||
DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
|
||||
nm.RebuildAddressSpace();
|
||||
Materialise(nm, native: true);
|
||||
var fresh = Condition(nm);
|
||||
var idBefore = (byte[]?)fresh.EventId.Value?.Clone();
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
fresh.EventId.Value.ShouldBe(idBefore);
|
||||
fresh.ActiveState.Id.Value.ShouldBeFalse();
|
||||
fresh.AckedState.Id.Value.ShouldBeTrue();
|
||||
fresh.Retain.Value.ShouldBeFalse();
|
||||
fresh.Message.Value.Text.ShouldBe("Xtravel", "a normal condition keeps the materialise's message");
|
||||
fresh.Severity.Value.ShouldBe((ushort)700, "and its authored severity");
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The source-data quality is carried for a NORMAL condition too, silently: quality is annotated only on a
|
||||
/// driver's connectivity edge, so without the carry a rebuild left every normal native condition reading
|
||||
/// <c>BadWaitingForInitialData</c> until the driver next flapped.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task A_normal_condition_keeps_its_quality_without_an_event()
|
||||
{
|
||||
var (host, nm) = await BootAsync();
|
||||
Materialise(nm, native: true);
|
||||
nm.WriteAlarmQuality(Alarm, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
|
||||
nm.RebuildAddressSpace();
|
||||
Materialise(nm, native: true);
|
||||
var fresh = Condition(nm);
|
||||
var idBefore = (byte[]?)fresh.EventId.Value?.Clone();
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
fresh.Quality.Value.Code.ShouldBe(StatusCodes.Good);
|
||||
fresh.EventId.Value.ShouldBe(idBefore);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>A condition the rebuild did NOT re-create has its captured state discarded when the apply
|
||||
/// completes, so a later deploy that adds the same RawPath back gets a fresh node, not a resurrection.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task A_condition_removed_by_the_rebuild_is_not_resurrected_by_a_later_add()
|
||||
{
|
||||
var (host, nm) = await BootAsync();
|
||||
Materialise(nm, native: true);
|
||||
nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
|
||||
nm.RebuildAddressSpace();
|
||||
nm.CompleteAddressSpaceApply(); // the alarm was removed: nothing re-materialised it
|
||||
|
||||
Materialise(nm, native: true); // a later, surgical re-add of the same RawPath
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
var readded = Condition(nm);
|
||||
readded.ActiveState.Id.Value.ShouldBeFalse();
|
||||
readded.AckedState.Id.Value.ShouldBeTrue();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>A native condition re-materialised as SCRIPTED under the same id is a different alarm: nothing
|
||||
/// is restored onto it (and the scripted engine's own #487 re-assert owns scripted state).</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task A_kind_swap_on_the_same_id_is_not_restored()
|
||||
{
|
||||
var (host, nm) = await BootAsync();
|
||||
Materialise(nm, native: true);
|
||||
nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
|
||||
nm.RebuildAddressSpace();
|
||||
Materialise(nm, native: false);
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
Condition(nm).ActiveState.Id.Value.ShouldBeFalse();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Scripted conditions are not captured — <c>ScriptedAlarmHostActor.ReassertConditionNodes</c>
|
||||
/// (#487) already re-projects them after a reload, and a second source would double-report.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Fact]
|
||||
public async Task A_scripted_condition_is_not_carried()
|
||||
{
|
||||
var (host, nm) = await BootAsync();
|
||||
Materialise(nm, native: false);
|
||||
nm.WriteAlarmCondition(Alarm, Fault(acked: false), DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
|
||||
nm.RebuildAddressSpace();
|
||||
Materialise(nm, native: false);
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
Condition(nm).ActiveState.Id.Value.ShouldBeFalse();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
private static void Materialise(OtOpcUaNodeManager nm, bool native)
|
||||
{
|
||||
nm.EnsureFolder(Device, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||
nm.MaterialiseAlarmCondition(Alarm, Device, "Xtravel", "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: native);
|
||||
}
|
||||
|
||||
private static Opc.Ua.AlarmConditionState Condition(OtOpcUaNodeManager nm) =>
|
||||
nm.TryGetAlarmCondition(Alarm, AddressSpaceRealm.Raw).ShouldNotBeNull();
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm)> BootAsync()
|
||||
{
|
||||
var host = new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.Gitea641Test",
|
||||
ApplicationUri = $"urn:OtOpcUa.Gitea641Test:{Guid.NewGuid():N}",
|
||||
OpcUaPort = AllocateFreePort(),
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<OpcUaApplicationHost>.Instance);
|
||||
|
||||
var server = new OtOpcUaSdkServer();
|
||||
await host.StartAsync(server, Ct);
|
||||
return (host, server.NodeManager!);
|
||||
}
|
||||
|
||||
private static int AllocateFreePort()
|
||||
{
|
||||
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
/// <summary>Cleans up the PKI root directory.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_pkiRoot))
|
||||
{
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||
catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user